From 7be88ff621383506869dc42ae3d3d876e9cd7756 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Tue, 18 Aug 2026 18:43:51 -0400 Subject: [PATCH] go-server: Rich Interactions runtime + choices kind (AskUserQuestion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Rich Interactions framework runtime + the `choices` kind to the Go LocalServer, mirroring the Rust reference (PR #475). Wave 2 of the polyglot rollout. - interaction.go: kind-agnostic framework — InteractionKind interface, the InteractionKinds catalog, and a per-connection park/resume InteractionRegistry (the analog of the write-confirmation ConfirmationRegistry). - choices.go: the `choices` kind (request_choices raise tool, validateChoices, fallback directive; capability choice_chips), mirroring choices.rs. - turn_runner.go: registers one raise tool per hosted kind — a kind whose declared capability parks the turn (emit interaction_required, block awaiting submit_interaction), the rest degrade to the conversational fallback. The raise tool's toolCall chunk is deferred + emitted before the park (as the confirmation path does) so ordering is deterministic. - dispatcher.go: captures `supports` at create_conversation_session and adds the submit_interaction action — validate via the kind, invalid -> retryable interaction_invalid (turn stays parked), valid -> canonicalize + resume. - protocol.go: interaction_required / interaction_invalid event builders. - server.go: teardown rejects parked interactions (fail-open to continue). Tests: validator unit tests + shared choices-fixture validation, and a WS park/resume integration suite (rich resume, invalid-stays-parked, and the no-capability conversational fallback). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YbN45JeWDbcjvFqGJvmVD3 --- .changeset/go-choices-interaction.md | 7 + go/server/choices.go | 396 +++++++++++++++++++++++++++ go/server/choices_test.go | 271 ++++++++++++++++++ go/server/dispatcher.go | 169 ++++++++++++ go/server/interaction.go | 244 +++++++++++++++++ go/server/interaction_e2e_test.go | 304 ++++++++++++++++++++ go/server/protocol.go | 51 +++- go/server/server.go | 3 + go/server/turn_runner.go | 156 ++++++++++- 9 files changed, 1599 insertions(+), 2 deletions(-) create mode 100644 .changeset/go-choices-interaction.md create mode 100644 go/server/choices.go create mode 100644 go/server/choices_test.go create mode 100644 go/server/interaction.go create mode 100644 go/server/interaction_e2e_test.go diff --git a/.changeset/go-choices-interaction.md b/.changeset/go-choices-interaction.md new file mode 100644 index 00000000..f37ba198 --- /dev/null +++ b/.changeset/go-choices-interaction.md @@ -0,0 +1,7 @@ +--- +'@smooai/smooth-operator': patch +--- + +Port the Rich Interactions runtime + the `choices` kind (AskUserQuestion) to the **Go** LocalServer, mirroring the Rust reference (PR #475) — wave 2 of the polyglot rollout. + +The Go server now hosts a kind-agnostic interaction framework (`InteractionKind` / `InteractionKinds` catalog / a per-connection park-resume `InteractionRegistry`, the analog of the write-confirmation `ConfirmationRegistry`) plus the `choices` kind. Each turn registers one `request_` raise tool per hosted kind: on a session that declared the kind's render capability (`supports` at `create_conversation_session`) the raise **parks the turn** — the tool blocks awaiting a `submit_interaction` while the server emits `interaction_required` — and on a text-only channel it degrades to the kind's conversational fallback directive. A new `submit_interaction` dispatcher action routes the visitor's values to the kind's server-side validator: invalid → retryable `interaction_invalid` (the turn stays parked), valid → the parked raise resumes with the canonical payload. The `choices` validator (`validateChoices`) enforces the same rules as the Rust reference and validates against the shared `spec/interactions/choices.schema.json` conformance fixtures. Capability id: `choice_chips`. diff --git a/go/server/choices.go b/go/server/choices.go new file mode 100644 index 00000000..a73c324f --- /dev/null +++ b/go/server/choices.go @@ -0,0 +1,396 @@ +package server + +import ( + "encoding/json" + "fmt" + "strings" + "unicode/utf8" +) + +// Choices — a structured multiple-choice ask modeled on Claude Code's +// AskUserQuestion: the reference Rich Interaction kind (the Go port of +// rust/smooth-operator/src/choices.rs). The agent asks 1–4 short questions, each +// with 2–4 labeled options; the turn parks until the visitor picks. Every question +// also carries an implicit free-text "Other" escape hatch, so the visitor can answer +// outside the enumerated options. +// +// - On a channel that declared the `choice_chips` capability, the agent's +// request_choices tool parks the turn and the server emits interaction_required; +// the client's chip/menu card resumes with a submit_interaction action. +// - On a text-only channel the same raise degrades to a conversational directive +// enumerating the questions + options. +// +// Both paths validate through validateChoices — one implementation, one behavior — +// and resume with the same structured payload. Validated against the shared choices +// fixtures (spec/conformance/fixtures.json) in choices_test.go. + +// headerMaxChars is the max length of a question's short header label (chip/tab caption). +const headerMaxChars = 12 + +// choiceChipsCapability gates the rich (parked card) path for the choices kind. +const choiceChipsCapability = "choice_chips" + +// ChoiceOption is one selectable option in a question. +type ChoiceOption struct { + // Label is the option's label — the value the visitor submits. + Label string `json:"label"` + // Description is a short human-readable gloss shown under/next to the label. + Description string `json:"description,omitempty"` +} + +// ChoiceQuestion is one question in a choices raise. +type ChoiceQuestion struct { + // Question is the prompt shown to the visitor. + Question string `json:"question"` + // Header is a short label (≤headerMaxChars) — the answer key and the chip/tab + // caption. Unique within a raise. + Header string `json:"header"` + // Options are the 2–4 enumerated options. An implicit free-text "Other" is always available too. + Options []ChoiceOption `json:"options"` + // MultiSelect reports whether the visitor may pick more than one option (default false). + MultiSelect bool `json:"multiSelect,omitempty"` +} + +// choicesSpec is the render spec carried on interaction_required for kind choices. +type choicesSpec struct { + Questions []ChoiceQuestion `json:"questions"` +} + +// ChoiceAnswer is the visitor's answer to one question, submitted via submit_interaction. +type ChoiceAnswer struct { + // Header identifies which question this answers — matches the spec question's Header. + Header string `json:"header"` + // Options are the selected option label(s). One for single-select; empty when the + // visitor only used the free-text "Other" escape hatch. + Options []string `json:"options,omitempty"` + // Other is the free-text "Other" answer, when the visitor answered outside the + // enumerated options. Blank ⇒ omitted. + Other string `json:"other,omitempty"` +} + +// selectionCount is the total picks the visitor made (selected labels + one for a +// non-blank "Other"). +func (a ChoiceAnswer) selectionCount() int { + n := len(a.Options) + if a.Other != "" { + n++ + } + return n +} + +// ChoiceValues is the visitor's submitted answers. +type ChoiceValues struct { + Answers []ChoiceAnswer `json:"answers"` +} + +// validateChoices validates submitted values against the raised questions, returning +// the normalized ChoiceValues or the full list of per-question errors. +// +// Rules (mirror choices.rs): +// - every question must be answered (a selection or a non-blank "Other"); +// - each selected label must be one of that question's option labels; +// - single-select: exactly one pick (one label XOR "Other"); multi-select: ≥1; +// - a blank/whitespace "Other" is treated as absent; labels are trimmed. +// +// When questions is empty (a prior-turn fallback raise whose spec is gone), validation +// degrades to format-only: any answer with at least one pick is accepted as-is. +// Returns every failed question (not just the first) so a card can annotate all of +// them in one round-trip. +func validateChoices(questions []ChoiceQuestion, values ChoiceValues) (ChoiceValues, []InteractionFieldError) { + // Normalize the raw answers first (trim labels + "Other", drop blanks). + normalized := make([]ChoiceAnswer, 0, len(values.Answers)) + for _, a := range values.Answers { + opts := make([]string, 0, len(a.Options)) + for _, o := range a.Options { + if t := strings.TrimSpace(o); t != "" { + opts = append(opts, t) + } + } + normalized = append(normalized, ChoiceAnswer{ + Header: strings.TrimSpace(a.Header), + Options: opts, + Other: strings.TrimSpace(a.Other), + }) + } + + // Format-only path: no spec to check membership/required-ness against. + if len(questions) == 0 { + var errs []InteractionFieldError + for _, a := range normalized { + if a.selectionCount() == 0 { + errs = append(errs, InteractionFieldError{Field: a.Header, Message: "select an option or provide an 'other' answer"}) + } + } + if len(normalized) == 0 { + errs = append(errs, InteractionFieldError{Field: "answers", Message: "provide an answer for each question, or declined=true"}) + } + if len(errs) > 0 { + return ChoiceValues{}, errs + } + return ChoiceValues{Answers: normalized}, nil + } + + var errs []InteractionFieldError + out := make([]ChoiceAnswer, 0, len(questions)) + + for _, q := range questions { + answer, found := findAnswer(normalized, q.Header) + if !found { + errs = append(errs, InteractionFieldError{Field: q.Header, Message: "this question must be answered"}) + continue + } + + // Every selected label must be one of the enumerated options. + badLabel := false + for _, label := range answer.Options { + if !hasOption(q.Options, label) { + badLabel = true + errs = append(errs, InteractionFieldError{Field: q.Header, Message: fmt.Sprintf("'%s' is not one of the offered options", label)}) + } + } + + count := answer.selectionCount() + if count == 0 { + errs = append(errs, InteractionFieldError{Field: q.Header, Message: "select an option or provide an 'other' answer"}) + } else if !q.MultiSelect && count > 1 { + errs = append(errs, InteractionFieldError{Field: q.Header, Message: "this question takes a single answer"}) + } + + if !badLabel { + out = append(out, answer) + } + } + + if len(errs) > 0 { + return ChoiceValues{}, errs + } + return ChoiceValues{Answers: out}, nil +} + +func findAnswer(answers []ChoiceAnswer, header string) (ChoiceAnswer, bool) { + for _, a := range answers { + if a.Header == header { + return a, true + } + } + return ChoiceAnswer{}, false +} + +func hasOption(options []ChoiceOption, label string) bool { + for _, o := range options { + if o.Label == label { + return true + } + } + return false +} + +// parseQuestions parses the raise tool's `questions` argument into validated +// ChoiceQuestions. Enforces the LLM-facing contract: 1–4 questions, each with a +// non-empty prompt, a non-empty header ≤12 chars (unique within the raise), and 2–4 +// options with non-empty labels. +func parseQuestions(raw any) ([]ChoiceQuestion, error) { + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("'questions' must be an array") + } + if len(items) < 1 || len(items) > 4 { + return nil, fmt.Errorf("'questions' must contain between 1 and 4 questions") + } + questions := make([]ChoiceQuestion, 0, len(items)) + seen := map[string]bool{} + for _, item := range items { + obj, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("each question must be an object") + } + question := strings.TrimSpace(asString(obj["question"])) + if question == "" { + return nil, fmt.Errorf("each question needs a non-empty 'question'") + } + header := strings.TrimSpace(asString(obj["header"])) + if header == "" { + return nil, fmt.Errorf("each question needs a non-empty 'header'") + } + if utf8.RuneCountInString(header) > headerMaxChars { + return nil, fmt.Errorf("header '%s' is too long (max %d characters)", header, headerMaxChars) + } + if seen[header] { + return nil, fmt.Errorf("duplicate question header '%s'", header) + } + seen[header] = true + + rawOptions, ok := obj["options"].([]any) + if !ok { + return nil, fmt.Errorf("question '%s' needs an 'options' array", header) + } + if len(rawOptions) < 2 || len(rawOptions) > 4 { + return nil, fmt.Errorf("question '%s' must offer between 2 and 4 options", header) + } + options := make([]ChoiceOption, 0, len(rawOptions)) + for _, opt := range rawOptions { + // Accept the object form { label, description? } and the bare-string shorthand. + var option ChoiceOption + switch v := opt.(type) { + case string: + option = ChoiceOption{Label: strings.TrimSpace(v)} + case map[string]any: + option = ChoiceOption{ + Label: strings.TrimSpace(asString(v["label"])), + Description: strings.TrimSpace(asString(v["description"])), + } + default: + return nil, fmt.Errorf("invalid option entry in '%s'", header) + } + if option.Label == "" { + return nil, fmt.Errorf("an option in '%s' has an empty label", header) + } + options = append(options, option) + } + + multi, _ := obj["multiSelect"].(bool) + questions = append(questions, ChoiceQuestion{ + Question: question, + Header: header, + Options: options, + MultiSelect: multi, + }) + } + return questions, nil +} + +// asString coerces a JSON-decoded value to a string ("" for anything non-string). +func asString(v any) string { + s, _ := v.(string) + return s +} + +// ChoicesKind is the `choices` Rich Interaction kind — a structured multiple-choice +// ask modeled on AskUserQuestion (see spec/interactions/choices.schema.json). +type ChoicesKind struct{} + +// Kind returns the wire kind id. +func (ChoicesKind) Kind() string { return "choices" } + +// Capability returns the render capability that gates the rich card path. +func (ChoicesKind) Capability() string { return choiceChipsCapability } + +// ToolSchema returns the request_choices raise tool's LLM-facing schema. +func (ChoicesKind) ToolSchema() InteractionToolSchema { + return InteractionToolSchema{ + Name: "request_choices", + Description: "Ask the visitor a structured multiple-choice question (1–4 questions, each " + + "with 2–4 labeled options) and wait for their pick. On channels that can render " + + "chips/menus the visitor taps an option; on text channels you will be told to " + + "enumerate the options and accept a natural-language answer. An implicit free-text " + + "\"Other\" is always available, so use this whenever the answer is likely (but not " + + "certainly) one of a small set — never free-form the menu yourself.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "questions": map[string]any{ + "type": "array", + "minItems": 1, + "maxItems": 4, + "description": "The questions to ask, in order (1–4).", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "question": map[string]any{"type": "string", "description": "The question prompt shown to the visitor."}, + "header": map[string]any{"type": "string", "maxLength": headerMaxChars, "description": "A short label (≤12 chars), unique within the raise. Used as the answer key and the chip/tab caption."}, + "options": map[string]any{ + "type": "array", + "minItems": 2, + "maxItems": 4, + "description": "The 2–4 options to offer. A free-text 'Other' is always available in addition.", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "label": map[string]any{"type": "string", "description": "The option label (the value submitted)."}, + "description": map[string]any{"type": "string", "description": "A short gloss for the option."}, + }, + "required": []any{"label"}, + }, + }, + "multiSelect": map[string]any{"type": "boolean", "description": "Allow selecting more than one option (default false)."}, + }, + "required": []any{"question", "header", "options"}, + }, + }, + "reason": map[string]any{"type": "string", "description": "Why you're asking, phrased for the visitor (e.g. \"to route you to the right team\")."}, + }, + "required": []any{"questions", "reason"}, + }, + } +} + +// ParseRequest parses + canonicalizes the raise tool's arguments into the choices spec. +func (k ChoicesKind) ParseRequest(args map[string]any) (InteractionRequest, error) { + questions, err := parseQuestions(args["questions"]) + if err != nil { + return InteractionRequest{}, err + } + reason := strings.TrimSpace(asString(args["reason"])) + if reason == "" { + reason = "to help you better" + } + spec, err := json.Marshal(choicesSpec{Questions: questions}) + if err != nil { + return InteractionRequest{}, err + } + return InteractionRequest{Kind: k.Kind(), Spec: spec, Reason: reason}, nil +} + +// Validate validates submitted values against the raised spec, returning the canonical +// (normalized) values or the full list of per-question errors. +func (ChoicesKind) Validate(spec json.RawMessage, values any) (any, []InteractionFieldError) { + var parsedSpec choicesSpec + if len(spec) > 0 { + // A malformed spec degrades to format-only (empty questions) rather than erroring. + _ = json.Unmarshal(spec, &parsedSpec) + } + // Round-trip the decoded values (a map from the frame) through JSON into the typed shape. + var parsedValues ChoiceValues + rawValues, err := json.Marshal(values) + if err == nil { + err = json.Unmarshal(rawValues, &parsedValues) + } + if err != nil { + return nil, []InteractionFieldError{{Field: "values", Message: fmt.Sprintf("invalid values shape: %v", err)}} + } + canonical, errs := validateChoices(parsedSpec.Questions, parsedValues) + if len(errs) > 0 { + return nil, errs + } + return canonical, nil +} + +// FallbackDirective is the conversational-degradation directive for text-only channels. +func (ChoicesKind) FallbackDirective(spec json.RawMessage, reason string) string { + var parsedSpec choicesSpec + if len(spec) > 0 { + _ = json.Unmarshal(spec, &parsedSpec) + } + lines := make([]string, 0, len(parsedSpec.Questions)) + for _, q := range parsedSpec.Questions { + labels := make([]string, 0, len(q.Options)) + for _, o := range q.Options { + labels = append(labels, o.Label) + } + multi := "" + if q.MultiSelect { + multi = " (choose one or more)" + } + lines = append(lines, fmt.Sprintf("- [%s] %s Options: %s%s.", q.Header, q.Question, strings.Join(labels, ", "), multi)) + } + return fmt.Sprintf( + "This visitor's channel cannot display choice chips. Ask the following question(s) "+ + "conversationally, naturally weaving in the reason (%s), and read out each option so "+ + "the visitor can pick:\n%s\nThe visitor may also answer with something not listed "+ + "(that's fine — capture it as their 'other' answer). Collect their pick(s) and continue "+ + "the conversation using the answers; one entry per question { header, options: [chosen "+ + "label(s)], other?: \"their free-text answer\" }. If a pick isn't one you offered, re-ask. "+ + "If the visitor declines to choose, continue helping them without it.", + reason, strings.Join(lines, "\n"), + ) +} diff --git a/go/server/choices_test.go b/go/server/choices_test.go new file mode 100644 index 00000000..20f13f4d --- /dev/null +++ b/go/server/choices_test.go @@ -0,0 +1,271 @@ +package server + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Unit tests for the choices Rich Interaction kind — the Go port of the Rust +// rust/smooth-operator/src/choices.rs tests, plus validation against the shared +// conformance fixtures (spec/conformance/fixtures.json), so the Go validator agrees +// with the Rust reference on the same wire shapes. + +func opt(label string) ChoiceOption { return ChoiceOption{Label: label} } + +func question(header string, labels []string, multi bool) ChoiceQuestion { + opts := make([]ChoiceOption, len(labels)) + for i, l := range labels { + opts[i] = opt(l) + } + return ChoiceQuestion{Question: header + "?", Header: header, Options: opts, MultiSelect: multi} +} + +func answer(header string, options []string, other string) ChoiceAnswer { + return ChoiceAnswer{Header: header, Options: options, Other: other} +} + +func TestValidateChoicesValidSingleSelectNormalizes(t *testing.T) { + qs := []ChoiceQuestion{question("Plan", []string{"Basic", "Pro"}, false)} + vals := ChoiceValues{Answers: []ChoiceAnswer{answer("Plan", []string{" Pro "}, "")}} + out, errs := validateChoices(qs, vals) + if errs != nil { + t.Fatalf("expected valid, got errors: %v", errs) + } + if len(out.Answers) != 1 || len(out.Answers[0].Options) != 1 || out.Answers[0].Options[0] != "Pro" { + t.Fatalf("expected normalized [Pro], got %+v", out.Answers) + } + if out.Answers[0].Other != "" { + t.Fatalf("expected no other, got %q", out.Answers[0].Other) + } +} + +func TestValidateChoicesMultiSelectKeepsAllPicks(t *testing.T) { + qs := []ChoiceQuestion{question("Topics", []string{"Sales", "Support", "Billing"}, true)} + vals := ChoiceValues{Answers: []ChoiceAnswer{answer("Topics", []string{"Sales", "Billing"}, "")}} + out, errs := validateChoices(qs, vals) + if errs != nil { + t.Fatalf("expected valid, got errors: %v", errs) + } + if strings.Join(out.Answers[0].Options, ",") != "Sales,Billing" { + t.Fatalf("expected [Sales Billing], got %v", out.Answers[0].Options) + } +} + +func TestValidateChoicesOtherEscapeHatchAccepted(t *testing.T) { + qs := []ChoiceQuestion{question("Plan", []string{"Basic", "Pro"}, false)} + vals := ChoiceValues{Answers: []ChoiceAnswer{answer("Plan", nil, " Enterprise, actually ")}} + out, errs := validateChoices(qs, vals) + if errs != nil { + t.Fatalf("expected valid, got errors: %v", errs) + } + if len(out.Answers[0].Options) != 0 { + t.Fatalf("expected no option picks, got %v", out.Answers[0].Options) + } + if out.Answers[0].Other != "Enterprise, actually" { + t.Fatalf("expected trimmed other, got %q", out.Answers[0].Other) + } +} + +func TestValidateChoicesUnknownLabelIsFieldError(t *testing.T) { + qs := []ChoiceQuestion{question("Plan", []string{"Basic", "Pro"}, false)} + vals := ChoiceValues{Answers: []ChoiceAnswer{answer("Plan", []string{"Platinum"}, "")}} + _, errs := validateChoices(qs, vals) + if len(errs) != 1 { + t.Fatalf("expected 1 error, got %v", errs) + } + if errs[0].Field != "Plan" || !strings.Contains(errs[0].Message, "not one of the offered") { + t.Fatalf("unexpected error: %+v", errs[0]) + } +} + +func TestValidateChoicesSingleSelectRejectsMultiplePicks(t *testing.T) { + qs := []ChoiceQuestion{question("Plan", []string{"Basic", "Pro"}, false)} + vals := ChoiceValues{Answers: []ChoiceAnswer{answer("Plan", []string{"Basic", "Pro"}, "")}} + _, errs := validateChoices(qs, vals) + if !anyErrContains(errs, "single answer") { + t.Fatalf("expected single-answer error, got %v", errs) + } +} + +func TestValidateChoicesUnansweredQuestionRequired(t *testing.T) { + qs := []ChoiceQuestion{ + question("Plan", []string{"Basic", "Pro"}, false), + question("Size", []string{"S", "M"}, false), + } + vals := ChoiceValues{Answers: []ChoiceAnswer{answer("Plan", []string{"Pro"}, "")}} + _, errs := validateChoices(qs, vals) + if len(errs) != 1 || errs[0].Field != "Size" || !strings.Contains(errs[0].Message, "must be answered") { + t.Fatalf("expected Size must-be-answered, got %v", errs) + } +} + +func TestValidateChoicesEmptyAnswerNeedsPickOrOther(t *testing.T) { + qs := []ChoiceQuestion{question("Plan", []string{"Basic", "Pro"}, false)} + vals := ChoiceValues{Answers: []ChoiceAnswer{answer("Plan", nil, "")}} + _, errs := validateChoices(qs, vals) + if !anyErrContains(errs, "select an option") { + t.Fatalf("expected select-an-option error, got %v", errs) + } +} + +func TestValidateChoicesFormatOnlyWhenSpecGone(t *testing.T) { + // No questions (prior-turn fallback raise) → format-only: a pick is accepted as-is. + out, errs := validateChoices(nil, ChoiceValues{Answers: []ChoiceAnswer{answer("Plan", []string{"Anything"}, "")}}) + if errs != nil { + t.Fatalf("format-only should accept any pick, got %v", errs) + } + if out.Answers[0].Options[0] != "Anything" { + t.Fatalf("format-only should keep the pick, got %v", out.Answers) + } + // …but an empty answer set still errors. + _, errs = validateChoices(nil, ChoiceValues{}) + if !anyErrContains(errs, "provide an answer") { + t.Fatalf("expected empty-answers error, got %v", errs) + } +} + +func TestParseQuestionsEnforcesContract(t *testing.T) { + // Happy path with shorthand string options. + qs, err := parseQuestions([]any{ + map[string]any{"question": "Which plan?", "header": "Plan", "options": []any{"Basic", "Pro"}}, + }) + if err != nil { + t.Fatalf("valid parse failed: %v", err) + } + if len(qs) != 1 || qs[0].Options[0].Label != "Basic" || qs[0].MultiSelect { + t.Fatalf("unexpected parse result: %+v", qs) + } + + // Too many questions. + tooMany := make([]any, 5) + for i := range tooMany { + tooMany[i] = map[string]any{"question": "q", "header": string(rune('A' + i)), "options": []any{"a", "b"}} + } + if _, err := parseQuestions(tooMany); err == nil { + t.Fatal("expected error for >4 questions") + } + // Too few options. + if _, err := parseQuestions([]any{map[string]any{"question": "q", "header": "H", "options": []any{"only"}}}); err == nil { + t.Fatal("expected error for <2 options") + } + // Header too long. + if _, err := parseQuestions([]any{map[string]any{"question": "q", "header": "ThisHeaderIsWayTooLong", "options": []any{"a", "b"}}}); err == nil { + t.Fatal("expected error for long header") + } + // Duplicate headers. + if _, err := parseQuestions([]any{ + map[string]any{"question": "q1", "header": "H", "options": []any{"a", "b"}}, + map[string]any{"question": "q2", "header": "H", "options": []any{"a", "b"}}, + }); err == nil { + t.Fatal("expected error for duplicate headers") + } +} + +func TestChoicesKindWiresReferenceSurface(t *testing.T) { + k := ChoicesKind{} + if k.Kind() != "choices" || k.Capability() != "choice_chips" || k.ToolSchema().Name != "request_choices" { + t.Fatalf("unexpected kind surface: %s/%s/%s", k.Kind(), k.Capability(), k.ToolSchema().Name) + } + + req, err := k.ParseRequest(map[string]any{ + "questions": []any{map[string]any{"question": "Which plan interests you?", "header": "Plan", + "options": []any{map[string]any{"label": "Basic"}, map[string]any{"label": "Pro"}}}}, + "reason": "to route you", + }) + if err != nil { + t.Fatalf("parse: %v", err) + } + if req.Kind != "choices" || req.Reason != "to route you" { + t.Fatalf("unexpected request: %+v", req) + } + var spec choicesSpec + if err := json.Unmarshal(req.Spec, &spec); err != nil || spec.Questions[0].Header != "Plan" { + t.Fatalf("unexpected spec: %s (%v)", req.Spec, err) + } + + // The validator, through the kind, produces the canonical values. + canonical, errs := k.Validate(req.Spec, map[string]any{"answers": []any{map[string]any{"header": "Plan", "options": []any{"Pro"}}}}) + if errs != nil { + t.Fatalf("expected valid submit, got %v", errs) + } + cv := canonical.(ChoiceValues) + if cv.Answers[0].Options[0] != "Pro" { + t.Fatalf("unexpected canonical values: %+v", cv) + } + + // The fallback directive enumerates the options. + directive := k.FallbackDirective(req.Spec, "to route you") + if !strings.Contains(directive, "Basic, Pro") { + t.Fatalf("fallback directive missing enumerated options: %s", directive) + } +} + +// TestChoicesFixturesValidate cross-checks the Go kind against the shared conformance +// fixtures the Rust reference validates against: the choices_values submitted against the +// choices_spec must validate to the choices_payload's normalized answers. +func TestChoicesFixturesValidate(t *testing.T) { + fixtures := loadFixtures(t) + spec := fixtureInstance(t, fixtures, "choices_spec") + values := fixtureInstance(t, fixtures, "choices_values") + payload := fixtureInstance(t, fixtures, "choices_payload") + + specJSON, err := json.Marshal(spec) + if err != nil { + t.Fatalf("marshal spec: %v", err) + } + + canonical, errs := ChoicesKind{}.Validate(specJSON, values) + if errs != nil { + t.Fatalf("shared choices_values should validate against choices_spec, got %v", errs) + } + + // The canonical result must match the fixture payload's `values` (jsonEqual + // normalizes both through JSON, so the struct tags line up with the wire shape). + if !jsonEqual(canonical, payload["values"]) { + got, _ := json.Marshal(canonical) + want, _ := json.Marshal(payload["values"]) + t.Fatalf("canonical values != fixture payload values\n got: %s\nwant: %s", got, want) + } + if payload["status"] != "submitted" { + t.Fatalf("fixture payload status = %v, want submitted", payload["status"]) + } +} + +func loadFixtures(t *testing.T) map[string]any { + t.Helper() + path := filepath.Join("..", "..", "spec", "conformance", "fixtures.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixtures: %v", err) + } + var f map[string]any + if err := json.Unmarshal(data, &f); err != nil { + t.Fatalf("parse fixtures: %v", err) + } + return f +} + +func fixtureInstance(t *testing.T, fixtures map[string]any, key string) map[string]any { + t.Helper() + entry, ok := fixtures[key].(map[string]any) + if !ok { + t.Fatalf("fixture %q missing", key) + } + inst, ok := entry["instance"].(map[string]any) + if !ok { + t.Fatalf("fixture %q has no instance", key) + } + return inst +} + +func anyErrContains(errs []InteractionFieldError, sub string) bool { + for _, e := range errs { + if strings.Contains(e.Message, sub) { + return true + } + } + return false +} diff --git a/go/server/dispatcher.go b/go/server/dispatcher.go index fd8ee13c..b831f273 100644 --- a/go/server/dispatcher.go +++ b/go/server/dispatcher.go @@ -63,6 +63,22 @@ type FrameDispatcher struct { // the gateway's /model/info, forwarded to every turn runner to clamp max_tokens. // nil → the raised default is unclamped (EPIC th-1cc9fa). modelCeiling *int + // interactionKinds is the stateless catalog of Rich Interaction kinds hosted this + // connection (choices, …). Threaded into every turn so the runner registers one + // raise tool per kind. Defaults to DefaultInteractionKinds() in the constructor. + interactionKinds *InteractionKinds + // interactions is the per-connection session-keyed park/resume registry a + // submit_interaction frame resolves (the Rich Interactions analog of confirmations). + // Shared with the turn runner's raise tools. Created on demand in the constructor. + interactions *InteractionRegistry + // supports maps sessionId → the render capabilities its client declared at + // create_conversation_session (`supports`). A kind's rich card path is offered only + // when its capability is present; otherwise the turn degrades to the conversational + // fallback. Per-connection (like confirmations): the create + send + submit frames + // for a session all ride the same connection. th-choices. + supports map[string]map[string]bool + supportsMu sync.Mutex + // turns tracks in-flight spawned send_message turns so the connection loop can wait // for them to finish (and flush their eventual_response) on teardown — the // graceful-drain contract. send_message runs its turn as a goroutine (so the read @@ -108,6 +124,9 @@ func NewFrameDispatcher(store SessionStore, client core.ChatClient, access Acces tools: tools, confirmTools: confirmTools, confirmations: confirmations, + interactionKinds: DefaultInteractionKinds(), + interactions: NewInteractionRegistry(), + supports: map[string]map[string]bool{}, agentConfigs: agentConfigs, judgeModel: judgeModel, authRequiringTools: authRequiringTools, @@ -173,6 +192,11 @@ type inboundFrame struct { AgentID string `json:"agentId"` UserName string `json:"userName"` UserEmail string `json:"userEmail"` + // create_conversation_session — the client's render capabilities for this session + // (per spec/actions/create-conversation-session.schema.json). A per-kind list gating + // the Rich Interactions the server may emit mid-turn; absent ⇒ text-only, every kind + // degrades to its conversational fallback. Unknown values are ignored (forward-compat). + Supports []string `json:"supports"` // create_conversation_session — optional: resume an existing conversation (bind the new // session to it) when known; absent/unknown → a fresh conversation (unchanged). th-d5b446. ConversationID string `json:"conversationId"` @@ -198,6 +222,14 @@ type inboundFrame struct { Approved *bool `json:"approved"` // verify_otp — the one-time code the user entered. Code string `json:"code"` + // submit_interaction — resume a parked Rich Interaction. InteractionID must echo the + // interaction_required event's id (a stale card can't resolve a newer park); Kind, when + // present, is cross-checked against the parked kind. Declined resumes with a decline; + // otherwise Values (raw, kind-shaped) is routed to the kind's server-side validator. + InteractionID string `json:"interactionId"` + Kind string `json:"kind"` + Declined bool `json:"declined"` + Values json.RawMessage `json:"values"` } // Dispatch parses one raw frame and routes it. A handler failure mid-turn emits a @@ -227,6 +259,8 @@ func (d *FrameDispatcher) Dispatch(ctx context.Context, raw []byte, sink EventSi d.handleCancel(frame, sink) case "confirm_tool_action": d.handleConfirmToolAction(frame, sink) + case "submit_interaction": + d.handleSubmitInteraction(ctx, frame, sink) case "verify_otp": d.handleVerifyOtp(ctx, frame, sink) case "": @@ -307,6 +341,9 @@ func (d *FrameDispatcher) handleCreateSession(ctx context.Context, frame inbound } // A freshly created session never passes through scopedSession, so associate here too. d.associateSession(&session) + // Record the client's declared render capabilities for this session so a later turn + // offers a kind's rich card only when its capability is present (else the fallback). + d.setSupports(session.SessionID, frame.Supports) data := map[string]any{ "sessionId": session.SessionID, "conversationId": session.ConversationID, @@ -674,6 +711,12 @@ func (d *FrameDispatcher) handleSendMessage(ctx context.Context, frame inboundFr defer extTurn.Close(ctx) runner := NewTurnRunner(d.client, d.store, effectiveSystemPrompt, d.knowledge, effectiveTools, d.confirmTools, d.confirmations, workflow, session.CurrentStepID, d.judgeModel, d.modelCeiling) runner.hooks = d.hooks + // Rich Interactions: give the runner the hosted kinds, the park/resume registry, + // and this session's declared capabilities, so it registers one raise tool per + // kind (rich park when the capability is declared, else conversational fallback). + runner.interactionKinds = d.interactionKinds + runner.interactions = d.interactions + runner.capabilities = d.capabilities(frame.SessionID) // Span attribution: the owning org (grouped by smooai.org_id on the turn span). runner.orgID = d.access.Principal.Org result, err := runner.Run(turnCtx, frame.SessionID, session.ConversationID, requestID, frame.Message, sink) @@ -754,6 +797,132 @@ func (d *FrameDispatcher) handleConfirmToolAction(frame inboundFrame, sink Event })) } +// setSupports records a session's declared render capabilities (from +// create_conversation_session). An absent/empty list means text-only. Idempotent. +func (d *FrameDispatcher) setSupports(sessionID string, supports []string) { + caps := make(map[string]bool, len(supports)) + for _, c := range supports { + if c != "" { + caps[c] = true + } + } + d.supportsMu.Lock() + d.supports[sessionID] = caps + d.supportsMu.Unlock() +} + +// capabilities returns the render capabilities a session declared at create time (an +// empty set when it declared none or is unknown on this connection → every kind +// degrades to its conversational fallback). +func (d *FrameDispatcher) capabilities(sessionID string) map[string]bool { + d.supportsMu.Lock() + defer d.supportsMu.Unlock() + if caps, ok := d.supports[sessionID]; ok { + return caps + } + return map[string]bool{} +} + +// handleSubmitInteraction resumes a turn parked on a Rich Interaction. +// +// Per spec/actions/submit-interaction.schema.json the client replies with +// {action, sessionId, requestId, interactionId, kind?, values?, declined?} to an +// interaction_required event. Validation is SERVER-SIDE, routed to the parked kind's +// validator against the spec the raise carried: +// - invalid → an interaction_invalid event with per-field errors; the turn STAYS +// parked so the card can resubmit (mirrors otp_invalid — never a terminal error); +// - valid → the parked raise resumes with the canonical values, and an +// immediate_response acks; +// - declined:true → the raise resumes with a declined payload. +// +// The interactionId must echo the event's, so a stale submit can never resolve a newer +// park; the pending record is taken only on resolution, so a duplicate submit is a clean +// NO_PENDING_INTERACTION no-op. The requestId is load-bearing (it echoes the originating +// interaction_required and keys the resumed stream), so require it. +func (d *FrameDispatcher) handleSubmitInteraction(ctx context.Context, frame inboundFrame, sink EventSink) { + if frame.RequestID == "" { + sink(errorEvent("", "VALIDATION_ERROR", "submit_interaction requires a 'requestId'")) + return + } + if frame.SessionID == "" { + sink(errorEvent(frame.RequestID, "VALIDATION_ERROR", "submit_interaction requires a 'sessionId'")) + return + } + + // Peek the pending interaction WITHOUT consuming the park — an invalid submit must + // leave the turn parked for a resubmit. Scope the session first: an unreadable session + // reports the identical NO_PENDING_INTERACTION an unknown id produces, so a submit can + // never land in another user's parked turn. + session, err := d.scopedSession(ctx, frame.SessionID) + if err != nil { + d.internalError(sink, frame.RequestID, "submit_interaction", err) + return + } + var pending pendingInteraction + var havePending bool + if session != nil { + pending, havePending = d.interactions.Pending(frame.SessionID) + } + if !havePending { + sink(errorEvent(frame.RequestID, "NO_PENDING_INTERACTION", "no interaction is awaiting submission for session '"+frame.SessionID+"'")) + return + } + + // The submit must target THIS interaction instance (and, when it names a kind, the + // right kind) — a stale card can never resolve a newer park. + if frame.InteractionID != pending.InteractionID { + sink(errorEvent(frame.RequestID, "INTERACTION_MISMATCH", "the submitted 'interactionId' does not match the pending interaction")) + return + } + if frame.Kind != "" && frame.Kind != pending.Kind { + sink(errorEvent(frame.RequestID, "INTERACTION_MISMATCH", "the pending interaction is '"+pending.Kind+"', not '"+frame.Kind+"'")) + return + } + + // Decline path: resume the raise with a declined payload. + if frame.Declined { + if d.interactions.Resolve(frame.SessionID, pending.InteractionID, InteractionOutcome{Status: outcomeDeclined}) { + sink(immediateResponse(frame.RequestID, 200, "Interaction declined", map[string]any{ + "sessionId": frame.SessionID, + "interactionId": pending.InteractionID, + "declined": true, + })) + } + return + } + + // Values path: route to the parked kind's server-side validator. + if len(frame.Values) == 0 { + sink(errorEvent(frame.RequestID, "VALIDATION_ERROR", "submit_interaction requires 'values' (or 'declined': true)")) + return + } + kind := d.interactionKinds.Get(pending.Kind) + if kind == nil { + // A parked kind the catalog no longer hosts (shouldn't happen). + sink(errorEvent(frame.RequestID, "NO_PENDING_INTERACTION", "interaction kind '"+pending.Kind+"' is not hosted by this server")) + return + } + var values any + if err := json.Unmarshal(frame.Values, &values); err != nil { + sink(errorEvent(frame.RequestID, "VALIDATION_ERROR", "submit_interaction 'values' is not valid JSON")) + return + } + + canonical, fieldErrs := kind.Validate(pending.Spec, values) + if len(fieldErrs) > 0 { + // Retryable: the turn stays parked; the client re-renders the card with the + // per-field errors (never a terminal error event). + sink(interactionInvalid(frame.RequestID, pending.InteractionID, pending.Kind, fieldErrs, "Some fields need attention.")) + return + } + if d.interactions.Resolve(frame.SessionID, pending.InteractionID, InteractionOutcome{Status: outcomeSubmitted, Values: canonical}) { + sink(immediateResponse(frame.RequestID, 200, "Interaction submitted", map[string]any{ + "sessionId": frame.SessionID, + "interactionId": pending.InteractionID, + })) + } +} + // offerOtp emits the OTP-offer sequence for a turn whose end_user tool was refused for lack // of a verified session: otp_verification_required (prompt the client), then SendOtp on the // host service, then otp_sent (ack delivery) — or an error event if delivery fails. The masked diff --git a/go/server/interaction.go b/go/server/interaction.go new file mode 100644 index 00000000..3d58175c --- /dev/null +++ b/go/server/interaction.go @@ -0,0 +1,244 @@ +package server + +import ( + "encoding/json" + "sync" +) + +// Rich Interactions — the extensible structured-interaction framework (the Go port +// of the Rust rust/smooth-operator/src/interaction.rs + smooth-operator-server +// wiring). One pattern, many kinds: an agent raises a structured interaction +// (choice chips, identity intake, a date picker, …) through a per-kind raise tool. +// +// - On a channel whose client declared the kind's render capability (`supports` +// at create_conversation_session), the raise PARKS the turn — the raise tool +// blocks inside its Execute awaiting the visitor, the server emits +// interaction_required, the client renders a rich card and replies with a +// submit_interaction action, and the turn resumes with the validated payload. +// - On a text-only channel the same raise degrades to the kind's conversational +// fallback directive (the model collects the answer turn by turn). +// +// Both paths run the kind's server-side validator and resume with the SAME +// canonical payload. Adding a kind = implementing InteractionKind + registering it; +// no new protocol events, no new client verbs. This file is kind-agnostic; the +// reference kind is ChoicesKind (choices.go). +// +// Two registries live here, mirroring the confirmation HITL machinery +// (confirmation.go): InteractionKinds is the stateless catalog of hosted kinds, +// and InteractionRegistry is the per-connection park/resume map a submit_interaction +// frame resolves (the analog of ConfirmationRegistry). + +// InteractionFieldError is a single per-field validation failure, carried on the +// interaction_invalid event. Field is a kind-specific key (choices: the question +// header). +type InteractionFieldError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// InteractionToolSchema is the LLM-facing schema for a kind's raise tool (per-kind, +// so parameters stay precise). Convention: Name is "request_". +type InteractionToolSchema struct { + Name string + Description string + Parameters map[string]any +} + +// InteractionRequest is a parsed raise: what the agent asked the visitor for. +type InteractionRequest struct { + // Kind is the interaction kind (e.g. "choices"). + Kind string + // Spec is the kind-specific render spec (shape per spec/interactions/.schema.json#/$defs/Spec). + // Carried on interaction_required and re-parsed by Validate. + Spec json.RawMessage + // Reason is why the agent raised it (card header / woven into the conversational ask). + Reason string +} + +// InteractionOutcome is how a parked interaction resolved. Fed back to the parked +// raise tool by the submit_interaction handler (submitted/declined) or by teardown +// (no_response). +type InteractionOutcome struct { + // Status is "submitted", "declined", or "no_response". + Status string + // Values is the validated, canonical payload — set only when Status == "submitted". + Values any +} + +const ( + outcomeSubmitted = "submitted" + outcomeDeclined = "declined" + outcomeNoResponse = "no_response" +) + +// InteractionKind is one interaction kind — the extension seam. A kind supplies only +// what differs per interaction; all park/resume/event/registry machinery is shared: +// 1. identity (Kind / Capability), +// 2. the LLM-facing raise-tool surface (ToolSchema + ParseRequest), +// 3. the server-side validator (Validate) producing the canonical values, +// 4. the conversational degradation (FallbackDirective) for text-only channels. +type InteractionKind interface { + // Kind is the wire kind id (e.g. "choices") — selects the client card and the validator. + Kind() string + // Capability is the client render capability that gates the rich path (e.g. + // "choice_chips"). A session that declared it in `supports` parks the card; + // anything else gets the conversational fallback. + Capability() string + // ToolSchema is the raise tool's LLM-facing schema (name it "request_"). + ToolSchema() InteractionToolSchema + // ParseRequest parses + canonicalizes the raise tool's arguments into the kind's + // spec (carried on interaction_required) and the human-readable reason. Errors on + // malformed arguments; the text is surfaced to the model. + ParseRequest(args map[string]any) (InteractionRequest, error) + // Validate validates submitted values against spec, returning the canonical + // (normalized) values or the full list of per-field errors (every failed field, + // so a card can annotate all of them in one round-trip). A nil/empty spec ⇒ + // format-only validation (a prior-turn fallback raise whose spec is gone). + Validate(spec json.RawMessage, values any) (any, []InteractionFieldError) + // FallbackDirective is the conversational-degradation directive for text-only + // channels: instructions the model follows to collect the same information turn + // by turn. + FallbackDirective(spec json.RawMessage, reason string) string +} + +// InteractionKinds is the stateless catalog of interaction kinds a server hosts — +// the Go analog of the Rust InteractionRegistry-of-kinds. Immutable after build, so +// it needs no locking and can be shared across connections. +type InteractionKinds struct { + kinds []InteractionKind + byID map[string]InteractionKind +} + +// NewInteractionKinds builds a catalog from the given kinds (registration order +// preserved; a later kind with a duplicate id shadows an earlier one on lookup). +func NewInteractionKinds(kinds ...InteractionKind) *InteractionKinds { + byID := make(map[string]InteractionKind, len(kinds)) + for _, k := range kinds { + byID[k.Kind()] = k + } + return &InteractionKinds{kinds: kinds, byID: byID} +} + +// DefaultInteractionKinds is the reference catalog: choices. +func DefaultInteractionKinds() *InteractionKinds { + return NewInteractionKinds(ChoicesKind{}) +} + +// Get looks up a kind by its wire id (nil if not hosted). +func (c *InteractionKinds) Get(kind string) InteractionKind { + if c == nil { + return nil + } + return c.byID[kind] +} + +// All returns every registered kind, in registration order. +func (c *InteractionKinds) All() []InteractionKind { + if c == nil { + return nil + } + return c.kinds +} + +// pendingInteraction is a parked raise awaiting a submit_interaction: the id the +// submit must echo, the kind (routes to its validator), the spec (drives +// validation), and the channel that resumes the parked raise tool. +type pendingInteraction struct { + // InteractionID is the server-generated id for this instance; the submit must echo + // it so a stale submit can never resolve a newer park. + InteractionID string + // Kind is the interaction kind (routes to its validator). + Kind string + // Spec is the kind-specific spec the raise carried (drives validation). + Spec json.RawMessage + // outcome resumes the parked raise tool. Buffered cap 1 so a resolve never blocks. + outcome chan InteractionOutcome +} + +// InteractionRegistry is the per-connection park/resume map for Rich Interactions — +// the kind-agnostic analog of ConfirmationRegistry. When a raise tool parks the turn +// it registers here (keyed by sessionId); a later submit_interaction frame on the +// same connection peeks the pending record to validate against its spec, then +// resolves it (feeding the outcome back to the parked raise tool). Touched from two +// goroutines — the parked turn registers + awaits, the read loop's submit handler +// resolves — so every method is mutex-guarded. +type InteractionRegistry struct { + mu sync.Mutex + // pending maps sessionId → the parked interaction awaiting submission. At most one + // per session (a raise parks the turn; no second raise can run until it resumes). + pending map[string]pendingInteraction +} + +// NewInteractionRegistry builds an empty registry (no interaction parked). +func NewInteractionRegistry() *InteractionRegistry { + return &InteractionRegistry{pending: map[string]pendingInteraction{}} +} + +// Register registers (and returns) a fresh outcome channel for a parked raise on +// sessionID. Any prior pending interaction for the session is resolved no_response +// first, so a stale parked turn is never left dangling and the newest raise wins +// (mirrors ConfirmationRegistry.Register). +func (r *InteractionRegistry) Register(sessionID, interactionID, kind string, spec json.RawMessage) chan InteractionOutcome { + r.mu.Lock() + defer r.mu.Unlock() + if prior, ok := r.pending[sessionID]; ok { + select { + case prior.outcome <- InteractionOutcome{Status: outcomeNoResponse}: + default: + } + delete(r.pending, sessionID) + } + ch := make(chan InteractionOutcome, 1) + r.pending[sessionID] = pendingInteraction{InteractionID: interactionID, Kind: kind, Spec: spec, outcome: ch} + return ch +} + +// Pending peeks the parked interaction for sessionID WITHOUT consuming it — the +// submit handler needs the kind + spec to validate before deciding whether to +// resolve (an invalid submit must leave the turn parked for a resubmit). +func (r *InteractionRegistry) Pending(sessionID string) (pendingInteraction, bool) { + r.mu.Lock() + defer r.mu.Unlock() + p, ok := r.pending[sessionID] + return p, ok +} + +// Resolve resolves the parked raise for sessionID with the outcome, IF interactionID +// echoes the pending instance. Returns false when nothing is parked or the id +// mismatches (a stale/duplicate submit → a clean no-op). Taking the record out makes +// a duplicate resolve a no-op (mirrors the Rust take-on-resolution). +func (r *InteractionRegistry) Resolve(sessionID, interactionID string, outcome InteractionOutcome) bool { + r.mu.Lock() + defer r.mu.Unlock() + p, ok := r.pending[sessionID] + if !ok || p.InteractionID != interactionID { + return false + } + delete(r.pending, sessionID) + // Buffered cap 1 → never blocks; the parked raise tool receives the outcome. + p.outcome <- outcome + return true +} + +// Clear drops any parked interaction for sessionID (turn ended) so a stale entry +// can't mis-route a later submit. Idempotent. +func (r *InteractionRegistry) Clear(sessionID string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.pending, sessionID) +} + +// RejectAll resolves every outstanding parked interaction as no_response — called on +// connection teardown so any turn parked on a raise unparks and finishes cleanly +// (never leave a turn hung forever). Mirrors ConfirmationRegistry.RejectAll. +func (r *InteractionRegistry) RejectAll() { + r.mu.Lock() + defer r.mu.Unlock() + for sid, p := range r.pending { + select { + case p.outcome <- InteractionOutcome{Status: outcomeNoResponse}: + default: + } + delete(r.pending, sid) + } +} diff --git a/go/server/interaction_e2e_test.go b/go/server/interaction_e2e_test.go new file mode 100644 index 00000000..ce84eb50 --- /dev/null +++ b/go/server/interaction_e2e_test.go @@ -0,0 +1,304 @@ +package server + +import ( + "testing" + + core "github.com/SmooAI/smooth-operator-core/go/core" + "github.com/SmooAI/smooth-operator/go/protocol" +) + +// Rich Interactions park/resume over the real WebSocket transport — the Go port of the +// Rust rust/smooth-operator-server/tests/submit_interaction.rs. Drives the choices kind +// end to end against a live server with a MockLlmProvider scripting the request_choices +// raise (offline, no gateway): +// +// - rich session (declared choice_chips): raise → interaction_required → submit_interaction +// → the turn resumes with the validated values reaching the model → reply; +// - invalid submit stays parked (interaction_invalid) and a corrected submit resumes; +// - fallback session (no choice_chips): the raise degrades to the conversational +// directive — NO interaction_required, the turn completes without parking. + +// choicesQuestionsArg is the request_choices tool argument the mock scripts: one +// single-select question. Small on purpose so the assertions stay legible. +const choicesQuestionsArg = `{"questions":[{"question":"Which plan interests you?","header":"Plan","options":[{"label":"Basic"},{"label":"Pro"}]}],"reason":"to route you"}` + +// choicesServer spins up a local server whose mock LLM scripts a request_choices call +// followed by a final text reply. The choices kind is hosted by default +// (DefaultInteractionKinds), so no extra wiring is needed beyond the mock. +func choicesServer(t *testing.T) *LocalServer { + t.Helper() + mock := core.NewMockLlmProvider() + mock.PushToolCall("call-1", "request_choices", choicesQuestionsArg) + mock.PushText("Great — I'll set you up on the Pro plan.") + + ls, err := SpawnLocal( + WithLocalAddr("127.0.0.1:0"), + WithLocalChatClient(mock), + ) + if err != nil { + t.Fatalf("spawn: %v", err) + } + return ls +} + +// createSessionSupports runs create_conversation_session declaring the given render +// capabilities and returns the sessionId (the shared createSession helper declares none). +func createSessionSupports(t *testing.T, transport protocol.Transport, supports []string) string { + t.Helper() + frame := map[string]any{ + "action": "create_conversation_session", + "requestId": "r-create", + "agentId": "11111111-1111-1111-1111-111111111111", + "userName": "Alice", + "userEmail": "alice@example.com", + } + if supports != nil { + frame["supports"] = supports + } + sendFrame(t, transport, frame) + ev := expectType(t, transport, "immediate_response") + data, _ := ev["data"].(map[string]any) + sid, _ := data["sessionId"].(string) + if sid == "" { + t.Fatalf("create session returned no sessionId (event=%s)", mustJSON(ev)) + } + return sid +} + +// TestSubmitInteractionRichPathResumes drives the full rich path: the raise parks the +// turn (interaction_required with the choices spec), the client submits a valid pick, the +// server acks, the validated values reach the model as the raise tool's result, and the +// turn completes. +func TestSubmitInteractionRichPathResumes(t *testing.T) { + ls := choicesServer(t) + defer ls.Shutdown() + transport := connectTransport(t, ls) + defer transport.Close() + + sessionID := createSessionSupports(t, transport, []string{"choice_chips"}) + + sendFrame(t, transport, map[string]any{ + "action": "send_message", + "requestId": "r-msg", + "sessionId": sessionID, + "message": "I want to sign up", + }) + if ack := expectType(t, transport, "immediate_response"); mustStatus(t, ack) != 202 { + t.Fatalf("expected 202 ack, got %v", ack["status"]) + } + + // The raise tool's toolCall chunk is emitted (deterministically) before the park. + call := expectType(t, transport, "stream_chunk") + if name, _ := dot(t, call, "data.state.rawResponse.toolCall.name"); name != "request_choices" { + t.Fatalf("expected request_choices toolCall chunk, got %v (event=%s)", name, mustJSON(call)) + } + + // The turn PARKS: interaction_required carries the kind, the spec, and an interactionId. + req := expectType(t, transport, "interaction_required") + if rid, _ := req["requestId"].(string); rid != "r-msg" { + t.Fatalf("interaction_required requestId = %q, want r-msg", rid) + } + kind, _ := dot(t, req, "data.data.kind") + if kind != "choices" { + t.Fatalf("interaction_required kind = %v, want choices (event=%s)", kind, mustJSON(req)) + } + interactionID, _ := dot(t, req, "data.data.interactionId") + iid, _ := interactionID.(string) + if iid == "" { + t.Fatalf("interaction_required carried no interactionId (event=%s)", mustJSON(req)) + } + if header, _ := dot(t, req, "data.data.spec.questions.0.header"); header != "Plan" { + t.Fatalf("interaction_required spec question header = %v, want Plan (event=%s)", header, mustJSON(req)) + } + + // Submit a valid pick → the server acks and the parked raise resumes. The ack and the + // resumed tool-result chunk come from different goroutines, so collect the tail and + // assert on its contents rather than a strict interleaving. + sendFrame(t, transport, map[string]any{ + "action": "submit_interaction", + "requestId": "r-msg", + "sessionId": sessionID, + "interactionId": iid, + "kind": "choices", + "values": map[string]any{"answers": []any{map[string]any{"header": "Plan", "options": []any{"Pro"}}}}, + }) + + tail := collectUntil(t, transport, "eventual_response") + if !hasAckFor(t, tail, iid) { + t.Fatalf("expected a 200 submit ack echoing interactionId %q, tail=%s", iid, mustJSON(tail)) + } + res := findToolResult(t, tail, "request_choices") + if !contains(res, "submitted") || !contains(res, "Pro") { + t.Fatalf("raise tool result should carry the submitted pick, got %q", res) + } + if reply := replyFrom(tail); reply != "Great — I'll set you up on the Pro plan." { + t.Fatalf("streamed reply = %q, want the wrap-up", reply) + } +} + +// TestSubmitInteractionInvalidStaysParked drives the retryable path: an invalid submit +// (a label not offered) returns interaction_invalid WITHOUT resuming the turn, and a +// corrected submit then resumes it. +func TestSubmitInteractionInvalidStaysParked(t *testing.T) { + ls := choicesServer(t) + defer ls.Shutdown() + transport := connectTransport(t, ls) + defer transport.Close() + + sessionID := createSessionSupports(t, transport, []string{"choice_chips"}) + sendFrame(t, transport, map[string]any{ + "action": "send_message", "requestId": "r-msg", "sessionId": sessionID, "message": "sign me up", + }) + expectType(t, transport, "immediate_response") // 202 + expectType(t, transport, "stream_chunk") // request_choices toolCall + req := expectType(t, transport, "interaction_required") + iid, _ := mustDotString(t, req, "data.data.interactionId") + + // Invalid pick (Platinum isn't offered) → interaction_invalid, turn STAYS parked. + sendFrame(t, transport, map[string]any{ + "action": "submit_interaction", "requestId": "r-msg", "sessionId": sessionID, + "interactionId": iid, "kind": "choices", + "values": map[string]any{"answers": []any{map[string]any{"header": "Plan", "options": []any{"Platinum"}}}}, + }) + invalid := expectType(t, transport, "interaction_invalid") + field, _ := dot(t, invalid, "data.data.errors.0.field") + if field != "Plan" { + t.Fatalf("interaction_invalid error field = %v, want Plan (event=%s)", field, mustJSON(invalid)) + } + + // A corrected submit resumes the still-parked turn (same interactionId — the park + // survived the invalid attempt). + sendFrame(t, transport, map[string]any{ + "action": "submit_interaction", "requestId": "r-msg", "sessionId": sessionID, + "interactionId": iid, "kind": "choices", + "values": map[string]any{"answers": []any{map[string]any{"header": "Plan", "options": []any{"Basic"}}}}, + }) + tail := collectUntil(t, transport, "eventual_response") + if !hasAckFor(t, tail, iid) { + t.Fatalf("expected a 200 submit ack after the corrected submit, tail=%s", mustJSON(tail)) + } + if res := findToolResult(t, tail, "request_choices"); !contains(res, "Basic") { + t.Fatalf("resumed tool result should carry the corrected pick Basic, got %q", res) + } +} + +// TestChoicesFallbackNoCapabilityDoesNotPark drives the text-only fallback: a session +// that did NOT declare choice_chips gets the conversational directive from the raise tool +// (no interaction_required, no park) and the turn completes normally. +func TestChoicesFallbackNoCapabilityDoesNotPark(t *testing.T) { + ls := choicesServer(t) + defer ls.Shutdown() + transport := connectTransport(t, ls) + defer transport.Close() + + // No `supports` → text-only channel. + sessionID := createSessionSupports(t, transport, nil) + sendFrame(t, transport, map[string]any{ + "action": "send_message", "requestId": "r-msg", "sessionId": sessionID, "message": "sign me up", + }) + expectType(t, transport, "immediate_response") // 202 + + // The raise tool degrades to the fallback directive — the model sees it as a tool + // result, and there is NO interaction_required / park anywhere in the turn. + tail := collectUntil(t, transport, "eventual_response") + for _, ev := range tail { + if typ, _ := ev["type"].(string); typ == "interaction_required" { + t.Fatalf("a fallback session must NOT park (got interaction_required): %s", mustJSON(ev)) + } + } + res := findToolResult(t, tail, "request_choices") + if !contains(res, "conversational") || !contains(res, "Basic, Pro") { + t.Fatalf("fallback tool result should carry the conversational directive, got %q", res) + } + // The turn completes normally (no parked interaction to resume). + if reply := replyFrom(tail); reply == "" { + t.Fatalf("expected a wrap-up reply on the fallback path") + } +} + +// mustStatus pulls the integer status off an immediate_response (fatal if absent). +func mustStatus(t *testing.T, ev map[string]any) int { + t.Helper() + s, ok := asInt(ev["status"]) + if !ok { + t.Fatalf("event has no integer status: %s", mustJSON(ev)) + } + return s +} + +// mustDotString reads a dotted path as a non-empty string (fatal otherwise). +func mustDotString(t *testing.T, obj map[string]any, path string) (string, bool) { + t.Helper() + v, ok := dot(t, obj, path) + s, _ := v.(string) + if !ok || s == "" { + t.Fatalf("path %q missing or empty (obj=%s)", path, mustJSON(obj)) + } + return s, true +} + +// collectUntil reads events until (and including) the first of the given type, returning +// all collected events. Used where two goroutines emit into the sink and the exact +// interleaving isn't guaranteed (the submit ack vs the resumed tool-result chunk). +func collectUntil(t *testing.T, transport protocol.Transport, typ string) []map[string]any { + t.Helper() + var out []map[string]any + for { + ev := nextEv(t, transport) + out = append(out, ev) + if got, _ := ev["type"].(string); got == typ { + return out + } + } +} + +// hasAckFor reports whether the collected events include a 200 immediate_response whose +// data.interactionId echoes iid (the submit ack). +func hasAckFor(t *testing.T, events []map[string]any, iid string) bool { + t.Helper() + for _, ev := range events { + if typ, _ := ev["type"].(string); typ != "immediate_response" { + continue + } + if s, ok := asInt(ev["status"]); !ok || s != 200 { + continue + } + if got, _ := dot(t, ev, "data.interactionId"); got == iid { + return true + } + } + return false +} + +// findToolResult returns the result string of the first stream_chunk toolResult for the +// named tool (fatal if none present). +func findToolResult(t *testing.T, events []map[string]any, tool string) string { + t.Helper() + for _, ev := range events { + if typ, _ := ev["type"].(string); typ != "stream_chunk" { + continue + } + name, ok := dot(t, ev, "data.state.rawResponse.toolResult.name") + if !ok || name != tool { + continue + } + res, _ := dot(t, ev, "data.state.rawResponse.toolResult.result") + s, _ := res.(string) + return s + } + t.Fatalf("no toolResult chunk for %q in %s", tool, mustJSON(events)) + return "" +} + +// replyFrom accumulates the reply text from the collected stream_token events. +func replyFrom(events []map[string]any) string { + reply := "" + for _, ev := range events { + if typ, _ := ev["type"].(string); typ == "stream_token" { + if tok, ok := ev["token"].(string); ok { + reply += tok + } + } + } + return reply +} diff --git a/go/server/protocol.go b/go/server/protocol.go index 3d7d5a96..b9b1886b 100644 --- a/go/server/protocol.go +++ b/go/server/protocol.go @@ -1,6 +1,9 @@ package server -import "time" +import ( + "encoding/json" + "time" +) // Builders for the server→client protocol event frames. Every event is a // map[string]any serialized as a JSON text frame. The shapes mirror the Rust @@ -163,6 +166,52 @@ func writeConfirmationRequired(requestID, toolID, actionDescription string) map[ } } +// interactionRequired is the Rich Interactions envelope: emitted mid-turn when an +// agent's raise tool parks awaiting the visitor on a session that declared the kind's +// render capability. The client renders the kind's card and replies with a +// submit_interaction action carrying the same requestId + interactionId. +// +// Wire shape matches spec/events/interaction-required.schema.json and the Rust +// reference byte-for-byte: double-nested data.data.{interactionId, kind, spec, reason}. +// spec is the kind-specific render spec (raw JSON) the client's card renders from. +func interactionRequired(requestID, interactionID, kind string, spec json.RawMessage, reason string) map[string]any { + return map[string]any{ + "type": "interaction_required", + "requestId": requestID, + "data": map[string]any{ + "requestId": requestID, + "data": map[string]any{ + "interactionId": interactionID, + "kind": kind, + "spec": spec, + "reason": reason, + }, + }, + "timestamp": nowMs(), + } +} + +// interactionInvalid is emitted when a submit_interaction carried values that failed +// the kind's server-side validation. The turn REMAINS parked; the client re-renders +// the card with the per-field errors. Mirrors otp_invalid (retryable, never a terminal +// error). Wire shape matches spec/events/interaction-invalid.schema.json. +func interactionInvalid(requestID, interactionID, kind string, errors []InteractionFieldError, message string) map[string]any { + return map[string]any{ + "type": "interaction_invalid", + "requestId": requestID, + "data": map[string]any{ + "requestId": requestID, + "data": map[string]any{ + "interactionId": interactionID, + "kind": kind, + "errors": errors, + "message": message, + }, + }, + "timestamp": nowMs(), + } +} + // otpVerificationRequired is emitted after a turn's auth gate refused an end_user tool on // an unverified session and the host has an OtpService installed. It tells the client to // collect a one-time code. Wire shape matches spec/events/otp-verification-required.schema.json diff --git a/go/server/server.go b/go/server/server.go index 623e4986..dc41c33f 100644 --- a/go/server/server.go +++ b/go/server/server.go @@ -356,6 +356,9 @@ func (s *Server) connectionLoop(conn *websocket.Conn, access AccessContext) { // rather than inline. teardown := func(status websocket.StatusCode, reason string) { confirmations.RejectAll() + // Unpark any turn blocked on a Rich Interaction raise (resolve no_response) so it + // finishes cleanly — the same fail-open-to-continue contract as confirmations. + dispatcher.interactions.RejectAll() dispatcher.WaitForTurns() sendMu.Lock() if !sinkClosed { diff --git a/go/server/turn_runner.go b/go/server/turn_runner.go index 7415e699..4d1d0e0d 100644 --- a/go/server/turn_runner.go +++ b/go/server/turn_runner.go @@ -10,6 +10,7 @@ import ( "time" core "github.com/SmooAI/smooth-operator-core/go/core" + "github.com/google/uuid" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -88,6 +89,19 @@ type TurnRunner struct { // the dispatcher so a confirm_tool_action frame resolves the verdict a parked // turn awaits. nil → HITL off. confirmations *ConfirmationRegistry + // interactionKinds is the catalog of Rich Interaction kinds hosted this turn. When + // set (with interactions), the runner registers one raise tool per kind: a kind whose + // capability the session declared PARKS the turn on raise (emit interaction_required, + // await a submit_interaction), the rest degrade to their conversational fallback + // directive. nil → no interaction tools (behavior unchanged). Set by the dispatcher. + interactionKinds *InteractionKinds + // interactions is the session-keyed park/resume registry a submit_interaction frame + // resolves, shared with the dispatcher. nil → Rich Interactions off. Set by the dispatcher. + interactions *InteractionRegistry + // capabilities is the render capabilities the session's client declared at + // create_conversation_session — gates which kinds get the rich (parked card) path + // vs the conversational fallback. Empty → every kind falls back. Set by the dispatcher. + capabilities map[string]bool // workflow is the agent's structured conversation workflow (nil → freeform). When // set, the runner judges the turn after it completes and returns the advanced step // id in TurnResult.NextStepID. The current step is already rendered into systemPrompt @@ -230,6 +244,16 @@ func (r *TurnRunner) Run(ctx context.Context, sessionID, conversationID, request opts.MaxTokens = clampMaxTokens(DefaultMaxTokens, r.modelCeiling) opts.MaxIterations = DefaultMaxIterations + // Rich Interactions: register ONE raise tool per hosted kind (choices, …). A kind + // whose render capability the session declared PARKS the turn on raise — the raise + // tool blocks awaiting a submit_interaction while the server emits interaction_required + // — and the rest degrade to their conversational-fallback directive. With no catalog + // (the default), nothing is added and behavior is unchanged. Appended AFTER the base + // tools so a raise tool never shadows a built-in. + if r.interactionKinds != nil && r.interactions != nil { + opts.Tools = append(append([]core.Tool{}, opts.Tools...), r.interactionTools(sessionID, requestID, sink)...) + } + // Write-confirmation HITL: when configured with tool patterns AND a registry is // present, install a HumanGate that parks the turn before a gated tool runs (emit // write_confirmation_required, await the client's verdict via the session-keyed @@ -377,7 +401,12 @@ consume: // DEFER a confirmation-gated tool's toolCall chunk: it is emitted from the // gate AFTER write_confirmation_required, so the wire order matches the // reference (Rust) server. Non-gated tools emit their chunk inline as before. - if r.isGated(ev.Name) { + // + // A Rich Interaction raise tool's chunk is likewise deferred and emitted from + // INSIDE the tool (right before interaction_required / the fallback result), so + // the toolCall chunk deterministically precedes the park event rather than + // racing it across goroutines. + if r.isGated(ev.Name) || r.isInteractionRaise(ev.Name) { continue } sink(streamChunk(requestID, ev.Name, toolCallState(ev.Name, ev.Arguments))) @@ -530,3 +559,128 @@ func toolResultState(name, result string) map[string]any { }, } } + +// interactionTimeout is how long a parked raise tool waits for a submit_interaction +// before giving up and letting the turn continue without the details (generous — a human +// is filling a card). Mirrors the Rust INTERACTION_TIMEOUT. +const interactionTimeout = 300 * time.Second + +// interactionTools builds one raise tool per hosted Rich Interaction kind, closing over +// this turn's sink + ids so the tool can park (emit interaction_required, block awaiting a +// submit_interaction) or, on a text-only channel, degrade to the kind's conversational +// fallback directive. The Go analog of the Rust runner registering RequestInteractionTool +// per kind (rust/smooth-operator/src/tools/interaction.rs). +func (r *TurnRunner) interactionTools(sessionID, requestID string, sink EventSink) []core.Tool { + var tools []core.Tool + for _, kind := range r.interactionKinds.All() { + rich := r.capabilities[kind.Capability()] + tools = append(tools, r.raiseTool(kind, rich, sessionID, requestID, sink)) + } + return tools +} + +// isInteractionRaise reports whether name is one of the hosted kinds' raise tools +// (request_). The stream loop defers these tools' toolCall chunk so it can be +// emitted deterministically from inside the tool (before interaction_required). +func (r *TurnRunner) isInteractionRaise(name string) bool { + if r.interactionKinds == nil { + return false + } + for _, kind := range r.interactionKinds.All() { + if kind.ToolSchema().Name == name { + return true + } + } + return false +} + +// raiseTool builds the request_ tool for one kind. rich selects the park path: +// - rich: register the outcome channel, emit interaction_required, and BLOCK inside the +// tool until a submit_interaction resolves it (or the turn's context is cancelled, or +// the park times out → the turn continues without the details, never an error). +// - text-only: return the kind's conversational-fallback directive immediately. +// +// The tool blocks inside Execute; that is safe because the whole turn runs on its own +// goroutine (send_message spawns it), so the connection's read loop stays free to receive +// the submit_interaction that unparks it — exactly like the write-confirmation gate. +func (r *TurnRunner) raiseTool(kind InteractionKind, rich bool, sessionID, requestID string, sink EventSink) core.Tool { + schema := kind.ToolSchema() + return core.FuncTool{ + ToolName: schema.Name, + Desc: schema.Description, + Params: schema.Parameters, + Fn: func(ctx context.Context, args map[string]any) (string, error) { + // Emit the deferred toolCall chunk here (the stream loop skipped it), so it + // deterministically precedes interaction_required / the fallback result. + if argsJSON, err := json.Marshal(args); err == nil { + sink(streamChunk(requestID, schema.Name, toolCallState(schema.Name, string(argsJSON)))) + } + + req, err := kind.ParseRequest(args) + if err != nil { + return "", err + } + + if !rich { + // Text-only channel: degrade to the kind's conversational directive. The + // model collects the answer turn by turn and continues; no park. + return marshalInteractionResult(map[string]any{ + "mode": "conversational", + "kind": req.Kind, + "spec": req.Spec, + "reason": req.Reason, + "instructions": kind.FallbackDirective(req.Spec, req.Reason), + }), nil + } + + // Rich channel: park the turn. Register the outcome channel, emit + // interaction_required, then await the visitor's submit_interaction. + interactionID := uuid.NewString() + outcome := r.interactions.Register(sessionID, interactionID, req.Kind, req.Spec) + sink(interactionRequired(requestID, interactionID, req.Kind, req.Spec, req.Reason)) + + select { + case oc := <-outcome: + switch oc.Status { + case outcomeSubmitted: + return marshalInteractionResult(map[string]any{"status": outcomeSubmitted, "values": oc.Values}), nil + case outcomeDeclined: + return marshalInteractionResult(map[string]any{ + "status": outcomeDeclined, + "message": "The visitor declined. Continue helping them without this and do not ask again this conversation.", + }), nil + default: + return noResponseInteractionResult(), nil + } + case <-ctx.Done(): + // The turn's context was cancelled (connection torn down / cancel frame) + // before a submit landed — drop the park and let the turn unwind. + r.interactions.Clear(sessionID) + return noResponseInteractionResult(), ctx.Err() + case <-time.After(interactionTimeout): + // The visitor never answered the card — continue without it (not an error). + r.interactions.Clear(sessionID) + return noResponseInteractionResult(), nil + } + }, + } +} + +// noResponseInteractionResult is the tool result when a parked interaction was not +// answered (timeout / teardown): the turn continues without the details. +func noResponseInteractionResult() string { + return marshalInteractionResult(map[string]any{ + "status": outcomeNoResponse, + "message": "The visitor did not respond to the card. Continue without it; you may offer again later if it becomes relevant.", + }) +} + +// marshalInteractionResult serializes a raise tool's result envelope to a JSON string the +// model reads. On the (unreachable) marshal error, falls back to a minimal envelope. +func marshalInteractionResult(v map[string]any) string { + b, err := json.Marshal(v) + if err != nil { + return `{"status":"no_response"}` + } + return string(b) +}