diff --git a/internal/sessions/goal.go b/internal/sessions/goal.go new file mode 100644 index 000000000..4aacafddb --- /dev/null +++ b/internal/sessions/goal.go @@ -0,0 +1,422 @@ +package sessions + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +const ( + GoalObjectiveMaxLength = 4_000 + GoalMaxConsecutiveContinuations = 20 + goalContinuationLimitStatusReason = "automatic continuation limit reached" +) + +func validateGoalObjective(objective string) (string, error) { + objective = strings.TrimSpace(objective) + if objective == "" { + return "", fmt.Errorf("goal objective is required") + } + if utf8.RuneCountInString(objective) > GoalObjectiveMaxLength { + return "", fmt.Errorf("goal objective cannot exceed %d characters", GoalObjectiveMaxLength) + } + return objective, nil +} + +// CreateGoal persists a new active goal for a session. It refuses to replace an +// existing goal so callers must make replacement an explicit user decision. +func (store *Store) CreateGoal(sessionID, objective string, tokenBudget int) (Metadata, Event, error) { + if !ValidSessionID(sessionID) { + return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID) + } + var err error + objective, err = validateGoalObjective(objective) + if err != nil { + return Metadata{}, Event{}, err + } + if tokenBudget < 0 { + return Metadata{}, Event{}, fmt.Errorf("goal token budget cannot be negative") + } + + unlock, err := store.lockSession(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + defer unlock() + + session, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + if session.Goal != nil { + return Metadata{}, Event{}, fmt.Errorf("session already has a goal") + } + now := store.timestamp() + session.Goal = &Goal{ + Objective: objective, + Status: GoalStatusActive, + TokenBudget: tokenBudget, + ContinuationLimit: GoalMaxConsecutiveContinuations, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.writeMetadata(session); err != nil { + return Metadata{}, Event{}, err + } + event, err := store.appendEventLocked(sessionID, AppendEventInput{ + Type: EventGoalCreated, + Payload: session.Goal, + }) + if err != nil { + return Metadata{}, Event{}, err + } + loaded, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + return loaded, event, nil +} + +// UpdateGoal changes a goal's lifecycle state. Terminal goals remain available +// for status/history until the user explicitly clears them. +func (store *Store) UpdateGoal(sessionID string, status GoalStatus, reason string) (Metadata, Event, error) { + if !ValidSessionID(sessionID) { + return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID) + } + if !validGoalStatus(status) { + return Metadata{}, Event{}, fmt.Errorf("invalid goal status %q", status) + } + + unlock, err := store.lockSession(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + defer unlock() + + session, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + if session.Goal == nil { + return Metadata{}, Event{}, fmt.Errorf("session has no goal") + } + session.Goal.Status = status + session.Goal.StatusReason = strings.TrimSpace(reason) + if status == GoalStatusActive { + session.Goal.ContinuationCount = 0 + if session.Goal.ContinuationLimit <= 0 { + session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations + } + } + session.Goal.UpdatedAt = store.timestamp() + if err := store.writeMetadata(session); err != nil { + return Metadata{}, Event{}, err + } + event, err := store.appendEventLocked(sessionID, AppendEventInput{ + Type: EventGoalUpdated, + Payload: session.Goal, + }) + if err != nil { + return Metadata{}, Event{}, err + } + loaded, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + return loaded, event, nil +} + +// EditGoal replaces the objective and optional budget without resetting usage or +// lifecycle timestamps. User-facing command handling owns confirmation. +func (store *Store) EditGoal(sessionID, objective string, tokenBudget int) (Metadata, Event, error) { + if !ValidSessionID(sessionID) { + return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID) + } + var err error + objective, err = validateGoalObjective(objective) + if err != nil { + return Metadata{}, Event{}, err + } + if tokenBudget < 0 { + return Metadata{}, Event{}, fmt.Errorf("goal token budget cannot be negative") + } + unlock, err := store.lockSession(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + defer unlock() + + session, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + if session.Goal == nil { + return Metadata{}, Event{}, fmt.Errorf("session has no goal") + } + session.Goal.Objective = objective + session.Goal.TokenBudget = tokenBudget + session.Goal.Status = GoalStatusActive + session.Goal.StatusReason = "" + session.Goal.ContinuationCount = 0 + if session.Goal.ContinuationLimit <= 0 { + session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations + } + if tokenBudget > 0 && session.Goal.TokensUsed >= tokenBudget { + session.Goal.Status = GoalStatusBudgetLimited + session.Goal.StatusReason = "token budget reached" + } + session.Goal.UpdatedAt = store.timestamp() + if err := store.writeMetadata(session); err != nil { + return Metadata{}, Event{}, err + } + event, err := store.appendEventLocked(sessionID, AppendEventInput{ + Type: EventGoalUpdated, + Payload: session.Goal, + }) + if err != nil { + return Metadata{}, Event{}, err + } + loaded, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + return loaded, event, nil +} + +// ResetGoalContinuations starts a fresh autonomous-run allowance after explicit +// user input. It makes the safety bound consecutive rather than lifetime-wide. +func (store *Store) ResetGoalContinuations(sessionID string) (Metadata, error) { + if !ValidSessionID(sessionID) { + return Metadata{}, fmt.Errorf("invalid zero session id %q", sessionID) + } + unlock, err := store.lockSession(sessionID) + if err != nil { + return Metadata{}, err + } + defer unlock() + + session, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, err + } + if session.Goal == nil || session.Goal.Status != GoalStatusActive { + return session, nil + } + changed := false + if session.Goal.ContinuationCount != 0 { + session.Goal.ContinuationCount = 0 + changed = true + } + if session.Goal.ContinuationLimit <= 0 { + session.Goal.ContinuationLimit = GoalMaxConsecutiveContinuations + changed = true + } + if !changed { + return session, nil + } + session.Goal.UpdatedAt = store.timestamp() + if err := store.writeMetadata(session); err != nil { + return Metadata{}, err + } + return session, nil +} + +// ReserveGoalContinuation atomically reserves one automatic continuation. Once +// the persisted consecutive-run limit is exhausted it pauses the goal before +// another provider request can start, even when the provider reports no usage. +func (store *Store) ReserveGoalContinuation(sessionID string) (Metadata, *Event, bool, error) { + if !ValidSessionID(sessionID) { + return Metadata{}, nil, false, fmt.Errorf("invalid zero session id %q", sessionID) + } + unlock, err := store.lockSession(sessionID) + if err != nil { + return Metadata{}, nil, false, err + } + defer unlock() + + session, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, nil, false, err + } + if session.Goal == nil || session.Goal.Status != GoalStatusActive { + return session, nil, false, nil + } + limit := session.Goal.ContinuationLimit + if limit <= 0 { + limit = GoalMaxConsecutiveContinuations + session.Goal.ContinuationLimit = limit + } + if session.Goal.ContinuationCount < limit { + session.Goal.ContinuationCount++ + session.Goal.UpdatedAt = store.timestamp() + if err := store.writeMetadata(session); err != nil { + return Metadata{}, nil, false, err + } + return session, nil, true, nil + } + + session.Goal.Status = GoalStatusPaused + session.Goal.StatusReason = goalContinuationLimitStatusReason + session.Goal.UpdatedAt = store.timestamp() + if err := store.writeMetadata(session); err != nil { + return Metadata{}, nil, false, err + } + event, err := store.appendEventLocked(sessionID, AppendEventInput{ + Type: EventGoalUpdated, + Payload: session.Goal, + }) + if err != nil { + return Metadata{}, nil, false, err + } + loaded, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, nil, false, err + } + return loaded, &event, false, nil +} + +// AddGoalUsage accounts tokens consumed while a goal is active. Reaching the +// optional budget pauses the goal before another autonomous turn can start. +func (store *Store) AddGoalUsage(sessionID string, tokens int) (Metadata, *Event, error) { + if !ValidSessionID(sessionID) { + return Metadata{}, nil, fmt.Errorf("invalid zero session id %q", sessionID) + } + if tokens < 0 { + return Metadata{}, nil, fmt.Errorf("goal token usage cannot be negative") + } + unlock, err := store.lockSession(sessionID) + if err != nil { + return Metadata{}, nil, err + } + defer unlock() + + session, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, nil, err + } + if session.Goal == nil || tokens == 0 { + return session, nil, nil + } + session.Goal.TokensUsed += tokens + session.Goal.UpdatedAt = store.timestamp() + budgetReached := false + if session.Goal.Status == GoalStatusActive && + session.Goal.TokenBudget > 0 && + session.Goal.TokensUsed >= session.Goal.TokenBudget { + session.Goal.Status = GoalStatusBudgetLimited + session.Goal.StatusReason = "token budget reached" + budgetReached = true + } + if err := store.writeMetadata(session); err != nil { + return Metadata{}, nil, err + } + if !budgetReached { + return session, nil, nil + } + event, err := store.appendEventLocked(sessionID, AppendEventInput{ + Type: EventGoalUpdated, + Payload: session.Goal, + }) + if err != nil { + return Metadata{}, nil, err + } + loaded, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, nil, err + } + return loaded, &event, nil +} + +// PauseGoalIfActive pauses a goal only while it is still active. It is used by +// cancellation paths where an in-flight agent may have completed or blocked the +// goal immediately before the cancellation reached the runtime. +func (store *Store) PauseGoalIfActive(sessionID, reason string) (Metadata, *Event, error) { + if !ValidSessionID(sessionID) { + return Metadata{}, nil, fmt.Errorf("invalid zero session id %q", sessionID) + } + unlock, err := store.lockSession(sessionID) + if err != nil { + return Metadata{}, nil, err + } + defer unlock() + + session, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, nil, err + } + if session.Goal == nil || session.Goal.Status != GoalStatusActive { + return session, nil, nil + } + session.Goal.Status = GoalStatusPaused + session.Goal.StatusReason = strings.TrimSpace(reason) + session.Goal.UpdatedAt = store.timestamp() + if err := store.writeMetadata(session); err != nil { + return Metadata{}, nil, err + } + event, err := store.appendEventLocked(sessionID, AppendEventInput{ + Type: EventGoalUpdated, + Payload: session.Goal, + }) + if err != nil { + return Metadata{}, nil, err + } + loaded, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, nil, err + } + return loaded, &event, nil +} + +// ClearGoal removes the current goal while preserving an audit event. +func (store *Store) ClearGoal(sessionID string) (Metadata, Event, error) { + if !ValidSessionID(sessionID) { + return Metadata{}, Event{}, fmt.Errorf("invalid zero session id %q", sessionID) + } + unlock, err := store.lockSession(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + defer unlock() + + session, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + if session.Goal == nil { + return Metadata{}, Event{}, fmt.Errorf("session has no goal") + } + cleared := cloneGoal(session.Goal) + session.Goal = nil + if err := store.writeMetadata(session); err != nil { + return Metadata{}, Event{}, err + } + event, err := store.appendEventLocked(sessionID, AppendEventInput{ + Type: EventGoalCleared, + Payload: cleared, + }) + if err != nil { + return Metadata{}, Event{}, err + } + loaded, err := store.readMetadata(sessionID) + if err != nil { + return Metadata{}, Event{}, err + } + return loaded, event, nil +} + +func validGoalStatus(status GoalStatus) bool { + switch status { + case GoalStatusActive, GoalStatusPaused, GoalStatusBlocked, GoalStatusBudgetLimited, GoalStatusUsageLimited, GoalStatusComplete: + return true + default: + return false + } +} + +func cloneGoal(goal *Goal) *Goal { + if goal == nil { + return nil + } + copy := *goal + return © +} diff --git a/internal/sessions/goal_test.go b/internal/sessions/goal_test.go new file mode 100644 index 000000000..e2c979b2c --- /dev/null +++ b/internal/sessions/goal_test.go @@ -0,0 +1,382 @@ +package sessions + +import ( + "os" + "strings" + "testing" + "time" +) + +func TestGoalLifecyclePersistsInSessionMetadata(t *testing.T) { + now := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC) + store := NewStore(StoreOptions{ + RootDir: t.TempDir(), + Now: func() time.Time { + now = now.Add(time.Second) + return now + }, + }) + session, err := store.Create(CreateInput{SessionID: "goal_session"}) + if err != nil { + t.Fatal(err) + } + + created, event, err := store.CreateGoal(session.SessionID, "Ship the release", 1_000) + if err != nil { + t.Fatal(err) + } + if event.Type != EventGoalCreated { + t.Fatalf("create event = %q, want %q", event.Type, EventGoalCreated) + } + if created.Goal == nil || created.Goal.Objective != "Ship the release" || + created.Goal.Status != GoalStatusActive || created.Goal.TokenBudget != 1_000 { + t.Fatalf("created goal = %#v", created.Goal) + } + + accounted, _, err := store.AddGoalUsage(session.SessionID, 250) + if err != nil { + t.Fatal(err) + } + if accounted.Goal.TokensUsed != 250 || accounted.Goal.Status != GoalStatusActive { + t.Fatalf("accounted goal = %#v", accounted.Goal) + } + + paused, event, err := store.UpdateGoal(session.SessionID, GoalStatusPaused, "user interrupted") + if err != nil { + t.Fatal(err) + } + if event.Type != EventGoalUpdated || paused.Goal.Status != GoalStatusPaused || + paused.Goal.StatusReason != "user interrupted" { + t.Fatalf("paused goal/event = %#v / %#v", paused.Goal, event) + } + + loaded, err := store.Get(session.SessionID) + if err != nil { + t.Fatal(err) + } + if loaded == nil || loaded.Goal == nil || loaded.Goal.Status != GoalStatusPaused { + t.Fatalf("reloaded session = %#v", loaded) + } + + cleared, event, err := store.ClearGoal(session.SessionID) + if err != nil { + t.Fatal(err) + } + if event.Type != EventGoalCleared || cleared.Goal != nil { + t.Fatalf("cleared goal/event = %#v / %#v", cleared.Goal, event) + } +} + +func TestGoalBudgetPausesAtLimit(t *testing.T) { + store := NewStore(StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(CreateInput{SessionID: "budget_session"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := store.CreateGoal(session.SessionID, "Stay bounded", 100); err != nil { + t.Fatal(err) + } + + updated, event, err := store.AddGoalUsage(session.SessionID, 100) + if err != nil { + t.Fatal(err) + } + if updated.Goal.Status != GoalStatusBudgetLimited || updated.Goal.StatusReason != "token budget reached" { + t.Fatalf("budgeted goal = %#v", updated.Goal) + } + if event == nil || event.Type != EventGoalUpdated { + t.Fatalf("budget transition event = %#v", event) + } +} + +func TestGoalContinuationLimitStopsWithoutProviderUsage(t *testing.T) { + store := NewStore(StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(CreateInput{SessionID: "continuation_limit"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := store.CreateGoal(session.SessionID, "Stay bounded without usage events", 0); err != nil { + t.Fatal(err) + } + + for continuation := 1; continuation <= GoalMaxConsecutiveContinuations; continuation++ { + updated, event, reserved, err := store.ReserveGoalContinuation(session.SessionID) + if err != nil { + t.Fatal(err) + } + if !reserved || event != nil { + t.Fatalf("continuation %d: reserved=%v event=%#v", continuation, reserved, event) + } + if updated.Goal.ContinuationCount != continuation { + t.Fatalf("continuation count = %d, want %d", updated.Goal.ContinuationCount, continuation) + } + if updated.Goal.TokensUsed != 0 { + t.Fatalf("provider-independent guard unexpectedly recorded tokens: %#v", updated.Goal) + } + } + + stopped, event, reserved, err := store.ReserveGoalContinuation(session.SessionID) + if err != nil { + t.Fatal(err) + } + if reserved || event == nil || stopped.Goal.Status != GoalStatusPaused || + stopped.Goal.StatusReason != goalContinuationLimitStatusReason { + t.Fatalf("unbounded continuation was not stopped: goal=%#v event=%#v reserved=%v", stopped.Goal, event, reserved) + } + reloaded, err := store.Get(session.SessionID) + if err != nil { + t.Fatal(err) + } + if reloaded.Goal.ContinuationCount != GoalMaxConsecutiveContinuations || + reloaded.Goal.Status != GoalStatusPaused { + t.Fatalf("continuation guard did not persist: %#v", reloaded.Goal) + } + + resumed, _, err := store.UpdateGoal(session.SessionID, GoalStatusActive, "") + if err != nil { + t.Fatal(err) + } + if resumed.Goal.ContinuationCount != 0 { + t.Fatalf("explicit resume did not reset consecutive continuations: %#v", resumed.Goal) + } +} + +func TestResetGoalContinuationsPersistsOnlyActiveChanges(t *testing.T) { + newStore := func(t *testing.T) (*Store, Metadata) { + t.Helper() + now := time.Date(2026, 7, 26, 10, 0, 0, 0, time.UTC) + store := NewStore(StoreOptions{ + RootDir: t.TempDir(), + Now: func() time.Time { + now = now.Add(time.Second) + return now + }, + }) + session, err := store.Create(CreateInput{SessionID: "reset_goal"}) + if err != nil { + t.Fatal(err) + } + return store, session + } + pinMetadataTime := func(t *testing.T, store *Store, sessionID string) time.Time { + t.Helper() + path := store.metadataPath(sessionID) + sentinel := time.Date(2001, 2, 3, 4, 5, 6, 0, time.UTC) + if err := os.Chtimes(path, sentinel, sentinel); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info.ModTime() + } + assertMetadataTime := func(t *testing.T, store *Store, sessionID string, want time.Time) { + t.Helper() + info, err := os.Stat(store.metadataPath(sessionID)) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(want) { + t.Fatalf("metadata was rewritten: modtime=%s want=%s", info.ModTime(), want) + } + } + + t.Run("no goal is a no-op", func(t *testing.T) { + store, session := newStore(t) + modTime := pinMetadataTime(t, store, session.SessionID) + updated, err := store.ResetGoalContinuations(session.SessionID) + if err != nil { + t.Fatal(err) + } + if updated.Goal != nil { + t.Fatalf("no-goal reset created a goal: %#v", updated.Goal) + } + assertMetadataTime(t, store, session.SessionID, modTime) + }) + + t.Run("inactive goal is a no-op", func(t *testing.T) { + store, session := newStore(t) + if _, _, err := store.CreateGoal(session.SessionID, "Pause safely", 0); err != nil { + t.Fatal(err) + } + if _, _, reserved, err := store.ReserveGoalContinuation(session.SessionID); err != nil || !reserved { + t.Fatalf("reserve continuation: reserved=%v err=%v", reserved, err) + } + paused, _, err := store.UpdateGoal(session.SessionID, GoalStatusPaused, "user paused") + if err != nil { + t.Fatal(err) + } + modTime := pinMetadataTime(t, store, session.SessionID) + updated, err := store.ResetGoalContinuations(session.SessionID) + if err != nil { + t.Fatal(err) + } + if updated.Goal.ContinuationCount != 1 || updated.Goal.UpdatedAt != paused.Goal.UpdatedAt { + t.Fatalf("inactive goal changed during reset: before=%#v after=%#v", paused.Goal, updated.Goal) + } + assertMetadataTime(t, store, session.SessionID, modTime) + }) + + t.Run("already reset active goal is a no-op", func(t *testing.T) { + store, session := newStore(t) + created, _, err := store.CreateGoal(session.SessionID, "Stay reset", 0) + if err != nil { + t.Fatal(err) + } + modTime := pinMetadataTime(t, store, session.SessionID) + updated, err := store.ResetGoalContinuations(session.SessionID) + if err != nil { + t.Fatal(err) + } + if updated.Goal.ContinuationCount != 0 || + updated.Goal.ContinuationLimit != GoalMaxConsecutiveContinuations || + updated.Goal.UpdatedAt != created.Goal.UpdatedAt { + t.Fatalf("already-reset goal changed: before=%#v after=%#v", created.Goal, updated.Goal) + } + assertMetadataTime(t, store, session.SessionID, modTime) + }) + + t.Run("active goal reset persists", func(t *testing.T) { + store, session := newStore(t) + if _, _, err := store.CreateGoal(session.SessionID, "Reset progress", 0); err != nil { + t.Fatal(err) + } + reserved, _, ok, err := store.ReserveGoalContinuation(session.SessionID) + if err != nil || !ok { + t.Fatalf("reserve continuation: reserved=%v err=%v", ok, err) + } + updated, err := store.ResetGoalContinuations(session.SessionID) + if err != nil { + t.Fatal(err) + } + if updated.Goal.ContinuationCount != 0 || + updated.Goal.ContinuationLimit != GoalMaxConsecutiveContinuations || + updated.Goal.UpdatedAt == reserved.Goal.UpdatedAt { + t.Fatalf("active reset did not persist: before=%#v after=%#v", reserved.Goal, updated.Goal) + } + reloaded, err := store.Get(session.SessionID) + if err != nil { + t.Fatal(err) + } + if reloaded.Goal == nil || *reloaded.Goal != *updated.Goal { + t.Fatalf("active reset was not durable: updated=%#v reloaded=%#v", updated.Goal, reloaded.Goal) + } + }) +} + +func TestGoalObjectiveLengthIsBounded(t *testing.T) { + store := NewStore(StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(CreateInput{SessionID: "objective_limit"}) + if err != nil { + t.Fatal(err) + } + tooLong := strings.Repeat("g", GoalObjectiveMaxLength+1) + if _, _, err := store.CreateGoal(session.SessionID, tooLong, 0); err == nil || + !strings.Contains(err.Error(), "cannot exceed") { + t.Fatalf("oversized objective error = %v", err) + } +} + +func TestEditGoalUpdatesStateAndRejectsInvalidInputWithoutMutation(t *testing.T) { + store := NewStore(StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(CreateInput{SessionID: "edit_goal"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := store.CreateGoal(session.SessionID, "Original objective", 1_000); err != nil { + t.Fatal(err) + } + if _, _, err := store.AddGoalUsage(session.SessionID, 250); err != nil { + t.Fatal(err) + } + + edited, event, err := store.EditGoal(session.SessionID, "Updated objective", 500) + if err != nil { + t.Fatal(err) + } + if event.Type != EventGoalUpdated { + t.Fatalf("edit event = %q, want %q", event.Type, EventGoalUpdated) + } + if edited.Goal == nil || + edited.Goal.Objective != "Updated objective" || + edited.Goal.TokenBudget != 500 || + edited.Goal.TokensUsed != 250 || + edited.Goal.Status != GoalStatusActive || + edited.Goal.StatusReason != "" { + t.Fatalf("edited goal = %#v", edited.Goal) + } + + limited, _, err := store.EditGoal(session.SessionID, "Stay within budget", 200) + if err != nil { + t.Fatal(err) + } + if limited.Goal == nil || + limited.Goal.Status != GoalStatusBudgetLimited || + limited.Goal.StatusReason != "token budget reached" || + limited.Goal.TokensUsed != 250 { + t.Fatalf("budget-limited goal = %#v", limited.Goal) + } + + before := *limited.Goal + eventsBefore, err := store.ReadEvents(session.SessionID) + if err != nil { + t.Fatal(err) + } + if _, _, err := store.EditGoal(session.SessionID, " ", 200); err == nil { + t.Fatal("EditGoal should reject an empty objective") + } + if _, _, err := store.EditGoal(session.SessionID, "Invalid budget", -1); err == nil { + t.Fatal("EditGoal should reject a negative token budget") + } + after, err := store.Get(session.SessionID) + if err != nil { + t.Fatal(err) + } + if after == nil || after.Goal == nil || *after.Goal != before { + t.Fatalf("invalid edit mutated goal: before=%#v after=%#v", before, after) + } + eventsAfter, err := store.ReadEvents(session.SessionID) + if err != nil { + t.Fatal(err) + } + if len(eventsAfter) != len(eventsBefore) { + t.Fatalf("invalid edit appended events: before=%d after=%d", len(eventsBefore), len(eventsAfter)) + } +} + +func TestCreateGoalRefusesImplicitReplacement(t *testing.T) { + store := NewStore(StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(CreateInput{SessionID: "replace_session"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := store.CreateGoal(session.SessionID, "First", 0); err != nil { + t.Fatal(err) + } + if _, _, err := store.CreateGoal(session.SessionID, "Second", 0); err == nil { + t.Fatal("CreateGoal should require an explicit clear before replacement") + } +} + +func TestPauseGoalIfActiveDoesNotOverwriteTerminalState(t *testing.T) { + store := NewStore(StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(CreateInput{SessionID: "terminal_session"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := store.CreateGoal(session.SessionID, "Finish", 0); err != nil { + t.Fatal(err) + } + if _, _, err := store.UpdateGoal(session.SessionID, GoalStatusComplete, ""); err != nil { + t.Fatal(err) + } + + updated, event, err := store.PauseGoalIfActive(session.SessionID, "cancelled") + if err != nil { + t.Fatal(err) + } + if event != nil || updated.Goal.Status != GoalStatusComplete { + t.Fatalf("terminal goal changed during cancellation: goal=%#v event=%#v", updated.Goal, event) + } +} diff --git a/internal/sessions/store.go b/internal/sessions/store.go index 61039d4f4..0c4ac7050 100644 --- a/internal/sessions/store.go +++ b/internal/sessions/store.go @@ -45,6 +45,9 @@ const ( EventSpecDraft EventType = "spec_draft" EventSpecApproved EventType = "spec_approved" EventSpecRejected EventType = "spec_rejected" + EventGoalCreated EventType = "goal_created" + EventGoalUpdated EventType = "goal_updated" + EventGoalCleared EventType = "goal_cleared" ) type SessionKind string @@ -65,6 +68,29 @@ const ( SpecStatusRejected SpecStatus = "rejected" ) +type GoalStatus string + +const ( + GoalStatusActive GoalStatus = "active" + GoalStatusPaused GoalStatus = "paused" + GoalStatusBlocked GoalStatus = "blocked" + GoalStatusBudgetLimited GoalStatus = "budget_limited" + GoalStatusUsageLimited GoalStatus = "usage_limited" + GoalStatusComplete GoalStatus = "complete" +) + +type Goal struct { + Objective string `json:"objective"` + Status GoalStatus `json:"status"` + StatusReason string `json:"statusReason,omitempty"` + TokenBudget int `json:"tokenBudget,omitempty"` + TokensUsed int `json:"tokensUsed,omitempty"` + ContinuationCount int `json:"continuationCount,omitempty"` + ContinuationLimit int `json:"continuationLimit,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + type Metadata struct { SessionID string `json:"sessionId"` SessionKind SessionKind `json:"sessionKind,omitempty"` @@ -91,6 +117,7 @@ type Metadata struct { SpecRejectReason string `json:"specRejectReason,omitempty"` SpecSourceSessionID string `json:"specSourceSessionId,omitempty"` SpecImplSessionID string `json:"specImplSessionId,omitempty"` + Goal *Goal `json:"goal,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` EventCount int `json:"eventCount"` @@ -123,6 +150,7 @@ type CreateInput struct { SpecRejectReason string SpecSourceSessionID string SpecImplSessionID string + Goal *Goal } type ForkInput struct { @@ -279,6 +307,7 @@ func (store *Store) Create(input CreateInput) (Metadata, error) { SpecRejectReason: strings.TrimSpace(input.SpecRejectReason), SpecSourceSessionID: strings.TrimSpace(input.SpecSourceSessionID), SpecImplSessionID: strings.TrimSpace(input.SpecImplSessionID), + Goal: cloneGoal(input.Goal), CreatedAt: timestamp, UpdatedAt: timestamp, EventCount: 0, diff --git a/internal/tools/goal.go b/internal/tools/goal.go new file mode 100644 index 000000000..fc0adb6ee --- /dev/null +++ b/internal/tools/goal.go @@ -0,0 +1,187 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/Gitlawb/zero/internal/sessions" +) + +type goalTool struct { + baseTool + store *sessions.Store + sessionID string + action string +} + +// NewGoalTools returns goal lifecycle tools bound to one captured session. The +// binding prevents a late tool call from mutating a different session after a +// resume or session switch. +func NewGoalTools(store *sessions.Store, sessionID string) []Tool { + return []Tool{ + newGetGoalTool(store, sessionID), + newCreateGoalTool(store, sessionID), + newUpdateGoalTool(store, sessionID), + } +} + +func newGetGoalTool(store *sessions.Store, sessionID string) Tool { + return &goalTool{ + baseTool: baseTool{ + name: "get_goal", + description: "Get the current persistent goal and its status, token budget, and usage for this session.", + parameters: Schema{ + Type: "object", + AdditionalProperties: false, + }, + safety: readOnlySafety("Reads goal state for the current session."), + capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true}, + }, + store: store, + sessionID: sessionID, + action: "get", + } +} + +func newCreateGoalTool(store *sessions.Store, sessionID string) Tool { + minimum := 0 + maximum := 1_000_000_000 + maxObjectiveLength := sessions.GoalObjectiveMaxLength + return &goalTool{ + baseTool: baseTool{ + name: "create_goal", + description: "Create one persistent goal for this session when the user explicitly asks to pursue an ongoing objective. " + + "Do not infer a goal from an ordinary one-turn task. This fails while any goal is still stored.", + parameters: Schema{ + Type: "object", + Properties: map[string]PropertySchema{ + "objective": { + Type: "string", + Description: "The concrete objective to keep pursuing.", + MaxLength: &maxObjectiveLength, + }, + "token_budget": { + Type: "integer", + Description: "Optional maximum total tokens. Zero means no goal-specific limit.", + Minimum: &minimum, + Maximum: &maximum, + }, + }, + Required: []string{"objective"}, + AdditionalProperties: false, + }, + safety: Safety{ + SideEffect: SideEffectWrite, + Permission: PermissionPrompt, + Reason: "Creates persistent goal state and enables automatic follow-up runs for the current session.", + AdvertiseInAuto: true, + }, + capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys}, + }, + store: store, + sessionID: sessionID, + action: "create", + } +} + +func newUpdateGoalTool(store *sessions.Store, sessionID string) Tool { + return &goalTool{ + baseTool: baseTool{ + name: "update_goal", + description: "Finish the current goal or mark it blocked. Use complete only when the objective is achieved and no required work remains. " + + "Use blocked only when progress cannot continue without user input or an external state change.", + parameters: Schema{ + Type: "object", + Properties: map[string]PropertySchema{ + "status": { + Type: "string", + Description: "The terminal state to apply.", + Enum: []string{string(sessions.GoalStatusComplete), string(sessions.GoalStatusBlocked)}, + }, + "reason": { + Type: "string", + Description: "Why the goal is blocked. Optional for completed goals.", + }, + }, + Required: []string{"status"}, + AdditionalProperties: false, + }, + safety: Safety{ + SideEffect: SideEffectNone, + Permission: PermissionAllow, + Reason: "Updates persistent goal state for the current session.", + }, + capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false, ResourceKeys: sessionResourceKeys}, + }, + store: store, + sessionID: sessionID, + action: "update", + } +} + +func (tool *goalTool) Run(_ context.Context, args map[string]any) Result { + if tool.store == nil || !sessions.ValidSessionID(tool.sessionID) { + return errorResult("Error: Goal state is unavailable for this run.") + } + switch tool.action { + case "get": + session, err := tool.store.Get(tool.sessionID) + if err != nil { + return errorResult("Error: Read goal: " + err.Error()) + } + if session == nil || session.Goal == nil { + return okResult("No goal is set for this session.") + } + return goalResult(session.Goal) + case "create": + objective, err := stringArg(args, "objective", "", true) + if err != nil { + return errorResult("Error: Invalid arguments for create_goal: " + err.Error()) + } + tokenBudget, err := intArg(args, "token_budget", 0, 0, 1_000_000_000) + if err != nil { + return errorResult("Error: Invalid arguments for create_goal: " + err.Error()) + } + session, _, err := tool.store.CreateGoal(tool.sessionID, objective, tokenBudget) + if err != nil { + return errorResult("Error: Create goal: " + err.Error()) + } + return goalResult(session.Goal) + case "update": + statusText, err := stringArg(args, "status", "", true) + if err != nil { + return errorResult("Error: Invalid arguments for update_goal: " + err.Error()) + } + status := sessions.GoalStatus(strings.ToLower(statusText)) + if status != sessions.GoalStatusComplete && status != sessions.GoalStatusBlocked { + return errorResult(`Error: Invalid arguments for update_goal: status must be "complete" or "blocked"`) + } + reason, err := stringArgWithEmpty(args, "reason", "", false, true) + if err != nil { + return errorResult("Error: Invalid arguments for update_goal: " + err.Error()) + } + if status == sessions.GoalStatusBlocked && strings.TrimSpace(reason) == "" { + return errorResult("Error: Invalid arguments for update_goal: blocked goals require a reason") + } + session, _, err := tool.store.UpdateGoal(tool.sessionID, status, reason) + if err != nil { + return errorResult("Error: Update goal: " + err.Error()) + } + return goalResult(session.Goal) + default: + return errorResult("Error: Unknown goal action.") + } +} + +func goalResult(goal *sessions.Goal) Result { + if goal == nil { + return okResult("No goal is set for this session.") + } + data, err := json.MarshalIndent(goal, "", " ") + if err != nil { + return errorResult(fmt.Sprintf("Error: Encode goal: %v", err)) + } + return okResult(string(data)) +} diff --git a/internal/tools/goal_test.go b/internal/tools/goal_test.go new file mode 100644 index 000000000..6016de15a --- /dev/null +++ b/internal/tools/goal_test.go @@ -0,0 +1,76 @@ +package tools + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sessions" +) + +func TestGoalToolsAreBoundToTheirSession(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + first, err := store.Create(sessions.CreateInput{SessionID: "first"}) + if err != nil { + t.Fatal(err) + } + second, err := store.Create(sessions.CreateInput{SessionID: "second"}) + if err != nil { + t.Fatal(err) + } + goalTools := NewGoalTools(store, first.SessionID) + + result := goalTools[1].Run(context.Background(), map[string]any{"objective": "Finish first"}) + if result.Status != StatusOK { + t.Fatalf("create result = %#v", result) + } + loadedFirst, _ := store.Get(first.SessionID) + loadedSecond, _ := store.Get(second.SessionID) + if loadedFirst.Goal == nil || loadedFirst.Goal.Objective != "Finish first" { + t.Fatalf("first goal = %#v", loadedFirst.Goal) + } + if loadedSecond.Goal != nil { + t.Fatalf("second goal unexpectedly changed = %#v", loadedSecond.Goal) + } +} + +func TestCreateGoalToolDeclaresTokenBudgetMaximum(t *testing.T) { + create := NewGoalTools(nil, "goal")[1] + maximum := create.Parameters().Properties["token_budget"].Maximum + if maximum == nil || *maximum != 1_000_000_000 { + t.Fatalf("token_budget maximum = %v, want 1000000000", maximum) + } + maxLength := create.Parameters().Properties["objective"].MaxLength + if maxLength == nil || *maxLength != sessions.GoalObjectiveMaxLength { + t.Fatalf("objective maxLength = %v, want %d", maxLength, sessions.GoalObjectiveMaxLength) + } + safety := create.Safety() + if safety.SideEffect != SideEffectWrite || safety.Permission != PermissionPrompt || !safety.AdvertiseInAuto { + t.Fatalf("create_goal safety = %#v, want prompted persistent write advertised in auto mode", safety) + } +} + +func TestUpdateGoalToolRestrictsAgentTransitions(t *testing.T) { + store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()}) + session, err := store.Create(sessions.CreateInput{SessionID: "goal"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := store.CreateGoal(session.SessionID, "Finish it", 0); err != nil { + t.Fatal(err) + } + update := NewGoalTools(store, session.SessionID)[2] + + result := update.Run(context.Background(), map[string]any{"status": "paused"}) + if result.Status != StatusError || !strings.Contains(result.Output, "complete") { + t.Fatalf("paused result = %#v", result) + } + result = update.Run(context.Background(), map[string]any{"status": "blocked"}) + if result.Status != StatusError || !strings.Contains(result.Output, "require a reason") { + t.Fatalf("reasonless blocked result = %#v", result) + } + result = update.Run(context.Background(), map[string]any{"status": "complete"}) + if result.Status != StatusOK { + t.Fatalf("complete result = %#v", result) + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go index d21c1f19b..c0e0533e1 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -83,6 +83,7 @@ type PropertySchema struct { Minimum *int `json:"minimum,omitempty"` Maximum *int `json:"maximum,omitempty"` MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` MinItems *int `json:"minItems,omitempty"` // Properties/Required describe nested object fields (for Type "object" or an // object-typed Items). diff --git a/internal/tui/btw.go b/internal/tui/btw.go index a6b26eae1..5fcb7a4ba 100644 --- a/internal/tui/btw.go +++ b/internal/tui/btw.go @@ -83,6 +83,7 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) { }) } parent.btw = btwState{} + parent.goalContinuationsSuspended = true // A scrollback print that was already scheduled may acknowledge after the // side surface is active. The hidden model does not receive that unscoped // acknowledgement, so clear its print latch and rebuild on return. @@ -175,6 +176,7 @@ func (m model) leaveBTW() (model, tea.Cmd) { } m, _ = m.clearLoopsForSessionSwitch() parent := *m.btw.parent + parent.goalContinuationsSuspended = false parent.btwRunIDSeq = maxInt(parent.btwRunIDSeq, m.runID) parent.btw = btwState{} // A hidden parent completion may have scheduled an unscoped git-sweep result @@ -196,13 +198,15 @@ func (m model) leaveBTW() (model, tea.Cmd) { text: "Returned from the isolated BTW conversation. Its messages were not added to this session.", }) parent.resetFlushFrontier("· returned from btw ·") - return parent, batchCommands(sweepCmd, spinnerCmd) + var goalCmd tea.Cmd + parent, goalCmd = parent.launchGoalContinuationIfReady() + return parent, batchCommands(sweepCmd, spinnerCmd, goalCmd) } func btwCommandUnavailable(command parsedCommand) bool { arg := strings.ToLower(strings.TrimSpace(command.text)) switch command.kind { - case commandNew, commandResume, commandRetitle, commandSpec, commandLoop, + case commandNew, commandResume, commandRetitle, commandSpec, commandLoop, commandGoal, commandRewind, commandCompact, commandSTTModel, commandMCP: return true case commandModel: diff --git a/internal/tui/btw_test.go b/internal/tui/btw_test.go index 8a0bc6656..0d2b958a7 100644 --- a/internal/tui/btw_test.go +++ b/internal/tui/btw_test.go @@ -135,6 +135,51 @@ func TestBTWCanOpenWhileParentRunContinues(t *testing.T) { } } +func TestBTWHiddenParentDoesNotLaunchGoalContinuation(t *testing.T) { + m := newBTWTestModel(t) + goalSession, _, err := m.sessionStore.CreateGoal(m.activeSession.SessionID, "Stay visible to the user", 0) + if err != nil { + t.Fatal(err) + } + m.activeSession = goalSession + m.provider = &fakeProvider{} + m.pending = true + m.runID = 7 + m.activeRunID = 7 + + side, _ := m.handleBTWCommand("") + routed, _, ok := side.routeBTWParentMessage(agentResponseMsg{ + runID: 7, + goalAware: true, + rows: []transcriptRow{{kind: rowAssistant, text: "main turn finished", final: true}}, + }) + if !ok || routed.btw.parent == nil { + t.Fatal("parent completion was not routed while BTW was active") + } + parent := routed.btw.parent + if parent.pending { + t.Fatal("hidden parent launched an automatic goal continuation") + } + if parent.activeSession.Goal == nil || parent.activeSession.Goal.ContinuationCount != 0 { + t.Fatalf("hidden parent consumed a continuation: %#v", parent.activeSession.Goal) + } + + returned, cmd := routed.leaveBTW() + if cmd == nil || !returned.pending { + t.Fatalf("returning from BTW did not resume the deferred goal: pending=%v cmd=%v", returned.pending, cmd) + } + if returned.goalContinuationsSuspended { + t.Fatal("goal continuations remained suspended after leaving BTW") + } + if returned.activeSession.Goal == nil || returned.activeSession.Goal.ContinuationCount != 1 { + t.Fatalf("returning from BTW reserved an unexpected continuation: %#v", returned.activeSession.Goal) + } + again, duplicateCmd := returned.launchGoalContinuationIfReady() + if duplicateCmd != nil || again.activeSession.Goal.ContinuationCount != 1 { + t.Fatalf("returning from BTW launched more than one continuation: goal=%#v cmd=%v", again.activeSession.Goal, duplicateCmd) + } +} + func TestBTWInlineQuestionStartsSideRun(t *testing.T) { m := newBTWTestModel(t) m.provider = &fakeProvider{} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 889e9a26a..3ee9cf7e8 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -50,6 +50,7 @@ const ( commandBTW commandSkills commandLoop + commandGoal commandVoice commandSTTModel commandUnknown @@ -338,6 +339,13 @@ var commandDefinitions = []commandDefinition{ description: "Repeat a prompt or command on an interval (e.g. /loop 5m /babysit-prs), or self-paced when no interval is given.", kind: commandLoop, }, + { + name: "/goal", + usage: "/goal [--tokens N] | status | pause | resume | edit | clear", + group: commandGroupSession, + description: "Create and pursue one persistent objective for this session.", + kind: commandGoal, + }, { name: "/help", usage: "/help", diff --git a/internal/tui/goal.go b/internal/tui/goal.go new file mode 100644 index 000000000..4b5ac83fa --- /dev/null +++ b/internal/tui/goal.go @@ -0,0 +1,358 @@ +package tui + +import ( + "context" + "fmt" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/errhint" + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/tools" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +const goalContinuationPrompt = "Continue pursuing the active goal. Review the existing session context, make concrete progress, and use update_goal when the objective is complete or genuinely blocked." + +func (m model) handleGoalCommand(args string) (model, tea.Cmd) { + action, rest := splitGoalCommand(args) + if rest != "" && (action == "status" || action == "pause" || action == "resume" || action == "clear") { + return m.appendGoalError("Usage: /goal " + action), nil + } + switch action { + case "status": + return m.appendGoalStatus(), nil + case "pause": + if m.activeSession.Goal == nil { + return m.appendGoalError("No goal is set for this session."), nil + } + if m.pending { + m.cancelRun() + return m, nil + } + return m.setGoalStatus(sessions.GoalStatusPaused, "paused by user"), nil + case "resume": + if m.pending { + return m.appendGoalError("A run is already in progress."), nil + } + if m.activeSession.Goal == nil { + return m.appendGoalError("No goal is set for this session."), nil + } + if m.activeSession.Goal.Status == sessions.GoalStatusBudgetLimited && + m.activeSession.Goal.TokenBudget > 0 && + m.activeSession.Goal.TokensUsed >= m.activeSession.Goal.TokenBudget { + return m.appendGoalError("The token budget is exhausted. Increase it with /goal edit --tokens N ."), nil + } + m = m.setGoalStatus(sessions.GoalStatusActive, "") + return m.launchGoalContinuationIfReady() + case "clear": + if m.activeSession.Goal == nil { + return m.appendGoalError("No goal is set for this session."), nil + } + if m.pending { + m.cancelRun() + } + updated, event, err := m.sessionStore.ClearGoal(m.activeSession.SessionID) + if err != nil { + return m.appendGoalError(err.Error()), nil + } + m.activeSession = updated + m.sessionEvents = append(m.sessionEvents, event) + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Goal cleared."}) + return m, nil + case "edit": + objective, budget, err := parseGoalObjective(rest) + if err != nil { + return m.appendGoalError(err.Error()), nil + } + if m.activeSession.Goal == nil { + return m.appendGoalError("No goal is set for this session."), nil + } + if m.pending { + return m.appendGoalError("Pause the current run before editing its goal."), nil + } + if !goalBudgetSpecified(rest) { + budget = m.activeSession.Goal.TokenBudget + } + updated, event, err := m.sessionStore.EditGoal(m.activeSession.SessionID, objective, budget) + if err != nil { + return m.appendGoalError(err.Error()), nil + } + m.activeSession = updated + m.sessionEvents = append(m.sessionEvents, event) + message := "Goal updated and resumed: " + objective + if updated.Goal.Status == sessions.GoalStatusBudgetLimited { + message = "Goal updated, but its token budget is still exhausted. Increase the budget or use --tokens 0 for no goal-specific limit." + } + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: message}) + return m.launchGoalContinuationIfReady() + case "create": + if m.pending { + return m.appendGoalError("A run is already in progress."), nil + } + objective, budget, err := parseGoalObjective(rest) + if err != nil { + return m.appendGoalError(err.Error()), nil + } + if m.activeSession.Goal != nil { + return m.appendGoalError("This session already has a goal. Use /goal edit or /goal clear first."), nil + } + m, err = m.ensureActiveSession(objective) + if err != nil { + return m.appendGoalError("session create error: " + err.Error()), nil + } + updated, event, err := m.sessionStore.CreateGoal(m.activeSession.SessionID, objective, budget) + if err != nil { + return m.appendGoalError(err.Error()), nil + } + m.activeSession = updated + m.sessionEvents = append(m.sessionEvents, event) + return m.launchPrompt(objective) + default: + return m.appendGoalError("Unknown action. Use /goal, /goal pause, /goal resume, /goal edit , or /goal clear."), nil + } +} + +func splitGoalCommand(args string) (string, string) { + trimmed := strings.TrimSpace(args) + if trimmed == "" || strings.EqualFold(trimmed, "status") { + return "status", "" + } + first, rest, _ := strings.Cut(trimmed, " ") + switch strings.ToLower(first) { + case "status", "pause", "resume", "clear": + return strings.ToLower(first), strings.TrimSpace(rest) + case "edit": + return "edit", strings.TrimSpace(rest) + default: + return "create", trimmed + } +} + +func parseGoalObjective(input string) (string, int, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", 0, fmt.Errorf("goal objective is required") + } + budget := 0 + if strings.HasPrefix(input, "--tokens") { + fields := strings.Fields(input) + if len(fields) < 3 || fields[0] != "--tokens" { + return "", 0, fmt.Errorf("usage: /goal [--tokens N] ") + } + value, err := strconv.Atoi(fields[1]) + if err != nil || value < 0 { + return "", 0, fmt.Errorf("goal token budget must be a non-negative integer") + } + budget = value + input = strings.TrimSpace(strings.Join(fields[2:], " ")) + } + if input == "" { + return "", 0, fmt.Errorf("goal objective is required") + } + return input, budget, nil +} + +func goalBudgetSpecified(input string) bool { + return strings.HasPrefix(strings.TrimSpace(input), "--tokens") +} + +func (m model) appendGoalStatus() model { + goal := m.activeSession.Goal + if goal == nil { + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ + kind: rowSystem, + text: "Goal\nstatus: none\nStart one with /goal .", + }) + return m + } + budget := "unlimited" + if goal.TokenBudget > 0 { + budget = fmt.Sprintf("%d / %d tokens", goal.TokensUsed, goal.TokenBudget) + } else if goal.TokensUsed > 0 { + budget = fmt.Sprintf("%d tokens used", goal.TokensUsed) + } + lines := []string{ + "Goal", + "status: " + string(goal.Status), + "objective: " + goal.Objective, + "budget: " + budget, + fmt.Sprintf( + "automatic continuations: %d / %d", + goal.ContinuationCount, + goalContinuationLimit(goal), + ), + } + if goal.StatusReason != "" { + lines = append(lines, "reason: "+goal.StatusReason) + } + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: strings.Join(lines, "\n")}) + return m +} + +func goalContinuationLimit(goal *sessions.Goal) int { + if goal != nil && goal.ContinuationLimit > 0 { + return goal.ContinuationLimit + } + return sessions.GoalMaxConsecutiveContinuations +} + +func (m model) goalFooterSummary() string { + if m.activeSession.Goal == nil { + return "" + } + return "goal " + string(m.activeSession.Goal.Status) +} + +func (m model) appendGoalError(message string) model { + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "Goal: " + message}) + return m +} + +func (m model) setGoalStatus(status sessions.GoalStatus, reason string) model { + if m.sessionStore == nil || m.activeSession.SessionID == "" || m.activeSession.Goal == nil { + return m.appendGoalError("Goal state is unavailable.") + } + updated, event, err := m.sessionStore.UpdateGoal(m.activeSession.SessionID, status, reason) + if err != nil { + return m.appendGoalError(err.Error()) + } + m.activeSession = updated + m.sessionEvents = append(m.sessionEvents, event) + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ + kind: rowSystem, + text: "Goal " + string(status) + ".", + }) + return m +} + +func (m model) goalRegistry() *tools.Registry { + registry := cloneToolRegistry(m.registry) + if m.activeSession.SessionID == "" { + return registry + } + for _, tool := range tools.NewGoalTools(m.sessionStore, m.activeSession.SessionID) { + registry.Register(tool) + } + return registry +} + +func (m model) goalSystemPrompt(base string) string { + goal := m.activeSession.Goal + if goal == nil { + return base + } + instruction := fmt.Sprintf( + "Persistent goal for this session:\nObjective: %s\nStatus: %s\n"+ + "When the status is active, keep pursuing this objective across turns. "+ + "Call update_goal with complete only after the objective is genuinely achieved, "+ + "or blocked with a concrete reason when progress requires user input or an external change.", + goal.Objective, + goal.Status, + ) + if strings.TrimSpace(base) == "" { + return instruction + } + return base + "\n\n" + instruction +} + +func (m model) launchGoalContinuationIfReady() (model, tea.Cmd) { + goal := m.activeSession.Goal + if goal == nil || goal.Status != sessions.GoalStatusActive || m.pending || + m.compactInFlight || m.exiting || m.provider == nil || + m.goalContinuationsSuspended { + return m, nil + } + updated, event, reserved, err := m.sessionStore.ReserveGoalContinuation(m.activeSession.SessionID) + if err != nil { + return m.appendGoalError("reserve automatic continuation: " + err.Error()), nil + } + m.activeSession = updated + if event != nil { + m.sessionEvents = append(m.sessionEvents, *event) + } + if !reserved { + if event != nil && updated.Goal != nil && updated.Goal.Status == sessions.GoalStatusPaused { + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ + kind: rowSystem, + text: fmt.Sprintf( + "Goal paused after %d automatic continuations. Review progress, then use /goal resume to continue.", + goalContinuationLimit(updated.Goal), + ), + }) + } + return m, nil + } + goal = updated.Goal + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{ + kind: rowSystem, + text: "Continuing goal: " + goal.Objective, + }) + prompt := m.sessionPrompt(goalContinuationPrompt) + m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{ + "role": "goal", + "content": goalContinuationPrompt, + }) + if err != nil { + return m.appendGoalError("session record error: " + err.Error()), nil + } + runCtx, cancel := context.WithCancel(m.ctx) + m = m.beginRun(cancel) + return m, tea.Batch(m.runAgent(m.activeRunID, runCtx, prompt, nil), m.spinner.Tick) +} + +func (m model) reconcileGoalAfterRun(usageEvents []zeroruntime.Usage, runErr error) model { + if m.sessionStore == nil || m.activeSession.SessionID == "" { + return m + } + loaded, err := m.sessionStore.Get(m.activeSession.SessionID) + if err != nil || loaded == nil { + if err != nil { + return m.appendGoalError("reload after run: " + err.Error()) + } + return m + } + if loaded.Goal == nil { + return m + } + tokens := 0 + for _, event := range usageEvents { + tokens += event.TotalTokens() + } + if tokens > 0 { + updated, _, addErr := m.sessionStore.AddGoalUsage(loaded.SessionID, tokens) + if addErr != nil { + return m.appendGoalError("account usage: " + addErr.Error()) + } + loaded = &updated + } + if runErr != nil && loaded.Goal.Status == sessions.GoalStatusActive { + status := sessions.GoalStatusPaused + reason := "run stopped after an error" + if errhint.Classify(runErr) == errhint.RateLimit { + status = sessions.GoalStatusUsageLimited + reason = "provider usage limit reached" + } + updated, _, updateErr := m.sessionStore.UpdateGoal( + loaded.SessionID, + status, + reason, + ) + if updateErr != nil { + return m.appendGoalError("pause after error: " + updateErr.Error()) + } + loaded = &updated + message := "Goal paused because the run stopped with an error. Use /goal resume to continue." + if status == sessions.GoalStatusUsageLimited { + message = "Goal stopped at the provider usage limit. Use /goal resume when capacity is available." + } + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: message}) + } + m.activeSession = *loaded + if events, readErr := m.sessionStore.ReadEvents(loaded.SessionID); readErr == nil { + m.sessionEvents = events + } + return m +} diff --git a/internal/tui/goal_test.go b/internal/tui/goal_test.go new file mode 100644 index 000000000..5f76d6c79 --- /dev/null +++ b/internal/tui/goal_test.go @@ -0,0 +1,362 @@ +package tui + +import ( + "context" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/sessions" + "github.com/Gitlawb/zero/internal/tools" + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +func TestGoalCommandCreatesPersistentGoalAndStartsRun(t *testing.T) { + store := testSessionStore(t) + m := newModel(context.Background(), Options{ + Provider: &scriptedProvider{}, + Registry: tools.NewRegistry(), + SessionStore: store, + }) + + next, cmd := m.handleGoalCommand("--tokens 500 Ship the release") + if cmd == nil || !next.pending { + t.Fatal("creating a goal should start its first run") + } + if next.activeSession.Goal == nil || + next.activeSession.Goal.Objective != "Ship the release" || + next.activeSession.Goal.TokenBudget != 500 || + next.activeSession.Goal.Status != sessions.GoalStatusActive { + t.Fatalf("active goal = %#v", next.activeSession.Goal) + } + loaded, err := store.Get(next.activeSession.SessionID) + if err != nil { + t.Fatal(err) + } + if loaded == nil || loaded.Goal == nil || loaded.Goal.Objective != "Ship the release" { + t.Fatalf("persisted session = %#v", loaded) + } +} + +func TestGoalCommandDoesNotCreateGoalDuringActiveRun(t *testing.T) { + store := testSessionStore(t) + session, err := store.Create(sessions.CreateInput{SessionID: "goal_pending"}) + if err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + Provider: &scriptedProvider{}, + Registry: tools.NewRegistry(), + SessionStore: store, + }) + m.activeSession = session + m.pending = true + + next, cmd := m.handleGoalCommand("Do not replace the active run") + if cmd != nil { + t.Fatal("goal creation during an active run returned a command") + } + if next.activeSession.Goal != nil { + t.Fatalf("goal was created during an active run: %#v", next.activeSession.Goal) + } + loaded, err := store.Get(session.SessionID) + if err != nil { + t.Fatal(err) + } + if loaded.Goal != nil { + t.Fatalf("goal was persisted during an active run: %#v", loaded.Goal) + } + if !transcriptContains(next.transcript, "A run is already in progress.") { + t.Fatalf("missing active-run explanation: %#v", next.transcript) + } +} + +func TestGoalRunRegistryContainsSessionBoundTools(t *testing.T) { + store := testSessionStore(t) + session, err := store.Create(sessions.CreateInput{SessionID: "goal_tools"}) + if err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + Registry: tools.NewRegistry(), + SessionStore: store, + }) + m.activeSession = session + + registry := m.goalRegistry() + for _, name := range []string{"get_goal", "create_goal", "update_goal"} { + if _, ok := registry.Get(name); !ok { + t.Fatalf("goal registry missing %q", name) + } + } + if _, ok := m.registry.Get("get_goal"); ok { + t.Fatal("goal tools should not mutate the shared base registry") + } +} + +func TestLoopRunExcludesGoalToolsAndInstructions(t *testing.T) { + store := testSessionStore(t) + session, err := store.Create(sessions.CreateInput{SessionID: "goal_loop"}) + if err != nil { + t.Fatal(err) + } + session, _, err = store.CreateGoal(session.SessionID, "Keep this out of loops", 0) + if err != nil { + t.Fatal(err) + } + provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{{ + {Type: zeroruntime.StreamEventText, Content: "Loop iteration complete."}, + {Type: zeroruntime.StreamEventDone}, + }}} + m := newModel(context.Background(), Options{ + Provider: provider, + Registry: tools.NewRegistry(), + SessionStore: store, + }) + m.activeSession = session + m.activeLoopID = "loop-1" + + _ = execCmd(m.runAgentWithOptions(1, context.Background(), "run loop", nil, tuiAgentRunOptions{})) + if len(provider.requests) != 1 { + t.Fatalf("provider requests = %d, want 1", len(provider.requests)) + } + request := provider.requests[0] + for _, definition := range request.Tools { + switch definition.Name { + case "get_goal", "create_goal", "update_goal": + t.Fatalf("loop request exposed goal tool %q", definition.Name) + } + } + for _, message := range request.Messages { + if strings.Contains(message.Content, "Persistent goal for this session:") { + t.Fatalf("loop request included goal instructions: %q", message.Content) + } + } +} + +func TestLoopRunDoesNotConsumeGoalBudgetOrLaunchContinuation(t *testing.T) { + store := testSessionStore(t) + session, err := store.Create(sessions.CreateInput{SessionID: "goal_loop_usage"}) + if err != nil { + t.Fatal(err) + } + session, _, err = store.CreateGoal(session.SessionID, "Keep loop usage separate", 10) + if err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + Provider: &scriptedProvider{}, + Registry: tools.NewRegistry(), + SessionStore: store, + }) + m.activeSession = session + m.pending = true + m.activeRunID = 1 + m.activeLoopID = "loop-1" + + updated, _ := m.Update(agentResponseMsg{ + runID: 1, + goalAware: false, + usageEvents: []zeroruntime.Usage{{InputTokens: 8, OutputTokens: 4}}, + rows: []transcriptRow{{kind: rowAssistant, text: "loop result", final: true}}, + }) + next := updated.(model) + loaded, err := store.Get(session.SessionID) + if err != nil { + t.Fatal(err) + } + if loaded.Goal.TokensUsed != 0 || loaded.Goal.ContinuationCount != 0 { + t.Fatalf("loop run mutated goal accounting: %#v", loaded.Goal) + } + if next.pending { + t.Fatal("loop completion launched a goal continuation") + } +} + +func TestAgentCanCompleteGoalWithoutAnotherContinuation(t *testing.T) { + store := testSessionStore(t) + provider := &scriptedProvider{scripts: [][]zeroruntime.StreamEvent{ + { + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "goal_done", ToolName: "update_goal"}, + {Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "goal_done", ArgumentsFragment: `{"status":"complete"}`}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "goal_done"}, + {Type: zeroruntime.StreamEventDone}, + }, + { + {Type: zeroruntime.StreamEventText, Content: "The goal is complete."}, + {Type: zeroruntime.StreamEventDone}, + }, + }} + m := newModel(context.Background(), Options{ + Provider: provider, + Registry: tools.NewRegistry(), + SessionStore: store, + }) + + running, cmd := m.handleGoalCommand("Finish the task") + response := execCmd(cmd) + if response == nil { + t.Fatal("goal run did not return an agent response") + } + updated, nextCmd := running.Update(response) + settled := updated.(model) + if settled.activeSession.Goal == nil || settled.activeSession.Goal.Status != sessions.GoalStatusComplete { + t.Fatalf("completed goal = %#v", settled.activeSession.Goal) + } + if settled.pending { + t.Fatal("completed goal started another continuation") + } + // Background title/recap/sweep commands may still be returned; none should + // have changed the settled goal back to active. + _ = nextCmd +} + +func TestGoalBudgetStopsAutomaticContinuation(t *testing.T) { + store := testSessionStore(t) + session, err := store.Create(sessions.CreateInput{SessionID: "goal_budget"}) + if err != nil { + t.Fatal(err) + } + session, _, err = store.CreateGoal(session.SessionID, "Stay bounded", 20) + if err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + Provider: &scriptedProvider{}, + Registry: tools.NewRegistry(), + SessionStore: store, + }) + m.activeSession = session + + m = m.reconcileGoalAfterRun([]zeroruntime.Usage{{InputTokens: 12, OutputTokens: 8}}, nil) + if m.activeSession.Goal.Status != sessions.GoalStatusBudgetLimited { + t.Fatalf("budgeted goal status = %q", m.activeSession.Goal.Status) + } + next, cmd := m.launchGoalContinuationIfReady() + if cmd != nil || next.pending { + t.Fatal("a budget-paused goal must not launch another run") + } +} + +func TestActiveGoalLaunchesContinuation(t *testing.T) { + store := testSessionStore(t) + session, err := store.Create(sessions.CreateInput{SessionID: "goal_continue"}) + if err != nil { + t.Fatal(err) + } + session, _, err = store.CreateGoal(session.SessionID, "Keep going", 0) + if err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + Provider: &scriptedProvider{}, + Registry: tools.NewRegistry(), + SessionStore: store, + }) + m.activeSession = session + + next, cmd := m.launchGoalContinuationIfReady() + if cmd == nil || !next.pending { + t.Fatal("active goal should launch a continuation while idle") + } + if !transcriptContains(next.transcript, "Continuing goal: Keep going") { + t.Fatalf("continuation was not surfaced: %#v", next.transcript) + } +} + +func TestGoalContinuationChainStopsAtPersistedLimit(t *testing.T) { + store := testSessionStore(t) + session, err := store.Create(sessions.CreateInput{SessionID: "goal_hard_stop"}) + if err != nil { + t.Fatal(err) + } + session, _, err = store.CreateGoal(session.SessionID, "Never run forever", 0) + if err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + Provider: &scriptedProvider{}, + Registry: tools.NewRegistry(), + SessionStore: store, + }) + m.activeSession = session + + for continuation := 1; continuation <= sessions.GoalMaxConsecutiveContinuations; continuation++ { + next, cmd := m.launchGoalContinuationIfReady() + if cmd == nil || !next.pending { + t.Fatalf("continuation %d did not launch", continuation) + } + if next.runCancel != nil { + next.runCancel() + } + next.runCancel = nil + next.pending = false + next.activeRunID = 0 + m = next + } + stopped, cmd := m.launchGoalContinuationIfReady() + if cmd != nil || stopped.pending { + t.Fatal("goal exceeded its automatic continuation limit") + } + if stopped.activeSession.Goal.Status != sessions.GoalStatusPaused || + !transcriptContains(stopped.transcript, "Goal paused after") { + t.Fatalf("hard stop was not surfaced: goal=%#v transcript=%#v", stopped.activeSession.Goal, stopped.transcript) + } +} + +func TestGoalActionsRejectTrailingArguments(t *testing.T) { + for _, action := range []string{"status extra", "pause extra", "resume extra", "clear extra"} { + m := newModel(context.Background(), Options{}) + next, cmd := m.handleGoalCommand(action) + if cmd != nil || !transcriptContains(next.transcript, "Usage: /goal") { + t.Fatalf("%q silently accepted trailing arguments: %#v", action, next.transcript) + } + } +} + +func TestCancelRunPausesActiveGoal(t *testing.T) { + store := testSessionStore(t) + session, err := store.Create(sessions.CreateInput{SessionID: "goal_cancel"}) + if err != nil { + t.Fatal(err) + } + session, _, err = store.CreateGoal(session.SessionID, "Keep going", 0) + if err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{SessionStore: store}) + m.activeSession = session + m.pending = true + m.activeRunID = 1 + + m.cancelRun() + + if m.activeSession.Goal.Status != sessions.GoalStatusPaused { + t.Fatalf("cancelled goal status = %q", m.activeSession.Goal.Status) + } + if !transcriptContains(m.transcript, "Goal paused") { + t.Fatalf("cancel did not explain goal pause: %#v", m.transcript) + } +} + +func TestParseGoalObjective(t *testing.T) { + objective, budget, err := parseGoalObjective("--tokens 1200 finish the migration") + if err != nil { + t.Fatal(err) + } + if objective != "finish the migration" || budget != 1200 { + t.Fatalf("parsed objective/budget = %q/%d", objective, budget) + } + if _, _, err := parseGoalObjective("--tokens nope task"); err == nil || + !strings.Contains(err.Error(), "non-negative integer") { + t.Fatalf("invalid budget error = %v", err) + } +} + +func TestGoalStatusIsVisibleInNarrowFooter(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.activeSession.Goal = &sessions.Goal{Objective: "Ship", Status: sessions.GoalStatusActive} + status := plainRender(t, m.statusLine(51)) + if !strings.Contains(status, "goal active") { + t.Fatalf("narrow status omitted active goal: %q", status) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 734f6e653..b27b62ce0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -245,10 +245,13 @@ type model struct { loopCounter int loopTicking bool loopLeavePrompt commandKind - exiting bool - runCancel context.CancelFunc - runID int - activeRunID int + // goalContinuationsSuspended keeps a hidden parent from launching autonomous + // work while the user is in an isolated BTW conversation. + goalContinuationsSuspended bool + exiting bool + runCancel context.CancelFunc + runID int + activeRunID int // flushRunIDs holds the ids of runs cancelled while still in flight, mapped // to the session they were recording into AT CANCEL TIME. Each cancelled // agent goroutine keeps running to completion and returns its accumulated @@ -585,6 +588,7 @@ type agentResponseMsg struct { sessionEvents []pendingSessionEvent specReview *pendingSpecReviewPrompt err error + goalAware bool // Turn metadata for settled rows that do not otherwise carry it. turnTools int turnElapsed time.Duration @@ -2406,6 +2410,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.streamingText = nil m.streamingReasoning = "" m.streamingReasoningExpanded = false + if msg.goalAware { + m = m.reconcileGoalAfterRun(msg.usageEvents, msg.err) + } // Roll the completed run's wall-time into the session's rolling average so // /context can surface typical turn latency, not just token counts. if msg.turnElapsed > 0 { @@ -2460,8 +2467,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // other loops remain. m, loopTickCmd = m.ensureLoopTick() } + hadQueuedMessage := strings.TrimSpace(m.queuedMessage) != "" next, queuedCmd := m.launchQueuedMessageIfReady() - return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd) + var goalCmd tea.Cmd + if msg.goalAware && !hadQueuedMessage && msg.specReview == nil { + next, goalCmd = next.launchGoalContinuationIfReady() + } + return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd, goalCmd) case sessionTitleGeneratedMsg: return m.handleSessionTitleGenerated(msg) case recapGeneratedMsg: @@ -4337,6 +4349,8 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) { return m.handleBTWCommand(command.text) case commandLoop: return m.handleLoopCommand(command.text) + case commandGoal: + return m.handleGoalCommand(command.text) case commandExit: // Closing the session stops its foreground loops mid-task; warn once so a // token-spending loop isn't ended by reflex. @@ -4741,6 +4755,14 @@ func (m model) launchPrompt(prompt string) (model, tea.Cmd) { text: "session create error: " + err.Error(), }) } else { + if m.activeLoopID == "" && sessions.IsResumableKind(m.activeSession.SessionKind) && + m.activeSession.Goal != nil && m.activeSession.Goal.Status == sessions.GoalStatusActive { + if updated, resetErr := m.sessionStore.ResetGoalContinuations(m.activeSession.SessionID); resetErr != nil { + m = m.appendGoalError("reset automatic continuation count: " + resetErr.Error()) + } else { + m.activeSession = updated + } + } agentPrompt := m.sessionPrompt(prompt) m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{ "role": "user", @@ -4883,6 +4905,8 @@ func (m *model) rememberInput(value string) { } func (m *model) cancelRun() { + goalWasActive := m.pending && m.activeSession.Goal != nil && + m.activeSession.Goal.Status == sessions.GoalStatusActive if m.runCancel != nil { m.runCancel() } @@ -4933,6 +4957,18 @@ func (m *model) cancelRun() { *m = next } } + if goalWasActive && m.sessionStore != nil && m.activeSession.SessionID != "" { + updated, event, err := m.sessionStore.PauseGoalIfActive(m.activeSession.SessionID, "run cancelled by user") + if err != nil { + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "Goal: pause after cancellation: " + err.Error()}) + } else { + m.activeSession = updated + if event != nil { + m.sessionEvents = append(m.sessionEvents, *event) + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Goal paused. Use /goal resume to continue."}) + } + } + } m.pending = false m.runCancel = nil m.activeRunID = 0 @@ -4982,9 +5018,19 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str usageModelID := m.modelName var specReview *pendingSpecReviewPrompt options := m.agentOptions - options.Registry = m.registry + options.Registry = cloneToolRegistry(m.registry) + goalAwareRun := !runOptions.specDraft && m.activeLoopID == "" && + sessions.IsResumableKind(m.activeSession.SessionKind) + if goalAwareRun { + options.Registry = m.goalRegistry() + } if runOptions.registry != nil { - options.Registry = runOptions.registry + options.Registry = cloneToolRegistry(runOptions.registry) + if goalAwareRun && m.activeSession.SessionID != "" { + for _, tool := range tools.NewGoalTools(m.sessionStore, m.activeSession.SessionID) { + options.Registry.Register(tool) + } + } } options.PermissionMode = m.permissionMode if runOptions.permissionMode != "" { @@ -4993,6 +5039,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str if runOptions.systemPrompt != "" { options.SystemPrompt = runOptions.systemPrompt } + if goalAwareRun { + options.SystemPrompt = m.goalSystemPrompt(options.SystemPrompt) + } options.SessionID = m.activeSession.SessionID options.ProviderName = m.providerName options.Model = m.modelName @@ -5401,7 +5450,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str Type: sessions.EventError, Payload: map[string]any{"message": err.Error()}, }) - return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)} + return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)} } if runOptions.specDraft { if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" { @@ -5411,10 +5460,10 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str Type: sessions.EventError, Payload: map[string]any{"message": err.Error()}, }) - return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)} + return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)} } flushReasoning(m.now()) - return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, turnTools: toolCalls, turnElapsed: m.now().Sub(started)} + return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.now().Sub(started)} } flushReasoning(m.now()) elapsed := m.now().Sub(started) @@ -5439,7 +5488,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str "content": result.FinalAnswer, }, }) - return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft} + return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft} } } diff --git a/internal/tui/session.go b/internal/tui/session.go index 9f2a6f544..6d40e374d 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -317,18 +317,26 @@ func (m model) formatResumeSummary(session sessions.Metadata, eventCount int) st if recorded := strings.TrimSpace(session.Provider); recorded != "" && !strings.EqualFold(recorded, m.providerName) { providerLine += " (recorded: " + recorded + ")" } + lines := []string{ + "id: " + session.SessionID, + "title: " + displayValue(session.Title, "untitled"), + modelLine, + providerLine, + fmt.Sprintf("events: %d", eventCount), + } + if session.Goal != nil { + goalLine := "goal: " + string(session.Goal.Status) + " — " + session.Goal.Objective + if session.Goal.Status == sessions.GoalStatusActive { + goalLine += " (run /goal resume to continue)" + } + lines = append(lines, goalLine) + } return renderCommandOutput(commandOutput{ Title: "Resumed Zero session", Status: commandStatusOK, Sections: []commandSection{{ Title: "Session", - Lines: []string{ - "id: " + session.SessionID, - "title: " + displayValue(session.Title, "untitled"), - modelLine, - providerLine, - fmt.Sprintf("events: %d", eventCount), - }, + Lines: lines, }}, }) } diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index 4f3d449d1..ddbfbca63 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -1056,6 +1056,35 @@ func TestResumedPromptIncludesSessionContext(t *testing.T) { } } +func TestResumeActiveGoalDoesNotStartAutomaticRun(t *testing.T) { + store := testSessionStore(t) + session, err := store.Create(sessions.CreateInput{SessionID: "resume_active_goal"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := store.CreateGoal(session.SessionID, "Wait for explicit resume", 0); err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{ + Provider: &scriptedProvider{}, + Registry: tools.NewRegistry(), + SessionStore: store, + }) + m.input.SetValue("/resume " + session.SessionID) + + updated, cmd := m.Update(testKey(tea.KeyEnter)) + next := updated.(model) + if cmd != nil || next.pending { + t.Fatal("resuming a session unexpectedly started a billed goal run") + } + if next.activeSession.Goal == nil || next.activeSession.Goal.Status != sessions.GoalStatusActive { + t.Fatalf("active goal was not restored: %#v", next.activeSession.Goal) + } + if !transcriptContains(next.transcript, "run /goal resume to continue") { + t.Fatalf("resume did not explain how to continue the goal: %#v", next.transcript) + } +} + func TestResumeCommandReportsMissingSession(t *testing.T) { m := newModel(context.Background(), Options{SessionStore: testSessionStore(t)}) m.input.SetValue("/resume missing_session") diff --git a/internal/tui/view.go b/internal/tui/view.go index c3d949805..7d8e472c8 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -211,6 +211,9 @@ func (m model) statusLine(width int) string { if dictation := m.dictationStatusChip(); dictation != "" { return fitStyledLine(prefix+btwChip+dictation, width) } + if goalSummary := m.goalFooterSummary(); goalSummary != "" { + left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render("◎ ") + zeroTheme.muted.Render(goalSummary) + } return fitStyledLine(left, width) } @@ -240,6 +243,9 @@ func (m model) statusLine(width int) string { // Active loops surface a persistent "↻ N loops · next 3:05pm" segment so a // running loop is always visible (hidden during an exit/cancel confirm above). if !m.exitConfirmActive && !m.cancelConfirmActive { + if goalSummary := m.goalFooterSummary(); goalSummary != "" { + left += separator + zeroTheme.accent.Render("◎ ") + zeroTheme.muted.Render(goalSummary) + } if loopSummary := m.loopFooterSummary(); loopSummary != "" { left += separator + zeroTheme.accent.Render("↻ ") + zeroTheme.muted.Render(loopSummary) }