Skip to content

Commit c8f9ca4

Browse files
committed
Merge remote-tracking branch 'origin/main' into notification_json
2 parents bdead09 + 6602aae commit c8f9ca4

File tree

3 files changed

+238
-9
lines changed

3 files changed

+238
-9
lines changed

executor_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3558,3 +3558,185 @@ func TestExecuteNotificationAction_TypeCommand(t *testing.T) {
35583558
})
35593559
}
35603560
}
3561+
func TestExecuteSessionEndAction_TypeOutput(t *testing.T) {
3562+
tests := []struct {
3563+
name string
3564+
action Action
3565+
wantContinue bool
3566+
wantSystemMessage string
3567+
wantErr bool
3568+
}{
3569+
{
3570+
name: "Message only -> systemMessage set, continue=true",
3571+
action: Action{
3572+
Type: "output",
3573+
Message: "Session cleanup completed",
3574+
},
3575+
wantContinue: true,
3576+
wantSystemMessage: "Session cleanup completed",
3577+
wantErr: false,
3578+
},
3579+
{
3580+
name: "Empty message -> fail-safe (systemMessage=fixed message, continue=true)",
3581+
action: Action{
3582+
Type: "output",
3583+
Message: "",
3584+
},
3585+
wantContinue: true,
3586+
wantSystemMessage: "Empty message in SessionEnd action",
3587+
wantErr: false,
3588+
},
3589+
{
3590+
name: "exit_status specified -> ignore exit_status, emit warning",
3591+
action: Action{
3592+
Type: "output",
3593+
Message: "Test message",
3594+
ExitStatus: intPtr(2),
3595+
},
3596+
wantContinue: true,
3597+
wantSystemMessage: "Test message",
3598+
wantErr: false,
3599+
},
3600+
}
3601+
3602+
for _, tt := range tests {
3603+
t.Run(tt.name, func(t *testing.T) {
3604+
runner := &stubRunnerWithOutput{}
3605+
executor := &ActionExecutor{runner: runner}
3606+
input := &SessionEndInput{}
3607+
3608+
result, err := executor.ExecuteSessionEndAction(tt.action, input, map[string]interface{}{})
3609+
3610+
if (err != nil) != tt.wantErr {
3611+
t.Errorf("ExecuteSessionEndAction() error = %v, wantErr %v", err, tt.wantErr)
3612+
return
3613+
}
3614+
3615+
if result == nil {
3616+
t.Fatal("Expected non-nil ActionOutput, got nil")
3617+
}
3618+
3619+
if result.Continue != tt.wantContinue {
3620+
t.Errorf("Continue = %v, want %v", result.Continue, tt.wantContinue)
3621+
}
3622+
3623+
if result.SystemMessage != tt.wantSystemMessage {
3624+
t.Errorf("SystemMessage = %q, want %q", result.SystemMessage, tt.wantSystemMessage)
3625+
}
3626+
})
3627+
}
3628+
}
3629+
3630+
func TestExecuteSessionEndAction_TypeCommand(t *testing.T) {
3631+
tests := []struct {
3632+
name string
3633+
stdout string
3634+
stderr string
3635+
exitCode int
3636+
cmdErr error
3637+
wantContinue bool
3638+
wantSystemMessage string
3639+
wantErr bool
3640+
}{
3641+
{
3642+
name: "Valid JSON output with all fields",
3643+
stdout: `{"continue": true, "stopReason": "cleanup done", "suppressOutput": false, "systemMessage": "Session ended"}`,
3644+
exitCode: 0,
3645+
wantContinue: true,
3646+
wantErr: false,
3647+
},
3648+
{
3649+
name: "Valid JSON output with minimal fields",
3650+
stdout: `{"continue": true}`,
3651+
exitCode: 0,
3652+
wantContinue: true,
3653+
wantErr: false,
3654+
},
3655+
{
3656+
name: "Empty stdout -> continue=true, no error",
3657+
stdout: "",
3658+
exitCode: 0,
3659+
wantContinue: true,
3660+
wantSystemMessage: "",
3661+
wantErr: false,
3662+
},
3663+
{
3664+
name: "Command failed (exit code 1) -> fail-safe (continue=true, systemMessage=error)",
3665+
stdout: "",
3666+
stderr: "command error",
3667+
exitCode: 1,
3668+
wantContinue: true,
3669+
wantSystemMessage: "Command failed with exit code 1: command error",
3670+
wantErr: false,
3671+
},
3672+
{
3673+
name: "Invalid JSON -> fail-safe (continue=true, systemMessage=error)",
3674+
stdout: `{"continue": "invalid"}`,
3675+
exitCode: 0,
3676+
wantContinue: true,
3677+
wantSystemMessage: "Command output is not valid JSON: {\"continue\": \"invalid\"}",
3678+
wantErr: false,
3679+
},
3680+
{
3681+
name: "Unsupported field in JSON -> warning to stderr, continue processing",
3682+
stdout: `{"continue": true, "decision": "block"}`,
3683+
exitCode: 0,
3684+
wantContinue: true,
3685+
wantErr: false,
3686+
},
3687+
}
3688+
3689+
for _, tt := range tests {
3690+
t.Run(tt.name, func(t *testing.T) {
3691+
runner := &stubRunnerWithOutput{
3692+
stdout: tt.stdout,
3693+
stderr: tt.stderr,
3694+
exitCode: tt.exitCode,
3695+
err: tt.cmdErr,
3696+
}
3697+
executor := &ActionExecutor{runner: runner}
3698+
input := &SessionEndInput{}
3699+
action := Action{
3700+
Type: "command",
3701+
Command: "test-command",
3702+
}
3703+
3704+
// Capture stderr to check for warnings
3705+
oldStderr := os.Stderr
3706+
r, w, _ := os.Pipe()
3707+
os.Stderr = w
3708+
3709+
result, err := executor.ExecuteSessionEndAction(action, input, map[string]interface{}{})
3710+
3711+
_ = w.Close()
3712+
os.Stderr = oldStderr
3713+
var buf bytes.Buffer
3714+
_, _ = io.Copy(&buf, r)
3715+
stderrOutput := buf.String()
3716+
3717+
if (err != nil) != tt.wantErr {
3718+
t.Errorf("ExecuteSessionEndAction() error = %v, wantErr %v", err, tt.wantErr)
3719+
return
3720+
}
3721+
3722+
if result == nil {
3723+
t.Fatal("Expected non-nil ActionOutput, got nil")
3724+
}
3725+
3726+
if result.Continue != tt.wantContinue {
3727+
t.Errorf("Continue = %v, want %v", result.Continue, tt.wantContinue)
3728+
}
3729+
3730+
if tt.wantSystemMessage != "" && result.SystemMessage != tt.wantSystemMessage {
3731+
t.Errorf("SystemMessage = %q, want %q", result.SystemMessage, tt.wantSystemMessage)
3732+
}
3733+
3734+
// Check for unsupported field warnings
3735+
if tt.name == "Unsupported field in JSON -> warning to stderr, continue processing" {
3736+
if !strings.Contains(stderrOutput, "Warning") || !strings.Contains(stderrOutput, "decision") {
3737+
t.Errorf("Expected warning about unsupported field 'decision' in stderr, got: %s", stderrOutput)
3738+
}
3739+
}
3740+
})
3741+
}
3742+
}

