Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/agent/agent_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ func (a *sessionAgent) saveSessionUsage(ctx context.Context, sessionID string, u
return session.Session{}, false
}
a.updateSessionUsage(pm, &s, usage, a.openrouterCost(meta))
s.Provider = pm.ModelCfg.Provider
s.Model = pm.ModelCfg.Model
updated, saveErr := a.sessions.Save(ctx, s)
if saveErr != nil {
slog.Warn(logMsg, "session_id", sessionID, "error", saveErr)
Expand Down
34 changes: 32 additions & 2 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,45 @@ func (app *App) resolveSession(ctx context.Context, continueSessionID string, us
return sess, nil

case useLast:
sess, err := app.Sessions.GetLast(ctx)
sess, err := app.lastSessionForActiveAgent(ctx)
if err != nil {
return session.Session{}, fmt.Errorf("no sessions found to continue")
}
return sess, nil

default:
return app.Sessions.Create(ctx, agent.DefaultSessionName)
metadata := session.Metadata{AgentName: config.AgentCoder}
if app.config != nil {
if agentName := app.config.Overrides().AgentName; agentName != "" {
metadata.AgentName = agentName
}
}
if app.AgentCoordinator != nil {
model := app.AgentCoordinator.Model()
metadata.Provider = model.ModelCfg.Provider
metadata.Model = model.ModelCfg.Model
}
return app.Sessions.CreateWithMetadata(ctx, agent.DefaultSessionName, metadata)
}
}

func (app *App) lastSessionForActiveAgent(ctx context.Context) (session.Session, error) {
agentName := config.AgentCoder
if app.config != nil {
if override := app.config.Overrides().AgentName; override != "" {
agentName = override
}
}
sessions, err := app.Sessions.List(ctx)
if err != nil {
return session.Session{}, err
}
for _, sess := range sessions {
if sess.AgentName == agentName {
return sess, nil
}
}
return session.Session{}, sql.ErrNoRows
}

// RunNonInteractive runs the application in non-interactive mode with the
Expand Down
18 changes: 14 additions & 4 deletions internal/app/resolve_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,17 @@ func (m *mockSessionService) Subscribe(context.Context) <-chan pubsub.Event[sess
}

func (m *mockSessionService) Create(_ context.Context, title string) (session.Session, error) {
s := session.Session{ID: "new-session-id", Title: title}
return m.CreateWithMetadata(context.Background(), title, session.Metadata{})
}

