feat(session): accept handoff_notes in session end

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 21:28:07 +00:00
parent bebf9f8df5
commit cc552ed9dd
2 changed files with 105 additions and 12 deletions

View file

@ -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")
}

View file

@ -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{