Files
zero/internal/specialist/exec_test.go
0176ee49ff feat(agent): add PermissionModePlan for interactive read-only planning (#853)
* feat(agent): add PermissionModePlan for interactive read-only planning

* fix(agent): drop redundant modeName branch, add plan mode regression tests

string(permissionMode) already yields "plan" / "spec-draft", so the
if/else recomputing modeName in the denial message was dead branching
on the same values. Also add plan-mode coverage mirroring three of the
four existing spec-draft regression tests: advertised tool set, and
denied write_file/bash calls. The fourth (submit-and-stop review
control) has no plan-mode analog, since plan mode has no submit tool.

* fix(agent): deny request_permissions in plan/spec-draft even when the registry omits it

request_permissions is dispatched by name in executeToolCall before the
registry-based ToolAdvertised gate runs, so that gate only helps when
the tool happens to be present in the caller's registry. A plan- or
spec-draft-mode registry that simply omits the tool (rather than
registering it as denied) let the call fall through to a real
turn/session-scoped permission grant, defeating the read-only
boundary. Deny it unconditionally at the top of
executeRequestPermissions for both read-only modes instead.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(agent): suppress executable hooks while plan/spec-draft mode is active

Plan mode promises a read-only turn, but sessionStart/sessionEnd fire on
every run and beforeTool/afterTool fire around allowed read calls, and
all four execute configured host commands outside the advertised-tool
and sandbox gates — so a project hook could mutate the workspace or
spawn a process from a session that advertises it cannot. Gate all four
dispatch points on the run's permission mode, with a regression test
asserting no hook command launches during a plan-mode run.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(agent): close plan-mode tool advertisement bypass

toolAdvertisedInPlan whitelisted ask_user and update_plan by name
alone, so a caller could register a mutating tool under either name
and have it advertised and executed in plan mode. Validate every tool
against its Safety() instead. Also exclude lsp_navigate, which is
marked SideEffectRead but lazily spawns a real language-server
process, contradicting plan mode's read-only guarantee. Add
regression tests for both.

* fix(agent): keep trust-gated hooks in spec-draft mode

Plan mode still suppresses executable hooks so a read-only planning turn
cannot spawn host processes via session or tool hooks. Spec-draft keeps
the existing trust model: project hooks fire when the workspace (or its
worktree trust root) is trusted. Unconditionally suppressing hooks in
spec-draft broke TestExecSpecWorktreeInheritsTrustEndToEnd for trusted
worktrees under --use-spec --worktree.

* fix(agent): cover plan-mode spoof and lsp_navigate denials

Expand the name-only spoof regression to both update_plan and ask_user,
and add an execution-path denial for lsp_navigate so plan mode cannot
spawn language servers even when a call still arrives.

* fix(agent): filter tool_search deferred candidates by plan/spec-draft visibility

tool_search resolved and ranked deferred tools by EnabledTools/DisabledTools
only, never by the run's permission-mode visibility. tool_search itself is
already denied at dispatch in plan/spec-draft (its Safety carries no side
effect, so it fails the same SideEffect==Read advertisement gate direct calls
use), so this was not reachable through the normal Run() path today. But it
is a real landmine: if that outer gate is ever loosened independently (e.g.
tool_search's no-side-effect Safety is judged advertisable), the loader had
no gate of its own and would hand a deferred write/mutator tool's name,
description, and full schema straight to a plan/spec-draft model.

Mirror agent.ToolAdvertised's plan/spec-draft branches inside the tools
package (toolAdvertisedForPermissionMode, next to the existing
toolAllowedByFilters mirror that avoids the same import cycle) and apply it
alongside the operator filters in visibleDeferredTools and
visibleEagerToolNames.

Added unit tests in tool_search_test.go for both modes, and an end-to-end
agent test that force-calls tool_search in plan mode and asserts no schema
leaks. Verified by reverting tool_search.go and confirming the new tests fail
(one shows load_tools resolving to the mutator's name); restored and
confirmed they pass. Also confirmed via a temporary probe that if plan mode's
outer advertisement gate is loosened, this filter is what actually stops the
leak.

* fix(agent): require Safety for spec-draft ask_user/submit_spec

Do not advertise or load re-registered control tools by name alone in
spec-draft mode. ask_user must be SideEffectRead+Allow and submit_spec
must be SideEffectWrite+Allow, matching the real tools. Apply the same
filter in tool_search and add spoof regression tests.

Refs Gitlawb/zero#642

* fix(agent): wire plan mode into TUI, CLI, and ACP entry points

Address the P1 finding that PermissionModePlan was documented but never
selected by /plan, zero exec, or ACP mode selectors. /plan on|off now
toggles the session permission mode (restoring the prior mode on off),
zero exec --plan selects plan for a run, and ACP advertises plan as a
client-selectable mode. Integration coverage for each entry path.

* fix(cli): reject --plan combined with --worktree

Worktree preparation runs in runExec before the plan permission mode is
assigned, so `zero exec --plan --worktree` could still trigger workspace
mutation ahead of the read-only gate. Reject the combination during
option validation, alongside the existing --use-spec/--skip-permissions-
unsafe conflict checks, so no worktree prep can occur.

Addresses a coderabbitai finding on PR #642.

* test(tools): assert spoofed tool schema doesn't leak via tool_search

The spoofed-control-tool regression only asserted on the description
string; Parameters() exposed no distinctive schema marker, so a
regression that leaked the schema without the description would still
have passed. Add a spoofed_secret property to the test tool's schema
and assert it's absent from result.Output alongside the description.

Addresses a coderabbitai finding on PR #642.

* fix(tui): gate local mutating commands behind plan mode

/plan on only flips the agent permission mode, which gates agent tool
calls. Local TUI commands that run entirely inside the TUI process
bypass that gate: /rewind restores workspace files from a checkpoint,
/export writes a transcript to disk, and /sandbox-setup spawns a
native host process. Add a shared plan-mode guard at the start of
dispatchCommand (mirroring the existing BTW-unavailable guard) that
rejects these three commands while permissionMode is
agent.PermissionModePlan, with regression coverage proving each is
blocked with no mutation/process spawn in plan mode and unaffected
outside it.

Addresses a coderabbitai finding on PR #642.

* fix(agent,specialist): layer plan mode system prompt and enforce read-only subagent mode

* Fix jatmn review findings for PR 642

* fix(agent,cli,tui): resolve CodeRabbit review comments on active turn model field and --plan permission mode conflict

* fix(agent): propagate permission mode to exec options and serialize ACP mode changes

Parse permissionMode in resolveExecPermissionMode, acquire turnMu.Lock in ACP handleSetMode to serialize mode changes with active turns, and block MCP subcommands in TUI plan mode.

Refs #642

* fix(agent,tui): update tests off removed tools.CoreTools/NewWriteFileTool/NewLSPNavigateTool wrappers

Those were thin unscoped wrappers around the Scoped variants, deleted
upstream in #706 since nothing else called them directly. Only these
tests still did; switch to the Scoped calls main's own tests already
use.

* fix(agent): fail-closed beforeTool vetoes and permission-mode plan guards

Keep beforeTool deny gates active under plan mode so hooksSuppressed no longer
fails open, and stop propagating --permission-mode for auto/ask/member children
so swarm members keep write tools. Apply --plan combination rejects to
--permission-mode plan as well.

Refs #853

* fix(agent): address CodeRabbit findings for plan-mode advertisement and entry paths

Unify plan/spec-draft tool advertisement in tools.ToolAdvertisedForPermissionMode
(with PermissionDeny short-circuit), cover ACP config and --permission-mode plan
list-tools paths, and allow bare /mcp while blocking mutating MCP subcommands.

Refs #853

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
2026-08-10 10:15:29 +08:00

494 lines
18 KiB
Go

package specialist
import (
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestBuildArgsCreatesFreshSpecialistExecInvocation(t *testing.T) {
executor := Executor{
NewSessionID: func() (string, error) { return "child_session", nil },
}
manifest := Manifest{
Metadata: Metadata{
Name: "reviewer",
Description: "Reviews code",
Model: "claude-sonnet-4.5",
ReasoningEffort: "high",
},
SystemPrompt: "Review carefully.",
ResolvedTools: []string{"grep", "read_file"},
}
result, err := executor.BuildArgs(BuildArgsInput{
Manifest: manifest,
Prompt: "Review this patch",
ParentSessionID: "parent_session",
ParentToolUseID: "toolu_123",
ParentModel: "gpt-4.1",
ParentReasoningEffort: "medium",
CurrentDepth: 1,
Description: "Auth diff",
})
if err != nil {
t.Fatalf("BuildArgs returned error: %v", err)
}
if result.SessionID != "child_session" || result.PromptFile != "" {
t.Fatalf("unexpected result metadata: %#v", result)
}
wantArgs := []string{
"exec", "--init-session-id", "child_session",
result.Args[3],
"--model", "claude-sonnet-4.5",
"--reasoning-effort", "high",
"--auto", "low", // no PermissionMode set -> fail-safe low (not unsafe high)
"--output-format", "stream-json",
"--enabled-tools", "grep,read_file",
"--depth", "2",
"--tag", "specialist",
"--calling-session-id", "parent_session",
"--calling-tool-use-id", "toolu_123",
"--session-title", "reviewer: Auth diff",
}
if !reflect.DeepEqual(result.Args, wantArgs) {
t.Fatalf("args mismatch\ngot: %#v\nwant: %#v", result.Args, wantArgs)
}
prompt := result.Args[3]
for _, want := range []string{"Specialist: reviewer", "Task description: Auth diff", "Review carefully.", "Review this patch"} {
if !strings.Contains(prompt, want) {
t.Fatalf("wrapped prompt missing %q:\n%s", want, prompt)
}
}
}
func TestSpecialistAutonomyByPermissionMode(t *testing.T) {
cases := map[string]string{
"": "low", // fail-safe: an unset mode does NOT inherit unsafe autonomy
"unsafe": "high",
"auto": "low",
"ask": "low",
"spec-draft": "low",
"whatever": "low",
}
for mode, want := range cases {
if got := specialistAutonomy(mode); got != want {
t.Errorf("specialistAutonomy(%q) = %q, want %q", mode, got, want)
}
}
}
func TestMemberAwareAutonomy(t *testing.T) {
// A non-unsafe member runs at "member" (write/edit + sandboxed shell), a plain
// specialist at "low" (read-only), and an unsafe parent at "high" either way.
cases := []struct {
mode string
member bool
want string
}{
{"auto", false, "low"},
{"ask", false, "low"},
{"auto", true, "member"},
{"ask", true, "member"},
{"", true, "member"}, // member, fail-safe non-unsafe, still write-capable
{"unsafe", true, "high"}, // unsafe parent keeps full autonomy
{"unsafe", false, "high"},
}
for _, c := range cases {
if got := memberAwareAutonomy(c.mode, c.member); got != c.want {
t.Errorf("memberAwareAutonomy(%q, %v) = %q, want %q", c.mode, c.member, got, c.want)
}
}
}
func TestBuildArgsMemberAutonomyEmitsMember(t *testing.T) {
executor := Executor{NewSessionID: func() (string, error) { return "child", nil }}
manifest := Manifest{Metadata: Metadata{Name: "subagent"}, SystemPrompt: "x", ResolvedTools: []string{"read_file", "write_file"}}
// A non-unsafe member → --auto member (write-capable), not the read-only low.
res, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: "auto", MemberAutonomy: true})
if err != nil {
t.Fatalf("BuildArgs: %v", err)
}
if !containsSequence(res.Args, []string{"--auto", "member"}) {
t.Fatalf("non-unsafe member must yield --auto member, got %v", res.Args)
}
// --permission-mode auto would short-circuit resolveExecPermissionMode and
// strip the member rung; member children must rely on --auto alone.
if containsSequence(res.Args, []string{"--permission-mode"}) {
t.Fatalf("non-plan member must not emit --permission-mode (would override --auto member), got %v", res.Args)
}
// Without the member flag, the same parent stays --auto low (unchanged).
plain, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: "auto"})
if err != nil {
t.Fatalf("BuildArgs(plain): %v", err)
}
if !containsSequence(plain.Args, []string{"--auto", "low"}) || containsSequence(plain.Args, []string{"--auto", "member"}) {
t.Fatalf("a plain specialist must stay --auto low, got %v", plain.Args)
}
if containsSequence(plain.Args, []string{"--permission-mode"}) {
t.Fatalf("auto parent must not emit --permission-mode, got %v", plain.Args)
}
// An unsafe member still runs --auto high, never downgraded to member.
unsafe, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: "unsafe", MemberAutonomy: true})
if err != nil {
t.Fatalf("BuildArgs(unsafe member): %v", err)
}
if !containsSequence(unsafe.Args, []string{"--auto", "high"}) {
t.Fatalf("unsafe member must yield --auto high, got %v", unsafe.Args)
}
if containsSequence(unsafe.Args, []string{"--permission-mode"}) {
t.Fatalf("unsafe parent must not emit --permission-mode, got %v", unsafe.Args)
}
}
// TestBuildArgsPropagatesPermissionModeOnlyForPlanAndSpecDraft pins that
// --permission-mode is emitted only when --auto cannot express the mode
// (plan/spec-draft). Emitting it for auto/ask/member would either strip swarm
// member write tools or widen headless ask children past --auto low.
func TestBuildArgsPropagatesPermissionModeOnlyForPlanAndSpecDraft(t *testing.T) {
executor := Executor{NewSessionID: func() (string, error) { return "child", nil }}
manifest := Manifest{Metadata: Metadata{Name: "worker"}, SystemPrompt: "x", ResolvedTools: []string{"read_file", "write_file", "bash"}}
for _, mode := range []string{"auto", "ask", "unsafe", "member", ""} {
res, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: mode})
if err != nil {
t.Fatalf("BuildArgs(%q): %v", mode, err)
}
if containsSequence(res.Args, []string{"--permission-mode"}) {
t.Fatalf("mode %q must not emit --permission-mode, got %v", mode, res.Args)
}
}
for _, mode := range []string{"plan", "spec-draft"} {
res, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: mode})
if err != nil {
t.Fatalf("BuildArgs(%q): %v", mode, err)
}
if !containsSequence(res.Args, []string{"--permission-mode", mode}) {
t.Fatalf("mode %q must emit --permission-mode %s, got %v", mode, mode, res.Args)
}
if !containsSequence(res.Args, []string{"--auto", "low"}) {
t.Fatalf("mode %q must still emit --auto low, got %v", mode, res.Args)
}
}
// Resume path uses the same rule.
resume, err := executor.BuildResumeArgs(BuildResumeArgsInput{
SessionID: "child_session", Prompt: "p", Manifest: manifest, PermissionMode: "ask",
})
if err != nil {
t.Fatalf("BuildResumeArgs(ask): %v", err)
}
if containsSequence(resume.Args, []string{"--permission-mode"}) {
t.Fatalf("resume ask must not emit --permission-mode, got %v", resume.Args)
}
planResume, err := executor.BuildResumeArgs(BuildResumeArgsInput{
SessionID: "child_session", Prompt: "p", Manifest: manifest, PermissionMode: "plan",
})
if err != nil {
t.Fatalf("BuildResumeArgs(plan): %v", err)
}
if !containsSequence(planResume.Args, []string{"--permission-mode", "plan"}) {
t.Fatalf("resume plan must emit --permission-mode plan, got %v", planResume.Args)
}
}
func TestChildPermissionModeFlag(t *testing.T) {
cases := map[string]string{
"": "",
"auto": "",
"ask": "",
"unsafe": "",
"member": "",
"plan": "plan",
"spec-draft": "spec-draft",
" plan ": "plan",
}
for in, want := range cases {
if got := childPermissionModeFlag(in); got != want {
t.Errorf("childPermissionModeFlag(%q) = %q, want %q", in, got, want)
}
}
}
func TestBuildArgsAutonomyHonorsPermissionMode(t *testing.T) {
executor := Executor{NewSessionID: func() (string, error) { return "child", nil }}
manifest := Manifest{Metadata: Metadata{Name: "reviewer"}, SystemPrompt: "x", ResolvedTools: []string{"read_file"}}
// A non-unsafe parent mode yields a non-unsafe child (--auto low), so a
// swarm member never gains more authority than a non-unsafe orchestrator.
res, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: "auto"})
if err != nil {
t.Fatalf("BuildArgs: %v", err)
}
if !containsSequence(res.Args, []string{"--auto", "low"}) {
t.Fatalf("non-unsafe parent must yield --auto low, got %v", res.Args)
}
if containsSequence(res.Args, []string{"--auto", "high"}) {
t.Fatalf("non-unsafe parent must NOT yield --auto high: %v", res.Args)
}
// Only an explicit unsafe parent keeps --auto high.
out, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: "unsafe"})
if err != nil {
t.Fatalf("BuildArgs(unsafe): %v", err)
}
if !containsSequence(out.Args, []string{"--auto", "high"}) {
t.Fatalf("unsafe parent must yield --auto high, got %v", out.Args)
}
// An unset mode is fail-safe low (the foot-gun fix): a caller that forgets to
// wire PermissionMode does NOT silently get an unsafe child.
empty, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: ""})
if err != nil {
t.Fatalf("BuildArgs(empty): %v", err)
}
if !containsSequence(empty.Args, []string{"--auto", "low"}) {
t.Fatalf("empty mode must yield --auto low (fail-safe), got %v", empty.Args)
}
}
func TestBuildResumeArgsAutonomyHonorsPermissionMode(t *testing.T) {
executor := Executor{}
manifest := Manifest{Metadata: Metadata{Name: "reviewer"}, ResolvedTools: []string{"read_file"}}
res, err := executor.BuildResumeArgs(BuildResumeArgsInput{
SessionID: "child_session", Prompt: "p", Manifest: manifest, PermissionMode: "auto",
})
if err != nil {
t.Fatalf("BuildResumeArgs: %v", err)
}
if !containsSequence(res.Args, []string{"--auto", "low"}) || containsSequence(res.Args, []string{"--auto", "high"}) {
t.Fatalf("resume non-unsafe parent must yield --auto low, got %v", res.Args)
}
}
func TestBuildArgsSessionTitleFallsBackToNameWithoutDescription(t *testing.T) {
executor := Executor{NewSessionID: func() (string, error) { return "child", nil }}
manifest := Manifest{
Metadata: Metadata{Name: "reviewer", Description: "Reviews code"},
SystemPrompt: "Review carefully.",
ResolvedTools: []string{"read_file"},
}
// Description is optional. When omitted, the session title must still carry
// the specialist name so AgentName (derived from the title) is non-empty and
// the session remains resumable.
result, err := executor.BuildArgs(BuildArgsInput{
Manifest: manifest,
Prompt: "Do the thing",
})
if err != nil {
t.Fatalf("BuildArgs returned error: %v", err)
}
if !containsSequence(result.Args, []string{"--session-title", "reviewer"}) {
t.Fatalf("args missing name-only session title: %#v", result.Args)
}
for index, arg := range result.Args {
if arg == "--session-title" && index+1 < len(result.Args) {
if got := result.Args[index+1]; got != "reviewer" {
t.Fatalf("session title = %q, want %q", got, "reviewer")
}
}
}
}
func TestBuildArgsInheritsParentModelAndReasoning(t *testing.T) {
executor := Executor{NewSessionID: func() (string, error) { return "child", nil }}
manifest := Manifest{
Metadata: Metadata{Name: "worker", Description: "Works"},
SystemPrompt: "Do work.",
ResolvedTools: []string{"read_file"},
}
result, err := executor.BuildArgs(BuildArgsInput{
Manifest: manifest,
Prompt: "Do the thing",
ParentModel: "gpt-4.1",
ParentReasoningEffort: "medium",
})
if err != nil {
t.Fatalf("BuildArgs returned error: %v", err)
}
if !containsSequence(result.Args, []string{"--model", "gpt-4.1"}) {
t.Fatalf("args missing inherited model: %#v", result.Args)
}
if !containsSequence(result.Args, []string{"--reasoning-effort", "medium"}) {
t.Fatalf("args missing inherited reasoning effort: %#v", result.Args)
}
}
func TestBuildArgsDefaultsToReadOnlyToolAllowlist(t *testing.T) {
executor := Executor{NewSessionID: func() (string, error) { return "child", nil }}
result, err := executor.BuildArgs(BuildArgsInput{
Manifest: Manifest{
Metadata: Metadata{Name: "worker", Description: "Works"},
SystemPrompt: "Do work.",
},
Prompt: "Do the thing",
})
if err != nil {
t.Fatalf("BuildArgs returned error: %v", err)
}
if !containsSequence(result.Args, []string{"--enabled-tools", "glob,grep,list_directory,read_file,read_minified_file,view_image"}) {
t.Fatalf("args missing default read-only allowlist: %#v", result.Args)
}
}
func TestBuildArgsWritesLargePromptFile(t *testing.T) {
root := t.TempDir()
var writtenPrompt string
executor := Executor{
NewSessionID: func() (string, error) { return "child", nil },
PromptFileMaxSize: 16,
WritePromptFile: func(prompt string) (string, error) {
writtenPrompt = prompt
path := filepath.Join(root, "prompt.md")
return path, os.WriteFile(path, []byte(prompt), 0o600)
},
}
result, err := executor.BuildArgs(BuildArgsInput{
Manifest: Manifest{
Metadata: Metadata{Name: "worker", Description: "Works"},
SystemPrompt: strings.Repeat("system ", 10),
},
Prompt: "Do the large thing",
})
if err != nil {
t.Fatalf("BuildArgs returned error: %v", err)
}
if result.PromptFile != filepath.Join(root, "prompt.md") {
t.Fatalf("PromptFile = %q", result.PromptFile)
}
if !reflect.DeepEqual(result.Args[:5], []string{"exec", "--init-session-id", "child", "--file", result.PromptFile}) {
t.Fatalf("prompt file args = %#v", result.Args[:5])
}
if !strings.Contains(writtenPrompt, "Do the large thing") {
t.Fatalf("written prompt missing task: %s", writtenPrompt)
}
}
func TestBuildResumeArgsUsesExistingSession(t *testing.T) {
result, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
SessionID: "child_session",
Prompt: "Follow up",
CurrentDepth: 2,
})
if err != nil {
t.Fatalf("BuildResumeArgs returned error: %v", err)
}
wantPrefix := []string{"exec", "--resume", "child_session"}
if !reflect.DeepEqual(result.Args[:3], wantPrefix) {
t.Fatalf("resume args prefix = %#v", result.Args[:3])
}
if !containsSequence(result.Args, []string{"--auto", "low"}) || // no PermissionMode -> fail-safe low
!containsSequence(result.Args, []string{"--output-format", "stream-json"}) ||
!containsSequence(result.Args, []string{"--depth", "3"}) ||
!containsSequence(result.Args, []string{"--tag", "specialist"}) {
t.Fatalf("resume args missing required flags: %#v", result.Args)
}
if !strings.Contains(result.Args[3], "Follow-up Instructions") || !strings.Contains(result.Args[3], "Follow up") {
t.Fatalf("resume prompt not wrapped correctly: %s", result.Args[3])
}
}
func TestBuildArgsRejectsInvalidInputs(t *testing.T) {
tests := []struct {
name string
input BuildArgsInput
want string
}{
{name: "negative depth", input: BuildArgsInput{Prompt: "hi", CurrentDepth: -1}, want: "depth"},
{name: "empty prompt", input: BuildArgsInput{CurrentDepth: 0}, want: "prompt"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := (Executor{}).BuildArgs(tc.input)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("BuildArgs error = %v, want %q", err, tc.want)
}
})
}
}
func TestRunRejectsDepthExceedingMax(t *testing.T) {
_, err := (Executor{}).Run(context.Background(), TaskParameters{
Prompt: "hi",
}, TaskRunOptions{CurrentDepth: maxSpecialistDepth + 1})
if err == nil || !strings.Contains(err.Error(), "depth") {
t.Fatalf("Run error = %v, want depth error", err)
}
}
// TestRunRejectsDepthAtMax covers the boundary: a parent already AT the cap
// must be rejected too, since this Run call would launch a child one level
// past it (--depth CurrentDepth+1). Only checking ">" here would let that
// child start before the guard ever caught it. The guard sits before the
// fresh/resume branch in Run, so both call shapes must be proven to reject
// here rather than falling through to runFresh/runResume.
func TestRunRejectsDepthAtMax(t *testing.T) {
tests := []struct {
name string
params TaskParameters
}{
{name: "fresh", params: TaskParameters{Prompt: "hi"}},
{name: "resume", params: TaskParameters{Prompt: "hi", Resume: "child_session"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := (Executor{}).Run(context.Background(), tc.params, TaskRunOptions{CurrentDepth: maxSpecialistDepth})
if err == nil || !strings.Contains(err.Error(), "depth") {
t.Fatalf("Run error = %v, want depth error", err)
}
})
}
}
func TestBuildArgsRejectsInvalidSessionIDs(t *testing.T) {
_, err := (Executor{NewSessionID: func() (string, error) { return "../escape", nil }}).BuildArgs(BuildArgsInput{
Prompt: "hi",
})
if err == nil || !strings.Contains(err.Error(), "invalid specialist session id") {
t.Fatalf("BuildArgs error = %v", err)
}
_, err = (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
SessionID: "../escape",
Prompt: "hi",
})
if err == nil || !strings.Contains(err.Error(), "invalid resume session id") {
t.Fatalf("BuildResumeArgs error = %v", err)
}
}
func TestBuildArgsNormalizesGeneratedSessionID(t *testing.T) {
result, err := (Executor{
NewSessionID: func() (string, error) { return " child_session ", nil },
}).BuildArgs(BuildArgsInput{Prompt: "hi"})
if err != nil {
t.Fatalf("BuildArgs returned error: %v", err)
}
if result.SessionID != "child_session" {
t.Fatalf("SessionID = %q, want child_session", result.SessionID)
}
if !containsSequence(result.Args, []string{"--init-session-id", "child_session"}) {
t.Fatalf("args missing normalized session id: %#v", result.Args)
}
}
func containsSequence(values []string, sequence []string) bool {
for index := 0; index+len(sequence) <= len(values); index++ {
if reflect.DeepEqual(values[index:index+len(sequence)], sequence) {
return true
}
}
return false
}