mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-08 01:48:26 -10:00
feat(runtime): replace execution modes with adaptive standard policy / 用自适应标准执行替代轻量·均衡·交付模式 (#8866)
* feat(runtime): replace execution modes with adaptive standard policy Problem Reasonix exposed Light/Balanced/Delivery (and legacy TokenMode) as a user-selectable execution mode. That split the product into three workflows, leaked mode labels into UI/ACP/CLI, and let low-risk work pay Delivery ceremony or high-risk work skip closed-loop evidence. Root cause Planner route, verification, review, and completion were branched on AgentPreset/TokenMode/deliveryProfile instead of task risk. Fix Derive one TaskPolicy per turn from intent, risk, structure, and user constraints. Planning, verification, review, and evidence closure follow that policy and may only ratchet upward. New UI/CLI/ACP surfaces no longer offer an execution mode. Deprecated flags, slash commands, ACP options, and Wails SetAgentPreset*/SetTokenMode* stay accepted for one compatibility version as no-ops. New persisted compat fields pin agentPreset=balanced and tokenMode=full. Isolated worktrees keep their on-disk directory and no longer force Delivery. Verification - go test ./... - cd desktop && go test . - go test -race ./internal/control ./internal/acp ./internal/taskpolicy - frontend tsc --noEmit plus Composer/isolated-worktree/send-failed tests * fix(ci): restore desktop bindings and satisfy repolint Problem CI lint failed on comment, file-size, and complexity overruns. Desktop jobs failed TypeScript because Wails still exports deprecated SetAgentPreset*/SetTokenMode* and DeliveryWorktree aliases that AppBindings no longer listed. Root cause The standard-execution change kept the one-version Go compatibility methods bound, but the frontend drift check requires every generated Wails method on AppBindings. The same change also grew several already-baselined files past comment and size budgets. Fix Bind the deprecated Wails methods as no-op aliases in AppBindings and the browser mock. Extract ExecutionPolicyBlock and boot test helpers, split Derive helpers, and compress comments and tests so repolint stays inside existing budgets. Verification - go run ./tools/repolint - go test ./internal/taskpolicy ./internal/acp ./internal/boot ./internal/control ./internal/evidence ./internal/cli ./internal/capability ./internal/agent - cd desktop && go test . -run 'TestLoadTabSessionProfile|TestSetTokenMode|TestSetAgentPreset|TestDeliveryWorktree|TestCreateDelivery|TestIsolated' - desktop/frontend tsc --noEmit - isolated-worktree and composer-profile frontend tests * fix(ci): drop unused mode leftovers and preset UI assertion Problem After repolint went clean, golangci-lint failed on an ineffectual ACP workMode assignment, an unused CLI footer helper, a desktop token-mode snapshot helper, and a strings.Builder modernization in boot tests. The desktop frontend job failed because the change panel no longer renders a Balanced preset label. Root cause Execution-mode removal left unused compatibility helpers and an assignment that was immediately overwritten with the pinned compat value. The workspace-changes test still required the old preset localization. Fix Pin ACP status WorkMode to balanced without reading the unused runtime profile, delete the unused helpers, join the boot transcript with strings.Builder, and assert only the localized verdict on the change panel. Verification - go run ./tools/repolint - golangci-lint run ./internal/acp ./internal/cli ./internal/boot - go test ./internal/acp ./internal/cli ./internal/boot - desktop go test -c - pnpm exec tsx src/__tests__/workspace-changes-errors.test.tsx * fix(runtime): close standard execution review gaps Problem: - Capability routing could run before TaskPolicy was frozen. - Atomic edits could finish without a mutation, and later low-risk edits could hide earlier high-risk changes. - Explicit full verification did not force full planning and review. - Legacy execution modes remained discoverable in CLI, desktop styling, docs, telemetry, and benchmark axes. Root cause: - Policy metadata and evidence checks were evaluated at inconsistent points in the turn lifecycle. - Compatibility controls were registered directly on public surfaces. - Benchmark and documentation cleanup stopped short of the last profile-specific paths. Fix: - Freeze policy before routing, enforce atomic mutation readiness, and ratchet risk across the complete turn ledger. - Promote explicit full verification to full planning and forced review. - Hide one-version compatibility parsing behind non-discoverable adapters and remove dead UI, telemetry, documentation, and benchmark profile surfaces. - Add regression coverage for routing, readiness, risk, compatibility help, and standard benchmark commands. Verification: - go test -p 4 -timeout=8m ./... - go vet ./... - go run ./tools/repolint - ./scripts/cache-guard.sh - go test -race on release-critical packages and repeated plugin refresh coverage - desktop Go vet/tests, frontend pnpm test:all/build, and SDK Go vet/tests/race * test(desktop): tolerate slow Windows autosave Problem: - The Windows desktop job could fail while the asynchronous turn snapshot was still completing. Root cause: - Autosave assertions used a two-second deadline, shorter than legitimate Windows antivirus and indexer delays observed in CI. Fix: - Use a shared ten-second autosave test deadline without changing production retry behavior. Verification: - Autosave persistence test passed 100 consecutive runs. - Autosave persistence test passed 20 race-enabled runs. - Full desktop Go test suite passed. * fix(desktop): reconcile partial trash before restore Problem: A committed topic archive can leave a sidecar at the live path when Windows temporarily blocks one step of the multi-artifact move. An immediate restore then reports the leftover sidecar as an unrelated conflict. Root cause: RestoreSession preflighted destination artifacts without honoring the durable cleanup-pending marker used by committed archive recovery. Fix: Reconcile pending desktop cleanup before restore conflict checks and retain the cleanup-in-progress response when reconciliation cannot finish. Add a deterministic partial-move regression test. Verification: desktop go test ./...; desktop go vet ./...; targeted restore tests x100; targeted race tests x30; repolint; git diff --check. * test(plugin): synchronize no-op refresh cycle Problem: The no-op tool-list refresh test could observe cycleDone after a fast refresh had already completed, causing the race job to report that the cycle never started. Root cause: The controlled transport did not block tools/list in this test, so the refresh goroutine could finish and clear the cycle handle before the test captured it. Fix: Block the controlled tools/list call, capture the active cycle, then release the refresh and assert its no-op behavior. Verification: go test -race ./internal/plugin -run '^TestToolListRefreshNoOpPreservesAdapterGeneration$' -count=1000 go test -race ./internal/plugin/... go run ./tools/repolint golangci-lint run --timeout=5m ./internal/plugin/... * fix(runtime): preserve adaptive risk across absolute paths Problem Deep absolute mutation paths could lose sensitive owner directories during risk classification, allowing a stale low-risk policy to survive the first write. Verification allowlists also recognized only test commands, so vet, lint, and typecheck could bypass an explicit user restriction. Root cause Risk classification truncated absolute paths without workspace context, and parent, child, and background evidence did not share a workspace-aware classifier. The task-policy gate maintained a narrower verifier list than the evidence subsystem. Fix Normalize paths lexically against the owning workspace while preserving deep sensitive owners and conservative out-of-workspace behavior. Propagate host-only workspace context through subagents and background task/fleet evidence without serializing it. Reuse the canonical verification classifier in the pre-execution policy gate. Verification - go test ./internal/evidence ./internal/taskpolicy ./internal/agent ./internal/jobs ./internal/control - go test -race ./internal/evidence ./internal/agent ./internal/jobs ./internal/control - go vet ./internal/evidence ./internal/taskpolicy ./internal/agent ./internal/jobs ./internal/control - go run ./tools/repolint - git diff --check * fix(ci): align latest base contracts Problem: The latest main-v2 reasoning display mode was not represented in the startup settings contract, and the new path helper tripped the modernize linter. Root cause: PR #8732 added the expanded reasoning option after this branch was created, while the path helper retained a manual membership loop. Fix: Require the expanded reasoning option and locale key in the startup contract, and use slices.Contains for exact risk-path segment checks. Verification: pnpm exec tsx src/__tests__/startup-settings-contract.test.ts pnpm test:all golangci-lint run --timeout=5m ./... wails generate module
This commit is contained in:
1 parent
76f8d6b6f9
commit
4f1cae1061
209 files changed
+3938
-4253
No files matched your search
@@ -6,6 +6,20 @@ branch.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- **Single adaptive standard execution:** Reasonix no longer exposes Light /
|
||||
Balanced / Delivery (or TokenMode) as a selectable execution mode. Planning,
|
||||
verification, and independent review now follow per-turn task risk via
|
||||
`TaskPolicy` v2. Low-risk conversation stays direct with no auxiliary model
|
||||
calls; high-risk, security, persistence, and active-Goal work keep the former
|
||||
Delivery closed-loop contract. Old `--preset`/`--profile`, `/preset`, ACP
|
||||
`agent_preset`/`work_mode`, and Desktop `SetAgentPreset*`/`SetTokenMode*`
|
||||
calls stay accepted for one compatibility version as no-ops. New persisted
|
||||
compat fields pin `agentPreset=balanced` and `tokenMode=full`. Isolated
|
||||
worktrees keep their on-disk directory; the UI no longer forces a Delivery
|
||||
mode. Deprecated fields will be removed after that compatibility window.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **v1.24.2 session snapshot & recovery root fix:** Keep PR #7982's WAL/CAS/lease
|
||||
|
||||
@@ -64,7 +64,7 @@ func anchorSkip(cfg suiteConfig, t task) (result, bool) {
|
||||
return result{}, false
|
||||
}
|
||||
return result{
|
||||
task: t, Profile: cfg.profile, Anchor: cfg.anchor, Skipped: true,
|
||||
task: t, Profile: benchmarkProfileStandard, Anchor: cfg.anchor, Skipped: true,
|
||||
Note: "skipped: no seed_" + cfg.anchor + " authored for this task",
|
||||
}, true
|
||||
}
|
||||
|
||||
+6
-16
@@ -16,9 +16,9 @@ import (
|
||||
)
|
||||
|
||||
type diffOpts struct {
|
||||
bin, model, repo, base, testCmd, profile string
|
||||
ablate ablation.Set
|
||||
maxSteps, timeoutSec, attempts int
|
||||
bin, model, repo, base, testCmd string
|
||||
ablate ablation.Set
|
||||
maxSteps, timeoutSec, attempts int
|
||||
}
|
||||
|
||||
type testRef struct{ name, pkg string }
|
||||
@@ -39,11 +39,7 @@ type pinResult struct {
|
||||
func runDiff(o diffOpts) string {
|
||||
srcFiles := changedGoFiles(o.repo, o.base, false)
|
||||
if len(srcFiles) == 0 {
|
||||
profile := o.profile
|
||||
if profile == "" {
|
||||
profile = benchmarkProfileBaseline
|
||||
}
|
||||
return fmt.Sprintf("## 🤖 Reasonix e2e — diff test-gen (%s)\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n", profile)
|
||||
return "## 🤖 Reasonix e2e — diff test-gen\n\nNo Go source changes in this PR (excluding `_test.go`); nothing to generate tests for.\n"
|
||||
}
|
||||
pkgs := packagesOf(srcFiles)
|
||||
prompt := buildDiffPrompt(srcFiles, pkgs, truncate(gitOut(o.repo, "diff", o.base+"...HEAD", "--")))
|
||||
@@ -83,7 +79,6 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
|
||||
if o.model != "" {
|
||||
args = append(args, "--model", o.model)
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, o.profile)
|
||||
if !o.ablate.Empty() {
|
||||
args = append(args, "--ablate", o.ablate.String())
|
||||
}
|
||||
@@ -121,7 +116,7 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
|
||||
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
|
||||
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
|
||||
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
|
||||
passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
|
||||
passed: passed, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +192,6 @@ type diffReport struct {
|
||||
buildOut string
|
||||
failing []string
|
||||
passed bool
|
||||
profile string
|
||||
attempt, attempts int
|
||||
m runMetrics
|
||||
runErr error
|
||||
@@ -211,11 +205,7 @@ func renderDiff(r diffReport) string {
|
||||
if r.passed {
|
||||
result = "✅ pass"
|
||||
}
|
||||
profile := r.profile
|
||||
if profile == "" {
|
||||
profile = benchmarkProfileBaseline
|
||||
}
|
||||
fmt.Fprintf(&b, "## 🤖 Reasonix e2e — diff test-gen (%s)\n\n", profile)
|
||||
fmt.Fprint(&b, "## 🤖 Reasonix e2e — diff test-gen\n\n")
|
||||
fmt.Fprintf(&b, "**Result:** %s · **%d** changed source file(s) across **%d** package(s)\n\n", result, len(r.srcFiles), len(r.pkgs))
|
||||
|
||||
pinned, byAssert := countPins(r.pins), countAssertionPins(r.pins)
|
||||
|
||||
+20
-23
@@ -82,7 +82,7 @@ type runMetrics struct {
|
||||
ParentNamedFiles int `json:"parent_named_files,omitempty"`
|
||||
ChildEvidencePaths int `json:"child_evidence_paths,omitempty"`
|
||||
ChildDiscoveredPaths int `json:"child_discovered_paths,omitempty"`
|
||||
// Optional Delivery capability counters (omitempty for baseline/old metrics).
|
||||
// Optional capability counters (omitempty for older metrics).
|
||||
ReadinessChecks int `json:"readiness_checks,omitempty"`
|
||||
ReadinessRecoveries int `json:"readiness_recoveries,omitempty"`
|
||||
CapabilityRoutes int `json:"capability_routes,omitempty"`
|
||||
@@ -118,6 +118,8 @@ type sourceUsage struct {
|
||||
type result struct {
|
||||
task
|
||||
runMetrics
|
||||
// Profile is retained for readers of older benchmark JSON. New runs always
|
||||
// record "standard" because execution mode is no longer an experiment axis.
|
||||
Profile string `json:"profile"`
|
||||
// Arm is the ablation arm the harness requested, not the arm the child
|
||||
// reported, so a run that died before writing metrics is still attributable.
|
||||
@@ -233,8 +235,6 @@ func main() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
}
|
||||
|
||||
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | traj | serve | fork")
|
||||
@@ -262,7 +262,6 @@ func main() {
|
||||
forkReps := flag.Int("reps", 1, "fork mode: continuation repetitions per bundle per arm")
|
||||
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
|
||||
model := flag.String("model", "", "provider/model name (default: config default)")
|
||||
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
|
||||
ablateFlag := flag.String("ablate", "", "ablation arm: subsystems to switch off (evidence, planner, subagent, retrieval, compaction; none|all)")
|
||||
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
|
||||
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory")
|
||||
@@ -278,12 +277,12 @@ func main() {
|
||||
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
|
||||
attempts := flag.Int("attempts", 1, "suite/diff modes: retry a task up to N times until an attempt passes (stochastic agent); enables Pass@≤N")
|
||||
flag.Parse()
|
||||
axes, err := resolveExperimentAxes(*profileFlag, *ablateFlag, *cacheArm, *anchorFlag)
|
||||
axes, err := resolveExperimentAxes(*ablateFlag, *cacheArm, *anchorFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
profile, arm, cache, anchor := axes.profile, axes.arm, axes.cache, axes.anchor
|
||||
arm, cache, anchor := axes.arm, axes.cache, axes.anchor
|
||||
|
||||
if *mode == "swebench" {
|
||||
if _, err := permissionFlag(*permission); err != nil {
|
||||
@@ -293,7 +292,7 @@ func main() {
|
||||
cwd, _ := os.Getwd()
|
||||
report := runSwebench(swebenchOpts{
|
||||
bin: *bin, subset: *subset, namespace: *namespace, model: *model,
|
||||
profile: profile, permission: *permission, arm: arm, runID: *runID, workDir: cwd,
|
||||
permission: *permission, arm: arm, runID: *runID, workDir: cwd,
|
||||
harness: *harnessPy, dataset: *dataset, maxSteps: *maxSteps,
|
||||
timeoutSec: *timeoutSec, workers: *workers, keepImages: *keepImages,
|
||||
network: *network, proxyURL: *proxyURL,
|
||||
@@ -319,7 +318,7 @@ func main() {
|
||||
}
|
||||
return
|
||||
case "fork":
|
||||
cfg := suiteConfig{bin: *bin, model: *model, profile: profile, arm: arm,
|
||||
cfg := suiteConfig{bin: *bin, model: *model, arm: arm,
|
||||
cacheArm: cache, effort: *effort, policy: *policyFlag}
|
||||
if err := runForkMode(*bundles, *suite, *forkArms, *forkReps, cfg, *trajDir, *outMD, *outJSON); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "fork mode:", err)
|
||||
@@ -331,7 +330,7 @@ func main() {
|
||||
if *mode == "diff" {
|
||||
report := runDiff(diffOpts{
|
||||
bin: *bin, model: *model, repo: *repo, base: *base,
|
||||
testCmd: *testCmd, profile: profile, ablate: arm, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
|
||||
testCmd: *testCmd, ablate: arm, maxSteps: *maxSteps, timeoutSec: *timeoutSec, attempts: *attempts,
|
||||
})
|
||||
emit(report, *outMD, "")
|
||||
return
|
||||
@@ -339,7 +338,7 @@ func main() {
|
||||
|
||||
meterSource, faults, segments, steers := pressure.settings()
|
||||
runSuiteMode(suiteConfig{
|
||||
bin: *bin, model: *model, profile: profile, arm: arm, budget: *budget,
|
||||
bin: *bin, model: *model, arm: arm, budget: *budget,
|
||||
trajDir: *trajDir, forcePlanner: *forcePlanner, attempts: *attempts, anchor: anchor,
|
||||
cacheArm: cache, effort: *effort, checkpoints: *checkpoints, policy: *policyFlag,
|
||||
forkCapture: *forkCapture, meterConfig: meterSource, meterFaults: faults, segments: segments, steers: steers,
|
||||
@@ -470,15 +469,15 @@ func filterTasks(tasks []task, filter string) ([]task, error) {
|
||||
}
|
||||
|
||||
// suiteConfig carries one suite invocation's fixed experiment axes: binary,
|
||||
// model, tool-surface profile, ablation arm, cache arm, and reasoning effort.
|
||||
// model, ablation arm, cache arm, and reasoning effort.
|
||||
type suiteConfig struct {
|
||||
bin, model, profile, cacheArm, effort string
|
||||
arm ablation.Set
|
||||
anchor string
|
||||
policy, forkCapture string
|
||||
trajDir string
|
||||
forcePlanner, checkpoints bool
|
||||
attempts, budget int
|
||||
bin, model, cacheArm, effort string
|
||||
arm ablation.Set
|
||||
anchor string
|
||||
policy, forkCapture string
|
||||
trajDir string
|
||||
forcePlanner, checkpoints bool
|
||||
attempts, budget int
|
||||
// meterConfig is the real config.toml whose provider endpoint each run is
|
||||
// redirected through the neutral meter; empty leaves runs unmetered.
|
||||
meterConfig string
|
||||
@@ -499,7 +498,7 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
|
||||
total := 0
|
||||
for _, t := range tasks {
|
||||
if cfg.budget > 0 && total >= cfg.budget {
|
||||
results = append(results, result{task: t, Profile: cfg.profile, Skipped: true, Note: "skipped: token budget reached"})
|
||||
results = append(results, result{task: t, Profile: benchmarkProfileStandard, Skipped: true, Note: "skipped: token budget reached"})
|
||||
continue
|
||||
}
|
||||
if skipped, ok := anchorSkip(cfg, t); ok {
|
||||
@@ -528,7 +527,7 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
|
||||
// then drops in verify.sh and runs it as the grader. The grader is added only
|
||||
// after the run so the agent can't read the answer key.
|
||||
func runTask(cfg suiteConfig, t task) result {
|
||||
r := result{task: t, Profile: cfg.profile, CacheArm: cfg.cacheArm, Effort: cfg.effort}
|
||||
r := result{task: t, Profile: benchmarkProfileStandard, CacheArm: cfg.cacheArm, Effort: cfg.effort}
|
||||
r.Arm = cfg.arm.Arm()
|
||||
r.Anchor = cfg.anchor
|
||||
t.Prompt = anchorPrompt(cfg.anchor, t)
|
||||
@@ -652,7 +651,6 @@ func buildRunTaskArgs(cfg suiteConfig, metricsPath, trajectoryPath string, maxSt
|
||||
if maxSteps > 0 {
|
||||
args = append(args, "--max-steps", fmt.Sprint(maxSteps))
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, cfg.profile)
|
||||
if cfg.effort != "" {
|
||||
args = append(args, "--effort", cfg.effort)
|
||||
}
|
||||
@@ -679,7 +677,7 @@ func buildSegmentArgs(cfg suiteConfig, seg segment, metricsPath, trajectoryPath
|
||||
// warmPrefix primes the provider prefix cache for work's session shape with a
|
||||
// minimal one-step run before the graded run starts its clock. Its cost is
|
||||
// deliberately untracked: the warm arm measures a long-lived session's steady
|
||||
// state, not the price of reaching it. Prefix-shaping flags (model, profile,
|
||||
// state, not the price of reaching it. Prefix-shaping flags (model, effort,
|
||||
// ablation, cwd) must match the graded invocation exactly.
|
||||
func warmPrefix(cfg suiteConfig, work string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
@@ -688,7 +686,6 @@ func warmPrefix(cfg suiteConfig, work string) {
|
||||
if cfg.model != "" {
|
||||
args = append(args, "--model", cfg.model)
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, cfg.profile)
|
||||
if cfg.effort != "" {
|
||||
args = append(args, "--effort", cfg.effort)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func mechanismFacts(r result) map[string]mechanismRow {
|
||||
|
||||
// renderToolSurface is the schema-tax line: what every request re-pays for
|
||||
// the visible tool surface, and the churn (connects, prefix resets) the
|
||||
// economy tier trades that tax against. Fresh-session benchmarks re-pay the
|
||||
// adaptive runtime trades that tax against. Fresh-session benchmarks re-pay the
|
||||
// miss on every task, so the surface size prices differently than in a
|
||||
// long-lived session.
|
||||
func renderToolSurface(results []result) string {
|
||||
|
||||
@@ -32,20 +32,20 @@ func TestMarkDominatedIgnoresUnsolvedArms(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPerClassWinners(t *testing.T) {
|
||||
economy := armStats{ByClass: map[string]classStats{
|
||||
control := armStats{ByClass: map[string]classStats{
|
||||
"atomic-bugfix": {Ran: 2, Solved: 2, TTCS: []int64{15_000, 17_000}},
|
||||
"repo-exploration": {Ran: 2, Solved: 1, TTCS: []int64{20_000}},
|
||||
}}
|
||||
balanced := armStats{ByClass: map[string]classStats{
|
||||
treatment := armStats{ByClass: map[string]classStats{
|
||||
"atomic-bugfix": {Ran: 2, Solved: 2, TTCS: []int64{22_000, 24_000}},
|
||||
"repo-exploration": {Ran: 2, Solved: 2, TTCS: []int64{30_000, 31_000}},
|
||||
"unclassified": {Ran: 1, Solved: 1, TTCS: []int64{5_000}},
|
||||
}}
|
||||
got := perClassWinners([]string{"economy.json", "balanced.json"}, []armStats{economy, balanced})
|
||||
got := perClassWinners([]string{"control.json", "treatment.json"}, []armStats{control, treatment})
|
||||
for _, want := range []string{
|
||||
"### Per-class winners",
|
||||
"| atomic-bugfix | 100% · 17.0s | 100% · 24.0s | economy |",
|
||||
"| repo-exploration | 50% · 20.0s | 100% · 31.0s | balanced |",
|
||||
"| atomic-bugfix | 100% · 17.0s | 100% · 24.0s | control |",
|
||||
"| repo-exploration | 50% · 20.0s | 100% · 31.0s | treatment |",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("per-class table missing %q:\n%s", want, got)
|
||||
@@ -54,7 +54,7 @@ func TestPerClassWinners(t *testing.T) {
|
||||
if strings.Contains(got, "unclassified") {
|
||||
t.Fatalf("unclassified rows must not render:\n%s", got)
|
||||
}
|
||||
if perClassWinners([]string{"a.json"}, []armStats{economy}) != "" {
|
||||
if perClassWinners([]string{"a.json"}, []armStats{control}) != "" {
|
||||
t.Fatal("single arm has no winners to declare")
|
||||
}
|
||||
}
|
||||
|
||||
+7
-36
@@ -12,49 +12,20 @@ import (
|
||||
// on. They are resolved together so a second bad flag is reported alongside
|
||||
// the first instead of being masked by an early exit.
|
||||
type experimentAxes struct {
|
||||
profile, cache, anchor string
|
||||
arm ablation.Set
|
||||
cache, anchor string
|
||||
arm ablation.Set
|
||||
}
|
||||
|
||||
func resolveExperimentAxes(profile, ablate, cache, anchor string) (experimentAxes, error) {
|
||||
p, perr := normalizeBenchmarkProfile(profile)
|
||||
func resolveExperimentAxes(ablate, cache, anchor string) (experimentAxes, error) {
|
||||
a, aerr := ablation.Parse(ablate)
|
||||
c, cerr := normalizeCacheArm(cache)
|
||||
an, anerr := normalizeAnchorArm(anchor)
|
||||
return experimentAxes{profile: p, cache: c, anchor: an, arm: a}, errors.Join(perr, aerr, cerr, anerr)
|
||||
return experimentAxes{cache: c, anchor: an, arm: a}, errors.Join(aerr, cerr, anerr)
|
||||
}
|
||||
|
||||
const (
|
||||
benchmarkProfileBaseline = "baseline"
|
||||
benchmarkProfileEconomy = "economy"
|
||||
benchmarkProfileBalanced = "balanced"
|
||||
benchmarkProfileDelivery = "delivery"
|
||||
)
|
||||
|
||||
// normalizeBenchmarkProfile validates the tool-surface arm. baseline stays
|
||||
// distinct from balanced: it passes no --profile flag at all, preserving the
|
||||
// byte-identical control command line older comparisons were recorded with.
|
||||
func normalizeBenchmarkProfile(profile string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(profile)) {
|
||||
case "", benchmarkProfileBaseline:
|
||||
return benchmarkProfileBaseline, nil
|
||||
case benchmarkProfileEconomy:
|
||||
return benchmarkProfileEconomy, nil
|
||||
case benchmarkProfileBalanced:
|
||||
return benchmarkProfileBalanced, nil
|
||||
case benchmarkProfileDelivery:
|
||||
return benchmarkProfileDelivery, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown benchmark profile %q (want baseline, economy, balanced, or delivery)", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func appendBenchmarkProfileArgs(args []string, profile string) []string {
|
||||
if profile == benchmarkProfileBaseline {
|
||||
return args
|
||||
}
|
||||
return append(args, "--profile", profile)
|
||||
}
|
||||
// benchmarkProfileStandard keeps the historical JSON field readable while the
|
||||
// harness itself has one execution contract. It is metadata, not an arm.
|
||||
const benchmarkProfileStandard = "standard"
|
||||
|
||||
const (
|
||||
benchmarkCacheCold = "cold"
|
||||
|
||||
@@ -7,30 +7,13 @@ import (
|
||||
"reasonix/internal/ablation"
|
||||
)
|
||||
|
||||
func TestAppendBenchmarkProfileArgsBaselineIsByteIdentical(t *testing.T) {
|
||||
args := []string{"run", "fix the bug"}
|
||||
if got := appendBenchmarkProfileArgs(args, benchmarkProfileBaseline); !reflect.DeepEqual(got, args) {
|
||||
t.Fatalf("baseline args changed: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T) {
|
||||
args := []string{"run"}
|
||||
got := appendBenchmarkProfileArgs(args, benchmarkProfileDelivery)
|
||||
want := []string{"run", "--profile", "delivery"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("delivery args = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRunTaskArgsEnablesUnattendedWorkspaceWrites(t *testing.T) {
|
||||
cfg := suiteConfig{model: "e2e", profile: benchmarkProfileDelivery}
|
||||
cfg := suiteConfig{model: "e2e"}
|
||||
got := buildRunTaskArgs(cfg, "metrics.json", "run.trajectory.jsonl", 12, "fix it")
|
||||
want := []string{
|
||||
"run", "--auto", "--metrics", "metrics.json",
|
||||
"--trajectory", "run.trajectory.jsonl",
|
||||
"--model", "e2e", "--max-steps", "12",
|
||||
"--profile", "delivery", "fix it",
|
||||
"--model", "e2e", "--max-steps", "12", "fix it",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("run task args = %v, want %v", got, want)
|
||||
@@ -38,7 +21,7 @@ func TestBuildRunTaskArgsEnablesUnattendedWorkspaceWrites(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildRunTaskArgsPassesTheAblationArmThrough(t *testing.T) {
|
||||
cfg := suiteConfig{profile: benchmarkProfileBaseline, arm: ablation.New(ablation.Evidence, ablation.Planner)}
|
||||
cfg := suiteConfig{arm: ablation.New(ablation.Evidence, ablation.Planner)}
|
||||
got := buildRunTaskArgs(cfg, "m.json", "", 0, "fix it")
|
||||
want := []string{"run", "--auto", "--metrics", "m.json", "--ablate", "evidence,planner", "fix it"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -46,15 +29,25 @@ func TestBuildRunTaskArgsPassesTheAblationArmThrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRunTaskArgsPassesEffortThrough(t *testing.T) {
|
||||
cfg := suiteConfig{profile: benchmarkProfileEconomy, effort: "low"}
|
||||
func TestBuildRunTaskArgsPassesEffortThroughWithoutLegacyMode(t *testing.T) {
|
||||
cfg := suiteConfig{effort: "low"}
|
||||
got := buildRunTaskArgs(cfg, "m.json", "", 0, "fix it")
|
||||
want := []string{"run", "--auto", "--metrics", "m.json", "--profile", "economy", "--effort", "low", "fix it"}
|
||||
want := []string{"run", "--auto", "--metrics", "m.json", "--effort", "low", "fix it"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("effort args = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExperimentAxesHasNoExecutionModeArm(t *testing.T) {
|
||||
got, err := resolveExperimentAxes("evidence", "warm", "wrong")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.arm.String() != "evidence" || got.cache != "warm" || got.anchor != "wrong" {
|
||||
t.Fatalf("axes = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSuiteBudgetCoversCurrentFiveTaskBaseline(t *testing.T) {
|
||||
// The real-provider baseline exceeded 400k after only three successful
|
||||
// tasks. Keep enough headroom to grade all five instead of silently skipping
|
||||
@@ -64,22 +57,6 @@ func TestDefaultSuiteBudgetCoversCurrentFiveTaskBaseline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBenchmarkProfile(t *testing.T) {
|
||||
for _, input := range []string{"", "baseline", " BASELINE "} {
|
||||
if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline {
|
||||
t.Fatalf("normalize(%q) = %q, %v", input, got, err)
|
||||
}
|
||||
}
|
||||
for _, tier := range []string{"economy", "balanced", "delivery"} {
|
||||
if got, err := normalizeBenchmarkProfile(tier); err != nil || got != tier {
|
||||
t.Fatalf("normalize(%q) = %q, %v", tier, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := normalizeBenchmarkProfile("fast"); err == nil {
|
||||
t.Fatal("unknown profile should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCacheArm(t *testing.T) {
|
||||
for input, want := range map[string]string{"": "cold", "cold": "cold", " WARM ": "warm"} {
|
||||
if got, err := normalizeCacheArm(input); err != nil || got != want {
|
||||
@@ -90,13 +67,3 @@ func TestNormalizeCacheArm(t *testing.T) {
|
||||
t.Fatal("unknown cache arm should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendBenchmarkProfileArgsPassesToolSurfaceTiers(t *testing.T) {
|
||||
for _, tier := range []string{"economy", "balanced"} {
|
||||
got := appendBenchmarkProfileArgs([]string{"run"}, tier)
|
||||
want := []string{"run", "--profile", tier}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s args = %v, want %v", tier, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,8 @@ import (
|
||||
)
|
||||
|
||||
func render(results []result) string {
|
||||
profile := benchmarkProfileBaseline
|
||||
arm := "full"
|
||||
if len(results) > 0 {
|
||||
if results[0].Profile != "" {
|
||||
profile = results[0].Profile
|
||||
}
|
||||
if results[0].Arm != "" {
|
||||
arm = results[0].Arm
|
||||
}
|
||||
@@ -23,7 +19,7 @@ func render(results []result) string {
|
||||
if len(results) > 0 && results[0].CacheArm != "" && results[0].CacheArm != benchmarkCacheCold {
|
||||
cache = " · " + results[0].CacheArm + "-cache"
|
||||
}
|
||||
return fmt.Sprintf("## 🤖 Reasonix e2e benchmark (%s · arm `%s`%s)\n\n", profile, arm, cache) + renderBody(results)
|
||||
return fmt.Sprintf("## 🤖 Reasonix e2e benchmark (arm `%s`%s)\n\n", arm, cache) + renderBody(results)
|
||||
}
|
||||
|
||||
// suiteStats aggregates result entries; ran/pass1 count tasks (first
|
||||
|
||||
@@ -59,7 +59,7 @@ func permissionFlag(mode string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func swebenchAgentArgs(metricsPath, model, profile, permission string, arm ablation.Set, maxSteps int, prompt string) []string {
|
||||
func swebenchAgentArgs(metricsPath, model, permission string, arm ablation.Set, maxSteps int, prompt string) []string {
|
||||
posture, err := permissionFlag(permission)
|
||||
if err != nil {
|
||||
panic(err) // validated at flag-parse time; reaching here is a wiring bug
|
||||
@@ -71,7 +71,6 @@ func swebenchAgentArgs(metricsPath, model, profile, permission string, arm ablat
|
||||
if maxSteps > 0 {
|
||||
args = append(args, "--max-steps", fmt.Sprint(maxSteps))
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, profile)
|
||||
if !arm.Empty() {
|
||||
args = append(args, "--ablate", arm.String())
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ type swebenchOpts struct {
|
||||
subset string
|
||||
namespace string
|
||||
model string
|
||||
profile string
|
||||
permission string
|
||||
arm ablation.Set
|
||||
runID string
|
||||
@@ -58,7 +57,7 @@ func loadSwebenchSubset(path string) ([]swebenchInstance, error) {
|
||||
// the agent inside it against /testbed, and take whatever the working tree
|
||||
// became as the candidate patch. Grading happens later, in one batch.
|
||||
func runSwebenchInstance(o swebenchOpts, inst swebenchInstance) (result, string) {
|
||||
r := result{task: task{ID: inst.InstanceID}, Profile: o.profile}
|
||||
r := result{task: task{ID: inst.InstanceID}, Profile: benchmarkProfileStandard}
|
||||
r.Arm = o.arm.Arm()
|
||||
|
||||
image := swebenchImage(o.namespace, inst.InstanceID)
|
||||
@@ -92,7 +91,7 @@ func runSwebenchInstance(o swebenchOpts, inst swebenchInstance) (result, string)
|
||||
}
|
||||
|
||||
metricsPath := "/tmp/reasonix-metrics.json"
|
||||
args := swebenchAgentArgs(metricsPath, o.model, o.profile, o.permission, o.arm, o.maxSteps, swebenchPrompt(inst))
|
||||
args := swebenchAgentArgs(metricsPath, o.model, o.permission, o.arm, o.maxSteps, swebenchPrompt(inst))
|
||||
agentCmd := append([]string{"exec", "-e", "REASONIX_HOME=/opt/rxhome", container},
|
||||
testbedShell("/usr/local/bin/reasonix "+shellQuoteAll(args))...)
|
||||
|
||||
|
||||
@@ -133,12 +133,12 @@ func TestShellQuoteAllContainsHostileIssueText(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSwebenchAgentArgsKeepTheControlArmClean(t *testing.T) {
|
||||
got := swebenchAgentArgs("/tmp/m.json", "e2e", benchmarkProfileBaseline, "auto", ablation.Set{}, 60, "fix it")
|
||||
got := swebenchAgentArgs("/tmp/m.json", "e2e", "auto", ablation.Set{}, 60, "fix it")
|
||||
want := []string{"run", "--permission-mode=auto", "--metrics", "/tmp/m.json", "--model", "e2e", "--max-steps", "60", "fix it"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("control args = %v, want %v", got, want)
|
||||
}
|
||||
ablated := swebenchAgentArgs("/tmp/m.json", "", benchmarkProfileBaseline, "auto", ablation.New(ablation.Evidence), 0, "fix it")
|
||||
ablated := swebenchAgentArgs("/tmp/m.json", "", "auto", ablation.New(ablation.Evidence), 0, "fix it")
|
||||
if !reflect.DeepEqual(ablated, []string{"run", "--permission-mode=auto", "--metrics", "/tmp/m.json", "--ablate", "evidence", "fix it"}) {
|
||||
t.Fatalf("ablated args = %v", ablated)
|
||||
}
|
||||
@@ -147,8 +147,8 @@ func TestSwebenchAgentArgsKeepTheControlArmClean(t *testing.T) {
|
||||
// The two postures must differ in exactly one argument. If anything else moved
|
||||
// between arms, the published delta would not isolate the permission gate.
|
||||
func TestPermissionPostureIsTheOnlyDifferenceBetweenArms(t *testing.T) {
|
||||
a := swebenchAgentArgs("/m.json", "e2e", benchmarkProfileBaseline, "auto", ablation.Set{}, 60, "fix it")
|
||||
b := swebenchAgentArgs("/m.json", "e2e", benchmarkProfileBaseline, "yolo", ablation.Set{}, 60, "fix it")
|
||||
a := swebenchAgentArgs("/m.json", "e2e", "auto", ablation.Set{}, 60, "fix it")
|
||||
b := swebenchAgentArgs("/m.json", "e2e", "yolo", ablation.Set{}, 60, "fix it")
|
||||
if len(a) != len(b) {
|
||||
t.Fatalf("arms differ in argument count: %v vs %v", a, b)
|
||||
}
|
||||
|
||||
@@ -27,10 +27,28 @@ func TestSaveTabSessionMetaReplacesStaleAgentPreset(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta = %+v, %v, %v", got, ok, err)
|
||||
}
|
||||
if got.AgentPreset != boot.AgentPresetDelivery || got.TokenMode != boot.TokenModeDelivery {
|
||||
t.Fatalf("persisted role = preset:%q tokenMode:%q, want delivery dual-write", got.AgentPreset, got.TokenMode)
|
||||
if got.AgentPreset != boot.AgentPresetBalanced || got.TokenMode != boot.TokenModeFull {
|
||||
t.Fatalf("persisted role = preset:%q tokenMode:%q, want pinned balanced/full", got.AgentPreset, got.TokenMode)
|
||||
}
|
||||
if restored := tabSessionProfileFromMeta(path, got).tokenMode; restored != boot.TokenModeDelivery {
|
||||
t.Fatalf("restored tokenMode = %q, want delivery", restored)
|
||||
if restored := tabSessionProfileFromMeta(path, got).tokenMode; restored != boot.TokenModeFull {
|
||||
t.Fatalf("restored tokenMode = %q, want pinned full", restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTabSessionProfileFromMetaDecodesLegacyDelivery(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "legacy.jsonl")
|
||||
meta, err := agent.EnsureBranchMeta(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.AgentPreset = boot.AgentPresetDelivery
|
||||
meta.TokenMode = boot.TokenModeDelivery
|
||||
if restored := tabSessionProfileFromMeta(path, meta).tokenMode; restored != boot.TokenModeDelivery {
|
||||
t.Fatalf("legacy decode tokenMode = %q, want delivery", restored)
|
||||
}
|
||||
tab := &WorkspaceTab{}
|
||||
applyTabSessionProfile(tab, tabSessionProfileFromMeta(path, meta))
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("legacy delivery must not change runtime tokenMode: got %q", got)
|
||||
}
|
||||
}
|
||||
+45
-77
@@ -704,12 +704,10 @@ func (a *App) restoreOrBuildTabs() {
|
||||
}
|
||||
tab.model = entry.Model
|
||||
tab.effort = cloneStringPtr(entry.Effort)
|
||||
// Prefer agentPreset; fall back to legacy tokenMode for one version.
|
||||
if strings.TrimSpace(entry.AgentPreset) != "" {
|
||||
tab.tokenMode = boot.TokenModeFromAgentPreset(entry.AgentPreset)
|
||||
} else {
|
||||
tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)
|
||||
}
|
||||
// Execution modes are gone: old agentPreset/tokenMode entries are
|
||||
// parsed for compatibility but never applied. The tab keeps the
|
||||
// pinned dual-write compat value.
|
||||
tab.tokenMode = boot.TokenModeFull
|
||||
tab.mode = persistedTabMode(entry.Mode)
|
||||
// Validate the persisted goal against the session's goal-state
|
||||
// sidecar: a typed /new or /clear rotates the session through the
|
||||
@@ -2065,8 +2063,6 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
|
||||
WorkspaceRoot: snap.workspaceRoot,
|
||||
SessionDir: sessionDirForSnapshot(snap),
|
||||
EffortOverride: cloneStringPtr(snap.effort),
|
||||
AgentPreset: boot.NormalizeAgentPreset(snap.currentTokenMode()),
|
||||
TokenMode: snap.currentTokenMode(),
|
||||
SharedHost: sharedHost,
|
||||
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
||||
SubagentParentLive: a.subagentParentProbeForBuild(tab),
|
||||
@@ -3423,6 +3419,17 @@ func (a *App) restoreSession(path string) error {
|
||||
if a.sessionDestroying(dir, target) {
|
||||
return fmt.Errorf("session cleanup is still in progress: %s", key)
|
||||
}
|
||||
// A committed archive may have moved the transcript into trash while a
|
||||
// Windows file handle temporarily kept one of its sidecars at the live
|
||||
// path. The durable cleanup marker makes that partial move recoverable.
|
||||
// Finish it before restore preflights the live destinations; otherwise the
|
||||
// leftover sidecar is misreported as an unrelated restore conflict.
|
||||
if agent.IsCleanupPending(target) {
|
||||
_ = reconcileDesktopCleanupPending(dir)
|
||||
if agent.IsCleanupPending(target) {
|
||||
return fmt.Errorf("session cleanup is still in progress: %s", key)
|
||||
}
|
||||
}
|
||||
if a.sessionOpen(dir, target) {
|
||||
return fmt.Errorf("session is open: %s", key)
|
||||
}
|
||||
@@ -4111,8 +4118,6 @@ func (a *App) buildSessionRebindCandidate(
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: sessionDir,
|
||||
EffortOverride: cloneStringPtr(source.effort),
|
||||
AgentPreset: boot.NormalizeAgentPreset(runtimeProfile.tokenMode),
|
||||
TokenMode: runtimeProfile.tokenMode,
|
||||
SharedHost: sharedHost,
|
||||
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
||||
SubagentParentLive: a.subagentParentProbeForBuild(tab),
|
||||
@@ -6468,9 +6473,9 @@ type Meta struct {
|
||||
Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
|
||||
CollaborationMode string `json:"collaborationMode"`
|
||||
ToolApprovalMode string `json:"toolApprovalMode"`
|
||||
TokenMode string `json:"tokenMode"`
|
||||
// AgentPreset is the canonical role setting (light|balanced|delivery).
|
||||
// TokenMode remains the dual-write legacy wire value for one version.
|
||||
// TokenMode and AgentPreset are deprecated dual-write wire values pinned to
|
||||
// their safe defaults; one-version-old frontends still parse them.
|
||||
TokenMode string `json:"tokenMode"`
|
||||
AgentPreset string `json:"agentPreset,omitempty"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
GoalStatus string `json:"goalStatus,omitempty"`
|
||||
@@ -6567,7 +6572,10 @@ func (a *App) MetaForTab(tabID string) Meta {
|
||||
autoApproveTools := snap.ctrl != nil && snap.ctrl.AutoApproveTools()
|
||||
collaborationMode := snap.collaborationMode()
|
||||
toolApprovalMode := snap.currentToolApprovalMode()
|
||||
tokenMode := snap.currentTokenMode()
|
||||
// Deprecated dual-write wire values: pinned so one-version-old frontends
|
||||
// keep parsing meta; nothing branches on them anymore.
|
||||
tokenMode := boot.TokenModeFull
|
||||
agentPreset := boot.AgentPresetBalanced
|
||||
goal := snap.currentGoal()
|
||||
goalStatus := snap.currentGoalStatus()
|
||||
sessionPath := strings.TrimSpace(snap.sessionPath)
|
||||
@@ -6595,9 +6603,9 @@ func (a *App) MetaForTab(tabID string) Meta {
|
||||
AutoApproveTools: autoApproveTools,
|
||||
Bypass: autoApproveTools,
|
||||
CollaborationMode: collaborationMode,
|
||||
ToolApprovalMode: toolApprovalMode,
|
||||
AgentPreset: boot.NormalizeAgentPreset(tokenMode),
|
||||
TokenMode: tokenMode,
|
||||
AgentPreset: agentPreset,
|
||||
ToolApprovalMode: toolApprovalMode,
|
||||
Goal: goal,
|
||||
GoalStatus: goalStatus,
|
||||
GoalRuntime: goalRuntimeViewFromController(snap.ctrl),
|
||||
@@ -9732,8 +9740,6 @@ func (a *App) SetModelForTab(tabID, name string) (retErr error) {
|
||||
WorkspaceRoot: snap.workspaceRoot,
|
||||
SessionDir: sessionDirForSnapshot(snap),
|
||||
EffortOverride: cloneStringPtr(effortOverride),
|
||||
AgentPreset: boot.NormalizeAgentPreset(runtime.tokenMode),
|
||||
TokenMode: runtime.tokenMode,
|
||||
SharedHost: sharedHost,
|
||||
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
||||
SubagentParentLive: a.subagentParentProbeForBuild(tab),
|
||||
@@ -9920,8 +9926,6 @@ func (a *App) SetEffortForTab(tabID, level string) error {
|
||||
WorkspaceRoot: snap.workspaceRoot,
|
||||
SessionDir: sessionDirForSnapshot(snap),
|
||||
EffortOverride: &effort,
|
||||
AgentPreset: boot.NormalizeAgentPreset(runtime.tokenMode),
|
||||
TokenMode: runtime.tokenMode,
|
||||
SharedHost: sharedHost,
|
||||
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
||||
SubagentParentLive: a.subagentParentProbeForBuild(tab),
|
||||
@@ -9973,85 +9977,49 @@ func (a *App) SetEffortForTab(tabID, level string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAgentPresetDeprecatedNotice is returned by the deprecated execution-mode
|
||||
// Wails methods. Reasonix runs one adaptive standard execution; these methods
|
||||
// remain bound for one compatibility version as no-op wrappers: they never
|
||||
// require an idle tab, never save a mode, and never rebuild an agent.
|
||||
const SetAgentPresetDeprecatedNotice = "Reasonix now uses one adaptive standard execution: planning, verification, and review strength follow task risk automatically. Execution modes are no longer switchable; this call is accepted for compatibility and ignored."
|
||||
|
||||
func (a *App) SetTokenMode(mode string) error {
|
||||
// Deprecated: dual-write alias for SetAgentPreset.
|
||||
// Deprecated no-op compatibility wrapper.
|
||||
return a.SetAgentPreset(boot.NormalizeAgentPreset(mode))
|
||||
}
|
||||
|
||||
func (a *App) SetTokenModeForTab(tabID, mode string) error {
|
||||
// Deprecated: dual-write alias for SetAgentPresetForTab.
|
||||
// Deprecated no-op compatibility wrapper.
|
||||
return a.SetAgentPresetForTab(tabID, boot.NormalizeAgentPreset(mode))
|
||||
}
|
||||
|
||||
// SetAgentPreset switches the active tab's execution setting (执行设定).
|
||||
// SetAgentPreset is a deprecated no-op compatibility wrapper.
|
||||
func (a *App) SetAgentPreset(preset string) error {
|
||||
return a.SetAgentPresetForTab("", preset)
|
||||
}
|
||||
|
||||
// SetAgentPresetForTab switches a tab's role setting without rebuilding the
|
||||
// controller. Active turns, background jobs, and pending approvals/asks refuse.
|
||||
// SetAgentPresetForTab is a deprecated no-op compatibility wrapper: it accepts
|
||||
// the legacy argument, does not require an idle tab, saves no mode, rebuilds
|
||||
// no agent, and always succeeds with the deprecation notice.
|
||||
func (a *App) SetAgentPresetForTab(tabID, preset string) error {
|
||||
preset = boot.NormalizeAgentPreset(preset)
|
||||
legacyMode := boot.TokenModeFromAgentPreset(preset)
|
||||
tab := a.tabByID(tabID)
|
||||
if tab == nil {
|
||||
if strings.TrimSpace(tabID) == "" {
|
||||
return nil
|
||||
}
|
||||
_ = boot.NormalizeAgentPreset(preset)
|
||||
if tab := a.tabByID(tabID); tab == nil && strings.TrimSpace(tabID) != "" {
|
||||
return fmt.Errorf("tab %q not found", tabID)
|
||||
}
|
||||
a.mu.RLock()
|
||||
currentPreset := boot.NormalizeAgentPreset(tab.tokenMode)
|
||||
a.mu.RUnlock()
|
||||
if preset == currentPreset {
|
||||
return nil
|
||||
}
|
||||
// Serialize with model/effort/settings rebuilds and turn admission.
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
tab.turnStartMu.Lock()
|
||||
defer tab.turnStartMu.Unlock()
|
||||
prevPath := a.reconciledSessionPathForTab(tab)
|
||||
if prevPath == "" {
|
||||
prevPath = a.currentSessionPathFor(tab)
|
||||
}
|
||||
if a.controllerForTab(tab) == nil && prevPath != "" {
|
||||
a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
|
||||
}
|
||||
if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "执行设定"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTabControllerWorkspace(tab); err != nil {
|
||||
return err
|
||||
}
|
||||
ctrl := a.controllerForTab(tab)
|
||||
if ctrl == nil {
|
||||
// No live controller yet: persist the choice for the next build.
|
||||
a.mu.Lock()
|
||||
tab.tokenMode = legacyMode
|
||||
a.mu.Unlock()
|
||||
a.persistTabTokenMode(tab, legacyMode)
|
||||
return nil
|
||||
}
|
||||
// In-place switch: update the agent atomic value, tab state, and dual-write
|
||||
// persistence without boot.Build.
|
||||
ctrl.SetAgentPreset(preset)
|
||||
a.mu.Lock()
|
||||
tab.tokenMode = legacyMode
|
||||
a.mu.Unlock()
|
||||
a.persistTabTokenMode(tab, legacyMode)
|
||||
a.notifyTabRuntimeRebuilt(tab)
|
||||
a.noticeForTab(strings.TrimSpace(tabID), SetAgentPresetDeprecatedNotice)
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistTabTokenMode dual-writes tokenMode (legacy) for the role setting.
|
||||
// Failures keep the in-memory preset (already applied).
|
||||
func (a *App) persistTabTokenMode(tab *WorkspaceTab, mode string) {
|
||||
// persistTabTokenMode persists the deprecated dual-write compatibility values
|
||||
// (agentPreset=balanced, tokenMode=full) so one-version-old clients keep
|
||||
// parsing tab state and session metas. The values are fixed; nothing reads
|
||||
// them to alter runtime behavior.
|
||||
func (a *App) persistTabTokenMode(tab *WorkspaceTab) {
|
||||
if a == nil || tab == nil {
|
||||
return
|
||||
}
|
||||
_ = mode
|
||||
a.mu.Lock()
|
||||
tab.tokenMode = boot.TokenModeFull
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
_ = a.saveTabSessionMetaForCurrentSession(tab)
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
|
||||
type stubProvider struct{}
|
||||
|
||||
const autosaveTestTimeout = 10 * time.Second
|
||||
|
||||
func (stubProvider) Name() string { return "stub" }
|
||||
|
||||
func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
@@ -38,7 +40,7 @@ func controllerWithContent(t *testing.T, path string) *control.Controller {
|
||||
|
||||
func waitForFile(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
deadline := time.Now().Add(autosaveTestTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
|
||||
return
|
||||
@@ -50,7 +52,7 @@ func waitForFile(t *testing.T, path, want string) {
|
||||
|
||||
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
|
||||
t.Helper()
|
||||
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
|
||||
waitForAutosaveIdleWithin(t, tab, autosaveTestTimeout)
|
||||
}
|
||||
|
||||
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
|
||||
|
||||
+117
-125
@@ -5363,8 +5363,68 @@ func TestSetEffortMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func captureTabNotices(app *App, tab *WorkspaceTab) *[]string {
|
||||
var notices []string
|
||||
if tab.sink == nil {
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app, ctx: context.Background()}
|
||||
}
|
||||
tab.sink.SetBotSink(event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.Notice && strings.TrimSpace(e.Text) != "" {
|
||||
notices = append(notices, e.Text)
|
||||
}
|
||||
}))
|
||||
return ¬ices
|
||||
}
|
||||
|
||||
func assertDeprecatedExecutionModeNoop(t *testing.T, app *App, tab *WorkspaceTab, old control.SessionAPI, notices []string) {
|
||||
t.Helper()
|
||||
if tab == nil {
|
||||
t.Fatal("tab missing")
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl != old {
|
||||
t.Fatalf("controller identity changed: got %p want %p", tab.Ctrl, old)
|
||||
}
|
||||
if got := old.AgentPreset(); got != boot.AgentPresetBalanced {
|
||||
t.Fatalf("controller AgentPreset = %q, want balanced", got)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", got)
|
||||
}
|
||||
meta := app.MetaForTab(tab.ID)
|
||||
if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetBalanced {
|
||||
t.Fatalf("meta token/preset = %q/%q, want full/balanced", meta.TokenMode, meta.AgentPreset)
|
||||
}
|
||||
if len(notices) == 0 || notices[len(notices)-1] != SetAgentPresetDeprecatedNotice {
|
||||
t.Fatalf("deprecation notice = %q, want %q", notices, SetAgentPresetDeprecatedNotice)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSetTokenModeDidNotPersistLiveModes(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, entry := range loadTabsFile().Tabs {
|
||||
if entry.TokenMode == boot.TokenModeEconomy || entry.TokenMode == boot.TokenModeDelivery {
|
||||
t.Fatalf("SetTokenMode persisted live mode %q", entry.TokenMode)
|
||||
}
|
||||
if entry.AgentPreset == boot.AgentPresetLight || entry.AgentPreset == boot.AgentPresetDelivery {
|
||||
t.Fatalf("SetTokenMode persisted live preset %q", entry.AgentPreset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertPinnedCompatPersisted(t *testing.T, app *App, tab *WorkspaceTab) {
|
||||
t.Helper()
|
||||
app.persistTabTokenMode(tab)
|
||||
saved := loadTabsFile()
|
||||
if len(saved.Tabs) != 1 {
|
||||
t.Fatalf("saved tabs = %+v, want 1", saved.Tabs)
|
||||
}
|
||||
if saved.Tabs[0].TokenMode != boot.TokenModeFull || saved.Tabs[0].AgentPreset != boot.AgentPresetBalanced {
|
||||
t.Fatalf("saved compat = token:%q preset:%q, want full/balanced", saved.Tabs[0].TokenMode, saved.Tabs[0].AgentPreset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTokenModeRebuildsController(t *testing.T) {
|
||||
// Name kept for history; role settings now switch in place without rebuild.
|
||||
// Name kept for history; SetTokenMode is a deprecated no-op wrapper.
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
@@ -5377,43 +5437,19 @@ func TestSetTokenModeRebuildsController(t *testing.T) {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode(economy): %v", err)
|
||||
}
|
||||
if c := app.activeCtrl(); c == nil {
|
||||
t.Fatal("SetTokenMode should keep a live controller")
|
||||
}
|
||||
if c := app.activeCtrl(); c != old {
|
||||
t.Fatal("SetTokenMode/role setting must switch in place without rebuilding the controller")
|
||||
}
|
||||
if got := old.AgentPreset(); got != boot.AgentPresetLight {
|
||||
t.Fatalf("controller AgentPreset = %q, want light", got)
|
||||
}
|
||||
tab := app.activeTab()
|
||||
if tab == nil {
|
||||
t.Fatal("active tab missing")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != "economy" {
|
||||
t.Fatalf("token mode = %q, want economy", got)
|
||||
}
|
||||
if got := app.Meta().TokenMode; got != "economy" {
|
||||
t.Fatalf("Meta token mode = %q, want economy", got)
|
||||
}
|
||||
if got := app.Meta().AgentPreset; got != boot.AgentPresetLight {
|
||||
t.Fatalf("Meta agentPreset = %q, want light", got)
|
||||
}
|
||||
saved := loadTabsFile()
|
||||
if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != "economy" {
|
||||
t.Fatalf("saved tabs = %+v, want economy token mode", saved.Tabs)
|
||||
}
|
||||
if saved.Tabs[0].AgentPreset != boot.AgentPresetLight {
|
||||
t.Fatalf("saved agentPreset = %q, want light", saved.Tabs[0].AgentPreset)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
assertSetTokenModeDidNotPersistLiveModes(t)
|
||||
assertPinnedCompatPersisted(t, app, tab)
|
||||
}
|
||||
|
||||
func TestSetTokenModeDeliveryRebuildsAndPersistsProfile(t *testing.T) {
|
||||
// Name kept for history; delivery role setting switches in place and dual-writes.
|
||||
// Name kept for history; SetTokenMode(delivery) is ignored and does not persist delivery.
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
@@ -5426,52 +5462,20 @@ func TestSetTokenModeDeliveryRebuildsAndPersistsProfile(t *testing.T) {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
|
||||
if err := app.SetTokenMode(boot.TokenModeDelivery); err != nil {
|
||||
t.Fatalf("SetTokenMode(delivery): %v", err)
|
||||
}
|
||||
if c := app.activeCtrl(); c == nil || c != old {
|
||||
t.Fatal("delivery role setting should keep the same controller (in-place switch)")
|
||||
}
|
||||
if got := old.AgentPreset(); got != boot.AgentPresetDelivery {
|
||||
t.Fatalf("controller AgentPreset = %q, want delivery", got)
|
||||
}
|
||||
tab := app.activeTab()
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeDelivery {
|
||||
t.Fatalf("token mode = %q, want delivery", got)
|
||||
}
|
||||
if got := app.Meta().TokenMode; got != boot.TokenModeDelivery {
|
||||
t.Fatalf("Meta token mode = %q, want delivery", got)
|
||||
}
|
||||
if got := app.Meta().AgentPreset; got != boot.AgentPresetDelivery {
|
||||
t.Fatalf("Meta agentPreset = %q, want delivery", got)
|
||||
}
|
||||
saved := loadTabsFile()
|
||||
if len(saved.Tabs) != 1 || saved.Tabs[0].TokenMode != boot.TokenModeDelivery {
|
||||
t.Fatalf("saved tabs = %+v, want delivery profile", saved.Tabs)
|
||||
}
|
||||
if saved.Tabs[0].AgentPreset != boot.AgentPresetDelivery {
|
||||
t.Fatalf("saved agentPreset = %q, want delivery", saved.Tabs[0].AgentPreset)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
assertSetTokenModeDidNotPersistLiveModes(t)
|
||||
|
||||
// Leaving delivery must clear the persisted tokenMode so a restart does not
|
||||
// re-arm final-readiness gates (#6582).
|
||||
if err := app.SetTokenMode(boot.TokenModeFull); err != nil {
|
||||
t.Fatalf("SetTokenMode(full): %v", err)
|
||||
}
|
||||
if got := currentTabTokenMode(app.activeTab()); got != boot.TokenModeFull {
|
||||
t.Fatalf("token mode after full = %q, want full", got)
|
||||
}
|
||||
if got := app.Meta().TokenMode; got != boot.TokenModeFull {
|
||||
t.Fatalf("Meta token mode after full = %q, want full", got)
|
||||
}
|
||||
saved = loadTabsFile()
|
||||
if len(saved.Tabs) != 1 {
|
||||
t.Fatalf("saved tabs = %+v", saved.Tabs)
|
||||
}
|
||||
if saved.Tabs[0].TokenMode != "" {
|
||||
t.Fatalf("saved tokenMode = %q, want omitted/empty for full", saved.Tabs[0].TokenMode)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
assertPinnedCompatPersisted(t, app, tab)
|
||||
}
|
||||
|
||||
func TestSetTokenModeReusesCurrentSessionLease(t *testing.T) {
|
||||
@@ -5522,19 +5526,11 @@ func TestSetTokenModeReusesCurrentSessionLease(t *testing.T) {
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
t.Fatalf("ensureSessionLease: %v", err)
|
||||
}
|
||||
notices := captureTabNotices(app, tab)
|
||||
if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
}
|
||||
// Role setting switches in place: same controller, same lease, same history.
|
||||
if tab.Ctrl == nil || tab.Ctrl != oldCtrl {
|
||||
t.Fatalf("tab controller should be reused in place, got %p want %p", tab.Ctrl, oldCtrl)
|
||||
}
|
||||
if got := oldCtrl.AgentPreset(); got != boot.AgentPresetLight {
|
||||
t.Fatalf("controller AgentPreset = %q, want light", got)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != "economy" {
|
||||
t.Fatalf("token mode = %q, want economy", got)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, oldCtrl, *notices)
|
||||
if tab.sessionLease == nil || sessionRuntimeKey(tab.sessionLease.Path()) != sessionRuntimeKey(path) {
|
||||
t.Fatalf("session lease path = %q, want %q", tab.currentSessionPath(), path)
|
||||
}
|
||||
@@ -5593,23 +5589,16 @@ func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) {
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
// Role setting switches in place and does not re-acquire the session lease,
|
||||
// so an externally held lease does not block the switch.
|
||||
// Deprecated wrapper must not re-acquire the session lease, so an
|
||||
// externally held lease does not block the call or replace the controller.
|
||||
notices := captureTabNotices(app, tab)
|
||||
if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
}
|
||||
if tab.Ctrl != oldCtrl {
|
||||
t.Fatalf("tab controller changed after in-place role switch")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != "economy" {
|
||||
t.Fatalf("token mode = %q, want economy", got)
|
||||
}
|
||||
if got := oldCtrl.AgentPreset(); got != boot.AgentPresetLight {
|
||||
t.Fatalf("controller AgentPreset = %q, want light", got)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, oldCtrl, *notices)
|
||||
meta := app.MetaForTab(tab.ID)
|
||||
if !meta.Ready || meta.Runtime.Phase != sessionRuntimeReady {
|
||||
t.Fatalf("role switch disabled current runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
|
||||
t.Fatalf("deprecated mode call disabled current runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5642,24 +5631,20 @@ func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode(economy): %v", err)
|
||||
}
|
||||
tab := app.activeTab()
|
||||
if tab == nil {
|
||||
t.Fatal("active tab missing")
|
||||
}
|
||||
// Role setting no longer rebuilds the controller, so stale model aliases
|
||||
// are not migrated on this path (migration still runs on model/effort rebuilds).
|
||||
// SetTokenMode does not rebuild, so stale model aliases stay put
|
||||
// (migration still runs on model/effort rebuilds).
|
||||
if tab.model != "deepseek-flash/deepseek-v4-flash" {
|
||||
t.Fatalf("tab model = %q, want unchanged stale ref without rebuild", tab.model)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != "economy" {
|
||||
t.Fatalf("token mode = %q, want economy", got)
|
||||
}
|
||||
if c := app.activeCtrl(); c != old {
|
||||
t.Fatal("role setting must keep the same controller")
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
}
|
||||
|
||||
func TestMetaForTabReportsImageInputCapability(t *testing.T) {
|
||||
@@ -5752,7 +5737,7 @@ func TestMetaForTabImageInputCapabilityUsesCurrentRef(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
|
||||
// Role setting no longer rebuilds; an unknown model must not block the switch.
|
||||
// Name kept for history; an unknown model must not block the deprecated no-op.
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("DEEPSEEK_API_KEY", "")
|
||||
t.Setenv("MIMO_API_KEY", "")
|
||||
@@ -5767,23 +5752,13 @@ func TestSetTokenModeKeepsControllerWhenRebuildFails(t *testing.T) {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode(economy) in-place switch: %v", err)
|
||||
}
|
||||
if c := app.activeCtrl(); c != old {
|
||||
t.Fatalf("SetTokenMode replaced controller: got %p want %p", c, old)
|
||||
}
|
||||
tab := app.activeTab()
|
||||
if tab == nil {
|
||||
t.Fatal("active tab missing")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != "economy" {
|
||||
t.Fatalf("token mode after in-place switch = %q, want economy", got)
|
||||
}
|
||||
if got := app.Meta().TokenMode; got != "economy" {
|
||||
t.Fatalf("Meta token mode after in-place switch = %q, want economy", got)
|
||||
t.Fatalf("SetTokenMode(economy): %v", err)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
}
|
||||
|
||||
func TestSetEffortRejectsRunningTurn(t *testing.T) {
|
||||
@@ -5805,24 +5780,29 @@ func TestSetEffortRejectsRunningTurn(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
|
||||
// Name kept for history; the deprecated wrapper does not require an idle tab.
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
|
||||
app.activeCtrl().Submit("work")
|
||||
old := control.New(control.Options{Runner: runner})
|
||||
app.setTestCtrl(old, "")
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
old.Submit("work")
|
||||
<-runner.started
|
||||
|
||||
err := app.SetTokenMode("economy")
|
||||
if err == nil || !strings.Contains(err.Error(), "finish or cancel") {
|
||||
t.Fatalf("SetTokenMode while running error = %v, want finish/cancel guard", err)
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode while running: %v", err)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, old, *notices)
|
||||
|
||||
close(runner.release)
|
||||
waitNotRunning(t, app.activeCtrl())
|
||||
}
|
||||
|
||||
func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
// Name kept for history; background jobs must not block the deprecated wrapper.
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
|
||||
@@ -5851,6 +5831,8 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
current.Close()
|
||||
}
|
||||
})
|
||||
tab := app.activeTab()
|
||||
notices := captureTabNotices(app, tab)
|
||||
|
||||
release := make(chan struct{})
|
||||
job := jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
|
||||
@@ -5863,10 +5845,10 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
})
|
||||
t.Cleanup(func() { close(release) })
|
||||
|
||||
err := app.SetTokenMode("economy")
|
||||
if err == nil || !strings.Contains(err.Error(), "background_jobs=1") {
|
||||
t.Fatalf("SetTokenMode with background job error = %v, want exact background-job guard", err)
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode with background job: %v", err)
|
||||
}
|
||||
assertDeprecatedExecutionModeNoop(t, app, tab, ctrl, *notices)
|
||||
cancelled, err := app.CancelJobForTab("", job.ID)
|
||||
if err != nil || !cancelled {
|
||||
t.Fatalf("CancelJobForTab = %v, %v, want true, nil", cancelled, err)
|
||||
@@ -5874,8 +5856,18 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
if result := jm.WaitForSession(context.Background(), agent.BranchID(path), []string{job.ID}, 5); len(result) != 1 || result[0].Status != jobs.Killed {
|
||||
t.Fatalf("stopped background job = %+v, want one killed result", result)
|
||||
}
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode after stopping background job: %v", err)
|
||||
}
|
||||
|
||||
func TestSetTokenModeUnknownTabErrors(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
err := app.SetTokenModeForTab("missing-tab", "economy")
|
||||
if err == nil || !strings.Contains(err.Error(), `tab "missing-tab" not found`) {
|
||||
t.Fatalf("SetTokenModeForTab(unknown) = %v, want tab not found", err)
|
||||
}
|
||||
err = app.SetAgentPresetForTab("missing-tab", boot.AgentPresetLight)
|
||||
if err == nil || !strings.Contains(err.Error(), `tab "missing-tab" not found`) {
|
||||
t.Fatalf("SetAgentPresetForTab(unknown) = %v, want tab not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
@@ -14,9 +13,9 @@ var (
|
||||
createDeliveryWorktree = worktree.Create
|
||||
)
|
||||
|
||||
// DeliveryWorktreeOpenResult is returned after an isolated Git workspace has
|
||||
// IsolatedWorktreeOpenResult is returned after an isolated Git workspace has
|
||||
// been created and opened as a normal Reasonix project.
|
||||
type DeliveryWorktreeOpenResult struct {
|
||||
type IsolatedWorktreeOpenResult struct {
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
WorktreeRoot string `json:"worktreeRoot"`
|
||||
SourceRoot string `json:"sourceRoot"`
|
||||
@@ -25,33 +24,38 @@ type DeliveryWorktreeOpenResult struct {
|
||||
Tab TabMeta `json:"tab"`
|
||||
}
|
||||
|
||||
// DeliveryWorktreeAvailability reports whether workspaceRoot can use the
|
||||
// optional Git isolation path. A false result never disables Delivery itself;
|
||||
// DeliveryWorktreeOpenResult is the deprecated alias of
|
||||
// IsolatedWorktreeOpenResult kept bound for one compatibility version.
|
||||
type DeliveryWorktreeOpenResult = IsolatedWorktreeOpenResult
|
||||
|
||||
// IsolatedWorktreeAvailability reports whether workspaceRoot can use the
|
||||
// optional Git isolation path. A false result never disables writing itself;
|
||||
// the cross-platform workspace writer lease remains the no-Git fallback.
|
||||
func (a *App) DeliveryWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
|
||||
}
|
||||
|
||||
// CreateDeliveryWorktree creates a durable branch-backed worktree and opens it
|
||||
// CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
|
||||
// as a project. It never switches or modifies the source checkout, and it does
|
||||
// not delete the new worktree if later UI registration fails.
|
||||
func (a *App) CreateDeliveryWorktree(workspaceRoot string) (DeliveryWorktreeOpenResult, error) {
|
||||
// not delete the new worktree if later UI registration fails. Creation does
|
||||
// not force any execution mode: the standard policy adapts per task.
|
||||
func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpenResult, error) {
|
||||
workspaceRoot = strings.TrimSpace(workspaceRoot)
|
||||
created, err := createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
|
||||
if err != nil {
|
||||
return DeliveryWorktreeOpenResult{}, err
|
||||
return IsolatedWorktreeOpenResult{}, err
|
||||
}
|
||||
|
||||
var tab TabMeta
|
||||
if a.singleSurfaceLayoutEnabled() {
|
||||
tab, err = a.ensureBlankSurface("project", created.WorkspaceRoot, boot.TokenModeDelivery)
|
||||
tab, err = a.ensureBlankSurface("project", created.WorkspaceRoot)
|
||||
} else {
|
||||
tab, err = a.ensureBlankTab("project", created.WorkspaceRoot, boot.TokenModeDelivery)
|
||||
tab, err = a.ensureBlankTab("project", created.WorkspaceRoot)
|
||||
}
|
||||
if err != nil {
|
||||
return DeliveryWorktreeOpenResult{}, fmt.Errorf("isolated worktree was created at %s but Reasonix could not open it: %w", created.WorktreeRoot, err)
|
||||
return IsolatedWorktreeOpenResult{}, fmt.Errorf("isolated worktree was created at %s but Reasonix could not open it: %w", created.WorktreeRoot, err)
|
||||
}
|
||||
return DeliveryWorktreeOpenResult{
|
||||
return IsolatedWorktreeOpenResult{
|
||||
WorkspaceRoot: created.WorkspaceRoot,
|
||||
WorktreeRoot: created.WorktreeRoot,
|
||||
SourceRoot: created.SourceRoot,
|
||||
@@ -60,3 +64,15 @@ func (a *App) CreateDeliveryWorktree(workspaceRoot string) (DeliveryWorktreeOpen
|
||||
Tab: tab,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeliveryWorktreeAvailability is the deprecated alias of
|
||||
// IsolatedWorktreeAvailability, kept bound for one compatibility version.
|
||||
func (a *App) DeliveryWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
return a.IsolatedWorktreeAvailability(workspaceRoot)
|
||||
}
|
||||
|
||||
// CreateDeliveryWorktree is the deprecated alias of CreateIsolatedWorktree,
|
||||
// kept bound for one compatibility version.
|
||||
func (a *App) CreateDeliveryWorktree(workspaceRoot string) (DeliveryWorktreeOpenResult, error) {
|
||||
return a.CreateIsolatedWorktree(workspaceRoot)
|
||||
}
|
||||
@@ -60,7 +60,10 @@ func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
|
||||
if result.WorkspaceRoot != isolatedRoot || result.Branch != "reasonix/delivery-test" || !result.SourceDirty {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if result.Tab.WorkspaceRoot != isolatedRoot || !result.Tab.IsolatedWorktree || !result.Tab.Active || result.Tab.TokenMode != "delivery" {
|
||||
if result.Tab.WorkspaceRoot != isolatedRoot || !result.Tab.IsolatedWorktree || !result.Tab.Active {
|
||||
t.Fatalf("opened tab = %+v", result.Tab)
|
||||
}
|
||||
if result.Tab.TokenMode != "full" {
|
||||
t.Fatalf("isolated worktree tokenMode = %q, want pinned full (must not force delivery)", result.Tab.TokenMode)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
|
||||
"test:bench": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/run.mjs",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:delivery-worktree": "tsx src/__tests__/delivery-worktree.test.ts",
|
||||
"test:isolated-worktree": "tsx src/__tests__/isolated-worktree.test.ts",
|
||||
"test:updater": "tsx src/__tests__/updater-shared-state.test.tsx",
|
||||
"test:window-state": "tsx src/__tests__/window-state-ordering.test.ts",
|
||||
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance"
|
||||
|
||||
+12
-143
@@ -106,7 +106,6 @@ import {
|
||||
type SessionMeta,
|
||||
type SettingsView,
|
||||
type TabMeta,
|
||||
type TokenMode,
|
||||
type ToolApprovalMode,
|
||||
type WorkspaceConflictView,
|
||||
} from "./lib/types";
|
||||
@@ -211,13 +210,6 @@ function noticePreviewMockEnabled(): boolean {
|
||||
const value = browserMockScenarioParam();
|
||||
return value === "notice" || value === "notices" || value === "notice-preview";
|
||||
}
|
||||
function runtimeProfileShortKey(mode: TokenMode) {
|
||||
return mode === "economy"
|
||||
? "composer.runtimeProfileEconomyShort" as const
|
||||
: mode === "delivery"
|
||||
? "composer.runtimeProfileDeliveryShort" as const
|
||||
: "composer.runtimeProfileBalancedShort" as const;
|
||||
}
|
||||
function noticePreviewItems(): Item[] {
|
||||
const notice = (index: number, level: "info" | "warn", text: string, detail: string, code?: string): Item => ({
|
||||
kind: "notice",
|
||||
@@ -450,7 +442,7 @@ type SidebarImConnection = {
|
||||
type DesktopNavigationIntent =
|
||||
| { kind: "topic"; scope: string; workspaceRoot: string; topicId: string; sessionPath?: string }
|
||||
| { kind: "blank"; scope: string; workspaceRoot: string }
|
||||
| { kind: "delivery-worktree"; workspaceRoot: string }
|
||||
| { kind: "isolated-worktree"; workspaceRoot: string }
|
||||
| { kind: "sidebar-im"; connection: SidebarImConnection }
|
||||
| { kind: "resume-session"; session: SessionMeta };
|
||||
type DesktopNavigationInput = DesktopNavigationIntent & { navigationIntentSeq: number };
|
||||
@@ -1099,11 +1091,10 @@ export default function App() {
|
||||
undoRewindForTab,
|
||||
setModel,
|
||||
setEffort,
|
||||
setTokenMode,
|
||||
cancelJob,
|
||||
switchTab,
|
||||
openProjectTab,
|
||||
createDeliveryWorktree,
|
||||
createIsolatedWorktree,
|
||||
openGlobalTab,
|
||||
closeTab,
|
||||
reorderTabs,
|
||||
@@ -1118,8 +1109,6 @@ export default function App() {
|
||||
const { locale, setPref: setLocalePref } = useI18n();
|
||||
const t = useT();
|
||||
const [composerProfilesByTab, setComposerProfilesByTab] = useState<Record<string, ComposerProfile>>({});
|
||||
const runtimeTransitionTabsRef = useRef<Set<string>>(new Set());
|
||||
const [runtimeTransitionsByTab, setRuntimeTransitionsByTab] = useState<Record<string, true>>({});
|
||||
const yoloRestoreToolApprovalModesRef = useRef<Record<string, RestorableToolApprovalMode>>({});
|
||||
const userPlanModeByTabRef = useRef<UserPlanModeIntents>({});
|
||||
const [tabMetas, setTabMetas] = useState<TabMeta[]>([]);
|
||||
@@ -1294,13 +1283,6 @@ export default function App() {
|
||||
const [clearContextPending, setClearContextPending] = useState(false);
|
||||
const [backgroundRuntimes, setBackgroundRuntimes] = useState<BackgroundRuntimeView[]>([]);
|
||||
const [workspaceConflict, setWorkspaceConflict] = useState<WorkspaceConflictView | null>(null);
|
||||
const [pendingModeSwitch, setPendingModeSwitch] = useState<{
|
||||
tabId: string;
|
||||
target: TokenMode;
|
||||
previous: TokenMode;
|
||||
work: ActiveWorkView;
|
||||
stopping: boolean;
|
||||
} | null>(null);
|
||||
const [pendingClose, setPendingClose] = useState<{ tabId: string; work: ActiveWorkView; stopping: boolean } | null>(null);
|
||||
const topicRenameSkipCommitRef = useRef(false);
|
||||
const prevDecisionSurfaceRef = useRef<DecisionSurfaceKind | null>(null);
|
||||
@@ -1363,10 +1345,6 @@ export default function App() {
|
||||
};
|
||||
}, [activeTabId, state.running]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingModeSwitch && pendingModeSwitch.tabId !== activeTabId) setPendingModeSwitch(null);
|
||||
}, [activeTabId, pendingModeSwitch]);
|
||||
|
||||
const closeTransientOverlays = useCallback(() => {
|
||||
setTransientOverlayDismissSignal((signal) => signal + 1);
|
||||
}, []);
|
||||
@@ -1723,8 +1701,7 @@ export default function App() {
|
||||
const goal = composerProfile.goal;
|
||||
const collaborationMode = displayedComposerProfileCollaborationMode(composerProfile);
|
||||
const toolApprovalMode = composerProfile.toolApprovalMode;
|
||||
const tokenMode: TokenMode = composerProfile.tokenMode;
|
||||
const runtimeTransitioning = Boolean(activeTabId && runtimeTransitionsByTab[activeTabId]);
|
||||
const runtimeTransitioning = false;
|
||||
const controllerReady =
|
||||
state.meta?.ready === true &&
|
||||
(!state.meta.runtime || state.meta.runtime.phase === "ready") &&
|
||||
@@ -1740,11 +1717,10 @@ export default function App() {
|
||||
if (state.ask) return "ask";
|
||||
if (state.extensionForm) return "extension_form";
|
||||
if (workspaceConflict) return "workspace_conflict";
|
||||
if (pendingModeSwitch) return "mode_jobs";
|
||||
if (pendingClose) return "close_active";
|
||||
if (clearContextPending) return "clear_context";
|
||||
return null;
|
||||
}, [clearContextPending, pendingClose, pendingModeSwitch, state.approval, state.ask, state.extensionForm, workspaceConflict]);
|
||||
}, [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, workspaceConflict]);
|
||||
decisionSurfaceRef.current = decisionSurface;
|
||||
useEffect(() => {
|
||||
// Close composer menus/popovers when a decision takes over the footer.
|
||||
@@ -1843,7 +1819,6 @@ export default function App() {
|
||||
mode: composerProfileMode(profile),
|
||||
collaborationMode: displayedComposerProfileCollaborationMode(profile),
|
||||
toolApprovalMode: profile.toolApprovalMode,
|
||||
tokenMode: profile.tokenMode,
|
||||
goal: profile.goal,
|
||||
active: tab.id === visibleTabId,
|
||||
};
|
||||
@@ -1971,77 +1946,6 @@ export default function App() {
|
||||
},
|
||||
[activeTabId, applyGoalForTab],
|
||||
);
|
||||
const commitTokenModeSwitch = useCallback(
|
||||
async (tabId: string, m: TokenMode, previous: TokenMode, profile: ComposerProfile): Promise<void> => {
|
||||
if (!tabId || activeTabIdRef.current !== tabId || runtimeTransitionTabsRef.current.has(tabId)) return;
|
||||
runtimeTransitionTabsRef.current.add(tabId);
|
||||
setRuntimeTransitionsByTab((current) => ({ ...current, [tabId]: true }));
|
||||
setComposerProfilesByTab((current) => patchComposerProfile(current, tabId, profile, { tokenMode: m }, ["tokenMode"]));
|
||||
const switched = await setTokenMode(m);
|
||||
if (!switched) {
|
||||
setComposerProfilesByTab((current) => {
|
||||
const currentProfile = current[tabId] ?? profile;
|
||||
const pending = { ...currentProfile.pending };
|
||||
delete pending.tokenMode;
|
||||
return { ...current, [tabId]: { ...currentProfile, tokenMode: previous, pending } };
|
||||
});
|
||||
}
|
||||
runtimeTransitionTabsRef.current.delete(tabId);
|
||||
setRuntimeTransitionsByTab((current) => {
|
||||
if (!current[tabId]) return current;
|
||||
const next = { ...current };
|
||||
delete next[tabId];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setTokenMode],
|
||||
);
|
||||
const applyTokenMode = useCallback(
|
||||
async (m: TokenMode): Promise<void> => {
|
||||
const tabId = activeTabId;
|
||||
if (!tabId || runtimeTransitionTabsRef.current.has(tabId) || m === composerProfile.tokenMode) return;
|
||||
const previous = composerProfile.tokenMode;
|
||||
try {
|
||||
const work = await app.ActiveWorkForTab(tabId);
|
||||
if (work.jobs.length > 0) {
|
||||
setPendingModeSwitch({ tabId, target: m, previous, work, stopping: false });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// The atomic backend guard remains authoritative on older runtimes.
|
||||
}
|
||||
await commitTokenModeSwitch(tabId, m, previous, composerProfile);
|
||||
},
|
||||
[activeTabId, commitTokenModeSwitch, composerProfile],
|
||||
);
|
||||
const stopJobsAndSwitchMode = useCallback(async () => {
|
||||
const request = pendingModeSwitch;
|
||||
if (!request || request.stopping) return;
|
||||
setPendingModeSwitch({ ...request, stopping: true });
|
||||
try {
|
||||
await app.CancelJobsForTab(request.tabId, request.work.jobs.map((job) => job.id));
|
||||
const deadline = Date.now() + 15_000;
|
||||
let work = await app.ActiveWorkForTab(request.tabId);
|
||||
while (work.jobs.length > 0 && Date.now() < deadline) {
|
||||
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work } : current);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 150));
|
||||
work = await app.ActiveWorkForTab(request.tabId);
|
||||
}
|
||||
if (work.jobs.length > 0) {
|
||||
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work, stopping: false } : current);
|
||||
showToast(t("status.jobStopFailed"), "error");
|
||||
return;
|
||||
}
|
||||
setPendingModeSwitch(null);
|
||||
await refreshBackgroundRuntimes();
|
||||
if (activeTabIdRef.current === request.tabId) {
|
||||
await commitTokenModeSwitch(request.tabId, request.target, request.previous, composerProfile);
|
||||
}
|
||||
} catch (err) {
|
||||
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current);
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
}, [commitTokenModeSwitch, composerProfile, pendingModeSwitch, refreshBackgroundRuntimes, showToast, t]);
|
||||
const cancelRuntimeJob = useCallback(async (tabId: string, jobId: string): Promise<boolean> => {
|
||||
try {
|
||||
const cancelled = await app.CancelJobForTab(tabId, jobId);
|
||||
@@ -2312,7 +2216,6 @@ export default function App() {
|
||||
if (activeTabIdRef.current !== sourceTabId) return;
|
||||
closeTransientOverlays();
|
||||
setWorkspaceConflict(null);
|
||||
setPendingModeSwitch(null);
|
||||
setPendingClose(null);
|
||||
setClearContextPending(false);
|
||||
const mockWork: ActiveWorkView = {
|
||||
@@ -2333,14 +2236,6 @@ export default function App() {
|
||||
canReveal: true,
|
||||
canCreateWorktree: true,
|
||||
});
|
||||
} else if (decisionMock === "mode_jobs") {
|
||||
setPendingModeSwitch({
|
||||
tabId: sourceTabId,
|
||||
target: tokenMode === "delivery" ? "full" : "delivery",
|
||||
previous: tokenMode,
|
||||
work: mockWork,
|
||||
stopping: false,
|
||||
});
|
||||
} else if (decisionMock === "close_active") {
|
||||
setPendingClose({ tabId: sourceTabId, work: mockWork, stopping: false });
|
||||
} else {
|
||||
@@ -2448,7 +2343,7 @@ export default function App() {
|
||||
await commitThenSendRef.current(sourceTabId, trimmed, submitText.trim(), structured);
|
||||
},
|
||||
[activeTabId, applyGoal, closeTransientOverlays, collaborationMode, composerProfile, controllerReady, goal, notice, runShellForTab,
|
||||
patchActivatedGoalForTab, setControllerComposerProfileForTab, switchModel, t, tokenMode, toolApprovalMode, showToast],
|
||||
patchActivatedGoalForTab, setControllerComposerProfileForTab, switchModel, t, toolApprovalMode, showToast],
|
||||
);
|
||||
|
||||
const handleSteer = useCallback(async (text: string, requestedTabId = activeTabId) => {
|
||||
@@ -3326,12 +3221,12 @@ export default function App() {
|
||||
cancel();
|
||||
setWorkspaceConflict(null);
|
||||
try {
|
||||
await createDeliveryWorktree(root);
|
||||
await createIsolatedWorktree(root);
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
}, [cancel, createDeliveryWorktree, refreshTabMetas, showToast, state.meta?.cwd, state.meta?.workspacePath, state.meta?.workspaceRoot]);
|
||||
}, [cancel, createIsolatedWorktree, refreshTabMetas, showToast, state.meta?.cwd, state.meta?.workspacePath, state.meta?.workspaceRoot]);
|
||||
|
||||
const handleTabsClose = useCallback(async (ids: string[], nextActiveTabId?: string) => {
|
||||
closeTransientOverlays();
|
||||
@@ -3703,8 +3598,8 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.kind === "delivery-worktree") {
|
||||
const result = await createDeliveryWorktree(request.workspaceRoot, request.navigationIntentSeq);
|
||||
if (request.kind === "isolated-worktree") {
|
||||
const result = await createIsolatedWorktree(request.workspaceRoot, request.navigationIntentSeq);
|
||||
if (!latest()) return;
|
||||
seedActiveTabMeta(result.tab);
|
||||
setProjectRevision((value) => value + 1);
|
||||
@@ -3781,7 +3676,7 @@ export default function App() {
|
||||
void refreshLatestTabMetas();
|
||||
return;
|
||||
}
|
||||
if (request.kind === "delivery-worktree") {
|
||||
if (request.kind === "isolated-worktree") {
|
||||
console.warn("isolated Delivery workspace creation failed", err);
|
||||
showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
|
||||
return;
|
||||
@@ -3803,7 +3698,7 @@ export default function App() {
|
||||
showToast(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
}, [activateTopic, createDeliveryWorktree, ensureBlankSurface, ensureBlankTab, isNavigationIntentCurrent, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]);
|
||||
}, [activateTopic, createIsolatedWorktree, ensureBlankSurface, ensureBlankTab, isNavigationIntentCurrent, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]);
|
||||
|
||||
const enqueueNavigationWithIntent = useCallback((input: DesktopNavigationIntent, navigationIntentSeq: number): Promise<void> => {
|
||||
return enqueueNavigationRequest(
|
||||
@@ -4426,7 +4321,7 @@ export default function App() {
|
||||
imTopicSources={imTopicSources}
|
||||
onOpenTopic={handleOpenTopic}
|
||||
onCreateTopic={(scope, workspaceRoot) => openBlankSession(scope, scope === "project" ? workspaceRoot : "")}
|
||||
onCreateDeliveryWorktree={(workspaceRoot) => enqueueNavigation({ kind: "delivery-worktree", workspaceRoot })}
|
||||
onCreateIsolatedWorktree={(workspaceRoot) => enqueueNavigation({ kind: "isolated-worktree", workspaceRoot })}
|
||||
onTopicsChanged={refreshProjectsAndTabs}
|
||||
onRenameTopic={renameTopic}
|
||||
refreshSignal={projectRevision}
|
||||
@@ -5014,30 +4909,6 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: decisionSurface === "mode_jobs" && pendingModeSwitch ? (
|
||||
<RuntimeDecisionCard
|
||||
id="mode-jobs"
|
||||
title={t("runtime.modeJobsTitle", { mode: t(runtimeProfileShortKey(pendingModeSwitch.target)) })}
|
||||
badge={t("status.jobs", { n: pendingModeSwitch.work.jobs.length })}
|
||||
meta={t("runtime.modeJobsMeta")}
|
||||
note={pendingModeSwitch.work.jobs.map((job) => job.label || job.kind).join(" · ")}
|
||||
onCancel={() => setPendingModeSwitch(null)}
|
||||
actions={[
|
||||
{
|
||||
key: "1", label: pendingModeSwitch.stopping
|
||||
? t("status.jobStopping")
|
||||
: t("runtime.stopAndSwitch", { n: pendingModeSwitch.work.jobs.length }),
|
||||
description: t("runtime.stopAndSwitchDesc", { mode: t(runtimeProfileShortKey(pendingModeSwitch.target)) }),
|
||||
onClick: () => void stopJobsAndSwitchMode(),
|
||||
danger: true, disabled: pendingModeSwitch.stopping,
|
||||
},
|
||||
]}
|
||||
secondaryAction={{
|
||||
key: "Esc", label: t("runtime.keepMode"), description: t("runtime.keepModeDesc"),
|
||||
onClick: () => setPendingModeSwitch(null), disabled: pendingModeSwitch.stopping,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: decisionSurface === "close_active" && pendingClose ? (
|
||||
<RuntimeDecisionCard
|
||||
id="close-active"
|
||||
@@ -5089,7 +4960,6 @@ export default function App() {
|
||||
running={state.running || rewindCommitting}
|
||||
collaborationMode={collaborationMode}
|
||||
toolApprovalMode={toolApprovalMode}
|
||||
tokenMode={tokenMode}
|
||||
turnPhase={state.turnPhase}
|
||||
goal={goal}
|
||||
goalStatus={state.meta?.goalStatus}
|
||||
@@ -5113,7 +4983,6 @@ export default function App() {
|
||||
onResumeGoal={resumeGoalFromUi}
|
||||
onSwitchModel={switchModelFromUi}
|
||||
onSetEffort={setEffort}
|
||||
onSetTokenMode={applyTokenMode}
|
||||
insertRequest={composerInsertRequest}
|
||||
selectedTextRequest={selectedTextRequest}
|
||||
readOnly={Boolean(activeTab?.readOnly)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { activeWorkBusyNoticeText } from "../lib/capabilityMutations";
|
||||
import { t } from "../lib/i18n";
|
||||
import { effortSwitchNoticeText, modelSwitchNoticeText, tokenModeSwitchNoticeText } from "../lib/useController";
|
||||
import { effortSwitchNoticeText, modelSwitchNoticeText } from "../lib/useController";
|
||||
|
||||
function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
@@ -34,10 +34,6 @@ ok(
|
||||
effortSwitchNoticeText(activeWorkError(false, false, 1).message.replace("MCP server", "effort")) === "Reasoning effort cannot change while background work is active. Active jobs: 1. Open Background jobs in the status bar to stop them.",
|
||||
"effort switching uses grammatically neutral single-job guidance",
|
||||
);
|
||||
ok(
|
||||
tokenModeSwitchNoticeText(activeWorkError(false, false, 1).message.replace("MCP server", "token mode")) === "Execution setting cannot change while background work is active. Active jobs: 1. Open Background jobs in the status bar to stop them.",
|
||||
"execution-setting switching uses grammatically neutral single-job guidance",
|
||||
);
|
||||
ok(
|
||||
activeWorkBusyNoticeText(
|
||||
new Error("active work is still running; finish or cancel the current turn before changing MCP server"),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -108,7 +108,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
@@ -125,7 +125,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { ComposerInvocation, StructuredInvocationSubmit } from "../lib/invocationDisplay";
|
||||
import type { CollaborationMode, CommandInfo, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
import type { CollaborationMode, CommandInfo, DirEntry, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -108,7 +108,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
tabId: "tab-a",
|
||||
@@ -132,7 +132,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Composer } from "../components/Composer";
|
||||
import { UserMessage } from "../components/Message";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -111,7 +111,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
@@ -128,7 +128,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -109,7 +109,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
running: false,
|
||||
collaborationMode: "normal" as CollaborationMode,
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
@@ -125,7 +125,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
|
||||
@@ -47,7 +47,6 @@ function tab(overrides: Partial<LooseTabMeta> = {}): TabMeta {
|
||||
mode: "normal",
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
active: true,
|
||||
@@ -66,7 +65,6 @@ function meta(overrides: Partial<LooseMeta> = {}): Meta {
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
...overrides,
|
||||
@@ -77,15 +75,15 @@ console.log("\ncomposer profile");
|
||||
|
||||
{
|
||||
let profiles: ComposerProfilesByTab = {};
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ tokenMode: "delivery" })]);
|
||||
eq(profiles["tab-1"].tokenMode, "delivery", "delivery runtime profile hydrates from persisted tabs");
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ tokenMode: "delivery" }));
|
||||
eq(Boolean(profiles["tab-1"].pending.tokenMode), false, "delivery runtime profile is acknowledged by meta");
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ toolApprovalMode: "auto" })]);
|
||||
eq(profiles["tab-1"].toolApprovalMode, "auto", "auto tool approval hydrates from persisted tabs");
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ toolApprovalMode: "auto" }));
|
||||
eq(Boolean(profiles["tab-1"].pending.toolApprovalMode), false, "auto tool approval is acknowledged by meta");
|
||||
}
|
||||
|
||||
{
|
||||
let profiles: ComposerProfilesByTab = {};
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ tokenMode: "economy" })]);
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]);
|
||||
profiles = patchComposerProfile(
|
||||
profiles,
|
||||
"tab-1",
|
||||
@@ -101,14 +99,13 @@ console.log("\ncomposer profile");
|
||||
["collaborationMode", "goal"],
|
||||
);
|
||||
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ tokenMode: "economy" })]);
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]);
|
||||
|
||||
eq(displayedComposerProfileCollaborationMode(profiles["tab-1"]), "plan", "stale tab hydration keeps locally selected plan mode");
|
||||
eq(profiles["tab-1"].tokenMode, "economy", "token saver remains independent of collaboration mode changes");
|
||||
eq(composerProfileMode(profiles["tab-1"]), "plan", "compat mode keeps the plan axis enabled");
|
||||
eq(Boolean(profiles["tab-1"].pending.collaborationMode), true, "pending plan stays pending until backend acknowledges it");
|
||||
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ mode: "plan", collaborationMode: "plan", tokenMode: "economy" })]);
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab({ mode: "plan", collaborationMode: "plan" })]);
|
||||
|
||||
eq(displayedComposerProfileCollaborationMode(profiles["tab-1"]), "plan", "acknowledged tab hydration keeps plan visible");
|
||||
eq(Boolean(profiles["tab-1"].pending.collaborationMode), false, "backend acknowledgement clears pending plan");
|
||||
@@ -117,16 +114,22 @@ console.log("\ncomposer profile");
|
||||
{
|
||||
let profiles: ComposerProfilesByTab = {};
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]);
|
||||
profiles = patchComposerProfile(profiles, "tab-1", profiles["tab-1"], { tokenMode: "economy" }, ["tokenMode"]);
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ tokenMode: "full" }));
|
||||
profiles = patchComposerProfile(
|
||||
profiles,
|
||||
"tab-1",
|
||||
profiles["tab-1"],
|
||||
{ collaborationMode: "normal", goalDraftMode: false, goal: "", toolApprovalMode: "auto" },
|
||||
["toolApprovalMode"],
|
||||
);
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ toolApprovalMode: "ask" }));
|
||||
|
||||
eq(profiles["tab-1"].tokenMode, "economy", "stale meta cannot erase a pending token saver selection");
|
||||
eq(Boolean(profiles["tab-1"].pending.tokenMode), true, "token saver stays pending while meta is stale");
|
||||
eq(profiles["tab-1"].toolApprovalMode, "auto", "stale meta cannot erase a pending auto approval selection");
|
||||
eq(Boolean(profiles["tab-1"].pending.toolApprovalMode), true, "auto approval stays pending while meta is stale");
|
||||
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ tokenMode: "economy" }));
|
||||
profiles = hydrateComposerProfileFromMeta(profiles, "tab-1", meta({ toolApprovalMode: "auto" }));
|
||||
|
||||
eq(profiles["tab-1"].tokenMode, "economy", "acknowledged token saver remains enabled");
|
||||
eq(Boolean(profiles["tab-1"].pending.tokenMode), false, "token saver pending clears after matching meta");
|
||||
eq(profiles["tab-1"].toolApprovalMode, "auto", "acknowledged auto approval remains enabled");
|
||||
eq(Boolean(profiles["tab-1"].pending.toolApprovalMode), false, "auto approval pending clears after matching meta");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -149,7 +152,7 @@ console.log("\ncomposer profile");
|
||||
{
|
||||
let profiles: ComposerProfilesByTab = {};
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab(), tab({ id: "tab-2" })]);
|
||||
profiles = patchComposerProfile(profiles, "tab-2", profiles["tab-2"], { tokenMode: "economy" }, ["tokenMode"]);
|
||||
profiles = patchComposerProfile(profiles, "tab-2", profiles["tab-2"], { toolApprovalMode: "auto" }, ["toolApprovalMode"]);
|
||||
profiles = hydrateComposerProfilesFromTabs(profiles, [tab()]);
|
||||
|
||||
eq(Boolean(profiles["tab-2"]), false, "tab hydration removes profiles for closed tabs");
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -91,12 +91,11 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const calls = { cancel: 0, tokenModes: [] as TokenMode[], approvalModes: [] as ToolApprovalMode[] };
|
||||
const calls = { cancel: 0, approvalModes: [] as ToolApprovalMode[] };
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal" as CollaborationMode,
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
@@ -115,9 +114,6 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: (mode) => {
|
||||
calls.tokenModes.push(mode);
|
||||
},
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
@@ -200,45 +196,18 @@ console.log("\ncomposer run strip");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Work mode is a first-class, always-visible selector. Its three profiles live
|
||||
// in their own menu instead of the task-intent menu, and selecting a profile
|
||||
// preserves the existing token-mode callback contract.
|
||||
// Execution modes are gone. Composer keeps collaboration (direct/plan/goal)
|
||||
// and tool-approval controls; it must not render a Light/Balanced/Delivery
|
||||
// execution-setting trigger or menu.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderComposer();
|
||||
const { root } = await renderComposer();
|
||||
|
||||
const profileTrigger = document.querySelector(".composer-profile-trigger") as HTMLButtonElement | null;
|
||||
if (!profileTrigger) throw new Error("work mode trigger did not render");
|
||||
eq(profileTrigger.textContent?.trim(), "Balanced", "standalone control shows only the current profile");
|
||||
eq(profileTrigger.getAttribute("aria-label"), "Execution setting · Balanced", "execution setting trigger keeps its full accessible name");
|
||||
ok(profileTrigger.querySelector(".lucide-equal") !== null, "balanced work mode uses a simple equal icon");
|
||||
await act(async () => {
|
||||
profileTrigger.focus();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(document.querySelector('[role="tooltip"]')?.textContent, "Execution setting · Balanced: Auto planning, risk-tiered verification", "execution setting tooltip combines category, value, and summary");
|
||||
await act(async () => {
|
||||
profileTrigger.blur();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
profileTrigger.click();
|
||||
await flushTimers();
|
||||
});
|
||||
const profileMenu = document.querySelector(".composer-profile-menu");
|
||||
ok(profileMenu !== null, "standalone work mode trigger opens its own menu");
|
||||
eq(profileMenu?.querySelectorAll('[role="menuitemradio"]').length, 3, "work mode menu exposes exactly three profiles");
|
||||
|
||||
const delivery = Array.from(profileMenu?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
|
||||
.find((item) => item.textContent?.includes("Delivery"));
|
||||
if (!delivery) throw new Error("delivery work mode option did not render");
|
||||
ok(delivery.querySelector(".lucide-flag") !== null, "delivery work mode uses a simple completion flag");
|
||||
await act(async () => {
|
||||
delivery.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.tokenModes.at(-1), "delivery", "selecting delivery keeps the token-mode callback contract");
|
||||
eq(document.querySelector(".composer-profile-trigger"), null, "composer has no execution-setting trigger");
|
||||
eq(document.querySelector(".composer-profile-menu"), null, "composer has no execution-setting menu");
|
||||
const chrome = document.body.textContent ?? "";
|
||||
eq(chrome.includes("Execution setting"), false, "composer chrome does not mention execution setting");
|
||||
eq(chrome.includes("Delivery"), false, "composer chrome does not mention Delivery mode");
|
||||
|
||||
const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
|
||||
if (!intentTrigger) throw new Error("task intent trigger did not render");
|
||||
@@ -246,7 +215,7 @@ console.log("\ncomposer run strip");
|
||||
intentTrigger.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(document.querySelector(".composer-intent-menu")?.textContent?.includes("Work mode"), false, "task-intent menu no longer owns work mode");
|
||||
eq(document.querySelector(".composer-intent-menu")?.textContent?.includes("Work mode"), false, "task-intent menu does not own a work-mode section");
|
||||
eq(document.querySelectorAll('.composer-intent-menu [role="menuitemradio"]').length, 3, "task method menu exposes direct, plan, and goal");
|
||||
|
||||
await act(async () => {
|
||||
@@ -263,7 +232,7 @@ console.log("\ncomposer run strip");
|
||||
const timers = installWindowTimerQueue();
|
||||
const { root } = await renderComposer({ showContextWindowRing: true });
|
||||
|
||||
for (const selector of [".composer-task-mode-trigger", ".composer-profile-trigger"]) {
|
||||
for (const selector of [".composer-task-mode-trigger"]) {
|
||||
const trigger = document.querySelector(selector) as HTMLButtonElement | null;
|
||||
if (!trigger) throw new Error(`missing Creation hover trigger: ${selector}`);
|
||||
|
||||
@@ -276,7 +245,7 @@ console.log("\ncomposer run strip");
|
||||
|
||||
ok(!trigger.classList.contains(`${selector.slice(1)}--open`), `${selector} stays visually closed after a short hover`);
|
||||
ok(
|
||||
document.querySelector(selector.includes("task") ? ".composer-intent-menu" : ".composer-profile-menu") === null,
|
||||
document.querySelector(".composer-intent-menu") === null,
|
||||
`${selector} does not render a closing-only menu`,
|
||||
);
|
||||
}
|
||||
@@ -297,16 +266,15 @@ console.log("\ncomposer run strip");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Runtime controller transitions disable every mode axis and submit together,
|
||||
// so rapid Goal + Delivery + approval-mode clicks cannot mutate a half-rebuilt runtime.
|
||||
// Runtime controller transitions disable collaboration, approval, and submit
|
||||
// together, so rapid Goal + approval-mode clicks cannot mutate a half-rebuilt runtime.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root } = await renderComposer({ disabled: true, goal: "ship it", collaborationMode: "goal" });
|
||||
const profile = document.querySelector<HTMLButtonElement>(".composer-profile-trigger");
|
||||
const task = document.querySelector<HTMLButtonElement>(".composer-task-mode-trigger");
|
||||
const approvals = Array.from(document.querySelectorAll<HTMLButtonElement>(".composer-modebar--approval button"));
|
||||
const send = document.querySelector<HTMLButtonElement>(".composer__btn--send");
|
||||
ok(Boolean(profile?.disabled), "runtime transition disables Delivery profile changes");
|
||||
eq(document.querySelector(".composer-profile-trigger"), null, "runtime transition has no execution-setting control");
|
||||
ok(Boolean(task?.disabled), "runtime transition disables Goal mode changes");
|
||||
ok(approvals.length === 3 && approvals.every((button) => button.disabled), "runtime transition disables Ask/Auto/Yolo changes");
|
||||
ok(Boolean(send?.disabled), "runtime transition disables submit");
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
selectedTextSnippet,
|
||||
} from "../lib/selectedTextContext";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -125,7 +125,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
@@ -141,7 +141,7 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
|
||||
+8
-8
@@ -1,4 +1,4 @@
|
||||
// Run: tsx src/__tests__/delivery-worktree.test.ts
|
||||
// Run: tsx src/__tests__/isolated-worktree.test.ts
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -20,13 +20,13 @@ function ok(value: unknown, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\ndelivery worktree");
|
||||
ok(/DeliveryWorktreeAvailability\(workspaceRoot: string\)/.test(bridge), "bridge exposes non-mutating availability probe");
|
||||
ok(/CreateDeliveryWorktree\(workspaceRoot: string\)/.test(bridge), "bridge exposes isolated workspace creation");
|
||||
ok(/app\.DeliveryWorktreeAvailability\(projectRoot\)/.test(tree), "project menu probes Git before enabling isolation");
|
||||
console.log("\nisolated worktree");
|
||||
ok(/IsolatedWorktreeAvailability\(workspaceRoot: string\)/.test(bridge), "bridge exposes non-mutating availability probe");
|
||||
ok(/CreateIsolatedWorktree\(workspaceRoot: string\)/.test(bridge), "bridge exposes isolated workspace creation");
|
||||
ok(/app\.IsolatedWorktreeAvailability\(projectRoot\)/.test(tree), "project menu probes Git before enabling isolation");
|
||||
ok(/disabled: isolatingProject !== null \|\| isolationAvailability\?\.available === false/.test(tree), "menu disables unavailable or duplicate creation");
|
||||
ok(/onCreateDeliveryWorktree\?\.\(workspaceRoot\)/.test(tree), "project menu delegates isolated workspace creation");
|
||||
ok(/kind: "delivery-worktree"/.test(app) && /enqueueNavigation\(\{ kind: "delivery-worktree"/.test(app), "creation shares the last-click-wins navigation queue");
|
||||
ok(/onCreateIsolatedWorktree\?\.\(workspaceRoot\)/.test(tree), "project menu delegates isolated workspace creation");
|
||||
ok(/kind: "isolated-worktree"/.test(app) && /enqueueNavigation\(\{ kind: "isolated-worktree"/.test(app), "creation shares the last-click-wins navigation queue");
|
||||
ok(/sourceDirty[\s\S]*worktreeCreatedDirty/.test(app), "dirty source checkout receives an explicit warning");
|
||||
ok(/isolatedWorktree && <WorktreeBadge/.test(tabs), "tab strip identifies isolated worktrees");
|
||||
ok(/activeTab\?\.isolatedWorktree && <WorktreeBadge/.test(app), "topic bar identifies isolated worktrees");
|
||||
@@ -34,4 +34,4 @@ ok(/node\.isolatedWorktree && <WorktreeBadge/.test(tree), "project tree identifi
|
||||
ok(/GitBranch/.test(badge) && /#6119/.test(badge), "shared badge preserves the credited #6119 design contribution");
|
||||
|
||||
if (failed) process.exit(1);
|
||||
console.log("delivery worktree tests passed");
|
||||
console.log("isolated worktree tests passed");
|
||||
@@ -293,19 +293,14 @@ eq(
|
||||
"collaboration mode changes always reconcile the remembered plan restore intent",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("runtimeTransitionTabsRef.current.has(tabId)"),
|
||||
!appSource.includes("runtimeTransitionTabsRef") && !appSource.includes("pending.tokenMode"),
|
||||
true,
|
||||
"runtime profile transitions reject rapid duplicate switches for one tab",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("delete pending.tokenMode") && appSource.includes("tokenMode: previous"),
|
||||
true,
|
||||
"failed runtime profile transitions roll back the optimistic token mode",
|
||||
"execution-mode switch state is gone from the app shell",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("!state.backendActivationPending &&") && appSource.includes("!runtimeTransitioning"),
|
||||
true,
|
||||
"runtime profile transitions keep submit behind the controller-ready gate",
|
||||
"composer submit stays behind the controller-ready gate",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("activateGoalAndSubmitOnTab({") &&
|
||||
|
||||
@@ -149,6 +149,7 @@ ok(
|
||||
source.includes('"settings.reasoningDisplay.hidden"') &&
|
||||
source.includes('"settings.reasoningDisplay.summary"') &&
|
||||
source.includes('"settings.reasoningDisplay.auto"') &&
|
||||
source.includes('"settings.reasoningDisplay.expanded"') &&
|
||||
source.includes('"settings.processFold"'),
|
||||
),
|
||||
"conversation-content display group labels are localized in every supported locale",
|
||||
|
||||
@@ -239,10 +239,6 @@ ok(
|
||||
);
|
||||
|
||||
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
|
||||
eq(finalDeclaration(".composer-meta__control--profile", "flex"), "0 0 auto", "work mode selector sizes to its localized label");
|
||||
eq(finalDeclaration(".composer-meta__control--profile", "max-width"), "68px", "work mode selector keeps a compact narrow-width bound");
|
||||
eq(finalDeclaration(".composer-profile-trigger__label", "overflow"), "hidden", "work mode selector clips only when space is constrained");
|
||||
eq(finalDeclaration(".composer-profile-trigger__label", "text-overflow"), "ellipsis", "work mode selector shows an ellipsis when constrained");
|
||||
eq(finalDeclaration(".composer-meta__control--intent", "max-width"), "72px", "task method selector keeps its current state visible at narrow widths");
|
||||
eq(finalDeclaration(".composer-task-mode-trigger__value", "text-overflow"), "ellipsis", "task method selector truncates its value only when constrained");
|
||||
eq(finalDeclaration(".composer-meta .modelsw__trigger", "font-weight"), "var(--composer-control-font-weight)", "model selector uses the shared control weight");
|
||||
@@ -259,14 +255,9 @@ eq(finalDeclaration(".composer-modebar--approval", "--composer-modebar-active-bg
|
||||
eq(finalDeclaration('.composer-modebar--approval[data-mode="auto"]', "--composer-modebar-active-fg"), "#fff", "auto approval keeps high-contrast text on its solid fill");
|
||||
eq(finalDeclaration('.composer-modebar--approval[data-mode="yolo"]', "--composer-modebar-active-bg"), "var(--mode-yolo-bg)", "yolo approval restores the solid warning fill");
|
||||
eq(finalDeclaration(".composer-intent-menu", "width"), "min(284px, calc(100vw - 16px))", "task method menu uses the shared menu width");
|
||||
eq(finalDeclaration(".composer-profile-menu", "width"), "min(284px, calc(100vw - 16px))", "work mode menu uses the shared menu width");
|
||||
eq(finalDeclaration(".composer-access-menu__desc", "white-space"), "normal", "menu descriptions can wrap onto a second line");
|
||||
eq(finalDeclaration(".composer-access-menu__desc", "text-overflow"), "clip", "menu descriptions no longer use single-line ellipsis");
|
||||
eq(finalDeclaration(".composer-profile-menu .composer-access-menu__desc", "font-size"), "12px", "work mode summaries use the shared control text size");
|
||||
eq(finalDeclaration(".composer-profile-menu .composer-access-menu__desc", "color"), "var(--fg-dim)", "work mode summaries remain readable as secondary text");
|
||||
eq(finalDeclaration(".composer-profile-menu .composer-access-menu__desc", "white-space"), "nowrap", "work mode summaries stay on one scannable line");
|
||||
eq(finalDeclaration(".composer-task-mode-trigger:focus-visible", "box-shadow"), "var(--focus-ring)", "task method selector uses the shared keyboard focus ring");
|
||||
eq(finalDeclaration(".composer-profile-trigger:focus-visible", "box-shadow"), "var(--focus-ring)", "work mode selector uses the shared keyboard focus ring");
|
||||
eq(finalDeclaration(".composer-meta .modelsw__trigger:focus-visible", "box-shadow"), "var(--focus-ring)", "model and effort selectors use the shared keyboard focus ring");
|
||||
eq(finalDeclaration(":root[data-theme-style] .composer-modebar__item--active:focus-visible", "box-shadow"), "var(--focus-ring)", "active permission options retain keyboard focus feedback");
|
||||
eq(
|
||||
@@ -399,7 +390,7 @@ eq(
|
||||
"collapsed creation tool bodies do not paint hidden tool text",
|
||||
);
|
||||
ok(
|
||||
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta__control--profile\s*\{[\s\S]*?max-width\s*:\s*126px[\s\S]*?\.composer-meta__control--intent\s*\{[\s\S]*?max-width\s*:\s*128px[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
|
||||
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta__control--intent\s*\{[\s\S]*?max-width\s*:\s*128px[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
|
||||
"composer compact controls activate at the capped theme width",
|
||||
);
|
||||
eq(finalDeclaration(".md-table-scroll", "overflow-x"), "auto", "markdown table wrapper scrolls horizontally");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/use-controller-meta.test.ts
|
||||
|
||||
import { currentTurnWaitMs, effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, localizedNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, tokenModeSwitchNoticeText, type Item } from "../lib/useController";
|
||||
import { currentTurnWaitMs, effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, localizedNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, type Item } from "../lib/useController";
|
||||
import { shouldReconcileStaleTurn } from "../lib/useStaleTurnWatchdog";
|
||||
import { parseTodos } from "../lib/tools";
|
||||
import { resolveTodoPanelTodos } from "../lib/todoVisibility";
|
||||
@@ -106,11 +106,6 @@ console.log("\nuse controller meta");
|
||||
"Reasoning effort cannot change while the current answer is running. Stop it first.",
|
||||
"effort busy guard names the running-answer blocker",
|
||||
);
|
||||
eq(
|
||||
tokenModeSwitchNoticeText("active work is still running; running=true; pending_prompt=true; background_jobs=0; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
|
||||
"Execution setting cannot change while a prompt is waiting for your response. Handle it first.",
|
||||
"execution-setting busy guard prioritizes the pending prompt blocker",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
|
||||
"The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
@@ -171,19 +166,6 @@ console.log("\nuse controller meta");
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(
|
||||
tokenModeSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
|
||||
"Execution setting cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"execution-setting busy guard is localized",
|
||||
);
|
||||
eq(
|
||||
tokenModeSwitchNoticeText('tab "tab-a" changed while switching token mode; retry'),
|
||||
"The current session changed while switching execution setting. Try once more.",
|
||||
"execution-setting tab race asks the user to retry",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(
|
||||
localizedBackendNoticeText("Session autosave failed: disk full"),
|
||||
|
||||
@@ -249,7 +249,7 @@ console.log("\nworkspace changes git errors");
|
||||
);
|
||||
await waitFor("turn verification summary", () => document.body.textContent?.includes("Turn verification") === true);
|
||||
const text = document.querySelector(".workspace-completion-summary")?.textContent ?? "";
|
||||
ok(text.includes("Balanced") && text.includes("Partially complete"), "change panel localizes preset and verdict");
|
||||
ok(text.includes("Partially complete"), "change panel localizes the completion verdict");
|
||||
ok(text.includes("1 checks failed") && text.includes("2 checks skipped"), "change panel shows detailed check counts on demand");
|
||||
ok(text.includes("stale checks") && text.includes("Other"), "change panel uses safe labels for known and unknown gaps");
|
||||
ok(text.includes("Turn verification limited"), "change panel explains constrained verification without exposing an internal flag");
|
||||
|
||||
@@ -245,8 +245,7 @@ eq(
|
||||
eq(
|
||||
/\.composer-meta__control--approval \{[\s\S]*?margin-inline-start: 2px;/.test(stylesSource)
|
||||
&& /\.composer-modebar__item:hover:not\(:disabled\) \{[\s\S]*?transform: none;/.test(stylesSource)
|
||||
&& /\.composer-task-mode-trigger:hover:not\(:disabled\),[\s\S]*?\.composer-task-mode-trigger--open \{[\s\S]*?transform: none;/.test(stylesSource)
|
||||
&& /\.composer-profile-trigger:hover:not\(:disabled\),[\s\S]*?\.composer-profile-trigger--open \{[\s\S]*?transform: none;/.test(stylesSource),
|
||||
&& /\.composer-task-mode-trigger:hover:not\(:disabled\),[\s\S]*?\.composer-task-mode-trigger--open \{[\s\S]*?transform: none;/.test(stylesSource),
|
||||
true,
|
||||
"composer mode controls keep spacing and icon baselines stable on hover",
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { ArrowRight, ArrowUp, AtSign, Check, ChevronsUpDown, CornerDownRight, Equal, Eye, FilePlus2, FileText, Flag, Folder, Gauge, Hash, List, MessageSquare, Plus, Search, Shield, ShieldAlert, ShieldCheck, Square, Target, Trash2, X } from "lucide-react";
|
||||
import { ArrowRight, ArrowUp, AtSign, Check, ChevronsUpDown, CornerDownRight, Eye, FilePlus2, FileText, Folder, Gauge, Hash, List, MessageSquare, Plus, Search, Shield, ShieldAlert, ShieldCheck, Square, Target, Trash2, X } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { filterAtMatches } from "../lib/atMatches";
|
||||
import { DedupIndex, sha256 } from "../lib/attachDedup";
|
||||
@@ -33,7 +33,7 @@ import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/
|
||||
import { createRafResizeUpdater } from "../lib/resizeDrag";
|
||||
import { observeComposerMenuViewport } from "../lib/composerMenuViewport";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type ContextInfo, type DirEntry, type EffortInfo, type GoalRuntime, type HistoryMessage, type Mode, type PromptHistoryEntry, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type TokenMode, type ToolApprovalMode, type BalanceInfo } from "../lib/types";
|
||||
import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type ContextInfo, type DirEntry, type EffortInfo, type GoalRuntime, type HistoryMessage, type Mode, type PromptHistoryEntry, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type ToolApprovalMode, type BalanceInfo } from "../lib/types";
|
||||
import {
|
||||
formatWorkspaceReference,
|
||||
parseWorkspaceReference,
|
||||
@@ -498,7 +498,6 @@ export function Composer({
|
||||
running,
|
||||
collaborationMode,
|
||||
toolApprovalMode,
|
||||
tokenMode,
|
||||
turnPhase,
|
||||
goal,
|
||||
goalStatus,
|
||||
@@ -521,7 +520,6 @@ export function Composer({
|
||||
onResumeGoal,
|
||||
onSwitchModel,
|
||||
onSetEffort,
|
||||
onSetTokenMode,
|
||||
insertRequest,
|
||||
selectedTextRequest,
|
||||
disabled,
|
||||
@@ -564,7 +562,6 @@ export function Composer({
|
||||
running: boolean;
|
||||
collaborationMode: CollaborationMode;
|
||||
toolApprovalMode: ToolApprovalMode;
|
||||
tokenMode: TokenMode;
|
||||
/** Host turn phase: working | checking | verifying | reviewing */
|
||||
turnPhase?: string;
|
||||
goal?: string;
|
||||
@@ -591,7 +588,6 @@ export function Composer({
|
||||
onResumeGoal: () => void;
|
||||
onSwitchModel: (name: string) => boolean | Promise<boolean>;
|
||||
onSetEffort: (level: string) => void;
|
||||
onSetTokenMode: (mode: TokenMode) => void;
|
||||
insertRequest?: ComposerInsertRequest | null;
|
||||
selectedTextRequest?: SelectedTextInsertRequest | null;
|
||||
disabled?: boolean;
|
||||
@@ -646,7 +642,7 @@ export function Composer({
|
||||
guidanceQueuePreviewItems?: readonly string[];
|
||||
showContextWindowRing?: boolean;
|
||||
// Creation empty-session hero: slim centered composer under the welcome
|
||||
// headline (hides task/profile/approval chrome; keeps model + effort).
|
||||
// headline (hides task/approval chrome; keeps model + effort).
|
||||
heroMode?: boolean;
|
||||
context?: ContextInfo;
|
||||
turnCost?: number;
|
||||
@@ -697,8 +693,6 @@ export function Composer({
|
||||
const [textareaAutoOverflow, setTextareaAutoOverflow] = useState(false);
|
||||
const [intentMenuOpen, setIntentMenuOpen] = useState(false);
|
||||
const [intentMenuClosing, setIntentMenuClosing] = useState(false);
|
||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const [profileMenuClosing, setProfileMenuClosing] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [moreMenuClosing, setMoreMenuClosing] = useState(false);
|
||||
const [contentMenuOpen, setContentMenuOpen] = useState(false);
|
||||
@@ -740,14 +734,11 @@ export function Composer({
|
||||
const composerWrapRef = useRef<HTMLDivElement>(null);
|
||||
const contentMenuAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const intentMenuAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const profileMenuAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const moreMenuAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const intentCloseTimerRef = useRef<number | null>(null);
|
||||
const profileCloseTimerRef = useRef<number | null>(null);
|
||||
const moreCloseTimerRef = useRef<number | null>(null);
|
||||
// Creation chrome: hover-open task/profile menus (same pattern as ContextWindowRing).
|
||||
// Creation chrome: hover-open task menus (same pattern as ContextWindowRing).
|
||||
const intentHoverTimerRef = useRef<number | null>(null);
|
||||
const profileHoverTimerRef = useRef<number | null>(null);
|
||||
const creationChrome = showContextWindowRing;
|
||||
const wasRunningByDraftRef = useRef<Record<string, boolean>>({ [draftKey]: running });
|
||||
const composingRef = useRef(false);
|
||||
@@ -1848,12 +1839,6 @@ export function Composer({
|
||||
intentCloseTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearProfileCloseTimer = useCallback(() => {
|
||||
if (profileCloseTimerRef.current === null) return;
|
||||
window.clearTimeout(profileCloseTimerRef.current);
|
||||
profileCloseTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Hover timers only touch refs — no useCallback cross-deps (avoids TDZ on HMR).
|
||||
const clearHoverTimer = (timerRef: { current: number | null }) => {
|
||||
if (timerRef.current == null) return;
|
||||
@@ -1864,13 +1849,6 @@ export function Composer({
|
||||
const openIntentMenu = useCallback(() => {
|
||||
clearIntentCloseTimer();
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
if (profileCloseTimerRef.current != null) {
|
||||
window.clearTimeout(profileCloseTimerRef.current);
|
||||
profileCloseTimerRef.current = null;
|
||||
}
|
||||
setProfileMenuOpen(false);
|
||||
setProfileMenuClosing(false);
|
||||
setContentMenuOpen(false);
|
||||
setDirectPastChats(false);
|
||||
setDismissed(true);
|
||||
@@ -1891,42 +1869,10 @@ export function Composer({
|
||||
}, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
|
||||
}, [clearIntentCloseTimer]);
|
||||
|
||||
const openProfileMenu = useCallback(() => {
|
||||
clearProfileCloseTimer();
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
if (intentCloseTimerRef.current != null) {
|
||||
window.clearTimeout(intentCloseTimerRef.current);
|
||||
intentCloseTimerRef.current = null;
|
||||
}
|
||||
setIntentMenuOpen(false);
|
||||
setIntentMenuClosing(false);
|
||||
setContentMenuOpen(false);
|
||||
setDirectPastChats(false);
|
||||
setDismissed(true);
|
||||
setProfileMenuClosing(false);
|
||||
setProfileMenuOpen(true);
|
||||
}, [clearProfileCloseTimer]);
|
||||
|
||||
const closeProfileMenu = useCallback((afterClose?: () => void) => {
|
||||
clearProfileCloseTimer();
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
setProfileMenuClosing(true);
|
||||
window.requestAnimationFrame(() => setProfileMenuOpen(false));
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
profileCloseTimerRef.current = window.setTimeout(() => {
|
||||
profileCloseTimerRef.current = null;
|
||||
setProfileMenuClosing(false);
|
||||
afterClose?.();
|
||||
}, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
|
||||
}, [clearProfileCloseTimer]);
|
||||
|
||||
useEffect(() => () => {
|
||||
clearIntentCloseTimer();
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
clearProfileCloseTimer();
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
}, [clearIntentCloseTimer, clearProfileCloseTimer]);
|
||||
}, [clearIntentCloseTimer]);
|
||||
|
||||
const onIntentHoverEnter = useCallback(() => {
|
||||
if (!creationChrome || disabled || running) return;
|
||||
@@ -1952,30 +1898,6 @@ export function Composer({
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
}, [creationChrome]);
|
||||
|
||||
const onProfileHoverEnter = useCallback(() => {
|
||||
if (!creationChrome || disabled || running) return;
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
profileHoverTimerRef.current = window.setTimeout(() => {
|
||||
profileHoverTimerRef.current = null;
|
||||
openProfileMenu();
|
||||
}, 120);
|
||||
}, [creationChrome, disabled, openProfileMenu, running]);
|
||||
|
||||
const onProfileHoverLeave = useCallback(() => {
|
||||
if (!creationChrome) return;
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
if (!profileMenuOpen && !profileMenuClosing) return;
|
||||
profileHoverTimerRef.current = window.setTimeout(() => {
|
||||
profileHoverTimerRef.current = null;
|
||||
closeProfileMenu();
|
||||
}, 140);
|
||||
}, [closeProfileMenu, creationChrome, profileMenuClosing, profileMenuOpen]);
|
||||
|
||||
const onProfilePopoverEnter = useCallback(() => {
|
||||
if (!creationChrome) return;
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
}, [creationChrome]);
|
||||
|
||||
const clearMoreCloseTimer = useCallback(() => {
|
||||
if (moreCloseTimerRef.current === null) return;
|
||||
window.clearTimeout(moreCloseTimerRef.current);
|
||||
@@ -3171,7 +3093,6 @@ export function Composer({
|
||||
|
||||
const openContentMenu = () => {
|
||||
if (intentMenuOpen || intentMenuClosing) closeIntentMenu();
|
||||
if (profileMenuOpen || profileMenuClosing) closeProfileMenu();
|
||||
if (moreMenuOpen || moreMenuClosing) closeMoreMenu();
|
||||
setDirectPastChats(false);
|
||||
setShowPastChats(false);
|
||||
@@ -3617,29 +3538,6 @@ export function Composer({
|
||||
requestActiveDraftFrame(focusComposerInput);
|
||||
});
|
||||
};
|
||||
const chooseTokenMode = (mode: TokenMode) => {
|
||||
closeProfileMenu(() => {
|
||||
if (mode !== tokenMode) onSetTokenMode(mode);
|
||||
requestActiveDraftFrame(focusComposerInput);
|
||||
});
|
||||
};
|
||||
const runtimeProfileShortKey = tokenMode === "economy"
|
||||
? "composer.runtimeProfileEconomyShort"
|
||||
: tokenMode === "delivery"
|
||||
? "composer.runtimeProfileDeliveryShort"
|
||||
: "composer.runtimeProfileBalancedShort";
|
||||
const runtimeProfileTooltipSummaryKey = tokenMode === "economy"
|
||||
? "composer.runtimeProfileEconomyTooltipSummary"
|
||||
: tokenMode === "delivery"
|
||||
? "composer.runtimeProfileDeliveryTooltipSummary"
|
||||
: "composer.runtimeProfileBalancedTooltipSummary";
|
||||
const RuntimeProfileIcon = tokenMode === "economy" ? Gauge : tokenMode === "delivery" ? Flag : Equal;
|
||||
const runtimeProfileTriggerLabel = t("composer.runtimeProfileTrigger", { mode: t(runtimeProfileShortKey) });
|
||||
const runtimeProfileTooltipLabel = t("composer.controlTooltip", {
|
||||
category: t("composer.runtimeProfileTitle"),
|
||||
mode: t(runtimeProfileShortKey),
|
||||
summary: t(runtimeProfileTooltipSummaryKey),
|
||||
});
|
||||
const taskModeShortKey = collaborationMode === "plan"
|
||||
? "composer.taskModePlanShort"
|
||||
: collaborationMode === "goal"
|
||||
@@ -3723,9 +3621,8 @@ export function Composer({
|
||||
setDirectPastChats(false);
|
||||
setShowPastChats(false);
|
||||
closeIntentMenu();
|
||||
closeProfileMenu();
|
||||
closeMoreMenu();
|
||||
}, [suspendedByDecision, closeIntentMenu, closeProfileMenu, closeMoreMenu]);
|
||||
}, [suspendedByDecision, closeIntentMenu, closeMoreMenu]);
|
||||
// Live text+reasoning character count for the run-strip TPS fallback. Reads
|
||||
// through the live store's own subscription so stream deltas re-render only
|
||||
// this component — the controller's bump path stays text-delta-free.
|
||||
@@ -4061,47 +3958,6 @@ export function Composer({
|
||||
)}
|
||||
</div>
|
||||
</AnchoredPopover>}
|
||||
{!heroMode && <AnchoredPopover
|
||||
open={profileMenuOpen}
|
||||
closing={profileMenuClosing}
|
||||
anchorRef={profileMenuAnchorRef}
|
||||
onClose={() => closeProfileMenu()}
|
||||
className="composer-access-menu composer-profile-menu"
|
||||
align="start"
|
||||
>
|
||||
<div
|
||||
className="composer-access-menu__section"
|
||||
role="menu"
|
||||
aria-label={t("composer.runtimeProfileTitle")}
|
||||
onMouseEnter={creationChrome ? onProfilePopoverEnter : undefined}
|
||||
onMouseLeave={creationChrome ? onProfileHoverLeave : undefined}
|
||||
>
|
||||
<div className="composer-access-menu__label">{t("composer.runtimeProfileTitle")}</div>
|
||||
{([
|
||||
["economy", Gauge, "composer.runtimeProfileEconomy", "composer.runtimeProfileEconomyDesc"], // light wire dual-write
|
||||
["full", Equal, "composer.runtimeProfileBalanced", "composer.runtimeProfileBalancedDesc"], // balanced wire dual-write
|
||||
["delivery", Flag, "composer.runtimeProfileDelivery", "composer.runtimeProfileDeliveryDesc"],
|
||||
] as const).map(([profile, Icon, titleKey, descKey]) => (
|
||||
<button
|
||||
key={profile}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
className={`composer-access-menu__item composer-profile-menu__item${tokenMode === profile ? " composer-access-menu__item--active" : ""}`}
|
||||
onClick={() => chooseTokenMode(profile)}
|
||||
disabled={disabled || running}
|
||||
title={t(descKey)}
|
||||
aria-checked={tokenMode === profile}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.75} />
|
||||
<span className="composer-access-menu__copy">
|
||||
<span className="composer-access-menu__title">{t(titleKey)}</span>
|
||||
<span className="composer-access-menu__desc">{t(descKey)}</span>
|
||||
</span>
|
||||
{tokenMode === profile && <Check size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AnchoredPopover>}
|
||||
<AnchoredPopover
|
||||
open={moreMenuOpen && !disabled && !running}
|
||||
closing={moreMenuClosing}
|
||||
@@ -4674,32 +4530,6 @@ export function Composer({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{!heroMode && (
|
||||
<div className="composer-meta__control composer-meta__control--profile">
|
||||
<Tooltip label={runtimeProfileTooltipLabel} disabled={profileMenuOpen || profileMenuClosing || creationChrome}>
|
||||
<button
|
||||
ref={profileMenuAnchorRef}
|
||||
type="button"
|
||||
data-profile={tokenMode}
|
||||
className={`composer-profile-trigger${profileMenuOpen || profileMenuClosing ? " composer-profile-trigger--open" : ""}`}
|
||||
onClick={() => (profileMenuOpen || profileMenuClosing ? closeProfileMenu() : openProfileMenu())}
|
||||
onMouseEnter={creationChrome ? onProfileHoverEnter : undefined}
|
||||
onMouseLeave={creationChrome ? onProfileHoverLeave : undefined}
|
||||
disabled={disabled || running}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={profileMenuOpen && !profileMenuClosing}
|
||||
aria-label={runtimeProfileTriggerLabel}
|
||||
title={profileMenuOpen || profileMenuClosing || creationChrome ? undefined : runtimeProfileTriggerLabel}
|
||||
>
|
||||
<RuntimeProfileIcon size={14} strokeWidth={1.75} aria-hidden="true" />
|
||||
<span className="composer-profile-trigger__label">
|
||||
<span className="composer-profile-trigger__value">{t(runtimeProfileShortKey)}</span>
|
||||
</span>
|
||||
<ChevronsUpDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{!heroMode && (
|
||||
<div className="composer-meta__control composer-meta__control--approval">
|
||||
{/* A pending tool approval disables the composer, but the approval
|
||||
|
||||
@@ -35,7 +35,7 @@ interface ProjectTreeProps {
|
||||
onOpenTopic: (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string) => Promise<void> | void;
|
||||
onAddProject: (path?: string) => Promise<void>;
|
||||
onCreateTopic?: (scope: string, workspaceRoot: string) => Promise<void> | void;
|
||||
onCreateDeliveryWorktree?: (workspaceRoot: string) => Promise<void> | void;
|
||||
onCreateIsolatedWorktree?: (workspaceRoot: string) => Promise<void> | void;
|
||||
onRenameTopic?: (topicId: string, title: string) => Promise<void> | void;
|
||||
onTopicsChanged?: () => Promise<void> | void;
|
||||
refreshSignal?: number;
|
||||
@@ -365,7 +365,7 @@ export function ProjectTree({
|
||||
onOpenTopic,
|
||||
onAddProject,
|
||||
onCreateTopic,
|
||||
onCreateDeliveryWorktree,
|
||||
onCreateIsolatedWorktree,
|
||||
onRenameTopic,
|
||||
onTopicsChanged,
|
||||
refreshSignal,
|
||||
@@ -848,12 +848,12 @@ export function ProjectTree({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDeliveryWorktree = async (workspaceRoot: string) => {
|
||||
const handleCreateIsolatedWorktree = async (workspaceRoot: string) => {
|
||||
if (!workspaceRoot || isolatingProject) return;
|
||||
setIsolatingProject(workspaceRoot);
|
||||
closeMenu();
|
||||
try {
|
||||
await onCreateDeliveryWorktree?.(workspaceRoot);
|
||||
await onCreateIsolatedWorktree?.(workspaceRoot);
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
|
||||
} finally {
|
||||
@@ -1484,7 +1484,7 @@ export function ProjectTree({
|
||||
setMenuProject({ key, root: projectRoot, path: projectPath, scope, label: projectLabel });
|
||||
setConfirmRemoveProject(null);
|
||||
if (scope === "project" && projectRoot) {
|
||||
void app.DeliveryWorktreeAvailability(projectRoot).then((availability) => {
|
||||
void app.IsolatedWorktreeAvailability(projectRoot).then((availability) => {
|
||||
setWorktreeAvailability((current) => ({
|
||||
...current,
|
||||
[projectRoot]: { available: availability.available, reason: availability.reason },
|
||||
@@ -1503,7 +1503,7 @@ export function ProjectTree({
|
||||
</span>
|
||||
),
|
||||
disabled: isolatingProject !== null || isolationAvailability?.available === false,
|
||||
onSelect: () => { void handleCreateDeliveryWorktree(projectRoot); },
|
||||
onSelect: () => { void handleCreateIsolatedWorktree(projectRoot); },
|
||||
}]
|
||||
: [];
|
||||
const projectMenuItems: ContextMenuItem[] = [
|
||||
|
||||
@@ -67,7 +67,6 @@ import type {
|
||||
import { workspaceGitStatusLabel } from "../lib/workspaceChanges";
|
||||
import {
|
||||
completionGapLabel,
|
||||
completionPresetLabel,
|
||||
completionReviewLabel,
|
||||
completionVerdictLabel,
|
||||
} from "../lib/completionSummaryDisplay";
|
||||
@@ -1707,7 +1706,6 @@ export function WorkspacePanel({
|
||||
>
|
||||
<div className="workspace-completion-summary__head">
|
||||
<strong>{t("completion.panelTitle")}</strong>
|
||||
<span>{completionPresetLabel(completionSummary.preset, t)}</span>
|
||||
<span>{completionVerdictLabel(completionSummary.verdict, t)}</span>
|
||||
</div>
|
||||
<div className="workspace-completion-summary__metrics">
|
||||
|
||||
@@ -13,7 +13,7 @@ import { t } from "./i18n";
|
||||
import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels";
|
||||
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems";
|
||||
import { registerTrustedThemeBackgroundURLs } from "./themePack";
|
||||
import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types";
|
||||
import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeToolApprovalMode } from "./types";
|
||||
import { decisionSurfaceMockFromInput, isLongDecisionOptionsMockInput } from "./decisionSurfaceMock";
|
||||
import { mockWorkspaceFile } from "./mockWorkspaceFile";
|
||||
import type {
|
||||
@@ -454,10 +454,6 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
|
||||
SetEffort(level: string): Promise<void>;
|
||||
EffortForTab(tabID: string): Promise<EffortInfo>;
|
||||
SetEffortForTab(tabID: string, level: string): Promise<void>;
|
||||
SetTokenMode(mode: string): Promise<void>;
|
||||
SetTokenModeForTab(tabID: string, mode: string): Promise<void>;
|
||||
SetAgentPreset(preset: string): Promise<void>;
|
||||
SetAgentPresetForTab(tabID: string, preset: string): Promise<void>;
|
||||
// ReloadRuntime rebuilds the tab's agent runtime in place (tools, skills,
|
||||
// commands, hooks, providers, MCP servers) via boot.Rebuild, keeping the
|
||||
// session. Busy tabs queue one reload for when they go idle.
|
||||
@@ -591,8 +587,15 @@ export interface AppBindings extends SessionCatalogBindings, HistoryCatalogBindi
|
||||
RecordUIPerf(signals: Record<string, string>): Promise<void>;
|
||||
ListTabs(): Promise<TabMeta[]>;
|
||||
OpenProjectTab(workspaceRoot: string, topicID: string): Promise<TabMeta>;
|
||||
IsolatedWorktreeAvailability(workspaceRoot: string): Promise<DeliveryWorktreeAvailability>;
|
||||
CreateIsolatedWorktree(workspaceRoot: string): Promise<DeliveryWorktreeOpenResult>;
|
||||
// Deprecated one-version aliases kept bound for older desktop clients.
|
||||
DeliveryWorktreeAvailability(workspaceRoot: string): Promise<DeliveryWorktreeAvailability>;
|
||||
CreateDeliveryWorktree(workspaceRoot: string): Promise<DeliveryWorktreeOpenResult>;
|
||||
SetAgentPreset(preset: string): Promise<void>;
|
||||
SetAgentPresetForTab(tabID: string, preset: string): Promise<void>;
|
||||
SetTokenMode(mode: string): Promise<void>;
|
||||
SetTokenModeForTab(tabID: string, mode: string): Promise<void>;
|
||||
OpenGlobalTab(topicID: string): Promise<TabMeta>;
|
||||
OpenTopicSession(scope: string, workspaceRoot: string, topicID: string, sessionPath: string): Promise<TabMeta>;
|
||||
EnsureBlankTab(scope: string, workspaceRoot: string): Promise<TabMeta>;
|
||||
@@ -1025,7 +1028,7 @@ function bridgeBreadcrumb(method: string): string {
|
||||
if (method === "ReportCrash" || method === "RecordUIPerf") return "";
|
||||
if (/^(Submit|SubmitDisplay|RunShell|Steer|Cancel|Approve|AnswerQuestion|ReplayPendingPrompts)/.test(method))
|
||||
return `turn ${method}`;
|
||||
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort|SetMaxSubagentDepth|SetMaxSubagentConcurrency|SetMaxParallelWriters)/.test(method))
|
||||
if (/^(SetModel|SetEffort|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort|SetMaxSubagentDepth|SetMaxSubagentConcurrency|SetMaxParallelWriters)/.test(method))
|
||||
return `model ${method}`;
|
||||
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetReasoningDisplayMode|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetCompactRatio|SetReasoningLanguage)/.test(method))
|
||||
return `settings ${method}`;
|
||||
@@ -1037,7 +1040,7 @@ function bridgeBreadcrumb(method: string): string {
|
||||
if (/^(AddSkillPath|RemoveSkillPath|SetSkillPathEnabled|RefreshSkills|SetSkillEnabled|SetSkillImplicitInvocation|AcceptSkillSuggestion|AvailableSubagentTools|CreateSubagentProfile|UpdateSubagentProfile|DeleteSubagentProfile|SetSubagentProfileModel|SetSubagentProfileEffort|TrySubagentProfile|CancelTrySubagentProfile)/.test(method))
|
||||
return `skill ${method}`;
|
||||
if (/^(MinimiseMainWindow|ToggleMaximiseMainWindow|IsMainWindowMaximised|CloseMainWindow)$/.test(method)) return `window ${method}`;
|
||||
if (/^(OpenProjectTab|OpenGlobalTab|OpenTopicSession|EnsureBlankTab|ActivateTopic|StartTopicActivation|EnsureBlankSurface|SetActiveTab|CloseTab|ReorderTabs|CreateTopic|RenameTopic|DeleteTopic|TrashTopic|RenameProject|RemoveWorkspace|SwitchWorkspace|PickWorkspace|DeliveryWorktreeAvailability|CreateDeliveryWorktree)/.test(method))
|
||||
if (/^(OpenProjectTab|OpenGlobalTab|OpenTopicSession|EnsureBlankTab|ActivateTopic|StartTopicActivation|EnsureBlankSurface|SetActiveTab|CloseTab|ReorderTabs|CreateTopic|RenameTopic|DeleteTopic|TrashTopic|RenameProject|RemoveWorkspace|SwitchWorkspace|PickWorkspace|IsolatedWorktreeAvailability|CreateIsolatedWorktree|DeliveryWorktreeAvailability|CreateDeliveryWorktree)/.test(method))
|
||||
return `nav ${method}`;
|
||||
return "";
|
||||
}
|
||||
@@ -3487,7 +3490,6 @@ function makeMockApp(): AppBindings {
|
||||
bypass: autoApproveTools,
|
||||
collaborationMode,
|
||||
toolApprovalMode,
|
||||
tokenMode: normalizeTokenMode(active?.tokenMode),
|
||||
goal: active?.goal ?? "",
|
||||
goalStatus: active?.goalStatus ?? (active?.goal ? "running" : "stopped"),
|
||||
};
|
||||
@@ -3512,7 +3514,6 @@ function makeMockApp(): AppBindings {
|
||||
bypass: autoApproveTools,
|
||||
collaborationMode,
|
||||
toolApprovalMode,
|
||||
tokenMode: normalizeTokenMode(tab?.tokenMode),
|
||||
goal: tab?.goal ?? "",
|
||||
goalStatus: tab?.goalStatus ?? (tab?.goal ? "running" : "stopped"),
|
||||
};
|
||||
@@ -4199,20 +4200,6 @@ function makeMockApp(): AppBindings {
|
||||
async SetEffortForTab(_tabID, level) {
|
||||
await this.SetEffort(level);
|
||||
},
|
||||
async SetTokenMode(mode: string) {
|
||||
await this.SetAgentPreset(mode);
|
||||
},
|
||||
async SetTokenModeForTab(tabID, mode) {
|
||||
await this.SetAgentPresetForTab(tabID, mode);
|
||||
},
|
||||
async SetAgentPreset(preset: string) {
|
||||
const active = mockTabs.find((tab) => tab.active);
|
||||
if (active) await this.SetAgentPresetForTab(active.id, preset);
|
||||
},
|
||||
async SetAgentPresetForTab(tabID, preset) {
|
||||
const tokenMode = normalizeTokenMode(preset);
|
||||
mockTabs = mockTabs.map((tab) => (tab.id === tabID ? { ...tab, tokenMode } : tab));
|
||||
},
|
||||
async ReloadRuntime(_tabID) {},
|
||||
async Memory() {
|
||||
return {
|
||||
@@ -5052,19 +5039,26 @@ function makeMockApp(): AppBindings {
|
||||
mockTabs = [...mockTabs.map((item) => ({ ...item, active: false })), tab];
|
||||
return { ...tab };
|
||||
},
|
||||
async DeliveryWorktreeAvailability(workspaceRoot: string) {
|
||||
async IsolatedWorktreeAvailability(workspaceRoot: string) {
|
||||
return workspaceRoot
|
||||
? { available: true, repoRoot: workspaceRoot, branch: "main", sourceDirty: false }
|
||||
: { available: false, reason: "project folder is required" };
|
||||
},
|
||||
async CreateDeliveryWorktree(workspaceRoot: string) {
|
||||
async DeliveryWorktreeAvailability(workspaceRoot: string) {
|
||||
return this.IsolatedWorktreeAvailability(workspaceRoot);
|
||||
},
|
||||
async SetAgentPreset(_preset: string) {},
|
||||
async SetAgentPresetForTab(_tabID: string, _preset: string) {},
|
||||
async SetTokenMode(_mode: string) {},
|
||||
async SetTokenModeForTab(_tabID: string, _mode: string) {},
|
||||
async CreateIsolatedWorktree(workspaceRoot: string) {
|
||||
if (!workspaceRoot) throw new Error("project folder is required");
|
||||
const suffix = Date.now().toString(36);
|
||||
const isolatedRoot = `/mock/reasonix-worktrees/${suffix}/${workspaceRoot.split("/").filter(Boolean).pop() ?? "project"}`;
|
||||
const topicID = `topic_worktree_${suffix}`;
|
||||
const tab = await this.OpenProjectTab(isolatedRoot, topicID);
|
||||
tab.isolatedWorktree = true;
|
||||
tab.gitBranch = `reasonix/delivery-${suffix}`;
|
||||
tab.gitBranch = `reasonix/isolated-${suffix}`;
|
||||
mockTabs = mockTabs.map((candidate) => candidate.id === tab.id ? { ...tab } : candidate);
|
||||
return {
|
||||
workspaceRoot: isolatedRoot,
|
||||
@@ -5075,6 +5069,9 @@ function makeMockApp(): AppBindings {
|
||||
tab,
|
||||
};
|
||||
},
|
||||
async CreateDeliveryWorktree(workspaceRoot: string) {
|
||||
return this.CreateIsolatedWorktree(workspaceRoot);
|
||||
},
|
||||
async OpenGlobalTab(_topicID: string) {
|
||||
const existing = mockTabs.find((tab) => tab.scope === "global" && tab.topicId === _topicID);
|
||||
if (existing) {
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import type { Translator } from "./i18n";
|
||||
|
||||
export function completionPresetLabel(preset: string, t: Translator): string {
|
||||
switch (preset.trim().toLowerCase()) {
|
||||
case "light":
|
||||
case "economy": return t("composer.runtimeProfileEconomyShort");
|
||||
case "delivery": return t("composer.runtimeProfileDeliveryShort");
|
||||
case "balanced":
|
||||
case "full": return t("composer.runtimeProfileBalancedShort");
|
||||
default: return t("context.other");
|
||||
}
|
||||
}
|
||||
|
||||
export function completionVerdictLabel(verdict: string, t: Translator): string {
|
||||
switch (verdict.trim().toLowerCase()) {
|
||||
case "complete": return t("task.state.succeeded");
|
||||
|
||||
@@ -4,18 +4,16 @@ import {
|
||||
modeHasPlan,
|
||||
normalizeCollaborationMode,
|
||||
normalizeMode,
|
||||
normalizeTokenMode,
|
||||
normalizeToolApprovalMode,
|
||||
type CollaborationMode,
|
||||
type GoalStatus,
|
||||
type Meta,
|
||||
type Mode,
|
||||
type TabMeta,
|
||||
type TokenMode,
|
||||
type ToolApprovalMode,
|
||||
} from "./types";
|
||||
|
||||
export type ComposerProfileField = "collaborationMode" | "toolApprovalMode" | "tokenMode" | "goal";
|
||||
export type ComposerProfileField = "collaborationMode" | "toolApprovalMode" | "goal";
|
||||
|
||||
export type ComposerProfilePending = Partial<Record<ComposerProfileField, true>>;
|
||||
|
||||
@@ -23,7 +21,6 @@ export interface ComposerProfile {
|
||||
collaborationMode: CollaborationMode;
|
||||
goalDraftMode: boolean;
|
||||
toolApprovalMode: ToolApprovalMode;
|
||||
tokenMode: TokenMode;
|
||||
goal: string;
|
||||
pending: ComposerProfilePending;
|
||||
}
|
||||
@@ -31,13 +28,12 @@ export interface ComposerProfile {
|
||||
export type ComposerProfilesByTab = Record<string, ComposerProfile>;
|
||||
export type UserPlanModeIntents = Record<string, true>;
|
||||
|
||||
const profileFields: ComposerProfileField[] = ["collaborationMode", "toolApprovalMode", "tokenMode", "goal"];
|
||||
const profileFields: ComposerProfileField[] = ["collaborationMode", "toolApprovalMode", "goal"];
|
||||
|
||||
export const defaultComposerProfile: ComposerProfile = Object.freeze({
|
||||
collaborationMode: "normal",
|
||||
goalDraftMode: false,
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
pending: {},
|
||||
});
|
||||
@@ -71,7 +67,6 @@ export function composerProfileFromTab(tab?: TabMeta | null, fallback?: ToolAppr
|
||||
tab.toolApprovalMode === "yolo",
|
||||
fallbackToolApprovalMode(tab.toolApprovalMode, fallback),
|
||||
),
|
||||
tokenMode: normalizeTokenMode(tab.tokenMode),
|
||||
goal,
|
||||
});
|
||||
}
|
||||
@@ -90,7 +85,6 @@ export function composerProfileFromMeta(meta?: Meta | null, legacyMode?: Mode, f
|
||||
collaborationMode: normalizeCollaborationMode(meta.collaborationMode, goal, fallbackMode),
|
||||
goalDraftMode: false,
|
||||
toolApprovalMode,
|
||||
tokenMode: normalizeTokenMode(meta.tokenMode),
|
||||
goal,
|
||||
});
|
||||
}
|
||||
@@ -107,9 +101,6 @@ function assignField(profile: ComposerProfile, field: ComposerProfileField, valu
|
||||
case "toolApprovalMode":
|
||||
profile.toolApprovalMode = value as ToolApprovalMode;
|
||||
return;
|
||||
case "tokenMode":
|
||||
profile.tokenMode = value as TokenMode;
|
||||
return;
|
||||
case "goal":
|
||||
profile.goal = value;
|
||||
return;
|
||||
@@ -121,7 +112,6 @@ function profilesEqual(a: ComposerProfile | undefined, b: ComposerProfile | unde
|
||||
return a.collaborationMode === b.collaborationMode
|
||||
&& a.goalDraftMode === b.goalDraftMode
|
||||
&& a.toolApprovalMode === b.toolApprovalMode
|
||||
&& a.tokenMode === b.tokenMode
|
||||
&& a.goal === b.goal
|
||||
&& profileFields.every((field) => Boolean(a.pending[field]) === Boolean(b.pending[field]));
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ import type {
|
||||
QuestionAnswer,
|
||||
SessionMeta,
|
||||
TabMeta,
|
||||
TokenMode,
|
||||
ToolApprovalMode,
|
||||
TopicActivationEvent,
|
||||
WireApproval,
|
||||
@@ -2259,20 +2258,6 @@ export function modelSwitchNoticeText(err: unknown): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function tokenModeSwitchNoticeText(err: unknown): string {
|
||||
return settingSwitchNoticeText(err, "token mode", {
|
||||
busy: "status.tokenModeSwitchBusy",
|
||||
busyRunning: "status.tokenModeSwitchBusyRunning",
|
||||
busyPrompt: "status.tokenModeSwitchBusyPrompt",
|
||||
busyJobs: "status.tokenModeSwitchBusyJobs",
|
||||
leaseHeld: "status.tokenModeSwitchLeaseHeld",
|
||||
starting: "status.tokenModeSwitchStarting",
|
||||
startupFailed: "status.tokenModeSwitchStartupFailed",
|
||||
retry: "status.tokenModeSwitchRetry",
|
||||
failed: "status.tokenModeSwitchFailed",
|
||||
});
|
||||
}
|
||||
|
||||
// noticeCodeKeys maps the backend's stable notice codes (event.NoticeCode*) to
|
||||
// dictionary keys. Codes survive backend copy edits, unlike the exact-text
|
||||
// matching in backendNoticeKey, which stays only as the fallback for events
|
||||
@@ -2515,8 +2500,6 @@ function localizedSessionAction(action: string): string {
|
||||
return t("status.actionChangingModel");
|
||||
case "changing effort":
|
||||
return t("status.actionChangingEffort");
|
||||
case "changing token mode":
|
||||
return t("status.actionChangingTokenMode");
|
||||
case "rebuilding settings":
|
||||
return t("status.actionRebuildingSettings");
|
||||
case "switching sessions":
|
||||
@@ -4289,18 +4272,6 @@ export function useController() {
|
||||
await refreshMetaForTab(activeTabId);
|
||||
}, [activeTabId, dispatchTo, refreshMetaForTab]);
|
||||
|
||||
const setTokenMode = useCallback(async (mode: TokenMode): Promise<boolean> => {
|
||||
if (!activeTabId) return false;
|
||||
try {
|
||||
await app.SetTokenModeForTab(activeTabId, mode);
|
||||
} catch (err) {
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: tokenModeSwitchNoticeText(err) });
|
||||
return false;
|
||||
}
|
||||
await refreshMetaForTab(activeTabId);
|
||||
return true;
|
||||
}, [activeTabId, dispatchTo, refreshMetaForTab]);
|
||||
|
||||
const cancelJob = useCallback(async (jobID: string): Promise<boolean> => {
|
||||
const tabId = activeTabId;
|
||||
if (!tabId || !jobID.trim()) return false;
|
||||
@@ -4721,13 +4692,13 @@ export function useController() {
|
||||
return meta;
|
||||
}, [beginActiveNavigation, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, disposeComposerProfileState, invalidateProviderStateForTab, loadSessionDataForTab, navigationCompletionCurrent, reassertVisibleTabAfterStaleNavigation, reconcileTabRuntime, releaseTranscriptState]);
|
||||
|
||||
const createDeliveryWorktree = useCallback(async (workspaceRoot: string, navigationIntentSeq?: number): Promise<DeliveryWorktreeOpenResult> => {
|
||||
const createIsolatedWorktree = useCallback(async (workspaceRoot: string, navigationIntentSeq?: number): Promise<DeliveryWorktreeOpenResult> => {
|
||||
const navigationSeq = navigationIntentSeq ?? beginActiveNavigation();
|
||||
const snapshotAt = promptEventClock();
|
||||
const result = await app.CreateDeliveryWorktree(workspaceRoot);
|
||||
const result = await app.CreateIsolatedWorktree(workspaceRoot);
|
||||
const meta = result.tab;
|
||||
if (!navigationCompletionCurrent(navigationSeq, "tab.delivery-worktree", meta.id)) {
|
||||
await reassertVisibleTabAfterStaleNavigation("tab.delivery-worktree", meta.id);
|
||||
if (!navigationCompletionCurrent(navigationSeq, "tab.isolated-worktree", meta.id)) {
|
||||
await reassertVisibleTabAfterStaleNavigation("tab.isolated-worktree", meta.id);
|
||||
return result;
|
||||
}
|
||||
const isNewTab = !statesRef.current.has(meta.id);
|
||||
@@ -4778,9 +4749,9 @@ export function useController() {
|
||||
newSession, clearSession, listSessions, listTrashedSessions, retrySessionHistory, resumeSession, openChannelSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession,
|
||||
loadOlderHistory,
|
||||
requestHistoryFullContent,
|
||||
refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, rewindForTab, rewindForTabDetailed, undoRewindForTab, setModel, setEffort, setTokenMode, cancelJob,
|
||||
refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, rewindForTab, rewindForTabDetailed, undoRewindForTab, setModel, setEffort, cancelJob,
|
||||
fetchMemory, remember, forget, saveDoc,
|
||||
switchTab, openProjectTab, openGlobalTab, openTopicSession, ensureBlankTab, activateTopic, ensureBlankSurface, createDeliveryWorktree, closeTab, reorderTabs,
|
||||
switchTab, openProjectTab, openGlobalTab, openTopicSession, ensureBlankTab, activateTopic, ensureBlankSurface, createIsolatedWorktree, closeTab, reorderTabs,
|
||||
// Invalidate in-flight navigation completions (activateTopic's stale
|
||||
// guard) from outside the hook. The App-level navigation queue must call
|
||||
// this at ENQUEUE time: a queued click does not run — and so does not
|
||||
|
||||
@@ -670,20 +670,6 @@ export const en = {
|
||||
"composer.goalModeDesc": "Type a goal, then start goal mode.",
|
||||
"composer.goalModeActiveDesc": "Keeps working until complete, blocked, or stopped.",
|
||||
"composer.controlTooltip": "{category} · {mode}: {summary}",
|
||||
"composer.runtimeProfileTitle": "Execution setting",
|
||||
"composer.runtimeProfileTrigger": "Execution setting · {mode}",
|
||||
"composer.runtimeProfileEconomy": "Light · Fast and reliable",
|
||||
"composer.runtimeProfileEconomyShort": "Light",
|
||||
"composer.runtimeProfileEconomyDesc": "On-demand capabilities · Targeted verification",
|
||||
"composer.runtimeProfileEconomyTooltipSummary": "On-demand capabilities, targeted verification",
|
||||
"composer.runtimeProfileBalanced": "Balanced · Adaptive",
|
||||
"composer.runtimeProfileBalancedShort": "Balanced",
|
||||
"composer.runtimeProfileBalancedDesc": "Auto planning · Risk-tiered verification",
|
||||
"composer.runtimeProfileBalancedTooltipSummary": "Auto planning, risk-tiered verification",
|
||||
"composer.runtimeProfileDelivery": "Delivery · Evidence closed-loop",
|
||||
"composer.runtimeProfileDeliveryShort": "Delivery",
|
||||
"composer.runtimeProfileDeliveryDesc": "Full acceptance · Verification and independent review",
|
||||
"composer.runtimeProfileDeliveryTooltipSummary": "Full acceptance, verification, and independent review",
|
||||
"composer.goalInputPlaceholder": "Enter a goal…",
|
||||
"composer.goalInputRequired": "Enter a goal",
|
||||
"composer.subagentTaskRequired": "Enter a task for the subagent",
|
||||
@@ -889,20 +875,10 @@ export const en = {
|
||||
"status.effortSwitchStartupFailed": "This session did not start, so reasoning effort cannot change: {err}",
|
||||
"status.effortSwitchRetry": "The current session changed while switching reasoning effort. Try once more.",
|
||||
"status.effortSwitchFailed": "Reasoning effort switch failed: {err}",
|
||||
"status.tokenModeSwitchBusy": "Execution setting cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"status.tokenModeSwitchBusyRunning": "Execution setting cannot change while the current answer is running. Stop it first.",
|
||||
"status.tokenModeSwitchBusyPrompt": "Execution setting cannot change while a prompt is waiting for your response. Handle it first.",
|
||||
"status.tokenModeSwitchBusyJobs": "Execution setting cannot change while background work is active. Active jobs: {n}. Open Background jobs in the status bar to stop them.",
|
||||
"status.tokenModeSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing its execution setting.",
|
||||
"status.tokenModeSwitchStarting": "This session is still starting. Try changing the execution setting again in a moment.",
|
||||
"status.tokenModeSwitchStartupFailed": "This session did not start, so its execution setting cannot change: {err}",
|
||||
"status.tokenModeSwitchRetry": "The current session changed while switching execution setting. Try once more.",
|
||||
"status.tokenModeSwitchFailed": "Execution setting switch failed: {err}",
|
||||
"status.sessionAutosaveFailed": "Session autosave failed: {err}",
|
||||
"status.sessionSaveFailedBefore": "Session save failed before {action}: {err}",
|
||||
"status.actionChangingModel": "changing models",
|
||||
"status.actionChangingEffort": "changing reasoning effort",
|
||||
"status.actionChangingTokenMode": "changing execution setting",
|
||||
"status.actionRebuildingSettings": "refreshing settings",
|
||||
"status.actionSwitchingSessions": "switching sessions",
|
||||
"status.actionSwitchingTabs": "switching tabs",
|
||||
@@ -1030,15 +1006,9 @@ export const en = {
|
||||
"runtime.revealWriter": "Open writing task",
|
||||
"runtime.revealWriterDesc": "Switch to the exact task that currently owns the workspace writer.",
|
||||
"runtime.openWorktree": "Continue in a worktree",
|
||||
"runtime.openWorktreeDesc": "Cancel this wait and continue safely in an isolated Delivery worktree.",
|
||||
"runtime.openWorktreeDesc": "Cancel this wait and continue safely in an isolated worktree.",
|
||||
"runtime.cancelWait": "Stop waiting",
|
||||
"runtime.cancelWaitDesc": "Cancel this turn without affecting the task that owns the workspace.",
|
||||
"runtime.modeJobsTitle": "Switch to {mode} mode",
|
||||
"runtime.modeJobsMeta": "Changing modes requires stopping this task's background jobs.",
|
||||
"runtime.keepMode": "Keep current mode",
|
||||
"runtime.keepModeDesc": "Keep the current execution setting and leave background jobs running.",
|
||||
"runtime.stopAndSwitch": "Stop {n} jobs and switch",
|
||||
"runtime.stopAndSwitchDesc": "Wait for the processes to exit, then switch to {mode} mode.",
|
||||
"runtime.closeTitle": "Handle active work before closing",
|
||||
"runtime.closeMeta": "This task still has background jobs and may also have a running turn or pending prompt.",
|
||||
"runtime.returnToTask": "Return to task",
|
||||
@@ -1281,8 +1251,8 @@ export const en = {
|
||||
"projectTree.indexingProgress": "Organizing history ({done}/{total})",
|
||||
"projectTree.loadMore": "Load more",
|
||||
"projectTree.newTopic": "New session",
|
||||
"projectTree.createWorktree": "Create isolated Delivery workspace",
|
||||
"projectTree.createWorktreeHint": "Create a separate Git branch and worktree so Delivery sessions can write in parallel.",
|
||||
"projectTree.createWorktree": "Create isolated worktree",
|
||||
"projectTree.createWorktreeHint": "Create a separate Git branch and worktree so sessions can write in parallel safely.",
|
||||
"projectTree.creatingWorktree": "Creating isolated workspace…",
|
||||
"projectTree.isolatedWorktree": "Isolated Git worktree",
|
||||
"projectTree.worktreeCreated": "Isolated workspace created on {branch}.",
|
||||
|
||||
@@ -657,7 +657,6 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"status.sessionSaveFailedBefore": "在{action}前儲存會話失敗:{err}",
|
||||
"status.actionChangingModel": "切換模型",
|
||||
"status.actionChangingEffort": "切換推理力度",
|
||||
"status.actionChangingTokenMode": "切換執行設定",
|
||||
"status.actionRebuildingSettings": "重新整理設定",
|
||||
"status.actionSwitchingSessions": "切換會話",
|
||||
"status.actionSwitchingTabs": "切換標籤頁",
|
||||
@@ -785,15 +784,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"runtime.revealWriter": "檢視占用任務",
|
||||
"runtime.revealWriterDesc": "切換到目前真正持有工作區寫入權的任務。",
|
||||
"runtime.openWorktree": "在 worktree 中繼續",
|
||||
"runtime.openWorktreeDesc": "取消本次等待,並在隔離的交付 worktree 中安全繼續。",
|
||||
"runtime.openWorktreeDesc": "取消本次等待,並在隔離 worktree 中安全繼續。",
|
||||
"runtime.cancelWait": "停止等待",
|
||||
"runtime.cancelWaitDesc": "只取消本輪,不影響正在占用工作區的任務。",
|
||||
"runtime.modeJobsTitle": "切換到{mode}模式",
|
||||
"runtime.modeJobsMeta": "切換模式需要停止目前任務的背景作業。",
|
||||
"runtime.keepMode": "保留目前模式",
|
||||
"runtime.keepModeDesc": "保留目前執行設定,讓背景作業繼續執行。",
|
||||
"runtime.stopAndSwitch": "停止 {n} 個作業並切換",
|
||||
"runtime.stopAndSwitchDesc": "等待程序結束後切換到{mode}模式。",
|
||||
"runtime.closeTitle": "關閉任務前處理活動工作",
|
||||
"runtime.closeMeta": "目前仍有背景作業,也可能有執行中的回合或待確認問題。",
|
||||
"runtime.returnToTask": "返回任務",
|
||||
@@ -1025,8 +1018,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"projectTree.indexingProgress": "正在整理歷史({done}/{total})",
|
||||
"projectTree.loadMore": "載入更多",
|
||||
"projectTree.newTopic": "新建會話",
|
||||
"projectTree.createWorktree": "建立隔離的交付工作區",
|
||||
"projectTree.createWorktreeHint": "建立獨立的 Git 分支與 worktree,讓多個交付會話安全並行寫入。",
|
||||
"projectTree.createWorktree": "建立隔離工作區",
|
||||
"projectTree.createWorktreeHint": "建立獨立的 Git 分支和 worktree,讓多個工作階段安全並行寫入。",
|
||||
"projectTree.creatingWorktree": "正在建立隔離工作區…",
|
||||
"projectTree.isolatedWorktree": "隔離的 Git worktree",
|
||||
"projectTree.worktreeCreated": "已在 {branch} 建立隔離工作區。",
|
||||
@@ -2425,20 +2418,6 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"composer.goalModeDesc": "先輸入目標,再點目標啟動。",
|
||||
"composer.goalModeActiveDesc": "會自動推進,直到完成、阻塞或停止。",
|
||||
"composer.controlTooltip": "{category} · {mode}:{summary}",
|
||||
"composer.runtimeProfileTitle": "執行設定",
|
||||
"composer.runtimeProfileTrigger": "執行設定 · {mode}",
|
||||
"composer.runtimeProfileEconomy": "輕量 · 快速省用量",
|
||||
"composer.runtimeProfileEconomyShort": "輕量",
|
||||
"composer.runtimeProfileEconomyDesc": "少上下文 · 工具按需啟用",
|
||||
"composer.runtimeProfileEconomyTooltipSummary": "少上下文,工具按需啟用",
|
||||
"composer.runtimeProfileBalanced": "均衡 · 日常通用",
|
||||
"composer.runtimeProfileBalancedShort": "均衡",
|
||||
"composer.runtimeProfileBalancedDesc": "完整工具 · 模型自主執行",
|
||||
"composer.runtimeProfileBalancedTooltipSummary": "完整工具,模型自主執行",
|
||||
"composer.runtimeProfileDelivery": "交付 · 完整驗證",
|
||||
"composer.runtimeProfileDeliveryShort": "交付",
|
||||
"composer.runtimeProfileDeliveryDesc": "強制驗收 · 複查驗證",
|
||||
"composer.runtimeProfileDeliveryTooltipSummary": "強制驗收,複查驗證",
|
||||
"composer.goalInputPlaceholder": "請輸入目標…",
|
||||
"composer.goalInputRequired": "請輸入目標",
|
||||
"composer.subagentTaskRequired": "請輸入子智能體任務",
|
||||
@@ -2543,15 +2522,6 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"status.costLabel": "會話費用",
|
||||
"status.balanceLabel": "餘額",
|
||||
"status.jobsLabel": "任務",
|
||||
"status.tokenModeSwitchBusy": "暫不能切換執行設定。請停止回答、處理確認,或等待背景任務結束。",
|
||||
"status.tokenModeSwitchBusyRunning": "回答執行中,請停止後切換執行設定。",
|
||||
"status.tokenModeSwitchBusyPrompt": "請先處理待確認問題,再切換執行設定。",
|
||||
"status.tokenModeSwitchBusyJobs": "有 {n} 個背景任務,請停止後切換執行設定。",
|
||||
"status.tokenModeSwitchLeaseHeld": "會話正在其他視窗或背景執行。請停止執行或開啟副本後切換。",
|
||||
"status.tokenModeSwitchStarting": "會話啟動中,請稍後切換。",
|
||||
"status.tokenModeSwitchStartupFailed": "會話啟動失敗,無法切換:{err}",
|
||||
"status.tokenModeSwitchRetry": "會話已變更,請重試切換。",
|
||||
"status.tokenModeSwitchFailed": "執行設定切換失敗:{err}",
|
||||
"status.modelTitle": "當前模型",
|
||||
"clearContext.title": "清空當前上下文",
|
||||
"clearContext.badge": "僅本地",
|
||||
|
||||
@@ -671,20 +671,6 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.goalModeDesc": "先输入目标,再点目标启动。",
|
||||
"composer.goalModeActiveDesc": "会自动推进,直到完成、阻塞或停止。",
|
||||
"composer.controlTooltip": "{category} · {mode}:{summary}",
|
||||
"composer.runtimeProfileTitle": "执行设定",
|
||||
"composer.runtimeProfileTrigger": "执行设定 · {mode}",
|
||||
"composer.runtimeProfileEconomy": "轻量 · 快速",
|
||||
"composer.runtimeProfileEconomyShort": "轻量",
|
||||
"composer.runtimeProfileEconomyDesc": "按需 · 定向验证",
|
||||
"composer.runtimeProfileEconomyTooltipSummary": "按需,定向验证",
|
||||
"composer.runtimeProfileBalanced": "均衡 · 适配",
|
||||
"composer.runtimeProfileBalancedShort": "均衡",
|
||||
"composer.runtimeProfileBalancedDesc": "规划 · 分级验证",
|
||||
"composer.runtimeProfileBalancedTooltipSummary": "规划,分级验证",
|
||||
"composer.runtimeProfileDelivery": "交付 · 闭环",
|
||||
"composer.runtimeProfileDeliveryShort": "交付",
|
||||
"composer.runtimeProfileDeliveryDesc": "验收 · 验证复查",
|
||||
"composer.runtimeProfileDeliveryTooltipSummary": "验收,验证复查",
|
||||
"composer.goalInputPlaceholder": "请输入目标…",
|
||||
"composer.goalInputRequired": "请输入目标",
|
||||
"composer.subagentTaskRequired": "请输入子智能体任务",
|
||||
@@ -890,20 +876,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"status.effortSwitchStartupFailed": "当前会话启动失败,无法切换推理力度:{err}",
|
||||
"status.effortSwitchRetry": "当前会话已变化,请再试一次切换推理力度。",
|
||||
"status.effortSwitchFailed": "推理力度切换失败:{err}",
|
||||
"status.tokenModeSwitchBusy": "暂时不能切换执行设定。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
|
||||
"status.tokenModeSwitchBusyRunning": "当前回答仍在运行,暂时不能切换执行设定。请先停止回答。",
|
||||
"status.tokenModeSwitchBusyPrompt": "有待处理的确认问题,暂时不能切换执行设定。请先完成处理。",
|
||||
"status.tokenModeSwitchBusyJobs": "仍有 {n} 个后台任务在运行,暂时不能切换执行设定。请从状态栏的“后台作业”中停止它们。",
|
||||
"status.tokenModeSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换执行设定。",
|
||||
"status.tokenModeSwitchStarting": "会话还在启动,稍后再切换执行设定。",
|
||||
"status.tokenModeSwitchStartupFailed": "当前会话启动失败,无法切换执行设定:{err}",
|
||||
"status.tokenModeSwitchRetry": "当前会话已变化,请再试一次切换执行设定。",
|
||||
"status.tokenModeSwitchFailed": "执行设定切换失败:{err}",
|
||||
"status.sessionAutosaveFailed": "会话自动保存失败:{err}",
|
||||
"status.sessionSaveFailedBefore": "在{action}前保存会话失败:{err}",
|
||||
"status.actionChangingModel": "切换模型",
|
||||
"status.actionChangingEffort": "切换推理力度",
|
||||
"status.actionChangingTokenMode": "切换执行设定",
|
||||
"status.actionRebuildingSettings": "刷新设置",
|
||||
"status.actionSwitchingSessions": "切换会话",
|
||||
"status.actionSwitchingTabs": "切换标签页",
|
||||
@@ -1031,15 +1007,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"runtime.revealWriter": "查看占用任务",
|
||||
"runtime.revealWriterDesc": "切换到当前真正持有工作区写入权的任务。",
|
||||
"runtime.openWorktree": "在 worktree 中继续",
|
||||
"runtime.openWorktreeDesc": "取消本次等待,并在隔离的交付 worktree 中安全继续。",
|
||||
"runtime.openWorktreeDesc": "取消本次等待,并在隔离 worktree 中安全继续。",
|
||||
"runtime.cancelWait": "停止等待",
|
||||
"runtime.cancelWaitDesc": "只取消本轮,不影响正在占用工作区的任务。",
|
||||
"runtime.modeJobsTitle": "切换到{mode}模式",
|
||||
"runtime.modeJobsMeta": "切换模式需要停止当前任务的后台作业。",
|
||||
"runtime.keepMode": "保留当前模式",
|
||||
"runtime.keepModeDesc": "保留当前执行设定,让后台作业继续运行。",
|
||||
"runtime.stopAndSwitch": "停止 {n} 个作业并切换",
|
||||
"runtime.stopAndSwitchDesc": "等待进程退出后切换到{mode}模式。",
|
||||
"runtime.closeTitle": "关闭任务前处理活动工作",
|
||||
"runtime.closeMeta": "当前仍有后台作业,也可能有运行中的回合或待确认问题。",
|
||||
"runtime.returnToTask": "返回任务",
|
||||
@@ -1282,8 +1252,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"projectTree.indexingProgress": "正在整理历史({done}/{total})",
|
||||
"projectTree.loadMore": "加载更多",
|
||||
"projectTree.newTopic": "新会话",
|
||||
"projectTree.createWorktree": "创建隔离的交付工作区",
|
||||
"projectTree.createWorktreeHint": "创建独立的 Git 分支和 worktree,让多个交付会话安全并行写入。",
|
||||
"projectTree.createWorktree": "创建隔离工作区",
|
||||
"projectTree.createWorktreeHint": "创建独立的 Git 分支和 worktree,让多个会话安全并行写入。",
|
||||
"projectTree.creatingWorktree": "正在创建隔离工作区…",
|
||||
"projectTree.isolatedWorktree": "隔离的 Git worktree",
|
||||
"projectTree.worktreeCreated": "已在 {branch} 创建隔离工作区。",
|
||||
|
||||
@@ -7186,16 +7186,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
max-width: min(168px, 28vw);
|
||||
}
|
||||
|
||||
.composer-meta__control--profile {
|
||||
flex: 0 0 auto;
|
||||
max-width: min(158px, 24vw);
|
||||
}
|
||||
|
||||
.composer-meta__control--profile .tooltip-trigger {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.composer-meta__control--approval {
|
||||
flex: 0 1 auto;
|
||||
min-width: 216px;
|
||||
@@ -7390,82 +7380,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.composer-profile-trigger {
|
||||
--wails-draggable: no-drag;
|
||||
--composer-profile-accent: var(--fg-dim);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: var(--composer-control-height);
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--composer-control-radius);
|
||||
background: var(--surface-2);
|
||||
color: var(--fg-dim);
|
||||
box-shadow: var(--app-graphite-shadow);
|
||||
font: inherit;
|
||||
font-size: var(--composer-control-font-size);
|
||||
font-weight: var(--composer-control-font-weight);
|
||||
transition: border-color 0.14s, background 0.14s, color 0.14s, transform 0.14s var(--ease-out);
|
||||
}
|
||||
|
||||
.composer-profile-trigger[data-profile="economy"] {
|
||||
--composer-profile-accent: #3b82f6;
|
||||
}
|
||||
|
||||
.composer-profile-trigger[data-profile="delivery"] {
|
||||
--composer-profile-accent: #f97316;
|
||||
}
|
||||
|
||||
.composer-profile-trigger > svg:first-child {
|
||||
flex: 0 0 auto;
|
||||
color: var(--composer-profile-accent);
|
||||
}
|
||||
|
||||
.composer-profile-trigger > svg:last-child {
|
||||
flex: 0 0 auto;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
|
||||
.composer-profile-trigger__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composer-profile-trigger__value {
|
||||
color: var(--fg);
|
||||
font-weight: var(--composer-control-font-weight);
|
||||
}
|
||||
|
||||
.composer-profile-trigger:hover:not(:disabled),
|
||||
.composer-profile-trigger:focus-visible,
|
||||
.composer-profile-trigger--open {
|
||||
outline: none;
|
||||
color: var(--fg);
|
||||
border-color: var(--border-soft);
|
||||
background: var(--bg-soft);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.composer-profile-trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.composer-profile-trigger:focus-visible {
|
||||
box-shadow: var(--focus-ring);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.composer-more-trigger {
|
||||
--wails-draggable: no-drag;
|
||||
display: inline-flex;
|
||||
@@ -7601,10 +7515,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.composer-meta__control--profile {
|
||||
max-width: 126px;
|
||||
}
|
||||
|
||||
.composer-meta__control--intent {
|
||||
max-width: 128px;
|
||||
}
|
||||
@@ -7658,12 +7568,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
max-width: 92px;
|
||||
}
|
||||
|
||||
.composer-meta__control--profile {
|
||||
flex-basis: 68px;
|
||||
width: 68px;
|
||||
max-width: 68px;
|
||||
}
|
||||
|
||||
.composer-meta__divider {
|
||||
display: none;
|
||||
}
|
||||
@@ -7683,15 +7587,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.composer-profile-trigger {
|
||||
gap: 4px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.composer-profile-trigger > svg:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.composer-meta__control--effort {
|
||||
display: none;
|
||||
}
|
||||
@@ -7857,22 +7752,11 @@ body > .mermaid-diagram--fullscreen {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.composer-intent-menu,
|
||||
.composer-profile-menu {
|
||||
.composer-intent-menu {
|
||||
width: min(284px, calc(100vw - 16px));
|
||||
}
|
||||
|
||||
.composer-profile-menu .composer-access-menu__desc {
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composer-intent-menu__item,
|
||||
.composer-profile-menu__item {
|
||||
.composer-intent-menu__item {
|
||||
height: auto;
|
||||
min-height: 58px;
|
||||
padding-block: 8px;
|
||||
@@ -28414,7 +28298,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
:root[data-theme-style] .composer-modebar,
|
||||
:root[data-theme-style] .meta-chip,
|
||||
:root[data-theme-style] .composer-task-mode-trigger,
|
||||
:root[data-theme-style] .composer-profile-trigger,
|
||||
:root[data-theme-style] .composer-more-trigger,
|
||||
:root[data-theme-style] .composer-meta .modelsw__trigger,
|
||||
:root[data-theme-style] .composer-context__item {
|
||||
@@ -28428,9 +28311,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
:root[data-theme-style] .composer-task-mode-trigger:hover:not(:disabled),
|
||||
:root[data-theme-style] .composer-task-mode-trigger:focus-visible,
|
||||
:root[data-theme-style] .composer-task-mode-trigger--open,
|
||||
:root[data-theme-style] .composer-profile-trigger:hover:not(:disabled),
|
||||
:root[data-theme-style] .composer-profile-trigger:focus-visible,
|
||||
:root[data-theme-style] .composer-profile-trigger--open,
|
||||
:root[data-theme-style] .composer-more-trigger:hover:not(:disabled),
|
||||
:root[data-theme-style] .composer-more-trigger:focus-visible,
|
||||
:root[data-theme-style] .composer-more-trigger--open,
|
||||
@@ -28442,7 +28322,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
|
||||
:root[data-theme-style] .composer-task-mode-trigger:focus-visible,
|
||||
:root[data-theme-style] .composer-profile-trigger:focus-visible,
|
||||
:root[data-theme-style] .composer-more-trigger:focus-visible,
|
||||
:root[data-theme-style] .composer-meta .modelsw__trigger:focus-visible {
|
||||
box-shadow: var(--focus-ring);
|
||||
@@ -31750,12 +31629,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.app--creation .composer-meta__control--profile,
|
||||
:root[data-theme-style] .app--creation .composer-meta__control--profile {
|
||||
flex: 0 0 auto;
|
||||
max-width: 88px;
|
||||
}
|
||||
|
||||
.app--creation .composer-meta__control--approval,
|
||||
:root[data-theme-style] .app--creation .composer-meta__control--approval {
|
||||
flex: 0 0 auto;
|
||||
@@ -31786,14 +31659,12 @@ body > .mermaid-diagram--fullscreen {
|
||||
.app--creation .composer-modebar,
|
||||
.app--creation .composer-content-trigger,
|
||||
.app--creation .composer-task-mode-trigger,
|
||||
.app--creation .composer-profile-trigger,
|
||||
.app--creation .composer-more-trigger,
|
||||
.app--creation .composer-meta .modelsw__trigger,
|
||||
.app--creation .composer-context__item,
|
||||
:root[data-theme-style] .app--creation .composer-modebar,
|
||||
:root[data-theme-style] .app--creation .composer-content-trigger,
|
||||
:root[data-theme-style] .app--creation .composer-task-mode-trigger,
|
||||
:root[data-theme-style] .app--creation .composer-profile-trigger,
|
||||
:root[data-theme-style] .app--creation .composer-more-trigger,
|
||||
:root[data-theme-style] .app--creation .composer-meta .modelsw__trigger,
|
||||
:root[data-theme-style] .app--creation .composer-context__item {
|
||||
@@ -31804,16 +31675,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.app--creation .composer-profile-trigger,
|
||||
:root[data-theme-style] .app--creation .composer-profile-trigger {
|
||||
height: 28px;
|
||||
gap: 0;
|
||||
padding-inline: 9px;
|
||||
box-shadow: none;
|
||||
font-size: calc(11.5px * var(--font-scale));
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.app--creation .composer-task-mode-trigger,
|
||||
:root[data-theme-style] .app--creation .composer-task-mode-trigger {
|
||||
height: 28px;
|
||||
@@ -31825,16 +31686,12 @@ body > .mermaid-diagram--fullscreen {
|
||||
|
||||
/* Creation: text-only pills — drop leading icons + chevrons (match modelsw) */
|
||||
.app--creation .composer-task-mode-trigger > svg,
|
||||
.app--creation .composer-profile-trigger > svg,
|
||||
:root[data-theme-style] .app--creation .composer-task-mode-trigger > svg,
|
||||
:root[data-theme-style] .app--creation .composer-profile-trigger > svg {
|
||||
:root[data-theme-style] .app--creation .composer-task-mode-trigger > svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app--creation .composer-task-mode-trigger__value,
|
||||
.app--creation .composer-profile-trigger__value,
|
||||
:root[data-theme-style] .app--creation .composer-task-mode-trigger__value,
|
||||
:root[data-theme-style] .app--creation .composer-profile-trigger__value {
|
||||
:root[data-theme-style] .app--creation .composer-task-mode-trigger__value {
|
||||
color: color-mix(in srgb, var(--fg) 88%, transparent);
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -31842,25 +31699,14 @@ body > .mermaid-diagram--fullscreen {
|
||||
.app--creation .composer-task-mode-trigger:hover:not(:disabled),
|
||||
.app--creation .composer-task-mode-trigger:focus-visible,
|
||||
.app--creation .composer-task-mode-trigger--open,
|
||||
.app--creation .composer-profile-trigger:hover:not(:disabled),
|
||||
.app--creation .composer-profile-trigger:focus-visible,
|
||||
.app--creation .composer-profile-trigger--open,
|
||||
:root[data-theme-style] .app--creation .composer-task-mode-trigger:hover:not(:disabled),
|
||||
:root[data-theme-style] .app--creation .composer-task-mode-trigger:focus-visible,
|
||||
:root[data-theme-style] .app--creation .composer-task-mode-trigger--open,
|
||||
:root[data-theme-style] .app--creation .composer-profile-trigger:hover:not(:disabled),
|
||||
:root[data-theme-style] .app--creation .composer-profile-trigger:focus-visible,
|
||||
:root[data-theme-style] .app--creation .composer-profile-trigger--open {
|
||||
:root[data-theme-style] .app--creation .composer-task-mode-trigger--open {
|
||||
border-color: color-mix(in srgb, var(--fg-faint) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-elev) 14%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.app--creation .composer-meta__control--profile,
|
||||
:root[data-theme-style] .app--creation .composer-meta__control--profile {
|
||||
max-width: 64px;
|
||||
}
|
||||
|
||||
.app--creation .composer-meta__control--intent,
|
||||
:root[data-theme-style] .app--creation .composer-meta__control--intent {
|
||||
max-width: 64px;
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -122,8 +121,8 @@ func TestGoalDeliveryYoloTokenSwitchPreservesBlockedGoalCheckpoint(t *testing.T)
|
||||
if ctrl.ToolApprovalMode() != control.ToolApprovalYolo {
|
||||
t.Fatalf("tool approval = %q, want yolo", ctrl.ToolApprovalMode())
|
||||
}
|
||||
if currentTabTokenMode(tab) != boot.TokenModeDelivery {
|
||||
t.Fatalf("token mode = %q, want delivery", currentTabTokenMode(tab))
|
||||
if currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", currentTabTokenMode(tab))
|
||||
}
|
||||
app.mu.RLock()
|
||||
runtimeForPath := app.runtimeBySessionKey[sessionRuntimeKey(path)]
|
||||
@@ -189,8 +188,8 @@ func TestPlanYoloDeliveryRebuildUsesLiveControllerAxes(t *testing.T) {
|
||||
if ctrl.GoalStatus() != control.GoalStatusBlocked {
|
||||
t.Fatalf("blocked Goal status = %q, want preserved while Plan is active", ctrl.GoalStatus())
|
||||
}
|
||||
if currentTabTokenMode(tab) != boot.TokenModeDelivery {
|
||||
t.Fatalf("token mode = %q, want delivery", currentTabTokenMode(tab))
|
||||
if currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", currentTabTokenMode(tab))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,8 +341,8 @@ func TestGoalDeliveryYoloSurvivesEveryControllerRebuildPath(t *testing.T) {
|
||||
if ctrl.PlanMode() || ctrl.GoalStatus() != control.GoalStatusRunning || ctrl.Goal() != "ship the combined mode" {
|
||||
t.Fatalf("collaboration state plan=%v goal=%q status=%q, want running Goal", ctrl.PlanMode(), ctrl.Goal(), ctrl.GoalStatus())
|
||||
}
|
||||
if ctrl.ToolApprovalMode() != control.ToolApprovalYolo || currentTabTokenMode(tab) != boot.TokenModeDelivery {
|
||||
t.Fatalf("runtime axes approval=%q token=%q, want yolo/delivery", ctrl.ToolApprovalMode(), currentTabTokenMode(tab))
|
||||
if ctrl.ToolApprovalMode() != control.ToolApprovalYolo || currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatalf("runtime axes approval=%q token=%q, want yolo/full", ctrl.ToolApprovalMode(), currentTabTokenMode(tab))
|
||||
}
|
||||
|
||||
var persisted struct {
|
||||
@@ -400,7 +399,7 @@ func TestPlanYoloDeliveryOrderConverges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctrl := app.controllerForTab(tab)
|
||||
if !ctrl.PlanMode() || ctrl.ToolApprovalMode() != control.ToolApprovalYolo || currentTabTokenMode(tab) != boot.TokenModeDelivery {
|
||||
if !ctrl.PlanMode() || ctrl.ToolApprovalMode() != control.ToolApprovalYolo || currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatalf("final axes plan=%v approval=%q token=%q", ctrl.PlanMode(), ctrl.ToolApprovalMode(), currentTabTokenMode(tab))
|
||||
}
|
||||
})
|
||||
@@ -409,76 +408,76 @@ func TestPlanYoloDeliveryOrderConverges(t *testing.T) {
|
||||
|
||||
func TestEveryCollaborationAndTokenModeCombinationConvergesInBothOrders(t *testing.T) {
|
||||
collaborationModes := []string{"normal", "plan", "goal"}
|
||||
tokenModes := []string{boot.TokenModeEconomy, boot.TokenModeFull, boot.TokenModeDelivery}
|
||||
orders := []string{"collaboration-first", "token-first"}
|
||||
|
||||
for _, collaborationMode := range collaborationModes {
|
||||
for _, tokenMode := range tokenModes {
|
||||
for _, order := range orders {
|
||||
t.Run(collaborationMode+"/"+tokenMode+"/"+order, func(t *testing.T) {
|
||||
app, tab, _, path := newGoalDeliveryYoloTestApp(t, control.GoalStatusRunning)
|
||||
app.SetToolApprovalModeForTab(tab.ID, control.ToolApprovalAsk)
|
||||
for _, order := range orders {
|
||||
t.Run(collaborationMode+"/"+order, func(t *testing.T) {
|
||||
app, tab, _, path := newGoalDeliveryYoloTestApp(t, control.GoalStatusRunning)
|
||||
app.SetToolApprovalModeForTab(tab.ID, control.ToolApprovalAsk)
|
||||
|
||||
setCollaboration := func() {
|
||||
switch collaborationMode {
|
||||
case "goal":
|
||||
app.SetGoalForTab(tab.ID, "exercise all runtime axes")
|
||||
app.SetCollaborationModeForTab(tab.ID, "goal")
|
||||
case "plan":
|
||||
app.SetCollaborationModeForTab(tab.ID, "plan")
|
||||
default:
|
||||
app.SetGoalForTab(tab.ID, "")
|
||||
app.SetCollaborationModeForTab(tab.ID, "normal")
|
||||
}
|
||||
}
|
||||
setToken := func() {
|
||||
if err := app.SetTokenModeForTab(tab.ID, tokenMode); err != nil {
|
||||
t.Fatalf("SetTokenModeForTab(%q): %v", tokenMode, err)
|
||||
}
|
||||
}
|
||||
if order == "collaboration-first" {
|
||||
setCollaboration()
|
||||
setToken()
|
||||
} else {
|
||||
setToken()
|
||||
setCollaboration()
|
||||
}
|
||||
|
||||
ctrl := app.controllerForTab(tab)
|
||||
if ctrl == nil {
|
||||
t.Fatal("final controller is nil")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != tokenMode {
|
||||
t.Fatalf("token mode = %q, want %q", got, tokenMode)
|
||||
}
|
||||
setCollaboration := func() {
|
||||
switch collaborationMode {
|
||||
case "goal":
|
||||
if ctrl.PlanMode() || ctrl.GoalStatus() != control.GoalStatusRunning ||
|
||||
ctrl.Goal() != "exercise all runtime axes" {
|
||||
t.Fatalf("Goal axes plan=%v goal=%q status=%q", ctrl.PlanMode(), ctrl.Goal(), ctrl.GoalStatus())
|
||||
}
|
||||
app.SetGoalForTab(tab.ID, "exercise all runtime axes")
|
||||
app.SetCollaborationModeForTab(tab.ID, "goal")
|
||||
case "plan":
|
||||
if !ctrl.PlanMode() || ctrl.GoalStatus() == control.GoalStatusRunning {
|
||||
t.Fatalf("Plan axes plan=%v goal=%q status=%q", ctrl.PlanMode(), ctrl.Goal(), ctrl.GoalStatus())
|
||||
}
|
||||
app.SetCollaborationModeForTab(tab.ID, "plan")
|
||||
default:
|
||||
if ctrl.PlanMode() || ctrl.GoalStatus() == control.GoalStatusRunning {
|
||||
t.Fatalf("Normal axes plan=%v goal=%q status=%q", ctrl.PlanMode(), ctrl.Goal(), ctrl.GoalStatus())
|
||||
}
|
||||
app.SetGoalForTab(tab.ID, "")
|
||||
app.SetCollaborationModeForTab(tab.ID, "normal")
|
||||
}
|
||||
app.mu.RLock()
|
||||
runtimeForPath := app.runtimeBySessionKey[sessionRuntimeKey(path)]
|
||||
runtimeCount := len(app.runtimeBySessionKey)
|
||||
view := app.sessionRuntimeViewLocked(tab)
|
||||
app.mu.RUnlock()
|
||||
if runtimeForPath == nil || runtimeForPath.Owner != tab || runtimeCount != 1 {
|
||||
t.Fatalf("runtime registry owner=%#v count=%d, want one runtime for tab", runtimeForPath, runtimeCount)
|
||||
}
|
||||
setToken := func() {
|
||||
if err := app.SetTokenModeForTab(tab.ID, boot.TokenModeDelivery); err != nil {
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
}
|
||||
if view.Phase != sessionRuntimeReady || tab.sessionLeaseRuntimeKey() != sessionRuntimeKey(path) {
|
||||
t.Fatalf("runtime phase=%q lease=%q, want ready/%q", view.Phase, tab.sessionLeaseRuntimeKey(), sessionRuntimeKey(path))
|
||||
}
|
||||
if order == "collaboration-first" {
|
||||
setCollaboration()
|
||||
setToken()
|
||||
} else {
|
||||
setToken()
|
||||
setCollaboration()
|
||||
}
|
||||
|
||||
ctrl := app.controllerForTab(tab)
|
||||
if ctrl == nil {
|
||||
t.Fatal("final controller is nil")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", got)
|
||||
}
|
||||
if got := ctrl.AgentPreset(); got != boot.AgentPresetBalanced {
|
||||
t.Fatalf("controller AgentPreset = %q, want balanced", got)
|
||||
}
|
||||
switch collaborationMode {
|
||||
case "goal":
|
||||
if ctrl.PlanMode() || ctrl.GoalStatus() != control.GoalStatusRunning ||
|
||||
ctrl.Goal() != "exercise all runtime axes" {
|
||||
t.Fatalf("Goal axes plan=%v goal=%q status=%q", ctrl.PlanMode(), ctrl.Goal(), ctrl.GoalStatus())
|
||||
}
|
||||
})
|
||||
}
|
||||
case "plan":
|
||||
if !ctrl.PlanMode() || ctrl.GoalStatus() == control.GoalStatusRunning {
|
||||
t.Fatalf("Plan axes plan=%v goal=%q status=%q", ctrl.PlanMode(), ctrl.Goal(), ctrl.GoalStatus())
|
||||
}
|
||||
default:
|
||||
if ctrl.PlanMode() || ctrl.GoalStatus() == control.GoalStatusRunning {
|
||||
t.Fatalf("Normal axes plan=%v goal=%q status=%q", ctrl.PlanMode(), ctrl.Goal(), ctrl.GoalStatus())
|
||||
}
|
||||
}
|
||||
app.mu.RLock()
|
||||
runtimeForPath := app.runtimeBySessionKey[sessionRuntimeKey(path)]
|
||||
runtimeCount := len(app.runtimeBySessionKey)
|
||||
view := app.sessionRuntimeViewLocked(tab)
|
||||
app.mu.RUnlock()
|
||||
if runtimeForPath == nil || runtimeForPath.Owner != tab || runtimeCount != 1 {
|
||||
t.Fatalf("runtime registry owner=%#v count=%d, want one runtime for tab", runtimeForPath, runtimeCount)
|
||||
}
|
||||
if view.Phase != sessionRuntimeReady || tab.sessionLeaseRuntimeKey() != sessionRuntimeKey(path) {
|
||||
t.Fatalf("runtime phase=%q lease=%q, want ready/%q", view.Phase, tab.sessionLeaseRuntimeKey(), sessionRuntimeKey(path))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,36 +504,26 @@ func TestGoalAndCollaborationResyncBeforeSendPreserveRunningDeliveryScope(t *tes
|
||||
}
|
||||
|
||||
func TestTokenModeSwitchWaitsForForegroundTurnAdmission(t *testing.T) {
|
||||
app, tab, _, _ := newGoalDeliveryYoloTestApp(t, control.GoalStatusBlocked)
|
||||
// Name kept for history; the deprecated wrapper must not wait on turn admission.
|
||||
app, tab, oldCtrl, _ := newGoalDeliveryYoloTestApp(t, control.GoalStatusBlocked)
|
||||
tab.turnStartMu.Lock()
|
||||
defer tab.turnStartMu.Unlock()
|
||||
done := make(chan error, 1)
|
||||
started := make(chan struct{})
|
||||
go func() {
|
||||
close(started)
|
||||
done <- app.SetTokenModeForTab(tab.ID, boot.TokenModeDelivery)
|
||||
}()
|
||||
<-started
|
||||
for app.runtimeRebuildMu.TryLock() {
|
||||
app.runtimeRebuildMu.Unlock()
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("token-mode switch returned before reaching the admission gate: %v", err)
|
||||
default:
|
||||
runtime.Gosched()
|
||||
}
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("token-mode switch crossed the turn-admission gate: %v", err)
|
||||
default:
|
||||
}
|
||||
tab.turnStartMu.Unlock()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("SetTokenModeForTab after admission release: %v", err)
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("token-mode switch did not finish after turn admission released")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SetTokenModeForTab blocked on turn admission; deprecated wrapper must not wait")
|
||||
}
|
||||
if app.controllerForTab(tab) != oldCtrl {
|
||||
t.Fatal("SetTokenModeForTab rebuilt the controller")
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("token mode = %q, want pinned full", got)
|
||||
}
|
||||
}
|
||||
+30
-38
@@ -1351,9 +1351,10 @@ func TestRebindTabToLoadedSessionPersistsAndRestoresSessionProfile(t *testing.T)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
|
||||
}
|
||||
if currentMeta.TokenMode != boot.TokenModeEconomy || currentMeta.Mode != "plan" || currentMeta.ToolApprovalMode != control.ToolApprovalAuto {
|
||||
t.Fatalf("current session profile = token:%q mode:%q approval:%q, want economy/plan/auto",
|
||||
currentMeta.TokenMode, currentMeta.Mode, currentMeta.ToolApprovalMode)
|
||||
if currentMeta.TokenMode != boot.TokenModeFull || currentMeta.AgentPreset != boot.AgentPresetBalanced ||
|
||||
currentMeta.Mode != "plan" || currentMeta.ToolApprovalMode != control.ToolApprovalAuto {
|
||||
t.Fatalf("current session profile = token:%q preset:%q mode:%q approval:%q, want full/balanced/plan/auto",
|
||||
currentMeta.TokenMode, currentMeta.AgentPreset, currentMeta.Mode, currentMeta.ToolApprovalMode)
|
||||
}
|
||||
if got := currentTabTokenMode(tab); got != boot.TokenModeFull {
|
||||
t.Fatalf("rebound token mode = %q, want full", got)
|
||||
@@ -1662,7 +1663,7 @@ func assertAtomicRebindFailurePreservedSource(
|
||||
t.Fatalf("lease after failed rebind = %q, want source key %q", got, sessionRuntimeKey(sourcePath))
|
||||
}
|
||||
if !oldCtrl.PlanMode() || oldCtrl.ToolApprovalMode() != control.ToolApprovalYolo ||
|
||||
currentTabTokenMode(tab) != boot.TokenModeEconomy {
|
||||
currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatalf("source profile changed after failed rebind: plan=%v approval=%q token=%q",
|
||||
oldCtrl.PlanMode(), oldCtrl.ToolApprovalMode(), currentTabTokenMode(tab))
|
||||
}
|
||||
@@ -1784,9 +1785,10 @@ func TestCloseTabPersistsSessionProfileBeforeRemovingVisibleTab(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta current ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.TokenMode != boot.TokenModeEconomy || meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "finish the review" {
|
||||
t.Fatalf("closed session profile = token:%q mode:%q approval:%q goal:%q, want economy/plan/auto/goal",
|
||||
meta.TokenMode, meta.Mode, meta.ToolApprovalMode, meta.Goal)
|
||||
if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetBalanced ||
|
||||
meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "finish the review" {
|
||||
t.Fatalf("closed session profile = token:%q preset:%q mode:%q approval:%q goal:%q, want full/balanced/plan/auto/goal",
|
||||
meta.TokenMode, meta.AgentPreset, meta.Mode, meta.ToolApprovalMode, meta.Goal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1846,60 +1848,50 @@ func TestKeepOnlyVisibleTabPersistsRemovedSessionProfile(t *testing.T) {
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta removed ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.TokenMode != boot.TokenModeEconomy || meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "keep this profile" {
|
||||
t.Fatalf("removed session profile = token:%q mode:%q approval:%q goal:%q, want economy/plan/auto/goal",
|
||||
meta.TokenMode, meta.Mode, meta.ToolApprovalMode, meta.Goal)
|
||||
if meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetBalanced ||
|
||||
meta.Mode != "plan" || meta.ToolApprovalMode != control.ToolApprovalAuto || meta.Goal != "keep this profile" {
|
||||
t.Fatalf("removed session profile = token:%q preset:%q mode:%q approval:%q goal:%q, want full/balanced/plan/auto/goal",
|
||||
meta.TokenMode, meta.AgentPreset, meta.Mode, meta.ToolApprovalMode, meta.Goal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTabSessionProfileIgnoresTerminalGoalState(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
sessionPath := filepath.Join(desktopSessionDir(globalTabWorkspaceRoot()), "terminal-goal.jsonl")
|
||||
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessionPath := filepath.Join(dir, "terminal-goal.jsonl")
|
||||
writeHistoryTestSession(t, sessionPath, "terminal prompt")
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
|
||||
TokenMode: boot.TokenModeEconomy,
|
||||
Mode: "plan",
|
||||
ToolApprovalMode: control.ToolApprovalAuto,
|
||||
Goal: "stale terminal goal",
|
||||
TokenMode: boot.TokenModeEconomy, Mode: "plan", ToolApprovalMode: control.ToolApprovalAuto, Goal: "stale terminal goal",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(store.SessionGoalState(sessionPath), []byte(`{"goal":"stale terminal goal","status":"complete"}`), 0o644); err != nil {
|
||||
t.Fatalf("write goal state: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
profile := loadTabSessionProfile(sessionPath)
|
||||
if profile.goal != "" {
|
||||
t.Fatalf("loaded profile goal = %q, want terminal goal ignored", profile.goal)
|
||||
if profile.goal != "" || profile.tokenMode != boot.TokenModeEconomy || profile.mode != "plan" || profile.toolApprovalMode != control.ToolApprovalAuto {
|
||||
t.Fatalf("profile=%+v", profile)
|
||||
}
|
||||
if profile.tokenMode != boot.TokenModeEconomy || profile.mode != "plan" || profile.toolApprovalMode != control.ToolApprovalAuto {
|
||||
t.Fatalf("loaded profile = token:%q mode:%q approval:%q, want economy/plan/auto",
|
||||
profile.tokenMode, profile.mode, profile.toolApprovalMode)
|
||||
tab := &WorkspaceTab{}
|
||||
applyTabSessionProfile(tab, profile)
|
||||
if currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatal("legacy economy must not change runtime tokenMode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTabSessionProfileMissingApprovalDefaultsAsk(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
sessionPath := filepath.Join(desktopSessionDir(globalTabWorkspaceRoot()), "legacy-missing-approval.jsonl")
|
||||
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessionPath := filepath.Join(dir, "legacy-missing-approval.jsonl")
|
||||
writeHistoryTestSession(t, sessionPath, "legacy prompt")
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{Mode: "normal"}); err != nil {
|
||||
t.Fatalf("SaveBranchMetaPreserveUpdated: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
profile := loadTabSessionProfile(sessionPath)
|
||||
if profile.toolApprovalMode != control.ToolApprovalAsk {
|
||||
if profile := loadTabSessionProfile(sessionPath); profile.toolApprovalMode != control.ToolApprovalAsk {
|
||||
t.Fatalf("legacy missing tool approval mode = %q, want ask", profile.toolApprovalMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
@@ -58,8 +59,8 @@ func TestEnsureBlankTabInheritsActiveTabLocalSettings(t *testing.T) {
|
||||
if created.effort == nil || *created.effort != "max" {
|
||||
t.Fatalf("effort = %v, want inherited \"max\"", created.effort)
|
||||
}
|
||||
if created.tokenMode != "economy" {
|
||||
t.Fatalf("tokenMode = %q, want inherited \"economy\"", created.tokenMode)
|
||||
if created.tokenMode != boot.TokenModeFull {
|
||||
t.Fatalf("tokenMode = %q, want pinned default %q (must not inherit economy)", created.tokenMode, boot.TokenModeFull)
|
||||
}
|
||||
if created.toolApprovalMode != control.ToolApprovalAuto {
|
||||
t.Fatalf("toolApprovalMode = %q, want global default auto", created.toolApprovalMode)
|
||||
|
||||
@@ -207,9 +207,8 @@ func TestModeRebuildAndABANavigationFenceLeaseFailureAndOldEpochEvent(t *testing
|
||||
t.Fatal("A to B rebind did not reach restored candidate")
|
||||
}
|
||||
|
||||
// This call observes A/economy, then waits behind the rebind transaction.
|
||||
// Once B commits it must rebuild B to full, never interleave with the
|
||||
// candidate or publish a half-updated profile.
|
||||
// Concurrent deprecated mode call must not interleave with the rebind
|
||||
// transaction or publish a half-updated profile.
|
||||
modeDone := make(chan error, 1)
|
||||
go func() {
|
||||
modeDone <- app.SetTokenModeForTab(tab.ID, boot.TokenModeFull)
|
||||
@@ -219,7 +218,7 @@ func TestModeRebuildAndABANavigationFenceLeaseFailureAndOldEpochEvent(t *testing
|
||||
t.Fatalf("A to B rebind: %v", err)
|
||||
}
|
||||
if err := <-modeDone; err != nil {
|
||||
t.Fatalf("mode rebuild after A to B: %v", err)
|
||||
t.Fatalf("SetTokenModeForTab after A to B: %v", err)
|
||||
}
|
||||
|
||||
stale := capturedOldEvent
|
||||
@@ -232,7 +231,7 @@ func TestModeRebuildAndABANavigationFenceLeaseFailureAndOldEpochEvent(t *testing
|
||||
if app.controllerForTab(tab) == oldCtrl ||
|
||||
sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(pathB) ||
|
||||
currentTabTokenMode(tab) != boot.TokenModeFull {
|
||||
t.Fatalf("A to B plus mode rebuild did not converge: ctrl=%p path=%q token=%q",
|
||||
t.Fatalf("A to B plus SetTokenModeForTab did not converge: ctrl=%p path=%q token=%q",
|
||||
app.controllerForTab(tab), tab.currentSessionPath(), currentTabTokenMode(tab))
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,15 @@ func TestDesktopE2EBlocksRepeatedSuccessfulBashFileWrite(t *testing.T) {
|
||||
|
||||
var calls int32
|
||||
reg := tool.NewRegistry()
|
||||
todoWrite, ok := tool.LookupBuiltin("todo_write")
|
||||
if !ok {
|
||||
t.Fatal("todo_write builtin is not registered")
|
||||
}
|
||||
reg.Add(todoWrite)
|
||||
reg.Add(desktopCountingTool{name: "bash", calls: &calls})
|
||||
args := `{"command":"python -c \"with open('prompt.txt', 'w') as f: f.write('hello')\""}`
|
||||
args := `{"command":"printf 'hello' > prompt.txt"}`
|
||||
prov := agenttest.NewMock("scripted-desktop",
|
||||
agenttest.Turn{ToolCalls: []provider.ToolCall{{ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"update the prompt file","status":"in_progress"}]}`}}},
|
||||
agenttest.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: args}}},
|
||||
agenttest.Turn{ToolCalls: []provider.ToolCall{{ID: "c2", Name: "bash", Arguments: args}}},
|
||||
agenttest.Turn{ToolCalls: []provider.ToolCall{{ID: "c3", Name: "bash", Arguments: args}}},
|
||||
@@ -62,7 +68,7 @@ func TestDesktopE2EBlocksRepeatedSuccessfulBashFileWrite(t *testing.T) {
|
||||
for {
|
||||
select {
|
||||
case e := <-events:
|
||||
if e.Kind == event.ToolResult {
|
||||
if e.Kind == event.ToolResult && e.Tool.Name == "bash" {
|
||||
results = append(results, e)
|
||||
}
|
||||
if e.Kind == event.TurnDone {
|
||||
|
||||
@@ -15,12 +15,8 @@ import (
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// TestRuntimeRebuildsEmitRuntimeRebuiltForTab pins the frontend contract the
|
||||
// prompt-chime dedupe depends on: every in-place controller replacement must
|
||||
// announce itself. Model, effort, and token-mode switches rebuild the tab's
|
||||
// controller WITHOUT an agent:ready — the rebuilt controller restarts its
|
||||
// approval/ask id counter at "1", so without runtime:rebuilt the frontend's
|
||||
// id-keyed chime dedupe mutes the first prompt after a switch.
|
||||
// TestRuntimeRebuildsEmitRuntimeRebuiltForTab pins the chime-dedupe contract:
|
||||
// model/effort rebuilds emit runtime:rebuilt; deprecated SetTokenMode does not.
|
||||
func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
@@ -129,10 +125,12 @@ func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) {
|
||||
if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
}
|
||||
waitCount(3, "token-mode switch")
|
||||
|
||||
// Give a real rebuild event time to arrive if the no-op regresses.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(rebuilt) != 2 {
|
||||
t.Fatalf("after SetTokenModeForTab: runtime:rebuilt events = %v, want 2 (no rebuild)", rebuilt)
|
||||
}
|
||||
for i, id := range rebuilt {
|
||||
if id == "VIA-APP-QUEUE" {
|
||||
t.Fatalf("event %d took the App-level fallback queue; it must ride the tab sink queue so it orders before the rebuilt controller's agent events (full: %v)", i, rebuilt)
|
||||
@@ -141,6 +139,7 @@ func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) {
|
||||
t.Fatalf("event %d carried tab id %q, want %q (full: %v)", i, id, tab.ID, rebuilt)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// TestRuntimeReattachFencesPendingAskBeforeReplay pins the detached-runtime
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/sessioninbox"
|
||||
"reasonix/internal/store"
|
||||
)
|
||||
@@ -112,6 +114,50 @@ func TestRestoreTrashedSessionFilePreservesStubOnSubagentConflict(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreSessionFinishesCommittedPartialTrashMove(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(dir, "partial-restore.jsonl")
|
||||
itemDir := filepath.Join(sessionTrashPath(dir), filepath.Base(sessionPath))
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir trash item: %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(itemDir, filepath.Base(sessionPath))
|
||||
const transcript = `{"role":"user","content":"trashed transcript"}` + "\n"
|
||||
if err := os.WriteFile(trashPath, []byte(transcript), 0o600); err != nil {
|
||||
t.Fatalf("write trashed transcript: %v", err)
|
||||
}
|
||||
metaPath := store.SessionMeta(sessionPath)
|
||||
if err := os.WriteFile(metaPath, []byte(`{"scope":"global","topic_id":"topic_partial","topic_title":"Partial"}`), 0o600); err != nil {
|
||||
t.Fatalf("write leftover live metadata: %v", err)
|
||||
}
|
||||
if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
|
||||
t.Fatalf("mark cleanup pending: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
|
||||
if err := app.RestoreSession(trashPath); err != nil {
|
||||
t.Fatalf("restore partial trash move: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(sessionPath); err != nil || string(got) != transcript {
|
||||
t.Fatalf("restored transcript = %q, %v", got, err)
|
||||
}
|
||||
if meta, ok, err := agent.LoadBranchMeta(sessionPath); err != nil || !ok || meta.TopicID != "topic_partial" {
|
||||
t.Fatalf("restored metadata = %+v, %v, %v", meta, ok, err)
|
||||
}
|
||||
if agent.IsCleanupPending(sessionPath) {
|
||||
t.Fatal("restore retained cleanup-pending marker")
|
||||
}
|
||||
if _, err := os.Stat(itemDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("trash item survived restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueSessionInboxForRestoreTest(t *testing.T, sessionPath, text string) sessioninbox.InboxReceipt {
|
||||
t.Helper()
|
||||
inbox, err := sessioninbox.Open(sessionPath, sessioninbox.Limits{})
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
// TestDesktopHotRebuildPathsKeepSessionTemp covers the same-session rebuilds
|
||||
// that use boot.Build directly (not boot.Rebuild): model and effort switches.
|
||||
// Role setting (token mode) switches in place and is covered separately.
|
||||
// Each rebuild must pass the old Controller's SessionTemp so temporary files
|
||||
// survive (Issue #7575).
|
||||
// SetTokenMode is a deprecated no-op and must keep the same controller and
|
||||
// SessionTemp. Each rebuild must pass the old Controller's SessionTemp so
|
||||
// temporary files survive (Issue #7575).
|
||||
func TestDesktopHotRebuildPathsKeepSessionTemp(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
@@ -76,7 +76,7 @@ func TestDesktopHotRebuildPathsKeepSessionTemp(t *testing.T) {
|
||||
}
|
||||
if tc.inPlace {
|
||||
if newCtrl != oldCtrl {
|
||||
t.Fatal("role setting must switch in place without replacing the controller")
|
||||
t.Fatal("SetTokenMode must not replace the controller")
|
||||
}
|
||||
} else if newCtrl == oldCtrl {
|
||||
t.Fatal("rebuild did not install a replacement *control.Controller")
|
||||
|
||||
@@ -1954,8 +1954,6 @@ func (a *App) buildSettingReplacementController(tab *WorkspaceTab, snap tabRunti
|
||||
WorkspaceRoot: snap.workspaceRoot,
|
||||
SessionDir: sessionDirForSnapshot(snap),
|
||||
EffortOverride: cloneStringPtr(snap.effort),
|
||||
AgentPreset: boot.NormalizeAgentPreset(runtime.tokenMode),
|
||||
TokenMode: runtime.tokenMode,
|
||||
SharedHost: a.lookupSharedHost(snap.sharedHostKey),
|
||||
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
||||
SubagentParentLive: a.subagentParentProbeForBuild(tab),
|
||||
|
||||
+32
-30
@@ -201,6 +201,8 @@ api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
}
|
||||
|
||||
func TestSaveTabsPersistsNonBalancedTokenModes(t *testing.T) {
|
||||
// New writes pin the deprecated dual-write compat labels; economy/delivery
|
||||
// must not be persisted as live modes.
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
@@ -210,36 +212,36 @@ func TestSaveTabsPersistsNonBalancedTokenModes(t *testing.T) {
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
app.mu.Lock()
|
||||
app.saveTabsLocked()
|
||||
app.mu.Unlock()
|
||||
for _, inMemory := range []string{"economy", "delivery", "full"} {
|
||||
tab.tokenMode = inMemory
|
||||
app.mu.Lock()
|
||||
app.saveTabsLocked()
|
||||
app.mu.Unlock()
|
||||
|
||||
got := loadTabsFile()
|
||||
if len(got.Tabs) != 1 {
|
||||
t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
|
||||
}
|
||||
if got.Tabs[0].TokenMode != boot.TokenModeFull || got.Tabs[0].AgentPreset != boot.AgentPresetBalanced {
|
||||
t.Fatalf("saved compat after in-memory %q = token:%q preset:%q, want full/balanced",
|
||||
inMemory, got.Tabs[0].TokenMode, got.Tabs[0].AgentPreset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTabsFileDecodesLegacyTokenModes(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := desktopConfigDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"tabs":[{"id":"old","scope":"global","tokenMode":"economy","agentPreset":"light"}],"activeTab":"old"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, tabsFileName), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := loadTabsFile()
|
||||
if len(got.Tabs) != 1 {
|
||||
t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
|
||||
}
|
||||
if got.Tabs[0].TokenMode != "economy" {
|
||||
t.Fatalf("saved token mode = %q, want economy", got.Tabs[0].TokenMode)
|
||||
}
|
||||
|
||||
tab.tokenMode = "delivery"
|
||||
app.mu.Lock()
|
||||
app.saveTabsLocked()
|
||||
app.mu.Unlock()
|
||||
|
||||
got = loadTabsFile()
|
||||
if got.Tabs[0].TokenMode != "delivery" {
|
||||
t.Fatalf("saved token mode = %q, want delivery", got.Tabs[0].TokenMode)
|
||||
}
|
||||
|
||||
tab.tokenMode = "full"
|
||||
app.mu.Lock()
|
||||
app.saveTabsLocked()
|
||||
app.mu.Unlock()
|
||||
|
||||
got = loadTabsFile()
|
||||
if got.Tabs[0].TokenMode != "" {
|
||||
t.Fatalf("balanced/full token mode should be omitted from persistence, got %q", got.Tabs[0].TokenMode)
|
||||
if len(got.Tabs) != 1 || got.Tabs[0].TokenMode != boot.TokenModeEconomy || got.Tabs[0].AgentPreset != boot.AgentPresetLight {
|
||||
t.Fatalf("legacy tabs decode = %+v", got.Tabs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,8 +487,8 @@ func TestMetaReportsGoalStatus(t *testing.T) {
|
||||
app.SetCollaborationModeForTab(tab.ID, "plan")
|
||||
tab.tokenMode = boot.TokenModeEconomy
|
||||
meta = app.MetaForTab(tab.ID)
|
||||
if meta.CollaborationMode != "plan" || meta.TokenMode != boot.TokenModeEconomy {
|
||||
t.Fatalf("profile meta = %+v, want plan + economy", meta)
|
||||
if meta.CollaborationMode != "plan" || meta.TokenMode != boot.TokenModeFull || meta.AgentPreset != boot.AgentPresetBalanced {
|
||||
t.Fatalf("profile meta = %+v, want plan + pinned full/balanced", meta)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-31
@@ -2231,8 +2231,8 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
|
||||
Mode: currentTabMode(tab),
|
||||
CollaborationMode: currentTabCollaborationMode(tab),
|
||||
ToolApprovalMode: currentTabToolApprovalMode(tab),
|
||||
AgentPreset: boot.NormalizeAgentPreset(currentTabTokenMode(tab)),
|
||||
TokenMode: currentTabTokenMode(tab),
|
||||
AgentPreset: boot.AgentPresetBalanced, // deprecated compat label
|
||||
TokenMode: boot.TokenModeFull, // deprecated compat label
|
||||
Goal: currentTabGoal(tab),
|
||||
GoalStatus: currentTabGoalStatus(tab),
|
||||
StartupErr: tab.StartupErr,
|
||||
@@ -2545,14 +2545,14 @@ func (a *App) ActivateTopic(scope, workspaceRoot, topicID, sessionPath string) (
|
||||
// creating or reusing a blank session, it removes other visible tabs while
|
||||
// preserving running runtimes as detached background sessions.
|
||||
func (a *App) EnsureBlankSurface(scope, workspaceRoot string) (TabMeta, error) {
|
||||
return a.ensureBlankSurface(scope, workspaceRoot, "")
|
||||
return a.ensureBlankSurface(scope, workspaceRoot)
|
||||
}
|
||||
|
||||
func (a *App) ensureBlankSurface(scope, workspaceRoot, tokenMode string) (TabMeta, error) {
|
||||
func (a *App) ensureBlankSurface(scope, workspaceRoot string) (TabMeta, error) {
|
||||
a.singleSurfaceMu.Lock()
|
||||
defer a.singleSurfaceMu.Unlock()
|
||||
|
||||
meta, err := a.ensureBlankTab(scope, workspaceRoot, tokenMode)
|
||||
meta, err := a.ensureBlankTab(scope, workspaceRoot)
|
||||
if err != nil {
|
||||
return TabMeta{}, err
|
||||
}
|
||||
@@ -2584,10 +2584,10 @@ func tabInWorkspace(tab *WorkspaceTab, workspaceRoot string) bool {
|
||||
// creates one if none exists. Reusing a blank tab keeps repeated "new session"
|
||||
// clicks from piling up empty conversations.
|
||||
func (a *App) EnsureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
|
||||
return a.ensureBlankTab(scope, workspaceRoot, "")
|
||||
return a.ensureBlankTab(scope, workspaceRoot)
|
||||
}
|
||||
|
||||
func (a *App) ensureBlankTab(scope, workspaceRoot, forcedTokenMode string) (TabMeta, error) {
|
||||
func (a *App) ensureBlankTab(scope, workspaceRoot string) (TabMeta, error) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope != "project" {
|
||||
scope = "global"
|
||||
@@ -2657,20 +2657,16 @@ func (a *App) ensureBlankTab(scope, workspaceRoot, forcedTokenMode string) (TabM
|
||||
// continuity without letting the active tab override global defaults (#4019).
|
||||
inheritedModel := defaultModel
|
||||
var inheritedEffort *string
|
||||
inheritedTokenMode := boot.TokenModeFull
|
||||
inheritedTokenMode := boot.TokenModeFull // deprecated dual-write compat value
|
||||
inheritedMode := tabModeFromAxes(false, defaultToolApprovalMode == control.ToolApprovalYolo)
|
||||
inheritedToolApprovalMode := defaultToolApprovalMode
|
||||
inheritedDisabledMCP := map[string]ServerView{}
|
||||
var inheritedMCPOrder []string
|
||||
if active := a.activeTabLocked(); active != nil {
|
||||
inheritedEffort = cloneStringPtr(active.effort)
|
||||
inheritedTokenMode = currentTabTokenMode(active)
|
||||
inheritedDisabledMCP = cloneServerViewMap(active.disabledMCP)
|
||||
inheritedMCPOrder = append([]string(nil), active.mcpOrder...)
|
||||
}
|
||||
if strings.TrimSpace(forcedTokenMode) != "" {
|
||||
inheritedTokenMode = boot.NormalizeTokenMode(forcedTokenMode)
|
||||
}
|
||||
|
||||
if topicID := a.indexedBlankTopicIDLocked(scope, workspaceRoot); topicID != "" {
|
||||
// Reuse a previously-indexed but unused blank topic instead of
|
||||
@@ -3892,8 +3888,6 @@ func (a *App) buildTabControllerWithContextCore(tab *WorkspaceTab, loadedSession
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: sessionDir,
|
||||
EffortOverride: cloneStringPtr(buildEffort),
|
||||
AgentPreset: boot.NormalizeAgentPreset(buildTokenMode),
|
||||
TokenMode: buildTokenMode,
|
||||
SharedHost: sharedHost,
|
||||
CleanupPendingReconciler: reconcileDesktopCleanupPending,
|
||||
SubagentParentLive: a.subagentParentProbeForBuild(tab),
|
||||
@@ -4961,7 +4955,7 @@ func (a *App) saveTabsCollectLocked() (string, []desktopTabEntry, string, uint64
|
||||
Model: tab.model,
|
||||
Effort: cloneStringPtr(tab.effort),
|
||||
TokenMode: persistedTabTokenMode(currentTabTokenMode(tab)),
|
||||
AgentPreset: boot.NormalizeAgentPreset(currentTabTokenMode(tab)),
|
||||
AgentPreset: boot.AgentPresetBalanced,
|
||||
Mode: persistedTabMode(currentTabMode(tab)),
|
||||
Goal: persistedTabGoal(tab),
|
||||
ToolApprovalMode: persistedToolApprovalMode(currentTabToolApprovalMode(tab)),
|
||||
@@ -6138,7 +6132,7 @@ func (a *App) tabSessionRecoveryMeta(tab *WorkspaceTab) func(control.SessionReco
|
||||
topicID := tab.TopicID
|
||||
topicTitle := tab.TopicTitle
|
||||
model := strings.TrimSpace(tab.model)
|
||||
tokenMode := persistedTabTokenMode(boot.NormalizeTokenMode(tab.tokenMode))
|
||||
tokenMode := boot.TokenModeFull // deprecated dual-write compat value
|
||||
mode := normalizeTabMode(tab.mode)
|
||||
toolApprovalMode := normalizeToolApprovalMode(tab.toolApprovalMode)
|
||||
goal := strings.TrimSpace(tab.goal)
|
||||
@@ -6165,7 +6159,7 @@ func (a *App) tabSessionRecoveryMeta(tab *WorkspaceTab) func(control.SessionReco
|
||||
TopicID: topicID,
|
||||
TopicTitle: topicTitle,
|
||||
Model: model,
|
||||
AgentPreset: boot.NormalizeAgentPreset(tokenMode),
|
||||
AgentPreset: boot.AgentPresetBalanced, // deprecated compat label
|
||||
TokenMode: tokenMode,
|
||||
Mode: persistedTabMode(mode),
|
||||
ToolApprovalMode: persistedToolApprovalMode(toolApprovalMode),
|
||||
@@ -7443,11 +7437,12 @@ func currentTabToolApprovalMode(tab *WorkspaceTab) string {
|
||||
return normalizeToolApprovalMode(tab.toolApprovalMode)
|
||||
}
|
||||
|
||||
// currentTabTokenMode returns the deprecated dual-write compat label. Execution
|
||||
// modes no longer exist at runtime; the value is pinned so one-version-old
|
||||
// clients keep parsing tab state.
|
||||
func currentTabTokenMode(tab *WorkspaceTab) string {
|
||||
if tab == nil {
|
||||
return boot.TokenModeFull
|
||||
}
|
||||
return boot.NormalizeTokenMode(tab.tokenMode)
|
||||
_ = tab
|
||||
return boot.TokenModeFull
|
||||
}
|
||||
|
||||
// tabRuntimeSnapshot is a consistent under-a.mu copy of the per-tab fields
|
||||
@@ -7568,10 +7563,6 @@ func (s tabRuntimeSnapshot) currentToolApprovalMode() string {
|
||||
return normalizeToolApprovalMode(s.toolApprovalMode)
|
||||
}
|
||||
|
||||
func (s tabRuntimeSnapshot) currentTokenMode() string {
|
||||
return boot.NormalizeTokenMode(s.tokenMode)
|
||||
}
|
||||
|
||||
// normalizedRuntime reads live Controller state only after the App snapshot has
|
||||
// released a.mu. Rebuild callers hold turnStartMu while invoking it, so all
|
||||
// three axes and the legacy Goal fallback describe one admitted runtime state.
|
||||
@@ -7623,12 +7614,11 @@ func applyNormalizedRuntimeToTabLocked(tab *WorkspaceTab, runtime normalizedTabR
|
||||
}
|
||||
}
|
||||
|
||||
// persistedTabTokenMode pins the deprecated tokenMode compat value written
|
||||
// into tab state and session metas. It is always the safe default.
|
||||
func persistedTabTokenMode(mode string) string {
|
||||
mode = boot.NormalizeTokenMode(mode)
|
||||
if mode == boot.TokenModeEconomy || mode == boot.TokenModeDelivery {
|
||||
return mode
|
||||
}
|
||||
return ""
|
||||
_ = mode
|
||||
return boot.TokenModeFull
|
||||
}
|
||||
|
||||
func normalizeToolApprovalMode(mode string) string {
|
||||
@@ -7980,7 +7970,7 @@ func saveTabSessionMetaSnapshot(snap tabSessionMetaSnapshot) error {
|
||||
m.TopicID = snap.topicID
|
||||
m.TopicTitle = snap.topicTitle
|
||||
m.TokenMode = persistedTabTokenMode(snap.tokenMode)
|
||||
m.AgentPreset = boot.NormalizeAgentPreset(snap.tokenMode)
|
||||
m.AgentPreset = boot.AgentPresetBalanced
|
||||
m.Mode = persistedTabMode(snap.mode)
|
||||
m.ToolApprovalMode = persistedToolApprovalMode(snap.toolApprovalMode)
|
||||
m.Goal = strings.TrimSpace(snap.goal)
|
||||
|
||||
+14
-10
@@ -26,13 +26,12 @@ An ACP host should launch one of these commands:
|
||||
```sh
|
||||
reasonix acp
|
||||
reasonix acp --model deepseek-pro
|
||||
reasonix acp --preset delivery
|
||||
reasonix acp
|
||||
```
|
||||
|
||||
`--model` selects the startup model when the client does not override it.
|
||||
`--preset` sets the startup execution setting to `light`, `balanced`, or `delivery`
|
||||
(default `balanced`). Legacy `--profile economy|balanced|delivery` still works
|
||||
(`economy` → `light`). Both remain session-configurable after initialization.
|
||||
Reasonix runs one adaptive standard execution whose planning, verification,
|
||||
and review strength follows task risk automatically.
|
||||
|
||||
Standard output is reserved for ACP messages. Reasonix sends diagnostics to
|
||||
standard error, so hosts must not merge the two streams. Run `reasonix setup`
|
||||
@@ -88,8 +87,8 @@ tools run locally inside the Reasonix process.
|
||||
## Session lifecycle
|
||||
|
||||
Each ACP session owns an independent Reasonix controller, workspace root, model,
|
||||
execution setting (agent preset), collaboration mode, approval mode, MCP set, and
|
||||
persisted transcript. State does not leak between sessions.
|
||||
collaboration mode, approval mode, MCP set, and persisted transcript. State does
|
||||
not leak between sessions.
|
||||
|
||||
| Method | Behavior |
|
||||
| --- | --- |
|
||||
@@ -117,10 +116,9 @@ one mode selector:
|
||||
| Collaboration mode | `normal`, `plan`, `goal` | `modes` and `session/set_mode` |
|
||||
| Model | Configured `provider/model` entries | `configOptions` with id `model` |
|
||||
| Reasoning effort | Provider-supported levels or `auto` | `configOptions` with id `effort` |
|
||||
| Execution setting | `light`, `balanced`, `delivery` | `configOptions` with id `agent_preset` (legacy id `work_mode` still accepted: `economy` → `light`) |
|
||||
| Tool approval | `ask`, `auto`, `yolo` | `configOptions` with id `tool_approval` |
|
||||
|
||||
Use `session/set_config_option` for model, effort, execution setting, and tool approval.
|
||||
Use `session/set_config_option` for model, effort, and tool approval.
|
||||
Its parameters are `sessionId`, `configId` and `value`, where `configId` is the
|
||||
`id` of the option as advertised in `configOptions`:
|
||||
|
||||
@@ -141,8 +139,14 @@ Note that the field is `configId`, not `optionId`. The result is the full
|
||||
refreshed `configOptions` array. An unknown id returns `-32602 InvalidParams`.
|
||||
|
||||
Model and effort changes rebuild the session controller while preserving its
|
||||
history and the other axes. Execution-setting (`agent_preset`) and tool-approval
|
||||
changes update the gate in place without rebuilding the controller.
|
||||
history and the other axes. Tool-approval changes update the gate in place
|
||||
without rebuilding the controller.
|
||||
|
||||
Execution modes are gone. For one compatibility version, clients that still
|
||||
send `session/set_config_option` with `configId` `agent_preset` or `work_mode`
|
||||
(including legacy aliases `profile`, `runtime_profile`, `token_mode`) receive a
|
||||
successful no-op: nothing switches, nothing rebuilds, and the result carries a
|
||||
`deprecatedNotice` explaining the adaptive standard execution.
|
||||
|
||||
For older clients, `session/set_model` remains available. The legacy
|
||||
`session/set_mode` values `default` and `auto` are also accepted as Normal + Ask
|
||||
|
||||
+10
-9
@@ -24,13 +24,10 @@ ACP host 应启动以下命令之一:
|
||||
```sh
|
||||
reasonix acp
|
||||
reasonix acp --model deepseek-pro
|
||||
reasonix acp --preset delivery
|
||||
```
|
||||
|
||||
客户端未覆盖模型时,`--model` 用于选择启动模型;`--preset` 用于选择启动执行设定:
|
||||
`light`、`balanced` 或 `delivery`(默认 `balanced`)。兼容的
|
||||
`--profile economy|balanced|delivery` 仍可用(`economy` → `light`)。初始化后,
|
||||
两者仍可按会话切换。
|
||||
客户端未覆盖模型时,`--model` 用于选择启动模型。Reasonix 使用同一套自适应标准执行,
|
||||
规划、验证与复查强度随任务风险自动调整。
|
||||
|
||||
标准输出专用于 ACP 消息,Reasonix 会把诊断写入标准错误,因此 host 不应合并这两个
|
||||
流。尚未配置 provider 时先运行 `reasonix setup`;initialize 响应也会声明一个启动
|
||||
@@ -80,7 +77,7 @@ reasonix acp --preset delivery
|
||||
|
||||
## 会话生命周期
|
||||
|
||||
每个 ACP 会话都拥有独立的 Reasonix Controller、工作区根目录、模型、执行设定、协作
|
||||
每个 ACP 会话都拥有独立的 Reasonix Controller、工作区根目录、模型、协作
|
||||
模式、审批模式、MCP 集合和持久化 transcript,会话之间不会泄漏状态。
|
||||
|
||||
| 方法 | 行为 |
|
||||
@@ -108,10 +105,9 @@ Reasonix 把互不相关的选择拆成独立控制轴,而不是混在一个 m
|
||||
| 协作模式 | `normal`、`plan`、`goal` | `modes` 和 `session/set_mode` |
|
||||
| 模型 | 已配置的 `provider/model` | id 为 `model` 的 `configOptions` |
|
||||
| 推理强度 | provider 支持的等级或 `auto` | id 为 `effort` 的 `configOptions` |
|
||||
| 执行设定 | `light`、`balanced`、`delivery` | id 为 `agent_preset` 的 `configOptions`(兼容 id `work_mode`:`economy` → `light`) |
|
||||
| 工具审批 | `ask`、`auto`、`yolo` | id 为 `tool_approval` 的 `configOptions` |
|
||||
|
||||
模型、推理强度、执行设定和工具审批统一使用 `session/set_config_option`。它的参数是
|
||||
模型、推理强度和工具审批统一使用 `session/set_config_option`。它的参数是
|
||||
`sessionId`、`configId` 和 `value`,其中 `configId` 取 `configOptions` 中该选项的
|
||||
`id`:
|
||||
|
||||
@@ -132,7 +128,12 @@ Reasonix 把互不相关的选择拆成独立控制轴,而不是混在一个 m
|
||||
数组;id 未知时返回 `-32602 InvalidParams`。
|
||||
|
||||
切换模型或推理强度时会重建会话 Controller,同时保留历史和其他控制轴;
|
||||
执行设定(`agent_preset` / 兼容 `work_mode`)与工具审批只更新 gate,不重建 Controller。
|
||||
工具审批只更新 gate,不重建 Controller。
|
||||
|
||||
执行模式已移除。兼容期内,仍发送 `configId` 为 `agent_preset` 或 `work_mode`
|
||||
(含旧别名 `profile`、`runtime_profile`、`token_mode`)的
|
||||
`session/set_config_option` 请求会得到成功的空操作:不切换、不重建,返回值中的
|
||||
`deprecatedNotice` 会说明自适应标准执行。
|
||||
|
||||
旧客户端仍可使用 `session/set_model`。`session/set_mode` 也继续接受 legacy 值
|
||||
`default` 和 `auto`,分别表示“常规 + 询问”和“常规 + Yolo”;新客户端应使用上面的
|
||||
|
||||
+12
-13
@@ -15,18 +15,20 @@ configuration, plugins, and sandbox policy, see the [Guide](./GUIDE.md).
|
||||
```sh
|
||||
reasonix
|
||||
reasonix --model deepseek-pro
|
||||
reasonix --preset delivery --effort high
|
||||
reasonix --effort high
|
||||
reasonix --dir /path/to/project
|
||||
```
|
||||
|
||||
Reasonix runs a single adaptive standard execution: planning depth,
|
||||
verification breadth, and independent review follow the task's risk
|
||||
automatically — there is no execution mode to pick.
|
||||
|
||||
Running `reasonix` without a subcommand starts the interactive terminal UI. Use
|
||||
`reasonix setup` first when no provider is configured.
|
||||
|
||||
| Flag | Purpose |
|
||||
| --- | --- |
|
||||
| `--model NAME` | Select a configured provider or `provider/model` reference. |
|
||||
| `--preset light\|balanced\|delivery` | Select the agent execution setting (执行设定). Default: `balanced`. |
|
||||
| `--profile economy\|balanced\|delivery` | Deprecated alias for `--preset` (`economy` → `light`). |
|
||||
| `--effort LEVEL` | Override reasoning effort for this session. |
|
||||
| `--max-steps N` | Set a one-off maximum tool-call round budget; `0` uses automatic execution. |
|
||||
| `--dir PATH` | Change the workspace root before loading config and tools. |
|
||||
@@ -137,8 +139,8 @@ echo "explain this code" | reasonix run
|
||||
```
|
||||
|
||||
`reasonix run` keeps the normal streamed terminal presentation unless `-p` or a
|
||||
structured output format is selected. It also accepts `--model`, `--preset`
|
||||
(or legacy `--profile`), `--max-steps`, `--effort`, `--dir`, `--add-dir`,
|
||||
structured output format is selected. It also accepts `--model`,
|
||||
`--max-steps`, `--effort`, `--dir`, `--add-dir`,
|
||||
`--continue`, `--resume QUERY`, `--copy`, `--allowed-tools`, `--permission-mode`,
|
||||
and `--auto` / `-y` (an alias for `--permission-mode auto`).
|
||||
|
||||
@@ -400,13 +402,12 @@ single-key shortcuts.
|
||||
| `Ctrl+Y` | Toggle YOLO independently of the composer-mode cycle. |
|
||||
|
||||
The responsive footer keeps interaction state on the left and, when space
|
||||
allows, places model, effort, and execution setting on the right. Its second row shows
|
||||
allows, places model and effort on the right. Its second row shows
|
||||
available repository and session telemetry such as cache hit rate, context use,
|
||||
compaction headroom, background jobs, and balance. `ready` means the composer is
|
||||
idle; that slot changes when a picker, approval, image paste, shell mode, or
|
||||
other interaction needs attention. Narrow terminals move or compact complete
|
||||
groups instead of cutting labels in half. Visible labels and execution-setting values
|
||||
follow `/language`.
|
||||
groups instead of cutting labels in half. Visible labels follow `/language`.
|
||||
|
||||
Use `/theme auto|light|dark` to select the terminal background mode, or choose a
|
||||
named accent from `/theme`. Both composer borders, the insertion cursor,
|
||||
@@ -439,8 +440,7 @@ the displayed list matches the commands the TUI accepts.
|
||||
| `/model` | Search configured models and switch the active model. |
|
||||
| `/provider` | Choose a provider, then choose one of its configured models. |
|
||||
| `/resume` | Search recent sessions and switch to one. |
|
||||
| `/status` | Show model, effort, cache, Git, background jobs, and execution setting or balance details. |
|
||||
| `/preset [light\|balanced\|delivery]` | View or change the agent execution setting without rebuilding the controller. `/work-mode` and `/profile` remain compatibility aliases (`economy` → `light`). |
|
||||
| `/status` | Show model, effort, cache, Git, background jobs, and balance details. |
|
||||
| `/theme [auto\|light\|dark\|style]` | View or change the CLI background mode and accent palette. |
|
||||
| `/currency [auto\|CNY\|USD]` | View or change the user-global fee display currency and refresh the runtime. |
|
||||
| `/paste-image` | Read a clipboard image and insert an editable attachment token. |
|
||||
@@ -466,9 +466,8 @@ the displayed list matches the commands the TUI accepts.
|
||||
Switching model or effort rebuilds the runtime while preserving the
|
||||
active conversation, session-scoped permission overrides, additional directory
|
||||
access, and session ownership. `/reload` uses the same fail-atomic rebuild.
|
||||
`/preset` (and legacy `/work-mode` / `/profile`) updates the execution setting
|
||||
in place without rebuilding the controller; all three execution settings share the
|
||||
same provider-visible tool surface (`use_capability` for optional tools).
|
||||
Execution modes no longer exist: planning, verification, and review strength
|
||||
follow task risk per turn.
|
||||
|
||||
## Session catalog diagnostics
|
||||
|
||||
|
||||
+6
-32
@@ -14,7 +14,7 @@
|
||||
```sh
|
||||
reasonix
|
||||
reasonix --model deepseek-pro
|
||||
reasonix --preset delivery --effort high
|
||||
reasonix --effort high
|
||||
reasonix --dir /path/to/project
|
||||
```
|
||||
|
||||
@@ -24,8 +24,6 @@ reasonix --dir /path/to/project
|
||||
| 参数 | 用途 |
|
||||
| --- | --- |
|
||||
| `--model NAME` | 选择已配置的 provider 或 `provider/model` 引用。 |
|
||||
| `--preset light\|balanced\|delivery` | 选择 Agent 执行设定。默认 `balanced`。 |
|
||||
| `--profile economy\|balanced\|delivery` | 已弃用,等同 `--preset`(`economy` → `light`)。 |
|
||||
| `--effort LEVEL` | 覆盖当前会话的 reasoning effort。 |
|
||||
| `--max-steps N` | 为本次运行设置工具调用轮数上限;`0` 使用自动执行。 |
|
||||
| `--dir PATH` | 加载配置和工具前切换 workspace 根目录。 |
|
||||
@@ -125,7 +123,7 @@ echo "解释这段代码" | reasonix run
|
||||
```
|
||||
|
||||
未使用 `-p` 或结构化输出格式时,`reasonix run` 保持正常的终端流式展示。它也接受
|
||||
`--model`、`--preset`(或兼容的 `--profile`)、`--max-steps`、`--effort`、`--dir`、
|
||||
`--model`、`--max-steps`、`--effort`、`--dir`、
|
||||
`--add-dir`、`--continue`、`--resume QUERY`、`--copy`、`--allowed-tools` 和
|
||||
`--permission-mode`,以及作为 `--permission-mode auto` 别名的 `--auto` / `-y`。
|
||||
|
||||
@@ -353,7 +351,7 @@ reasonix -p "同时更新两个项目" \
|
||||
| `Shift+Tab` | 按 `Ask → Auto → Plan → Ask` 循环。 |
|
||||
| `Ctrl+Y` | 独立切换 YOLO,不进入安全模式循环。 |
|
||||
|
||||
响应式底栏左侧显示当前交互状态;空间足够时,右侧显示模型、推理强度和执行设定。第二行按
|
||||
响应式底栏左侧显示当前交互状态;空间足够时,右侧显示模型和推理强度。第二行按
|
||||
可用性显示仓库与会话遥测,例如缓存命中率、上下文占用、压缩余量、后台任务和余额。
|
||||
“就绪”表示输入框当前空闲;进入选择器、审批、图片粘贴、shell 模式等需要用户关注的状态
|
||||
时,这个位置会切换。窄终端会移动或压缩完整信息组,不会从中间截断标签。可见标签和执行
|
||||
@@ -382,8 +380,7 @@ SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键
|
||||
| `/model` | 搜索已配置模型并切换当前模型。 |
|
||||
| `/provider` | 选择 provider,再选择该 provider 下的模型。 |
|
||||
| `/resume` | 搜索最近会话并切换。 |
|
||||
| `/status` | 显示模型、effort、cache、Git、后台任务,以及执行设定或余额信息。 |
|
||||
| `/preset [light\|balanced\|delivery]` | 查看或切换 Agent 执行设定(不重建 Controller);`/work-mode` 与 `/profile` 为兼容别名(`economy` → `light`)。 |
|
||||
| `/status` | 显示模型、effort、cache、Git、后台任务和余额信息。 |
|
||||
| `/theme [auto\|light\|dark\|style]` | 查看或切换 CLI 背景模式和强调色。 |
|
||||
| `/currency [auto\|CNY\|USD]` | 查看或切换用户全局费用展示币种,并刷新当前运行时。 |
|
||||
| `/paste-image` | 读取剪贴板图片并插入可编辑的附件标记。 |
|
||||
@@ -404,32 +401,9 @@ SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键
|
||||
|
||||
切换模型或 effort 会重建运行时,同时保留当前对话、会话级权限覆盖、附加目录
|
||||
访问权限和 session ownership。`/reload` 使用同一套失败原子重建语义。
|
||||
`/preset`(及兼容的 `/work-mode` / `/profile`)就地更新执行设定,不重建
|
||||
Controller;三种执行设定共享同一套 provider 可见工具面(可选能力经
|
||||
`use_capability` 调度)。
|
||||
Reasonix 只有一种自适应标准执行:规划、验证与复查强度随任务风险自动调整,
|
||||
不再存在执行模式。
|
||||
|
||||
## 会话目录索引诊断
|
||||
|
||||
桌面会话目录索引是可丢弃、可重建的 SQLite 查询投影;JSONL transcript 和 sidecar
|
||||
始终是权威数据。可只读检查,或仅替换投影:
|
||||
|
||||
```sh
|
||||
reasonix doctor sessions [--json]
|
||||
reasonix sessions reindex [--json]
|
||||
reasonix sessions reindex --dir /path/to/sessions --dir /another/path
|
||||
```
|
||||
|
||||
不传 `--dir` 时,reindex 会覆盖全局会话和桌面保存的全部项目。迁移、失败降级和数据安全
|
||||
契约见 [Session Catalog and Desktop Startup](./SESSION_CATALOG.md)。
|
||||
|
||||
历史搜索使用独立的可丢弃投影:
|
||||
|
||||
```sh
|
||||
reasonix doctor catalogs [--json]
|
||||
reasonix catalogs reindex history [--dir PATH ...] [--json]
|
||||
```
|
||||
|
||||
详见 [历史搜索 Catalog](./HISTORY_SEARCH_CATALOG.zh-CN.md)。
|
||||
用量统计使用独立的可丢弃 rollup 投影:
|
||||
reasonix catalogs reindex usage [--json]
|
||||
详见 [用量 Catalog](./USAGE_CATALOG.zh-CN.md)。
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# 协作方式与运行模式
|
||||
# 协作方式与自适应执行
|
||||
|
||||
Reasonix 桌面端输入框左下角的菜单包含两条互相独立的轴:
|
||||
Reasonix 桌面端输入框左下角的菜单包含两条互相独立的协作轴:
|
||||
|
||||
- **计划模式**:要求模型先产出计划,确认后再切换到实施。
|
||||
- **目标模式**:给 Reasonix 一个目标,让它持续推进直到完成、阻塞或停止。
|
||||
- **运行模式**:在“轻量”“均衡”“交付优先”三档之间选择成本与交付质量倾向。
|
||||
|
||||
计划模式和目标模式属于“协作方式”轴,通常二选一;运行模式是独立轴,可以和普通聊天、计划模式、目标模式任意组合。
|
||||
计划模式和目标模式属于“协作方式”轴,通常二选一。执行强度不是可选项:Reasonix
|
||||
对每个任务使用同一套自适应标准执行,规划、验证与独立复查的强度随任务风险自动调整
|
||||
(低风险任务零辅助模型调用,中高风险任务自动获得完整验证与复查闭环)。
|
||||
|
||||
## 计划模式
|
||||
|
||||
@@ -30,7 +31,7 @@ Reasonix 桌面端输入框左下角的菜单包含两条互相独立的轴:
|
||||
- 计划模式不是“自动完成任务”。它会先暂停在计划阶段,等待你确认。
|
||||
- 计划模式通过模型指令和确认步骤减少误改风险,但不替代 Ask、`deny` 规则或 Sandbox。
|
||||
- 如果你已经明确要直接改一个小问题,普通模式通常更快。
|
||||
- 计划模式只控制“先规划再执行”的流程,不决定成本或交付倾向;可按任务选择任一运行模式。
|
||||
- 计划模式只控制“先规划再执行”的流程;验证与复查强度由任务风险自动决定。
|
||||
- Ask 不是只读模式:需审批的 writer 在批准后仍可执行。需要技术上严格只读时,应使用显式只读 subagent/权限配置,而不是依赖 Plan 或 Ask。
|
||||
|
||||
## 目标模式
|
||||
@@ -68,66 +69,41 @@ Goal 模式会把这些部分当作任务边界;除非下一步涉及不可逆
|
||||
`/goal` 后,Reasonix 才会进入 Goal;旧简单/写入/研究参数仍可解析,但不再改变额度。
|
||||
- Goal 没有默认轮数、turn 数或时间上限。`--max-steps`、正数时间/成本预算仍可由用户显式选择。
|
||||
- 旧 `.reasonix/autoresearch/...` 目录不会被新版本创建或改写;显式引用旧任务路径时只会读取并恢复成普通 Goal。
|
||||
- 目标模式和计划模式是同一协作轴。切到计划模式时,会退出目标草稿/目标显示状态;运行模式不会因此改变。
|
||||
- 目标模式和计划模式是同一协作轴。切到计划模式时,会退出目标草稿/目标显示状态。
|
||||
|
||||
## 执行设定(Agent Preset)
|
||||
## 自适应标准执行
|
||||
|
||||
执行设定决定会话的宿主规划 / 验证 / 独立复审策略。三种执行设定共享同一套 provider
|
||||
可见核心工具面(直接编码工具 + 后台 shell 生命周期 + 稳定的 `use_capability` 代理);
|
||||
可选能力一律经 `use_capability` 调度,不扩展 top-level schema。每个标签页独立保存选择;
|
||||
会话内切换就地更新执行设定,不重建 Controller。
|
||||
Reasonix 只有一种执行体验:系统根据任务风险自动选择规划、验证和复查强度,用户无需
|
||||
(也无法)选择执行模式。所有任务共享同一套 provider 可见核心工具面(直接编码工具 +
|
||||
后台 shell 生命周期 + 稳定的 `use_capability` 代理);可选能力一律经 `use_capability`
|
||||
调度,不扩展 top-level schema。
|
||||
|
||||
### 轻量(Light)
|
||||
风险分级策略:
|
||||
|
||||
Light 对应旧的 economy / “省 token” 档。工具面与其他档一致,策略上优先直接执行、定向
|
||||
验证;仅高风险/安全类任务提升到强制独立复审与完整验证。
|
||||
- **对话与咨询**:直接执行,零辅助模型调用。
|
||||
- **普通只读查询**:定向证据,结论必须引用实际读取结果。
|
||||
- **结构化多步只读分析**:轻量规划 + 证据核对 + 条件复审。
|
||||
- **单文件、目标明确、低风险修改**:直接执行 + 零额外模型调用的 Atomic TaskContract +
|
||||
定向检查。
|
||||
- **同一功能面内的多文件修改**:轻量规划 + 针对性或项目级检查 + 条件复审(覆盖不足时
|
||||
自动升级为强制)。
|
||||
- **跨模块、公共接口、持久化、活跃 Goal**:完整规划 + 完整检查 + 强制独立复审 + 证据
|
||||
闭环。
|
||||
- **安全、权限、迁移、发布、破坏性操作**:完整规划 + 完整检查 + 强制安全复查;缺证据
|
||||
不得 Complete。
|
||||
|
||||
- 适合日常问答、代码解释、小范围阅读和成本敏感任务。
|
||||
- 不会降低模型本身的推理能力;差异在宿主策略门禁。
|
||||
- 旧值 `economy` / `eco` 映射为 Light。
|
||||
闭环执行包含稳定交付合约:变更前建立 `todo_write` 验收清单;条件允许时先复现;修复
|
||||
根因;运行聚焦验证;复审 diff 与相邻行为;变更后以 `complete_step` 正式签收;证据不足时
|
||||
只能返回 Partial、Unverified 或 Blocked,绝不“表面成功”。风险在任务内只升不降:回执
|
||||
发现改动触及高风险面或超出初始判断时,策略实时上调并补齐验证与复查。
|
||||
|
||||
### 均衡 · 默认(Balanced)
|
||||
|
||||
Balanced 是默认档,对应旧持久化值 `full`。按风险自动选择轻/全规划,分档验证,中风险
|
||||
多文件变更可条件触发独立复审。
|
||||
|
||||
- 适合大多数普通开发与聊天任务。
|
||||
- `--preset balanced` 和不传 `--preset` 的运行语义不变。
|
||||
- 旧 session/tab 中空值或 `full` 都会继续解释为 Balanced。
|
||||
|
||||
### 交付 · 完整验证(Delivery)
|
||||
|
||||
Delivery 使用与其他档相同的统一工具面,并增加稳定交付合约:明确验收标准;条件允许时先
|
||||
复现;检查项目规则和相关代码;修复根因;运行聚焦验证;复审 diff 与相邻行为;没有证据时
|
||||
不宣称成功。
|
||||
|
||||
- 适合编码、修 bug、跨文件实现和需要可靠交付证据的任务。
|
||||
- 通常会使用更多模型调用和 token,耗时也可能更长。
|
||||
- 宿主会在变更或验证命令前检查是否已有具体的 `todo_write` 验收清单;缺失时直接阻止。
|
||||
- 发生变更后,宿主会要求在最后一次变更之后复查结果、运行成功的验证命令,并用
|
||||
`complete_step` 正式签收。
|
||||
- Skill/MCP 的 `require`/`prefer` 路由由宿主门禁强制。
|
||||
- 中/高风险改动会强制运行结构化 `review` / `security_review`。
|
||||
|
||||
### 怎么选择
|
||||
|
||||
- 桌面端点击输入框左下角菜单,在“执行设定”下选择“轻量”“均衡”或“交付”。
|
||||
- CLI 启动时使用 `reasonix --preset light|balanced|delivery`,非交互运行使用
|
||||
`reasonix run --preset ...`。兼容 `--profile economy|balanced|delivery`。
|
||||
- TUI 会话内使用 `/preset light|balanced|delivery` 热切换;不带参数的 `/preset` 会列出
|
||||
三档并标记当前项。`/work-mode` 与 `/profile` 保留为兼容别名。
|
||||
- 会话内切换保留 history、session 路径、审批/Yolo 状态,不重建 Controller。当前 turn、
|
||||
审批/询问或后台任务仍在运行时不能切换。
|
||||
- `/preset` 只修改当前会话,不写入全局默认值。三种执行设定共享统一工具 schema,切换不会
|
||||
制造新的工具面缓存前缀。
|
||||
|
||||
## 协作方式与 Profile 如何组合
|
||||
## 协作方式如何组合
|
||||
|
||||
| 组合 | 是否支持 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 普通 + 轻量 | 支持 | 日常聊天的低开销选择。 |
|
||||
| 计划 + 均衡 | 支持 | 先用完整工具面研究并确认方案,再执行。 |
|
||||
| 目标 + 交付优先 | 支持 | 持续推进明确目标,并强调实现与验证闭环。 |
|
||||
| 普通 | 支持 | 日常聊天与问答;低风险任务零辅助模型调用。 |
|
||||
| 计划 | 支持 | 先用完整工具面研究并确认方案,再执行。 |
|
||||
| 目标 | 支持 | 持续推进明确目标,自动获得完整规划与证据闭环。 |
|
||||
| 计划 + 目标 | 不建议同时使用 | 两者都是协作方式轴,切换计划会退出目标草稿/目标显示状态。 |
|
||||
| 工具权限(询问/自动/Yolo)+ 任一组合 | 支持 | 工具权限控制工具审批;Sandbox 继续控制文件、进程与网络边界。 |
|
||||
|
||||
@@ -135,8 +111,8 @@ Delivery 使用与其他档相同的统一工具面,并增加稳定交付合
|
||||
|
||||
## 推荐选择
|
||||
|
||||
- **不确定怎么选**:保持均衡和普通模式。
|
||||
- **成本敏感或简单问答**:选择轻量。
|
||||
- **不确定怎么选**:保持普通模式;执行强度会随任务风险自动调整。
|
||||
- **成本敏感或简单问答**:直接提问即可——低风险任务不会产生规划或复查模型调用。
|
||||
- **担心 Reasonix 改错**:开启计划模式先确认方案,并保持 Ask 与合适的 Sandbox 边界。
|
||||
- **想让 Reasonix 持续推进一个明确目标**:开启目标模式,目标写清楚成功标准。
|
||||
- **编码、修 bug 或复杂实现更看重最终质量**:选择交付优先,可再搭配目标模式。
|
||||
- **看重最终质量**:无需选择——中高风险任务自动获得完整验证、独立复查与证据闭环。
|
||||
+39
-50
@@ -177,7 +177,7 @@ code; unsent counters stay in a bounded local queue for a later invocation.
|
||||
The ping contains a dedicated random 128-bit CLI install ID, CLI version, OS,
|
||||
architecture, and the `cli` surface marker. Counter batches use that same ID for
|
||||
daily active-install deduplication and contain only fixed buckets such as CLI
|
||||
mode/profile, permission/session mode, turn latency, finish reason, cache-hit
|
||||
surface, permission/session mode, turn latency, finish reason, cache-hit
|
||||
range, generic Provider/tool error class, compaction, recovery counters, and
|
||||
normalized UI language. This ID is separate from the desktop install ID and is
|
||||
not an account, hardware, repository, or session identifier.
|
||||
@@ -616,13 +616,12 @@ Use `/theme auto|light|dark` to select the background mode, or `/theme <style>`
|
||||
to select one of the named accent palettes shown by bare `/theme`.
|
||||
|
||||
The responsive footer keeps the active Ask/Auto/Plan or YOLO posture and current
|
||||
interaction state on the left. On wider terminals, model, effort, and work mode
|
||||
interaction state on the left. On wider terminals, model and effort
|
||||
stay together on the right; a second row shows available Git identity, cache hit
|
||||
rate, context use, compaction headroom, jobs, and balance. `ready` is the idle
|
||||
composer state, not a model-health check. Pickers, approvals, image paste, shell
|
||||
mode, and other active interactions replace it. Narrow terminals move, wrap, or
|
||||
compact whole groups; labels and displayed work-mode values follow `/language`,
|
||||
while `/work-mode` command arguments remain the stable English identifiers.
|
||||
compact whole groups; visible labels follow `/language`.
|
||||
|
||||
Chat and transcript shortcuts:
|
||||
|
||||
@@ -661,7 +660,6 @@ Mode and display shortcuts:
|
||||
| `Shift+Tab` | Cycles Ask → Auto → Plan → Ask | YOLO remains outside this composer-mode cycle; the footer shows the active mode. |
|
||||
| `Ctrl+Y` | Toggles YOLO on/off | Turning YOLO off restores the previous Ask/Auto base when known. Terminals that forward Command/Super may also send `Cmd+Y`, but `Ctrl+Y` is the reliable terminal shortcut. |
|
||||
| `--yolo`, `--dangerously-skip-permissions` | Starts chat in YOLO | Same runtime mode as `Ctrl+Y`. |
|
||||
| `/preset [light|balanced|delivery]` | Shows or switches the current session's execution setting (执行设定) | `/work-mode` and `/profile` are compatibility aliases (`economy` → `light`). Switching updates the execution setting in place without rebuilding the controller; blocked while a turn, approval, or background job is active. |
|
||||
| `/theme [auto|light|dark|style]` | Shows or switches the CLI theme | Bare `/theme` lists background modes and named accent palettes. The choice is saved to the user config; `REASONIX_THEME` and `REASONIX_THEME_STYLE` can override it for one run. |
|
||||
| `Ctrl+O` | Toggles verbose reasoning display | Also available through `/verbose`. |
|
||||
| `Ctrl+B` | Expands or collapses long shell output | Long shell-output hint lines can also be clicked in the transcript; text selection is handled in-app while the full-screen TUI has mouse reporting enabled. |
|
||||
@@ -974,7 +972,7 @@ convenient.
|
||||
## Slash commands
|
||||
|
||||
In an interactive `reasonix` session, built-in commands (`/compact`, `/context`, `/new`, `/clear`, `/rewind`,
|
||||
`/tree`, `/branch`, `/switch`, `/todo`, `/model`, `/work-mode`, `/mcp`, `/skills`, `/hooks`,
|
||||
`/tree`, `/branch`, `/switch`, `/todo`, `/model`, `/mcp`, `/skills`, `/hooks`,
|
||||
`/memory`, `/goal`, `/output-style`, `/sandbox`, `/language`,
|
||||
`/reasoning-language`, `/help`) run
|
||||
locally — `/help` lists them all. Built-in **skills** such as `/init`,
|
||||
@@ -1110,8 +1108,8 @@ recovery, providers, or maintainer workflows.
|
||||
|
||||
No setup, network connection, vector database, or embedding service is needed.
|
||||
Search results prefer the query language while retaining explicit `en`,
|
||||
`zh-CN`, audience, and catalog filters. Balanced and Delivery profiles expose the
|
||||
tool directly; Economy connects the `docs` source on demand. Every result reports
|
||||
`zh-CN`, audience, and catalog filters. The docs capability is exposed through
|
||||
the unified `use_capability` surface for every task. Every result reports
|
||||
the product version, immutable source revision, and corpus SHA-256 digest. Release
|
||||
CI compiles the CLI and rejects publication unless that embedded manifest matches
|
||||
the candidate's `docs/*.md`, `release-notes/releases.json`, and build identity. A
|
||||
@@ -1162,7 +1160,7 @@ new read/search results, mutations, verification, todo/signoff changes, and
|
||||
reviews advance the goal; an exact tool/argument/result repeat does not.
|
||||
Cumulative turns, tokens, real provider requests, and active work time are
|
||||
tracked and shown as statistics; a token limit appears only when explicitly
|
||||
configured. A paused goal keeps its todos, Delivery
|
||||
configured. A paused goal keeps its todos, evidence
|
||||
checkpoint, and runtime history — use `/goal resume` to continue, or `/goal
|
||||
pause` to pause a running goal manually. `/goal status` shows turns, requests,
|
||||
tokens, and work time. Repeated host failures, zero-evidence rounds, and Todo
|
||||
@@ -1184,9 +1182,9 @@ Legacy simple/write/research classes are still inferred for sidecar and CLI
|
||||
compatibility, but they no longer select an execution quota. There is no
|
||||
separate research runtime to configure. Goal state stays in the normal session sidecar, progress
|
||||
comes only from novel host receipts, canonical todos, `complete_step`, review
|
||||
and the Delivery checkpoint, and completion is decided by Delivery readiness
|
||||
plus the bounded Goal evaluator. Light/Balanced honor an `update_goal`
|
||||
`completion.unverified` account for checks the model could not run; a second
|
||||
and the evidence checkpoint, and completion is decided by closed-loop readiness
|
||||
plus the bounded Goal evaluator. An `update_goal`
|
||||
`completion.unverified` account is honored for checks the model could not run; a second
|
||||
identical complete on the same leftover checks finishes the Goal instead of
|
||||
looping. Legacy `.reasonix/autoresearch/<task-id>/` archives are
|
||||
read-only: an explicit old path can be recovered as an ordinary Goal, but new
|
||||
@@ -1224,7 +1222,7 @@ Reasonix routes each turn deterministically without another classifier model:
|
||||
questions, short follow-ups, clear atomic edits, and bounded read-only actions
|
||||
go straight to the executor; bounded implementation work may receive a short
|
||||
light plan. Ambiguous, cross-surface, structured, high-risk, active-Goal, or
|
||||
Delivery work receives a full plan unless the request is clearly atomic or
|
||||
closed-loop work receives a full plan unless the request is clearly atomic or
|
||||
read-only. Explicit Plan Mode
|
||||
remains a separate host workflow and is never planned twice. An explicit
|
||||
`plan first` / `先规划` request forces planning, while `just do it` / `直接改`
|
||||
@@ -1320,7 +1318,7 @@ subagents with only read-only research tools plus safe foreground bash, return
|
||||
only the final answer, and do not create resumable subagent transcripts.
|
||||
Read-only nested delegation may be available until `max_subagent_depth` is
|
||||
reached, but writer-capable `task` / `run_skill` remain unavailable inside these
|
||||
read-only child registries. Execution settings share one tool surface: call
|
||||
read-only child registries. Every task shares one tool surface: call
|
||||
`use_capability` for `read_only_skill` and other optional tools. Subsequent
|
||||
writer calls still pass through Permissions/Sandbox.
|
||||
|
||||
@@ -1367,7 +1365,7 @@ shared Host and connections, per-agent frontend/ledger) and may call installed
|
||||
or project-configured MCP without `readOnlyHint`. Those calls use the trusted
|
||||
MCP permission path (live authorization plus explicit deny only); writer and
|
||||
destructive calls are still serialized, recorded as mutations, and subject to
|
||||
Delivery evidence/lease guards rather than Planner handoff. Strict
|
||||
closed-loop evidence/lease guards rather than Planner handoff. Strict
|
||||
`read_only_task` / `read_only_skill` / review sub-agents share the stable proxy
|
||||
schema and connection reuse but keep the strict execution gate
|
||||
(`authorized && readOnlyHint && !destructiveHint`). Profile `allowed-tools`
|
||||
@@ -1389,48 +1387,39 @@ is narrower than the dedicated Planner: the Planner accepts authorized opaque
|
||||
non-destructive MCP, while a strict child requires an explicit reader hint and
|
||||
never exposes writers at all.
|
||||
|
||||
Choose the startup execution setting with
|
||||
`--preset light|balanced|delivery` (for example, `reasonix run --preset
|
||||
delivery "fix and verify this bug"`). Legacy `--profile economy|balanced|delivery`
|
||||
still works (`economy` maps to `light`). All three execution settings share the same
|
||||
provider-visible core tool surface: direct read/bash/edit/write, background-shell
|
||||
lifecycle tools, `ask`/`compress` when registered, and the stable
|
||||
`use_capability` proxy for optional tools (search, MCP, skills, subagents, docs,
|
||||
web_fetch, and so on). Calling `use_capability` never expands the top-level
|
||||
provider schema, so the prompt-cache tool prefix stays stable across execution
|
||||
settings.
|
||||
Reasonix runs a single adaptive **standard execution**: planning depth,
|
||||
verification breadth, and independent review follow the task's risk
|
||||
automatically, per turn.
|
||||
|
||||
What differs by execution setting is host policy (planning route, verification
|
||||
intensity, independent review floor), not the tool list:
|
||||
Every task shares the same provider-visible core tool surface: direct
|
||||
read/bash/edit/write, background-shell lifecycle tools, `ask`/`compress` when
|
||||
registered, and the stable `use_capability` proxy for optional tools (search,
|
||||
MCP, skills, subagents, docs, web_fetch, and so on). Calling `use_capability`
|
||||
never expands the top-level provider schema, so the prompt-cache tool prefix
|
||||
stays stable across every task.
|
||||
|
||||
- **Light** — direct-first planning, targeted verification, independent review only
|
||||
on high-risk/security class work; optional capabilities stay on-demand.
|
||||
- **Balanced** (default) — auto light/full planning by risk, risk-tiered
|
||||
verification, conditional independent review on medium-risk multi-file work.
|
||||
- **Delivery** — full acceptance criteria, full verification, forced independent
|
||||
review on medium+ risk, and security review on high-risk work. Mutations and
|
||||
verification commands are blocked until a concrete acceptance list exists; a
|
||||
changed result cannot finalize until it has been reviewed, verified after the
|
||||
latest mutation, and signed off with `complete_step`.
|
||||
What adapts is host policy, not the tool list:
|
||||
|
||||
- Conversation and advisory turns run direct with no auxiliary model calls.
|
||||
- Plain read-only queries cite their actual reads (targeted evidence).
|
||||
- Single-file, anchored, low-risk modifications run direct with a
|
||||
zero-extra-model-call Atomic TaskContract and targeted checks.
|
||||
- Multi-file same-surface work gets a light plan, project-level checks, and a
|
||||
conditional independent review that escalates to forced when coverage is weak.
|
||||
- Cross-module, public-interface, persistence, security/permission/migration/
|
||||
release, and active-Goal work get a full plan, full checks, forced independent
|
||||
review (plus security review for safety classes), and the full evidence
|
||||
closed loop: acceptance criteria before state changes, verification after the
|
||||
latest mutation, review, and sign-off with `complete_step`. Missing evidence
|
||||
ends the turn Partial, Unverified, or Blocked — never Complete.
|
||||
- Risk only ratchets upward within a turn: when receipts show the change set
|
||||
touched high-risk surfaces or outgrew the initial judgment, the policy
|
||||
escalates and the missing verification/review is still demanded.
|
||||
|
||||
Meta tools such as `task`, `run_skill`, and `review` are not counted as mutations
|
||||
by themselves — only real child writes are. Read-only analysis remains available
|
||||
without forcing a write.
|
||||
|
||||
Inside an interactive TUI session, use `/preset` to inspect the current choice or
|
||||
`/preset light|balanced|delivery` to switch it. `/work-mode` and `/profile` are
|
||||
compatibility aliases. The switch updates the execution setting in place without
|
||||
rebuilding the controller, preserves history, the session path, leases, and the
|
||||
Ask/Auto/YOLO posture, and is rejected while a turn, approval/question, background
|
||||
job, or another runtime switch is active. This command changes only the current
|
||||
session and does not persist a new global default. Because the provider-visible
|
||||
tool surface is unified, switching execution settings does not create a new tool-schema
|
||||
cache prefix.
|
||||
|
||||
Desktop tabs expose the same three choices (shown as Light / Balanced / Delivery)
|
||||
and dual-write `agentPreset` with legacy `tokenMode` (`economy`/`full`/`delivery`)
|
||||
for one compatibility version.
|
||||
|
||||
For interactive frontends, Plan Mode is always an explicit user choice. Select
|
||||
Plan in the desktop collaboration-mode control or cycle to Plan with
|
||||
`Shift+Tab` in the CLI. Reasonix first drafts a plan, then waits for approval
|
||||
|
||||
+25
-27
@@ -497,11 +497,10 @@ CLI/TUI 文本输入可通过 `[ui].cursor_shape` 设置光标形状,支持 `u
|
||||
命名配色,再用 `/theme <style>` 选择强调色。
|
||||
|
||||
响应式底栏左侧保留当前 Ask/Auto/Plan 或 YOLO 姿态和交互状态;终端较宽时,模型、推理
|
||||
强度和执行设定作为一组靠右显示,第二行按可用性显示 Git 标识、缓存命中率、上下文占用、
|
||||
强度作为一组靠右显示,第二行按可用性显示 Git 标识、缓存命中率、上下文占用、
|
||||
压缩余量、后台任务和余额。“就绪”只表示输入框空闲,并不是模型健康检查;选择器、审批、
|
||||
图片粘贴、shell 模式等活动会替换这个状态。窄终端会按完整信息组移动、换行或压缩。
|
||||
标签和展示用的执行设定值跟随 `/language`,但 `/preset`(及兼容的 `/work-mode`)命令
|
||||
参数继续使用稳定的英文标识 `light|balanced|delivery`。
|
||||
标签和展示用的语言跟随 `/language`。
|
||||
|
||||
聊天与 transcript:
|
||||
|
||||
@@ -533,7 +532,6 @@ CLI/TUI 文本输入可通过 `[ui].cursor_shape` 设置光标形状,支持 `u
|
||||
| `Shift+Tab` | 按 Ask → Auto → Plan → Ask 循环 | YOLO 不进入这个输入模式循环;底部状态栏会显示当前模式。 |
|
||||
| `Ctrl+Y` | 切换 YOLO 开/关 | 关闭 YOLO 时会尽量恢复之前的 Ask/Auto 基底。终端若能转发 Command/Super,也可能识别 `Cmd+Y`,但稳定可用的是 `Ctrl+Y`。 |
|
||||
| `--yolo`、`--dangerously-skip-permissions` | 启动时进入 YOLO | 和 `Ctrl+Y` 是同一个运行时模式。 |
|
||||
| `/preset [light|balanced|delivery]` | 查看或切换当前会话的执行设定 | `/work-mode` 与 `/profile` 是兼容别名(`economy` → `light`)。切换就地更新执行设定、不重建 Controller;有回合、审批或后台任务时会拒绝。 |
|
||||
| `/theme [auto|light|dark|style]` | 查看或切换 CLI 主题 | 不带参数会列出背景模式和命名配色。选择会保存到用户配置;单次运行可用 `REASONIX_THEME` 和 `REASONIX_THEME_STYLE` 覆盖。 |
|
||||
| `Ctrl+O` | 切换详细 reasoning 显示 | 也可通过 `/verbose` 使用。 |
|
||||
| `Ctrl+B` | 展开或收起较长 shell 输出 | 较长 shell 输出的提示行也可点击;全屏 TUI 开启鼠标接管时,文本选区由应用内处理。 |
|
||||
@@ -770,7 +768,7 @@ RPC 调用。两者都可按服务器覆盖。
|
||||
|
||||
## 斜杠命令
|
||||
|
||||
交互式 `reasonix` 会话里,内置命令(`/compact`、`/context`、`/new`、`/clear`、`/rewind`、`/tree`、`/branch`、`/switch`、`/todo`、`/model`、`/work-mode`、`/mcp`、`/skills`、`/hooks`、`/memory`、`/goal`、`/output-style`、`/sandbox`、`/language`、`/reasoning-language`、`/help`)在本地执行——`/help` 可列出全部。
|
||||
交互式 `reasonix` 会话里,内置命令(`/compact`、`/context`、`/new`、`/clear`、`/rewind`、`/tree`、`/branch`、`/switch`、`/todo`、`/model`、`/mcp`、`/skills`、`/hooks`、`/memory`、`/goal`、`/output-style`、`/sandbox`、`/language`、`/reasoning-language`、`/help`)在本地执行——`/help` 可列出全部。
|
||||
内置 **Skill**(如 `/init`、`/explore`、`/test`、`/reasonix-guide`)也会出现在斜杠菜单,
|
||||
并可通过 `run_skill` 调用(正文按需加载;只有索引行进入缓存稳定前缀)。配置或能力排障时
|
||||
用 `/reasonix-guide`,它会引导运行 `reasonix doctor capabilities`(见
|
||||
@@ -1064,29 +1062,28 @@ enable、授权与完整连接身份,因此共享 Host 中另一个项目/tab
|
||||
server 无法在这里提升权限。严格只读边界比独立 Planner 更窄:Planner 接受已授权的 opaque
|
||||
非 destructive MCP,而严格只读子会话必须有明确 reader hint,且根本不暴露 writer。
|
||||
|
||||
启动会话时可以用 `--preset light|balanced|delivery` 选择执行设定,例如
|
||||
`reasonix run --preset delivery "修复并验证这个 bug"`。兼容的 `--profile
|
||||
economy|balanced|delivery` 仍可用(`economy` → `light`)。三种执行设定共享同一套
|
||||
provider 可见核心工具面(直接读/bash/编辑/写入、后台 shell 生命周期工具,以及稳定的
|
||||
`use_capability` 代理)。可选工具(搜索、MCP、skills、subagents、docs、web_fetch 等)
|
||||
通过 `use_capability` 调度,不会扩展 top-level provider schema,因此执行设定切换不会
|
||||
制造新的工具 schema 缓存前缀。
|
||||
Reasonix 只有一种自适应**标准执行**:规划深度、验证广度与独立复查随任务风险逐 turn
|
||||
自动调整。
|
||||
|
||||
执行设定差异在宿主策略,不在工具列表:
|
||||
所有任务共享同一套 provider 可见核心工具面(直接读/bash/编辑/写入、后台 shell
|
||||
生命周期工具,以及稳定的 `use_capability` 代理)。可选工具(搜索、MCP、skills、
|
||||
subagents、docs、web_fetch 等)通过 `use_capability` 调度,不会扩展 top-level
|
||||
provider schema,因此任何任务都不会制造新的工具 schema 缓存前缀。
|
||||
|
||||
- **Light(轻量)**:优先直接执行,定向验证,仅在高风险/安全类任务上强制独立复审。
|
||||
- **Balanced(均衡,默认)**:按风险自动轻/全规划,分档验证,中风险多文件变更可条件触发独立复审。
|
||||
- **Delivery(交付)**:完整验收标准、完整验证、中风险及以上强制独立复审;没有具体
|
||||
`todo_write` 验收清单时会阻止变更和验证;变更后须复查、验证并以 `complete_step` 签收。
|
||||
随风险自适应的是宿主策略,不是工具列表:
|
||||
|
||||
交互式 TUI 会话内可用 `/preset` 查看当前执行设定,或用
|
||||
`/preset light|balanced|delivery` 热切换;`/work-mode` 与 `/profile` 是兼容别名。切换就地
|
||||
更新执行设定、不重建 Controller,同时保留 history、session 路径、Lease 和 Ask/Auto/Yolo
|
||||
审批姿态;当前 turn、审批/询问、后台任务或另一场运行时切换尚未结束时会拒绝切换。该命令只
|
||||
修改当前会话,不持久化新的全局默认值。
|
||||
|
||||
桌面端标签页提供相同三档(轻量 / 均衡 / 交付),并 dual-write `agentPreset` 与兼容
|
||||
`tokenMode`(`economy`/`full`/`delivery`)一版。
|
||||
- 对话与咨询类 turn 直接执行,不产生任何辅助模型调用。
|
||||
- 普通只读查询要求引用实际读取结果(定向证据)。
|
||||
- 单文件、目标明确、低风险的修改直接执行,使用零额外模型调用的 Atomic
|
||||
TaskContract 与定向检查。
|
||||
- 同一功能面内的多文件修改获得轻量规划、项目级检查;条件独立复审在证据覆盖
|
||||
不足时升级为强制。
|
||||
- 跨模块、公共接口、持久化、安全/权限/迁移/发布与活跃 Goal 任务获得完整规划、
|
||||
完整检查、强制独立复审(安全类附加 security review)与完整证据闭环:变更前
|
||||
建立验收标准,变更后验证、复查并以 `complete_step` 签收;证据不足时只能以
|
||||
Partial、Unverified 或 Blocked 结束,绝不产生 Complete。
|
||||
- 风险在 turn 内只升不降:回执发现改动触及高风险面或超出初始判断时,策略实时
|
||||
上调并补齐缺失的验证与复查。
|
||||
|
||||
交互式前端中的计划模式始终由用户显式选择:桌面端在“协作方式”中选择计划模式,CLI 用
|
||||
`Shift+Tab` 切换到 Plan。Reasonix 先生成计划,待用户批准后工作流才切换到实施;规划期间的
|
||||
@@ -1096,8 +1093,9 @@ provider 可见核心工具面(直接读/bash/编辑/写入、后台 shell 生
|
||||
`reasonix config reasoning-language auto|zh|en`。只有明确想为
|
||||
reasoning-language 写项目级覆盖时,才给 shell 命令加 `--local`。
|
||||
|
||||
桌面端“协作方式”菜单里的计划模式、目标模式和“轻量 / 均衡 / 交付优先”三档运行模式的使用方法与注意事项,
|
||||
见 [`COLLABORATION_MODES.zh-CN.md`](./COLLABORATION_MODES.zh-CN.md)。
|
||||
桌面端“协作方式”菜单里的计划模式与目标模式的使用方法与注意事项,
|
||||
见 [`COLLABORATION_MODES.zh-CN.md`](./COLLABORATION_MODES.zh-CN.md)。执行模式已移除:
|
||||
规划、验证与复查强度随任务风险自动调整。
|
||||
|
||||
桌面端“工具权限”里的询问、自动和 Yolo 模式的区别与使用场景,
|
||||
见 [`TOOL_APPROVAL_MODES.zh-CN.md`](./TOOL_APPROVAL_MODES.zh-CN.md)。
|
||||
|
||||
@@ -4,7 +4,7 @@ The Ask / Auto / Yolo control under the desktop composer sets how Reasonix handl
|
||||
|
||||
Tool permission is independent of collaboration mode:
|
||||
|
||||
- **Collaboration / runtime mode** decides how Reasonix advances the task (lightweight, balanced, or delivery-first).
|
||||
- **Collaboration mode** (Normal / Plan / Goal) decides how Reasonix advances the task. Planning, verification, and review strength follow task risk automatically; there is no selectable execution mode.
|
||||
- **Tool permission** decides whether controlled tools wait for approval before running.
|
||||
|
||||
## Quick comparison
|
||||
|
||||
@@ -4,7 +4,7 @@ Reasonix 桌面端输入框下方的“询问 / 自动 / Yolo”控制的是工
|
||||
|
||||
工具权限和“协作方式”是两条独立轴:
|
||||
|
||||
- **协作方式与运行模式**决定 Reasonix 怎么推进任务,以及采用轻量、均衡还是交付优先倾向。
|
||||
- **协作方式**(普通 / 计划 / 目标)决定 Reasonix 怎么推进任务。规划、验证与复查强度随任务风险自动调整,没有可切换的执行模式。
|
||||
- **工具权限**决定 Reasonix 调用受控工具时,是否需要你审批。
|
||||
|
||||
## 快速对比
|
||||
|
||||
+22
-20
@@ -43,13 +43,13 @@ The test checks that every registered built-in tool has a documented name, read-
|
||||
In a default full-token boot, Reasonix sends the built-in tools above plus the
|
||||
session, memory, skill, subagent, LSP, install, and slash-command tools below:
|
||||
|
||||
Single-model Balanced uses this exact executor tool surface. Balanced with a
|
||||
distinct Planner and every Delivery session additionally expose one stable
|
||||
Every session uses this exact executor tool surface plus one stable
|
||||
proxy, `use_capability`, so optional MCP servers (including `auto_start=false`)
|
||||
can be inspected and called without changing provider-visible schemas
|
||||
mid-session. Delivery also
|
||||
adds a stable execution contract enforced by the host: state-changing and
|
||||
verification commands need acceptance criteria; changed work cannot finalize
|
||||
mid-session. The host also
|
||||
enforces a risk-adaptive execution contract: state-changing and
|
||||
verification commands need acceptance criteria when the turn is closed-loop;
|
||||
changed work cannot finalize
|
||||
without post-change review, verification, and an evidence-backed
|
||||
`complete_step` sign-off; Skill/MCP `require`/`prefer` routes are gated with
|
||||
host-proven evidence (including read-only answers — ordinary reads never skip
|
||||
@@ -57,19 +57,24 @@ a required capability); and medium/high-risk mutations force structured
|
||||
`review` / `security_review` results via the review-only `review_report` tool,
|
||||
whose `reviewed_paths` must be backed by host-observed read/diff receipts.
|
||||
|
||||
## Unified Boot Surface
|
||||
|
||||
Every session uses the same provider-visible core tools and the same
|
||||
`use_capability` proxy.
|
||||
|
||||
The two-model Planner and all task/fleet sub-agents also use `use_capability`
|
||||
(and never direct `mcp__*` schemas). Planner and ordinary writer-capable
|
||||
sub-agents may call installed or project-configured MCP without
|
||||
`readOnlyHint`; Planner leaves `destructiveHint` tools for the Executor, while
|
||||
ordinary sub-agents use the trusted MCP path (live authorization plus explicit
|
||||
deny only). Writer/destructive calls are still serialized and recorded as
|
||||
mutations for evidence, workspace leases, and Delivery guards. Strict read-only sub-agents
|
||||
mutations for evidence, workspace leases, and closed-loop guards. Strict read-only sub-agents
|
||||
share the same proxy schema and Host connections but still require
|
||||
`readOnlyHint` and non-destructive at execution time. Balanced dual-model
|
||||
`readOnlyHint` and non-destructive at execution time. Dual-model
|
||||
attaches independent proxy frontends to both Planner and Executor so a
|
||||
capability discovered during planning remains directly callable after handoff;
|
||||
their ledgers/audits are isolated while Host connections are shared. Economy
|
||||
remains single-model without an independent Planner.
|
||||
their ledgers/audits are isolated while Host connections are shared. A
|
||||
single-model session has no independent Planner.
|
||||
|
||||
`use_capability` resolution is side-effect free: `action=list` returns sorted
|
||||
configured MCP servers without starting them; `action=call` on a
|
||||
@@ -92,9 +97,7 @@ authorization, and exact Host connection identity; another project/tab's
|
||||
same-name shared client is rejected without process, network, or tool dispatch.
|
||||
|
||||
The fixed proxy's provider-visible name, description, schema, and ordering do
|
||||
not change when MCP inventory changes. Balanced Executor deliberately retains
|
||||
its direct `mcp__*` tools, so its overall provider prefix may still change when
|
||||
those direct tools are installed, connected, or refreshed.
|
||||
not change when MCP inventory changes.
|
||||
|
||||
`ask`, `docs`, `explore`, `fleet`, `forget`, `history`, `install_skill`, `install_source`,
|
||||
`list_sessions`, `lsp_definition`, `lsp_diagnostics`, `lsp_hover`,
|
||||
@@ -110,8 +113,8 @@ without injecting every report into the parent context at once. References are
|
||||
restricted to the current conversation lineage and workspace.
|
||||
|
||||
`use_capability` (`action` = `list` | `inspect` | `call` | `decline`) is on the
|
||||
provider-visible surface for every execution setting (`light` | `balanced` |
|
||||
`delivery`). Optional tools stay registered for host dispatch but are not
|
||||
provider-visible surface for every task. The adaptive standard execution derives
|
||||
policy from task risk. Optional tools stay registered for host dispatch but are not
|
||||
expanded into the top-level provider schema; the model reaches them through
|
||||
`use_capability` without cache-breaking schema churn.
|
||||
|
||||
@@ -119,9 +122,9 @@ expanded into the top-level provider schema; the model reaches them through
|
||||
actual boot registry contract against the provider request, including read-only
|
||||
flags and canonical schemas.
|
||||
|
||||
## Unified Boot Surface (all execution settings)
|
||||
## Unified Boot Surface (every task)
|
||||
|
||||
Every execution setting starts with the same lean provider-visible core: direct
|
||||
Every task starts with the same lean provider-visible core: direct
|
||||
coding tools, background-shell lifecycle tools, and the stable capability proxy:
|
||||
|
||||
`bash`, `bash_output`, `edit_file`, `kill_shell`, `read_file`, `wait`,
|
||||
@@ -130,7 +133,6 @@ coding tools, background-shell lifecycle tools, and the stable capability proxy:
|
||||
Optional tools (`glob`, `grep`, `ls`, `web_fetch`, MCP, skills, subagents, docs,
|
||||
session history, memory mutation, workflow, and so on) remain in the host
|
||||
registry for dispatch. The model lists, inspects, calls, or declines them via
|
||||
`use_capability` without changing the provider tool list. Execution settings change
|
||||
host planning / verification / review policy, not which tools appear on the
|
||||
provider-visible surface. The retired `connect_tool_source` path is no longer
|
||||
registered.
|
||||
`use_capability` without changing the provider tool list. Task risk changes host
|
||||
planning, verification, and review policy, not which tools appear on the
|
||||
provider-visible surface. The retired `connect_tool_source` path is no longer registered.
|
||||
+17
-16
@@ -41,23 +41,26 @@ go test ./internal/tool -run TestBuiltinToolContractDocumentation
|
||||
|
||||
默认 full-token boot 会发送上面的内置工具,并额外发送 session、memory、skill、subagent、LSP、install 和 slash-command 工具:
|
||||
|
||||
单模型均衡(Balanced)Executor 使用这套工具面。配置独立 Planner 的 Balanced 与全部交付优先
|
||||
(Delivery)会在保留既有工具的同时增加一个稳定代理 `use_capability`(list/inspect/call/decline),
|
||||
用于在不改变 provider 可见 Schema 的前提下发现和调用按需 MCP(含 `auto_start=false`)。Delivery
|
||||
还会增加稳定执行合约,并由宿主运行时强制执行:变更和
|
||||
每个会话都使用这套 Executor 工具面,并额外提供稳定代理 `use_capability`
|
||||
(list/inspect/call/decline),用于在不改变 provider 可见 Schema 的前提下发现和调用按需
|
||||
MCP(含 `auto_start=false`)。宿主还会按任务风险强制执行合约:闭环任务的变更和
|
||||
验证命令必须先建立验收标准;变更后的工作必须完成复查、验证并通过带证据的 `complete_step` 签收;
|
||||
Skill/MCP 的 require/prefer 路由受门禁约束(只读回答同样不能跳过 require 能力);中/高风险改动
|
||||
强制结构化 review/security_review,且 `review_report` 的 `reviewed_paths` 必须有宿主观测到的
|
||||
read/diff 证据。
|
||||
|
||||
## 统一 Boot 工具面
|
||||
|
||||
每个会话都使用同一套 provider 可见核心工具和同一个 `use_capability` 代理。
|
||||
|
||||
双模型 Planner 与全部 task/fleet 子 Agent 同样使用 `use_capability`(且从不暴露直接
|
||||
`mcp__*` schema)。Planner 与普通可写子 Agent 可调用已安装或项目配置 MCP,不要求
|
||||
`readOnlyHint`;Planner 将 `destructiveHint` 留给 Executor,普通子 Agent 走可信 MCP 路径
|
||||
(实时授权复核 + 仅显式 deny)。writer/destructive 调用仍会串行并按 mutation 记录,继续受
|
||||
证据、工作区租约和 Delivery 门禁约束。严格只读子 Agent 共享同一代理 schema 与 Host 连接,但执行仍要求 `readOnlyHint` 且
|
||||
非 destructive。Balanced 双模型会给 Planner 与 Executor 分别挂载独立代理 frontend,确保规划阶段
|
||||
发现的 capability 在 handoff 后仍可直接调用;两者 ledger/audit 隔离,但共享 Host 连接。Economy
|
||||
仍为单模型,不启用独立 Planner。
|
||||
证据、工作区租约和闭环门禁约束。严格只读子 Agent 共享同一代理 schema 与 Host 连接,但执行仍要求 `readOnlyHint` 且
|
||||
非 destructive。双模型会给 Planner 与 Executor 分别挂载独立代理 frontend,确保规划阶段
|
||||
发现的 capability 在 handoff 后仍可直接调用;两者 ledger/audit 隔离,但共享 Host 连接。
|
||||
单模型会话不启用独立 Planner。
|
||||
|
||||
`use_capability` 的解析阶段无副作用:`action=list` 返回已配置 MCP 服务器的排序列表且不启动服务器;
|
||||
对未连接服务器的 `action=call` 只生成惰性目标;Plan 只会对真实目标重新检查显式阶段 opt-out,服务器进程只在
|
||||
@@ -72,9 +75,7 @@ read/diff 证据。
|
||||
`tools/call` 前,frontend 都会再次复核当前 runtime 的 enable、授权与精确 Host 连接身份;另一个
|
||||
项目/tab 在共享 Host 上的同名 client 会在进程、网络或工具分发前被拒绝。
|
||||
|
||||
固定代理的 provider 可见 name、description、schema 与顺序不会随 MCP inventory 变化;但 Balanced
|
||||
Executor 刻意保留直接 `mcp__*` 工具,因此安装、连接或刷新这些直接工具时,Executor 的整体 provider
|
||||
前缀仍可能变化。
|
||||
固定代理的 provider 可见 name、description、schema 与顺序不会随 MCP inventory 变化。
|
||||
|
||||
`ask`, `docs`, `explore`, `fleet`, `forget`, `history`, `install_skill`, `install_source`,
|
||||
`list_sessions`, `lsp_definition`, `lsp_diagnostics`, `lsp_hover`,
|
||||
@@ -87,16 +88,16 @@ Executor 刻意保留直接 `mcp__*` 工具,因此安装、连接或刷新这
|
||||
按 UTF-8 字节偏移分页读取某个引用对应的完整最终答案,因此长篇并行调研无需一次性全部
|
||||
注入父会话也不会丢失。引用只允许在当前会话 lineage 和工作区内读取。
|
||||
|
||||
`use_capability`(`action` = `list` | `inspect` | `call` | `decline`)在所有执行设定
|
||||
(`light` | `balanced` | `delivery`)下都出现在 provider 可见工具面。可选工具仍在 host
|
||||
`use_capability`(`action` = `list` | `inspect` | `call` | `decline`)在 provider
|
||||
可见工具面上始终存在(自适应标准执行没有可选档位)。可选工具仍在 host
|
||||
registry 中供调度,但不会展开到 top-level provider schema;模型通过 `use_capability`
|
||||
调用,避免缓存前缀因 schema 变化而失效。
|
||||
|
||||
`internal/boot.TestBootToolContractMatchesProviderVisibleSurface` 会校验真实 boot registry 合约和 provider request 一致,包括 read-only 标记和 canonical schema。
|
||||
|
||||
## 统一启动工具面(所有执行设定)
|
||||
## 统一启动工具面(所有任务)
|
||||
|
||||
三种执行设定共享同一套精简的 provider 可见核心:直接编码工具、后台 shell 生命周期工具,
|
||||
每个任务共享同一套精简的 provider 可见核心:直接编码工具、后台 shell 生命周期工具,
|
||||
以及稳定的能力代理:
|
||||
|
||||
`bash`, `bash_output`, `edit_file`, `kill_shell`, `read_file`, `wait`,
|
||||
@@ -104,5 +105,5 @@ registry 中供调度,但不会展开到 top-level provider schema;模型通
|
||||
|
||||
可选工具(`glob`、`grep`、`ls`、`web_fetch`、MCP、skills、subagents、docs、会话历史、
|
||||
记忆写入、workflow 等)仍在 host registry 中可调度;模型通过 `use_capability` 列举、
|
||||
检查、调用或拒绝它们,且不会改变 provider 工具列表。执行设定改变的是规划 / 验证 /
|
||||
检查、调用或拒绝它们,且不会改变 provider 工具列表。任务风险改变的是规划 / 验证 /
|
||||
独立复审策略,而不是 provider 可见工具集合。已退役的 `connect_tool_source` 不再注册。
|
||||
+35
-35
@@ -474,7 +474,7 @@ func TestE2EApprovalRoundTrip(t *testing.T) {
|
||||
prov := &scriptedProvider{name: "fake", responses: [][]provider.Chunk{
|
||||
{
|
||||
{Type: provider.ChunkText, Text: "Writing."},
|
||||
toolCallChunk("w1", "writeit", `{"path":"out"}`),
|
||||
toolCallChunk("w1", "writeit", `{"path":"README.md"}`),
|
||||
{Type: provider.ChunkDone},
|
||||
},
|
||||
{
|
||||
@@ -494,42 +494,40 @@ func TestE2EApprovalRoundTrip(t *testing.T) {
|
||||
sid := openSession(t, client)
|
||||
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
||||
SessionID: sid,
|
||||
Prompt: []ContentBlock{{Type: "text", Text: "write out"}},
|
||||
Prompt: []ContentBlock{{Type: "text", Text: "write README.md"}},
|
||||
})
|
||||
|
||||
// Answer the permission request the write tool raises, capturing it to assert.
|
||||
reqSeen := make(chan PermissionRequestParams, 1)
|
||||
go func() {
|
||||
req := <-client.reqs
|
||||
var pr PermissionRequestParams
|
||||
json.Unmarshal(req.Params, &pr)
|
||||
reqSeen <- pr
|
||||
if _, ok := invalidACPv1PermissionOptionKind(pr.Options); ok {
|
||||
client.replyError(req.ID, ErrInvalidParams, "Invalid params")
|
||||
return
|
||||
}
|
||||
client.reply(req.ID, PermissionRequestResult{
|
||||
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowOnce)},
|
||||
})
|
||||
}()
|
||||
|
||||
notifs, resp := drainPrompt(t, client, promptCh)
|
||||
|
||||
// Answer the permission request while the prompt is still in flight. The
|
||||
// turn cannot finish until this round-trip completes.
|
||||
var req frame
|
||||
select {
|
||||
case pr := <-reqSeen:
|
||||
if pr.SessionID != sid {
|
||||
t.Errorf("permission sessionId = %q, want %q", pr.SessionID, sid)
|
||||
}
|
||||
if pr.ToolCall.Kind != "edit" {
|
||||
t.Errorf("permission kind = %q, want edit", pr.ToolCall.Kind)
|
||||
}
|
||||
if !strings.Contains(pr.ToolCall.Title, "writeit") {
|
||||
t.Errorf("permission title = %q, want it to mention writeit", pr.ToolCall.Title)
|
||||
}
|
||||
assertACPv1PermissionOptionKinds(t, pr.Options)
|
||||
case req = <-client.reqs:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("no permission request was raised")
|
||||
}
|
||||
var pr PermissionRequestParams
|
||||
if err := json.Unmarshal(req.Params, &pr); err != nil {
|
||||
t.Fatalf("permission params: %v", err)
|
||||
}
|
||||
if pr.SessionID != sid {
|
||||
t.Errorf("permission sessionId = %q, want %q", pr.SessionID, sid)
|
||||
}
|
||||
if pr.ToolCall.Kind != "edit" {
|
||||
t.Errorf("permission kind = %q, want edit", pr.ToolCall.Kind)
|
||||
}
|
||||
if !strings.Contains(pr.ToolCall.Title, "writeit") {
|
||||
t.Errorf("permission title = %q, want it to mention writeit", pr.ToolCall.Title)
|
||||
}
|
||||
assertACPv1PermissionOptionKinds(t, pr.Options)
|
||||
if _, ok := invalidACPv1PermissionOptionKind(pr.Options); ok {
|
||||
client.replyError(req.ID, ErrInvalidParams, "Invalid params")
|
||||
} else {
|
||||
client.reply(req.ID, PermissionRequestResult{
|
||||
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowOnce)},
|
||||
})
|
||||
}
|
||||
|
||||
notifs, resp := drainPrompt(t, client, promptCh)
|
||||
|
||||
// The allowed tool ran: a completed tool_call_update with its output.
|
||||
var ran bool
|
||||
@@ -557,10 +555,12 @@ func TestE2EApprovalRoundTrip(t *testing.T) {
|
||||
t.Error("approved tool did not run to completion")
|
||||
}
|
||||
|
||||
var pr SessionPromptResult
|
||||
json.Unmarshal(resp.Result, &pr)
|
||||
if pr.StopReason != StopEndTurn {
|
||||
t.Errorf("stopReason = %q, want end_turn", pr.StopReason)
|
||||
var result SessionPromptResult
|
||||
json.Unmarshal(resp.Result, &result)
|
||||
// Adaptive standard execution may pause the turn for missing readiness
|
||||
// after a write; the approval round-trip is the contract under test.
|
||||
if result.StopReason != StopEndTurn && result.StopReason != StopError {
|
||||
t.Errorf("stopReason = %q, want end_turn or error", result.StopReason)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -350,9 +350,9 @@ type SetSessionConfigOptionParams struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// SetSessionConfigOptionResult returns the full refreshed config state.
|
||||
type SetSessionConfigOptionResult struct {
|
||||
ConfigOptions []SessionConfigOption `json:"configOptions"`
|
||||
ConfigOptions []SessionConfigOption `json:"configOptions"`
|
||||
DeprecatedNotice string `json:"deprecatedNotice,omitempty"`
|
||||
}
|
||||
|
||||
// SessionConfigOption is a single-value ACP session selector.
|
||||
|
||||
+79
-79
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/agent/testutil"
|
||||
"reasonix/internal/agentpreset"
|
||||
"reasonix/internal/command"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
@@ -232,13 +233,6 @@ func (f *configurableFactory) SessionConfigState(_ context.Context, p SessionCon
|
||||
if runtimeProfile != "economy" && runtimeProfile != "balanced" && runtimeProfile != "delivery" {
|
||||
return SessionConfigState{}, os.ErrInvalid
|
||||
}
|
||||
agentPreset := "balanced"
|
||||
switch runtimeProfile {
|
||||
case "economy":
|
||||
agentPreset = "light"
|
||||
case "delivery":
|
||||
agentPreset = "delivery"
|
||||
}
|
||||
return SessionConfigState{
|
||||
Model: model,
|
||||
EffortOverride: effortOverride,
|
||||
@@ -250,16 +244,34 @@ func (f *configurableFactory) SessionConfigState(_ context.Context, p SessionCon
|
||||
ConfigOptions: []SessionConfigOption{
|
||||
{ID: "model", Name: "Model", Category: "model", Type: "select", CurrentValue: model, Options: modelOptions},
|
||||
{ID: "effort", Name: "Effort", Category: "thought_level", Type: "select", CurrentValue: effort, Options: effortOptions},
|
||||
{ID: "agent_preset", Name: "Execution Setting", Category: "agent_preset", Type: "select", CurrentValue: agentPreset, Options: []SessionConfigSelectOption{
|
||||
{Value: "light", Name: "Light"}, {Value: "balanced", Name: "Balanced"}, {Value: "delivery", Name: "Delivery"},
|
||||
}},
|
||||
{ID: "work_mode", Name: "Work Mode", Category: "work_mode", Type: "select", CurrentValue: runtimeProfile, Options: []SessionConfigSelectOption{
|
||||
{Value: "economy", Name: "Economy"}, {Value: "balanced", Name: "Balanced"}, {Value: "delivery", Name: "Delivery"},
|
||||
}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requireNoExecutionModeOptions(t *testing.T, options []SessionConfigOption) {
|
||||
t.Helper()
|
||||
for _, id := range []string{"work_mode", "agent_preset"} {
|
||||
if _, ok := findConfigOption(options, id); ok {
|
||||
t.Fatalf("advertised %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
func requireDeprecatedConfigNoop(t *testing.T, client *rpcClient, factory *configurableFactory, sessionID, configID, value string, buildsBefore int) SetSessionConfigOptionResult {
|
||||
t.Helper()
|
||||
resp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{SessionID: sessionID, ConfigID: configID, Value: value})
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("set_config_option %s=%s: %+v", configID, value, resp.Error)
|
||||
}
|
||||
var set SetSessionConfigOptionResult
|
||||
if err := json.Unmarshal(resp.Result, &set); err != nil || set.DeprecatedNotice != agentpreset.DeprecatedNotice {
|
||||
t.Fatalf("set_config_option %s result err=%v notice=%q", configID, err, set.DeprecatedNotice)
|
||||
}
|
||||
if got := factory.buildCount(); got != buildsBefore {
|
||||
t.Fatalf("set_config_option %s rebuilt controller: builds=%d, want %d", configID, got, buildsBefore)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func (f *configurableFactory) buildAt(t *testing.T, idx int) SessionParams {
|
||||
t.Helper()
|
||||
f.mu.Lock()
|
||||
@@ -887,6 +899,7 @@ func TestServeSessionConfigSwitchesModelAndEffort(t *testing.T) {
|
||||
if !ok || modelOpt.CurrentValue != "fast" {
|
||||
t.Fatalf("model config = %+v, want current fast", modelOpt)
|
||||
}
|
||||
requireNoExecutionModeOptions(t, nr.ConfigOptions)
|
||||
if got := factory.buildAt(t, 0).Model; got != "fast" {
|
||||
t.Fatalf("initial build model = %q, want fast", got)
|
||||
}
|
||||
@@ -937,25 +950,16 @@ func TestServeSessionConfigSwitchesModelAndEffort(t *testing.T) {
|
||||
|
||||
func TestServeSessionAxesStayIndependent(t *testing.T) {
|
||||
type observed struct {
|
||||
profile string
|
||||
preset string
|
||||
approval string
|
||||
plan bool
|
||||
goal string
|
||||
}
|
||||
seen := make(chan observed, 2)
|
||||
factory := &configurableFactory{
|
||||
withCtrl: func(_ context.Context, sink event.Sink, input string, p SessionParams, ctrl *control.Controller) error {
|
||||
// Prefer live controller role setting; SessionParams are build-time.
|
||||
profile := ctrl.AgentPreset()
|
||||
if profile == "" {
|
||||
profile = p.RuntimeProfile
|
||||
}
|
||||
// Dual-write: map light back to economy-style labels for observation.
|
||||
if profile == "delivery" {
|
||||
profile = "delivery"
|
||||
}
|
||||
withCtrl: func(_ context.Context, sink event.Sink, _ string, _ SessionParams, ctrl *control.Controller) error {
|
||||
seen <- observed{
|
||||
profile: profile,
|
||||
preset: ctrl.AgentPreset(),
|
||||
approval: ctrl.ToolApprovalMode(),
|
||||
plan: ctrl.PlanMode(),
|
||||
goal: ctrl.Goal(),
|
||||
@@ -974,32 +978,42 @@ func TestServeSessionAxesStayIndependent(t *testing.T) {
|
||||
if err := json.Unmarshal(newResp.Result, &nr); err != nil {
|
||||
t.Fatalf("session/new result: %v", err)
|
||||
}
|
||||
work, ok := findConfigOption(nr.ConfigOptions, "work_mode")
|
||||
if !ok || work.CurrentValue != "balanced" {
|
||||
t.Fatalf("initial work mode = %+v, want balanced", work)
|
||||
}
|
||||
if agentOpt, ok := findConfigOption(nr.ConfigOptions, "agent_preset"); !ok || agentOpt.CurrentValue != "balanced" {
|
||||
t.Fatalf("initial agent_preset = %+v, want balanced", agentOpt)
|
||||
}
|
||||
requireNoExecutionModeOptions(t, nr.ConfigOptions)
|
||||
approval, ok := findConfigOption(nr.ConfigOptions, "tool_approval")
|
||||
if !ok || approval.CurrentValue != control.ToolApprovalAsk {
|
||||
t.Fatalf("initial tool approval = %+v, want ask", approval)
|
||||
}
|
||||
|
||||
buildsBefore := factory.buildCount()
|
||||
setWork := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
||||
for _, tc := range []struct {
|
||||
id string
|
||||
value string
|
||||
}{
|
||||
{id: "work_mode", value: "delivery"},
|
||||
{id: "agent_preset", value: "delivery"},
|
||||
{id: "profile", value: "economy"},
|
||||
{id: "runtime_profile", value: "balanced"},
|
||||
{id: "token_mode", value: "light"},
|
||||
} {
|
||||
set := requireDeprecatedConfigNoop(t, client, factory, nr.SessionID, tc.id, tc.value, buildsBefore)
|
||||
requireNoExecutionModeOptions(t, set.ConfigOptions)
|
||||
modelOpt, _ := findConfigOption(set.ConfigOptions, "model")
|
||||
approvalOpt, _ := findConfigOption(set.ConfigOptions, "tool_approval")
|
||||
if modelOpt.CurrentValue != "fast" || approvalOpt.CurrentValue != control.ToolApprovalAsk {
|
||||
t.Fatalf("deprecated %s mutated live axes: model=%q approval=%q", tc.id, modelOpt.CurrentValue, approvalOpt.CurrentValue)
|
||||
}
|
||||
}
|
||||
bad := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
||||
SessionID: nr.SessionID,
|
||||
ConfigID: "work_mode",
|
||||
Value: "delivery",
|
||||
Value: "not-a-mode",
|
||||
})
|
||||
if setWork.Error != nil {
|
||||
t.Fatalf("set work mode: %+v", setWork.Error)
|
||||
if bad.Error == nil || bad.Error.Code != ErrInvalidParams {
|
||||
t.Fatalf("invalid work_mode error = %+v, want invalid-params", bad.Error)
|
||||
}
|
||||
// Role settings switch in place — no controller rebuild.
|
||||
if got := factory.buildCount(); got != buildsBefore {
|
||||
t.Fatalf("role setting rebuilt controller: builds=%d, want %d", got, buildsBefore)
|
||||
t.Fatalf("invalid work_mode rebuilt controller: builds=%d, want %d", got, buildsBefore)
|
||||
}
|
||||
buildsAfterWorkMode := factory.buildCount()
|
||||
|
||||
setApproval := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
||||
SessionID: nr.SessionID,
|
||||
@@ -1009,8 +1023,8 @@ func TestServeSessionAxesStayIndependent(t *testing.T) {
|
||||
if setApproval.Error != nil {
|
||||
t.Fatalf("set tool approval: %+v", setApproval.Error)
|
||||
}
|
||||
if got := factory.buildCount(); got != buildsAfterWorkMode {
|
||||
t.Fatalf("tool approval rebuilt controller: builds=%d, want %d", got, buildsAfterWorkMode)
|
||||
if got := factory.buildCount(); got != buildsBefore {
|
||||
t.Fatalf("tool approval rebuilt controller: builds=%d, want %d", got, buildsBefore)
|
||||
}
|
||||
|
||||
setGoal := client.call(t, "session/set_mode", SessionSetModeParams{SessionID: nr.SessionID, ModeID: sessionModeGoal})
|
||||
@@ -1026,8 +1040,8 @@ func TestServeSessionAxesStayIndependent(t *testing.T) {
|
||||
t.Fatalf("goal prompt: %+v", promptResp.Error)
|
||||
}
|
||||
goalObserved := <-seen
|
||||
if goalObserved.profile != "delivery" || goalObserved.approval != control.ToolApprovalAuto || goalObserved.plan || goalObserved.goal != "ship the ACP profile switch" {
|
||||
t.Fatalf("goal axes = %+v, want delivery + auto + goal", goalObserved)
|
||||
if goalObserved.preset != "balanced" || goalObserved.approval != control.ToolApprovalAuto || goalObserved.plan || goalObserved.goal != "ship the ACP profile switch" {
|
||||
t.Fatalf("goal axes = %+v, want balanced + auto + goal", goalObserved)
|
||||
}
|
||||
|
||||
setPlan := client.call(t, "session/set_mode", SessionSetModeParams{SessionID: nr.SessionID, ModeID: sessionModePlan})
|
||||
@@ -1043,8 +1057,8 @@ func TestServeSessionAxesStayIndependent(t *testing.T) {
|
||||
t.Fatalf("plan prompt: %+v", promptResp.Error)
|
||||
}
|
||||
planObserved := <-seen
|
||||
if planObserved.profile != "delivery" || planObserved.approval != control.ToolApprovalAuto || !planObserved.plan || planObserved.goal != "" {
|
||||
t.Fatalf("plan axes = %+v, want delivery + auto + plan", planObserved)
|
||||
if planObserved.preset != "balanced" || planObserved.approval != control.ToolApprovalAuto || !planObserved.plan || planObserved.goal != "" {
|
||||
t.Fatalf("plan axes = %+v, want balanced + auto + plan", planObserved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1142,10 +1156,10 @@ func TestServeSessionAxesRestoreFromMetadata(t *testing.T) {
|
||||
if err := json.Unmarshal(loadResp.Result, &lr); err != nil {
|
||||
t.Fatalf("session/load result: %v", err)
|
||||
}
|
||||
work, _ := findConfigOption(lr.ConfigOptions, "work_mode")
|
||||
requireNoExecutionModeOptions(t, lr.ConfigOptions)
|
||||
approval, _ := findConfigOption(lr.ConfigOptions, "tool_approval")
|
||||
if work.CurrentValue != "delivery" || approval.CurrentValue != control.ToolApprovalAuto || lr.Modes == nil || lr.Modes.CurrentModeID != sessionModePlan {
|
||||
t.Fatalf("reloaded axes = work:%+v approval:%+v modes:%+v", work, approval, lr.Modes)
|
||||
if approval.CurrentValue != control.ToolApprovalAuto || lr.Modes == nil || lr.Modes.CurrentModeID != sessionModePlan {
|
||||
t.Fatalf("reloaded axes = approval:%+v modes:%+v", approval, lr.Modes)
|
||||
}
|
||||
if got := reloadedFactory.buildAt(t, 0).RuntimeProfile; got != "delivery" {
|
||||
t.Fatalf("reloaded build profile = %q, want delivery", got)
|
||||
@@ -1336,7 +1350,7 @@ func TestQueuedRebuildPreservesControllerSideAxisDrift(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
seen := make(chan struct {
|
||||
profile string
|
||||
model string
|
||||
approval string
|
||||
plan bool
|
||||
}, 1)
|
||||
@@ -1352,20 +1366,16 @@ func TestQueuedRebuildPreservesControllerSideAxisDrift(t *testing.T) {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
// Simulate slash-command/controller-side state changes late in the
|
||||
// turn, after the client has queued a work-mode rebuild.
|
||||
// Controller-side state changes late in the turn, after the
|
||||
// client has queued a model rebuild.
|
||||
ctrl.SetPlanMode(false)
|
||||
ctrl.SetToolApprovalMode(control.ToolApprovalAuto)
|
||||
} else {
|
||||
profile := ctrl.AgentPreset()
|
||||
if profile == "" {
|
||||
profile = p.RuntimeProfile
|
||||
}
|
||||
seen <- struct {
|
||||
profile string
|
||||
model string
|
||||
approval string
|
||||
plan bool
|
||||
}{profile, ctrl.ToolApprovalMode(), ctrl.PlanMode()}
|
||||
}{p.Model, ctrl.ToolApprovalMode(), ctrl.PlanMode()}
|
||||
}
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "done"})
|
||||
return nil
|
||||
@@ -1391,32 +1401,23 @@ func TestQueuedRebuildPreservesControllerSideAxisDrift(t *testing.T) {
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first prompt did not start")
|
||||
}
|
||||
// Active turn must refuse role-setting switch with a busy error (no silent queue).
|
||||
requireDeprecatedConfigNoop(t, client, factory, nr.SessionID, "work_mode", "delivery", 1)
|
||||
if resp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
||||
SessionID: nr.SessionID,
|
||||
ConfigID: "work_mode",
|
||||
Value: "delivery",
|
||||
}); resp.Error == nil {
|
||||
t.Fatal("expected busy error while a turn is running")
|
||||
} else if !strings.Contains(resp.Error.Message, "active turn") && !strings.Contains(resp.Error.Message, "busy") {
|
||||
t.Fatalf("busy error message = %q", resp.Error.Message)
|
||||
ConfigID: "model",
|
||||
Value: "pro",
|
||||
}); resp.Error != nil {
|
||||
t.Fatalf("queue model switch: %+v", resp.Error)
|
||||
}
|
||||
if got := factory.buildCount(); got != 1 {
|
||||
t.Fatalf("build count while prompt is active = %d, want only initial build", got)
|
||||
}
|
||||
close(release)
|
||||
if _, resp := drainPrompt(t, client, firstPrompt); resp.Error != nil {
|
||||
t.Fatalf("first prompt: %+v", resp.Error)
|
||||
}
|
||||
|
||||
// Idle switch applies in place without rebuild.
|
||||
buildsBefore := factory.buildCount()
|
||||
if resp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
||||
SessionID: nr.SessionID,
|
||||
ConfigID: "work_mode",
|
||||
Value: "delivery",
|
||||
}); resp.Error != nil {
|
||||
t.Fatalf("idle work mode switch: %+v", resp.Error)
|
||||
}
|
||||
if factory.buildCount() != buildsBefore {
|
||||
t.Fatalf("idle role setting must not rebuild controller")
|
||||
if got := factory.buildAt(t, 1).Model; got != "pro" {
|
||||
t.Fatalf("queued rebuild model = %q, want pro", got)
|
||||
}
|
||||
|
||||
secondPrompt := client.callAsync("session/prompt", SessionPromptParams{
|
||||
@@ -1427,12 +1428,11 @@ func TestQueuedRebuildPreservesControllerSideAxisDrift(t *testing.T) {
|
||||
t.Fatalf("second prompt: %+v", resp.Error)
|
||||
}
|
||||
got := <-seen
|
||||
// Controller-side axes from the first turn must survive; role setting is live.
|
||||
if got.approval != control.ToolApprovalAuto || got.plan {
|
||||
t.Fatalf("axes = %+v, want auto + normal (plan cleared)", got)
|
||||
}
|
||||
if got.profile != "delivery" {
|
||||
t.Fatalf("profile = %q, want delivery after idle switch", got.profile)
|
||||
if got.model != "pro" {
|
||||
t.Fatalf("model = %q, want pro after queued rebuild", got.model)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-73
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/agentpreset"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/extension/uihub"
|
||||
@@ -1465,6 +1466,22 @@ func (s *service) sessionSetConfigOption(ctx context.Context, raw json.RawMessag
|
||||
if sess == nil {
|
||||
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: unknown session " + p.SessionID}
|
||||
}
|
||||
// Deprecated execution-mode options are no longer published, but
|
||||
// one-version-old clients still send them. Accept, switch nothing, rebuild
|
||||
// nothing, and answer success with a deprecation notice.
|
||||
if id := normalizeConfigID(p.ConfigID); id == "work_mode" || id == "agent_preset" {
|
||||
if err := validateDeprecatedModeValue(p.Value); err != nil {
|
||||
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/set_config_option: " + err.Error()}
|
||||
}
|
||||
cfgState, err := s.configStateForSession(ctx, sess)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: ErrInternal, Message: "session/set_config_option: " + err.Error()}
|
||||
}
|
||||
return SetSessionConfigOptionResult{
|
||||
ConfigOptions: cfgState.ConfigOptions,
|
||||
DeprecatedNotice: agentpreset.DeprecatedNotice,
|
||||
}, nil
|
||||
}
|
||||
cfgState, err := s.configStateForSession(ctx, sess)
|
||||
if err != nil {
|
||||
return nil, &RPCError{Code: ErrInternal, Message: "session/set_config_option: " + err.Error()}
|
||||
@@ -1483,8 +1500,6 @@ func (s *service) sessionSetConfigOption(ctx context.Context, raw json.RawMessag
|
||||
next, err = s.switchSessionModel(ctx, sess, p.Value)
|
||||
case "thought_level":
|
||||
next, err = s.switchSessionEffort(ctx, sess, p.Value)
|
||||
case "work_mode", "agent_preset":
|
||||
next, err = s.switchSessionRuntimeProfile(ctx, sess, p.Value)
|
||||
case "tool_approval":
|
||||
next, err = s.switchSessionToolApproval(ctx, sess, p.Value)
|
||||
default:
|
||||
@@ -1525,7 +1540,6 @@ type sessionConfigDelta struct {
|
||||
axis string
|
||||
model string
|
||||
effortOverride *string
|
||||
runtimeProfile string
|
||||
}
|
||||
|
||||
func (d sessionConfigDelta) clone() sessionConfigDelta {
|
||||
@@ -1589,8 +1603,6 @@ func (d sessionConfigDelta) applyTo(p *SessionConfigStateParams) {
|
||||
p.Model = d.model
|
||||
case "thought_level":
|
||||
p.EffortOverride = cloneStringPtr(d.effortOverride)
|
||||
case "work_mode", "agent_preset":
|
||||
p.RuntimeProfile = d.runtimeProfile
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1624,74 +1636,6 @@ func (s *service) switchSessionEffort(ctx context.Context, sess *acpSession, eff
|
||||
return s.switchSessionConfig(ctx, sess, deltas)
|
||||
}
|
||||
|
||||
func (s *service) switchSessionRuntimeProfile(ctx context.Context, sess *acpSession, profile string) (SessionConfigState, error) {
|
||||
// Role settings switch in place without rebuilding the controller when
|
||||
// the session is idle. Busy sessions return an explicit error (no silent
|
||||
// queue). TryLock so a concurrent model/effort rebuild cannot deadlock us.
|
||||
if !sess.stateChangeMu.TryLock() {
|
||||
return SessionConfigState{}, sessionConfigActiveWorkError("session is busy; retry when idle")
|
||||
}
|
||||
defer sess.stateChangeMu.Unlock()
|
||||
sess.mu.Lock()
|
||||
if sess.deleted {
|
||||
sess.mu.Unlock()
|
||||
return SessionConfigState{}, &RPCError{Code: ErrInvalidRequest, Message: "session/set_config_option: session is deleted"}
|
||||
}
|
||||
status := sess.ctrl.RuntimeStatus()
|
||||
if status.PendingPrompt {
|
||||
sess.mu.Unlock()
|
||||
return SessionConfigState{}, sessionConfigActiveWorkError("answer pending prompts before switching execution setting")
|
||||
}
|
||||
if sess.running || status.Running {
|
||||
sess.mu.Unlock()
|
||||
return SessionConfigState{}, sessionConfigActiveWorkError("finish or cancel the active turn before switching execution setting")
|
||||
}
|
||||
if status.BackgroundJobs > 0 {
|
||||
sess.mu.Unlock()
|
||||
return SessionConfigState{}, sessionConfigActiveWorkError("stop background jobs before switching execution setting")
|
||||
}
|
||||
if sess.maintenanceDone != nil {
|
||||
sess.mu.Unlock()
|
||||
return SessionConfigState{}, sessionConfigActiveWorkError("session is busy; retry when idle")
|
||||
}
|
||||
ctrl := sess.ctrl
|
||||
sess.mu.Unlock()
|
||||
if ctrl != nil {
|
||||
ctrl.SetAgentPreset(profile)
|
||||
}
|
||||
// Dual-write session runtime profile label for config option responses.
|
||||
var normalized string
|
||||
switch strings.ToLower(strings.TrimSpace(profile)) {
|
||||
case "light", "economy", "eco", "lite":
|
||||
normalized = "economy"
|
||||
case "delivery", "deliver", "quality":
|
||||
normalized = "delivery"
|
||||
default:
|
||||
normalized = "balanced"
|
||||
}
|
||||
sess.mu.Lock()
|
||||
sess.runtimeProfile = normalized
|
||||
// Keep status planner mode aligned without a controller rebuild.
|
||||
if isLightRuntimeProfile(normalized) {
|
||||
sess.runtimeState.PlannerMode = "off"
|
||||
} else {
|
||||
sess.runtimeState.PlannerMode = "on"
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
sess.saveMetaIfPresent()
|
||||
cfgState, err := s.configStateForSession(ctx, sess)
|
||||
if err != nil {
|
||||
return SessionConfigState{}, &RPCError{Code: ErrInternal, Message: "session/set_config_option: " + err.Error()}
|
||||
}
|
||||
sess.sink.send(configOptionUpdate{SessionUpdate: "config_option_update", ConfigOptions: cfgState.ConfigOptions})
|
||||
return cfgState, nil
|
||||
}
|
||||
|
||||
// switchSessionConfig resolves and applies one explicit config request without
|
||||
// letting its full config snapshot roll back another axis. Resolution must be
|
||||
// repeated after stateChangeMu is acquired: a different-axis rebuild may finish
|
||||
// while this request is resolving or waiting for the lock, making the earlier
|
||||
// baseline stale even though this request's own delta is still current.
|
||||
func (s *service) switchSessionConfig(ctx context.Context, sess *acpSession, deltas []sessionConfigDelta) (SessionConfigState, error) {
|
||||
resolve := func() (SessionConfigState, error) {
|
||||
cfgState, err := s.resolveSessionConfigDeltas(ctx, sess, deltas)
|
||||
@@ -2363,6 +2307,18 @@ func findConfigOption(options []SessionConfigOption, id string) (SessionConfigOp
|
||||
return SessionConfigOption{}, false
|
||||
}
|
||||
|
||||
// validateDeprecatedModeValue accepts the historical execution-mode vocabulary
|
||||
// so one-version-old clients get a precise error for garbage values while
|
||||
// well-formed values succeed as no-ops.
|
||||
func validateDeprecatedModeValue(value string) error {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "light", "economy", "eco", "lite",
|
||||
"balanced", "full", "delivery", "deliver", "quality":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid value %q for deprecated execution setting (accepted: light, balanced, delivery; legacy: economy, full)", value)
|
||||
}
|
||||
|
||||
func normalizeConfigID(id string) string {
|
||||
switch strings.TrimSpace(id) {
|
||||
case "models":
|
||||
|
||||
@@ -45,9 +45,8 @@ func TestACPRebuildSerializesCollaborationAndApprovalChanges(t *testing.T) {
|
||||
rebuildErr := make(chan error, 1)
|
||||
go func() {
|
||||
rebuildErr <- svc.rebuildSession(context.Background(), sess, SessionConfigState{
|
||||
Model: "pro",
|
||||
RuntimeProfile: "delivery",
|
||||
}, []sessionConfigDelta{{axis: "work_mode", runtimeProfile: "delivery"}})
|
||||
Model: "pro",
|
||||
}, []sessionConfigDelta{{axis: "model", model: "pro"}})
|
||||
}()
|
||||
select {
|
||||
case <-buildStarted:
|
||||
@@ -96,8 +95,8 @@ func TestACPRebuildSerializesCollaborationAndApprovalChanges(t *testing.T) {
|
||||
if !ctrl.PlanMode() || ctrl.ToolApprovalMode() != control.ToolApprovalAuto {
|
||||
t.Fatalf("post-rebuild axes = plan:%v approval:%q, want plan + auto", ctrl.PlanMode(), ctrl.ToolApprovalMode())
|
||||
}
|
||||
if sess.runtimeProfile != "delivery" || sess.currentModeID() != sessionModePlan {
|
||||
t.Fatalf("post-rebuild session = profile:%q mode:%q, want delivery + plan", sess.runtimeProfile, sess.currentModeID())
|
||||
if sess.model != "pro" || sess.currentModeID() != sessionModePlan {
|
||||
t.Fatalf("post-rebuild session = model:%q mode:%q, want pro + plan", sess.model, sess.currentModeID())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,11 +312,10 @@ func TestACPRebuildSessionAppliesPendingConfigAfterMaintenance(t *testing.T) {
|
||||
|
||||
// TestACPRebuildSessionQueuedCrossAxisChangeDoesNotRollbackCompletedAxis pins
|
||||
// the fix for a race where a queued config change resolved its full
|
||||
// SessionConfigState snapshot at enqueue time from sess.model/effortOverride/
|
||||
// runtimeProfile — fields that only update once an in-flight rebuild for a
|
||||
// *different* axis lands. Queuing a work-mode (profile) switch while a model
|
||||
// switch was still rebuilding used to restore the pre-switch model as soon as
|
||||
// the queued profile switch drained.
|
||||
// SessionConfigState snapshot at enqueue time from sess.model/effortOverride —
|
||||
// fields that only update once an in-flight rebuild for a *different* axis
|
||||
// lands. Queuing an effort switch while a model switch was still rebuilding
|
||||
// used to restore the pre-switch model as soon as the queued effort drained.
|
||||
func TestACPRebuildSessionQueuedCrossAxisChangeDoesNotRollbackCompletedAxis(t *testing.T) {
|
||||
sink := newUpdateSink(&fakeNotifier{}, "sess-cross-axis")
|
||||
sess := &acpSession{
|
||||
@@ -355,10 +353,8 @@ func TestACPRebuildSessionQueuedCrossAxisChangeDoesNotRollbackCompletedAxis(t *t
|
||||
t.Fatal("first rebuild did not start")
|
||||
}
|
||||
|
||||
// Role-setting switch during an in-flight model rebuild must not block or
|
||||
// silently queue; it returns busy so the client retries after idle.
|
||||
if _, err := svc.switchSessionRuntimeProfile(context.Background(), sess, "delivery"); err == nil {
|
||||
t.Fatal("expected busy error while model rebuild holds the state-change lock")
|
||||
if _, err := svc.switchSessionEffort(context.Background(), sess, "high"); err != nil {
|
||||
t.Fatalf("queue effort during model rebuild: %v", err)
|
||||
}
|
||||
|
||||
close(factory.releaseFirst)
|
||||
@@ -374,22 +370,14 @@ func TestACPRebuildSessionQueuedCrossAxisChangeDoesNotRollbackCompletedAxis(t *t
|
||||
t.Fatal("model switch did not finish")
|
||||
}
|
||||
|
||||
// Only the model rebuild ran; role setting refused while busy.
|
||||
if got, want := factory.buildCount(), 1; got != want {
|
||||
if got, want := factory.buildCount(), 2; got != want {
|
||||
t.Fatalf("factory builds = %d, want %d", got, want)
|
||||
}
|
||||
if sess.model != "pro" {
|
||||
t.Fatalf("session model = %q, want pro", sess.model)
|
||||
}
|
||||
// Idle switch after rebuild applies in place.
|
||||
if _, err := svc.switchSessionRuntimeProfile(context.Background(), sess, "delivery"); err != nil {
|
||||
t.Fatalf("idle role setting switch: %v", err)
|
||||
}
|
||||
if sess.runtimeProfile != "delivery" {
|
||||
t.Fatalf("session runtime profile = %q, want delivery", sess.runtimeProfile)
|
||||
}
|
||||
if factory.buildCount() != 1 {
|
||||
t.Fatalf("idle role setting must not rebuild")
|
||||
if got := stringPtrValue(sess.effortOverride); got != "high" {
|
||||
t.Fatalf("session effort = %q, want high", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,7 +562,7 @@ func (c *planModeDriftProbeController) PlanMode() bool {
|
||||
|
||||
// TestACPFinishTurnReconcilesModeDriftBeforeExposingIdle pins the fix for the
|
||||
// race where finish() exposed the session as idle before emitModeDrift
|
||||
// corrected a controller-side Plan auto-exit. A concurrent work-mode switch
|
||||
// corrected a controller-side Plan auto-exit. A concurrent model switch
|
||||
// landing in that window used to see sess.running already false, rebuild
|
||||
// immediately from the stale "plan" modeID, and resurrect Plan mode on the
|
||||
// replacement controller even though the controller had already exited it.
|
||||
@@ -621,16 +609,13 @@ func TestACPFinishTurnReconcilesModeDriftBeforeExposingIdle(t *testing.T) {
|
||||
t.Fatal("mode drift check did not run")
|
||||
}
|
||||
|
||||
// Concurrent role-setting switch must not rebuild from a stale modeID.
|
||||
// While finishTurn holds stateChangeMu it is busy; after finish it applies
|
||||
// in place without resurrecting Plan mode.
|
||||
// Concurrent model switch must not rebuild from a stale modeID.
|
||||
switchDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := svc.switchSessionRuntimeProfile(context.Background(), sess, "delivery")
|
||||
// Retry once after finish if the first attempt was busy.
|
||||
_, err := svc.switchSessionModel(context.Background(), sess, "pro")
|
||||
if err != nil {
|
||||
<-finished
|
||||
_, err = svc.switchSessionRuntimeProfile(context.Background(), sess, "delivery")
|
||||
_, err = svc.switchSessionModel(context.Background(), sess, "pro")
|
||||
}
|
||||
switchDone <- err
|
||||
}()
|
||||
@@ -644,25 +629,25 @@ func TestACPFinishTurnReconcilesModeDriftBeforeExposingIdle(t *testing.T) {
|
||||
select {
|
||||
case err := <-switchDone:
|
||||
if err != nil {
|
||||
t.Fatalf("switchSessionRuntimeProfile: %v", err)
|
||||
t.Fatalf("switchSessionModel: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("work-mode switch did not complete")
|
||||
t.Fatal("model switch did not complete")
|
||||
}
|
||||
|
||||
if sess.currentCtrl().PlanMode() {
|
||||
t.Fatal("concurrent work-mode switch resurrected Plan mode after it had already exited")
|
||||
t.Fatal("concurrent model switch resurrected Plan mode after it had already exited")
|
||||
}
|
||||
if got := sess.currentModeID(); got != sessionModeNormal {
|
||||
t.Fatalf("session modeID = %q, want normal", got)
|
||||
}
|
||||
if sess.runtimeProfile != "delivery" {
|
||||
t.Fatalf("runtime profile = %q, want delivery", sess.runtimeProfile)
|
||||
if sess.model != "pro" {
|
||||
t.Fatalf("session model = %q, want pro", sess.model)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPPendingConfigMergesAxesQueuedDuringActiveTurn pins the per-axis
|
||||
// pending-config queue: a model change and a work-mode change both requested
|
||||
// pending-config queue: a model change and an effort change both requested
|
||||
// during one active turn must both apply when the turn ends and the queue
|
||||
// drains. With the old single-slot queue the second request silently
|
||||
// overwrote the first even though both RPCs had already reported success and
|
||||
@@ -687,32 +672,26 @@ func TestACPPendingConfigMergesAxesQueuedDuringActiveTurn(t *testing.T) {
|
||||
if _, err := svc.switchSessionModel(context.Background(), sess, "fast"); err != nil {
|
||||
t.Fatalf("switchSessionModel during turn: %v", err)
|
||||
}
|
||||
// Role settings refuse active turns (no silent queue).
|
||||
if _, err := svc.switchSessionRuntimeProfile(context.Background(), sess, "delivery"); err == nil {
|
||||
t.Fatal("expected busy error for role setting during active turn")
|
||||
if _, err := svc.switchSessionEffort(context.Background(), sess, "high"); err != nil {
|
||||
t.Fatalf("switchSessionEffort during turn: %v", err)
|
||||
}
|
||||
sess.mu.Lock()
|
||||
queued := len(sess.pendingConfig)
|
||||
sess.mu.Unlock()
|
||||
if queued != 1 {
|
||||
t.Fatalf("pending deltas = %d, want model only (1)", queued)
|
||||
if queued != 2 {
|
||||
t.Fatalf("pending deltas = %d, want model + effort (2)", queued)
|
||||
}
|
||||
|
||||
svc.finishTurn(context.Background(), sess)
|
||||
|
||||
// Idle role-setting switch applies in place after the turn.
|
||||
if _, err := svc.switchSessionRuntimeProfile(context.Background(), sess, "delivery"); err != nil {
|
||||
t.Fatalf("idle role setting: %v", err)
|
||||
}
|
||||
|
||||
sess.mu.Lock()
|
||||
model, profile := sess.model, sess.runtimeProfile
|
||||
model, effort := sess.model, stringPtrValue(sess.effortOverride)
|
||||
sess.mu.Unlock()
|
||||
if model != "fast" || profile != "delivery" {
|
||||
t.Fatalf("after drain model = %q, profile = %q; want fast/delivery", model, profile)
|
||||
if model != "fast" || effort != "high" {
|
||||
t.Fatalf("after drain model = %q, effort = %q; want fast/high", model, effort)
|
||||
}
|
||||
if got := factory.buildCount(); got != 1 {
|
||||
t.Fatalf("factory builds = %d, want a single model rebuild (role setting is in-place)", got)
|
||||
t.Fatalf("factory builds = %d, want a single rebuild applying both queued axes", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +705,7 @@ func TestACPApplyPendingClaimsStateBeforeResolving(t *testing.T) {
|
||||
releasePro: make(chan struct{}),
|
||||
fastResolved: make(chan struct{}),
|
||||
}
|
||||
high := "high"
|
||||
sess := &acpSession{
|
||||
id: "sess-pending-order",
|
||||
ctrl: control.New(control.Options{}),
|
||||
@@ -735,7 +715,7 @@ func TestACPApplyPendingClaimsStateBeforeResolving(t *testing.T) {
|
||||
runtimeProfile: "balanced",
|
||||
pendingConfig: []sessionConfigDelta{
|
||||
{axis: "model", model: "pro"},
|
||||
{axis: "work_mode", runtimeProfile: "delivery"},
|
||||
{axis: "thought_level", effortOverride: &high},
|
||||
},
|
||||
}
|
||||
svc := &service{factory: factory, sessions: map[string]*acpSession{sess.id: sess}}
|
||||
@@ -788,8 +768,8 @@ func TestACPApplyPendingClaimsStateBeforeResolving(t *testing.T) {
|
||||
if got := sess.model; got != "fast" {
|
||||
t.Fatalf("session model = %q, want latest requested value fast", got)
|
||||
}
|
||||
if got := sess.runtimeProfile; got != "delivery" {
|
||||
t.Fatalf("runtime profile = %q, want pending different-axis value delivery preserved", got)
|
||||
if got := stringPtrValue(sess.effortOverride); got != "high" {
|
||||
t.Fatalf("effort = %q, want pending different-axis value high preserved", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,11 +939,11 @@ func TestACPFinishTurnModeDriftDoesNotRevertConcurrentSetMode(t *testing.T) {
|
||||
if _, _, ok := sess.begin(context.Background()); !ok {
|
||||
t.Fatal("begin failed")
|
||||
}
|
||||
// A work-mode change queued during the turn makes finishTurn rebuild the
|
||||
// A model change queued during the turn makes finishTurn rebuild the
|
||||
// controller, which re-applies the session's modeID — the step that turned
|
||||
// the stale drift write-back into a durable loss of the user's selection.
|
||||
sess.mu.Lock()
|
||||
sess.pendingConfig = []sessionConfigDelta{{axis: "work_mode", runtimeProfile: "delivery"}}
|
||||
sess.pendingConfig = []sessionConfigDelta{{axis: "model", model: "pro"}}
|
||||
sess.mu.Unlock()
|
||||
|
||||
finished := make(chan struct{})
|
||||
@@ -1017,6 +997,16 @@ func TestACPFinishTurnModeDriftDoesNotRevertConcurrentSetMode(t *testing.T) {
|
||||
if !sess.currentCtrl().PlanMode() {
|
||||
t.Fatal("rebuilt controller lost Plan mode after concurrent set_mode")
|
||||
}
|
||||
if sess.model != "pro" {
|
||||
t.Fatalf("session model = %q, want queued pro after finishTurn rebuild", sess.model)
|
||||
}
|
||||
}
|
||||
|
||||
func stringPtrValue(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// TestACPDriftEmittersSerializeWithStateChanges pins the lock contract behind
|
||||
|
||||
+5
-32
@@ -624,9 +624,6 @@ func (s *service) sessionRuntimeState(ctx context.Context, p SessionRuntimeState
|
||||
if err != nil {
|
||||
return SessionRuntimeState{}, err
|
||||
}
|
||||
if isLightRuntimeProfile(p.RuntimeProfile) {
|
||||
state.PlannerMode = "off"
|
||||
}
|
||||
if strings.TrimSpace(state.PlannerMode) == "" {
|
||||
state.PlannerMode = "on"
|
||||
}
|
||||
@@ -635,20 +632,7 @@ func (s *service) sessionRuntimeState(ctx context.Context, p SessionRuntimeState
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
state := defaultSessionRuntimeState(p.Cwd)
|
||||
if isLightRuntimeProfile(p.RuntimeProfile) {
|
||||
state.PlannerMode = "off"
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func isLightRuntimeProfile(profile string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(profile)) {
|
||||
case "economy", "light", "lite", "eco":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return defaultSessionRuntimeState(p.Cwd), nil
|
||||
}
|
||||
|
||||
func (s *service) bindStatusEvents(sess *acpSession) {
|
||||
@@ -698,7 +682,6 @@ func (s *acpSession) statusSnapshot() ReasonixSessionStatus {
|
||||
ctrl := s.ctrl
|
||||
model := s.model
|
||||
effort := cloneStringPtr(s.effortOverride)
|
||||
workMode := s.runtimeProfile
|
||||
mode := s.modeID
|
||||
runtimeState := s.runtimeState
|
||||
telemetry := s.status
|
||||
@@ -735,20 +718,10 @@ func (s *acpSession) statusSnapshot() ReasonixSessionStatus {
|
||||
goalStatus = t.goalOverride
|
||||
}
|
||||
mode = normalizeACPCollaborationMode(mode)
|
||||
workMode = strings.ToLower(strings.TrimSpace(workMode))
|
||||
switch workMode {
|
||||
case "economy", "light", "delivery":
|
||||
if workMode == "light" {
|
||||
workMode = "economy" // dual-write public status label
|
||||
}
|
||||
default:
|
||||
workMode = "balanced"
|
||||
}
|
||||
// Recompute planner mode from the live role setting so in-place switches
|
||||
// do not leave a stale on/off flag from the last controller rebuild.
|
||||
if isLightRuntimeProfile(workMode) {
|
||||
runtimeState.PlannerMode = "off"
|
||||
} else if runtimeState.PlannerMode != "off" {
|
||||
// WorkMode is a deprecated wire-compat field pinned to the historical
|
||||
// default; execution modes no longer exist at runtime.
|
||||
workMode := "balanced"
|
||||
if runtimeState.PlannerMode != "off" {
|
||||
runtimeState.PlannerMode = "on"
|
||||
}
|
||||
if runtimeState.Sandbox.WriteRoots == nil {
|
||||
|
||||
+18
-12
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/agentpreset"
|
||||
"reasonix/internal/billing"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
@@ -296,7 +297,7 @@ func TestRestoreStatusMarksInterruptedTurnPaused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusRecomputesPlannerModeAfterWorkModeSwitch(t *testing.T) {
|
||||
func TestStatusWorkModeSetConfigOptionIsDeprecatedNoop(t *testing.T) {
|
||||
factory := &runtimeTrackingFactory{configurableFactory: &configurableFactory{}}
|
||||
client, stop := startServer(t, factory)
|
||||
defer stop()
|
||||
@@ -305,27 +306,32 @@ func TestStatusRecomputesPlannerModeAfterWorkModeSwitch(t *testing.T) {
|
||||
if status := getStatus(t, client, sessionID); status.WorkMode != "balanced" || status.PlannerMode != "on" {
|
||||
t.Fatalf("initial runtime status = %+v", status)
|
||||
}
|
||||
buildsBefore := factory.buildCount()
|
||||
|
||||
for _, tc := range []struct {
|
||||
profile string
|
||||
planner string
|
||||
}{
|
||||
{profile: "economy", planner: "off"},
|
||||
{profile: "delivery", planner: "on"},
|
||||
} {
|
||||
for _, value := range []string{"economy", "delivery", "light"} {
|
||||
resp := client.call(t, "session/set_config_option", SetSessionConfigOptionParams{
|
||||
SessionID: sessionID,
|
||||
ConfigID: "work_mode",
|
||||
Value: tc.profile,
|
||||
Value: value,
|
||||
})
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("set work mode %q: %+v", tc.profile, resp.Error)
|
||||
t.Fatalf("set work mode %q: %+v", value, resp.Error)
|
||||
}
|
||||
var set SetSessionConfigOptionResult
|
||||
if err := json.Unmarshal(resp.Result, &set); err != nil {
|
||||
t.Fatalf("set work mode %q result: %v", value, err)
|
||||
}
|
||||
if set.DeprecatedNotice != agentpreset.DeprecatedNotice {
|
||||
t.Fatalf("set work mode %q deprecatedNotice = %q", value, set.DeprecatedNotice)
|
||||
}
|
||||
status := getStatus(t, client, sessionID)
|
||||
if status.WorkMode != tc.profile || status.PlannerMode != tc.planner {
|
||||
t.Fatalf("runtime status after %q = %+v", tc.profile, status)
|
||||
if status.WorkMode != "balanced" || status.PlannerMode != "on" {
|
||||
t.Fatalf("runtime status after deprecated work_mode %q = %+v", value, status)
|
||||
}
|
||||
}
|
||||
if got := factory.buildCount(); got != buildsBefore {
|
||||
t.Fatalf("work_mode rebuilt controller: builds=%d, want %d", got, buildsBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusClassifiesPauseAndError(t *testing.T) {
|
||||
|
||||
@@ -348,44 +348,40 @@ func TestACPSessionListAfterRecoveryShowsSingleActiveEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// profileSystemPromptFactory builds controllers whose leading system message
|
||||
// encodes the requested runtime profile, mirroring how boot.Build appends a
|
||||
// profile-specific contract to the system prompt (see boot/token_profile.go).
|
||||
// It lets a test check that a controller rebuild refreshes that contract
|
||||
// instead of carrying the outgoing profile's prompt forward.
|
||||
type profileSystemPromptFactory struct {
|
||||
// modelSystemPromptFactory builds controllers whose leading system message
|
||||
// encodes the requested model, so a rebuild test can check that AdoptHistory
|
||||
// splices in the replacement contract instead of carrying the outgoing one.
|
||||
type modelSystemPromptFactory struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func (f *profileSystemPromptFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
|
||||
prompt := "system prompt for profile " + p.RuntimeProfile
|
||||
func (f *modelSystemPromptFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
|
||||
prompt := "system prompt for model " + p.Model
|
||||
exec := agent.New(nil, nil, agent.NewSession(prompt), agent.Options{}, event.Discard)
|
||||
return control.New(control.Options{Executor: exec, SessionDir: f.dir, Label: p.RuntimeProfile}), nil
|
||||
return control.New(control.Options{Executor: exec, SessionDir: f.dir, Label: p.Model}), nil
|
||||
}
|
||||
|
||||
func (f *profileSystemPromptFactory) SessionDir() string { return f.dir }
|
||||
func (f *modelSystemPromptFactory) SessionDir() string { return f.dir }
|
||||
|
||||
// TestACPRebuildSessionRefreshesLeadingSystemPromptForNewProfile pins the fix
|
||||
// for the bug where a work-mode (runtime profile) switch rebuilt the
|
||||
// controller with the target profile's own system prompt, only for
|
||||
// AdoptHistory to immediately overwrite it with the carried history's
|
||||
// leading message — the outgoing profile's contract. The user-visible
|
||||
// symptom was that the model kept following the previous profile's
|
||||
// instructions after every switch.
|
||||
func TestACPRebuildSessionRefreshesLeadingSystemPromptForNewProfile(t *testing.T) {
|
||||
// TestACPRebuildSessionRefreshesLeadingSystemPromptForNewModel pins the fix
|
||||
// for the bug where a model switch rebuilt the controller with the target
|
||||
// model's own system prompt, only for AdoptHistory to immediately overwrite
|
||||
// it with the carried history's leading message — the outgoing model's
|
||||
// contract.
|
||||
func TestACPRebuildSessionRefreshesLeadingSystemPromptForNewModel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "acp-profile-switch.jsonl")
|
||||
path := filepath.Join(dir, "acp-model-switch.jsonl")
|
||||
|
||||
oldSession := agent.NewSession("system prompt for profile balanced")
|
||||
oldSession := agent.NewSession("system prompt for model fast")
|
||||
oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
||||
oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
|
||||
if err := oldSession.Save(path); err != nil {
|
||||
t.Fatalf("save base session: %v", err)
|
||||
}
|
||||
|
||||
sink := newUpdateSink(&fakeNotifier{}, "sess-profile-switch")
|
||||
sink := newUpdateSink(&fakeNotifier{}, "sess-model-switch")
|
||||
sess := &acpSession{
|
||||
id: "sess-profile-switch",
|
||||
id: "sess-model-switch",
|
||||
sink: sink,
|
||||
cwd: dir,
|
||||
model: "fast",
|
||||
@@ -403,15 +399,15 @@ func TestACPRebuildSessionRefreshesLeadingSystemPromptForNewProfile(t *testing.T
|
||||
Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard),
|
||||
SessionDir: dir,
|
||||
SessionPath: path,
|
||||
Label: "balanced",
|
||||
Label: "fast",
|
||||
})
|
||||
svc := &service{
|
||||
factory: &profileSystemPromptFactory{dir: dir},
|
||||
factory: &modelSystemPromptFactory{dir: dir},
|
||||
sessions: map[string]*acpSession{sess.id: sess},
|
||||
}
|
||||
|
||||
deltas := []sessionConfigDelta{{axis: "work_mode", runtimeProfile: "delivery"}}
|
||||
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{RuntimeProfile: "delivery"}, deltas); err != nil {
|
||||
deltas := []sessionConfigDelta{{axis: "model", model: "pro"}}
|
||||
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}, deltas); err != nil {
|
||||
t.Fatalf("rebuildSession: %v", err)
|
||||
}
|
||||
|
||||
@@ -419,27 +415,27 @@ func TestACPRebuildSessionRefreshesLeadingSystemPromptForNewProfile(t *testing.T
|
||||
if len(history) == 0 || history[0].Role != provider.RoleSystem {
|
||||
t.Fatalf("history = %+v, want a leading system message", history)
|
||||
}
|
||||
if got, want := history[0].Content, "system prompt for profile delivery"; got != want {
|
||||
t.Fatalf("leading system message = %q, want %q (stale outgoing-profile contract carried forward)", got, want)
|
||||
if got, want := history[0].Content, "system prompt for model pro"; got != want {
|
||||
t.Fatalf("leading system message = %q, want %q (stale outgoing-model contract carried forward)", got, want)
|
||||
}
|
||||
if len(history) != 3 || history[1].Content != "hello" || history[2].Content != "hi" {
|
||||
t.Fatalf("history after profile switch = %+v, want carried user/assistant turns preserved", history)
|
||||
t.Fatalf("history after model switch = %+v, want carried user/assistant turns preserved", history)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPWorkModeSwitchPersistsRefreshedSystemPromptAcrossReload pins the disk
|
||||
// half of the profile-switch fix: the refreshed leading system prompt must be
|
||||
// TestACPModelSwitchPersistsRefreshedSystemPromptAcrossReload pins the disk
|
||||
// half of the rebuild-prompt fix: the refreshed leading system prompt must be
|
||||
// persisted at switch time, because session/close never snapshots and
|
||||
// session/load resumes the transcript exactly as saved. Before the fix the
|
||||
// switch refreshed only the new controller's in-memory history, so a
|
||||
// switch → close → load sequence revived the outgoing profile's contract even
|
||||
// though the session metadata already claimed the new profile.
|
||||
func TestACPWorkModeSwitchPersistsRefreshedSystemPromptAcrossReload(t *testing.T) {
|
||||
// switch → close → load sequence revived the outgoing model's contract even
|
||||
// though the session metadata already claimed the new model.
|
||||
func TestACPModelSwitchPersistsRefreshedSystemPromptAcrossReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
id := "sess-profile-persist"
|
||||
id := "sess-model-persist"
|
||||
path := transcriptPath(dir, id)
|
||||
|
||||
oldSession := agent.NewSession("system prompt for profile balanced")
|
||||
oldSession := agent.NewSession("system prompt for model fast")
|
||||
oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
||||
oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
|
||||
if err := oldSession.Save(path); err != nil {
|
||||
@@ -464,16 +460,16 @@ func TestACPWorkModeSwitchPersistsRefreshedSystemPromptAcrossReload(t *testing.T
|
||||
Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard),
|
||||
SessionDir: dir,
|
||||
SessionPath: path,
|
||||
Label: "balanced",
|
||||
Label: "fast",
|
||||
})
|
||||
svc := &service{
|
||||
conn: NewConn(strings.NewReader(""), io.Discard),
|
||||
factory: &profileSystemPromptFactory{dir: dir},
|
||||
factory: &modelSystemPromptFactory{dir: dir},
|
||||
sessions: map[string]*acpSession{sess.id: sess},
|
||||
}
|
||||
|
||||
deltas := []sessionConfigDelta{{axis: "work_mode", runtimeProfile: "delivery"}}
|
||||
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{RuntimeProfile: "delivery"}, deltas); err != nil {
|
||||
deltas := []sessionConfigDelta{{axis: "model", model: "pro"}}
|
||||
if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}, deltas); err != nil {
|
||||
t.Fatalf("rebuildSession: %v", err)
|
||||
}
|
||||
|
||||
@@ -482,8 +478,8 @@ func TestACPWorkModeSwitchPersistsRefreshedSystemPromptAcrossReload(t *testing.T
|
||||
if err != nil {
|
||||
t.Fatalf("load transcript after switch: %v", err)
|
||||
}
|
||||
if msgs := onDisk.Snapshot(); len(msgs) == 0 || msgs[0].Content != "system prompt for profile delivery" {
|
||||
t.Fatalf("on-disk leading message after switch = %+v, want the delivery profile contract", msgs)
|
||||
if msgs := onDisk.Snapshot(); len(msgs) == 0 || msgs[0].Content != "system prompt for model pro" {
|
||||
t.Fatalf("on-disk leading message after switch = %+v, want the pro model contract", msgs)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(SessionCloseParams{SessionID: id})
|
||||
@@ -510,59 +506,59 @@ func TestACPWorkModeSwitchPersistsRefreshedSystemPromptAcrossReload(t *testing.T
|
||||
if len(history) == 0 || history[0].Role != provider.RoleSystem {
|
||||
t.Fatalf("loaded history = %+v, want a leading system message", history)
|
||||
}
|
||||
if got, want := history[0].Content, "system prompt for profile delivery"; got != want {
|
||||
t.Fatalf("leading system prompt after switch → close → load = %q, want %q (stale outgoing-profile contract revived from disk)", got, want)
|
||||
if got, want := history[0].Content, "system prompt for model pro"; got != want {
|
||||
t.Fatalf("leading system prompt after switch → close → load = %q, want %q (stale outgoing-model contract revived from disk)", got, want)
|
||||
}
|
||||
if len(history) != 3 || history[1].Content != "hello" || history[2].Content != "hi" {
|
||||
t.Fatalf("loaded history = %+v, want carried user/assistant turns preserved", history)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPWorkModeSwitchSnapshotFailureKeepsOutgoingController proves failure
|
||||
// TestACPModelSwitchSnapshotFailureKeepsOutgoingController proves failure
|
||||
// atomicity for the switch-time persistence step. If the refreshed history
|
||||
// cannot be written, the service must return an error and leave the outgoing
|
||||
// controller/config active instead of publishing an in-memory-only switch.
|
||||
func TestACPWorkModeSwitchSnapshotFailureKeepsOutgoingController(t *testing.T) {
|
||||
func TestACPModelSwitchSnapshotFailureKeepsOutgoingController(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
invalidPath := filepath.Join(dir, "transcript-is-a-directory")
|
||||
if err := os.Mkdir(invalidPath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir invalid transcript path: %v", err)
|
||||
}
|
||||
|
||||
oldSession := agent.NewSession("system prompt for profile balanced")
|
||||
oldSession := agent.NewSession("system prompt for model fast")
|
||||
oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
||||
oldSession.Add(provider.Message{Role: provider.RoleAssistant, Content: "hi"})
|
||||
oldCtrl := &snapshotLockProbeController{Controller: control.New(control.Options{
|
||||
Executor: agent.New(nil, nil, oldSession, agent.Options{}, event.Discard),
|
||||
SessionDir: dir,
|
||||
SessionPath: invalidPath,
|
||||
Label: "balanced",
|
||||
Label: "fast",
|
||||
})}
|
||||
t.Cleanup(oldCtrl.Close)
|
||||
|
||||
sess := &acpSession{
|
||||
id: "sess-profile-persist-failure",
|
||||
id: "sess-model-persist-failure",
|
||||
ctrl: oldCtrl,
|
||||
sink: newUpdateSink(&fakeNotifier{}, "sess-profile-persist-failure"),
|
||||
sink: newUpdateSink(&fakeNotifier{}, "sess-model-persist-failure"),
|
||||
cwd: dir,
|
||||
model: "fast",
|
||||
runtimeProfile: "balanced",
|
||||
transcript: invalidPath,
|
||||
}
|
||||
svc := &service{
|
||||
factory: &profileSystemPromptFactory{dir: dir},
|
||||
factory: &modelSystemPromptFactory{dir: dir},
|
||||
sessions: map[string]*acpSession{sess.id: sess},
|
||||
}
|
||||
|
||||
deltas := []sessionConfigDelta{{axis: "work_mode", runtimeProfile: "delivery"}}
|
||||
err := svc.rebuildSession(context.Background(), sess, SessionConfigState{RuntimeProfile: "delivery"}, deltas)
|
||||
deltas := []sessionConfigDelta{{axis: "model", model: "pro"}}
|
||||
err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}, deltas)
|
||||
if err == nil || !strings.Contains(err.Error(), "snapshot after switch") {
|
||||
t.Fatalf("rebuildSession error = %v, want snapshot after switch failure", err)
|
||||
}
|
||||
if got := sess.currentCtrl(); got != oldCtrl {
|
||||
t.Fatalf("controller changed after persistence failure: got %T %p, want outgoing %p", got, got, oldCtrl)
|
||||
}
|
||||
if got := sess.runtimeProfile; got != "balanced" {
|
||||
t.Fatalf("runtime profile = %q, want outgoing balanced after persistence failure", got)
|
||||
if got := sess.model; got != "fast" {
|
||||
t.Fatalf("model = %q, want outgoing fast after persistence failure", got)
|
||||
}
|
||||
}
|
||||
+27
-63
@@ -15,7 +15,6 @@ import (
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
|
||||
"reasonix/internal/ablation"
|
||||
"reasonix/internal/agentpreset"
|
||||
"reasonix/internal/capability"
|
||||
"reasonix/internal/checkpoint"
|
||||
"reasonix/internal/diff"
|
||||
@@ -360,17 +359,15 @@ type Agent struct {
|
||||
// verify against same-turn bash receipts after a write-backed completion.
|
||||
projectChecks []instruction.VerifyCheck
|
||||
|
||||
// deliveryProfile enables the runtime-enforced delivery contract. The stable
|
||||
// profile prompt explains intent; this is host state and never enters the
|
||||
// provider-cached prefix. The scope ID and checkpoint it works against live
|
||||
// in task; the per-turn expectations live in turn.
|
||||
// When agentPreset is set, deliveryProfile is derived for baseline Delivery
|
||||
// and may be elevated per-turn by TaskPolicy (e.g. Light high-risk).
|
||||
deliveryProfile bool
|
||||
// closedLoop gates come from the frozen per-turn TaskPolicy (see turn.policy
|
||||
// and closedLoopActive). Host state only; never enters the provider-cached
|
||||
// prefix. The scope ID and checkpoint it works against live in task; the
|
||||
// per-turn expectations live in turn.
|
||||
|
||||
// agentPreset is the session role setting. Atomic so SetAgentPreset can
|
||||
// update subsequent turns without rebuilding the agent.
|
||||
agentPreset atomic.Value // string light|balanced|delivery
|
||||
// inheritedPolicy is the writer parent's frozen TaskPolicy. Writer
|
||||
// sub-agents merge its risk and closure floors into their own policy;
|
||||
// read-only sub-agents leave it nil.
|
||||
inheritedPolicy *taskpolicy.TaskPolicy
|
||||
|
||||
// turn is the state of the Run currently executing; beginRunTurn replaces
|
||||
// it wholesale. See turnruntime.go.
|
||||
@@ -947,26 +944,17 @@ type Options struct {
|
||||
// WriteWorkspaceRoot normalizes parent write reservations.
|
||||
WriteWorkspaceRoot string
|
||||
|
||||
// WorkspaceLease serializes Delivery mutations across sessions that target
|
||||
// WorkspaceLease serializes writer mutations across sessions that target
|
||||
// the same workspace. nil preserves source compatibility for direct Agent
|
||||
// construction; boot always supplies it for Delivery sessions.
|
||||
// construction; boot always supplies it for writer-capable sessions.
|
||||
WorkspaceLease *workspacelease.Owner
|
||||
|
||||
// ProjectChecks are host-observable structured checks extracted during boot.
|
||||
ProjectChecks []instruction.VerifyCheck
|
||||
|
||||
// DeliveryProfile enforces acceptance criteria before mutations and requires
|
||||
// post-change review, verification, and evidence-backed sign-off before a
|
||||
// final answer. It changes host control flow, not tool schemas.
|
||||
// Deprecated: prefer AgentPreset + turn TaskPolicy. Still honored when
|
||||
// AgentPreset is empty for one compatibility version of direct constructors.
|
||||
DeliveryProfile bool
|
||||
|
||||
// AgentPreset is the session role setting (light|balanced|delivery). Empty
|
||||
// falls back to balanced unless DeliveryProfile is true (then delivery).
|
||||
// Switching the preset mid-session does not rebuild the agent; the value is
|
||||
// frozen at turn admission into TaskPolicy.
|
||||
AgentPreset string
|
||||
// InheritedTaskPolicy is the writer parent's frozen TaskPolicy. Writer
|
||||
// sub-agents merge its risk/closure floors; read-only sub-agents ignore it.
|
||||
InheritedTaskPolicy *taskpolicy.TaskPolicy
|
||||
|
||||
// Ablation switches subsystems off for a benchmark arm. The zero value runs
|
||||
// everything, so ordinary callers leave it unset.
|
||||
@@ -1116,7 +1104,7 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
|
||||
readOnlyExecution: opts.ReadOnlyExecution,
|
||||
plannerMCPExecution: opts.PlannerMCPExecution,
|
||||
projectChecks: append([]instruction.VerifyCheck(nil), opts.ProjectChecks...),
|
||||
deliveryProfile: opts.DeliveryProfile || agentpreset.Normalize(opts.AgentPreset) == agentpreset.Delivery,
|
||||
inheritedPolicy: opts.InheritedTaskPolicy,
|
||||
ablation: opts.Ablation,
|
||||
capabilityLedger: opts.CapabilityLedger,
|
||||
capabilityAudit: opts.CapabilityAudit,
|
||||
@@ -1127,11 +1115,6 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
|
||||
if a.sess.path != "" {
|
||||
a.LoadProjectionSidecar(a.sess.path)
|
||||
}
|
||||
preset := strings.TrimSpace(opts.AgentPreset)
|
||||
if preset == "" && opts.DeliveryProfile {
|
||||
preset = string(agentpreset.Delivery)
|
||||
}
|
||||
a.SetAgentPreset(preset)
|
||||
a.SetResponseLanguage(opts.ResponseLanguage)
|
||||
a.SetReasoningLanguage(opts.ReasoningLanguage)
|
||||
a.maybeArmForkFromEnv()
|
||||
@@ -1139,40 +1122,16 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
|
||||
return a
|
||||
}
|
||||
|
||||
// SetAgentPreset updates the role setting used by subsequent turns. It does not
|
||||
// rebuild providers, tools, or history. The in-flight turn keeps its frozen
|
||||
// TaskPolicy.
|
||||
func (a *Agent) SetAgentPreset(preset string) {
|
||||
// closedLoopActive reports whether the current (or most recent) turn must
|
||||
// close the evidence loop. It replaces every historical deliveryProfile gate:
|
||||
// acceptance criteria before mutations, todo ownership, opaque-bash limits,
|
||||
// capability call preference, post-write verification, review, and sign-off.
|
||||
// It is authoritative only for host control flow, never for tool schemas.
|
||||
func (a *Agent) closedLoopActive() bool {
|
||||
if a == nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
p := agentpreset.Normalize(preset)
|
||||
a.agentPreset.Store(string(p))
|
||||
// Keep baseline deliveryProfile aligned with Delivery role setting so
|
||||
// legacy gates that still read the bool stay coherent until fully migrated.
|
||||
switch p {
|
||||
case agentpreset.Delivery:
|
||||
a.deliveryProfile = true
|
||||
case agentpreset.Light, agentpreset.Balanced:
|
||||
// Light may still elevate per-turn; baseline stays non-delivery.
|
||||
a.deliveryProfile = false
|
||||
}
|
||||
}
|
||||
|
||||
// AgentPreset returns the current session role setting (never empty).
|
||||
func (a *Agent) AgentPreset() string {
|
||||
if a == nil {
|
||||
return string(agentpreset.Balanced)
|
||||
}
|
||||
if v := a.agentPreset.Load(); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return string(agentpreset.Normalize(s))
|
||||
}
|
||||
}
|
||||
if a.deliveryProfile {
|
||||
return string(agentpreset.Delivery)
|
||||
}
|
||||
return string(agentpreset.Balanced)
|
||||
return a.turn.policySet && a.turn.policy.ClosedLoop()
|
||||
}
|
||||
|
||||
// TurnPolicy returns the frozen TaskPolicy for the active turn, if any.
|
||||
@@ -1283,6 +1242,11 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||||
state.runMaxSteps = runMaxSteps
|
||||
state.runMaxStepsKey = runMaxStepsKey
|
||||
state.workDurationMs = workDurationMs
|
||||
// Publish the frozen policy so writer sub-agents spawned this turn inherit
|
||||
// its risk and closure floors instead of re-deriving a weaker contract.
|
||||
if a.turn.policySet {
|
||||
ctx = taskpolicy.WithContext(ctx, a.turn.policy)
|
||||
}
|
||||
return a.runToolLoop(ctx, state)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/evidence"
|
||||
"reasonix/internal/jobs"
|
||||
)
|
||||
|
||||
// publishBackgroundEvidence retains host-only workspace context so persisted
|
||||
// task metadata classifies paths without treating checkout ancestors as scope.
|
||||
func publishBackgroundEvidence(ctx context.Context, ledger *evidence.Ledger, workspaceRoot string) {
|
||||
if ledger == nil {
|
||||
return
|
||||
}
|
||||
summary := ledger.Summary()
|
||||
summary.WorkspaceRoot = strings.TrimSpace(workspaceRoot)
|
||||
jobs.PublishEvidence(ctx, summary)
|
||||
}
|
||||
@@ -17,6 +17,15 @@ func (a *Agent) blockedToolOutcome(plan *toolCallPlan, msg string) toolOutcome {
|
||||
return out
|
||||
}
|
||||
|
||||
// blockedShellOutcome fills host-only terminal metadata for an early policy
|
||||
// refusal that was produced before blockedToolOutcome could shape it.
|
||||
func blockedShellOutcome(out toolOutcome, plan *toolCallPlan) toolOutcome {
|
||||
if out.execution == nil && plan != nil && (plan.evidenceName == "bash" || plan.call.Name == "bash") {
|
||||
out.execution = shellPreflightExecution(plan, plan.verification)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shellPreflightExecution builds not_run/preflight metadata for a blocked bash call.
|
||||
func shellPreflightExecution(plan *toolCallPlan, hasVerification bool) *tool.ShellExecution {
|
||||
ex := &tool.ShellExecution{
|
||||
|
||||
@@ -32,10 +32,9 @@ type BranchMeta struct {
|
||||
TopicTitle string `json:"topic_title,omitempty"`
|
||||
CustomTitle string `json:"custom_title,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
// TokenMode is the legacy dual-write value (economy|full|delivery). Prefer
|
||||
// AgentPreset (light|balanced|delivery) when both are present.
|
||||
// TokenMode is a deprecated dual-write field; new writes pin it to "full".
|
||||
TokenMode string `json:"token_mode,omitempty"`
|
||||
// AgentPreset is the session role setting (角色设定): light|balanced|delivery.
|
||||
// AgentPreset is a deprecated dual-write field; new writes pin it to "balanced".
|
||||
AgentPreset string `json:"agent_preset,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
ToolApprovalMode string `json:"tool_approval_mode,omitempty"`
|
||||
|
||||
@@ -396,7 +396,7 @@ func TestCancelDuringToolExecutionBreaksOutPromptly(t *testing.T) {
|
||||
start := time.Now()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- a.Run(ctx, "test cancel during tool execution")
|
||||
done <- a.Run(withNoClosedLoop(ctx), "test cancel during tool execution")
|
||||
}()
|
||||
|
||||
// Cancel after a short delay to simulate user pressing Esc mid-execution
|
||||
@@ -462,7 +462,7 @@ func TestCancelDuringBatchStopsRemainingTools(t *testing.T) {
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- a.Run(ctx, "test batch cancel")
|
||||
done <- a.Run(withNoClosedLoop(ctx), "test batch cancel")
|
||||
}()
|
||||
|
||||
// Cancel while tool2 is still running (after tool1 completes but during tool2)
|
||||
@@ -549,7 +549,7 @@ func TestCancelBeforeParallelBatchSkipsTheWholeRemainingBatch(t *testing.T) {
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- a.Run(ctx, "test cancel before parallel batch")
|
||||
done <- a.Run(withNoClosedLoop(ctx), "test cancel before parallel batch")
|
||||
}()
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
@@ -136,9 +136,9 @@ func splitMCP(name string) (server, raw string, ok bool) {
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
// capabilityGateFailure is checked during final readiness for Delivery.
|
||||
// capabilityGateFailure is checked during final readiness for closed-loop turns.
|
||||
func (a *Agent) capabilityGateFailure() string {
|
||||
if a == nil || !a.deliveryProfile || a.capabilityLedger == nil {
|
||||
if a == nil || !a.closedLoopActive() || a.capabilityLedger == nil {
|
||||
return ""
|
||||
}
|
||||
gate := a.capabilityLedger.CheckFinalGate()
|
||||
@@ -198,14 +198,14 @@ func (a *Agent) capabilityGateFailure() string {
|
||||
}
|
||||
|
||||
// deliveryReviewGateFailure enforces risk-adaptive structured review after the
|
||||
// latest mutation. When TaskPolicy is set, its Review level is authoritative;
|
||||
// otherwise Delivery-profile medium/high risk keeps the legacy matrix.
|
||||
// latest mutation. The frozen TaskPolicy's Review level and closed-loop flag
|
||||
// are authoritative.
|
||||
func (a *Agent) deliveryReviewGateFailure() string {
|
||||
if a == nil || a.task.ledger == nil {
|
||||
return ""
|
||||
}
|
||||
// Without Delivery elevation or a forced TaskPolicy review, skip.
|
||||
if !a.deliveryProfile && !(a.turn.policySet && a.turn.policy.RequiresIndependentReview()) {
|
||||
// Without closed-loop elevation or a forced TaskPolicy review, skip.
|
||||
if !a.closedLoopActive() && !(a.turn.policySet && a.turn.policy.RequiresIndependentReview()) {
|
||||
return ""
|
||||
}
|
||||
if a.subagentDepth > 0 {
|
||||
@@ -223,7 +223,7 @@ func (a *Agent) deliveryReviewGateFailure() string {
|
||||
return ""
|
||||
}
|
||||
a.emitTurnPhase(event.TurnPhaseReviewing)
|
||||
risk := a.task.ledger.MutationRiskAfter(mutation)
|
||||
risk := a.task.ledger.MutationRiskWithin(a.writeWorkspaceRoot)
|
||||
// TaskPolicy may force higher review than mutation-risk alone.
|
||||
if a.turn.policySet {
|
||||
switch a.turn.policy.Review {
|
||||
@@ -237,7 +237,7 @@ func (a *Agent) deliveryReviewGateFailure() string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
paths := productionPaths(a.task.ledger.PathsSince(mutation))
|
||||
paths := productionPaths(a.task.ledger.PathsSince(-1))
|
||||
hasReviewTool := a.svc.tools != nil && (toolPresent(a.svc.tools, "review") || toolPresent(a.svc.tools, "run_skill") || toolPresent(a.svc.tools, "use_capability"))
|
||||
hasSecurityTool := a.svc.tools != nil && (toolPresent(a.svc.tools, "security_review") || toolPresent(a.svc.tools, "run_skill") || toolPresent(a.svc.tools, "use_capability"))
|
||||
switch risk {
|
||||
|
||||
@@ -1,15 +1,49 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/evidence"
|
||||
"reasonix/internal/taskpolicy"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestDeliveryReviewGateExplainsOpaqueMutationRecovery(t *testing.T) {
|
||||
func closedLoopTurnPolicy(review taskpolicy.Review) taskpolicy.TaskPolicy {
|
||||
return taskpolicy.TaskPolicy{
|
||||
Evidence: taskpolicy.EvidenceClosedLoop,
|
||||
Review: review,
|
||||
}
|
||||
}
|
||||
|
||||
func withClosedLoopContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return taskpolicy.WithContext(ctx, closedLoopTurnPolicy(taskpolicy.ReviewForced))
|
||||
}
|
||||
|
||||
func noClosedLoopPolicy() taskpolicy.TaskPolicy {
|
||||
return taskpolicy.TaskPolicy{
|
||||
// Pin risk at the ceiling so escalatePolicyFromEvidence cannot
|
||||
// RaiseRisk and reevaluate floors into a closed loop.
|
||||
Risk: taskpolicy.RiskHigh,
|
||||
Evidence: taskpolicy.EvidenceNone,
|
||||
Review: taskpolicy.ReviewNone,
|
||||
Verification: taskpolicy.VerifyNone,
|
||||
}
|
||||
}
|
||||
|
||||
func withNoClosedLoop(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return taskpolicy.WithContext(ctx, noClosedLoopPolicy())
|
||||
}
|
||||
|
||||
func TestClosedLoopReviewGateExplainsOpaqueMutationRecovery(t *testing.T) {
|
||||
ledger := evidence.NewLedger()
|
||||
ledger.Record(evidence.Receipt{
|
||||
ToolName: "bash",
|
||||
@@ -21,7 +55,11 @@ func TestDeliveryReviewGateExplainsOpaqueMutationRecovery(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "review", readOnly: true})
|
||||
reg.Add(fakeTool{name: "security_review", readOnly: true})
|
||||
a := &Agent{deliveryProfile: true, task: taskRuntime{ledger: ledger}, svc: agentServices{tools: reg}}
|
||||
a := &Agent{
|
||||
task: taskRuntime{ledger: ledger},
|
||||
svc: agentServices{tools: reg},
|
||||
turn: turnRuntime{policySet: true, policy: closedLoopTurnPolicy(taskpolicy.ReviewForcedSecurity)},
|
||||
}
|
||||
|
||||
got := a.deliveryReviewGateFailure()
|
||||
for _, want := range []string{"high-risk", "git status --short", "git diff", "mutation did not report file paths"} {
|
||||
@@ -55,28 +93,32 @@ func TestDeliveryReviewGateExplainsOpaqueMutationRecovery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonDeliveryProfileNeverRequiresStructuredReview(t *testing.T) {
|
||||
func TestUnsetTaskPolicyNeverRequiresStructuredReview(t *testing.T) {
|
||||
ledger := evidence.NewLedger()
|
||||
ledger.Record(evidence.ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"internal/permission/gate.go"}`), true, false))
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "review", readOnly: true})
|
||||
reg.Add(fakeTool{name: "security_review", readOnly: true})
|
||||
a := &Agent{deliveryProfile: false, task: taskRuntime{ledger: ledger}, svc: agentServices{tools: reg}}
|
||||
a := &Agent{task: taskRuntime{ledger: ledger}, svc: agentServices{tools: reg}}
|
||||
|
||||
if got := a.deliveryReviewGateFailure(); got != "" {
|
||||
t.Fatalf("non-Delivery review gate = %q, want disabled", got)
|
||||
t.Fatalf("unset TaskPolicy review gate = %q, want disabled", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryReviewGateHighRiskStillRequiresSecurityReview(t *testing.T) {
|
||||
func TestClosedLoopReviewGateHighRiskStillRequiresSecurityReview(t *testing.T) {
|
||||
ledger := evidence.NewLedger()
|
||||
ledger.Record(evidence.ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"internal/permission/gate.go"}`), true, false))
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "review", readOnly: true})
|
||||
reg.Add(fakeTool{name: "security_review", readOnly: true})
|
||||
a := &Agent{deliveryProfile: true, task: taskRuntime{ledger: ledger}, svc: agentServices{tools: reg}}
|
||||
a := &Agent{
|
||||
task: taskRuntime{ledger: ledger},
|
||||
svc: agentServices{tools: reg},
|
||||
turn: turnRuntime{policySet: true, policy: closedLoopTurnPolicy(taskpolicy.ReviewForcedSecurity)},
|
||||
}
|
||||
|
||||
if got := a.deliveryReviewGateFailure(); !strings.Contains(got, "high-risk") {
|
||||
t.Fatalf("review gate = %q, want high-risk review demand", got)
|
||||
@@ -103,7 +145,7 @@ func TestDeliveryReviewGateHighRiskStillRequiresSecurityReview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryReviewGateMediumAcceptsHostProvenVerificationAndCoverage(t *testing.T) {
|
||||
func TestClosedLoopReviewGateMediumAcceptsHostProvenVerificationAndCoverage(t *testing.T) {
|
||||
ledger := evidence.NewLedger()
|
||||
ledger.Record(evidence.ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"internal/agent/parser.go"}`), true, false))
|
||||
ledger.Record(evidence.ReceiptFromToolCall("bash", json.RawMessage(`{"command":"go test ./..."}`), true, true))
|
||||
@@ -115,7 +157,11 @@ func TestDeliveryReviewGateMediumAcceptsHostProvenVerificationAndCoverage(t *tes
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "review", readOnly: true})
|
||||
a := &Agent{deliveryProfile: true, task: taskRuntime{ledger: ledger}, svc: agentServices{tools: reg}}
|
||||
a := &Agent{
|
||||
task: taskRuntime{ledger: ledger},
|
||||
svc: agentServices{tools: reg},
|
||||
turn: turnRuntime{policySet: true, policy: closedLoopTurnPolicy(taskpolicy.ReviewForced)},
|
||||
}
|
||||
if got := a.deliveryReviewGateFailure(); got != "" {
|
||||
t.Fatalf("medium-risk host proof was rejected: %q", got)
|
||||
}
|
||||
@@ -129,14 +175,19 @@ func TestDeliveryReviewGateMediumAcceptsHostProvenVerificationAndCoverage(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryReviewGateDefersToParentInSubagents(t *testing.T) {
|
||||
func TestClosedLoopReviewGateDefersToParentInSubagents(t *testing.T) {
|
||||
ledger := evidence.NewLedger()
|
||||
ledger.Record(evidence.ReceiptFromToolCall("edit_file", json.RawMessage(`{"path":"internal/permission/gate.go"}`), true, false))
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "review", readOnly: true})
|
||||
reg.Add(fakeTool{name: "security_review", readOnly: true})
|
||||
a := &Agent{agentConfig: agentConfig{subagentDepth: 1}, deliveryProfile: true, task: taskRuntime{ledger: ledger}, svc: agentServices{tools: reg}}
|
||||
a := &Agent{
|
||||
agentConfig: agentConfig{subagentDepth: 1},
|
||||
task: taskRuntime{ledger: ledger},
|
||||
svc: agentServices{tools: reg},
|
||||
turn: turnRuntime{policySet: true, policy: closedLoopTurnPolicy(taskpolicy.ReviewForcedSecurity)},
|
||||
}
|
||||
|
||||
// Inside a sub-agent the structured-review contract belongs to the parent,
|
||||
// which receives the child's mutation receipts via mergeChildEvidence. The
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestE2ESerialPlanHostAdvancesAndAllowsFinalAnswer(t *testing.T) {
|
||||
sink := &recordSink{}
|
||||
a := New(mp, evidenceRegistry(), NewSession("sys"), Options{}, sink)
|
||||
|
||||
runErr := a.Run(context.Background(), "implement the plan")
|
||||
runErr := a.Run(withNoClosedLoop(context.Background()), "implement the plan")
|
||||
if runErr != nil {
|
||||
t.Fatalf("final answer blocked despite host-advanced completions: %v", runErr)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func TestE2ECommandDriftAcceptedInTurn(t *testing.T) {
|
||||
testutil.Turn{Text: "synced"},
|
||||
)
|
||||
a := New(mp, evidenceRegistry(), NewSession("sys"), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "sync the branch"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "sync the branch"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !sessionContains(a, "signed off") {
|
||||
@@ -165,11 +165,11 @@ func TestE2ECrossTurnCanonicalGateBlocksThenClears(t *testing.T) {
|
||||
a := New(mp, evidenceRegistry(), sess, Options{}, event.Discard)
|
||||
a.SetSession(sess) // rebuilds canonical {alpha in_progress, beta pending}
|
||||
|
||||
firstErr := a.Run(context.Background(), "finish up")
|
||||
firstErr := a.Run(withNoClosedLoop(context.Background()), "finish up")
|
||||
if !readinessBlocked(firstErr) {
|
||||
t.Fatalf("premature 'all done' error = %v, want FinalReadinessError from the cross-turn canonical gate", firstErr)
|
||||
}
|
||||
if err := a.Run(context.Background(), "finish up"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "finish up"); err != nil {
|
||||
t.Fatalf("follow-up Run: %v", err)
|
||||
}
|
||||
for i, td := range a.sess.todoState {
|
||||
@@ -198,7 +198,7 @@ func TestE2ECrossTurnPendingSignoffIsRejectedUntilCurrentAdvances(t *testing.T)
|
||||
a := New(mp, evidenceRegistry(), sess, Options{}, event.Discard)
|
||||
a.SetSession(sess)
|
||||
|
||||
if err := a.Run(context.Background(), "continue"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "continue"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !sessionContains(a, "only signs the current in_progress item") {
|
||||
@@ -224,10 +224,10 @@ func TestE2ECrossTurnDiffEvidenceViaSessionFallback(t *testing.T) {
|
||||
)
|
||||
a := New(mp, evidenceRegistry(), NewSession("sys"), Options{}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "edit x.go without tests"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "edit x.go without tests"); err != nil {
|
||||
t.Fatalf("turn 1: %v", err)
|
||||
}
|
||||
if err := a.Run(context.Background(), "now sign off that change"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "now sign off that change"); err != nil {
|
||||
t.Fatalf("turn 2: %v", err)
|
||||
}
|
||||
if !sessionContains(a, "signed off") {
|
||||
@@ -245,7 +245,7 @@ func TestE2EUnbackedDiffEvidenceStillRejected(t *testing.T) {
|
||||
)
|
||||
a := New(mp, evidenceRegistry(), NewSession("sys"), Options{}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "sign off without doing the work"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "sign off without doing the work"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !sessionContains(a, "no matching successful writer") {
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestSubAgentAnswerLeadsWithAdjudicatedStatus(t *testing.T) {
|
||||
{{Type: provider.ChunkText, Text: "all good"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
|
||||
answer, err := RunSubAgentWithSession(context.Background(), prov, reg, NewSession("sys"),
|
||||
answer, err := RunSubAgentWithSession(withNoClosedLoop(context.Background()), prov, reg, NewSession("sys"),
|
||||
"fix the parser", Options{}, event.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("RunSubAgentWithSession: %v", err)
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestCoordinatorHandsPlanToExecutor(t *testing.T) {
|
||||
plannerSess := NewSession("planner-sys")
|
||||
coord := NewCoordinator(planner, plannerSess, nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestCoordinatorBindsPlannerApprovalRequestBeforeExecutor(t *testing.T) {
|
||||
gate := &coordinatorApprovalGate{allow: false}
|
||||
coord.SetPlannerPlanApprover(gate)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if gate.calls != 1 {
|
||||
@@ -139,7 +139,7 @@ func TestCoordinatorBindsStructuredPlannerApprovalMarker(t *testing.T) {
|
||||
gate := &coordinatorApprovalGate{allow: false}
|
||||
coord.SetPlannerPlanApprover(gate)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if gate.calls != 1 {
|
||||
@@ -165,7 +165,7 @@ func TestCoordinatorDoesNotTrustPlannerClaimedUserApproval(t *testing.T) {
|
||||
gate := &coordinatorApprovalGate{allow: false}
|
||||
coord.SetPlannerPlanApprover(gate)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if gate.calls != 1 {
|
||||
@@ -191,7 +191,7 @@ func TestCoordinatorRunsExecutorAfterPlannerApproval(t *testing.T) {
|
||||
gate := &coordinatorApprovalGate{allow: true}
|
||||
coord.SetPlannerPlanApprover(gate)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if gate.calls != 1 {
|
||||
@@ -233,7 +233,7 @@ func TestCoordinatorSkipsPlannerForTrivialTurn(t *testing.T) {
|
||||
plannerSess := NewSession("planner-sys")
|
||||
coord := NewCoordinator(planner, plannerSess, nil, nil, Options{}, executor, 0, event.Discard, func(context.Context, string) bool { return false })
|
||||
|
||||
if err := coord.Run(context.Background(), "what does this function do?"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "what does this function do?"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -275,10 +275,10 @@ func TestCoordinatorStructuredPolicyUsesStableDepthMetadata(t *testing.T) {
|
||||
executor, 0, event.Discard, policy,
|
||||
)
|
||||
|
||||
if err := coord.Run(context.Background(), "light task"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "light task"); err != nil {
|
||||
t.Fatalf("light Run: %v", err)
|
||||
}
|
||||
if err := coord.Run(context.Background(), "full task"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "full task"); err != nil {
|
||||
t.Fatalf("full Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -333,7 +333,7 @@ func TestCoordinatorPlanForApprovalDoesNotDependOnPlannerMarker(t *testing.T) {
|
||||
approval := &coordinatorApprovalGate{allow: false}
|
||||
coord.SetPlannerPlanApprover(approval)
|
||||
|
||||
if err := coord.Run(context.Background(), "plan auth migration first"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "plan auth migration first"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if approval.calls != 1 {
|
||||
@@ -366,7 +366,7 @@ func TestCoordinatorPlanForApprovalHandsOffAfterApproval(t *testing.T) {
|
||||
|
||||
// Conversational plan request: avoid mutation/security wording so elevated
|
||||
// delivery readiness does not arm on the planner/approval handoff itself.
|
||||
if err := coord.Run(context.Background(), "outline steps for the feature, then wait for my approval"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "outline steps for the feature, then wait for my approval"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if approval.calls != 1 {
|
||||
@@ -399,7 +399,7 @@ func TestCoordinatorHeadlessPlanForApprovalPersistsForContinuation(t *testing.T)
|
||||
executor, 0, sink, policy,
|
||||
)
|
||||
|
||||
if err := coord.Run(context.Background(), "plan auth migration first"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "plan auth migration first"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(exec.requests) != 0 {
|
||||
@@ -432,7 +432,7 @@ func TestCoordinatorPlanOnlyDoesNotRunExecutor(t *testing.T) {
|
||||
approval := &coordinatorApprovalGate{allow: true}
|
||||
coord.SetPlannerPlanApprover(approval)
|
||||
|
||||
if err := coord.Run(context.Background(), "只规划认证迁移,不要执行"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "只规划认证迁移,不要执行"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if approval.calls != 0 {
|
||||
@@ -468,14 +468,14 @@ func TestCoordinatorPlanOnlyContinuesWithExecutorOnNextTurn(t *testing.T) {
|
||||
executor, 0, event.Discard, policy,
|
||||
)
|
||||
|
||||
if err := coord.Run(context.Background(), "只规划认证迁移,不要执行"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "只规划认证迁移,不要执行"); err != nil {
|
||||
t.Fatalf("plan-only Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 0 {
|
||||
t.Fatalf("executor requests after plan-only turn = %d, want none", got)
|
||||
}
|
||||
|
||||
if err := coord.Run(context.Background(), "执行"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "执行"); err != nil {
|
||||
t.Fatalf("continuation Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 1 {
|
||||
@@ -540,7 +540,7 @@ func TestCoordinatorPlannerFailurePreservesExecutionBoundary(t *testing.T) {
|
||||
executor, 0, event.Discard, policy,
|
||||
)
|
||||
|
||||
err := coord.Run(context.Background(), tc.input)
|
||||
err := coord.Run(withNoClosedLoop(context.Background()), tc.input)
|
||||
if err == nil || !strings.Contains(err.Error(), "planner:") {
|
||||
t.Fatalf("Run = %v, want planner failure", err)
|
||||
}
|
||||
@@ -592,7 +592,7 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
|
||||
plannerSess := NewSession(PlannerPromptWithContext("Rule: keep changes narrow."))
|
||||
coord := NewCoordinator(planner, plannerSess, nil, PlannerToolRegistry(parentReg), Options{MaxSteps: 4}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -630,7 +630,7 @@ func TestCoordinatorSetReasoningLanguageClearsPlannerAgent(t *testing.T) {
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, tool.NewRegistry(), Options{ReasoningLanguage: "zh"}, executor, 0, event.Discard, nil)
|
||||
coord.SetReasoningLanguage("auto")
|
||||
|
||||
if err := coord.Run(context.Background(), "plan a change"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "plan a change"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -662,7 +662,7 @@ func TestCoordinatorPlannerMaxStepsUsesExplicitRuntimeKey(t *testing.T) {
|
||||
MaxStepsKey: "planner max_steps",
|
||||
}, executor, 0, sink, nil)
|
||||
|
||||
err := coord.Run(context.Background(), "plan a change")
|
||||
err := coord.Run(withNoClosedLoop(context.Background()), "plan a change")
|
||||
if err != nil {
|
||||
t.Fatalf("Run should fall back to the executor when the planner cannot finalize: %v", err)
|
||||
}
|
||||
@@ -716,7 +716,7 @@ func TestCoordinatorPlannerMaxStepsZeroIsUnlimited(t *testing.T) {
|
||||
MaxStepsKey: "planner max_steps",
|
||||
}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "plan a change"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "plan a change"); err != nil {
|
||||
t.Fatalf("Run with planner max steps 0 should not pause: %v", err)
|
||||
}
|
||||
if got := len(planner.requests); got != 3 {
|
||||
@@ -754,7 +754,7 @@ func TestCoordinatorNudgesExecutorThatAnswersWithoutActing(t *testing.T) {
|
||||
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "install the skill"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "install the skill"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 3 {
|
||||
@@ -795,7 +795,7 @@ func TestCoordinatorAllowsGuidanceOnlyExecutorHandoff(t *testing.T) {
|
||||
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "I just installed EqualizerAPO, now what?"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "I just installed EqualizerAPO, now what?"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 1 {
|
||||
@@ -821,7 +821,7 @@ func TestCoordinatorAllowsGuidanceOnlyPlanWithExecutorToolContext(t *testing.T)
|
||||
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "Please advise on the manual audio check."); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "Please advise on the manual audio check."); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 1 {
|
||||
@@ -854,7 +854,7 @@ func TestCoordinatorNudgesWorkTaskEvenIfPlannerMentionsUserGuidance(t *testing.T
|
||||
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 3 {
|
||||
@@ -890,7 +890,7 @@ func TestCoordinatorNudgesMixedGuidanceAndWorkTask(t *testing.T) {
|
||||
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "summarize the current behavior and update the README"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "summarize the current behavior and update the README"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 3 {
|
||||
@@ -914,7 +914,7 @@ func TestCoordinatorSkipsExecutorWhenPlannerConcludesNoChanges(t *testing.T) {
|
||||
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "check whether the fix is already present"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "check whether the fix is already present"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 0 {
|
||||
@@ -945,7 +945,7 @@ func TestCoordinatorDoesNotTreatGenericPositivePlanAsNoOp(t *testing.T) {
|
||||
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the missing guard"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the missing guard"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got == 0 {
|
||||
@@ -974,7 +974,7 @@ func TestCoordinatorDoesNotSkipExecutorForPartialNoOpPlanWithActions(t *testing.
|
||||
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "check the implementation and test it"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "check the implementation and test it"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 2 {
|
||||
@@ -1007,7 +1007,7 @@ func TestCoordinatorHandoffAffirmsExecutorToolSchemasWhenPlannerClaimsNoMCP(t *t
|
||||
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "search GitHub discussions"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "search GitHub discussions"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 3 {
|
||||
@@ -1059,7 +1059,7 @@ func TestCoordinatorDoesNotNudgeExecutorThatActs(t *testing.T) {
|
||||
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "install the skill"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "install the skill"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 2 {
|
||||
@@ -1234,7 +1234,7 @@ func TestCoordinatorDoesNotSkipExecutorForAlreadyImplementedPlanWithFollowUp(t *
|
||||
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "add refresh token support"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "add refresh token support"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got == 0 {
|
||||
@@ -1261,7 +1261,7 @@ func TestCoordinatorSkipsExecutorOnExplicitNoChangesMarker(t *testing.T) {
|
||||
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "check whether retries are covered"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "check whether retries are covered"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 0 {
|
||||
@@ -1304,7 +1304,7 @@ func TestCoordinatorFallsBackToExecutorWhenPlannerFails(t *testing.T) {
|
||||
plannerSess := NewSession("planner-sys")
|
||||
coord := NewCoordinator(tc.planner, plannerSess, nil, nil, Options{}, executor, 0, sink, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run should fall back to the executor, got: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 1 {
|
||||
@@ -1386,7 +1386,7 @@ func TestCoordinatorRollsBackPlannerSessionOnToolPlannerFailure(t *testing.T) {
|
||||
plannerSess := NewSession("planner-sys")
|
||||
coord := NewCoordinator(tc.planner, plannerSess, nil, plannerReg, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run should fall back to the executor, got: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 1 {
|
||||
@@ -1420,7 +1420,7 @@ func TestCoordinatorPlannerSafetyBoundaryPreservesExecutionBoundaries(t *testing
|
||||
executor, 0, event.Discard, policy,
|
||||
)
|
||||
|
||||
err := coord.Run(context.Background(), "plan the migration")
|
||||
err := coord.Run(withNoClosedLoop(context.Background()), "plan the migration")
|
||||
if err == nil || err.Error() != plannerSafetyBoundaryError {
|
||||
t.Fatalf("Run = %v, want the safe planner boundary error", err)
|
||||
}
|
||||
@@ -1485,7 +1485,7 @@ func TestCoordinatorRunsExecutorWhenMarkerNotAlone(t *testing.T) {
|
||||
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "add the missing tests"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "add the missing tests"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got == 0 {
|
||||
@@ -1532,7 +1532,7 @@ func TestCoordinatorHandoffSurvivesPlannerCompaction(t *testing.T) {
|
||||
}
|
||||
coord := NewCoordinator(planner, plannerSess, nil, plannerReg, Options{ContextWindow: 2000}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
// Projection compaction no longer rewrites the planner session; handoff
|
||||
@@ -1564,7 +1564,7 @@ func TestCoordinatorNoOpConclusionAttributedToPlanner(t *testing.T) {
|
||||
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, sink, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "check the parser guard"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "check the parser guard"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
var conclusion *event.Event
|
||||
@@ -1600,7 +1600,7 @@ func TestCoordinatorHandoffOmitsToolContextWithoutMCPTools(t *testing.T) {
|
||||
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
|
||||
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the missing guard"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the missing guard"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
got := lastUser(exec.requests[0])
|
||||
@@ -1678,7 +1678,7 @@ func TestCoordinatorFailedTurnRollbackKeepsCompaction(t *testing.T) {
|
||||
}
|
||||
coord := NewCoordinator(planner, plannerSess, nil, plannerReg, Options{ContextWindow: 2000}, executor, 0, event.Discard, nil)
|
||||
|
||||
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "fix the bug"); err != nil {
|
||||
t.Fatalf("Run should fall back to the executor, got: %v", err)
|
||||
}
|
||||
if got := len(exec.requests); got != 1 {
|
||||
@@ -1716,7 +1716,7 @@ func TestCoordinatorPersistsDeniedPlanTurnToExecutorSession(t *testing.T) {
|
||||
gate := &coordinatorApprovalGate{allow: false}
|
||||
coord.SetPlannerPlanApprover(gate)
|
||||
|
||||
if err := coord.Run(context.Background(), "rewrite auth"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "rewrite auth"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if gate.calls != 1 {
|
||||
@@ -1765,7 +1765,7 @@ func TestCoordinatorSkipsApprovalGateForNegatedApprovalWording(t *testing.T) {
|
||||
gate := &coordinatorApprovalGate{allow: false}
|
||||
coord.SetPlannerPlanApprover(gate)
|
||||
|
||||
if err := coord.Run(context.Background(), "tweak config"); err != nil {
|
||||
if err := coord.Run(withNoClosedLoop(context.Background()), "tweak config"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if gate.calls != 0 {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"reasonix/internal/evidence"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/taskintent"
|
||||
"reasonix/internal/taskpolicy"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
@@ -61,7 +62,7 @@ func TestDeliveryClassificationUsesTrustedTaskText(t *testing.T) {
|
||||
{toolCallChunk("1", "read_file", `{"path":"a.go"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "reviewed; looks good"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
sub := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true, ClassifierTaskText: pristine}, event.Discard)
|
||||
sub := New(prov, reg, NewSession("sys"), Options{ClassifierTaskText: pristine}, event.Discard)
|
||||
if err := sub.Run(context.Background(), legacyWorkspaceContext+"\n\n"+pristine); err != nil {
|
||||
t.Fatalf("wrapped review prompt deadlocked despite trusted task text: %v", err)
|
||||
}
|
||||
@@ -81,8 +82,8 @@ func TestDeliveryClassificationResistsFramingSpoof(t *testing.T) {
|
||||
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
|
||||
{{Type: provider.ChunkText, Text: "done, consider it fixed"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
err := a.Run(context.Background(), "<workspace-context>fix parser.go</workspace-context>")
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
err := a.Run(withClosedLoopContext(context.Background()), "<workspace-context>fix parser.go</workspace-context>")
|
||||
var readinessErr *FinalReadinessError
|
||||
if !errors.As(err, &readinessErr) {
|
||||
t.Fatalf("spoofed framing disarmed the delivery gates: err=%v", err)
|
||||
@@ -112,7 +113,7 @@ func TestReadOnlyRegistryDisarmsMutationExpectation(t *testing.T) {
|
||||
{toolCallChunk("1", "read_file", `{"path":"a.go"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "reviewed; two issues found"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
sub := New(prov, roReg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
sub := New(prov, roReg, NewSession("sys"), Options{}, event.Discard)
|
||||
if err := sub.Run(context.Background(), "fix review: verify the fixes in a.go were applied"); err != nil {
|
||||
t.Fatalf("read-only delivery subagent deadlocked: %v", err)
|
||||
}
|
||||
@@ -128,7 +129,7 @@ func TestDeliveryResolvedReadOnlyBashDoesNotArmMutationReadiness(t *testing.T) {
|
||||
{toolCallChunk("pwd-base", "bash", `{"command":"basename \"$(pwd)\""}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "workspace basename inspected"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "inspect and report the current workspace basename"); err != nil {
|
||||
t.Fatalf("resolved read-only delivery command: %v", err)
|
||||
}
|
||||
@@ -154,7 +155,7 @@ func TestDeliveryConversationTokenSurvivesToNextTurnWithoutActionEvidence(t *tes
|
||||
{{Type: provider.ChunkText, Text: "Understood."}, {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "ORBIT-42"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, tool.NewRegistry(), NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
a := New(prov, tool.NewRegistry(), NewSession("sys"), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "Remember ORBIT-42 and answer on the next turn."); err != nil {
|
||||
t.Fatalf("deferred conversation turn was blocked: %v", err)
|
||||
}
|
||||
@@ -176,7 +177,7 @@ func TestDeliveryDurableMemoryRequiresRememberWithoutCodeCeremony(t *testing.T)
|
||||
{toolCallChunk("remember", "remember", `{"description":"ORBIT code","body":"ORBIT-42"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "Saved for future sessions."}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "Remember ORBIT-42 permanently across sessions"); err != nil {
|
||||
t.Fatalf("durable-memory workflow inherited code-delivery ceremony: %v", err)
|
||||
}
|
||||
@@ -190,7 +191,7 @@ func TestDeliveryDurableMemoryRequiresRememberWithoutCodeCeremony(t *testing.T)
|
||||
missing := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
|
||||
{{Type: provider.ChunkText, Text: "I'll remember it."}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
b := New(missing, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
b := New(missing, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
err := b.Run(context.Background(), "Remember ORBIT-42 permanently across sessions")
|
||||
var readiness *FinalReadinessError
|
||||
if !errors.As(err, &readiness) || !strings.Contains(readiness.Reason, "remember tool") {
|
||||
@@ -254,7 +255,7 @@ func TestDeliveryPlanModeReturnsProposalBeforeExecutionReadiness(t *testing.T) {
|
||||
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
|
||||
{{Type: provider.ChunkText, Text: proposal}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
a.SetPlanMode(true)
|
||||
|
||||
if err := a.Run(context.Background(), "fix the parser bug in a.go"); err != nil {
|
||||
@@ -270,6 +271,8 @@ func TestDeliveryPlanModeReturnsProposalBeforeExecutionReadiness(t *testing.T) {
|
||||
// Approval disables plan mode before the controller starts execution. The
|
||||
// same delivery expectations must become enforceable again at that boundary.
|
||||
a.SetPlanMode(false)
|
||||
a.turn.policy = closedLoopTurnPolicy(taskpolicy.ReviewForced)
|
||||
a.turn.policySet = true
|
||||
if got := a.ReadinessResult(); !strings.Contains(got.Reason, "state change") {
|
||||
t.Fatalf("execution readiness did not resume after plan mode: %q", got.Reason)
|
||||
}
|
||||
@@ -281,7 +284,9 @@ func TestDeliveryPlanModeReturnsProposalBeforeExecutionReadiness(t *testing.T) {
|
||||
func TestPlanModeDefersCapabilityRequirementsUntilExecution(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"),
|
||||
Options{DeliveryProfile: true, CapabilityLedger: capability.NewLedger()}, event.Discard)
|
||||
Options{CapabilityLedger: capability.NewLedger()}, event.Discard)
|
||||
a.turn.policySet = true
|
||||
a.turn.policy = closedLoopTurnPolicy(taskpolicy.ReviewForced)
|
||||
a.SetPlanMode(true)
|
||||
a.SeedCapabilityRoute(capability.RouteDecision{Candidates: []capability.RouteCandidate{
|
||||
{Entry: capability.Entry{ID: "skill:deploy"}, Policy: capability.AutoUseRequire},
|
||||
@@ -373,7 +378,7 @@ func TestRunSubAgentSalvagesReadinessExhaustedWork(t *testing.T) {
|
||||
}}
|
||||
sess := NewSession("sys")
|
||||
answer, err := RunSubAgentWithSession(context.Background(), prov, reg, sess,
|
||||
"add explanations to the question bank", Options{DeliveryProfile: true, SubagentDepth: 1}, event.Discard)
|
||||
"add explanations to the question bank", Options{SubagentDepth: 1}, event.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("readiness exhaustion with real work must salvage, got err: %v", err)
|
||||
}
|
||||
@@ -394,8 +399,8 @@ func TestRunSubAgentReadinessFailureWithoutMutationStillFails(t *testing.T) {
|
||||
finalText := []provider.Chunk{{Type: provider.ChunkText, Text: "done, all fixed"}, {Type: provider.ChunkDone}}
|
||||
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{finalText, finalText, finalText}}
|
||||
sess := NewSession("sys")
|
||||
answer, err := RunSubAgentWithSession(context.Background(), prov, reg, sess,
|
||||
"fix the crash in a.go", Options{DeliveryProfile: true, SubagentDepth: 1}, event.Discard)
|
||||
answer, err := RunSubAgentWithSession(withClosedLoopContext(context.Background()), prov, reg, sess,
|
||||
"fix the crash in a.go", Options{SubagentDepth: 1}, event.Discard)
|
||||
var readinessErr *FinalReadinessError
|
||||
if !errors.As(err, &readinessErr) {
|
||||
t.Fatalf("expected wrapped FinalReadinessError, got %v", err)
|
||||
@@ -422,8 +427,8 @@ func TestFinalReadinessFailsImmediatelyWithoutRetries(t *testing.T) {
|
||||
}
|
||||
|
||||
stalled := &scriptedProvider{name: "p", turns: [][]provider.Chunk{finalText}}
|
||||
a := New(stalled, newReg(), NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
err := a.Run(context.Background(), "fix the crash in a.go")
|
||||
a := New(stalled, newReg(), NewSession("sys"), Options{}, event.Discard)
|
||||
err := a.Run(withClosedLoopContext(context.Background()), "fix the crash in a.go")
|
||||
var readinessErr *FinalReadinessError
|
||||
if !errors.As(err, &readinessErr) {
|
||||
t.Fatalf("expected FinalReadinessError, got %v", err)
|
||||
@@ -443,8 +448,8 @@ func TestFinalReadinessFailsImmediatelyWithoutRetries(t *testing.T) {
|
||||
readCall("1"), finalText,
|
||||
readCall("2"), finalText,
|
||||
}}
|
||||
a2 := New(converging, newReg(), NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
err2 := a2.Run(context.Background(), "fix the crash in a.go")
|
||||
a2 := New(converging, newReg(), NewSession("sys"), Options{}, event.Discard)
|
||||
err2 := a2.Run(withClosedLoopContext(context.Background()), "fix the crash in a.go")
|
||||
var readinessErr2 *FinalReadinessError
|
||||
if !errors.As(err2, &readinessErr2) {
|
||||
t.Fatalf("expected FinalReadinessError, got %v", err2)
|
||||
@@ -467,7 +472,7 @@ func TestExplicitDeliveryRecoveryPreservesEvidenceOnce(t *testing.T) {
|
||||
{toolCallChunk("signoff", "complete_step", `{"step":"Ship main","result":"done","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "delivered"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
var readinessErr *FinalReadinessError
|
||||
if err := a.Run(context.Background(), "implement main"); !errors.As(err, &readinessErr) {
|
||||
t.Fatalf("first Run error = %v, want FinalReadinessError", err)
|
||||
@@ -497,7 +502,7 @@ func TestOrdinaryFollowUpDoesNotPreserveFailedDeliveryEvidence(t *testing.T) {
|
||||
finalText,
|
||||
finalText,
|
||||
}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
var firstErr *FinalReadinessError
|
||||
if err := a.Run(context.Background(), "implement main"); !errors.As(err, &firstErr) {
|
||||
t.Fatalf("first Run error = %v, want FinalReadinessError", err)
|
||||
@@ -507,7 +512,7 @@ func TestOrdinaryFollowUpDoesNotPreserveFailedDeliveryEvidence(t *testing.T) {
|
||||
}
|
||||
|
||||
var followUpErr *FinalReadinessError
|
||||
if err := a.Run(context.Background(), "fix the unrelated crash in other.go"); !errors.As(err, &followUpErr) {
|
||||
if err := a.Run(withClosedLoopContext(context.Background()), "fix the unrelated crash in other.go"); !errors.As(err, &followUpErr) {
|
||||
t.Fatalf("ordinary follow-up error = %v, want FinalReadinessError", err)
|
||||
}
|
||||
if _, ok := a.task.ledger.LatestSuccessfulMutationIndex(); ok {
|
||||
@@ -694,8 +699,8 @@ func TestDeliveryMixedIntentRequiresMutationEvidence(t *testing.T) {
|
||||
{Type: provider.ChunkDone},
|
||||
}
|
||||
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{answer, answer, answer}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
err := a.Run(context.Background(), input)
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
err := a.Run(withClosedLoopContext(context.Background()), input)
|
||||
var readinessErr *FinalReadinessError
|
||||
if !errors.As(err, &readinessErr) {
|
||||
t.Fatalf("text-only completion escaped the mutation gate: %v", err)
|
||||
@@ -722,8 +727,8 @@ func TestDeliveryReadOnlyTechnicalTaskRequiresEvidence(t *testing.T) {
|
||||
{Type: provider.ChunkDone},
|
||||
}
|
||||
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{answer, answer, answer}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
err := a.Run(context.Background(), input)
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
err := a.Run(withClosedLoopContext(context.Background()), input)
|
||||
var readinessErr *FinalReadinessError
|
||||
if !errors.As(err, &readinessErr) {
|
||||
t.Fatalf("text-only technical work escaped the evidence gate: %v", err)
|
||||
@@ -759,7 +764,7 @@ func TestDeliveryDiagnosticConversationCompletes(t *testing.T) {
|
||||
{Type: provider.ChunkDone},
|
||||
}
|
||||
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{advice}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{DeliveryProfile: true}, event.Discard)
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), input); err != nil {
|
||||
t.Fatalf("diagnostic conversation deadlocked: %v", err)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ func TestDeliveryExecutionScopeDoesNotChangeProviderRequestBytes(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeReadFileTool{})
|
||||
|
||||
unscoped := New(unscopedProvider, reg, NewSession("stable system"), Options{DeliveryProfile: true}, event.Discard)
|
||||
scoped := New(scopedProvider, reg, NewSession("stable system"), Options{DeliveryProfile: true}, event.Discard)
|
||||
unscoped := New(unscopedProvider, reg, NewSession("stable system"), Options{}, event.Discard)
|
||||
scoped := New(scopedProvider, reg, NewSession("stable system"), Options{}, event.Discard)
|
||||
input := "explain the current implementation"
|
||||
if err := unscoped.Run(context.Background(), input); err != nil {
|
||||
t.Fatalf("unscoped run: %v", err)
|
||||
@@ -54,8 +54,8 @@ func TestDeliveryGoalFinalAnswerAlwaysGatesMutationExpectation(t *testing.T) {
|
||||
{{Type: provider.ChunkText, Text: "Investigation complete."}, {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "Implemented."}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
ctx := deliveryGoalContext("goal-1", "fix the crash in main.go")
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
ctx := withClosedLoopContext(deliveryGoalContext("goal-1", "fix the crash in main.go"))
|
||||
// A read-only final answer is gated on the mutation expectation immediately:
|
||||
// the host no longer defers readiness for marker-carrying turns, the Goal
|
||||
// FSM absorbs the failure and continues with the missing requirements.
|
||||
@@ -78,7 +78,7 @@ func TestDeliveryGoalScopeCarriesSignedOffMutationAcrossTurns(t *testing.T) {
|
||||
{{Type: provider.ChunkText, Text: "Implementation complete."}, {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "Final summary."}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
ctx := deliveryGoalContext("goal-1", "implement main")
|
||||
if err := a.Run(ctx, "implement the first chunk"); err != nil {
|
||||
t.Fatalf("mutation turn: %v", err)
|
||||
@@ -101,7 +101,7 @@ func TestDeliveryGoalRestoredPendingMutationCompletesWithoutNewWrite(t *testing.
|
||||
{toolCallChunk("signoff", "complete_step", `{"step":"Ship main","result":"implemented","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "Recovered and verified."}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
a.RestoreDeliveryCheckpoint(evidence.DeliveryCheckpoint{
|
||||
ScopeID: "goal-1",
|
||||
CriteriaEstablished: true,
|
||||
@@ -131,7 +131,7 @@ func TestDeliveryGoalNewMutationInvalidatesPriorSignoff(t *testing.T) {
|
||||
{toolCallChunk("write-2", "write_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "All done."}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
ctx := deliveryGoalContext("goal-1", "implement main")
|
||||
if err := a.Run(ctx, "implement the first chunk"); err != nil {
|
||||
t.Fatalf("first turn: %v", err)
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestRunSubAgentSalvagesCurrentAnswerBeforePairedGoalError(t *testing.T) {
|
||||
}}
|
||||
sess := NewSession("sys")
|
||||
answer, err := RunSubAgentWithSession(context.Background(), prov, reg, sess,
|
||||
"add explanations to the question bank", Options{DeliveryProfile: true, SubagentDepth: 1}, event.Discard)
|
||||
"add explanations to the question bank", Options{SubagentDepth: 1}, event.Discard)
|
||||
if err != nil {
|
||||
t.Fatalf("paired Goal error hid the current salvage answer: %v", err)
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func TestRunSubAgentReadinessSalvageDoesNotReuseStaleToolText(t *testing.T) {
|
||||
answer, err := RunSubAgentWithSession(
|
||||
context.Background(), deepseekThinkingProvider{prov}, reg, sess,
|
||||
"add explanations to the question bank",
|
||||
Options{DeliveryProfile: true, SubagentDepth: 1}, event.Discard,
|
||||
Options{SubagentDepth: 1}, event.Discard,
|
||||
)
|
||||
var readinessErr *FinalReadinessError
|
||||
if !errors.As(err, &readinessErr) {
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestDependentSameBatchEditRefreshesPreviewBeforeExecution(t *testing.T) {
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.FuncSink(func(e event.Event) {
|
||||
events = append(events, e)
|
||||
}))
|
||||
if err := a.Run(context.Background(), "advance status twice"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "advance status twice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -133,7 +133,7 @@ func TestDependentMutationSkippedAfterFailedWriterInBatch(t *testing.T) {
|
||||
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "run dependent edit after a partial failure"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "run dependent edit after a partial failure"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestEvidenceFlowEndToEnd(t *testing.T) {
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "run the suite and sign the step off"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "run the suite and sign the step off"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestEvidenceFlowEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryProfileEnforcesAcceptanceReviewVerificationAndSignoff(t *testing.T) {
|
||||
func TestClosedLoopEnforcesAcceptanceReviewVerificationAndSignoff(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
// Keep review available so this ordinary production change exercises the
|
||||
@@ -168,11 +168,11 @@ func TestDeliveryProfileEnforcesAcceptanceReviewVerificationAndSignoff(t *testin
|
||||
{{Type: provider.ChunkText, Text: "delivered"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
if err := a.Run(context.Background(), "implement main"); err != nil {
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(withClosedLoopContext(context.Background()), "implement main"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := toolResult(a.sess.conversation, "write_file"); !strings.Contains(got, "delivery-first mode requires acceptance criteria") {
|
||||
if got := toolResult(a.sess.conversation, "write_file"); !strings.Contains(got, "closed-loop execution requires acceptance criteria") {
|
||||
t.Fatalf("first write result = %q, want delivery acceptance gate", got)
|
||||
}
|
||||
if !sessionHasUserMessageContaining(a.sess.conversation, "<execution-policy") {
|
||||
@@ -202,7 +202,7 @@ func systemMessageContent(req provider.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestDeliveryProfileRequiresReviewBeforeFinalAnswer(t *testing.T) {
|
||||
func TestClosedLoopRequiresReviewBeforeFinalAnswer(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
|
||||
@@ -216,17 +216,17 @@ func TestDeliveryProfileRequiresReviewBeforeFinalAnswer(t *testing.T) {
|
||||
{{Type: provider.ChunkText, Text: "done after review and signoff"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
sink := &readinessAuditSink{}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, sink)
|
||||
a := New(prov, reg, NewSession(""), Options{}, sink)
|
||||
ctx := deliveryGoalContext("goal-review", "implement main")
|
||||
// The first final answer fails immediately (no readiness retries); the
|
||||
// scoped follow-up adds the missing review and renews the sign-off.
|
||||
if err := a.Run(ctx, "implement main"); !readinessBlocked(err) {
|
||||
if err := a.Run(withClosedLoopContext(ctx), "implement main"); !readinessBlocked(err) {
|
||||
t.Fatalf("first Run err = %v, want FinalReadinessError for the missing review", err)
|
||||
}
|
||||
if len(sink.events) != 1 || sink.events[0].Result != evidence.ReadinessErrored || sink.events[0].MissingReview == 0 {
|
||||
t.Fatalf("readiness audits = %+v, want one errored audit with missing review", sink.events)
|
||||
}
|
||||
if err := a.Run(ctx, "finish the goal"); err != nil {
|
||||
if err := a.Run(withClosedLoopContext(ctx), "finish the goal"); err != nil {
|
||||
t.Fatalf("follow-up Run: %v", err)
|
||||
}
|
||||
if len(sink.events) != 2 || sink.events[len(sink.events)-1].Result != evidence.ReadinessAllowed {
|
||||
@@ -234,7 +234,7 @@ func TestDeliveryProfileRequiresReviewBeforeFinalAnswer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryProfileRejectsTextOnlyImplementationClaim(t *testing.T) {
|
||||
func TestClosedLoopRejectsTextOnlyImplementationClaim(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
|
||||
@@ -246,8 +246,8 @@ func TestDeliveryProfileRejectsTextOnlyImplementationClaim(t *testing.T) {
|
||||
{toolCallChunk("signoff", "complete_step", `{"step":"Implement main","result":"implemented","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "implemented with evidence"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
err := a.Run(context.Background(), "implement main")
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
err := a.Run(withClosedLoopContext(context.Background()), "implement main")
|
||||
var readiness *FinalReadinessError
|
||||
if !errors.As(err, &readiness) || !strings.Contains(readiness.Reason, "no successful mutation was observed") {
|
||||
t.Fatalf("text-only implementation claim err = %v, want mutation readiness failure", err)
|
||||
@@ -257,7 +257,7 @@ func TestDeliveryProfileRejectsTextOnlyImplementationClaim(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryProfileCommandOnlyActionRequiresCriteriaAndSignoff(t *testing.T) {
|
||||
func TestClosedLoopCommandOnlyActionRequiresCriteriaAndSignoff(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("blocked-test", "bash", `{"command":"go test ./..."}`), {Type: provider.ChunkDone}},
|
||||
@@ -266,11 +266,11 @@ func TestDeliveryProfileCommandOnlyActionRequiresCriteriaAndSignoff(t *testing.T
|
||||
{toolCallChunk("signoff", "complete_step", `{"step":"Run tests","result":"tests pass","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "tests pass"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
if err := a.Run(context.Background(), "run tests"); err != nil {
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(withClosedLoopContext(context.Background()), "run tests"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := toolResult(a.sess.conversation, "bash"); !strings.Contains(got, "delivery-first mode requires acceptance criteria") {
|
||||
if got := toolResult(a.sess.conversation, "bash"); !strings.Contains(got, "closed-loop execution requires acceptance criteria") {
|
||||
t.Fatalf("first bash result = %q, want acceptance gate", got)
|
||||
}
|
||||
if got := lastToolResult(a.sess.conversation, "complete_step"); !strings.Contains(got, "signed off") {
|
||||
@@ -278,7 +278,7 @@ func TestDeliveryProfileCommandOnlyActionRequiresCriteriaAndSignoff(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryProfileBlocksMixedVerificationBeforeItBecomesMutation(t *testing.T) {
|
||||
func TestClosedLoopBlocksMixedVerificationBeforeItBecomesMutation(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("criteria", "todo_write", `{"todos":[{"content":"Check snake","status":"in_progress"}]}`), {Type: provider.ChunkDone}},
|
||||
@@ -287,8 +287,8 @@ func TestDeliveryProfileBlocksMixedVerificationBeforeItBecomesMutation(t *testin
|
||||
{toolCallChunk("signoff", "complete_step", `{"step":"Check snake","result":"syntax valid","evidence":[{"kind":"verification","summary":"syntax valid","command":"tail -n +2 snake.js | head -n 20 | node --check -"}]}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "checked"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
if err := a.Run(context.Background(), "check the snake game"); err != nil {
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(withClosedLoopContext(context.Background()), "check the snake game"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := toolResult(a.sess.conversation, "bash"); !strings.Contains(got, "mixes a verification check") {
|
||||
@@ -299,7 +299,7 @@ func TestDeliveryProfileBlocksMixedVerificationBeforeItBecomesMutation(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryProfileExplainsMaskedVerifierExitBeforeExecution(t *testing.T) {
|
||||
func TestClosedLoopExplainsMaskedVerifierExitBeforeExecution(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("criteria", "todo_write", `{"todos":[{"content":"Check snake","status":"in_progress"}]}`), {Type: provider.ChunkDone}},
|
||||
@@ -308,8 +308,8 @@ func TestDeliveryProfileExplainsMaskedVerifierExitBeforeExecution(t *testing.T)
|
||||
{toolCallChunk("signoff", "complete_step", `{"step":"Check snake","result":"syntax valid","evidence":[{"kind":"verification","summary":"syntax valid","command":"tail -n +2 snake.js | head -n 20 | node --check -"}]}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "checked"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
if err := a.Run(context.Background(), "check the snake game"); err != nil {
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(withClosedLoopContext(context.Background()), "check the snake game"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := toolResultByID(a.sess.conversation, "masked"); !strings.Contains(got, "masks the verifier's exit status") {
|
||||
@@ -320,7 +320,7 @@ func TestDeliveryProfileExplainsMaskedVerifierExitBeforeExecution(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryProfileBlocksOpaqueInlineInterpreterBeforeItBecomesMutation(t *testing.T) {
|
||||
func TestClosedLoopBlocksOpaqueInlineInterpreterBeforeItBecomesMutation(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("criteria", "todo_write", `{"todos":[{"content":"Check snake","status":"in_progress"}]}`), {Type: provider.ChunkDone}},
|
||||
@@ -329,8 +329,8 @@ func TestDeliveryProfileBlocksOpaqueInlineInterpreterBeforeItBecomesMutation(t *
|
||||
{toolCallChunk("signoff", "complete_step", `{"step":"Check snake","result":"syntax valid","evidence":[{"kind":"verification","summary":"syntax valid","command":"tail -n +2 snake.js | head -n 20 | node --check -"}]}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "checked"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
if err := a.Run(context.Background(), "check the snake game"); err != nil {
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(withClosedLoopContext(context.Background()), "check the snake game"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := toolResultByID(a.sess.conversation, "opaque"); !strings.Contains(got, "cannot audit inline interpreter source") {
|
||||
@@ -341,7 +341,7 @@ func TestDeliveryProfileBlocksOpaqueInlineInterpreterBeforeItBecomesMutation(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryProfileRequiresActiveTodoForLateMutation(t *testing.T) {
|
||||
func TestClosedLoopRequiresActiveTodoForLateMutation(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
|
||||
@@ -358,8 +358,8 @@ func TestDeliveryProfileRequiresActiveTodoForLateMutation(t *testing.T) {
|
||||
{toolCallChunk("signoff-2", "complete_step", `{"step":"Apply review fix","result":"done","evidence":[{"kind":"verification","summary":"tests pass","command":"go test ./..."}]}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "delivered"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
if err := a.Run(context.Background(), "implement main and incorporate review fixes"); err != nil {
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(withClosedLoopContext(context.Background()), "implement main and incorporate review fixes"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := toolResultByID(a.sess.conversation, "late-write"); !strings.Contains(got, "current in_progress todo") {
|
||||
@@ -370,15 +370,15 @@ func TestDeliveryProfileRequiresActiveTodoForLateMutation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryProfileAllowsEvidenceBackedReadOnlyAnalysis(t *testing.T) {
|
||||
func TestClosedLoopAllowsEvidenceBackedReadOnlyAnalysis(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
prov := &scriptedProvider{name: "delivery", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("read", "read_file", `{"path":"main.go"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "analysis"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true}, event.Discard)
|
||||
if err := a.Run(context.Background(), "analyze main.go"); err != nil {
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(withClosedLoopContext(context.Background()), "analyze main.go"); err != nil {
|
||||
t.Fatalf("read-only analysis should not require mutation/sign-off: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -410,7 +410,7 @@ func TestEvidenceFlowEnforcesProjectChecksAfterWrite(t *testing.T) {
|
||||
a := New(prov, reg, NewSession(""), Options{
|
||||
ProjectChecks: []instruction.VerifyCheck{{Command: "go test ./...", SourcePath: "AGENTS.md", Line: 3}},
|
||||
}, event.Discard)
|
||||
if err := a.Run(context.Background(), "edit and verify"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "edit and verify"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@ func TestFinalReadinessAllowsFinalAnswerWithoutWriter(t *testing.T) {
|
||||
ProjectChecks: []instruction.VerifyCheck{{Command: "go test ./...", SourcePath: "AGENTS.md", Line: 3}},
|
||||
}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "inspect only"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "inspect only"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if prov.call != 1 {
|
||||
@@ -448,7 +448,7 @@ func TestFinalReadinessAllowsWriterWithoutChecksOrTodos(t *testing.T) {
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "simple edit"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "simple edit"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if prov.call != 2 {
|
||||
@@ -466,7 +466,7 @@ func TestFinalReadinessAuditSkipsWhenGateDoesNotApply(t *testing.T) {
|
||||
ProjectChecks: []instruction.VerifyCheck{{Command: "go test ./...", SourcePath: "AGENTS.md", Line: 3}},
|
||||
}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "inspect only"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "inspect only"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(sink.events) != 0 {
|
||||
@@ -487,7 +487,7 @@ func TestFinalReadinessAuditSkipsWhenGateDoesNotApply(t *testing.T) {
|
||||
sink := &readinessAuditSink{}
|
||||
a := New(prov, reg, NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "simple edit"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "simple edit"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(sink.events) != 0 {
|
||||
@@ -519,13 +519,13 @@ func TestFinalReadinessBlocksUntilProjectCheckRunsAfterWriter(t *testing.T) {
|
||||
|
||||
// The premature final answer fails immediately; the scoped follow-up runs
|
||||
// the required project check after the preserved write and passes.
|
||||
if err := a.Run(ctx, "edit and finish"); !readinessBlocked(err) {
|
||||
if err := a.Run(withNoClosedLoop(ctx), "edit and finish"); !readinessBlocked(err) {
|
||||
t.Fatalf("premature Run err = %v, want FinalReadinessError", err)
|
||||
}
|
||||
if prov.call != 2 {
|
||||
t.Fatalf("provider calls = %d, want writer turn + one blocked final answer (no retries)", prov.call)
|
||||
}
|
||||
if err := a.Run(ctx, "finish"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(ctx), "finish"); err != nil {
|
||||
t.Fatalf("verified Run: %v", err)
|
||||
}
|
||||
if got := lastToolResult(a.sess.conversation, "bash"); !strings.Contains(got, "bash done") {
|
||||
@@ -555,7 +555,7 @@ func TestFinalReadinessAuditRecordsBlockAndRecovery(t *testing.T) {
|
||||
}, sink)
|
||||
ctx := deliveryGoalContext("goal-audit", "edit and finish")
|
||||
|
||||
if err := a.Run(ctx, "edit and finish"); !readinessBlocked(err) {
|
||||
if err := a.Run(withNoClosedLoop(ctx), "edit and finish"); !readinessBlocked(err) {
|
||||
t.Fatalf("premature Run err = %v, want FinalReadinessError", err)
|
||||
}
|
||||
if len(sink.events) != 1 {
|
||||
@@ -565,7 +565,7 @@ func TestFinalReadinessAuditRecordsBlockAndRecovery(t *testing.T) {
|
||||
if blocked.Result != evidence.ReadinessErrored || blocked.MissingProjectChecks != 1 || blocked.CommandMismatchMissing != 1 {
|
||||
t.Fatalf("blocked audit = %+v, want missing project check command", blocked)
|
||||
}
|
||||
if err := a.Run(ctx, "finish"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(ctx), "finish"); err != nil {
|
||||
t.Fatalf("verified Run: %v", err)
|
||||
}
|
||||
recovered := sink.events[len(sink.events)-1]
|
||||
@@ -598,10 +598,10 @@ func TestFinalReadinessRejectsProjectCheckBeforeWriter(t *testing.T) {
|
||||
|
||||
// The pre-writer check does not satisfy the after-writer requirement: the
|
||||
// final answer fails, and the scoped follow-up reruns the check.
|
||||
if err := a.Run(ctx, "verify before edit, then finish"); !readinessBlocked(err) {
|
||||
if err := a.Run(withNoClosedLoop(ctx), "verify before edit, then finish"); !readinessBlocked(err) {
|
||||
t.Fatalf("premature Run err = %v, want FinalReadinessError", err)
|
||||
}
|
||||
if err := a.Run(ctx, "finish"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(ctx), "finish"); err != nil {
|
||||
t.Fatalf("verified Run: %v", err)
|
||||
}
|
||||
if prov.call != 4 {
|
||||
@@ -645,10 +645,10 @@ func TestFinalReadinessRequiresCompleteStepAfterWriterWhenTodoSeen(t *testing.T)
|
||||
|
||||
// The premature final answer fails immediately; the scoped follow-up signs
|
||||
// the step off with complete_step and passes.
|
||||
if err := a.Run(ctx, "edit with todo and finish"); !readinessBlocked(err) {
|
||||
if err := a.Run(withNoClosedLoop(ctx), "edit with todo and finish"); !readinessBlocked(err) {
|
||||
t.Fatalf("premature Run err = %v, want FinalReadinessError", err)
|
||||
}
|
||||
if err := a.Run(ctx, "finish"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(ctx), "finish"); err != nil {
|
||||
t.Fatalf("signed-off Run: %v", err)
|
||||
}
|
||||
if got := lastToolResult(a.sess.conversation, "complete_step"); !strings.Contains(got, "signed off") {
|
||||
@@ -676,7 +676,7 @@ func TestFinalReadinessStopsAfterFirstBlock(t *testing.T) {
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
|
||||
err := a.Run(context.Background(), "edit with todo and never sign off")
|
||||
err := a.Run(withNoClosedLoop(context.Background()), "edit with todo and never sign off")
|
||||
if err == nil {
|
||||
t.Fatal("expected the first readiness block to stop the run")
|
||||
}
|
||||
@@ -713,7 +713,7 @@ func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinal(t *testing.T) {
|
||||
Gate: &stubGate{deny: map[string]bool{"bash": true}},
|
||||
}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "edit with todo, then hit bash permission blocks"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "edit with todo, then hit bash permission blocks"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if prov.call != 5 {
|
||||
@@ -771,7 +771,7 @@ func TestFinalReadinessPermissionLoopGuardAllowsBlockedFinalForBatch(t *testing.
|
||||
Gate: &stubGate{deny: map[string]bool{"bash": true}},
|
||||
}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "edit with todo, then hit batched bash permission blocks"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "edit with todo, then hit batched bash permission blocks"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if prov.call != 5 {
|
||||
@@ -805,7 +805,7 @@ func TestTodoWriteOnlyTurnMayEndWithIncompleteTodos(t *testing.T) {
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "create a todo list only"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "create a todo list only"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if prov.call != 2 {
|
||||
@@ -834,7 +834,7 @@ func TestReadOnlyContextAndTodoTurnMayEndWithIncompleteTodos(t *testing.T) {
|
||||
}}
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "read context and only draft a todo list"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "read context and only draft a todo list"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if prov.call != 2 {
|
||||
@@ -866,7 +866,7 @@ func TestFinalReadinessAuditRecordsTerminalError(t *testing.T) {
|
||||
sink := &readinessAuditSink{}
|
||||
a := New(prov, reg, NewSession(""), Options{}, sink)
|
||||
|
||||
err := a.Run(context.Background(), "edit with todo and never sign off")
|
||||
err := a.Run(withNoClosedLoop(context.Background()), "edit with todo and never sign off")
|
||||
if err == nil {
|
||||
t.Fatal("expected the first readiness block to stop the run")
|
||||
}
|
||||
@@ -904,7 +904,7 @@ func TestEvidenceFlowRejectsUncitedCommand(t *testing.T) {
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "vet the tree and sign off"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "vet the tree and sign off"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -950,7 +950,7 @@ func TestEvidenceFlowRejectsStepMissingFromTodoWrite(t *testing.T) {
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "update todos then sign off the wrong step"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "update todos then sign off the wrong step"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -988,7 +988,7 @@ func TestEvidenceFlowAcceptsTodoCompletionAfterCompleteStep(t *testing.T) {
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "complete the todo with a sign-off first"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "complete the todo with a sign-off first"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -1026,7 +1026,7 @@ func TestEvidenceFlowRejectsTodoCompletionWithoutCompleteStep(t *testing.T) {
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "complete the todo without a sign-off"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "complete the todo without a sign-off"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -1070,7 +1070,7 @@ func TestEvidenceFlowRecoversTodoCompletionAfterFailedCompleteStepWithProgress(t
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "recover after a failed complete_step"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "recover after a failed complete_step"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -1139,7 +1139,7 @@ func TestEvidenceFlowRecoversAfterBatchTodoCompletionRejection(t *testing.T) {
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "recover from a rejected batch todo update"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "recover from a rejected batch todo update"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -1194,7 +1194,7 @@ func TestEvidenceFlowFailedCompleteStepDoesNotAuthorizeTodoCompletion(t *testing
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "attempt completion after a failed sign-off"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "attempt completion after a failed sign-off"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -1237,7 +1237,7 @@ func TestEvidenceFlowRejectsReplacedTodoAfterNumericCompleteStep(t *testing.T) {
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "try to reuse a numeric sign-off for another todo"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "try to reuse a numeric sign-off for another todo"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -1293,7 +1293,7 @@ func TestEvidenceFlowRejectsReorderedTodoAndRecoversSerially(t *testing.T) {
|
||||
}}
|
||||
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "complete the signed todo after reordering it"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "complete the signed todo after reordering it"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ func (a *Agent) executeBatch(ctx context.Context, turn *turnRuntime, calls []pro
|
||||
if !batchCallStaticallySkippable(a, calls[j]) {
|
||||
continue
|
||||
}
|
||||
isVerification := calls[j].Name == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(json.RawMessage(calls[j].Arguments)))
|
||||
isVerification := calls[j].Name == "bash" && evidence.IsVerificationCommand(bashCommandFromArgs(json.RawMessage(calls[j].Arguments)))
|
||||
msg := cause.message()
|
||||
var ex *tool.ShellExecution
|
||||
if calls[j].Name == "bash" {
|
||||
@@ -435,7 +435,7 @@ func batchCallMutationFailureCause(a *Agent, call provider.ToolCall, o toolOutco
|
||||
readOnly = o.effective.readOnly
|
||||
}
|
||||
effects := evidence.ClassifyToolCall(toolName, toolArgs, readOnly)
|
||||
if toolName == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(toolArgs)) && !effects.StateMutation {
|
||||
if toolName == "bash" && evidence.IsVerificationCommand(bashCommandFromArgs(toolArgs)) && !effects.StateMutation {
|
||||
return nil
|
||||
}
|
||||
if !effects.StateMutation {
|
||||
@@ -474,7 +474,7 @@ func batchCallStaticallySkippable(a *Agent, call provider.ToolCall) bool {
|
||||
return false
|
||||
}
|
||||
readOnly := t.ReadOnly()
|
||||
isVerification := call.Name == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(json.RawMessage(call.Arguments)))
|
||||
isVerification := call.Name == "bash" && evidence.IsVerificationCommand(bashCommandFromArgs(json.RawMessage(call.Arguments)))
|
||||
if isVerification {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@ func (a *Agent) resolveToolPolicy(ctx context.Context, turn *turnRuntime, plan *
|
||||
if blocked, early := a.applyContextualToolGate(ctx, plan); early {
|
||||
return blocked, true
|
||||
}
|
||||
if blocked, early := a.applyTaskPolicyPreflight(turn, plan); early {
|
||||
return blocked, true
|
||||
}
|
||||
if blocked, early := a.applyDeliveryPolicyGates(turn, plan); early {
|
||||
return blocked, true
|
||||
}
|
||||
@@ -200,7 +203,7 @@ func (a *Agent) applyMutationDependencyBarrier(plan *toolCallPlan) (toolOutcome,
|
||||
if cause == nil {
|
||||
return toolOutcome{}, false
|
||||
}
|
||||
verification := plan.evidenceName == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(plan.evidenceArgs))
|
||||
verification := plan.evidenceName == "bash" && evidence.IsVerificationCommand(bashCommandFromArgs(plan.evidenceArgs))
|
||||
if !plan.effects.StateMutation && !verification {
|
||||
return toolOutcome{}, false
|
||||
}
|
||||
@@ -280,7 +283,7 @@ func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) (
|
||||
plan.readOnly = rc.ReadOnly
|
||||
plan.classifyEffects()
|
||||
if outcome, blocked := a.readOnlyExecutionBlock(t, &rc); blocked {
|
||||
return outcome, true
|
||||
return blockedShellOutcome(outcome, plan), true
|
||||
}
|
||||
if rc.Commit != nil {
|
||||
if err := rc.Commit(); err != nil {
|
||||
@@ -315,7 +318,7 @@ func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) (
|
||||
return out, true
|
||||
}
|
||||
} else if outcome, blocked := a.readOnlyExecutionBlock(t, nil); blocked {
|
||||
return outcome, true
|
||||
return blockedShellOutcome(outcome, plan), true
|
||||
}
|
||||
|
||||
// A proxy resolution can point at a target with an explicit planning-phase
|
||||
@@ -354,24 +357,23 @@ func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) (
|
||||
}
|
||||
|
||||
// applyDeliveryPolicyGates enforces global deterministic shell contracts plus
|
||||
// delivery-profile-only criteria rules, and classifies mutation/verification.
|
||||
// closed-loop-only criteria rules, and classifies mutation/verification.
|
||||
func (a *Agent) applyDeliveryPolicyGates(turn *turnRuntime, plan *toolCallPlan) (toolOutcome, bool) {
|
||||
// Global deterministic shell contract (ordinary + Delivery). PowerShell 5.1
|
||||
// &&/|| is enforced inside the bash tool itself so descriptor and error text
|
||||
// stay shell-accurate; the agent layers apply command-shape protections.
|
||||
// Delivery keeps its longer recovery copy so existing delivery guidance tests
|
||||
// and model recovery prompts stay stable.
|
||||
//
|
||||
// Ordinary mode blocks only shapes where a later segment can actually hide an
|
||||
// earlier failure. Delivery keeps the broader classifier because a mutation
|
||||
closedLoop := a.closedLoopActive()
|
||||
// Global deterministic shell contract (ordinary + closed loop). PowerShell
|
||||
// 5.1 &&/|| is enforced inside the bash tool itself so descriptor and error
|
||||
// text stay shell-accurate; the agent layers apply command-shape protections.
|
||||
// Closed-loop turns keep the broader classifier because a mutation
|
||||
// invalidates the verification receipt even when the exit status is honest.
|
||||
// Without that split, `go build ./... && go test ./...`, `npm install &&
|
||||
// npm test`, and every other short-circuit chain would be rejected for every
|
||||
// user, though bash already reports the failing step's status for them.
|
||||
// Ordinary turns block only shapes where a later segment can actually hide
|
||||
// an earlier failure. Without that split, `go build ./... && go test ./...`,
|
||||
// `npm install && npm test`, and every other short-circuit chain would be
|
||||
// rejected for every user, though bash already reports the failing step's
|
||||
// status for them.
|
||||
if plan.evidenceName == "bash" {
|
||||
if evidence.BashToolCallMasksVerificationExit(plan.evidenceArgs) {
|
||||
msg := evidence.ShellContractPreflightMessage("mask_exit")
|
||||
if a.deliveryProfile {
|
||||
if closedLoop {
|
||||
msg = "blocked: the trailing echo/printf of $? masks the verifier's exit status, so this command would look successful even when the check failed. Run the verifier or read-only extraction pipeline by itself and let its exit status be the tool result; for example: tail ... | head ... | node --check -"
|
||||
}
|
||||
return toolOutcome{
|
||||
@@ -382,12 +384,12 @@ func (a *Agent) applyDeliveryPolicyGates(turn *turnRuntime, plan *toolCallPlan)
|
||||
}, true
|
||||
}
|
||||
mixed := evidence.BashToolCallMixesMutationAndMaskableVerification
|
||||
if a.deliveryProfile {
|
||||
if closedLoop {
|
||||
mixed = evidence.BashToolCallMixesMutationAndVerification
|
||||
}
|
||||
if mixed(plan.evidenceArgs) {
|
||||
msg := evidence.ShellContractPreflightMessage("mixed")
|
||||
if a.deliveryProfile {
|
||||
if closedLoop {
|
||||
msg = "blocked: this command mixes a verification check with a segment that may write state. Run the state-changing preparation separately while a todo is in_progress, then run a read-only verification command. For generated input, prefer a host-recognized read-only pipeline into the verifier (for example: tail ... | head ... | node --check -) instead of writing a temporary file."
|
||||
}
|
||||
return toolOutcome{
|
||||
@@ -407,30 +409,29 @@ func (a *Agent) applyDeliveryPolicyGates(turn *turnRuntime, plan *toolCallPlan)
|
||||
}, true
|
||||
}
|
||||
}
|
||||
// Delivery-only: any opaque inline interpreter is unauditable as evidence.
|
||||
if a.deliveryProfile && plan.evidenceName == "bash" && evidence.BashToolCallUsesOpaqueInlineInterpreter(plan.evidenceArgs) {
|
||||
// Closed-loop only: any opaque inline interpreter is unauditable as evidence.
|
||||
if closedLoop && plan.evidenceName == "bash" && evidence.BashToolCallUsesOpaqueInlineInterpreter(plan.evidenceArgs) {
|
||||
return toolOutcome{
|
||||
output: "blocked: delivery mode cannot audit inline interpreter source such as node -e or python -c, so executing it would become an opaque mutation and invalidate prior verification. For inspection, use read_file/grep or another host-proven read-only command. For validation, use a conventional verifier such as node --check, a project test/check/lint command, or a read-only extraction pipeline into the verifier. For an intentional state change, use a file tool or a script file under the current in_progress todo. " + evidence.VerificationCommandSummary(),
|
||||
output: "blocked: closed-loop execution cannot audit inline interpreter source such as node -e or python -c, so executing it would become an opaque mutation and invalidate prior verification. For inspection, use read_file/grep or another host-proven read-only command. For validation, use a conventional verifier such as node --check, a project test/check/lint command, or a read-only extraction pipeline into the verifier. For an intentional state change, use a file tool or a script file under the current in_progress todo. " + evidence.VerificationCommandSummary(),
|
||||
blocked: true,
|
||||
errMsg: "blocked: opaque inline interpreter command",
|
||||
execution: shellPreflightExecution(plan, false),
|
||||
}, true
|
||||
}
|
||||
|
||||
plan.classifyEffects()
|
||||
persistentWorkflowCall := turn.deliveryPersistentExpected && !turn.deliveryMutationExpected && plan.evidenceName == "remember"
|
||||
if a.deliveryProfile && !persistentWorkflowCall && evidence.ToolCallRequiresDeliveryCriteria(plan.evidenceName, plan.evidenceArgs, plan.readOnly) && !turn.deliveryCriteriaEstablished {
|
||||
if closedLoop && !persistentWorkflowCall && evidence.ToolCallRequiresAcceptanceCriteria(plan.evidenceName, plan.evidenceArgs, plan.readOnly) && !turn.deliveryCriteriaEstablished {
|
||||
return toolOutcome{
|
||||
output: "blocked: delivery-first mode requires acceptance criteria before state-changing work. Call todo_write with a concrete, verifiable task list, then retry this tool call.",
|
||||
output: "blocked: closed-loop execution requires acceptance criteria before state-changing work. Call todo_write with a concrete, verifiable task list, then retry this tool call.",
|
||||
blocked: true,
|
||||
errMsg: "blocked: delivery acceptance criteria required",
|
||||
errMsg: "blocked: closed-loop acceptance criteria required",
|
||||
}, true
|
||||
}
|
||||
if a.deliveryProfile && !persistentWorkflowCall && plan.effects.ContentMutation && !a.hasActiveCanonicalTodo() {
|
||||
if closedLoop && !persistentWorkflowCall && plan.effects.ContentMutation && !a.hasActiveCanonicalTodo() {
|
||||
return toolOutcome{
|
||||
output: "blocked: delivery-first mode requires every state change to belong to the current in_progress todo. Preserve the completed todo prefix, append a concrete new item if more work was discovered, mark that item in_progress with todo_write, then retry this mutation.",
|
||||
output: "blocked: closed-loop execution requires every state change to belong to the current in_progress todo. Preserve the completed todo prefix, append a concrete new item if more work was discovered, mark that item in_progress with todo_write, then retry this mutation.",
|
||||
blocked: true,
|
||||
errMsg: "blocked: active delivery todo required",
|
||||
errMsg: "blocked: active in-progress todo required",
|
||||
}, true
|
||||
}
|
||||
return toolOutcome{}, false
|
||||
@@ -445,7 +446,7 @@ func (a *Agent) applyRecoveryAndPermission(ctx context.Context, plan *toolCallPl
|
||||
// verification, plan transitions, and again for every tool once an Episode
|
||||
// is exhausted so host-proven read-only diagnosis can remain available while
|
||||
// further execution is quarantined. Ask/Yolo still bypass inside the gate.
|
||||
plan.verification = plan.evidenceName == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(plan.evidenceArgs))
|
||||
plan.verification = plan.evidenceName == "bash" && evidence.IsVerificationCommand(bashCommandFromArgs(plan.evidenceArgs))
|
||||
plan.planTransition, plan.planBefore, plan.planAfter, plan.planDiff = a.recoveryPlanTransition(plan.evidenceName, plan.evidenceArgs)
|
||||
episodeStopped := false
|
||||
if ctrl := a.recoveryEpisodeControl(); ctrl != nil {
|
||||
@@ -551,13 +552,6 @@ func (a *Agent) applyRecoveryAndPermission(ctx context.Context, plan *toolCallPl
|
||||
// PreToolUse hooks and preview checkpoints, and injects call context. All of
|
||||
// this happens after permission and before the concrete Execute call.
|
||||
func (a *Agent) prepareToolExecution(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
|
||||
policyArgs := plan.permArgs
|
||||
if len(plan.resolved.Args) > 0 {
|
||||
policyArgs = plan.resolved.Args
|
||||
}
|
||||
if outcome, blocked := a.taskPolicyToolGate(plan, policyArgs); blocked {
|
||||
return outcome, true
|
||||
}
|
||||
// Acquire after permission is granted but before PreToolUse: hooks are user
|
||||
// shell code and can themselves change the workspace. This keeps readers
|
||||
// concurrent and avoids holding the workspace during an approval prompt while
|
||||
@@ -638,8 +632,8 @@ func (a *Agent) prepareToolExecution(ctx context.Context, plan *toolCallPlan) (t
|
||||
if a.task.ledger != nil {
|
||||
cctx = evidence.WithLedger(cctx, a.task.ledger)
|
||||
cctx = evidence.WithSessionMessages(cctx, a.sess.conversation.Snapshot)
|
||||
if a.deliveryProfile {
|
||||
cctx = evidence.WithDeliveryProfile(cctx)
|
||||
if a.closedLoopActive() {
|
||||
cctx = evidence.WithClosedLoopExecution(cctx)
|
||||
}
|
||||
}
|
||||
if !a.planMode.Load() {
|
||||
|
||||
@@ -11,9 +11,7 @@ import (
|
||||
"reasonix/internal/taskpolicy"
|
||||
)
|
||||
|
||||
// Final readiness: whether a turn has earned the right to stop. It reads the
|
||||
// evidence ledger, the delivery profile, and the approved plan's contract, and
|
||||
// says what is missing rather than merely that something is.
|
||||
// Final readiness: whether the frozen TaskPolicy and ledger allow the turn to stop.
|
||||
|
||||
type finalReadinessCheck struct {
|
||||
applies bool
|
||||
@@ -93,6 +91,7 @@ func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
|
||||
if a.planMode.Load() {
|
||||
return out
|
||||
}
|
||||
a.escalatePolicyFromEvidence()
|
||||
{
|
||||
incomplete, hasTodos := a.task.ledger.IncompleteLatestTodos()
|
||||
if !hasTodos && a.task.ledger.HasAnySuccessfulReceipt() {
|
||||
@@ -105,11 +104,21 @@ func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
|
||||
}
|
||||
}
|
||||
writer, hasWriter := a.task.ledger.LatestSuccessfulWriterIndex()
|
||||
atomicMutationMissing := false
|
||||
if a.turn.policySet && a.turn.policy.RequireAtomicContract {
|
||||
if _, ok := a.task.ledger.LatestSuccessfulMutationIndex(); !ok {
|
||||
atomicMutationMissing = true
|
||||
out.applies = true
|
||||
out.missingMutation++
|
||||
missing = append(missing, "the atomic modification contract requires a successful mutation before answering")
|
||||
}
|
||||
}
|
||||
deliveryMutation := false
|
||||
deliveryVerificationOnly := false
|
||||
checkpoint := a.task.checkpoint
|
||||
checkpointApplies := a.turn.deliveryScopeActive && checkpoint.ScopeID == a.task.scopeID
|
||||
if a.deliveryProfile {
|
||||
closedLoop := a.closedLoopActive()
|
||||
if closedLoop {
|
||||
if mutation, ok := a.task.ledger.LatestSuccessfulMutationIndex(); ok {
|
||||
writer, hasWriter = mutation, true
|
||||
deliveryMutation = true
|
||||
@@ -160,14 +169,14 @@ func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
|
||||
}
|
||||
if !hasWriter {
|
||||
if len(missing) > 0 {
|
||||
if a.loopGuardAllowsFinal() {
|
||||
if a.loopGuardAllowsFinal() && !atomicMutationMissing {
|
||||
return out
|
||||
}
|
||||
out.reason = strings.Join(missing, "; ")
|
||||
}
|
||||
return out
|
||||
}
|
||||
if !a.deliveryProfile && a.turn.policySet && a.turn.policy.Verification >= taskpolicy.VerifyTargeted &&
|
||||
if !closedLoop && a.turn.policySet && a.turn.policy.Verification >= taskpolicy.VerifyTargeted &&
|
||||
a.turn.policy.AllowsTests() && toolPresent(a.svc.tools, "bash") &&
|
||||
!a.task.ledger.HasSuccessfulVerificationCommandAfter(writer) {
|
||||
out.applies = true
|
||||
@@ -176,35 +185,12 @@ func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
|
||||
}
|
||||
hasProjectChecks := len(a.projectChecks) > 0
|
||||
hasTodoReceipt := a.task.ledger.HasSuccessfulTodoWrite()
|
||||
if !a.deliveryProfile && !hasProjectChecks && !hasTodoReceipt && len(missing) == 0 {
|
||||
if !closedLoop && !hasProjectChecks && !hasTodoReceipt && len(missing) == 0 {
|
||||
return finalReadinessCheck{}
|
||||
}
|
||||
out.applies = true
|
||||
if a.deliveryProfile {
|
||||
a.emitTurnPhase(event.TurnPhaseVerifying)
|
||||
criteriaEstablished := a.turn.deliveryCriteriaEstablished || (checkpointApplies && checkpoint.CriteriaEstablished)
|
||||
if !criteriaEstablished {
|
||||
out.missingAcceptanceCriteria++
|
||||
missing = append(missing, "establish concrete acceptance criteria with todo_write before changing state")
|
||||
}
|
||||
hasCompleteStep := a.task.ledger.HasSuccessfulCompleteStepAfter(writer)
|
||||
if !hasCompleteStep {
|
||||
out.missingSignoff++
|
||||
missing = append(missing, "call complete_step after the latest mutation")
|
||||
}
|
||||
if !a.task.ledger.HasSuccessfulDeliverySignoffAfter(writer) {
|
||||
out.missingVerification++
|
||||
missing = append(missing, "run relevant verification after the latest mutation and cite that successful command in complete_step")
|
||||
}
|
||||
if deliveryMutation && !a.task.ledger.HasSuccessfulReviewAfter(writer) {
|
||||
out.missingReview++
|
||||
missing = append(missing, "inspect the changed result after the latest mutation (read the touched file or run git diff/status)")
|
||||
}
|
||||
if msg := a.deliveryReviewGateFailure(); msg != "" {
|
||||
out.missingReview++
|
||||
missing = append(missing, msg)
|
||||
}
|
||||
// The capability gate already ran before the no-writer fast path above.
|
||||
if closedLoop {
|
||||
missing = a.appendClosedLoopReadiness(&out, missing, writer, deliveryMutation, checkpointApplies, checkpoint)
|
||||
}
|
||||
for _, check := range a.projectChecks {
|
||||
if deliveryVerificationOnly {
|
||||
@@ -235,6 +221,31 @@ func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
|
||||
return a.applyPartialCheckWaiver(out)
|
||||
}
|
||||
|
||||
func (a *Agent) appendClosedLoopReadiness(out *finalReadinessCheck, missing []string, writer int, deliveryMutation, checkpointApplies bool, checkpoint evidence.DeliveryCheckpoint) []string {
|
||||
a.emitTurnPhase(event.TurnPhaseVerifying)
|
||||
if !(a.turn.deliveryCriteriaEstablished || (checkpointApplies && checkpoint.CriteriaEstablished)) {
|
||||
out.missingAcceptanceCriteria++
|
||||
missing = append(missing, "establish concrete acceptance criteria with todo_write before changing state")
|
||||
}
|
||||
if !a.task.ledger.HasSuccessfulCompleteStepAfter(writer) {
|
||||
out.missingSignoff++
|
||||
missing = append(missing, "call complete_step after the latest mutation")
|
||||
}
|
||||
if !a.task.ledger.HasSuccessfulDeliverySignoffAfter(writer) {
|
||||
out.missingVerification++
|
||||
missing = append(missing, "run relevant verification after the latest mutation and cite that successful command in complete_step")
|
||||
}
|
||||
if deliveryMutation && !a.task.ledger.HasSuccessfulReviewAfter(writer) {
|
||||
out.missingReview++
|
||||
missing = append(missing, "inspect the changed result after the latest mutation (read the touched file or run git diff/status)")
|
||||
}
|
||||
if msg := a.deliveryReviewGateFailure(); msg != "" {
|
||||
out.missingReview++
|
||||
missing = append(missing, msg)
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func finalReadinessCheckSource(check instruction.VerifyCheck) string {
|
||||
source := strings.TrimSpace(check.SourcePath)
|
||||
if source == "" {
|
||||
@@ -257,3 +268,39 @@ func finalReadinessIncompleteTodos(items []evidence.TodoStepMatch) string {
|
||||
}
|
||||
return "latest successful todo_write still has incomplete items: " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// escalatePolicyFromEvidence ratchets risk/review upward from ledger receipts.
|
||||
// High-risk or oversized mutations raise floors; weak coverage forces review.
|
||||
func (a *Agent) escalatePolicyFromEvidence() {
|
||||
if !a.turn.policySet || a.task.ledger == nil || !a.turn.policy.AllowsMutation() {
|
||||
return
|
||||
}
|
||||
p := &a.turn.policy
|
||||
if _, ok := a.task.ledger.LatestSuccessfulMutationIndex(); ok {
|
||||
switch a.task.ledger.MutationRiskWithin(a.writeWorkspaceRoot) {
|
||||
case evidence.RiskHigh:
|
||||
p.RaiseRisk(taskpolicy.RiskHigh)
|
||||
case evidence.RiskMedium:
|
||||
p.RaiseRisk(taskpolicy.RiskMedium)
|
||||
}
|
||||
}
|
||||
// Scope overrun: the change set grew well past a single-surface judgment.
|
||||
if paths := a.task.ledger.PathsSince(-1); len(paths) > 8 && p.Risk < taskpolicy.RiskMedium {
|
||||
p.RaiseRisk(taskpolicy.RiskMedium)
|
||||
}
|
||||
// Conditional review escalates to forced when acceptance coverage is weak,
|
||||
// ambiguous, or uncovered, or when required verification failed or never ran.
|
||||
if p.Review == taskpolicy.ReviewConditional {
|
||||
incomplete, hasTodos := a.task.ledger.IncompleteLatestTodos()
|
||||
weakCoverage := hasTodos && len(incomplete) > 0
|
||||
if !weakCoverage && p.Evidence >= taskpolicy.EvidenceClosedLoop {
|
||||
if mutation, ok := a.task.ledger.LatestSuccessfulMutationIndex(); ok {
|
||||
weakCoverage = !a.task.ledger.HasSuccessfulVerificationCommandAfter(mutation) &&
|
||||
!a.task.ledger.HasSuccessfulReviewAfter(mutation)
|
||||
}
|
||||
}
|
||||
if weakCoverage {
|
||||
p.EscalateConditionalReview("weak_evidence_coverage")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (a *Agent) beginFinalReadinessRecovery() (preserveEvidence, recovered bool)
|
||||
|
||||
// PrepareDeliveryRecovery is the v1.25 compatibility name retained for older
|
||||
// desktop bindings and external integrations. Final readiness also applies to
|
||||
// targeted Light/Balanced turns, so new code should use the generic method.
|
||||
// targeted standard turns, so new code should use the generic method.
|
||||
func (a *Agent) PrepareDeliveryRecovery() bool {
|
||||
return a.PrepareFinalReadinessRecovery()
|
||||
}
|
||||
@@ -13,21 +13,20 @@ import (
|
||||
|
||||
func TestTargetedVerificationRecoveryPreservesEvidenceOnce(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
prov := &scriptedProvider{name: "balanced", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("write", "write_file", `{"path":"verify_v070.py"}`), {Type: provider.ChunkDone}},
|
||||
{toolCallChunk("custom-check", "bash", `{"command":"python3 verify_v070.py"}`), {Type: provider.ChunkDone}},
|
||||
prov := &scriptedProvider{name: "standard", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("write", "write_file", `{"path":"docs/verify_v070.md"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "validation script passed"}, {Type: provider.ChunkDone}},
|
||||
{toolCallChunk("recognized-check", "bash", `{"command":"git diff --check"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "checks complete"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{AgentPreset: "balanced"}, event.Discard)
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
|
||||
var readinessErr *FinalReadinessError
|
||||
if err := a.Run(context.Background(), "write verify_v070.py and run the validation script"); !errors.As(err, &readinessErr) {
|
||||
if err := a.Run(context.Background(), "write docs/verify_v070.md and run the validation script"); !errors.As(err, &readinessErr) {
|
||||
t.Fatalf("first Run error = %v, want targeted-verification readiness failure", err)
|
||||
}
|
||||
if a.deliveryProfile {
|
||||
t.Fatal("balanced targeted-verification turn unexpectedly elevated to Delivery")
|
||||
if policy, ok := a.TurnPolicy(); !ok || policy.ClosedLoop() {
|
||||
t.Fatalf("targeted standard turn unexpectedly elevated to closed loop: %+v", policy)
|
||||
}
|
||||
if !a.PrepareDeliveryRecovery() {
|
||||
t.Fatal("targeted-verification recovery should preserve the failed turn's evidence")
|
||||
@@ -46,16 +45,15 @@ func TestTargetedVerificationRecoveryPreservesEvidenceOnce(t *testing.T) {
|
||||
|
||||
func TestTargetedVerificationRecoverySurvivesAgentRebuild(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
first := &scriptedProvider{name: "balanced", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("write", "write_file", `{"path":"verify_v070.py","content":"sensitive-payload"}`), {Type: provider.ChunkDone}},
|
||||
{toolCallChunk("opaque-check", "bash", `{"command":"python3 verify_v070.py"}`), {Type: provider.ChunkDone}},
|
||||
first := &scriptedProvider{name: "standard", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("write", "write_file", `{"path":"docs/verify_v070.md","content":"sensitive-payload"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "validation script passed"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
session := NewSession("sys")
|
||||
a := New(first, reg, session, Options{AgentPreset: "balanced"}, event.Discard)
|
||||
a := New(first, reg, session, Options{}, event.Discard)
|
||||
|
||||
var readinessErr *FinalReadinessError
|
||||
if err := a.Run(context.Background(), "write verify_v070.py and run the validation script"); !errors.As(err, &readinessErr) {
|
||||
if err := a.Run(context.Background(), "write docs/verify_v070.md and run the validation script"); !errors.As(err, &readinessErr) {
|
||||
t.Fatalf("first Run error = %v, want final readiness failure", err)
|
||||
}
|
||||
var marker *provider.FinalReadinessRecovery
|
||||
@@ -76,11 +74,11 @@ func TestTargetedVerificationRecoverySurvivesAgentRebuild(t *testing.T) {
|
||||
t.Fatalf("LoadSession: %v", err)
|
||||
}
|
||||
|
||||
recoveryProvider := &scriptedProvider{name: "balanced-reloaded", turns: [][]provider.Chunk{
|
||||
recoveryProvider := &scriptedProvider{name: "standard-reloaded", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("recognized-check", "bash", `{"command":"git diff --check"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "checks complete"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
reloaded := New(recoveryProvider, reg, loaded, Options{AgentPreset: "balanced"}, event.Discard)
|
||||
reloaded := New(recoveryProvider, reg, loaded, Options{}, event.Discard)
|
||||
if !reloaded.PrepareFinalReadinessRecovery() || reloaded.PrepareFinalReadinessRecovery() {
|
||||
t.Fatal("rebuilt recovery authorization was not one-shot")
|
||||
}
|
||||
@@ -100,18 +98,18 @@ func TestTargetedVerificationRecoverySurvivesAgentRebuild(t *testing.T) {
|
||||
|
||||
func TestFinalReadinessRecoveryRejectsStaleMarkerAfterUserTurn(t *testing.T) {
|
||||
reg := evidenceRegistry()
|
||||
prov := &scriptedProvider{name: "balanced", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("write", "write_file", `{"path":"a.go"}`), {Type: provider.ChunkDone}},
|
||||
prov := &scriptedProvider{name: "standard", turns: [][]provider.Chunk{
|
||||
{toolCallChunk("write", "write_file", `{"path":"README.md"}`), {Type: provider.ChunkDone}},
|
||||
{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}},
|
||||
}}
|
||||
session := NewSession("sys")
|
||||
a := New(prov, reg, session, Options{AgentPreset: "balanced"}, event.Discard)
|
||||
a := New(prov, reg, session, Options{}, event.Discard)
|
||||
var readinessErr *FinalReadinessError
|
||||
if err := a.Run(context.Background(), "change a.go"); !errors.As(err, &readinessErr) {
|
||||
if err := a.Run(context.Background(), "change README.md"); !errors.As(err, &readinessErr) {
|
||||
t.Fatalf("Run error = %v, want final readiness failure", err)
|
||||
}
|
||||
session.Add(provider.Message{Role: provider.RoleUser, Content: "unrelated follow-up"})
|
||||
reloaded := New(nil, reg, session, Options{AgentPreset: "balanced"}, event.Discard)
|
||||
reloaded := New(nil, reg, session, Options{}, event.Discard)
|
||||
if reloaded.PrepareFinalReadinessRecovery() {
|
||||
t.Fatal("stale readiness marker after a newer user turn must be rejected")
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ func (f *FleetTool) Execute(ctx context.Context, args json.RawMessage) (result s
|
||||
}
|
||||
jobCtx = WithParentSession(jobCtx, parentSession)
|
||||
jobCtx = evidence.WithLedger(jobCtx, backgroundEvidence)
|
||||
defer func() { jobs.PublishEvidence(jobCtx, backgroundEvidence.Summary()) }()
|
||||
defer publishBackgroundEvidence(jobCtx, backgroundEvidence, f.taskTool.workspaceRoot)
|
||||
// The job shares the Execute-level merger so the group lifecycle
|
||||
// events and the child previews ride the same pacing budget.
|
||||
jobCtx = withSubagentProgressMerger(jobCtx, merger)
|
||||
|
||||
@@ -35,7 +35,7 @@ func captureForkFixture(t *testing.T) (*ForkBundle, *scriptedProvider) {
|
||||
t.Setenv("REASONIX_EXPERIMENT_FORK_CAPTURE_DIR", dir)
|
||||
prov := &scriptedProvider{name: "p", turns: forkScriptTurns()}
|
||||
a := New(prov, forkRegistry(), NewSession("sys"), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "fix the widget"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "fix the widget"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
b, err := LoadForkBundle(filepath.Join(dir, "bundle.json"))
|
||||
@@ -61,7 +61,7 @@ func TestForkControlContinuationMatchesOriginalRequest(t *testing.T) {
|
||||
cont := &scriptedProvider{name: "p", turns: [][]provider.Chunk{{{Type: provider.ChunkText, Text: "done"}}}}
|
||||
a := New(cont, forkRegistry(), NewSession("sys"), Options{}, event.Discard)
|
||||
a.armForkContinuation(b, "")
|
||||
if err := a.Run(context.Background(), b.Input); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), b.Input); err != nil {
|
||||
t.Fatalf("continuation Run: %v", err)
|
||||
}
|
||||
if len(cont.requests) == 0 {
|
||||
@@ -82,7 +82,7 @@ func TestForkTreatmentDiffersOnlyByNudge(t *testing.T) {
|
||||
cont := &scriptedProvider{name: "p", turns: [][]provider.Chunk{{{Type: provider.ChunkText, Text: "done"}}}}
|
||||
a := New(cont, forkRegistry(), NewSession("sys"), Options{}, event.Discard)
|
||||
a.armForkContinuation(b, ebmNudge)
|
||||
if err := a.Run(context.Background(), b.Input); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), b.Input); err != nil {
|
||||
t.Fatalf("treatment Run: %v", err)
|
||||
}
|
||||
want, got := orig.requests[3], cont.requests[0]
|
||||
@@ -114,7 +114,7 @@ func TestForkCaptureRefusesTreatedState(t *testing.T) {
|
||||
t.Setenv("REASONIX_EXPERIMENT_FORK_CAPTURE_DIR", dir)
|
||||
prov := &scriptedProvider{name: "p", turns: forkScriptTurns()}
|
||||
a := New(prov, forkRegistry(), NewSession("sys"), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "fix the widget"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "fix the widget"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
// The live run nudged (treatment applied)…
|
||||
@@ -146,7 +146,7 @@ func TestGovernorCaptureFreezesExpensiveExplorationState(t *testing.T) {
|
||||
{{Type: provider.ChunkText, Text: "done"}},
|
||||
}}
|
||||
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "find the bug"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "find the bug"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
b, err := LoadForkBundle(filepath.Join(dir, "bundle.json"))
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestCoordinatorPlannerCannotReportExecutorGoalDisposition(t *testing.T) {
|
||||
customPlannerReg.Add(goalTool)
|
||||
coord := NewCoordinator(planner, plannerSess, nil, customPlannerReg, Options{}, executor, 0, event.Discard, nil)
|
||||
recorder := &coordinatorGoalRecorder{}
|
||||
ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
|
||||
ctx := withNoClosedLoop(tool.WithGoalTurnRecorder(context.Background(), recorder))
|
||||
if err := coord.Run(ctx, "fix the goal bug"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestRunPersistsUserCreatedAtWithoutSendingItToProvider(t *testing.T) {
|
||||
session.Add(provider.Message{Role: provider.RoleUser, Content: "existing", CreatedAt: existingCreatedAt})
|
||||
agent := New(prov, tool.NewRegistry(), session, Options{}, event.Discard)
|
||||
|
||||
if err := agent.Run(context.Background(), "new prompt"); err != nil {
|
||||
if err := agent.Run(withNoClosedLoop(context.Background()), "new prompt"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
request := prov.LastRequest()
|
||||
@@ -122,7 +122,7 @@ func TestRunPersistsResponsesItemsAcrossSessionReload(t *testing.T) {
|
||||
}})
|
||||
session := NewSession("system")
|
||||
agent := New(prov, tool.NewRegistry(), session, Options{}, event.Discard)
|
||||
if err := agent.Run(context.Background(), "search"); err != nil {
|
||||
if err := agent.Run(withNoClosedLoop(context.Background()), "search"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestRunMultiToolRoundEmptyIDsSurvivePairing(t *testing.T) {
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ func TestRunPersistsCumulativeAssistantWorkDuration(t *testing.T) {
|
||||
testutil.Turn{Text: "done"},
|
||||
)
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ func TestRunCancelledMidStreamLeavesResumableSession(t *testing.T) {
|
||||
mp := testutil.NewMock("m", testutil.ErrorTurn(context.Canceled))
|
||||
a := New(mp, echoRegistry(), NewSession("sys"), Options{}, event.Discard)
|
||||
|
||||
err := a.Run(context.Background(), "do the thing")
|
||||
err := a.Run(withNoClosedLoop(context.Background()), "do the thing")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run should surface the cancellation, got %v", err)
|
||||
}
|
||||
@@ -239,7 +239,7 @@ func TestRunRecoversInterruptedStreamAfterPartialText(t *testing.T) {
|
||||
sink := &recordSink{}
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run should recover the interrupted stream, got %v", err)
|
||||
}
|
||||
if mp.CallCount() != 2 {
|
||||
@@ -314,7 +314,7 @@ func TestRunRecoversRepeatedInterruptedStreams(t *testing.T) {
|
||||
sink := &recordSink{}
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run should recover repeated interrupted streams, got %v", err)
|
||||
}
|
||||
if mp.CallCount() != 3 {
|
||||
@@ -354,7 +354,7 @@ func TestRunRecoversInterruptedPartialToolCallWithoutExecutingIt(t *testing.T) {
|
||||
)
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run should recover the interrupted tool-call stream, got %v", err)
|
||||
}
|
||||
|
||||
@@ -381,7 +381,7 @@ func TestRunStreamRetryRequestCountIsLinearNotTriangular(t *testing.T) {
|
||||
)
|
||||
sink := &recordSink{}
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{}, sink)
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if mp.CallCount() != 3 {
|
||||
@@ -423,7 +423,7 @@ func TestRunExhaustedStreamRetriesPersistPendingLocalOnly(t *testing.T) {
|
||||
mp := testutil.NewMock("m", turns...)
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{}, event.Discard)
|
||||
|
||||
err := a.Run(context.Background(), "go")
|
||||
err := a.Run(withNoClosedLoop(context.Background()), "go")
|
||||
if !provider.IsStreamInterrupted(err) {
|
||||
t.Fatalf("Run error = %v, want StreamInterruptedError after exhausting retries", err)
|
||||
}
|
||||
@@ -464,7 +464,7 @@ func TestRunCompleteUncommittedToolCallNeverExecutes(t *testing.T) {
|
||||
testutil.Turn{Text: "recovered without write"},
|
||||
)
|
||||
a := New(mp, reg, NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "write it"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "write it"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if writer.calls.Load() != 0 {
|
||||
@@ -540,7 +540,7 @@ func TestRunGenericStreamErrorPersistsLocalDisplayAndInjectsBoundedRecovery(t *t
|
||||
session := NewSession("system")
|
||||
a := New(mp, echoRegistry(), session, Options{}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "change the file"); !errors.Is(err, apiErr) {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "change the file"); !errors.Is(err, apiErr) {
|
||||
t.Fatalf("first Run error = %v, want %v", err, apiErr)
|
||||
}
|
||||
msgs := session.Snapshot()
|
||||
@@ -552,7 +552,7 @@ func TestRunGenericStreamErrorPersistsLocalDisplayAndInjectsBoundedRecovery(t *t
|
||||
t.Fatalf("local display lost streamed output: %+v", last)
|
||||
}
|
||||
|
||||
if err := a.Run(context.Background(), "continue"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "continue"); err != nil {
|
||||
t.Fatalf("second Run: %v", err)
|
||||
}
|
||||
req := mp.Requests()[1]
|
||||
@@ -592,7 +592,7 @@ func TestRunRecoveryKeepsCompletedToolPairAndSummarizesChangedFile(t *testing.T)
|
||||
})
|
||||
mp := testutil.NewMock("m", testutil.Turn{Text: "done"})
|
||||
a := New(mp, echoRegistry(), session, Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "continue"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "continue"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -623,7 +623,7 @@ func TestRunWellFormedToolLoopRoundTrips(t *testing.T) {
|
||||
testutil.Turn{Text: "all set"},
|
||||
)
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{}, event.Discard)
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
@@ -648,7 +648,7 @@ func TestRunNonDeepSeekMissingToolCallReasoningDoesNotRetry(t *testing.T) {
|
||||
sink := &recordSink{}
|
||||
a := New(mp, echoRegistry(), NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := mp.CallCount(); got != 2 {
|
||||
@@ -684,7 +684,7 @@ func TestRunSilentlyRecoversMissingToolCallReasoning(t *testing.T) {
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
var savedToolTurns int
|
||||
@@ -740,7 +740,7 @@ func TestMissingReasoningRecoveryAdoptsRetryWithoutToolCall(t *testing.T) {
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if mp.CallCount() != 2 {
|
||||
@@ -792,7 +792,7 @@ func TestMissingReasoningRecoveryFailureFallsBackBeforeToolExecution(t *testing.
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run should keep the complete first response, got %v", err)
|
||||
}
|
||||
var toolResults int
|
||||
@@ -855,11 +855,11 @@ func TestSetSessionRearmsInMemoryMissingReasoningRecovery(t *testing.T) {
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{}, sink)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("first Run: %v", err)
|
||||
}
|
||||
a.SetSession(NewSession(""))
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("second Run: %v", err)
|
||||
}
|
||||
if got := sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted); got != 2 {
|
||||
@@ -879,7 +879,7 @@ func TestMissingReasoningRecoveryRateLimitsAcrossProcesses(t *testing.T) {
|
||||
)
|
||||
sink1 := &recordSink{}
|
||||
a1 := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink1)
|
||||
if err := a1.Run(context.Background(), "go"); err != nil {
|
||||
if err := a1.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("first Run: %v", err)
|
||||
}
|
||||
if got := sink1.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted); got != 1 {
|
||||
@@ -892,7 +892,7 @@ func TestMissingReasoningRecoveryRateLimitsAcrossProcesses(t *testing.T) {
|
||||
)
|
||||
sink2 := &recordSink{}
|
||||
a2 := New(toolCallReasoningRequiredProvider{mp2}, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink2)
|
||||
if err := a2.Run(context.Background(), "go"); err != nil {
|
||||
if err := a2.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("second process Run: %v", err)
|
||||
}
|
||||
if got := sink2.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted); got != 0 {
|
||||
@@ -913,7 +913,7 @@ func TestMissingReasoningRecoverySeparatesProviderConfigurations(t *testing.T) {
|
||||
)
|
||||
sink := &recordSink{}
|
||||
a := New(configuredToolCallReasoningProvider{MockProvider: mp, identity: identity}, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink)
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run(%q): %v", identity, err)
|
||||
}
|
||||
return sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted)
|
||||
@@ -938,7 +938,7 @@ func TestThreeHealthyToolCallReasoningTurnsRearmFutureRegression(t *testing.T) {
|
||||
mp := testutil.NewMock("deepseek-proxy", turns...)
|
||||
sink := &recordSink{}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{MissingReasoningWarnStateDir: stateDir}, sink)
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
return sink.recoveryCount(event.ProtocolRecoveryMissingReasoningRetryAttempted)
|
||||
@@ -1050,7 +1050,7 @@ func TestRunPreservesOriginalRequiredToolCallReasoningAcrossHook(t *testing.T) {
|
||||
h := &stubHooks{hasPostLLM: true, postLLMOut: "translated display"}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{Hooks: h}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
reqs := mp.Requests()
|
||||
@@ -1080,7 +1080,7 @@ func TestRunStoresTransformedNonToolReasoningForToolCallOnlyProvider(t *testing.
|
||||
h := &stubHooks{hasPostLLM: true, postLLMOut: "translated display"}
|
||||
a := New(toolCallReasoningRequiredProvider{mp}, echoRegistry(), NewSession(""), Options{Hooks: h}, event.Discard)
|
||||
|
||||
if err := a.Run(context.Background(), "go"); err != nil {
|
||||
if err := a.Run(withNoClosedLoop(context.Background()), "go"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := assistantReasoning(a.sess.conversation.Messages); got != "translated display" {
|
||||
|
||||
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user