From cc552ed9dd837e71a1da852bdad3b96f4296dfee Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 21:28:07 +0000 Subject: [PATCH] feat(session): accept handoff_notes in session end Co-Authored-By: Virgil --- pkg/agentic/session.go | 50 ++++++++++++++++++++------- pkg/agentic/session_test.go | 67 +++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/pkg/agentic/session.go b/pkg/agentic/session.go index a4a75ba..0c5787e 100644 --- a/pkg/agentic/session.go +++ b/pkg/agentic/session.go @@ -56,12 +56,13 @@ type SessionContinueInput struct { Context map[string]any `json:"context,omitempty"` } -// input := agentic.SessionEndInput{SessionID: "ses_abc123", Status: "completed"} +// input := agentic.SessionEndInput{SessionID: "ses_abc123", Status: "completed", HandoffNotes: map[string]any{"summary": "Ready for review"}} type SessionEndInput struct { - SessionID string `json:"session_id"` - Status string `json:"status,omitempty"` - Summary string `json:"summary,omitempty"` - Handoff map[string]any `json:"handoff,omitempty"` + SessionID string `json:"session_id"` + Status string `json:"status,omitempty"` + Summary string `json:"summary,omitempty"` + Handoff map[string]any `json:"handoff,omitempty"` + HandoffNotes map[string]any `json:"handoff_notes,omitempty"` } // out := agentic.SessionOutput{Success: true, Session: agentic.Session{SessionID: "ses_abc123"}} @@ -206,10 +207,11 @@ func (s *PrepSubsystem) handleSessionContinue(ctx context.Context, options core. // result := c.Action("session.end").Run(ctx, core.NewOptions(core.Option{Key: "session_id", Value: "ses_abc123"})) func (s *PrepSubsystem) handleSessionEnd(ctx context.Context, options core.Options) core.Result { _, output, err := s.sessionEnd(ctx, nil, SessionEndInput{ - SessionID: optionStringValue(options, "session_id", "id", "_arg"), - Status: optionStringValue(options, "status"), - Summary: optionStringValue(options, "summary"), - Handoff: optionAnyMapValue(options, "handoff"), + SessionID: optionStringValue(options, "session_id", "id", "_arg"), + Status: optionStringValue(options, "status"), + Summary: optionStringValue(options, "summary"), + Handoff: optionAnyMapValue(options, "handoff"), + HandoffNotes: optionAnyMapValue(options, "handoff_notes", "handoff-notes"), }) if err != nil { return core.Result{Value: err, OK: false} @@ -455,8 +457,10 @@ func (s *PrepSubsystem) sessionEnd(ctx context.Context, _ *mcp.CallToolRequest, if input.Summary != "" { body["summary"] = input.Summary } - if len(input.Handoff) > 0 { - body["handoff"] = input.Handoff + handoff := mergeSessionHandoff(input.Handoff, input.HandoffNotes) + if len(handoff) > 0 { + body["handoff"] = handoff + body["handoff_notes"] = handoff } path := core.Concat("/v1/sessions/", input.SessionID, "/end") @@ -715,7 +719,10 @@ func sessionEndFromInput(session Session, input SessionEndInput) Session { session.Summary = input.Summary } if len(session.Handoff) == 0 && len(input.Handoff) > 0 { - session.Handoff = input.Handoff + session.Handoff = mergeSessionHandoff(input.Handoff, input.HandoffNotes) + } + if len(session.Handoff) == 0 && len(input.HandoffNotes) > 0 { + session.Handoff = mergeSessionHandoff(input.HandoffNotes, nil) } if session.Status == "completed" || session.Status == "failed" || session.Status == "handed_off" { if session.EndedAt == "" { @@ -725,6 +732,25 @@ func sessionEndFromInput(session Session, input SessionEndInput) Session { return session } +func mergeSessionHandoff(primary, fallback map[string]any) map[string]any { + if len(primary) == 0 && len(fallback) == 0 { + return nil + } + + merged := map[string]any{} + for key, value := range primary { + if value != nil { + merged[key] = value + } + } + for key, value := range fallback { + if value != nil { + merged[key] = value + } + } + return merged +} + func sessionCacheRoot() string { return core.JoinPath(CoreRoot(), "sessions") } diff --git a/pkg/agentic/session_test.go b/pkg/agentic/session_test.go index 3e0d648..37ff0d0 100644 --- a/pkg/agentic/session_test.go +++ b/pkg/agentic/session_test.go @@ -212,6 +212,73 @@ func TestSession_HandleSessionEnd_Good(t *testing.T) { assert.Equal(t, "All green", output.Session.Summary) } +func TestSession_HandleSessionEnd_Good_HandoffNotes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/sessions/ses_handoff/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, "paused", 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"]) + assert.Equal(t, []any{"Needs input"}, handoffNotes["blockers"]) + + _, _ = w.Write([]byte(`{"data":{"session_id":"ses_handoff","agent_type":"codex","status":"paused","summary":"Ready for review","handoff_notes":{"summary":"Ready for review","next_steps":["Run the verifier"],"blockers":["Needs input"]}}}`)) + })) + defer server.Close() + + subsystem := testPrepWithPlatformServer(t, server, "secret-token") + result := subsystem.handleSessionEnd(context.Background(), core.NewOptions( + core.Option{Key: "session_id", Value: "ses_handoff"}, + core.Option{Key: "status", Value: "paused"}, + core.Option{Key: "summary", Value: "Ready for review"}, + core.Option{Key: "handoff_notes", Value: `{"summary":"Ready for review","next_steps":["Run the verifier"],"blockers":["Needs input"]}`}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(SessionOutput) + require.True(t, ok) + assert.Equal(t, "paused", 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"]) + assert.Equal(t, []any{"Run the verifier"}, output.Session.Handoff["next_steps"]) + assert.Equal(t, []any{"Needs input"}, output.Session.Handoff["blockers"]) +} + +func TestSession_HandleSessionEnd_Bad_MissingSessionID(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "secret-token") + result := subsystem.handleSessionEnd(context.Background(), core.NewOptions( + core.Option{Key: "status", Value: "completed"}, + core.Option{Key: "summary", Value: "All green"}, + )) + assert.False(t, result.OK) +} + +func TestSession_HandleSessionEnd_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.handleSessionEnd(context.Background(), core.NewOptions( + core.Option{Key: "session_id", Value: "ses_abc123"}, + core.Option{Key: "status", Value: "completed"}, + core.Option{Key: "summary", Value: "All green"}, + )) + assert.False(t, result.OK) +} + func TestSession_HandleSessionLog_Good(t *testing.T) { subsystem := testPrepWithPlatformServer(t, nil, "") require.NoError(t, writeSessionCache(&Session{