From b491f68b91a65dae34ad66fd92a0eda1c3ca3558 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 19:57:11 +0000 Subject: [PATCH] feat(agentic): add session handoff command Co-Authored-By: Virgil --- pkg/agentic/commands_session.go | 45 ++++++++++++++++++ pkg/agentic/commands_session_test.go | 68 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/pkg/agentic/commands_session.go b/pkg/agentic/commands_session.go index 4432d81..e766906 100644 --- a/pkg/agentic/commands_session.go +++ b/pkg/agentic/commands_session.go @@ -8,10 +8,55 @@ import ( func (s *PrepSubsystem) registerSessionCommands() { c := s.Core() + c.Command("session/handoff", core.Command{Description: "Pause a stored session with handoff context", Action: s.cmdSessionHandoff}) c.Command("session/resume", core.Command{Description: "Resume a paused or handed-off session from local cache", Action: s.cmdSessionResume}) c.Command("session/replay", core.Command{Description: "Build replay context for a stored session", Action: s.cmdSessionReplay}) } +// core-agent session handoff ses-abc123 --summary="Ready for review" --next-steps="Run the verifier" +func (s *PrepSubsystem) cmdSessionHandoff(options core.Options) core.Result { + sessionID := optionStringValue(options, "session_id", "session-id", "id", "_arg") + summary := optionStringValue(options, "summary") + if sessionID == "" { + core.Print(nil, "usage: core-agent session handoff --summary=\"Ready for review\" [--next-steps=\"Run the verifier\"] [--blockers=\"Needs input\"]") + return core.Result{Value: core.E("agentic.cmdSessionHandoff", "session_id is required", nil), OK: false} + } + if summary == "" { + core.Print(nil, "usage: core-agent session handoff --summary=\"Ready for review\" [--next-steps=\"Run the verifier\"] [--blockers=\"Needs input\"]") + return core.Result{Value: core.E("agentic.cmdSessionHandoff", "summary is required", nil), OK: false} + } + + result := s.handleSessionHandoff(s.commandContext(), core.NewOptions( + core.Option{Key: "session_id", Value: sessionID}, + core.Option{Key: "summary", Value: summary}, + core.Option{Key: "next_steps", Value: optionStringSliceValue(options, "next_steps", "next-steps")}, + core.Option{Key: "blockers", Value: optionStringSliceValue(options, "blockers")}, + core.Option{Key: "context_for_next", Value: optionAnyMapValue(options, "context_for_next", "context-for-next")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdSessionHandoff", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(SessionHandoffOutput) + if !ok { + err := core.E("agentic.cmdSessionHandoff", "invalid session handoff output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "session: %s", sessionID) + core.Print(nil, "summary: %s", summary) + if blockers, ok := output.HandoffContext["blockers"].([]string); ok && len(blockers) > 0 { + core.Print(nil, "blockers: %d", len(blockers)) + } + if nextSteps, ok := output.HandoffContext["next_steps"].([]string); ok && len(nextSteps) > 0 { + core.Print(nil, "next steps: %d", len(nextSteps)) + } + return core.Result{Value: output, OK: true} +} + func (s *PrepSubsystem) cmdSessionResume(options core.Options) core.Result { sessionID := optionStringValue(options, "session_id", "session-id", "id", "_arg") if sessionID == "" { diff --git a/pkg/agentic/commands_session_test.go b/pkg/agentic/commands_session_test.go index 99e3189..79fbadd 100644 --- a/pkg/agentic/commands_session_test.go +++ b/pkg/agentic/commands_session_test.go @@ -17,10 +17,78 @@ func TestCommandsSession_RegisterSessionCommands_Good(t *testing.T) { s.registerSessionCommands() + assert.Contains(t, c.Commands(), "session/handoff") assert.Contains(t, c.Commands(), "session/resume") assert.Contains(t, c.Commands(), "session/replay") } +func TestCommandsSession_CmdSessionHandoff_Good(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + require.NoError(t, writeSessionCache(&Session{ + SessionID: "ses-handoff", + AgentType: "codex", + Status: "active", + WorkLog: []map[string]any{ + {"type": "checkpoint", "message": "build passed"}, + {"type": "decision", "message": "hand off for review"}, + }, + })) + + result := s.cmdSessionHandoff(core.NewOptions( + core.Option{Key: "session_id", Value: "ses-handoff"}, + core.Option{Key: "summary", Value: "Ready for review"}, + core.Option{Key: "next_steps", Value: []string{"Run the verifier", "Merge if clean"}}, + core.Option{Key: "blockers", Value: []string{"Need final approval"}}, + core.Option{Key: "context_for_next", Value: map[string]any{"repo": "go-io"}}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(SessionHandoffOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, "ses-handoff", output.HandoffContext["session_id"]) + handoffNotes, ok := output.HandoffContext["handoff_notes"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "Ready for review", handoffNotes["summary"]) + + cached, err := readSessionCache("ses-handoff") + require.NoError(t, err) + require.NotNil(t, cached) + assert.Equal(t, "paused", cached.Status) + assert.NotEmpty(t, cached.Handoff) +} + +func TestCommandsSession_CmdSessionHandoff_Bad_MissingSummary(t *testing.T) { + s := newTestPrep(t) + + result := s.cmdSessionHandoff(core.NewOptions(core.Option{Key: "session_id", Value: "ses-handoff"})) + + assert.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "summary is required") +} + +func TestCommandsSession_CmdSessionHandoff_Ugly_CorruptedCacheFallsBackToRemoteError(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + require.True(t, fs.EnsureDir(sessionCacheRoot()).OK) + require.True(t, fs.WriteAtomic(sessionCachePath("ses-bad"), "{not-json").OK) + + result := s.cmdSessionHandoff(core.NewOptions( + core.Option{Key: "session_id", Value: "ses-bad"}, + core.Option{Key: "summary", Value: "Ready for review"}, + )) + + assert.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "no platform API key configured") +} + func TestCommandsSession_CmdSessionResume_Good(t *testing.T) { dir := t.TempDir() t.Setenv("CORE_WORKSPACE", dir)