feat(tui-go): interactive clarification question view
Render stage clarification questions as an AskUserQuestion-style form: header chip, prompt, selectable option chips, and a free-text custom slot. Arrow/hjkl nav, space to select, e for custom, enter to submit. Submit guards on all-answered and sends ClarificationResponse; the parked stage re-runs with the answers in context.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user