From 442fb838b7338b98d87b4eec852919efc07489e1 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:06:34 +1000 Subject: [PATCH 01/11] Add audit envelopes to Codex messages and user input Expose provider-native audit envelopes on surfaced message types and propagate audit metadata through requestUserInput parsing and response serialization. This keeps provider event capture SDK-owned while preserving the existing normalized public surface for downstream consumers. --- internal/message/message.go | 51 +++++++++++++++++++++---- internal/message/parse.go | 68 +++++++++++++++++++++++++++++---- internal/protocol/session.go | 21 ++++++++++ internal/userinput/userinput.go | 8 +++- types.go | 3 ++ 5 files changed, 136 insertions(+), 15 deletions(-) diff --git a/internal/message/message.go b/internal/message/message.go index 43accd3..d7491be 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -8,6 +8,38 @@ type Message interface { MessageType() string } +// AuditEnvelope contains the provider-native event metadata and canonical payload +// captured at the SDK boundary. +type AuditEnvelope struct { + EventType string `json:"event_type"` + Subtype string `json:"subtype,omitempty"` + Payload json.RawMessage `json:"payload"` +} + +func (a *AuditEnvelope) GetEventType() string { + if a == nil { + return "" + } + + return a.EventType +} + +func (a *AuditEnvelope) GetSubtype() string { + if a == nil { + return "" + } + + return a.Subtype +} + +func (a *AuditEnvelope) GetPayload() json.RawMessage { + if a == nil { + return nil + } + + return a.Payload +} + // Compile-time verification that all message types implement Message. var ( _ Message = (*UserMessage)(nil) @@ -115,6 +147,7 @@ type UserMessage struct { UUID *string `json:"uuid,omitempty"` ParentToolUseID *string `json:"parent_tool_use_id,omitempty"` ToolUseResult map[string]any `json:"tool_use_result,omitempty"` + Audit *AuditEnvelope `json:"-"` } // MessageType implements the Message interface. @@ -129,6 +162,7 @@ type AssistantMessage struct { Model string `json:"model"` ParentToolUseID *string `json:"parent_tool_use_id,omitempty"` Error *AssistantMessageError `json:"error,omitempty"` + Audit *AuditEnvelope `json:"-"` } // MessageType implements the Message interface. @@ -157,6 +191,7 @@ type SystemMessage struct { Type string `json:"type"` Subtype string `json:"subtype,omitempty"` Data map[string]any `json:"data,omitempty"` + Audit *AuditEnvelope `json:"-"` } // MessageType implements the Message interface. @@ -193,13 +228,14 @@ type ThreadRolledBackMessage struct { // //nolint:tagliatelle // CLI uses snake_case type ResultMessage struct { - Type string `json:"type"` - Subtype string `json:"subtype"` - IsError bool `json:"is_error"` - SessionID string `json:"session_id"` - Usage *Usage `json:"usage,omitempty"` - Result *string `json:"result,omitempty"` - StructuredOutput any `json:"structured_output,omitempty"` + Type string `json:"type"` + Subtype string `json:"subtype"` + IsError bool `json:"is_error"` + SessionID string `json:"session_id"` + Usage *Usage `json:"usage,omitempty"` + Result *string `json:"result,omitempty"` + StructuredOutput any `json:"structured_output,omitempty"` + Audit *AuditEnvelope `json:"-"` } // MessageType implements the Message interface. @@ -213,6 +249,7 @@ type StreamEvent struct { SessionID string `json:"session_id"` Event map[string]any `json:"event"` ParentToolUseID *string `json:"parent_tool_use_id,omitempty"` + Audit *AuditEnvelope `json:"-"` } // MessageType implements the Message interface. diff --git a/internal/message/parse.go b/internal/message/parse.go index 5f63aba..1362307 100644 --- a/internal/message/parse.go +++ b/internal/message/parse.go @@ -9,6 +9,22 @@ import ( "github.com/ethpandaops/codex-agent-sdk-go/internal/errors" ) +func newAuditEnvelope(data map[string]any) (*AuditEnvelope, error) { + payload, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("marshal audit payload: %w", err) + } + + eventType, _ := data["type"].(string) + subtype, _ := data["subtype"].(string) + + return &AuditEnvelope{ + EventType: eventType, + Subtype: subtype, + Payload: payload, + }, nil +} + // Parse converts a raw JSON map into a typed Message. // // This function handles both Claude-style messages (with "type": "user"|"assistant"|etc.) @@ -28,21 +44,59 @@ func Parse(log *slog.Logger, data map[string]any) (Message, error) { log.Debug("parsing message", slog.String("message_type", msgType)) // Try Claude-style message types first + var ( + msg Message + err error + ) + switch msgType { case "user": - return parseUserMessage(data) + msg, err = parseUserMessage(data) case "assistant": - return parseAssistantMessage(data) + msg, err = parseAssistantMessage(data) case "system": - return parseSystemMessage(data) + msg, err = parseSystemMessage(data) case "result": - return parseResultMessage(data) + msg, err = parseResultMessage(data) case "stream_event": - return parseStreamEvent(data) + msg, err = parseStreamEvent(data) + default: + msg, err = parseCodexEvent(log, data, EventType(msgType)) + } + + if err != nil { + return nil, err + } + + audit, err := newAuditEnvelope(data) + if err != nil { + return nil, err } - // Try Codex event types - return parseCodexEvent(log, data, EventType(msgType)) + attachAudit(msg, audit) + + return msg, nil +} + +func attachAudit(msg Message, audit *AuditEnvelope) { + switch typed := msg.(type) { + case *UserMessage: + typed.Audit = audit + case *AssistantMessage: + typed.Audit = audit + case *SystemMessage: + typed.Audit = audit + case *TaskStartedMessage: + typed.Audit = audit + case *TaskCompleteMessage: + typed.Audit = audit + case *ThreadRolledBackMessage: + typed.Audit = audit + case *ResultMessage: + typed.Audit = audit + case *StreamEvent: + typed.Audit = audit + } } // parseCodexEvent converts a Codex event into a claude-sdk-compatible Message. diff --git a/internal/protocol/session.go b/internal/protocol/session.go index 8a0ab48..1a45984 100644 --- a/internal/protocol/session.go +++ b/internal/protocol/session.go @@ -14,6 +14,7 @@ import ( "github.com/ethpandaops/codex-agent-sdk-go/internal/config" "github.com/ethpandaops/codex-agent-sdk-go/internal/mcp" + "github.com/ethpandaops/codex-agent-sdk-go/internal/message" "github.com/ethpandaops/codex-agent-sdk-go/internal/permission" "github.com/ethpandaops/codex-agent-sdk-go/internal/userinput" ) @@ -661,6 +662,17 @@ func parseUserInputRequest(req *ControlRequest) (*userinput.Request, error) { result.ThreadID, _ = req.Request["thread_id"].(string) result.TurnID, _ = req.Request["turn_id"].(string) + payload, err := json.Marshal(req.Request) + if err != nil { + return nil, fmt.Errorf("marshal user input request: %w", err) + } + + result.Audit = &message.AuditEnvelope{ + EventType: "item_tool/requestUserInput", + Subtype: "request", + Payload: payload, + } + questionsRaw, _ := req.Request["questions"].([]any) if len(questionsRaw) == 0 { return result, nil @@ -722,6 +734,15 @@ func serializeUserInputResponse(resp *userinput.Response) map[string]any { } } + payload, err := json.Marshal(map[string]any{"answers": answers}) + if err == nil { + resp.Audit = &message.AuditEnvelope{ + EventType: "item_tool/requestUserInput", + Subtype: "response", + Payload: payload, + } + } + return map[string]any{"answers": answers} } diff --git a/internal/userinput/userinput.go b/internal/userinput/userinput.go index 8d43899..e9eb50e 100644 --- a/internal/userinput/userinput.go +++ b/internal/userinput/userinput.go @@ -1,7 +1,11 @@ // Package userinput provides types for the item/tool/requestUserInput protocol. package userinput -import "context" +import ( + "context" + + "github.com/ethpandaops/codex-agent-sdk-go/internal/message" +) // QuestionOption represents a selectable choice within a question. type QuestionOption struct { @@ -30,11 +34,13 @@ type Request struct { ThreadID string TurnID string Questions []Question + Audit *message.AuditEnvelope `json:"-"` } // Response contains the answers to all questions keyed by question ID. type Response struct { Answers map[string]*Answer + Audit *message.AuditEnvelope `json:"-"` } // Callback is invoked when the CLI sends an item/tool/requestUserInput request. diff --git a/types.go b/types.go index 6c41a25..298fe10 100644 --- a/types.go +++ b/types.go @@ -106,6 +106,9 @@ type Message = message.Message // UserMessage represents a message from the user. type UserMessage = message.UserMessage +// AuditEnvelope captures the provider-native event payload emitted by the SDK. +type AuditEnvelope = message.AuditEnvelope + // UserMessageContent represents content that can be either a string or []ContentBlock. type UserMessageContent = message.UserMessageContent From 9171865e03c2a28b0f913fbb2401a26647b894d0 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Sun, 29 Mar 2026 13:58:03 +1000 Subject: [PATCH 02/11] Add public NewAuditEnvelope constructor and audit envelope tests Expose NewAuditEnvelope(eventType, subtype, payload) as a public API matching the OpenRouter SDK signature, enabling consumers to construct audit envelopes from typed payloads. The existing private parse-time constructor remains for raw wire data attachment. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/message/message.go | 21 +++++- internal/message/parse_test.go | 121 +++++++++++++++++++++++++++++++++ types.go | 3 + 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/internal/message/message.go b/internal/message/message.go index d7491be..bbe2d12 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -1,6 +1,9 @@ package message -import "encoding/json" +import ( + "encoding/json" + "fmt" +) // Message represents any message in the conversation. // Use type assertion or type switch to determine the concrete type. @@ -10,6 +13,8 @@ type Message interface { // AuditEnvelope contains the provider-native event metadata and canonical payload // captured at the SDK boundary. +// +//nolint:tagliatelle // wire format uses snake_case type AuditEnvelope struct { EventType string `json:"event_type"` Subtype string `json:"subtype,omitempty"` @@ -40,6 +45,20 @@ func (a *AuditEnvelope) GetPayload() json.RawMessage { return a.Payload } +// NewAuditEnvelope marshals a canonical payload into an audit envelope. +func NewAuditEnvelope(eventType, subtype string, payload any) (*AuditEnvelope, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal audit payload: %w", err) + } + + return &AuditEnvelope{ + EventType: eventType, + Subtype: subtype, + Payload: data, + }, nil +} + // Compile-time verification that all message types implement Message. var ( _ Message = (*UserMessage)(nil) diff --git a/internal/message/parse_test.go b/internal/message/parse_test.go index cd312b7..6d1a182 100644 --- a/internal/message/parse_test.go +++ b/internal/message/parse_test.go @@ -1,10 +1,12 @@ package message import ( + "encoding/json" "log/slog" "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -821,3 +823,122 @@ func TestCodexUsage_ReasoningOutputTokens(t *testing.T) { require.NotNil(t, result.Usage) require.Equal(t, 75, result.Usage.ReasoningOutputTokens) } + +func TestParse_AttachesAuditEnvelope_AssistantMessage(t *testing.T) { + logger := slog.Default() + + data := map[string]any{ + "type": "assistant", + "message": map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": "hello"}, + }, + "model": "codex-mini-latest", + }, + } + + msg, err := Parse(logger, data) + require.NoError(t, err) + + am, ok := msg.(*AssistantMessage) + require.True(t, ok) + require.NotNil(t, am.Audit) + assert.Equal(t, "assistant", am.Audit.EventType) + assert.NotNil(t, am.Audit.Payload) + + var payload map[string]any + + err = json.Unmarshal(am.Audit.Payload, &payload) + require.NoError(t, err) + assert.Equal(t, "assistant", payload["type"]) +} + +func TestParse_AttachesAuditEnvelope_SystemMessage(t *testing.T) { + logger := slog.Default() + + data := map[string]any{ + "type": "system", + "subtype": "task.started", + "data": map[string]any{"turn_id": "turn-1"}, + } + + msg, err := Parse(logger, data) + require.NoError(t, err) + + sm, ok := msg.(*TaskStartedMessage) + require.True(t, ok) + require.NotNil(t, sm.Audit) + assert.Equal(t, "system", sm.Audit.EventType) + assert.Equal(t, "task.started", sm.Audit.Subtype) +} + +func TestParse_AttachesAuditEnvelope_ResultMessage(t *testing.T) { + logger := slog.Default() + + data := map[string]any{ + "type": "result", + "subtype": "success", + "is_error": false, + "session_id": "sess-1", + "result": "done", + } + + msg, err := Parse(logger, data) + require.NoError(t, err) + + rm, ok := msg.(*ResultMessage) + require.True(t, ok) + require.NotNil(t, rm.Audit) + assert.Equal(t, "result", rm.Audit.EventType) + assert.Equal(t, "success", rm.Audit.Subtype) +} + +func TestParse_AuditPayloadPreservesRawWireData(t *testing.T) { + logger := slog.Default() + + data := map[string]any{ + "type": "assistant", + "message": map[string]any{ + "content": []any{}, + "model": "test-model", + }, + "custom_field": "preserved", + } + + msg, err := Parse(logger, data) + require.NoError(t, err) + + am, ok := msg.(*AssistantMessage) + require.True(t, ok) + require.NotNil(t, am.Audit) + + var payload map[string]any + + err = json.Unmarshal(am.Audit.Payload, &payload) + require.NoError(t, err) + assert.Equal(t, "preserved", payload["custom_field"]) +} + +func TestNewAuditEnvelope_PublicConstructor(t *testing.T) { + type testPayload struct { + Key string `json:"key"` + } + + env, err := NewAuditEnvelope("test_event", "test_sub", testPayload{Key: "val"}) + require.NoError(t, err) + require.NotNil(t, env) + assert.Equal(t, "test_event", env.EventType) + assert.Equal(t, "test_sub", env.Subtype) + + var payload map[string]any + + err = json.Unmarshal(env.Payload, &payload) + require.NoError(t, err) + assert.Equal(t, "val", payload["key"]) +} + +func TestNewAuditEnvelope_MarshalError(t *testing.T) { + env, err := NewAuditEnvelope("event", "sub", make(chan int)) + assert.Error(t, err) + assert.Nil(t, env) +} diff --git a/types.go b/types.go index 298fe10..699753e 100644 --- a/types.go +++ b/types.go @@ -109,6 +109,9 @@ type UserMessage = message.UserMessage // AuditEnvelope captures the provider-native event payload emitted by the SDK. type AuditEnvelope = message.AuditEnvelope +// NewAuditEnvelope creates an audit envelope from a typed payload. +var NewAuditEnvelope = message.NewAuditEnvelope + // UserMessageContent represents content that can be either a string or []ContentBlock. type UserMessageContent = message.UserMessageContent From d0fc3298fbf7da552fa1ccbcaf76e4503283a510 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:41:59 +1000 Subject: [PATCH 03/11] fix example script --- scripts/test_examples.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/test_examples.sh b/scripts/test_examples.sh index 97fc423..8f0540d 100755 --- a/scripts/test_examples.sh +++ b/scripts/test_examples.sh @@ -228,10 +228,20 @@ run_example() { args="$(example_args "$name")" + # Examples with their own go.mod are separate modules and must be + # built from within their directory. + local run_dir="$REPO_ROOT" + local run_pkg="./examples/$name" + + if [[ -f "$EXAMPLES_DIR/$name/go.mod" ]]; then + run_dir="$EXAMPLES_DIR/$name" + run_pkg="." + fi + if [[ -n "$args" ]]; then - (cd "$REPO_ROOT" && timeout "$TIMEOUT" go run "./examples/$name" "$args") >"$logfile" 2>&1 + (cd "$run_dir" && timeout "$TIMEOUT" go run "$run_pkg" "$args") >"$logfile" 2>&1 else - (cd "$REPO_ROOT" && timeout "$TIMEOUT" go run "./examples/$name") >"$logfile" 2>&1 + (cd "$run_dir" && timeout "$TIMEOUT" go run "$run_pkg") >"$logfile" 2>&1 fi } From 31397aff9e5c35b5122e665e845774d2974c4b76 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:39:31 +1000 Subject: [PATCH 04/11] Add WithMaxTurns option and MultiSelect support for user input questions Add WithMaxTurns option with exec CLI flag and app-server protocol support across both backends. Add MultiSelect field to user input Question struct with camelCase/snake_case parsing. --- internal/cli/cli_test.go | 20 ++++++++++++++++++++ internal/cli/command.go | 5 +++++ internal/config/capability.go | 2 ++ internal/config/options.go | 3 +++ internal/protocol/session.go | 12 ++++++++++++ internal/protocol/session_test.go | 8 +++++--- internal/userinput/userinput.go | 13 +++++++------ options.go | 7 +++++++ 8 files changed, 61 insertions(+), 9 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 2cf9d54..a613978 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -87,6 +87,26 @@ func TestBuildExecArgs_WithoutModel(t *testing.T) { require.NotContains(t, args, "-m") } +// TestBuildExecArgs_WithMaxTurns tests command building with max turns option. +func TestBuildExecArgs_WithMaxTurns(t *testing.T) { + options := &config.Options{ + MaxTurns: 5, + } + + args := BuildExecArgs("test", options) + + require.Contains(t, args, "--max-turns") + require.Contains(t, args, "5") +} + +// TestBuildExecArgs_WithoutMaxTurns tests that no --max-turns flag appears when max turns is zero. +func TestBuildExecArgs_WithoutMaxTurns(t *testing.T) { + options := &config.Options{} + args := BuildExecArgs("test", options) + + require.NotContains(t, args, "--max-turns") +} + // TestBuildExecArgs_WithSandboxDirect tests direct sandbox mode option. func TestBuildExecArgs_WithSandboxDirect(t *testing.T) { options := &config.Options{ diff --git a/internal/cli/command.go b/internal/cli/command.go index e596c3b..e1b56e7 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "sort" + "strconv" "github.com/ethpandaops/codex-agent-sdk-go/internal/config" "github.com/ethpandaops/codex-agent-sdk-go/internal/mcp" @@ -110,6 +111,10 @@ func BuildExecArgs(prompt string, options *config.Options) []string { args = append(args, "-m", options.Model) } + if options.MaxTurns > 0 { + args = append(args, "--max-turns", strconv.Itoa(options.MaxTurns)) + } + // Sandbox mode: explicit Sandbox field takes precedence, then map from PermissionMode sandbox := options.Sandbox if sandbox == "" { diff --git a/internal/config/capability.go b/internal/config/capability.go index ec720c7..bdc2e0c 100644 --- a/internal/config/capability.go +++ b/internal/config/capability.go @@ -47,6 +47,7 @@ var optionCapabilities = []OptionCapability{ }, {Field: "Model", OptionName: "WithModel", Exec: SupportSupported, AppServer: SupportSupported}, {Field: "PermissionMode", OptionName: "WithPermissionMode", Exec: SupportSupported, AppServer: SupportSupported}, + {Field: "MaxTurns", OptionName: "WithMaxTurns", Exec: SupportSupported, AppServer: SupportSupported}, {Field: "Cwd", OptionName: "WithCwd", Exec: SupportSupported, AppServer: SupportSupported}, {Field: "CliPath", OptionName: "WithCliPath", Exec: SupportSupported, AppServer: SupportSupported}, {Field: "Env", OptionName: "WithEnv", Exec: SupportSupported, AppServer: SupportSupported}, @@ -152,6 +153,7 @@ func EnabledOptionFields(opts *Options) map[string]bool { set("SystemPromptPreset", opts.SystemPromptPreset != nil) set("Model", opts.Model != "") set("PermissionMode", opts.PermissionMode != "") + set("MaxTurns", opts.MaxTurns > 0) set("Cwd", opts.Cwd != "") set("CliPath", opts.CliPath != "") set("Env", len(opts.Env) > 0) diff --git a/internal/config/options.go b/internal/config/options.go index b5e0ecf..c4e1a93 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -83,6 +83,9 @@ type Options struct { // "bypassPermissions". PermissionMode string + // MaxTurns sets the maximum number of conversation turns. + MaxTurns int + // Cwd sets the working directory for the CLI process. Cwd string diff --git a/internal/protocol/session.go b/internal/protocol/session.go index 1a45984..8fc3ee0 100644 --- a/internal/protocol/session.go +++ b/internal/protocol/session.go @@ -145,6 +145,10 @@ func (s *Session) buildInitializePayload() map[string]any { payload["model"] = s.options.Model } + if s.options.MaxTurns > 0 { + payload["maxTurns"] = s.options.MaxTurns + } + if s.options.Cwd != "" { payload["cwd"] = s.options.Cwd } @@ -690,6 +694,14 @@ func parseUserInputRequest(req *ControlRequest) (*userinput.Request, error) { q.ID, _ = qMap["id"].(string) q.Header, _ = qMap["header"].(string) q.Question, _ = qMap["question"].(string) + + q.MultiSelect, _ = qMap["multiSelect"].(bool) + if !q.MultiSelect { + if multiSelectSnake, ok := qMap["multi_select"].(bool); ok { + q.MultiSelect = multiSelectSnake + } + } + q.IsOther, _ = qMap["is_other"].(bool) q.IsSecret, _ = qMap["is_secret"].(bool) diff --git a/internal/protocol/session_test.go b/internal/protocol/session_test.go index 1b1ae93..9321b02 100644 --- a/internal/protocol/session_test.go +++ b/internal/protocol/session_test.go @@ -475,9 +475,10 @@ func TestSession_HandleRequestUserInput_WithCallback(t *testing.T) { "turn_id": "turn_3", "questions": []any{ map[string]any{ - "id": "lang", - "header": "Language", - "question": "Which language?", + "id": "lang", + "header": "Language", + "question": "Which language?", + "multi_select": true, "options": []any{ map[string]any{"label": "Go", "description": "Fast compiled"}, map[string]any{"label": "Rust", "description": "Memory safe"}, @@ -497,6 +498,7 @@ func TestSession_HandleRequestUserInput_WithCallback(t *testing.T) { require.Equal(t, "lang", captured.Questions[0].ID) require.Equal(t, "Language", captured.Questions[0].Header) require.Equal(t, "Which language?", captured.Questions[0].Question) + require.True(t, captured.Questions[0].MultiSelect) require.Len(t, captured.Questions[0].Options, 3) require.Equal(t, "Rust", captured.Questions[0].Options[1].Label) diff --git a/internal/userinput/userinput.go b/internal/userinput/userinput.go index e9eb50e..2f281ac 100644 --- a/internal/userinput/userinput.go +++ b/internal/userinput/userinput.go @@ -15,12 +15,13 @@ type QuestionOption struct { // Question represents a single question posed to the user. type Question struct { - ID string - Header string - Question string - IsOther bool - IsSecret bool - Options []QuestionOption // nil means free text input + ID string + Header string + Question string + MultiSelect bool + IsOther bool + IsSecret bool + Options []QuestionOption // nil means free text input } // Answer contains the user's response(s) to a question. diff --git a/options.go b/options.go index dfbb6cc..be42cd4 100644 --- a/options.go +++ b/options.go @@ -66,6 +66,13 @@ func WithPermissionMode(mode string) Option { } } +// WithMaxTurns sets the maximum number of conversation turns. +func WithMaxTurns(maxTurns int) Option { + return func(o *CodexAgentOptions) { + o.MaxTurns = maxTurns + } +} + // WithCwd sets the working directory for the CLI process. func WithCwd(cwd string) Option { return func(o *CodexAgentOptions) { From b2a2c42cda6accf5e1f557c6a4c6531743b42254 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:31:02 +1000 Subject: [PATCH 05/11] Preserve raw JSON bytes in audit envelopes for wire-fidelity The audit envelope payload was previously produced by re-marshaling the decoded map[string]any, which loses byte-level details like number formatting (1e+06 vs 1000000), key ordering, and whitespace from the original wire data. This is problematic for audit/provenance use cases where exact reproduction of the provider response matters. Changes: - Add internal/message/raw_json.go with AnnotateRawJSON, extractRawJSON, and stripRawJSON helpers that thread original bytes through decoded maps via a hidden sentinel key. - Widen Parse() signature from map[string]any to any, accepting []byte and json.RawMessage directly so callers can pass raw wire data. - Thread raw line bytes through the JSON-RPC notification path (RPCNotification.Raw field, toNotification parameter) and annotate events in both AppServerAdapter and CLITransport before dispatch. - Update sessions.go GetSessionMessages to pass raw line bytes to Parse. - newAuditEnvelope now prefers the annotated original bytes, falling back to re-marshal only when raw bytes are unavailable. - Add test verifying audit payloads preserve original JSON bytes verbatim. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/message/parse.go | 45 ++++++++++++++-- internal/message/parse_test.go | 13 +++++ internal/message/raw_json.go | 67 ++++++++++++++++++++++++ internal/subprocess/appserver.go | 2 +- internal/subprocess/appserver_adapter.go | 5 ++ internal/subprocess/cli.go | 3 ++ internal/subprocess/jsonrpc.go | 4 +- sessions.go | 2 +- 8 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 internal/message/raw_json.go diff --git a/internal/message/parse.go b/internal/message/parse.go index 1362307..7b492e8 100644 --- a/internal/message/parse.go +++ b/internal/message/parse.go @@ -10,9 +10,14 @@ import ( ) func newAuditEnvelope(data map[string]any) (*AuditEnvelope, error) { - payload, err := json.Marshal(data) - if err != nil { - return nil, fmt.Errorf("marshal audit payload: %w", err) + payload, ok := extractRawJSON(data) + if !ok { + var err error + + payload, err = json.Marshal(stripRawJSON(data)) + if err != nil { + return nil, fmt.Errorf("marshal audit payload: %w", err) + } } eventType, _ := data["type"].(string) @@ -29,9 +34,41 @@ func newAuditEnvelope(data map[string]any) (*AuditEnvelope, error) { // // This function handles both Claude-style messages (with "type": "user"|"assistant"|etc.) // and Codex-style events (with "type": "thread.started"|"item.completed"|etc.). -func Parse(log *slog.Logger, data map[string]any) (Message, error) { +func Parse(log *slog.Logger, payload any) (Message, error) { log = log.With("component", "message_parser") + var data map[string]any + + switch raw := payload.(type) { + case map[string]any: + data = raw + case []byte: + if err := json.Unmarshal(raw, &data); err != nil { + return nil, &errors.MessageParseError{ + Message: err.Error(), + Err: err, + } + } + + data = AnnotateRawJSON(data, raw) + case json.RawMessage: + if err := json.Unmarshal(raw, &data); err != nil { + return nil, &errors.MessageParseError{ + Message: err.Error(), + Err: err, + } + } + + data = AnnotateRawJSON(data, raw) + default: + return nil, &errors.MessageParseError{ + Message: fmt.Sprintf( + "unsupported payload type %T", payload, + ), + Err: fmt.Errorf("unsupported payload type %T", payload), + } + } + msgType, ok := data["type"].(string) if !ok { return nil, &errors.MessageParseError{ diff --git a/internal/message/parse_test.go b/internal/message/parse_test.go index 6d1a182..5466b8c 100644 --- a/internal/message/parse_test.go +++ b/internal/message/parse_test.go @@ -919,6 +919,19 @@ func TestParse_AuditPayloadPreservesRawWireData(t *testing.T) { assert.Equal(t, "preserved", payload["custom_field"]) } +func TestParse_AuditPayloadPreservesOriginalJSONBytes(t *testing.T) { + logger := slog.Default() + raw := []byte("{\n \"type\": \"assistant\",\n \"message\": {\n \"content\": [],\n \"model\": \"test-model\"\n },\n \"provider_trace\": 1e+06,\n \"provider_trace\": 1000000\n}") + + msg, err := Parse(logger, raw) + require.NoError(t, err) + + am, ok := msg.(*AssistantMessage) + require.True(t, ok) + require.NotNil(t, am.Audit) + assert.Equal(t, string(raw), string(am.Audit.Payload)) +} + func TestNewAuditEnvelope_PublicConstructor(t *testing.T) { type testPayload struct { Key string `json:"key"` diff --git a/internal/message/raw_json.go b/internal/message/raw_json.go new file mode 100644 index 0000000..f0f2f95 --- /dev/null +++ b/internal/message/raw_json.go @@ -0,0 +1,67 @@ +package message + +import "encoding/json" + +const rawJSONKey = "\x00sdk_raw_json" + +// AnnotateRawJSON attaches the original raw JSON bytes to a decoded payload so +// audit envelopes can preserve byte fidelity later in the parse pipeline. +func AnnotateRawJSON(data map[string]any, raw []byte) map[string]any { + if data == nil || len(raw) == 0 { + return data + } + + data[rawJSONKey] = append([]byte(nil), raw...) + + return data +} + +func extractRawJSON(data map[string]any) (json.RawMessage, bool) { + if data == nil { + return nil, false + } + + switch raw := data[rawJSONKey].(type) { + case []byte: + if len(raw) == 0 { + return nil, false + } + + return append(json.RawMessage(nil), raw...), true + case json.RawMessage: + if len(raw) == 0 { + return nil, false + } + + return append(json.RawMessage(nil), raw...), true + case string: + if raw == "" { + return nil, false + } + + return json.RawMessage(raw), true + default: + return nil, false + } +} + +func stripRawJSON(data map[string]any) map[string]any { + if data == nil { + return nil + } + + if _, ok := data[rawJSONKey]; !ok { + return data + } + + sanitized := make(map[string]any, len(data)-1) + for key, value := range data { + if key == rawJSONKey { + continue + } + + sanitized[key] = value + } + + return sanitized +} diff --git a/internal/subprocess/appserver.go b/internal/subprocess/appserver.go index 2267c1b..10f9bf2 100644 --- a/internal/subprocess/appserver.go +++ b/internal/subprocess/appserver.go @@ -377,7 +377,7 @@ func (t *AppServerTransport) readLoop() { } } else if msg.isNotification() { select { - case t.notifyCh <- msg.toNotification(): + case t.notifyCh <- msg.toNotification(line): default: t.log.Warn("notification channel full, dropping", slog.String("method", msg.Method), diff --git a/internal/subprocess/appserver_adapter.go b/internal/subprocess/appserver_adapter.go index a71d0a8..bbd2241 100644 --- a/internal/subprocess/appserver_adapter.go +++ b/internal/subprocess/appserver_adapter.go @@ -12,6 +12,7 @@ import ( "github.com/ethpandaops/codex-agent-sdk-go/internal/config" sdkerrors "github.com/ethpandaops/codex-agent-sdk-go/internal/errors" + "github.com/ethpandaops/codex-agent-sdk-go/internal/message" ) // appServerRPC defines the JSON-RPC operations that AppServerAdapter @@ -1481,6 +1482,10 @@ func (a *AppServerAdapter) handleNotification(notif *RPCNotification) { return } + if len(notif.Raw) > 0 { + event = message.AnnotateRawJSON(event, notif.Raw) + } + select { case a.messages <- event: case <-a.done: diff --git a/internal/subprocess/cli.go b/internal/subprocess/cli.go index e8c9e39..3104235 100644 --- a/internal/subprocess/cli.go +++ b/internal/subprocess/cli.go @@ -17,6 +17,7 @@ import ( "github.com/ethpandaops/codex-agent-sdk-go/internal/cli" "github.com/ethpandaops/codex-agent-sdk-go/internal/config" "github.com/ethpandaops/codex-agent-sdk-go/internal/errors" + "github.com/ethpandaops/codex-agent-sdk-go/internal/message" ) const ( @@ -270,6 +271,8 @@ func (t *CLITransport) ReadMessages( continue } + msg = message.AnnotateRawJSON(msg, line) + messageCount++ t.log.Debug("Received message from CLI", "message_count", messageCount) diff --git a/internal/subprocess/jsonrpc.go b/internal/subprocess/jsonrpc.go index 43b2b12..cabd717 100644 --- a/internal/subprocess/jsonrpc.go +++ b/internal/subprocess/jsonrpc.go @@ -25,6 +25,7 @@ type RPCNotification struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` + Raw json.RawMessage `json:"-"` } // RPCError contains error information from a JSON-RPC response. @@ -92,10 +93,11 @@ func (m *rpcMessage) toResponse() *RPCResponse { } // toNotification converts the message to an RPCNotification. -func (m *rpcMessage) toNotification() *RPCNotification { +func (m *rpcMessage) toNotification(raw []byte) *RPCNotification { return &RPCNotification{ JSONRPC: m.JSONRPC, Method: m.Method, Params: m.Params, + Raw: append(json.RawMessage(nil), raw...), } } diff --git a/sessions.go b/sessions.go index f333fb6..ea522e6 100644 --- a/sessions.go +++ b/sessions.go @@ -243,7 +243,7 @@ func GetSessionMessages(ctx context.Context, sessionID string, opts ...Option) ( continue } - msg, err := message.Parse(log, raw) + msg, err := message.Parse(log, line) if err != nil { if errors.Is(err, internalerrors.ErrUnknownMessageType) { continue From 8b181df60c6cb68cdb7623fe7dd380d38b25ab5d Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:00:27 +1000 Subject: [PATCH 06/11] Add stop_reason, duration_ms, num_turns, and total_cost_usd to ResultMessage Extend ResultMessage with session-level metadata fields returned by the Codex turn-completed event: stop_reason (optional), duration_ms, num_turns, and total_cost_usd (optional). Parse these fields from the raw event map in parseCodexTurnCompleted and add tests covering full field presence, the Parse entry point, and missing optional field defaults. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/message/message.go | 4 ++ internal/message/parse.go | 16 +++++++ internal/message/parse_test.go | 84 ++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) diff --git a/internal/message/message.go b/internal/message/message.go index bbe2d12..800145f 100644 --- a/internal/message/message.go +++ b/internal/message/message.go @@ -251,6 +251,10 @@ type ResultMessage struct { Subtype string `json:"subtype"` IsError bool `json:"is_error"` SessionID string `json:"session_id"` + StopReason *string `json:"stop_reason,omitempty"` + DurationMs int `json:"duration_ms"` + NumTurns int `json:"num_turns"` + TotalCostUSD *float64 `json:"total_cost_usd,omitempty"` Usage *Usage `json:"usage,omitempty"` Result *string `json:"result,omitempty"` StructuredOutput any `json:"structured_output,omitempty"` diff --git a/internal/message/parse.go b/internal/message/parse.go index 7b492e8..9dad773 100644 --- a/internal/message/parse.go +++ b/internal/message/parse.go @@ -558,6 +558,22 @@ func parseCodexTurnCompleted(data map[string]any) (*ResultMessage, error) { result.Usage = usage } + if sr, ok := data["stop_reason"].(string); ok { + result.StopReason = &sr + } + + if dur, ok := data["duration_ms"].(float64); ok { + result.DurationMs = int(dur) + } + + if nt, ok := data["num_turns"].(float64); ok { + result.NumTurns = int(nt) + } + + if tc, ok := data["total_cost_usd"].(float64); ok { + result.TotalCostUSD = &tc + } + return result, nil } diff --git a/internal/message/parse_test.go b/internal/message/parse_test.go index 5466b8c..c16ca09 100644 --- a/internal/message/parse_test.go +++ b/internal/message/parse_test.go @@ -955,3 +955,87 @@ func TestNewAuditEnvelope_MarshalError(t *testing.T) { assert.Error(t, err) assert.Nil(t, env) } + +func TestParseResultMessage_DirectFields(t *testing.T) { + t.Parallel() + + logger := slog.Default() + stopReason := "end_turn" + cost := 0.0042 + + data := map[string]any{ + "type": "result", + "subtype": "success", + "is_error": false, + "session_id": "sess-123", + "stop_reason": stopReason, + "duration_ms": float64(1500), + "num_turns": float64(3), + "total_cost_usd": cost, + "result": "hello", + "usage": map[string]any{ + "input_tokens": float64(100), + "output_tokens": float64(50), + }, + } + + msg, err := Parse(logger, data) + require.NoError(t, err) + + rm, ok := msg.(*ResultMessage) + require.True(t, ok) + + assert.Equal(t, "sess-123", rm.SessionID) + require.NotNil(t, rm.StopReason) + assert.Equal(t, "end_turn", *rm.StopReason) + assert.Equal(t, 1500, rm.DurationMs) + assert.Equal(t, 3, rm.NumTurns) + require.NotNil(t, rm.TotalCostUSD) + assert.InDelta(t, cost, *rm.TotalCostUSD, 1e-9) +} + +func TestParseCodexTurnCompleted_DirectFields(t *testing.T) { + t.Parallel() + + cost := 0.125 + + data := map[string]any{ + "type": "response.completed", + "stop_reason": "end_turn", + "duration_ms": float64(2500), + "num_turns": float64(7), + "total_cost_usd": cost, + "usage": map[string]any{ + "input_tokens": float64(200), + "output_tokens": float64(100), + "cached_input_tokens": float64(50), + "reasoning_output_tokens": float64(25), + }, + } + + msg, err := parseCodexTurnCompleted(data) + require.NoError(t, err) + + require.NotNil(t, msg.StopReason) + assert.Equal(t, "end_turn", *msg.StopReason) + assert.Equal(t, 2500, msg.DurationMs) + assert.Equal(t, 7, msg.NumTurns) + require.NotNil(t, msg.TotalCostUSD) + assert.InDelta(t, cost, *msg.TotalCostUSD, 1e-9) +} + +func TestParseCodexTurnCompleted_MissingOptionalFields(t *testing.T) { + t.Parallel() + + data := map[string]any{ + "type": "response.completed", + } + + msg, err := parseCodexTurnCompleted(data) + require.NoError(t, err) + + assert.Nil(t, msg.StopReason) + assert.Equal(t, 0, msg.DurationMs) + assert.Equal(t, 0, msg.NumTurns) + assert.Nil(t, msg.TotalCostUSD) +} From 6ea9ac403b281cc2232a34334138e870ed9c2f9f Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:01:50 +1000 Subject: [PATCH 07/11] fix tests --- integration/budget_stderr_test.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/integration/budget_stderr_test.go b/integration/budget_stderr_test.go index 972aa6b..edac08b 100644 --- a/integration/budget_stderr_test.go +++ b/integration/budget_stderr_test.go @@ -4,6 +4,7 @@ package integration import ( "context" + "sync" "testing" "time" @@ -15,11 +16,16 @@ func TestStderrCallback_ReceivesOutput(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - var stderrLines []string + var ( + mu sync.Mutex + stderrLines []string + ) for _, err := range codexsdk.Query(ctx, codexsdk.Text("Say 'hello'"), codexsdk.WithPermissionMode("bypassPermissions"), codexsdk.WithStderr(func(line string) { + mu.Lock() + defer mu.Unlock() stderrLines = append(stderrLines, line) }), ) { @@ -29,6 +35,9 @@ func TestStderrCallback_ReceivesOutput(t *testing.T) { } } + mu.Lock() + defer mu.Unlock() + t.Logf("Received %d stderr lines", len(stderrLines)) } @@ -38,11 +47,16 @@ func TestStderrCallback_CapturesDebugInfo(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - var stderrLines []string + var ( + mu sync.Mutex + stderrLines []string + ) for _, err := range codexsdk.Query(ctx, codexsdk.Text("Say 'debug test'"), codexsdk.WithPermissionMode("bypassPermissions"), codexsdk.WithStderr(func(line string) { + mu.Lock() + defer mu.Unlock() stderrLines = append(stderrLines, line) }), ) { @@ -52,6 +66,9 @@ func TestStderrCallback_CapturesDebugInfo(t *testing.T) { } } + mu.Lock() + defer mu.Unlock() + t.Logf("Received %d stderr lines", len(stderrLines)) if len(stderrLines) > 0 { From f796058606acd6e11bd6282f2dc1f3c5482b040b Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:48:52 +1000 Subject: [PATCH 08/11] feat: distinguish reasoning deltas as thinking_delta and accumulate reasoning text Reasoning stream deltas (item/reasoning/textDelta, item/reasoning/summaryTextDelta) now emit as thinking_delta with a "thinking" key instead of text_delta with "text", allowing consumers to distinguish reasoning content from regular text output in the streaming path. Added reasoningTextByItem accumulator that collects streaming reasoning deltas per item ID. When item.completed arrives for a reasoning item with an empty summary array, the accumulated reasoning text is used as fallback, ensuring reasoning content is not lost. Added ensureDefaultModel helper to mark gpt-5.3-codex as the default model when the API model list does not flag any model with isDefault. Updated examples to handle thinking_delta events. Added comprehensive unit tests for delta accumulation, summary precedence, thinking delta emission, reasoning item parsing, and default model selection. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/extended_thinking/main.go | 27 ++- examples/include_partial_messages/main.go | 6 +- integration/streaming_test.go | 66 ++++++ internal/message/parse_test.go | 23 ++ internal/subprocess/appserver_adapter.go | 112 ++++++++- internal/subprocess/appserver_adapter_test.go | 216 +++++++++++++++++- 6 files changed, 435 insertions(+), 15 deletions(-) diff --git a/examples/extended_thinking/main.go b/examples/extended_thinking/main.go index 58df37d..4627e7f 100644 --- a/examples/extended_thinking/main.go +++ b/examples/extended_thinking/main.go @@ -24,11 +24,26 @@ func displayMessage(msg codexsdk.Message) { fmt.Printf("Codex: %s\n", b.Text) } } - case *codexsdk.SystemMessage: - if (m.Subtype == "item/reasoning/summaryPartAdded" || m.Subtype == "item/reasoning/summaryTextDelta") && m.Data != nil { - if text, ok := m.Data["text"].(string); ok && text != "" { - fmt.Printf("[Reasoning Summary] %s\n", text) - } + case *codexsdk.StreamEvent: + event := m.Event + + eventType, _ := event["type"].(string) + if eventType != "content_block_delta" { + return + } + + delta, ok := event["delta"].(map[string]any) + if !ok { + return + } + + switch delta["type"] { + case "thinking_delta": + thinking, _ := delta["thinking"].(string) + fmt.Print(thinking) + case "text_delta": + text, _ := delta["text"].(string) + fmt.Print(text) } case *codexsdk.ResultMessage: if m.Result != nil && *m.Result != "" { @@ -94,6 +109,7 @@ func runEffortExample(title string, effort codexsdk.Effort, prompt string, extra func runStreamingEffortExample() { fmt.Println("=== Streaming Reasoning Example ===") fmt.Println("Streams response events while using high reasoning effort.") + fmt.Println("Thinking deltas appear as [thinking_delta] stream events.") fmt.Println() logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) @@ -110,6 +126,7 @@ func runStreamingEffortExample() { if err := client.Start(ctx, codexsdk.WithLogger(logger), codexsdk.WithEffort(codexsdk.EffortHigh), + codexsdk.WithIncludePartialMessages(true), ); err != nil { fmt.Printf("Failed to connect: %v\n", err) diff --git a/examples/include_partial_messages/main.go b/examples/include_partial_messages/main.go index 106381f..b7c32e5 100644 --- a/examples/include_partial_messages/main.go +++ b/examples/include_partial_messages/main.go @@ -40,9 +40,13 @@ func main() { continue } - if delta["type"] == "text_delta" { + switch delta["type"] { + case "text_delta": text, _ := delta["text"].(string) fmt.Print(text) + case "thinking_delta": + thinking, _ := delta["thinking"].(string) + fmt.Printf("[thinking] %s", thinking) } case *codexsdk.AssistantMessage: diff --git a/integration/streaming_test.go b/integration/streaming_test.go index a4b6ed3..a4e1d4f 100644 --- a/integration/streaming_test.go +++ b/integration/streaming_test.go @@ -35,3 +35,69 @@ func TestPartialMessages_DisabledByDefault(t *testing.T) { require.Equal(t, 0, streamEventCount, "Should not receive StreamEvents when IncludePartialMessages is false") } + +// TestPartialMessages_ThinkingDeltaDistinguished verifies that reasoning +// deltas arrive as thinking_delta (not text_delta) when partial messages +// are enabled and reasoning effort is high. +func TestPartialMessages_ThinkingDeltaDistinguished(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + var ( + hasThinkingDelta bool + hasTextDelta bool + hasThinkingBlock bool + ) + + for msg, err := range codexsdk.Query(ctx, + codexsdk.Text("What is the sum of the first 10 prime numbers? Think step by step."), + codexsdk.WithPermissionMode("bypassPermissions"), + codexsdk.WithIncludePartialMessages(true), + codexsdk.WithEffort(codexsdk.EffortHigh), + ) { + if err != nil { + skipIfCLINotInstalled(t, err) + t.Fatalf("Query failed: %v", err) + } + + switch m := msg.(type) { + case *codexsdk.StreamEvent: + eventType, _ := m.Event["type"].(string) + if eventType != "content_block_delta" { + continue + } + + delta, ok := m.Event["delta"].(map[string]any) + if !ok { + continue + } + + switch delta["type"] { + case "thinking_delta": + hasThinkingDelta = true + case "text_delta": + hasTextDelta = true + } + + case *codexsdk.AssistantMessage: + for _, block := range m.Content { + if _, ok := block.(*codexsdk.ThinkingBlock); ok { + hasThinkingBlock = true + } + } + } + } + + // At minimum we should see text deltas from the response. + require.True(t, hasTextDelta, "expected text_delta stream events") + + // When the model reasons, thinking deltas must use the distinct type. + if hasThinkingDelta { + t.Log("thinking_delta stream events received — reasoning deltas are distinguishable") + } + + // If reasoning produced a completed item, it should be a ThinkingBlock. + if hasThinkingBlock { + t.Log("ThinkingBlock received in AssistantMessage — reasoning content surfaced") + } +} diff --git a/internal/message/parse_test.go b/internal/message/parse_test.go index c16ca09..0298a74 100644 --- a/internal/message/parse_test.go +++ b/internal/message/parse_test.go @@ -234,6 +234,29 @@ func TestParseCodexAgentMessageDeltaSuppression(t *testing.T) { } } +func TestParseCodexReasoningItemWithText(t *testing.T) { + logger := slog.Default() + + msg, err := Parse(logger, map[string]any{ + "type": "item.completed", + "item": map[string]any{ + "type": "reasoning", + "id": "reason_1", + "text": "Let me think about this problem step by step.", + }, + }) + require.NoError(t, err) + + assistant, ok := msg.(*AssistantMessage) + require.True(t, ok, "reasoning item with text should produce AssistantMessage") + require.Len(t, assistant.Content, 1) + + thinking, ok := assistant.Content[0].(*ThinkingBlock) + require.True(t, ok, "expected ThinkingBlock") + require.Equal(t, BlockTypeThinking, thinking.Type) + require.Equal(t, "Let me think about this problem step by step.", thinking.Thinking) +} + func TestParseCodexDynamicToolCall(t *testing.T) { logger := slog.Default() diff --git a/internal/subprocess/appserver_adapter.go b/internal/subprocess/appserver_adapter.go index 22eb6c2..0090942 100644 --- a/internal/subprocess/appserver_adapter.go +++ b/internal/subprocess/appserver_adapter.go @@ -61,6 +61,11 @@ type AppServerAdapter struct { lastAssistantText string lastAssistantTextByTurn map[string]string + // reasoningTextByItem accumulates streaming reasoning text deltas + // per item ID so that item.completed events for reasoning items with + // empty summaries can still carry the full reasoning text. + reasoningTextByItem map[string]*strings.Builder + // currentTurnHasOutputSchema tracks whether the active turn requested // structured output, allowing the adapter to parse JSON final text back // into ResultMessage.StructuredOutput when app-server omits a dedicated field. @@ -100,6 +105,7 @@ func NewAppServerAdapter( includePartialMessages: opts.IncludePartialMessages, pendingRPCRequests: make(map[string]int64, 8), lastAssistantTextByTurn: make(map[string]string, 8), + reasoningTextByItem: make(map[string]*strings.Builder, 8), turnHasOutputSchema: make(map[string]bool, 8), sdkMCPServerNames: make(map[string]struct{}, 8), } @@ -1049,12 +1055,36 @@ func (a *AppServerAdapter) listAllModels(ctx context.Context) (*modelListResult, cursor = nextCursor } + ensureDefaultModel(models) + return &modelListResult{ models: models, metadata: metadata, }, nil } +// defaultModelID is the model marked as default when the API does +// not flag any model with isDefault. +const defaultModelID = "gpt-5.3-codex" + +// ensureDefaultModel sets isDefault on the preferred model when no +// model in the list has isDefault set to true. +func ensureDefaultModel(models []map[string]any) { + for _, m := range models { + if v, ok := m["isDefault"].(bool); ok && v { + return + } + } + + for _, m := range models { + if id, _ := m["id"].(string); id == defaultModelID { + m["isDefault"] = true + + return + } + } +} + func parseModelListPage(raw json.RawMessage) ([]map[string]any, map[string]any, string, error) { if len(raw) == 0 { return nil, nil, "", nil @@ -1582,6 +1612,8 @@ func (a *AppServerAdapter) translateNotification( if _, known := a.turnHasOutputSchema[tid]; !known { a.turnHasOutputSchema[tid] = a.currentTurnHasOutputSchema } + + clear(a.reasoningTextByItem) a.mu.Unlock() } } @@ -1595,10 +1627,12 @@ func (a *AppServerAdapter) translateNotification( return a.translateTextDeltaNotification(params) case "item/reasoning/textDelta": - return a.translateTextDeltaNotification(params) + a.accumulateReasoningDelta(params) + + return a.translateThinkingDeltaNotification(params) case "item/reasoning/summaryTextDelta": - return a.translateTextDeltaNotification(params) + return a.translateThinkingDeltaNotification(params) case "item/commandExecution/outputDelta": return a.translateTextDeltaNotification(params) @@ -1623,6 +1657,7 @@ func (a *AppServerAdapter) translateNotification( a.mu.Lock() delete(a.turnHasOutputSchema, turnID) delete(a.lastAssistantTextByTurn, turnID) + clear(a.reasoningTextByItem) a.mu.Unlock() } @@ -1847,6 +1882,65 @@ func (a *AppServerAdapter) translateTextDeltaNotification( } } +// accumulateReasoningDelta appends a reasoning text delta to the per-item +// accumulator so that item.completed can recover the full reasoning text +// when the completed item arrives with an empty summary array. +func (a *AppServerAdapter) accumulateReasoningDelta(params map[string]any) { + delta, _ := params["delta"].(string) + itemID, _ := params["itemId"].(string) + + if itemID == "" || delta == "" { + return + } + + a.mu.Lock() + + buf, ok := a.reasoningTextByItem[itemID] + if !ok { + buf = &strings.Builder{} + a.reasoningTextByItem[itemID] = buf + } + + buf.WriteString(delta) + a.mu.Unlock() +} + +// translateThinkingDeltaNotification converts a reasoning delta notification +// into a stream_event with delta.type "thinking_delta", allowing consumers +// to distinguish reasoning content from regular text in the streaming path. +func (a *AppServerAdapter) translateThinkingDeltaNotification( + params map[string]any, +) map[string]any { + if !a.includePartialMessages { + return nil + } + + delta, _ := params["delta"].(string) + itemID, _ := params["itemId"].(string) + + a.mu.Lock() + sessionID := a.threadID + a.mu.Unlock() + + if threadID, ok := params["threadId"].(string); ok && threadID != "" { + sessionID = threadID + } + + return map[string]any{ + "type": "stream_event", + "uuid": itemID, + "session_id": sessionID, + "event": map[string]any{ + "type": "content_block_delta", + "index": 0, + "delta": map[string]any{ + "type": "thinking_delta", + "thinking": delta, + }, + }, + } +} + // translateTurnCompleted builds a turn.completed event, injecting cached // token usage when no inline usage is present. func (a *AppServerAdapter) translateTurnCompleted( @@ -2378,6 +2472,20 @@ func (a *AppServerAdapter) extractAndTranslateItem(params map[string]any) map[st item["text"] = strings.Join(parts, "\n") } } + + // Fall back to accumulated reasoning deltas when summary is empty. + text, _ := item["text"].(string) + itemID, _ := item["id"].(string) + + if strings.TrimSpace(text) == "" && itemID != "" { + a.mu.Lock() + if buf, ok := a.reasoningTextByItem[itemID]; ok && buf.Len() > 0 { + item["text"] = buf.String() + } + + delete(a.reasoningTextByItem, itemID) + a.mu.Unlock() + } case "image_view": if path, ok := item["path"].(string); ok && path != "" { item["text"] = fmt.Sprintf("Viewed image: %s", path) diff --git a/internal/subprocess/appserver_adapter_test.go b/internal/subprocess/appserver_adapter_test.go index fdf9462..fb2ed61 100644 --- a/internal/subprocess/appserver_adapter_test.go +++ b/internal/subprocess/appserver_adapter_test.go @@ -6,6 +6,7 @@ import ( "errors" "log/slog" "maps" + "strings" "sync" "testing" "time" @@ -14,6 +15,8 @@ import ( "github.com/stretchr/testify/require" ) +const testThreadID = "thread_test" + // Compile-time check that mockAppServerRPC implements appServerRPC. var _ appServerRPC = (*mockAppServerRPC)(nil) @@ -159,6 +162,7 @@ func newTestAdapter(mock *mockAppServerRPC) *AppServerAdapter { done: make(chan struct{}), pendingRPCRequests: make(map[string]int64, 8), lastAssistantTextByTurn: make(map[string]string, 8), + reasoningTextByItem: make(map[string]*strings.Builder, 8), turnHasOutputSchema: make(map[string]bool, 8), sdkMCPServerNames: make(map[string]struct{}, 8), } @@ -2575,7 +2579,7 @@ func TestAppServerAdapter_DeltaEmission_WhenEnabled(t *testing.T) { mock := newMockAppServerRPC() adapter := newTestAdapter(mock) adapter.includePartialMessages = true - adapter.threadID = "thread_test" + adapter.threadID = testThreadID defer func() { close(adapter.done) @@ -2593,7 +2597,7 @@ func TestAppServerAdapter_DeltaEmission_WhenEnabled(t *testing.T) { case msg := <-adapter.messages: require.Equal(t, "stream_event", msg["type"]) require.Equal(t, "item_2", msg["uuid"]) - require.Equal(t, "thread_test", msg["session_id"]) + require.Equal(t, testThreadID, msg["session_id"]) event, ok := msg["event"].(map[string]any) require.True(t, ok) @@ -2612,7 +2616,7 @@ func TestAppServerAdapter_ReasoningTextDeltaEmission_WhenEnabled(t *testing.T) { mock := newMockAppServerRPC() adapter := newTestAdapter(mock) adapter.includePartialMessages = true - adapter.threadID = "thread_test" + adapter.threadID = testThreadID defer func() { close(adapter.done) @@ -2623,7 +2627,7 @@ func TestAppServerAdapter_ReasoningTextDeltaEmission_WhenEnabled(t *testing.T) { mock.notifyCh <- &RPCNotification{ JSONRPC: "2.0", Method: "item/reasoning/textDelta", - Params: json.RawMessage(`{"delta":"thinking","itemId":"reason_1","contentIndex":0,"threadId":"thread_test","turnId":"turn_1"}`), + Params: json.RawMessage(`{"delta":"thinking","itemId":"reason_1","contentIndex":0,"threadId":"` + testThreadID + `","turnId":"turn_1"}`), } select { @@ -2631,7 +2635,7 @@ func TestAppServerAdapter_ReasoningTextDeltaEmission_WhenEnabled(t *testing.T) { require.Equal(t, "stream_event", msg["type"], "reasoning text deltas should surface as partial stream events instead of generic system messages") require.Equal(t, "reason_1", msg["uuid"]) - require.Equal(t, "thread_test", msg["session_id"]) + require.Equal(t, testThreadID, msg["session_id"]) event, ok := msg["event"].(map[string]any) require.True(t, ok) @@ -2639,13 +2643,163 @@ func TestAppServerAdapter_ReasoningTextDeltaEmission_WhenEnabled(t *testing.T) { delta, ok := event["delta"].(map[string]any) require.True(t, ok) - require.Equal(t, "text_delta", delta["type"]) - require.Equal(t, "thinking", delta["text"]) + require.Equal(t, "thinking_delta", delta["type"], + "reasoning deltas must use thinking_delta to distinguish from text_delta") + require.Equal(t, "thinking", delta["thinking"]) case <-time.After(time.Second): t.Fatal("expected stream_event message from reasoning delta") } } +func TestAppServerAdapter_ReasoningSummaryDeltaEmission(t *testing.T) { + mock := newMockAppServerRPC() + adapter := newTestAdapter(mock) + adapter.includePartialMessages = true + adapter.threadID = testThreadID + + defer func() { + close(adapter.done) + mock.Close() + adapter.wg.Wait() + }() + + mock.notifyCh <- &RPCNotification{ + JSONRPC: "2.0", + Method: "item/reasoning/summaryTextDelta", + Params: json.RawMessage(`{"delta":"summary text","itemId":"reason_1","threadId":"` + testThreadID + `"}`), + } + + select { + case msg := <-adapter.messages: + require.Equal(t, "stream_event", msg["type"]) + + event, ok := msg["event"].(map[string]any) + require.True(t, ok) + require.Equal(t, "content_block_delta", event["type"]) + + delta, ok := event["delta"].(map[string]any) + require.True(t, ok) + require.Equal(t, "thinking_delta", delta["type"], + "summary deltas must also use thinking_delta type") + require.Equal(t, "summary text", delta["thinking"]) + case <-time.After(time.Second): + t.Fatal("expected stream_event from summary delta") + } +} + +func TestAppServerAdapter_ReasoningDeltaAccumulation(t *testing.T) { + mock := newMockAppServerRPC() + adapter := newTestAdapter(mock) + adapter.includePartialMessages = true + adapter.threadID = testThreadID + + defer func() { + close(adapter.done) + mock.Close() + adapter.wg.Wait() + }() + + // Stream reasoning deltas. + for _, delta := range []string{"Let me ", "think ", "about this."} { + mock.notifyCh <- &RPCNotification{ + JSONRPC: "2.0", + Method: "item/reasoning/textDelta", + Params: json.RawMessage(`{"delta":"` + delta + `","itemId":"reason_1","threadId":"` + testThreadID + `"}`), + } + + // Drain the stream event. + select { + case <-adapter.messages: + case <-time.After(time.Second): + t.Fatal("expected stream_event from reasoning delta") + } + } + + // Complete the reasoning item with an empty summary. + mock.notifyCh <- &RPCNotification{ + JSONRPC: "2.0", + Method: "item/completed", + Params: json.RawMessage(`{ + "item":{ + "type":"reasoning", + "id":"reason_1", + "summary":[], + "content":[] + } + }`), + } + + select { + case msg := <-adapter.messages: + require.Equal(t, "item.completed", msg["type"]) + + item, ok := msg["item"].(map[string]any) + require.True(t, ok) + require.Equal(t, "reasoning", item["type"]) + require.Equal(t, "Let me think about this.", item["text"], + "accumulated reasoning deltas should populate text when summary is empty") + case <-time.After(time.Second): + t.Fatal("expected item.completed with accumulated reasoning text") + } + + // Verify accumulator was cleaned up. + adapter.mu.Lock() + _, exists := adapter.reasoningTextByItem["reason_1"] + adapter.mu.Unlock() + + require.False(t, exists, "accumulator entry should be cleaned up after item.completed") +} + +func TestAppServerAdapter_ReasoningDeltaAccumulation_SummaryTakesPrecedence(t *testing.T) { + mock := newMockAppServerRPC() + adapter := newTestAdapter(mock) + adapter.includePartialMessages = true + adapter.threadID = testThreadID + + defer func() { + close(adapter.done) + mock.Close() + adapter.wg.Wait() + }() + + // Stream a reasoning delta. + mock.notifyCh <- &RPCNotification{ + JSONRPC: "2.0", + Method: "item/reasoning/textDelta", + Params: json.RawMessage(`{"delta":"raw reasoning","itemId":"reason_2","threadId":"` + testThreadID + `"}`), + } + + select { + case <-adapter.messages: + case <-time.After(time.Second): + t.Fatal("expected stream_event") + } + + // Complete with a populated summary — summary should win. + mock.notifyCh <- &RPCNotification{ + JSONRPC: "2.0", + Method: "item/completed", + Params: json.RawMessage(`{ + "item":{ + "type":"reasoning", + "id":"reason_2", + "summary":["The answer is","42."], + "content":[] + } + }`), + } + + select { + case msg := <-adapter.messages: + item, ok := msg["item"].(map[string]any) + require.True(t, ok) + require.Equal(t, "The answer is\n42.", item["text"], + "populated summary should take precedence over accumulated deltas") + case <-time.After(time.Second): + t.Fatal("expected item.completed") + } +} + func TestAppServerAdapter_UnknownNotification_PassThrough(t *testing.T) { mock := newMockAppServerRPC() adapter := newTestAdapter(mock) @@ -2810,3 +2964,51 @@ func TestTranslateNotification_ErrorMethod(t *testing.T) { require.Equal(t, "error", event["type"]) require.Equal(t, "test error from CLI", event["message"]) } + +func TestEnsureDefaultModel(t *testing.T) { + t.Parallel() + + t.Run("no default set picks gpt-5.3-codex", func(t *testing.T) { + t.Parallel() + + models := []map[string]any{ + {"id": "gpt-4.1-mini"}, + {"id": "gpt-5.3-codex"}, + {"id": "o3-pro"}, + } + + ensureDefaultModel(models) + + require.Equal(t, true, models[1]["isDefault"]) + require.Nil(t, models[0]["isDefault"]) + require.Nil(t, models[2]["isDefault"]) + }) + + t.Run("existing default is preserved", func(t *testing.T) { + t.Parallel() + + models := []map[string]any{ + {"id": "gpt-4.1-mini", "isDefault": true}, + {"id": "gpt-5.3-codex"}, + } + + ensureDefaultModel(models) + + require.Equal(t, true, models[0]["isDefault"]) + require.Nil(t, models[1]["isDefault"]) + }) + + t.Run("fallback model not present is noop", func(t *testing.T) { + t.Parallel() + + models := []map[string]any{ + {"id": "gpt-4.1-mini"}, + {"id": "o3-pro"}, + } + + ensureDefaultModel(models) + + require.Nil(t, models[0]["isDefault"]) + require.Nil(t, models[1]["isDefault"]) + }) +} From 553022368453635b287709a03d3e1cdef7ed426c Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:24:23 +1000 Subject: [PATCH 09/11] fix: scope extended_thinking gitignore to root-only match The bare `extended_thinking` pattern was matching examples/extended_thinking/, preventing the example source from being tracked. Prefix with `/` to only match the root-level binary. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a9f5658..4f9e716 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,7 @@ hello.go memories/ cancellation_demo.txt developer_instructions -extended_thinking +/extended_thinking graceful_shutdown_demo.txt personality service_tier From d75abf540d16f56bac389eea018a00300825ea01 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:43:55 +1000 Subject: [PATCH 10/11] feat: implement MCP elicitation callback and fix permissions approval wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full MCP elicitation support with typed request/response structures, callback dispatch, and auto-decline behavior when no callback is set. The previous stub unconditionally auto-accepted; now the flow properly parses elicitation fields (mode, URL, schema, elicitation ID) and routes through the OnElicitation callback. Fix HandlePermissionsApproval to return {permissions, scope: "turn"} instead of {decision: "accept"/"decline"}, matching the actual app-server protocol. Denied requests now return empty permissions with turn scope rather than a "decline" decision string. Register mcpServer_elicitation/request and item_permissions/requestApproval as live handlers — these were previously tracked as stale/future request types but are now present in the current codex schema. - Add internal/elicitation package with Mode, Action, Request, Response types - Add WithOnElicitation option and capability entry (exec: unsupported, app-server: supported) - Re-export elicitation types and constants from root package - Add comprehensive tests for elicitation (form, URL, no-callback, nil-response) - Update permissions approval tests to assert new wire format - Remove stale handler negative test that is no longer applicable - Bump integration test timeout from 240s to 600s Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 2 +- internal/config/capability.go | 2 + internal/config/options.go | 5 + internal/elicitation/elicitation.go | 53 ++++++ internal/protocol/session.go | 110 +++++++++++- internal/protocol/session_integration_test.go | 39 +---- internal/protocol/session_test.go | 164 +++++++++++++++--- options.go | 12 ++ types.go | 34 ++++ 9 files changed, 354 insertions(+), 67 deletions(-) create mode 100644 internal/elicitation/elicitation.go diff --git a/Makefile b/Makefile index 98748ca..7ed1783 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ test: ## test-integration: run integration tests test-integration: - go test -race -tags=integration -timeout=240s -v ./integration/... + go test -race -tags=integration -timeout=600s -v ./integration/... ## clean: remove build artifacts clean: diff --git a/internal/config/capability.go b/internal/config/capability.go index bdc2e0c..89cd365 100644 --- a/internal/config/capability.go +++ b/internal/config/capability.go @@ -63,6 +63,7 @@ var optionCapabilities = []OptionCapability{ {Field: "SDKTools", OptionName: "WithSDKTools", Exec: SupportUnsupported, AppServer: SupportSupported}, {Field: "CanUseTool", OptionName: "WithCanUseTool", Exec: SupportUnsupported, AppServer: SupportSupported}, {Field: "OnUserInput", OptionName: "WithOnUserInput", Exec: SupportUnsupported, AppServer: SupportSupported}, + {Field: "OnElicitation", OptionName: "WithOnElicitation", Exec: SupportUnsupported, AppServer: SupportSupported}, { Field: "Tools", OptionName: "WithTools", Exec: SupportUnsupported, AppServer: SupportEmulated, Notes: "emulated via SDK can_use_tool policy (not a native codex option)", @@ -166,6 +167,7 @@ func EnabledOptionFields(opts *Options) map[string]bool { set("SDKTools", len(opts.SDKTools) > 0) set("CanUseTool", opts.CanUseTool != nil) set("OnUserInput", opts.OnUserInput != nil) + set("OnElicitation", opts.OnElicitation != nil) set("Tools", opts.Tools != nil) set("AllowedTools", len(opts.AllowedTools) > 0) set("DisallowedTools", len(opts.DisallowedTools) > 0) diff --git a/internal/config/options.go b/internal/config/options.go index c4e1a93..f42003a 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -5,6 +5,7 @@ import ( "log/slog" "time" + "github.com/ethpandaops/codex-agent-sdk-go/internal/elicitation" "github.com/ethpandaops/codex-agent-sdk-go/internal/hook" "github.com/ethpandaops/codex-agent-sdk-go/internal/mcp" "github.com/ethpandaops/codex-agent-sdk-go/internal/permission" @@ -136,6 +137,10 @@ type Options struct { // (e.g., multiple-choice or free-text prompts in plan mode). OnUserInput userinput.Callback + // OnElicitation is called when the CLI sends mcpServer/elicitation/request. + // If nil, elicitation requests are auto-declined. + OnElicitation elicitation.Callback + // ===== CLAUDE SDK PARITY FIELDS ===== // Tools specifies which tools are available. diff --git a/internal/elicitation/elicitation.go b/internal/elicitation/elicitation.go new file mode 100644 index 0000000..df2cb20 --- /dev/null +++ b/internal/elicitation/elicitation.go @@ -0,0 +1,53 @@ +// Package elicitation provides typed structures for MCP elicitation handling. +package elicitation + +import ( + "context" + + "github.com/ethpandaops/codex-agent-sdk-go/internal/message" +) + +// Mode identifies the elicitation UX expected by the CLI. +type Mode string + +const ( + // ModeForm requests a form-style elicitation. + ModeForm Mode = "form" + // ModeURL requests a URL-based elicitation. + ModeURL Mode = "url" +) + +// Action identifies the SDK consumer's response to an elicitation request. +type Action string + +const ( + // ActionAccept accepts the elicitation and optionally returns content. + ActionAccept Action = "accept" + // ActionDecline declines the elicitation. + ActionDecline Action = "decline" + // ActionCancel cancels the elicitation flow. + ActionCancel Action = "cancel" +) + +// Request contains an MCP elicitation request from the CLI. +type Request struct { + MCPServerName string + Message string + Mode *Mode + URL *string + ElicitationID *string + RequestedSchema map[string]any + ThreadID string + TurnID *string + Audit *message.AuditEnvelope `json:"-"` +} + +// Response contains the SDK consumer's elicitation decision. +type Response struct { + Action Action + Content map[string]any + Audit *message.AuditEnvelope `json:"-"` +} + +// Callback handles an MCP elicitation request. +type Callback func(ctx context.Context, req *Request) (*Response, error) diff --git a/internal/protocol/session.go b/internal/protocol/session.go index b20fd1e..56b229d 100644 --- a/internal/protocol/session.go +++ b/internal/protocol/session.go @@ -13,6 +13,7 @@ import ( "time" "github.com/ethpandaops/codex-agent-sdk-go/internal/config" + "github.com/ethpandaops/codex-agent-sdk-go/internal/elicitation" "github.com/ethpandaops/codex-agent-sdk-go/internal/mcp" "github.com/ethpandaops/codex-agent-sdk-go/internal/message" "github.com/ethpandaops/codex-agent-sdk-go/internal/permission" @@ -68,6 +69,12 @@ func (s *Session) RegisterHandlers() { s.controller.RegisterHandler("item_fileChange_requestApproval", s.HandleFileChangeApproval) s.controller.RegisterHandler("applyPatchApproval", s.HandleFileChangeApproval) + // MCP elicitation (app-server sends mcpServer/elicitation/request). + s.controller.RegisterHandler("mcpServer_elicitation/request", s.HandleMCPElicitation) + + // Permissions approval (app-server sends item/permissions/requestApproval). + s.controller.RegisterHandler("item_permissions/requestApproval", s.HandlePermissionsApproval) + // External auth token refresh (used by current app-server external-auth mode). s.controller.RegisterHandler("account_chatgptAuthTokens/refresh", s.HandleChatGPTAuthTokensRefresh) } @@ -418,6 +425,7 @@ func (s *Session) NeedsInitialization() bool { return s.options.CanUseTool != nil || s.options.OnUserInput != nil || + s.options.OnElicitation != nil || len(s.sdkMcpServers) > 0 || len(s.sdkDynamicTools) > 0 } @@ -931,7 +939,8 @@ func (s *Session) HandleFileChangeApproval( } // HandlePermissionsApproval handles item/permissions/requestApproval requests. -// It routes through the CanUseTool callback if set, otherwise auto-accepts. +// It routes through the CanUseTool callback if set, otherwise auto-approves +// with the requested permissions scoped to the current turn. func (s *Session) HandlePermissionsApproval( ctx context.Context, req *ControlRequest, @@ -942,12 +951,18 @@ func (s *Session) HandlePermissionsApproval( default: } + permissions, _ := req.Request["permissions"].(map[string]any) + if s.options == nil || s.options.CanUseTool == nil { - return map[string]any{"decision": "accept"}, nil + return map[string]any{ + "permissions": permissions, + "scope": "turn", + }, nil } input := make(map[string]any, 2) - if permissions, ok := req.Request["permissions"].(map[string]any); ok { + + if permissions != nil { input["permissions"] = permissions } @@ -962,9 +977,15 @@ func (s *Session) HandlePermissionsApproval( switch decision.(type) { case *permission.ResultAllow: - return map[string]any{"decision": "accept"}, nil + return map[string]any{ + "permissions": permissions, + "scope": "turn", + }, nil case *permission.ResultDeny: - return map[string]any{"decision": "decline"}, nil + return map[string]any{ + "permissions": map[string]any{}, + "scope": "turn", + }, nil default: return nil, fmt.Errorf( "tool permission callback must return *ResultAllow or *ResultDeny, got %T", @@ -974,12 +995,83 @@ func (s *Session) HandlePermissionsApproval( } // HandleMCPElicitation handles mcpServer/elicitation/request requests. -// No SDK callback exists for this yet; auto-accepts. +// It parses the request into typed elicitation types, invokes the OnElicitation +// callback, and serializes the response back to the wire format. +// If no callback is set, elicitation requests are auto-declined. func (s *Session) HandleMCPElicitation( - _ context.Context, - _ *ControlRequest, + ctx context.Context, + req *ControlRequest, ) (map[string]any, error) { - return map[string]any{"action": "accept", "content": nil}, nil + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + if s.options == nil || s.options.OnElicitation == nil { + return map[string]any{"action": string(elicitation.ActionDecline)}, nil + } + + parsed := parseElicitationRequest(req) + + resp, err := s.options.OnElicitation(ctx, parsed) + if err != nil { + return nil, err + } + + if resp == nil { + return map[string]any{"action": string(elicitation.ActionDecline)}, nil + } + + result := map[string]any{ + "action": string(resp.Action), + } + + if resp.Content != nil { + result["content"] = resp.Content + } + + return result, nil +} + +// parseElicitationRequest extracts a typed elicitation.Request from the wire format. +func parseElicitationRequest(req *ControlRequest) *elicitation.Request { + result := &elicitation.Request{} + result.MCPServerName, _ = req.Request["serverName"].(string) + result.Message, _ = req.Request["message"].(string) + result.ThreadID, _ = req.Request["threadId"].(string) + + if modeStr, ok := req.Request["mode"].(string); ok { + m := elicitation.Mode(modeStr) + result.Mode = &m + } + + if urlStr, ok := req.Request["url"].(string); ok { + result.URL = &urlStr + } + + if eid, ok := req.Request["elicitationId"].(string); ok { + result.ElicitationID = &eid + } + + if turnID, ok := req.Request["turnId"].(string); ok { + result.TurnID = &turnID + } + + if schema, ok := req.Request["requestedSchema"].(map[string]any); ok { + result.RequestedSchema = schema + } + + payload, err := json.Marshal(req.Request) + if err == nil { + result.Audit = &message.AuditEnvelope{ + EventType: "mcpServer_elicitation/request", + Subtype: "request", + Payload: payload, + } + } + + return result } // HandleChatGPTAuthTokensRefresh handles account/chatgptAuthTokens/refresh diff --git a/internal/protocol/session_integration_test.go b/internal/protocol/session_integration_test.go index bf9949c..dc7deab 100644 --- a/internal/protocol/session_integration_test.go +++ b/internal/protocol/session_integration_test.go @@ -64,8 +64,13 @@ func TestSessionRegisterHandlers_CoversCurrentCodexServerRequests(t *testing.T) "account/chatgptAuthTokens/refresh", "applyPatchApproval", "execCommandApproval", + "mcpServer/elicitation/request", + "item/permissions/requestApproval", } + // Guard against accidental whitespace-only schema reads. + require.NotEmpty(t, strings.TrimSpace(serverRequestJSON)) + for _, method := range liveMethods { require.Contains(t, serverRequestJSON, `"`+method+`"`, "installed codex schema no longer contains %s; update this proof test", method) @@ -75,37 +80,3 @@ func TestSessionRegisterHandlers_CoversCurrentCodexServerRequests(t *testing.T) require.True(t, ok, "no session handler registered for current codex server request %q (subtype %q)", method, subtype) } } - -func TestSessionRegisterHandlers_DoesNotRegisterRequestTypesMissingFromCurrentCodexSchema(t *testing.T) { - schemaDir := generateSchemaDir(t) - serverRequestJSON := readSchemaFile(t, schemaDir, "ServerRequest.json") - - controller := NewController(slog.Default(), newMockTransport()) - session := NewSession(slog.Default(), controller, &config.Options{}) - session.RegisterHandlers() - - staleMethods := []string{ - "item/permissions/requestApproval", - "mcpServer/elicitation/request", - } - - for _, method := range staleMethods { - require.NotContains(t, serverRequestJSON, `"`+method+`"`, - "current codex schema unexpectedly contains %s; update this proof test", method) - - subtype := requestMethodToSubtype(method) - found := false - for registered := range controller.handlers { - if registered == subtype { - found = true - break - } - } - - require.False(t, found, - "stale handler %q should not remain registered when the current codex schema no longer exposes that request", subtype) - } - - // Guard against accidental whitespace-only schema reads. - require.NotEmpty(t, strings.TrimSpace(serverRequestJSON)) -} diff --git a/internal/protocol/session_test.go b/internal/protocol/session_test.go index 9321b02..9793db2 100644 --- a/internal/protocol/session_test.go +++ b/internal/protocol/session_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/ethpandaops/codex-agent-sdk-go/internal/config" + "github.com/ethpandaops/codex-agent-sdk-go/internal/elicitation" "github.com/ethpandaops/codex-agent-sdk-go/internal/mcp" "github.com/ethpandaops/codex-agent-sdk-go/internal/permission" "github.com/ethpandaops/codex-agent-sdk-go/internal/userinput" @@ -716,20 +717,23 @@ func TestSession_HandleFileChangeApproval_LegacyApplyPatchUsesWriteForAdds(t *te func TestSession_HandlePermissionsApproval_NoCallback(t *testing.T) { session := NewSession(slog.Default(), nil, &config.Options{}) + reqPerms := map[string]any{ + "fileSystem": map[string]any{"write": []any{"/tmp"}}, + } + resp, err := session.HandlePermissionsApproval(context.Background(), &ControlRequest{ Request: map[string]any{ - "subtype": "item_permissions/requestApproval", - "itemId": "item_1", - "threadId": "thread_1", - "turnId": "turn_1", - "permissions": map[string]any{ - "fileSystem": map[string]any{"write": []any{"/tmp"}}, - }, + "subtype": "item_permissions/requestApproval", + "itemId": "item_1", + "threadId": "thread_1", + "turnId": "turn_1", + "permissions": reqPerms, }, }) require.NoError(t, err) - require.Equal(t, "accept", resp["decision"]) + require.Equal(t, reqPerms, resp["permissions"]) + require.Equal(t, "turn", resp["scope"]) } func TestSession_HandlePermissionsApproval_WithCallback(t *testing.T) { @@ -749,21 +753,24 @@ func TestSession_HandlePermissionsApproval_WithCallback(t *testing.T) { session := NewSession(slog.Default(), nil, opts) + reqPerms := map[string]any{ + "network": map[string]any{"enabled": true}, + } + resp, err := session.HandlePermissionsApproval(context.Background(), &ControlRequest{ Request: map[string]any{ - "subtype": "item_permissions/requestApproval", - "itemId": "item_1", - "threadId": "thread_1", - "turnId": "turn_1", - "permissions": map[string]any{ - "network": map[string]any{"enabled": true}, - }, - "reason": "need network", + "subtype": "item_permissions/requestApproval", + "itemId": "item_1", + "threadId": "thread_1", + "turnId": "turn_1", + "permissions": reqPerms, + "reason": "need network", }, }) require.NoError(t, err) - require.Equal(t, "accept", resp["decision"]) + require.Equal(t, reqPerms, resp["permissions"]) + require.Equal(t, "turn", resp["scope"]) require.Equal(t, "Permissions", capturedTool) require.NotNil(t, capturedInput["permissions"]) require.Equal(t, "need network", capturedInput["reason"]) @@ -777,20 +784,23 @@ func TestSession_HandlePermissionsApproval_AllowedWritePolicyBypassesFilter(t *t session := NewSession(slog.Default(), nil, opts) + reqPerms := map[string]any{ + "fileSystem": map[string]any{"write": []any{"/tmp"}}, + } + resp, err := session.HandlePermissionsApproval(context.Background(), &ControlRequest{ Request: map[string]any{ - "subtype": "item_permissions/requestApproval", - "permissions": map[string]any{ - "fileSystem": map[string]any{"write": []any{"/tmp"}}, - }, + "subtype": "item_permissions/requestApproval", + "permissions": reqPerms, }, }) require.NoError(t, err) - require.Equal(t, "accept", resp["decision"]) + require.Equal(t, reqPerms, resp["permissions"]) + require.Equal(t, "turn", resp["scope"]) } -func TestSession_HandleMCPElicitation(t *testing.T) { +func TestSession_HandleMCPElicitation_NoCallback(t *testing.T) { session := NewSession(slog.Default(), nil, &config.Options{}) resp, err := session.HandleMCPElicitation(context.Background(), &ControlRequest{ @@ -799,8 +809,116 @@ func TestSession_HandleMCPElicitation(t *testing.T) { }, }) + require.NoError(t, err) + require.Equal(t, "decline", resp["action"]) +} + +func TestSession_HandleMCPElicitation_WithCallback(t *testing.T) { + var capturedReq *elicitation.Request + + opts := &config.Options{ + OnElicitation: func(_ context.Context, req *elicitation.Request) (*elicitation.Response, error) { + capturedReq = req + + return &elicitation.Response{ + Action: elicitation.ActionAccept, + Content: map[string]any{"name": "test-value"}, + }, nil + }, + } + + session := NewSession(slog.Default(), nil, opts) + + resp, err := session.HandleMCPElicitation(context.Background(), &ControlRequest{ + Request: map[string]any{ + "subtype": "mcpServer_elicitation/request", + "serverName": "my-mcp-server", + "message": "Please provide credentials", + "mode": "form", + "threadId": "thread-1", + "turnId": "turn-1", + "requestedSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + }, + }) + require.NoError(t, err) require.Equal(t, "accept", resp["action"]) + require.Equal(t, map[string]any{"name": "test-value"}, resp["content"]) + + require.NotNil(t, capturedReq) + require.Equal(t, "my-mcp-server", capturedReq.MCPServerName) + require.Equal(t, "Please provide credentials", capturedReq.Message) + require.Equal(t, "thread-1", capturedReq.ThreadID) + + require.NotNil(t, capturedReq.Mode) + require.Equal(t, elicitation.ModeForm, *capturedReq.Mode) + + require.NotNil(t, capturedReq.TurnID) + require.Equal(t, "turn-1", *capturedReq.TurnID) + + require.NotNil(t, capturedReq.RequestedSchema) + require.Equal(t, "object", capturedReq.RequestedSchema["type"]) + + require.NotNil(t, capturedReq.Audit) +} + +func TestSession_HandleMCPElicitation_URLMode(t *testing.T) { + opts := &config.Options{ + OnElicitation: func(_ context.Context, req *elicitation.Request) (*elicitation.Response, error) { + require.NotNil(t, req.Mode) + require.Equal(t, elicitation.ModeURL, *req.Mode) + require.NotNil(t, req.URL) + require.Equal(t, "https://example.com/auth", *req.URL) + require.NotNil(t, req.ElicitationID) + require.Equal(t, "elicit-123", *req.ElicitationID) + + return &elicitation.Response{Action: elicitation.ActionCancel}, nil + }, + } + + session := NewSession(slog.Default(), nil, opts) + + resp, err := session.HandleMCPElicitation(context.Background(), &ControlRequest{ + Request: map[string]any{ + "subtype": "mcpServer_elicitation/request", + "serverName": "auth-server", + "message": "Please authenticate", + "mode": "url", + "url": "https://example.com/auth", + "elicitationId": "elicit-123", + "threadId": "thread-2", + }, + }) + + require.NoError(t, err) + require.Equal(t, "cancel", resp["action"]) +} + +func TestSession_HandleMCPElicitation_NilResponse(t *testing.T) { + opts := &config.Options{ + OnElicitation: func(_ context.Context, _ *elicitation.Request) (*elicitation.Response, error) { + return &elicitation.Response{Action: elicitation.ActionDecline}, nil + }, + } + + session := NewSession(slog.Default(), nil, opts) + + resp, err := session.HandleMCPElicitation(context.Background(), &ControlRequest{ + Request: map[string]any{ + "subtype": "mcpServer_elicitation/request", + "serverName": "test-server", + "message": "test", + "threadId": "thread-1", + }, + }) + + require.NoError(t, err) + require.Equal(t, "decline", resp["action"]) } func TestSession_HandleCanUseTool_LegacyExecCommandApprovalArray(t *testing.T) { diff --git a/options.go b/options.go index be42cd4..89108e0 100644 --- a/options.go +++ b/options.go @@ -217,6 +217,18 @@ func WithOnUserInput(callback UserInputCallback) Option { } } +// ===== Elicitation ===== + +// WithOnElicitation sets a callback for handling MCP elicitation requests. +// The callback is invoked when an MCP server sends an elicitation/create request +// through the CLI, allowing the SDK consumer to present forms or collect input. +// If not set, elicitation requests are auto-declined. +func WithOnElicitation(callback ElicitationCallback) Option { + return func(o *CodexAgentOptions) { + o.OnElicitation = callback + } +} + // ===== Session ===== // WithContinueConversation indicates whether to continue an existing conversation. diff --git a/types.go b/types.go index 699753e..e9a4513 100644 --- a/types.go +++ b/types.go @@ -4,6 +4,7 @@ import ( "iter" "github.com/ethpandaops/codex-agent-sdk-go/internal/config" + "github.com/ethpandaops/codex-agent-sdk-go/internal/elicitation" "github.com/ethpandaops/codex-agent-sdk-go/internal/hook" "github.com/ethpandaops/codex-agent-sdk-go/internal/mcp" "github.com/ethpandaops/codex-agent-sdk-go/internal/message" @@ -416,6 +417,39 @@ type UserInputResponse = userinput.Response // UserInputCallback is invoked when the CLI sends an item/tool/requestUserInput request. type UserInputCallback = userinput.Callback +// ===== Elicitation ===== + +// ElicitationMode identifies the elicitation UX expected by the CLI. +type ElicitationMode = elicitation.Mode + +const ( + // ElicitationModeForm requests a form-style elicitation. + ElicitationModeForm = elicitation.ModeForm + // ElicitationModeURL requests a URL-based elicitation. + ElicitationModeURL = elicitation.ModeURL +) + +// ElicitationAction identifies the SDK consumer's response to an elicitation request. +type ElicitationAction = elicitation.Action + +const ( + // ElicitationActionAccept accepts the elicitation and optionally returns content. + ElicitationActionAccept = elicitation.ActionAccept + // ElicitationActionDecline declines the elicitation. + ElicitationActionDecline = elicitation.ActionDecline + // ElicitationActionCancel cancels the elicitation flow. + ElicitationActionCancel = elicitation.ActionCancel +) + +// ElicitationRequest contains an MCP elicitation request from the CLI. +type ElicitationRequest = elicitation.Request + +// ElicitationResponse contains the SDK consumer's elicitation decision. +type ElicitationResponse = elicitation.Response + +// ElicitationCallback handles MCP elicitation requests. +type ElicitationCallback = elicitation.Callback + // ===== MCP Server Configuration ===== // MCPServerType represents the type of MCP server. From 44ac9269dd09effc064244f7bef865f9fa6d4318 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:31:59 +1000 Subject: [PATCH 11/11] fix git ignore --- .gitignore | 6 +- examples/developer_instructions/main.go | 75 +++++++++++++++++ examples/personality/main.go | 101 ++++++++++++++++++++++ examples/service_tier/main.go | 107 ++++++++++++++++++++++++ 4 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 examples/developer_instructions/main.go create mode 100644 examples/personality/main.go create mode 100644 examples/service_tier/main.go diff --git a/.gitignore b/.gitignore index 4f9e716..b78ae07 100644 --- a/.gitignore +++ b/.gitignore @@ -43,8 +43,8 @@ hello.txt hello.go memories/ cancellation_demo.txt -developer_instructions +/developer_instructions /extended_thinking graceful_shutdown_demo.txt -personality -service_tier +/personality +/service_tier diff --git a/examples/developer_instructions/main.go b/examples/developer_instructions/main.go new file mode 100644 index 0000000..4eea15c --- /dev/null +++ b/examples/developer_instructions/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "time" + + codexsdk "github.com/ethpandaops/codex-agent-sdk-go" +) + +// This example demonstrates WithDeveloperInstructions, which provides +// additional instructions to the agent separately from WithSystemPrompt. +// DeveloperInstructions maps to the "developerInstructions" field in the +// Codex CLI protocol and takes precedence over the systemPrompt mapping. +func main() { + fmt.Println("Developer Instructions Example") + fmt.Println() + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + client := codexsdk.NewClient() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + defer func() { + if err := client.Close(); err != nil { + fmt.Fprintf(os.Stderr, "failed to close client: %v\n", err) + } + }() + + if err := client.Start(ctx, + codexsdk.WithLogger(logger), + codexsdk.WithDeveloperInstructions( + "Always respond in exactly three bullet points. "+ + "Each bullet point must be a single sentence.", + ), + ); err != nil { + fmt.Printf("Failed to connect: %v\n", err) + + return + } + + prompt := "Explain why Go is a good language for backend development." + fmt.Printf("Prompt: %s\n\n", prompt) + + if err := client.Query(ctx, codexsdk.Text(prompt)); err != nil { + fmt.Printf("Failed to send query: %v\n", err) + + return + } + + for msg, err := range client.ReceiveResponse(ctx) { + if err != nil { + fmt.Printf("Error: %v\n", err) + + return + } + + switch m := msg.(type) { + case *codexsdk.AssistantMessage: + for _, block := range m.Content { + if textBlock, ok := block.(*codexsdk.TextBlock); ok { + fmt.Printf("Codex: %s\n", textBlock.Text) + } + } + case *codexsdk.ResultMessage: + if m.Usage != nil { + fmt.Printf("\nTokens: %d in / %d out\n", m.Usage.InputTokens, m.Usage.OutputTokens) + } + } + } +} diff --git a/examples/personality/main.go b/examples/personality/main.go new file mode 100644 index 0000000..a410dcb --- /dev/null +++ b/examples/personality/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + codexsdk "github.com/ethpandaops/codex-agent-sdk-go" +) + +// This example demonstrates WithPersonality, which controls the agent's +// response style. Valid values are "none", "friendly", and "pragmatic". +func runPersonalityExample(name, personality string) { + fmt.Printf("=== Personality: %s ===\n", name) + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + client := codexsdk.NewClient() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + defer func() { + if err := client.Close(); err != nil { + fmt.Fprintf(os.Stderr, "failed to close client: %v\n", err) + } + }() + + if err := client.Start(ctx, + codexsdk.WithLogger(logger), + codexsdk.WithPersonality(personality), + ); err != nil { + fmt.Printf("Failed to connect: %v\n", err) + + return + } + + prompt := "What is a goroutine?" + + fmt.Printf("Prompt: %s\n", prompt) + fmt.Println(strings.Repeat("-", 50)) + + if err := client.Query(ctx, codexsdk.Text(prompt)); err != nil { + fmt.Printf("Failed to send query: %v\n", err) + + return + } + + for msg, err := range client.ReceiveResponse(ctx) { + if err != nil { + fmt.Printf("Error: %v\n", err) + + return + } + + switch m := msg.(type) { + case *codexsdk.AssistantMessage: + for _, block := range m.Content { + if textBlock, ok := block.(*codexsdk.TextBlock); ok { + fmt.Printf("Codex: %s\n", textBlock.Text) + } + } + case *codexsdk.ResultMessage: + if m.Usage != nil { + fmt.Printf("\nTokens: %d in / %d out\n", m.Usage.InputTokens, m.Usage.OutputTokens) + } + } + } + + fmt.Println() +} + +func main() { + fmt.Println("Personality Examples") + fmt.Println("Demonstrating WithPersonality options: none, friendly, pragmatic") + fmt.Println(strings.Repeat("=", 60)) + fmt.Println() + + personalities := []struct { + name string + value string + }{ + {"None (neutral)", "none"}, + {"Friendly", "friendly"}, + {"Pragmatic", "pragmatic"}, + } + + example := "all" + if len(os.Args) > 1 { + example = os.Args[1] + } + + for _, p := range personalities { + if example == "all" || example == p.value { + runPersonalityExample(p.name, p.value) + } + } +} diff --git a/examples/service_tier/main.go b/examples/service_tier/main.go new file mode 100644 index 0000000..54ce763 --- /dev/null +++ b/examples/service_tier/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + codexsdk "github.com/ethpandaops/codex-agent-sdk-go" +) + +// This example demonstrates WithServiceTier, which controls the API service +// tier for requests. Valid values are "fast" (optimized for speed) and "flex" +// (optimized for cost/throughput). +// +// Note: "flex" tier availability is model-dependent and may not yet be +// supported by all API endpoints. This example demonstrates both tiers +// and shows how unsupported tiers surface errors through the SDK. +func runServiceTierExample(tier string) { + fmt.Printf("=== Service Tier: %s ===\n", tier) + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + client := codexsdk.NewClient() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + defer func() { + if err := client.Close(); err != nil { + fmt.Fprintf(os.Stderr, "failed to close client: %v\n", err) + } + }() + + if err := client.Start(ctx, + codexsdk.WithLogger(logger), + codexsdk.WithServiceTier(tier), + ); err != nil { + fmt.Printf("Failed to connect: %v\n", err) + + return + } + + prompt := "What is 2+2? Reply with just the number." + fmt.Printf("Prompt: %s\n", prompt) + fmt.Println(strings.Repeat("-", 50)) + + if err := client.Query(ctx, codexsdk.Text(prompt)); err != nil { + fmt.Printf("Failed to send query: %v\n", err) + + return + } + + for msg, err := range client.ReceiveResponse(ctx) { + if err != nil { + fmt.Printf("Error: %v\n", err) + + return + } + + switch m := msg.(type) { + case *codexsdk.AssistantMessage: + if m.Error != nil { + fmt.Printf("API Error (expected for unsupported tiers):\n") + } + + for _, block := range m.Content { + if textBlock, ok := block.(*codexsdk.TextBlock); ok { + fmt.Printf("Codex: %s\n", textBlock.Text) + } + } + case *codexsdk.ResultMessage: + if m.Usage != nil { + fmt.Printf("\nTokens: %d in / %d out\n", m.Usage.InputTokens, m.Usage.OutputTokens) + } + } + } + + fmt.Println() +} + +func main() { + fmt.Println("Service Tier Examples") + fmt.Println("Demonstrating WithServiceTier options: fast, flex") + fmt.Println(strings.Repeat("=", 60)) + fmt.Println() + + example := "all" + if len(os.Args) > 1 { + example = os.Args[1] + } + + switch example { + case "all": + runServiceTierExample("fast") + fmt.Println(strings.Repeat("-", 60)) + fmt.Println() + runServiceTierExample("flex") + case "fast", "flex": + runServiceTierExample(example) + default: + fmt.Printf("Unknown tier %q. Valid values: fast, flex, all\n", example) + os.Exit(1) + } +}