Skip to content

Commit cfe46a2

Browse files
fix(prd): wrap PRD messages in payload and support configurable claude binary
Restructure PRDOutputMessage and PRDResponseCompleteMessage to use the payload wrapper pattern (matching SettingsResponseMessage), so Laravel's broadcastWith() correctly extracts the payload for the frontend. Add CHIEF_CLAUDE_BINARY env var to override the claude binary path, enabling E2E tests to use a mock instead of requiring real auth tokens.
1 parent 80ed779 commit cfe46a2

4 files changed

Lines changed: 112 additions & 75 deletions

File tree

internal/cmd/session.go

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ func (sm *sessionManager) newPRD(projectPath, projectName, sessionID, initialMes
126126
prompt := embed.GetInitPrompt(prdsDir, initialMessage)
127127

128128
// Spawn claude in print mode for non-interactive piped I/O
129-
cmd := exec.Command("claude", "-p", "--dangerously-skip-permissions", prompt)
129+
cmd := exec.Command(claudeBinary(), "-p", "--dangerously-skip-permissions", prompt)
130130
cmd.Dir = projectPath
131131
cmd.Env = filterEnv(os.Environ(), "CLAUDECODE")
132132

@@ -182,13 +182,12 @@ func (sm *sessionManager) newPRD(projectPath, projectName, sessionID, initialMes
182182
}
183183

184184
// Send prd_response_complete to signal the PRD session is done
185-
envelope := ws.NewMessage(ws.TypePRDResponseComplete)
186185
completeMsg := ws.PRDResponseCompleteMessage{
187-
Type: envelope.Type,
188-
ID: envelope.ID,
189-
Timestamp: envelope.Timestamp,
190-
SessionID: sessionID,
191-
Project: projectName,
186+
Type: ws.TypePRDResponseComplete,
187+
Payload: ws.PRDResponseCompletePayload{
188+
SessionID: sessionID,
189+
Project: projectName,
190+
},
192191
}
193192
if sendErr := sm.sender.Send(completeMsg); sendErr != nil {
194193
log.Printf("Error sending prd_response_complete: %v", sendErr)
@@ -226,7 +225,7 @@ func (sm *sessionManager) refinePRD(projectPath, projectName, sessionID, prdID,
226225
prompt := embed.GetEditPrompt(prdDir)
227226

228227
// Spawn claude in print mode for non-interactive piped I/O
229-
cmd := exec.Command("claude", "-p", "--dangerously-skip-permissions", prompt)
228+
cmd := exec.Command(claudeBinary(), "-p", "--dangerously-skip-permissions", prompt)
230229
cmd.Dir = projectPath
231230
cmd.Env = filterEnv(os.Environ(), "CLAUDECODE")
232231

@@ -289,13 +288,12 @@ func (sm *sessionManager) refinePRD(projectPath, projectName, sessionID, prdID,
289288
}
290289

291290
// Send prd_response_complete to signal the PRD session is done
292-
envelope := ws.NewMessage(ws.TypePRDResponseComplete)
293291
completeMsg := ws.PRDResponseCompleteMessage{
294-
Type: envelope.Type,
295-
ID: envelope.ID,
296-
Timestamp: envelope.Timestamp,
297-
SessionID: sessionID,
298-
Project: projectName,
292+
Type: ws.TypePRDResponseComplete,
293+
Payload: ws.PRDResponseCompletePayload{
294+
SessionID: sessionID,
295+
Project: projectName,
296+
},
299297
}
300298
if sendErr := sm.sender.Send(completeMsg); sendErr != nil {
301299
log.Printf("Error sending prd_response_complete: %v", sendErr)
@@ -327,14 +325,13 @@ func (sm *sessionManager) streamOutput(sessionID string, r io.Reader) {
327325
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
328326
for scanner.Scan() {
329327
line := scanner.Text()
330-
envelope := ws.NewMessage(ws.TypePRDOutput)
331328
msg := ws.PRDOutputMessage{
332-
Type: envelope.Type,
333-
ID: envelope.ID,
334-
Timestamp: envelope.Timestamp,
335-
SessionID: sessionID,
336-
Project: sess.project,
337-
Text: line + "\n",
329+
Type: ws.TypePRDOutput,
330+
Payload: ws.PRDOutputPayload{
331+
Content: line + "\n",
332+
SessionID: sessionID,
333+
Project: sess.project,
334+
},
338335
}
339336
if err := sm.sender.Send(msg); err != nil {
340337
log.Printf("Error sending prd_output: %v", err)
@@ -633,6 +630,15 @@ func handleClosePRDSession(sender messageSender, sessions *sessionManager, msg w
633630
log.Printf("Closed Claude PRD session %s (save=%v)", req.SessionID, req.Save)
634631
}
635632

633+
// claudeBinary returns the path to the claude CLI binary.
634+
// It checks the CHIEF_CLAUDE_BINARY environment variable first, falling back to "claude".
635+
func claudeBinary() string {
636+
if bin := os.Getenv("CHIEF_CLAUDE_BINARY"); bin != "" {
637+
return bin
638+
}
639+
return "claude"
640+
}
641+
636642
// filterEnv returns a copy of env with the named variables removed.
637643
func filterEnv(env []string, keys ...string) []string {
638644
filtered := make([]string, 0, len(env))

internal/cmd/session_test.go

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -374,13 +374,18 @@ echo "Session complete"
374374
t.Fatal("expected at least one prd_output message")
375375
}
376376

377-
// Verify session_id is set on all prd_output messages
377+
// Verify session_id and project are set on all prd_output messages (inside payload)
378378
for _, co := range prdOutputs {
379-
if co["session_id"] != "sess-mock-1" {
380-
t.Errorf("expected session_id 'sess-mock-1', got %v", co["session_id"])
379+
payload, _ := co["payload"].(map[string]interface{})
380+
if payload == nil {
381+
t.Error("expected prd_output to have a payload field")
382+
continue
381383
}
382-
if co["project"] != "myproject" {
383-
t.Errorf("expected project 'myproject', got %v", co["project"])
384+
if payload["session_id"] != "sess-mock-1" {
385+
t.Errorf("expected payload.session_id 'sess-mock-1', got %v", payload["session_id"])
386+
}
387+
if payload["project"] != "myproject" {
388+
t.Errorf("expected payload.project 'myproject', got %v", payload["project"])
384389
}
385390
}
386391

@@ -390,8 +395,11 @@ echo "Session complete"
390395
var msg map[string]interface{}
391396
if json.Unmarshal(raw, &msg) == nil && msg["type"] == "prd_response_complete" {
392397
hasComplete = true
393-
if msg["session_id"] != "sess-mock-1" {
394-
t.Errorf("expected session_id 'sess-mock-1' on prd_response_complete, got %v", msg["session_id"])
398+
payload, _ := msg["payload"].(map[string]interface{})
399+
if payload == nil {
400+
t.Error("expected prd_response_complete to have a payload field")
401+
} else if payload["session_id"] != "sess-mock-1" {
402+
t.Errorf("expected payload.session_id 'sess-mock-1' on prd_response_complete, got %v", payload["session_id"])
395403
}
396404
break
397405
}
@@ -403,13 +411,16 @@ echo "Session complete"
403411
// Verify we received some actual content
404412
hasContent := false
405413
for _, co := range prdOutputs {
406-
if text, ok := co["text"].(string); ok && strings.TrimSpace(text) != "" {
407-
hasContent = true
408-
break
414+
payload, _ := co["payload"].(map[string]interface{})
415+
if payload != nil {
416+
if content, ok := payload["content"].(string); ok && strings.TrimSpace(content) != "" {
417+
hasContent = true
418+
break
419+
}
409420
}
410421
}
411422
if !hasContent {
412-
t.Error("expected at least one prd_output with non-empty text")
423+
t.Error("expected at least one prd_output with non-empty content")
413424
}
414425
}
415426

@@ -638,9 +649,12 @@ done
638649
hasEcho := false
639650
for _, msg := range msgs {
640651
if msg["type"] == "prd_output" {
641-
if text, ok := msg["text"].(string); ok && strings.Contains(text, "echo: hello world") {
642-
hasEcho = true
643-
break
652+
payload, _ := msg["payload"].(map[string]interface{})
653+
if payload != nil {
654+
if content, ok := payload["content"].(string); ok && strings.Contains(content, "echo: hello world") {
655+
hasEcho = true
656+
break
657+
}
644658
}
645659
}
646660
}
@@ -991,13 +1005,18 @@ echo "Edit complete"
9911005
t.Fatal("expected at least one prd_output message")
9921006
}
9931007

994-
// Verify session_id is set on all prd_output messages
1008+
// Verify session_id and project are set on all prd_output messages (inside payload)
9951009
for _, co := range prdOutputs {
996-
if co["session_id"] != "sess-refine-mock-1" {
997-
t.Errorf("expected session_id 'sess-refine-mock-1', got %v", co["session_id"])
1010+
payload, _ := co["payload"].(map[string]interface{})
1011+
if payload == nil {
1012+
t.Error("expected prd_output to have a payload field")
1013+
continue
1014+
}
1015+
if payload["session_id"] != "sess-refine-mock-1" {
1016+
t.Errorf("expected payload.session_id 'sess-refine-mock-1', got %v", payload["session_id"])
9981017
}
999-
if co["project"] != "myproject" {
1000-
t.Errorf("expected project 'myproject', got %v", co["project"])
1018+
if payload["project"] != "myproject" {
1019+
t.Errorf("expected payload.project 'myproject', got %v", payload["project"])
10011020
}
10021021
}
10031022

@@ -1007,8 +1026,11 @@ echo "Edit complete"
10071026
var msg map[string]interface{}
10081027
if json.Unmarshal(raw, &msg) == nil && msg["type"] == "prd_response_complete" {
10091028
hasComplete = true
1010-
if msg["session_id"] != "sess-refine-mock-1" {
1011-
t.Errorf("expected session_id 'sess-refine-mock-1' on prd_response_complete, got %v", msg["session_id"])
1029+
payload, _ := msg["payload"].(map[string]interface{})
1030+
if payload == nil {
1031+
t.Error("expected prd_response_complete to have a payload field")
1032+
} else if payload["session_id"] != "sess-refine-mock-1" {
1033+
t.Errorf("expected payload.session_id 'sess-refine-mock-1' on prd_response_complete, got %v", payload["session_id"])
10121034
}
10131035
break
10141036
}
@@ -1020,9 +1042,12 @@ echo "Edit complete"
10201042
// Verify the user's message was received by Claude (should appear in prd_output)
10211043
hasUserMessage := false
10221044
for _, co := range prdOutputs {
1023-
if text, ok := co["text"].(string); ok && strings.Contains(text, "Add OAuth support") {
1024-
hasUserMessage = true
1025-
break
1045+
payload, _ := co["payload"].(map[string]interface{})
1046+
if payload != nil {
1047+
if content, ok := payload["content"].(string); ok && strings.Contains(content, "Add OAuth support") {
1048+
hasUserMessage = true
1049+
break
1050+
}
10261051
}
10271052
}
10281053
if !hasUserMessage {

internal/ws/messages.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -208,23 +208,29 @@ type ClaudeOutputMessage struct {
208208
Done bool `json:"done"`
209209
}
210210

211+
// PRDOutputPayload is the payload of a PRD output message.
212+
type PRDOutputPayload struct {
213+
Content string `json:"content"`
214+
SessionID string `json:"session_id"`
215+
Project string `json:"project"`
216+
}
217+
211218
// PRDOutputMessage streams PRD session output (text chunks from Claude).
212219
type PRDOutputMessage struct {
213-
Type string `json:"type"`
214-
ID string `json:"id"`
215-
Timestamp string `json:"timestamp"`
220+
Type string `json:"type"`
221+
Payload PRDOutputPayload `json:"payload"`
222+
}
223+
224+
// PRDResponseCompletePayload is the payload of a PRD response complete message.
225+
type PRDResponseCompletePayload struct {
216226
SessionID string `json:"session_id"`
217227
Project string `json:"project"`
218-
Text string `json:"text"`
219228
}
220229

221230
// PRDResponseCompleteMessage signals that a PRD session's Claude process has finished.
222231
type PRDResponseCompleteMessage struct {
223-
Type string `json:"type"`
224-
ID string `json:"id"`
225-
Timestamp string `json:"timestamp"`
226-
SessionID string `json:"session_id"`
227-
Project string `json:"project"`
232+
Type string `json:"type"`
233+
Payload PRDResponseCompletePayload `json:"payload"`
228234
}
229235

230236
// RunProgressMessage reports run state changes.

internal/ws/messages_test.go

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -950,12 +950,12 @@ func TestProjectListRoundTrip(t *testing.T) {
950950

951951
func TestPRDOutputRoundTrip(t *testing.T) {
952952
msg := PRDOutputMessage{
953-
Type: TypePRDOutput,
954-
ID: newUUID(),
955-
Timestamp: "2026-02-15T10:00:00Z",
956-
SessionID: "session-123",
957-
Project: "my-project",
958-
Text: "Here is the PRD content\n",
953+
Type: TypePRDOutput,
954+
Payload: PRDOutputPayload{
955+
Content: "Here is the PRD content\n",
956+
SessionID: "session-123",
957+
Project: "my-project",
958+
},
959959
}
960960

961961
data, err := json.Marshal(msg)
@@ -971,24 +971,24 @@ func TestPRDOutputRoundTrip(t *testing.T) {
971971
if got.Type != TypePRDOutput {
972972
t.Errorf("type = %q, want %q", got.Type, TypePRDOutput)
973973
}
974-
if got.SessionID != "session-123" {
975-
t.Errorf("session_id = %q, want %q", got.SessionID, "session-123")
974+
if got.Payload.SessionID != "session-123" {
975+
t.Errorf("payload.session_id = %q, want %q", got.Payload.SessionID, "session-123")
976976
}
977-
if got.Project != "my-project" {
978-
t.Errorf("project = %q, want %q", got.Project, "my-project")
977+
if got.Payload.Project != "my-project" {
978+
t.Errorf("payload.project = %q, want %q", got.Payload.Project, "my-project")
979979
}
980-
if got.Text != "Here is the PRD content\n" {
981-
t.Errorf("text = %q, want %q", got.Text, "Here is the PRD content\n")
980+
if got.Payload.Content != "Here is the PRD content\n" {
981+
t.Errorf("payload.content = %q, want %q", got.Payload.Content, "Here is the PRD content\n")
982982
}
983983
}
984984

985985
func TestPRDResponseCompleteRoundTrip(t *testing.T) {
986986
msg := PRDResponseCompleteMessage{
987-
Type: TypePRDResponseComplete,
988-
ID: newUUID(),
989-
Timestamp: "2026-02-15T10:00:00Z",
990-
SessionID: "session-123",
991-
Project: "my-project",
987+
Type: TypePRDResponseComplete,
988+
Payload: PRDResponseCompletePayload{
989+
SessionID: "session-123",
990+
Project: "my-project",
991+
},
992992
}
993993

994994
data, err := json.Marshal(msg)
@@ -1004,11 +1004,11 @@ func TestPRDResponseCompleteRoundTrip(t *testing.T) {
10041004
if got.Type != TypePRDResponseComplete {
10051005
t.Errorf("type = %q, want %q", got.Type, TypePRDResponseComplete)
10061006
}
1007-
if got.SessionID != "session-123" {
1008-
t.Errorf("session_id = %q, want %q", got.SessionID, "session-123")
1007+
if got.Payload.SessionID != "session-123" {
1008+
t.Errorf("payload.session_id = %q, want %q", got.Payload.SessionID, "session-123")
10091009
}
1010-
if got.Project != "my-project" {
1011-
t.Errorf("project = %q, want %q", got.Project, "my-project")
1010+
if got.Payload.Project != "my-project" {
1011+
t.Errorf("payload.project = %q, want %q", got.Payload.Project, "my-project")
10121012
}
10131013
}
10141014

0 commit comments

Comments
 (0)