mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e4fa20c1 | ||
|
|
c6f77b4f46 | ||
|
|
b44189dc86 | ||
|
|
f4ca3296c4 | ||
|
|
de8b4bd810 | ||
|
|
7f87eb1eb4 |
No files matched your search
@@ -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 ©
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
+6
-2
@@ -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:
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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] <objective> | status | pause | resume | edit | clear",
|
||||
group: commandGroupSession,
|
||||
description: "Create and pursue one persistent objective for this session.",
|
||||
kind: commandGoal,
|
||||
},
|
||||
{
|
||||
name: "/help",
|
||||
usage: "/help",
|
||||
|
||||
@@ -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 <objective>."), 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 <objective> 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 <objective>, 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] <objective>")
|
||||
}
|
||||
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 <objective>.",
|
||||
})
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+60
-11
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-7
@@ -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,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user