mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 05:01:36 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21cd7ae480 |
No files matched your search
@@ -67,7 +67,7 @@ func preflightExecSession(options execOptions) error {
|
||||
if session == nil {
|
||||
return execUsageError{"Zero session not found: " + options.resume}
|
||||
}
|
||||
if !sessions.IsResumableKind(session.SessionKind) {
|
||||
if !sessions.ExecResumable(*session, options.callingSessionID, options.tag) {
|
||||
return execUsageError{"Zero session is not resumable: " + options.resume}
|
||||
}
|
||||
case options.resumeLatest:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/specialist"
|
||||
"github.com/Twigpine/zero/internal/streamjson"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// TestTaskResumeContinuesTheSpecialistChildThroughExec runs the Task tool's own
|
||||
// arguments through the real exec path, for the fresh run and for the resume.
|
||||
// The fresh run creates a child session (kind child, tag specialist), and exec
|
||||
// refused to resume that kind, so every Task resume failed with "Zero session
|
||||
// is not resumable" while the specialist tests, which launch a fake binary,
|
||||
// stayed green.
|
||||
func TestTaskResumeContinuesTheSpecialistChildThroughExec(t *testing.T) {
|
||||
isolateCLIUserState(t)
|
||||
cwd := t.TempDir()
|
||||
store := sessions.NewStore(sessions.StoreOptions{})
|
||||
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_task", Cwd: cwd})
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
|
||||
provider := &capturingImageProvider{}
|
||||
deps := appDeps{
|
||||
stdin: strings.NewReader(""),
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return execResolvedConfig(), nil
|
||||
},
|
||||
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return provider, nil },
|
||||
}
|
||||
var childStderr []string
|
||||
executor := specialist.Executor{
|
||||
BinaryPath: "zero",
|
||||
SessionStore: store,
|
||||
NewSessionID: func() (string, error) { return "child_task", nil },
|
||||
Load: func(specialist.LoadOptions) (specialist.LoadResult, error) {
|
||||
return specialist.LoadResult{Specialists: []specialist.Manifest{{
|
||||
Metadata: specialist.Metadata{
|
||||
Name: "skim",
|
||||
Description: "Bounded read-only lookups.",
|
||||
Tools: []string{"read_file"},
|
||||
},
|
||||
SystemPrompt: "Find the thing and stop.",
|
||||
ResolvedTools: []string{"read_file"},
|
||||
}}}, nil
|
||||
},
|
||||
// The child is the real exec entry point, run in process with the
|
||||
// arguments the executor built, instead of a fake binary.
|
||||
RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (specialist.ChildRunResult, error) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runWithDeps(args, &stdout, &stderr, deps)
|
||||
childStderr = append(childStderr, stderr.String())
|
||||
events := []streamjson.Event{}
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
if line = strings.TrimSpace(line); line == "" {
|
||||
continue
|
||||
}
|
||||
var event streamjson.Event
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("child stream-json line %q: %v", line, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return specialist.ChildRunResult{Events: events, Stderr: stderr.String(), ExitCode: code, Started: true}, nil
|
||||
},
|
||||
}
|
||||
options := specialist.TaskRunOptions{ParentSessionID: parent.SessionID, ToolCallID: "call_1", Cwd: cwd}
|
||||
|
||||
fresh, err := executor.Run(context.Background(), specialist.TaskParameters{Name: "skim", Prompt: "find the config"}, options)
|
||||
if err != nil || fresh.Result.Status != tools.StatusOK {
|
||||
t.Fatalf("SETUP: fresh Task run = %+v, %v; child stderr %q", fresh.Result, err, childStderr)
|
||||
}
|
||||
child, err := store.Get("child_task")
|
||||
if err != nil || child == nil {
|
||||
t.Fatalf("SETUP: child session = %v, %v", child, err)
|
||||
}
|
||||
// SETUP: the fresh run made exactly the kind of session the resume is about.
|
||||
if child.SessionKind != sessions.SessionKindChild || child.Tag != specialist.SessionTagSpecialist || child.ParentSessionID != parent.SessionID {
|
||||
t.Fatalf("SETUP: child kind %q tag %q parent %q, want child/specialist/%s", child.SessionKind, child.Tag, child.ParentSessionID, parent.SessionID)
|
||||
}
|
||||
|
||||
options.ToolCallID = "call_2"
|
||||
resumed, err := executor.Run(context.Background(), specialist.TaskParameters{Name: "skim", Prompt: "now check the tests", Resume: "child_task"}, options)
|
||||
if err != nil || resumed.Result.Status != tools.StatusOK {
|
||||
t.Fatalf("Task resume = %+v, %v; child stderr %q", resumed.Result, err, childStderr)
|
||||
}
|
||||
provider.mu.Lock()
|
||||
prompt := provider.prompt
|
||||
provider.mu.Unlock()
|
||||
if !strings.Contains(prompt, "Continuing Zero session child_task.") || !strings.Contains(prompt, "now check the tests") {
|
||||
t.Fatalf("resumed child prompt = %q, want the child's session continued with the follow-up", prompt)
|
||||
}
|
||||
sessionsAfter, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessionsAfter) != 2 {
|
||||
t.Fatalf("sessions after resume = %d, want the parent and the one child, not a new session", len(sessionsAfter))
|
||||
}
|
||||
|
||||
// The child is still not resumable by hand, or from another session.
|
||||
other, err := store.Create(sessions.CreateInput{SessionID: "other_parent", Cwd: cwd})
|
||||
if err != nil {
|
||||
t.Fatalf("create other parent: %v", err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"exec", "--resume", "child_task", "keep going"},
|
||||
{"exec", "--resume", "child_task", "--tag", "specialist", "--calling-session-id", other.SessionID, "keep going"},
|
||||
} {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := runWithDeps(args, &stdout, &stderr, deps)
|
||||
if code == exitSuccess || !strings.Contains(stderr.String(), "Zero session is not resumable: child_task") {
|
||||
t.Errorf("%q exit=%d stderr=%q, want the not-resumable refusal", args, code, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A child session stays out of user-facing resume, but the run that started it
|
||||
// can continue it: the Task tool resumes a specialist through `zero exec
|
||||
// --resume`, naming the child's parent as its calling session and passing the
|
||||
// child's tag. Every other combination is refused as before.
|
||||
func TestPrepareExecResumesAChildOnlyForItsOwner(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir()})
|
||||
for _, input := range []CreateInput{
|
||||
{SessionID: "parent"},
|
||||
{SessionID: "other_parent"},
|
||||
{SessionID: "child_task", SessionKind: SessionKindChild, ParentSessionID: "parent", Tag: "specialist", Depth: 1},
|
||||
{SessionID: "side_task", SessionKind: SessionKindSide, ParentSessionID: "parent", Tag: "specialist"},
|
||||
} {
|
||||
if _, err := store.Create(input); err != nil {
|
||||
t.Fatalf("create %s: %v", input.SessionID, err)
|
||||
}
|
||||
}
|
||||
if _, err := store.AppendEvent("child_task", AppendEventInput{Type: EventMessage, Payload: map[string]any{"role": "assistant", "content": "found the config"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prepared, err := PrepareExec(PrepareExecOptions{Store: store, Resume: "child_task", CallingSessionID: "parent", Tag: "specialist"})
|
||||
if err != nil {
|
||||
t.Fatalf("owner resume of a child: %v", err)
|
||||
}
|
||||
if prepared.Mode != ModeResume || prepared.Session.SessionID != "child_task" || len(prepared.ContextEvents) != 1 {
|
||||
t.Fatalf("owner resume = mode %q session %q with %d context events, want the child resumed with its history", prepared.Mode, prepared.Session.SessionID, len(prepared.ContextEvents))
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
options PrepareExecOptions
|
||||
}{
|
||||
{name: "plain resume", options: PrepareExecOptions{Resume: "child_task"}},
|
||||
{name: "tag without a caller", options: PrepareExecOptions{Resume: "child_task", Tag: "specialist"}},
|
||||
{name: "caller without the tag", options: PrepareExecOptions{Resume: "child_task", CallingSessionID: "parent"}},
|
||||
{name: "another parent", options: PrepareExecOptions{Resume: "child_task", CallingSessionID: "other_parent", Tag: "specialist"}},
|
||||
{name: "another tag", options: PrepareExecOptions{Resume: "child_task", CallingSessionID: "parent", Tag: "spec"}},
|
||||
{name: "side session", options: PrepareExecOptions{Resume: "side_task", CallingSessionID: "parent", Tag: "specialist"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
options := tc.options
|
||||
options.Store = store
|
||||
_, err := PrepareExec(options)
|
||||
if err == nil || !strings.Contains(err.Error(), "Zero session is not resumable: "+options.Resume) {
|
||||
t.Fatalf("PrepareExec(%+v) error = %v, want the not-resumable refusal", tc.options, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
|
||||
if session == nil {
|
||||
return PreparedExec{}, ExecError{"Zero session not found: " + sessionID}
|
||||
}
|
||||
if !IsResumableKind(session.SessionKind) {
|
||||
if !ExecResumable(*session, options.CallingSessionID, options.Tag) {
|
||||
return PreparedExec{}, ExecError{"Zero session is not resumable: " + sessionID}
|
||||
}
|
||||
contextEvents, err := readExecContextEvents(store, session.SessionID)
|
||||
@@ -151,6 +151,29 @@ func PrepareExec(options PrepareExecOptions) (PreparedExec, error) {
|
||||
return PreparedExec{Mode: ModeNew, Session: session, ContextEvents: []Event{}, Store: store}, nil
|
||||
}
|
||||
|
||||
// ExecResumable reports whether `zero exec --resume` may continue session when
|
||||
// it runs with the given --calling-session-id and --tag. The exec preflight and
|
||||
// PrepareExec both ask this, so they cannot disagree.
|
||||
//
|
||||
// Any kind IsResumableKind accepts is resumable. A child session is hidden from
|
||||
// user-facing resume, but the Task tool continues a specialist by resuming the
|
||||
// child it created, so refusing every child made Task resume fail every time.
|
||||
// A child is resumable only when the resume names the child's recorded parent
|
||||
// as its calling session and carries the child's own tag. The Task tool passes
|
||||
// both. A plain `zero exec --resume <id>` passes neither and is still refused,
|
||||
// and side sessions such as /btw are never children, so they stay out too.
|
||||
func ExecResumable(session Metadata, callingSessionID string, tag string) bool {
|
||||
if IsResumableKind(session.SessionKind) {
|
||||
return true
|
||||
}
|
||||
if session.SessionKind != SessionKindChild {
|
||||
return false
|
||||
}
|
||||
parent := strings.TrimSpace(callingSessionID)
|
||||
tag = strings.TrimSpace(tag)
|
||||
return parent != "" && parent == session.ParentSessionID && tag != "" && tag == session.Tag
|
||||
}
|
||||
|
||||
func readExecContextEvents(store *Store, sessionID string) ([]Event, error) {
|
||||
// The session was picked from its metadata a moment ago. Make sure it is
|
||||
// still there, and held, before reading it to continue: see HoldToContinue.
|
||||
|
||||
@@ -151,6 +151,11 @@ type BuildResumeArgsInput struct {
|
||||
ParentModel string
|
||||
ParentReasoningEffort string
|
||||
PermissionMode string
|
||||
// ParentSessionID is the session resuming the child. exec resumes a child
|
||||
// session only for its recorded parent (sessions.PrepareExec), so without
|
||||
// it every Task resume is refused as "not resumable".
|
||||
ParentSessionID string
|
||||
ParentToolUseID string
|
||||
}
|
||||
|
||||
type BuildArgsResult struct {
|
||||
@@ -440,6 +445,12 @@ func (executor Executor) BuildResumeArgs(input BuildResumeArgsInput) (BuildArgsR
|
||||
}
|
||||
args = append(args, "--enabled-tools", strings.Join(toolAllowlist, ","))
|
||||
args = append(args, "--depth", strconv.Itoa(input.CurrentDepth+1), "--tag", sessionTagSpecialist)
|
||||
if parentSessionID := strings.TrimSpace(input.ParentSessionID); parentSessionID != "" {
|
||||
args = append(args, "--calling-session-id", parentSessionID)
|
||||
}
|
||||
if parentToolUseID := strings.TrimSpace(input.ParentToolUseID); parentToolUseID != "" {
|
||||
args = append(args, "--calling-tool-use-id", parentToolUseID)
|
||||
}
|
||||
if cwd := strings.TrimSpace(input.Cwd); cwd != "" {
|
||||
args = append(args, "--cwd", cwd)
|
||||
}
|
||||
@@ -513,6 +524,8 @@ func (executor Executor) runResume(ctx context.Context, params TaskParameters, o
|
||||
ParentModel: options.ParentModel,
|
||||
ParentReasoningEffort: options.ParentReasoningEffort,
|
||||
PermissionMode: options.PermissionMode,
|
||||
ParentSessionID: options.ParentSessionID,
|
||||
ParentToolUseID: options.ToolCallID,
|
||||
})
|
||||
if err != nil {
|
||||
return ExecResult{}, err
|
||||
|
||||
Reference in new issue
Block a user