From d91081406701acd26a5b50a21fa15926925bf714 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:19:22 +0000 Subject: [PATCH] feat(agentic): add session end CLI command Co-Authored-By: Virgil --- pkg/agentic/commands_session.go | 47 +++++++++++++++++++ pkg/agentic/commands_session_test.go | 67 ++++++++++++++++++++++++++++ pkg/agentic/commands_test.go | 2 + pkg/agentic/prep_test.go | 2 + 4 files changed, 118 insertions(+) diff --git a/pkg/agentic/commands_session.go b/pkg/agentic/commands_session.go index 3e75a06..0819a2a 100644 --- a/pkg/agentic/commands_session.go +++ b/pkg/agentic/commands_session.go @@ -10,6 +10,8 @@ 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("agentic:session/handoff", core.Command{Description: "Pause a stored session with handoff context", Action: s.cmdSessionHandoff}) + c.Command("session/end", core.Command{Description: "End a stored session with status, summary, and handoff notes", Action: s.cmdSessionEnd}) + c.Command("agentic:session/end", core.Command{Description: "End a stored session with status, summary, and handoff notes", Action: s.cmdSessionEnd}) c.Command("session/resume", core.Command{Description: "Resume a paused or handed-off session from local cache", Action: s.cmdSessionResume}) c.Command("agentic: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}) @@ -60,6 +62,51 @@ func (s *PrepSubsystem) cmdSessionHandoff(options core.Options) core.Result { return core.Result{Value: output, OK: true} } +// core-agent session end ses-abc123 --summary="Ready for review" --status=completed +func (s *PrepSubsystem) cmdSessionEnd(options core.Options) core.Result { + sessionID := optionStringValue(options, "session_id", "session-id", "id", "_arg") + summary := optionStringValue(options, "summary") + status := optionStringValue(options, "status") + if status == "" { + status = "completed" + } + if sessionID == "" { + core.Print(nil, "usage: core-agent session end --summary=\"Ready for review\" [--status=completed] [--handoff-notes=\"...\"]") + return core.Result{Value: core.E("agentic.cmdSessionEnd", "session_id is required", nil), OK: false} + } + if summary == "" { + core.Print(nil, "usage: core-agent session end --summary=\"Ready for review\" [--status=completed] [--handoff-notes=\"...\"]") + return core.Result{Value: core.E("agentic.cmdSessionEnd", "summary is required", nil), OK: false} + } + + result := s.handleSessionEnd(s.commandContext(), core.NewOptions( + core.Option{Key: "session_id", Value: sessionID}, + core.Option{Key: "status", Value: status}, + core.Option{Key: "summary", Value: summary}, + core.Option{Key: "handoff_notes", Value: optionAnyMapValue(options, "handoff_notes", "handoff-notes", "handoff")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdSessionEnd", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(SessionOutput) + if !ok { + err := core.E("agentic.cmdSessionEnd", "invalid session end output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "session: %s", output.Session.SessionID) + core.Print(nil, "status: %s", output.Session.Status) + core.Print(nil, "summary: %s", output.Session.Summary) + if len(output.Session.Handoff) > 0 { + core.Print(nil, "handoff: %d item(s)", len(output.Session.Handoff)) + } + 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 d2031c1..de01924 100644 --- a/pkg/agentic/commands_session_test.go +++ b/pkg/agentic/commands_session_test.go @@ -3,6 +3,8 @@ package agentic import ( + "net/http" + "net/http/httptest" "testing" "time" @@ -19,6 +21,8 @@ func TestCommandsSession_RegisterSessionCommands_Good(t *testing.T) { assert.Contains(t, c.Commands(), "session/handoff") assert.Contains(t, c.Commands(), "agentic:session/handoff") + assert.Contains(t, c.Commands(), "session/end") + assert.Contains(t, c.Commands(), "agentic:session/end") assert.Contains(t, c.Commands(), "session/resume") assert.Contains(t, c.Commands(), "agentic:session/resume") assert.Contains(t, c.Commands(), "session/replay") @@ -92,6 +96,69 @@ func TestCommandsSession_CmdSessionHandoff_Ugly_CorruptedCacheFallsBackToRemoteE assert.Contains(t, result.Value.(error).Error(), "no platform API key configured") } +func TestCommandsSession_CmdSessionEnd_Good(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/sessions/ses-end/end", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + + bodyResult := core.ReadAll(r.Body) + require.True(t, bodyResult.OK) + + var payload map[string]any + parseResult := core.JSONUnmarshalString(bodyResult.Value.(string), &payload) + require.True(t, parseResult.OK) + require.Equal(t, "completed", payload["status"]) + require.Equal(t, "Ready for review", payload["summary"]) + + handoffNotes, ok := payload["handoff_notes"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "Ready for review", handoffNotes["summary"]) + assert.Equal(t, []any{"Run the verifier"}, handoffNotes["next_steps"]) + + _, _ = w.Write([]byte(`{"data":{"session_id":"ses-end","agent_type":"codex","status":"completed","summary":"Ready for review","handoff":{"summary":"Ready for review","next_steps":["Run the verifier"]},"ended_at":"2026-03-31T12:00:00Z"}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.cmdSessionEnd(core.NewOptions( + core.Option{Key: "session_id", Value: "ses-end"}, + core.Option{Key: "summary", Value: "Ready for review"}, + core.Option{Key: "handoff_notes", Value: `{"summary":"Ready for review","next_steps":["Run the verifier"]}`}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(SessionOutput) + require.True(t, ok) + assert.Equal(t, "completed", output.Session.Status) + assert.Equal(t, "Ready for review", output.Session.Summary) + require.NotNil(t, output.Session.Handoff) + assert.Equal(t, "Ready for review", output.Session.Handoff["summary"]) +} + +func TestCommandsSession_CmdSessionEnd_Bad_MissingSummary(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + result := subsystem.cmdSessionEnd(core.NewOptions( + core.Option{Key: "session_id", Value: "ses-end"}, + )) + assert.False(t, result.OK) + require.Error(t, result.Value.(error)) + assert.Contains(t, result.Value.(error).Error(), "summary is required") +} + +func TestCommandsSession_CmdSessionEnd_Ugly_InvalidResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.cmdSessionEnd(core.NewOptions( + core.Option{Key: "session_id", Value: "ses-end"}, + core.Option{Key: "summary", Value: "Ready for review"}, + )) + assert.False(t, result.OK) +} + func TestCommandsSession_CmdSessionResume_Good(t *testing.T) { dir := t.TempDir() t.Setenv("CORE_WORKSPACE", dir) diff --git a/pkg/agentic/commands_test.go b/pkg/agentic/commands_test.go index 358a437..473533f 100644 --- a/pkg/agentic/commands_test.go +++ b/pkg/agentic/commands_test.go @@ -1389,6 +1389,8 @@ func TestCommands_RegisterCommands_Good_AllRegistered(t *testing.T) { assert.Contains(t, cmds, "plan/archive") assert.Contains(t, cmds, "plan/delete") assert.Contains(t, cmds, "agentic:plan-cleanup") + assert.Contains(t, cmds, "session/end") + assert.Contains(t, cmds, "agentic:session/end") assert.Contains(t, cmds, "pr-manage") assert.Contains(t, cmds, "agentic:pr-manage") assert.Contains(t, cmds, "review-queue") diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index 9c3be76..a146025 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -688,6 +688,8 @@ func TestPrep_OnStartup_Good_RegistersGenerateCommand(t *testing.T) { assert.Contains(t, c.Commands(), "lang/list") assert.Contains(t, c.Commands(), "plan-cleanup") assert.Contains(t, c.Commands(), "plan/from-issue") + assert.Contains(t, c.Commands(), "session/end") + assert.Contains(t, c.Commands(), "agentic:session/end") assert.Contains(t, c.Commands(), "session/resume") assert.Contains(t, c.Commands(), "session/replay") assert.Contains(t, c.Commands(), "review-queue")