main.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,52 @@ func main() {
286286
os.Exit(0)
287287
}
288288

289+
290+
if HookEventType(*eventType) == SessionEnd {
291+
// SessionEnd special handling with JSON output
292+
output, err := RunSessionEndHooks(config)
293+
if err != nil {
294+
// Log error to stderr
295+
fmt.Fprintf(os.Stderr, "Warning: %v\n", err)
296+
if output == nil {
297+
output = &SessionEndOutput{
298+
Continue: true,
299+
SystemMessage: fmt.Sprintf("Failed to process SessionEnd: %v", err),
300+
}
301+
} else {
302+
// fail-safe: SessionEnd always continue=true, add error to systemMessage
303+
errMsg := fmt.Sprintf("Failed to process SessionEnd: %v", err)
304+
if output.SystemMessage != "" {
305+
output.SystemMessage += "\n" + errMsg
306+
} else {
307+
output.SystemMessage = errMsg
308+
}
309+
}
310+
}
311+
312+
// Marshal JSON with 2-space indent
313+
jsonBytes, err := json.MarshalIndent(output, "", " ")
314+
if err != nil {
315+
// Marshal failure should not be fatal - output minimal valid JSON and exit 0
316+
fmt.Fprintf(os.Stderr, "Warning: Error marshaling JSON: %v\n", err)
317+
fallbackOutput := SessionEndOutput{
318+
Continue: true,
319+
SystemMessage: fmt.Sprintf("Failed to marshal output: %v", err),
320+
}
321+
jsonBytes, _ = json.MarshalIndent(fallbackOutput, "", " ")
322+
}
323+
324+
// Validate final JSON output against schema (non-functional requirement)
325+
if err := validateSessionEndOutput(jsonBytes); err != nil {
326+
// Validation failure should not be fatal - log warning and continue
327+
fmt.Fprintf(os.Stderr, "Warning: Final JSON output validation failed: %v\n", err)
328+
}
329+
330+
// Output JSON to stdout
331+
fmt.Println(string(jsonBytes))
332+
// Always exit 0 for SessionEnd (session end cannot be blocked)
333+
os.Exit(0)
334+
}
289335
if HookEventType(*eventType) == PostToolUse {
290336
// PostToolUse special handling with JSON output
291337
output, err := RunPostToolUseHooks(config)

types.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,16 @@ type SubagentStopOutput struct {
300300
SystemMessage string `json:"systemMessage,omitempty"`
301301
}
302302

303+
// SessionEndOutput はSessionEndフックのJSON出力全体を表す(Claude Code共通フィールド含む)
304+
// SessionEndはCommon JSON Fieldsのみで、hookSpecificOutput、decision、reasonフィールドは存在しない
305+
// セッション終了をブロックできないため、continueは常にtrueとする(fail-safe設計)
306+
type SessionEndOutput struct {
307+
Continue bool `json:"continue"`
308+
StopReason string `json:"stopReason,omitempty"`
309+
SuppressOutput bool `json:"suppressOutput,omitempty"`
310+
SystemMessage string `json:"systemMessage,omitempty"`
311+
}
312+
303313
// PostToolUseOutput represents the complete JSON output structure for PostToolUse hooks
304314
// following Claude Code JSON specification
305315
type PostToolUseOutput struct {
@@ -318,15 +328,6 @@ type PostToolUseHookSpecificOutput struct {
318328
AdditionalContext string `json:"additionalContext,omitempty"` // Optional: additional information for Claude
319329
}
320330

321-
// SessionEndOutput represents the JSON output structure for SessionEnd hooks
322-
// SessionEnd uses Common JSON Fields only (no hookSpecificOutput)
323-
type SessionEndOutput struct {
324-
Continue bool `json:"continue"`
325-
StopReason string `json:"stopReason,omitempty"`
326-
SuppressOutput bool `json:"suppressOutput,omitempty"`
327-
SystemMessage string `json:"systemMessage,omitempty"`
328-
}
329-
330331
// SessionEnd用
331332
type SessionEndInput struct {
332333
BaseInput

0 commit comments

Comments
 (0)