func (m *mockSessionService) CreateWithMetadata(_ context.Context, title string, metadata session.Metadata) (session.Session, error) {
s := session.Session{
ID: "new-session-id",
Title: title,
AgentName: metadata.AgentName,
Provider: metadata.Provider,
Model: metadata.Model,
}
m.created = append(m.created, s)
return s, nil
}
Expand Down Expand Up @@ -148,15 +158,15 @@ func TestResolveSession_ContinueByID_AgentToolSession(t *testing.T) {
func TestResolveSession_Last(t *testing.T) {
mock := &mockSessionService{
sessions: []session.Session{
{ID: "most-recent", Title: "Latest session"},
{ID: "older", Title: "Older session"},
{ID: "reviewer-newer", Title: "Latest review", AgentName: "reviewer"},
{ID: "coder-older", Title: "Older code", AgentName: "coder"},
},
}
app := newTestApp(mock)

sess, err := app.resolveSession(t.Context(), "", true)
require.NoError(t, err)
require.Equal(t, "most-recent", sess.ID)
require.Equal(t, "coder-older", sess.ID)
require.Empty(t, mock.created)
}

Expand Down
31 changes: 30 additions & 1 deletion internal/cmd/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,35 @@ lenos review
lenos review -m gpt-5
`,
RunE: func(cmd *cobra.Command, args []string) error {
sessionID, _ := cmd.Flags().GetString("session")
continueLast, _ := cmd.Flags().GetBool("continue")
ws, cleanup, err := setupWorkspaceWithProgressBar(cmd, config.AgentReviewer, nil, true)
if err != nil {
return err
}
defer cleanup()

if sessionID != "" {
sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
if err != nil {
return err
}
sessionID = sess.ID
} else if continueLast {
sess, ok, err := resolveWorkspaceContinueSession(cmd.Context(), ws)
if err != nil {
return err
}
if ok {
sessionID = sess.ID
continueLast = false
}
}

event.AppInitialized()

com := common.DefaultCommon(ws)
model := ui.New(com, "", false, reviewTriggerPrompt)
model := ui.New(com, sessionID, continueLast, reviewTriggerPrompt)

var env uv.Environ = os.Environ()
program := tea.NewProgram(
Expand All @@ -58,6 +77,13 @@ lenos review -m gpt-5
event.Error(err)
return errors.New("lenos crashed during review")
}
activeSessionID := model.ActiveSessionID()
if hint := formatResumeHint(activeSessionID); hint != "" {
cmd.Println(hint)
}
if hint := formatJournalHint(ws.WorkingDir(), activeSessionID); hint != "" {
cmd.Println(hint)
}
return nil
},
}
Expand All @@ -66,4 +92,7 @@ func init() {
reviewCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
reviewCmd.Flags().Bool("small-model", false, "Use the small-tier model for this session")
reviewCmd.Flags().String("reasoning-effort", "", "Reasoning effort for this session: medium, high, or xhigh")
reviewCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
reviewCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
reviewCmd.MarkFlagsMutuallyExclusive("session", "continue")
}
101 changes: 101 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"context"
"database/sql"
_ "embed"
"errors"
"fmt"
Expand Down Expand Up @@ -114,6 +115,15 @@ lenos --continue
return err
}
sessionID = sess.ID
} else if continueLast {
sess, ok, err := resolveWorkspaceContinueSession(cmd.Context(), ws)
if err != nil {
return err
}
if ok {
sessionID = sess.ID
continueLast = false
}
}

event.AppInitialized()
Expand Down Expand Up @@ -316,6 +326,10 @@ func setupWorkspace(cmd *cobra.Command, agentName string, contextFiles []string,
if err != nil {
return nil, nil, err
}
if err := applySessionResumeDefaultsFromFlags(ctx, cmd, store, conn, agentName); err != nil {
_ = conn.Close()
return nil, nil, err
}

logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "lenos.log")
lenoslog.Setup(logFile, debug)
Expand All @@ -336,6 +350,93 @@ func setupWorkspace(cmd *cobra.Command, agentName string, contextFiles []string,
return ws, cleanup, nil
}

func applySessionResumeDefaultsFromFlags(ctx context.Context, cmd *cobra.Command, store *config.ConfigStore, conn *sql.DB, requestedAgent string) error {
sessionFlag := cmd.Flags().Lookup("session")
continueFlag := cmd.Flags().Lookup("continue")
if sessionFlag == nil && continueFlag == nil {
return nil
}

sessionID := ""
if sessionFlag != nil {
sessionID, _ = cmd.Flags().GetString("session")
}
continueLast := false
if continueFlag != nil {
continueLast, _ = cmd.Flags().GetBool("continue")
}
if sessionID == "" && !continueLast {
return nil
}

svc := session.NewService(db.New(conn), conn)
var sess session.Session
var err error
switch {
case sessionID != "":
sess, err = resolveSessionID(ctx, svc, sessionID)
case continueLast:
list, listErr := svc.List(ctx)
if listErr != nil {
return fmt.Errorf("failed to list sessions: %w", listErr)
}
var ok bool
sess, ok = selectResumeSession(list, requestedAgent)
if !ok {
return nil
}
}
if err != nil {
return err
}

explicitAgent := cmd.Flags().Changed("agent")
explicitModel := cmd.Flags().Changed("model") ||
cmd.Flags().Changed("small-model") ||
cmd.Flags().Changed("reasoning-effort")
defaults := config.SessionResumeDefaults{
AgentName: sess.AgentName,
Provider: sess.Provider,
Model: sess.Model,
ExplicitAgent: explicitAgent,
ExplicitModel: explicitModel,
}
if defaults.AgentName == "" && requestedAgent != "" {
defaults.AgentName = requestedAgent
}
if defaults.AgentName != "" && !explicitAgent {
agentName, agentContextFile, resolveErr := resolveWorkspaceAgent(defaults.AgentName, store.Config().Options.AgentPaths)
if resolveErr != nil {
return resolveErr
}
defaults.AgentName = agentName
store.Overrides().AgentContextFile = agentContextFile
}
config.ApplySessionResumeDefaults(store, defaults)
return nil
}

func resolveWorkspaceContinueSession(ctx context.Context, ws workspace.Workspace) (session.Session, bool, error) {
sessions, err := ws.ListSessions(ctx)
if err != nil {
return session.Session{}, false, err
}
sess, ok := selectResumeSession(sessions, ws.AgentName())
return sess, ok, nil
}

func selectResumeSession(sessions []session.Session, agentName string) (session.Session, bool) {
if agentName == "" {
agentName = config.AgentCoder
}
for _, sess := range sessions {
if sess.AgentName == agentName {
return sess, true
}
}
return session.Session{}, false
}

func validateReadonlySandboxPolicy(readOnly bool, sandbox *bool, noSandbox bool) error {
if !readOnly {
return nil
Expand Down
35 changes: 35 additions & 0 deletions internal/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"

"github.com/tta-lab/lenos/internal/config"
"github.com/tta-lab/lenos/internal/session"

"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -68,6 +69,40 @@ func TestRootCmd_ReasoningEffortFlagDeclared(t *testing.T) {
require.Equal(t, "", f.DefValue, "--reasoning-effort default must be empty")
}

func TestReviewCmd_ContinueFlagsDeclared(t *testing.T) {
sessionFlag := reviewCmd.Flags().Lookup("session")
require.NotNil(t, sessionFlag, "--session flag must be declared on reviewCmd")
require.Equal(t, "s", sessionFlag.Shorthand)

continueFlag := reviewCmd.Flags().Lookup("continue")
require.NotNil(t, continueFlag, "--continue flag must be declared on reviewCmd")
require.Equal(t, "C", continueFlag.Shorthand)
}

func TestSelectResumeSessionDefaultsToCoder(t *testing.T) {
sessions := []session.Session{
{ID: "reviewer-newer", AgentName: config.AgentReviewer},
{ID: "coder-older", AgentName: config.AgentCoder},
}

got, ok := selectResumeSession(sessions, "")

require.True(t, ok)
require.Equal(t, "coder-older", got.ID)
}

func TestSelectResumeSessionUsesRequestedAgent(t *testing.T) {
sessions := []session.Session{
{ID: "coder-newer", AgentName: config.AgentCoder},
{ID: "reviewer-older", AgentName: config.AgentReviewer},
}

got, ok := selectResumeSession(sessions, config.AgentReviewer)

require.True(t, ok)
require.Equal(t, "reviewer-older", got.ID)
}

func TestRootCmd_PairWithFlagParse(t *testing.T) {
err := rootCmd.ParseFlags([]string{"--pair-with", "reviewer"})
require.NoError(t, err)
Expand Down
7 changes: 7 additions & 0 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ lenos run --readonly --agent reviewer "review the changes in HEAD"
}

appWs := ws.(*workspace.AppWorkspace)
if sessionID != "" {
sess, err := resolveWorkspaceSessionID(ctx, ws, sessionID)
if err != nil {
return err
}
sessionID = sess.ID
}

if verbose {
slog.SetDefault(slog.New(log.New(os.Stderr)))
Expand Down
35 changes: 35 additions & 0 deletions internal/config/session_resume.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package config

type SessionResumeDefaults struct {
AgentName string
Provider string
Model string
ExplicitAgent bool
ExplicitModel bool
}

func ApplySessionResumeDefaults(store *ConfigStore, defaults SessionResumeDefaults) {
if store == nil || store.config == nil {
return
}
agentName := defaults.AgentName
if agentName == "" {
agentName = AgentCoder
}
if !defaults.ExplicitAgent {
store.Overrides().AgentName = agentName
}
if defaults.ExplicitModel || defaults.Provider == "" || defaults.Model == "" {
return
}

tier := SelectedModelTypeLarge
if agentName == AgentReviewer {
tier = SelectedModelTypeReview
}
store.Overrides().ActiveTier = tier
store.SetActiveModel(tier, SelectedModel{
Provider: defaults.Provider,
Model: defaults.Model,
})
}
Loading