diff --git a/apps/tui-go/internal/app/clarcard.go b/apps/tui-go/internal/app/clarcard.go new file mode 100644 index 00000000..352f12b2 --- /dev/null +++ b/apps/tui-go/internal/app/clarcard.go @@ -0,0 +1,336 @@ +package app + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/correx/tui-go/internal/protocol" +) + +// clarcard.go renders and drives the clarification view: a stage's open questions +// presented as an interactive form — a header chip, the prompt, a row of selectable +// option chips, and a custom free-text slot, like the AskUserQuestion widget. +// Submitting sends the answers back; the parked stage re-runs with them in context. + +// --- per-question widget state --- + +func (m *Model) clarInitState(n int) { + m.clarFocus = 0 + m.clarCursor = 0 + m.clarTyping = false + m.clarDismissed = false + m.clarChosen = make([]map[int]bool, n) + for i := range m.clarChosen { + m.clarChosen[i] = map[int]bool{} + } + m.clarText = make([]string, n) +} + +func (m *Model) clarResetState() { + m.clarChosen = nil + m.clarText = nil + m.clarFocus = 0 + m.clarCursor = 0 + m.clarTyping = false + m.clarDismissed = false +} + +// clarEnsureState (re)initializes the per-question buffers when they don't match the +// selected session's question set — e.g. a clarification that arrived for a session +// before it was selected, so onClarificationRequired didn't size the buffers. +func (m *Model) clarEnsureState() { + s := m.session(m.selectedID) + if s == nil || s.Clar == nil { + return + } + if len(m.clarChosen) != len(s.Clar.Questions) || len(m.clarText) != len(s.Clar.Questions) { + m.clarInitState(len(s.Clar.Questions)) + } +} + +func (m Model) clarChosenAt(i, oi int) bool { + if i < 0 || i >= len(m.clarChosen) || m.clarChosen[i] == nil { + return false + } + return m.clarChosen[i][oi] +} + +func (m Model) clarTextAt(i int) string { + if i < 0 || i >= len(m.clarText) { + return "" + } + return m.clarText[i] +} + +func (m Model) clarAnswered(i int, q ClarQuestion) bool { + if strings.TrimSpace(m.clarTextAt(i)) != "" { + return true + } + for oi := range q.Options { + if m.clarChosenAt(i, oi) { + return true + } + } + return false +} + +// clarAnswerValue is the submitted value for question i: the custom text if present, +// else the chosen option(s) joined in option order. +func (m Model) clarAnswerValue(i int, q ClarQuestion) string { + if t := strings.TrimSpace(m.clarTextAt(i)); t != "" { + return t + } + var sel []string + for oi, opt := range q.Options { + if m.clarChosenAt(i, oi) { + sel = append(sel, opt) + } + } + return strings.Join(sel, ", ") +} + +// --- key handling --- + +func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + s := m.session(m.selectedID) + if s == nil || s.Clar == nil { + return m, nil + } + qs := s.Clar.Questions + if m.clarTyping { + return m.handleClarTyping(k), nil + } + switch k.Type { + case tea.KeyEsc: + m.clarDismissed = true + case tea.KeyUp: + m.clarFocusMove(-1, qs) + case tea.KeyDown: + m.clarFocusMove(1, qs) + case tea.KeyLeft: + m.clarCursorMove(-1, qs) + case tea.KeyRight: + m.clarCursorMove(1, qs) + case tea.KeySpace: + m.clarSelect(qs) + case tea.KeyEnter: + return m.clarSubmit() + case tea.KeyRunes: + switch string(k.Runes) { + case "k": + m.clarFocusMove(-1, qs) + case "j": + m.clarFocusMove(1, qs) + case "h": + m.clarCursorMove(-1, qs) + case "l": + m.clarCursorMove(1, qs) + case "e": + m.clarCursor = len(qs[m.clarFocus].Options) // jump to the custom slot + m.clarTyping = true + case "s": + return m.clarSubmit() + } + } + return m, nil +} + +func (m Model) handleClarTyping(k tea.KeyMsg) tea.Model { + if m.clarFocus >= len(m.clarText) { + return m + } + switch k.Type { + case tea.KeyEsc: + m.clarTyping = false + case tea.KeyEnter: + m.clarTyping = false + // committing custom text supersedes any chosen option for this question + if m.clarFocus < len(m.clarChosen) { + m.clarChosen[m.clarFocus] = map[int]bool{} + } + case tea.KeyBackspace: + if t := m.clarText[m.clarFocus]; len(t) > 0 { + m.clarText[m.clarFocus] = t[:len(t)-1] + } + case tea.KeyRunes, tea.KeySpace: + m.clarText[m.clarFocus] += string(k.Runes) + } + return m +} + +func (m *Model) clarFocusMove(d int, qs []ClarQuestion) { + if len(qs) == 0 { + return + } + m.clarFocus = clampInt(m.clarFocus+d, 0, len(qs)-1) + m.clarCursor = clampInt(m.clarCursor, 0, len(qs[m.clarFocus].Options)) +} + +func (m *Model) clarCursorMove(d int, qs []ClarQuestion) { + if m.clarFocus >= len(qs) { + return + } + m.clarCursor = clampInt(m.clarCursor+d, 0, len(qs[m.clarFocus].Options)) +} + +func (m *Model) clarSelect(qs []ClarQuestion) { + if m.clarFocus >= len(qs) || m.clarFocus >= len(m.clarChosen) { + return + } + q := qs[m.clarFocus] + if m.clarCursor >= len(q.Options) { // custom slot + m.clarTyping = true + return + } + if q.MultiSelect { + if m.clarChosen[m.clarFocus] == nil { + m.clarChosen[m.clarFocus] = map[int]bool{} + } + m.clarChosen[m.clarFocus][m.clarCursor] = !m.clarChosen[m.clarFocus][m.clarCursor] + } else { + m.clarChosen[m.clarFocus] = map[int]bool{m.clarCursor: true} + } + if m.clarFocus < len(m.clarText) { + m.clarText[m.clarFocus] = "" // choosing an option supersedes a typed answer + } +} + +func (m Model) clarSubmit() (tea.Model, tea.Cmd) { + s := m.session(m.selectedID) + if s == nil || s.Clar == nil { + return m, nil + } + qs := s.Clar.Questions + for i, q := range qs { + if !m.clarAnswered(i, q) { + m.clarFocus = i // nudge to the first unanswered rather than submitting partial + m.clarCursor = 0 + return m, nil + } + } + answers := make([]protocol.ClarificationAnswerDto, 0, len(qs)) + for i, q := range qs { + answers = append(answers, protocol.ClarificationAnswerDto{ + QuestionID: q.ID, + Value: m.clarAnswerValue(i, q), + }) + } + m.client.Send(protocol.ClarificationResponse(s.Clar.SessionID, s.Clar.StageID, s.Clar.RequestID, answers)) + s.Clar = nil + m.clarResetState() + return m, nil +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// --- rendering --- + +func (m Model) clarificationModal() string { + t := m.theme + w := m.modalWidth() + s := m.session(m.selectedID) + if s == nil || s.Clar == nil { + return "" + } + qs := s.Clar.Questions + + var b strings.Builder + b.WriteString(m.titleLine("clarifying questions")) + b.WriteString(mbg(t, " — "+s.Clar.StageID, t.P.Dim)) + answered := 0 + for i, q := range qs { + if m.clarAnswered(i, q) { + answered++ + } + } + b.WriteString(mbg(t, " ("+itoa(answered)+"/"+itoa(len(qs))+" answered)", t.P.Faint) + "\n\n") + + contentW := w - 6 + for i, q := range qs { + b.WriteString(m.clarQuestionLines(i, q, i == m.clarFocus, contentW)) + if i < len(qs)-1 { + b.WriteString("\n") + } + } + + b.WriteString("\n" + modalHints(t, [][2]string{ + {"←→", "option"}, {"↑↓", "question"}, {"space", "select"}, + {"e", "custom"}, {"enter", "submit"}, {"esc", "later"}, + })) + return t.Overlay.Width(w).Render(b.String()) +} + +// clarQuestionLines renders one question: a focus marker + header chip + prompt, then +// a wrapped row of option chips (cursor highlighted, chosen marked) plus a custom slot. +func (m Model) clarQuestionLines(i int, q ClarQuestion, focused bool, w int) string { + t := m.theme + var b strings.Builder + + marker := mbg(t, " ", t.P.BgPanel) + promptFg := t.P.Fg + if focused { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") + promptFg = t.P.FgStrong + } + head := marker + if q.Header != "" { + head += lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render("[" + q.Header + "] ") + } + head += lipgloss.NewStyle().Foreground(promptFg).Background(t.P.BgPanel).Render(q.Prompt) + if q.MultiSelect { + head += mbg(t, " (multi)", t.P.Faint) + } + b.WriteString(clip(head, w) + "\n") + + chips := make([]string, 0, len(q.Options)+1) + for oi, opt := range q.Options { + chips = append(chips, m.clarChip(opt, m.clarChosenAt(i, oi), focused && m.clarCursor == oi)) + } + chips = append(chips, m.clarCustomChip(i, q, focused)) + + gap := mbg(t, " ", t.P.BgPanel) + for _, line := range wrapStyled(chips, gap, 2, w-4) { + b.WriteString(mbg(t, " ", t.P.BgPanel) + line + "\n") + } + return b.String() +} + +// clarCustomChip renders the free-text slot: "+ custom" when empty, the typed text +// (with a caret while editing) otherwise. +func (m Model) clarCustomChip(i int, q ClarQuestion, focused bool) string { + text := m.clarTextAt(i) + chosen := strings.TrimSpace(text) != "" + cursor := focused && m.clarCursor >= len(q.Options) + label := "+ custom" + switch { + case cursor && m.clarTyping: + label = "✎ " + text + "▏" + case chosen: + label = "✎ " + text + } + return m.clarChip(label, chosen, cursor) +} + +// clarChip renders one option as a bracketed badge: inverse-highlighted when the +// cursor is on it, marked (●) when chosen. +func (m Model) clarChip(label string, chosen, cursor bool) string { + t := m.theme + mark, fg := "○ ", t.P.Dim + if chosen { + mark, fg = "● ", t.P.Accent2 + } + style := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel) + if cursor { + style = lipgloss.NewStyle().Foreground(t.P.BgPanel).Background(t.P.Accent).Bold(true) + } + return style.Render(" " + mark + label + " ") +} diff --git a/apps/tui-go/internal/app/clarcard_test.go b/apps/tui-go/internal/app/clarcard_test.go new file mode 100644 index 00000000..2a50e8f3 --- /dev/null +++ b/apps/tui-go/internal/app/clarcard_test.go @@ -0,0 +1,105 @@ +package app + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/correx/tui-go/internal/protocol" + "github.com/correx/tui-go/internal/ws" +) + +func clarModel() Model { + m := NewModel(ws.New("", 0)) // unconnected; Send just buffers + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + m.selectedID = "s1" + m.sessionEntered = true + m.applyServer(protocol.ServerMessage{ + Type: protocol.TypeClarification, + SessionID: "s1", + RequestID: "req-1", + StageID: "analyst", + Questions: []protocol.ClarificationQuestionDto{ + {ID: "stack", Prompt: "Which stack?", Options: []string{"React", "Vue"}, Header: "Stack"}, + }, + }) + return m +} + +func TestClarificationEntersStateAndRenders(t *testing.T) { + m := clarModel() + if m.displayState() != StateClarification { + t.Fatalf("want StateClarification, got %v", m.displayState()) + } + out := m.clarificationModal() + for _, want := range []string{"clarifying questions", "Which stack?", "React", "Vue", "Stack", "custom"} { + if !strings.Contains(out, want) { + t.Fatalf("modal missing %q:\n%s", want, out) + } + } +} + +func TestClarificationSelectAndSubmit(t *testing.T) { + m := clarModel() + // move the option cursor to "Vue" (index 1) and select it + m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight}) + m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace}) + if !m.clarChosenAt(0, 1) { + t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen) + } + // submit + m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if s := m.session("s1"); s == nil || s.Clar != nil { + t.Fatalf("expected clarification cleared after submit") + } + if m.displayState() != StateInSession { + t.Fatalf("want StateInSession after submit, got %v", m.displayState()) + } +} + +func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) { + m := clarModel() + m.applyServer(protocol.ServerMessage{ + Type: protocol.TypeClarification, SessionID: "s1", RequestID: "req-2", StageID: "analyst", + Questions: []protocol.ClarificationQuestionDto{ + {ID: "a", Prompt: "Q A?", Options: []string{"x"}}, + {ID: "b", Prompt: "Q B?", Options: []string{"y"}}, + }, + }) + // answer only the first question, then attempt submit + m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace}) + m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if s := m.session("s1"); s == nil || s.Clar == nil { + t.Fatalf("expected clarification to remain with partial answers") + } + if m.clarFocus != 1 { + t.Fatalf("expected focus nudged to first unanswered (1), got %d", m.clarFocus) + } +} + +func TestClarificationCustomTextAnswer(t *testing.T) { + m := clarModel() + m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + if !m.clarTyping { + t.Fatalf("expected typing mode after 'e'") + } + for _, r := range "Solid" { + m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.clarTyping { + t.Fatalf("expected typing committed") + } + if m.clarTextAt(0) != "Solid" { + t.Fatalf("want custom text 'Solid', got %q", m.clarTextAt(0)) + } + if !m.clarAnswered(0, m.session("s1").Clar.Questions[0]) { + t.Fatalf("expected question answered via custom text") + } +} + +func applyClarKey(m Model, k tea.KeyMsg) Model { + updated, _ := m.handleClarificationKey(k) + return updated.(Model) +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index b84053de..95513537 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -12,6 +12,7 @@ const ( StateIdle DisplayState = iota StateInSession StateApproval + StateClarification ) // InputMode toggles what a submitted line targets. @@ -107,6 +108,23 @@ type Approval struct { Rationale []string } +// ClarQuestion is one open question a stage raised (mirrors the wire DTO). +type ClarQuestion struct { + ID string + Prompt string + Header string + Options []string + MultiSelect bool +} + +// Clarification is a stage's pending set of open questions for the operator. +type Clarification struct { + RequestID string + SessionID string + StageID string + Questions []ClarQuestion +} + // Session is the UI's view of one server session. type Session struct { ID string @@ -122,7 +140,8 @@ type Session struct { ToolsByStage map[string][]ManifestTool Events []EventEntry Pending *Approval - Active bool // an inference/tool is in flight (drives the spinner) + Clar *Clarification // open questions awaiting answers (clarification view) + Active bool // an inference/tool is in flight (drives the spinner) } // Workflow is a launchable workflow advertised by the server. @@ -229,6 +248,14 @@ type Model struct { // approval steering input buffer steerBuffer string steering bool + + // clarification view (the interactive question form) + clarFocus int // focused question index + clarCursor int // option cursor in the focused question (== len(opts) → custom slot) + clarChosen []map[int]bool // per-question selected option indices + clarText []string // per-question free-text / custom answer + clarTyping bool // typing into the custom buffer for the focused question + clarDismissed bool // peeked away from the form (it can be reopened) } // NewModel builds the initial idle state. @@ -257,10 +284,16 @@ func (m Model) displayState() DisplayState { if m.selectedID == "" || !m.sessionEntered { return StateIdle } + s := m.session(m.selectedID) + // A stage's open questions take precedence: the run is parked on them, and they + // must be answered (or peeked away) before anything else makes sense. + if s != nil && s.Clar != nil && !m.clarDismissed { + return StateClarification + } if m.approvalDismissed { return StateInSession } - if s := m.session(m.selectedID); s != nil && s.Pending != nil { + if s != nil && s.Pending != nil { return StateApproval } return StateInSession diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 1344f097..7545e4a5 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -132,11 +132,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { m.touch(msg.SessionID, "COMPLETED") if s := m.session(msg.SessionID); s != nil { s.Active = false + s.Clar = nil } case protocol.TypeSessionFailed: m.touch(msg.SessionID, "FAILED") if s := m.session(msg.SessionID); s != nil { s.Active = false + s.Clar = nil } case protocol.TypeStageStarted: if s := m.session(msg.SessionID); s != nil { @@ -242,6 +244,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } case protocol.TypeApprovalRequired: m.onApprovalRequired(msg) + case protocol.TypeClarification: + m.onClarificationRequired(msg) case protocol.TypeApprovalResolved: if s := m.session(msg.SessionID); s != nil { s.Pending = nil @@ -400,6 +404,26 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) { } } +func (m *Model) onClarificationRequired(msg protocol.ServerMessage) { + qs := make([]ClarQuestion, 0, len(msg.Questions)) + for _, q := range msg.Questions { + qs = append(qs, ClarQuestion{ + ID: q.ID, Prompt: q.Prompt, Header: q.Header, + Options: q.Options, MultiSelect: q.MultiSelect, + }) + } + c := &Clarification{ + RequestID: msg.RequestID, SessionID: msg.SessionID, + StageID: msg.StageID, Questions: qs, + } + if s := m.session(msg.SessionID); s != nil { + s.Clar = c + } + if msg.SessionID == m.selectedID { + m.clarInitState(len(qs)) + } +} + func (m *Model) onSnapshot(msg protocol.ServerMessage) { var pending *Approval if len(msg.PendingAppr) > 0 { diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 44bbc523..0b2bb9db 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -104,6 +104,10 @@ func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.overlay != OverlayNone { return m.handleOverlayKey(k) } + if m.displayState() == StateClarification { + m.clarEnsureState() + return m.handleClarificationKey(k) + } if m.editMode == ModeInsert { return m.handleInsertKey(k) } diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 9f666ab5..56870ee0 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -55,6 +55,9 @@ func (m Model) View() string { m.renderFooter(), ) + if m.displayState() == StateClarification { + return m.center(m.clarificationModal()) + } if m.overlay != OverlayNone { return m.renderOverlay(base) } @@ -218,6 +221,9 @@ func (m Model) renderFooter() string { case m.displayState() == StateApproval: // Approval actions live in the band itself; the footer offers navigation only. hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")} + case m.displayState() == StateClarification: + // The question form owns its keys; the footer mirrors the essentials. + hints = []string{hint("←→", "option"), hint("↑↓", "question"), hint("spc", "select"), hint("enter", "submit"), hint("esc", "later")} case m.displayState() == StateIdle: if nrw { hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")} diff --git a/apps/tui-go/internal/protocol/golden_test.go b/apps/tui-go/internal/protocol/golden_test.go index 6f914934..082a9564 100644 --- a/apps/tui-go/internal/protocol/golden_test.go +++ b/apps/tui-go/internal/protocol/golden_test.go @@ -149,6 +149,27 @@ func TestDecodeGoldenServerFrames(t *testing.T) { } }, }, + { + name: "clarification.required carries structured questions", + json: `{"type":"clarification.required","sessionId":"s1","requestId":"req-1","stageId":"analyst",` + + `"questions":[{"id":"stack","prompt":"Which stack?","options":["React","Vue"],"multiSelect":false,"header":"Stack"}],` + + `"sequence":9,"sessionSequence":4}`, + wantType: TypeClarification, + eventBear: true, + check: func(t *testing.T, m ServerMessage) { + if m.RequestID != "req-1" || m.StageID != "analyst" { + t.Fatalf("clarification ids: %+v", m) + } + if len(m.Questions) != 1 { + t.Fatalf("clarification questions: %+v", m.Questions) + } + q := m.Questions[0] + if q.Prompt != "Which stack?" || q.Header != "Stack" || + len(q.Options) != 2 || q.Options[0] != "React" { + t.Fatalf("clarification question fields: %+v", q) + } + }, + }, } for _, c := range cases { @@ -193,6 +214,25 @@ func TestEncodeGoldenClientFrames(t *testing.T) { } }, }, + { + name: "ClarificationResponse carries ids + answers", + frame: ClarificationResponse("s1", "analyst", "req-1", + []ClarificationAnswerDto{{QuestionID: "stack", Value: "React"}}), + wantType: clientPrefix + "ClarificationResponse", + check: func(t *testing.T, raw map[string]any) { + if raw["sessionId"] != "s1" || raw["stageId"] != "analyst" || raw["requestId"] != "req-1" { + t.Fatalf("ClarificationResponse ids: %+v", raw) + } + answers, ok := raw["answers"].([]any) + if !ok || len(answers) != 1 { + t.Fatalf("ClarificationResponse answers: %+v", raw["answers"]) + } + a := answers[0].(map[string]any) + if a["questionId"] != "stack" || a["value"] != "React" { + t.Fatalf("ClarificationResponse answer: %+v", a) + } + }, + }, } for _, c := range cases { diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index b2a5c6b0..606ca52d 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -34,6 +34,7 @@ const ( TypeToolRejected = "tool.rejected" TypeApprovalRequired = "approval.required" TypeApprovalResolved = "approval.resolved" + TypeClarification = "clarification.required" TypeWorkspaceBound = "session.workspace_bound" TypeStageManifest = "stage.tool_manifest" TypeProviderStatus = "provider.status_changed" @@ -135,6 +136,26 @@ type ServerMessage struct { // session.stats — derived metrics for a session (reply to GetSessionStats) Stats *StatsDto `json:"stats"` + + // clarification.required — open questions a stage raised for the operator + Questions []ClarificationQuestionDto `json:"questions"` +} + +// ClarificationQuestionDto is one open question from a stage. Empty Options -> +// free-text answer; non-empty -> selectable choices. Mirrors the Kotlin +// ClarificationQuestion (core:events), which is the wire shape. +type ClarificationQuestionDto struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + Options []string `json:"options"` + MultiSelect bool `json:"multiSelect"` + Header string `json:"header"` +} + +// ClarificationAnswerDto is the operator's answer to one question. +type ClarificationAnswerDto struct { + QuestionID string `json:"questionId"` + Value string `json:"value"` } // StatsDto mirrors the server's MetricsReport: a session's derived metrics @@ -291,7 +312,8 @@ func (m ServerMessage) IsEventBearing() bool { TypeInferenceFailed, TypeInferenceRetry, TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected, TypeToolAssessed, - TypeApprovalRequired, TypeApprovalResolved, TypeWorkspaceBound, TypeArtifactCreated, + TypeApprovalRequired, TypeApprovalResolved, TypeClarification, + TypeWorkspaceBound, TypeArtifactCreated, TypeArtifactValid, TypePlanLocked, TypeRouterNarration: return true @@ -362,6 +384,17 @@ func ApprovalResponse(requestID, decision string, steeringNote *string) []byte { }) } +// ClarificationResponse answers a stage's open questions; the parked stage re-runs +// with the answers in context. +func ClarificationResponse(sessionID, stageID, requestID string, answers []ClarificationAnswerDto) []byte { + return encode("ClarificationResponse", map[string]any{ + "sessionId": sessionID, + "stageId": stageID, + "requestId": requestID, + "answers": answers, + }) +} + // Ping is a keepalive. func Ping(ts int64) []byte { return encode("Ping", map[string]any{"timestamp": ts})