diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go index f64e333b0..57aec4a38 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_live_linux_test.go @@ -66,7 +66,7 @@ func TestLiveClaudeSDKCancelResume(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider, SystemPrompt: "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."} + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider, SystemPrompt: "Follow the user's requested format. Preserve the exact verification value in conversation history. Use no tools."} running, err := startSingleTurn(ctx, config, request, out) if err != nil { t.Fatal(err) @@ -148,11 +148,11 @@ func TestLiveClaudeSDKCancelResume(t *testing.T) { nonce := "cancel-history-" + uuid.NewString() first := run("Remember this exact verification value: "+nonce+". First repeat it, then write two hundred numbered sentences about trees. Do not use tools.", "", true) id, _ := first.Outcome.Metadata[proto.DoneMetaAgentSessionID].(string) - if id == "" || first.Outcome.Content == "" || first.Failure == "" { + if id == "" || messageText(first.Events) == "" || first.Failure == "" { t.Fatal("live cancellation lost identity, partial output or interruption evidence") } second := run("Return only the exact cancel-history verification value in the earlier user request. Ignore the earlier request for numbered sentences.", id, false) - if second.Failure != "" || second.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(second.Outcome.Content, nonce) || first.NodePID == second.NodePID { + if second.Failure != "" || second.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(messageText(second.Events), nonce) || first.NodePID == second.NodePID { t.Fatalf("cold continuation did not preserve identity/history; evidence %s", root) } data, _ := json.MarshalIndent(map[string]any{"scope": "private Go factory -> maintained SDK/native -> real MiniMax cancellation and cold continuation; public admission remains separate", "verification_value": nonce, "executions": []evidence{first, second}}, "", " ") diff --git a/apps/daemon/internal/agent/claudesdk/cancellation_test.go b/apps/daemon/internal/agent/claudesdk/cancellation_test.go index 401f34b67..4a4945406 100644 --- a/apps/daemon/internal/agent/claudesdk/cancellation_test.go +++ b/apps/daemon/internal/agent/claudesdk/cancellation_test.go @@ -59,7 +59,7 @@ func TestCancellationWaitsForDrainAndPublishesOutcome(t *testing.T) { t.Fatal("successful cancellation preceded owned process release") } got := running.CancellationOutcome() - if got.Content != "partialtaildrained" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil || got.Usage.Tokens != nil { + if got.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || got.Usage.Raw["claude_sdk_result"] == nil || got.Usage.Tokens != nil { t.Fatalf("lost drained cancellation outcome: %+v", got) } var done proto.DonePayload @@ -108,7 +108,7 @@ func TestFailureKeepsOnlyVerifiedNativeIdentity(t *testing.T) { t.Fatal("native failure was not reported") } if mode == "failure" { - if done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Content != "partial" || done.Usage.Raw["claude_sdk_result"] == nil { + if done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Usage.Raw["claude_sdk_result"] == nil { t.Fatal("verified failure outcome was lost", done) } } else if done.Metadata[proto.DoneMetaAgentSessionID] != nil { @@ -168,7 +168,7 @@ func cancellationRequest() proto.PromptRequestPayload { func runCancellationHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { if mode == "cancellation-wait" { - emit(bridgeEvent{Type: "delta", Delta: "partial"}) + emit(bridgeEvent{Type: "delta", ItemID: "message", Delta: "partial"}) if !scanner.Scan() { return } @@ -179,7 +179,7 @@ func runCancellationHelper(request startRequest, mode string, scanner *bufio.Sca // These valid observations were in flight when cancellation started. emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: request.Resume, Usage: json.RawMessage(usageFixture)}) - emit(bridgeEvent{Type: "delta", Delta: "tail"}) + emit(bridgeEvent{Type: "delta", ItemID: "message", Delta: "tail"}) for { if _, err := os.Stat(filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "release")); err == nil { break @@ -187,7 +187,7 @@ func runCancellationHelper(request startRequest, mode string, scanner *bufio.Sca time.Sleep(time.Millisecond) } _, _ = os.Stderr.WriteString(strings.Repeat("x", 2*1024*1024)) - emit(bridgeEvent{Type: "delta", Delta: "drained"}) + emit(bridgeEvent{Type: "delta", ItemID: "message", Delta: "drained"}) emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) emit(bridgeEvent{Type: "error", Code: "cancelled"}) return @@ -199,7 +199,7 @@ func runCancellationHelper(request startRequest, mode string, scanner *bufio.Sca } emit(bridgeEvent{Type: "input_ready", SessionID: id}) emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: id, Usage: json.RawMessage(usageFixture)}) - emit(bridgeEvent{Type: "delta", Delta: "partial"}) + emit(bridgeEvent{Type: "delta", ItemID: "message", Delta: "partial"}) } emit(bridgeEvent{Type: "error", Code: "execution_failed"}) } diff --git a/apps/daemon/internal/agent/claudesdk/commands_session_test.go b/apps/daemon/internal/agent/claudesdk/commands_session_test.go index b19a7584e..bd928f6d7 100644 --- a/apps/daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/daemon/internal/agent/claudesdk/commands_session_test.go @@ -175,5 +175,5 @@ func runCommandsHelper(request startRequest, mode string, scanner *bufio.Scanner return } emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) - emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "completed"}) + emit(bridgeEvent{Type: "result", SessionID: request.Resume}) } diff --git a/apps/daemon/internal/agent/claudesdk/declaration.go b/apps/daemon/internal/agent/claudesdk/declaration.go index c2756ccd2..c296940a9 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration.go +++ b/apps/daemon/internal/agent/claudesdk/declaration.go @@ -22,7 +22,6 @@ const claudeSDKNodeEnv = "OAC_RUNTIME_CLAUDE_SDK_NODE" var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{Kind: "claude_sdk", Capabilities: proto.AgentKindCapabilities{ SubagentObservations: proto.CapabilityUnsupported, NativeSessionRecovery: proto.CapabilityUnsupported, - MessageItems: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, LocalEnvironment: proto.CapabilityUnsupported, WorkspaceReadPreparation: proto.CapabilityUnsupported, diff --git a/apps/daemon/internal/agent/claudesdk/declaration_test.go b/apps/daemon/internal/agent/claudesdk/declaration_test.go index dc5148eaf..4d04b8074 100644 --- a/apps/daemon/internal/agent/claudesdk/declaration_test.go +++ b/apps/daemon/internal/agent/claudesdk/declaration_test.go @@ -66,7 +66,7 @@ func TestClaudeSDKFeatureDiscovery(t *testing.T) { // The declaration must retain the complete baseline capability descriptor. func TestDeclaredCapabilityBaseline(t *testing.T) { - expected := map[string]bool{"MessageItems": true, "EnvironmentNone": true, "ProgrammaticToolCallingDisable": true, "SubagentControl": true, "FunctionTools": true} + expected := map[string]bool{"EnvironmentNone": true, "ProgrammaticToolCallingDisable": true, "SubagentControl": true, "FunctionTools": true} value := reflect.ValueOf(Declaration.Info.Capabilities) for i := 0; i < value.NumField(); i++ { name := value.Type().Field(i).Name diff --git a/apps/daemon/internal/agent/claudesdk/error_classification_test.go b/apps/daemon/internal/agent/claudesdk/error_classification_test.go index 12ed95144..fa946bf92 100644 --- a/apps/daemon/internal/agent/claudesdk/error_classification_test.go +++ b/apps/daemon/internal/agent/claudesdk/error_classification_test.go @@ -92,7 +92,7 @@ func runClassifiedFailureHelper(request startRequest, mode string, encode func(b os.Exit(7) } if mode == "after-terminal" { - fmt.Fprintln(os.Stdout, `{"type":"delta","delta":"late"}`) + fmt.Fprintln(os.Stdout, `{"type":"delta","item_id":"message","delta":"late"}`) } if mode == "scanner-error" { fmt.Fprintln(os.Stdout, strings.Repeat("x", 2*1024*1024)) diff --git a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go index 609d6995c..e2ea827c3 100644 --- a/apps/daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/daemon/internal/agent/claudesdk/execution_controls_test.go @@ -81,7 +81,7 @@ func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} schema := json.RawMessage(`{"type":"object","properties":{"n":{"const":9007199254740992}}}`) - request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), ObserveMessages: true, DisableSubagents: true, Model: "model", SystemPrompt: "Original instructions.", ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} + request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), DisableSubagents: true, Model: "model", SystemPrompt: "Original instructions.", ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: schema}}} start, _, err := prepareConfiguration(config, request) if err != nil { t.Fatal(err) diff --git a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go index 175618428..738cf56a8 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_live_linux_test.go @@ -68,6 +68,7 @@ func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { Settlement agent.TurnSettlement `json:"settlement"` SettlementError string `json:"settlement_error,omitempty"` Done proto.DonePayload `json:"done"` + Text string `json:"text"` Errors []string `json:"errors,omitempty"` } evidence := struct { @@ -82,7 +83,7 @@ func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { _ = os.WriteFile(filepath.Join(proof, "executor-evidence.json"), raw, 0600) } defer persist() - request := proto.PromptRequestPayload{DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: model, ModelProvider: provider, SystemPrompt: "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."} + request := proto.PromptRequestPayload{DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: model, ModelProvider: provider, SystemPrompt: "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."} factory := NewExecutorFactory(config) prepared := time.Now() owner, err := factory(ctx, request) @@ -124,6 +125,11 @@ func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { if event.ID != id { t.Fatal("output crossed Turn identity") } + if event.Type == proto.TypeDelta { + var delta proto.DeltaPayload + _ = event.DecodePayload(&delta) + record.Text += delta.Delta + } if event.Type == proto.TypeDelta && record.FirstTextMS == 0 { record.FirstTextMS = time.Since(started).Milliseconds() record.NativePIDs = nativeChildren(record.NodePID) @@ -165,11 +171,11 @@ func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { } marker := "REUSE-" + uuid.NewString() first := run("first", "Remember this marker: "+marker+". Reply with exactly the marker.", false) - if first.SettlementError != "" || !first.Settlement.Reusable || !strings.Contains(first.Done.Content, marker) { + if first.SettlementError != "" || !first.Settlement.Reusable || !strings.Contains(first.Text, marker) { t.Fatal("first Turn failed or was not reusable") } second := run("second", "What exact marker did I give you? Reply with only that marker.", false) - if second.SettlementError != "" || !second.Settlement.Reusable || !strings.Contains(second.Done.Content, marker) || first.NodePID != second.NodePID || len(first.NativePIDs) == 0 || !slices.Equal(first.NativePIDs, second.NativePIDs) { + if second.SettlementError != "" || !second.Settlement.Reusable || !strings.Contains(second.Text, marker) || first.NodePID != second.NodePID || len(first.NativePIDs) == 0 || !slices.Equal(first.NativePIDs, second.NativePIDs) { t.Fatal("ordinary Turns did not retain native execution and history") } interrupted := run("cancel", "List the numbers 1 through 10000, one number per line, without stopping early.", true) @@ -196,7 +202,7 @@ func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { evidence.Recovered = true } continued := run("continued", "What exact marker did I originally give you? Reply with only the marker.", false) - if continued.SettlementError != "" || !continued.Settlement.Reusable || !strings.Contains(continued.Done.Content, marker) { + if continued.SettlementError != "" || !continued.Settlement.Reusable || !strings.Contains(continued.Text, marker) { t.Fatal("history did not continue after cancellation") } if !evidence.Recovered && (continued.NodePID != second.NodePID || !slices.Equal(continued.NativePIDs, second.NativePIDs)) { diff --git a/apps/daemon/internal/agent/claudesdk/executor_test.go b/apps/daemon/internal/agent/claudesdk/executor_test.go index c1cecc7fb..bf043ccf3 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/daemon/internal/agent/claudesdk/executor_test.go @@ -63,7 +63,7 @@ func runPersistentExecutorHelper() { if cancel { encode(bridgeEvent{Type: "error", TurnID: active, Code: "cancelled"}) } else { - encode(bridgeEvent{Type: "result", TurnID: active, SessionID: "native-persistent", Text: active}) + encode(bridgeEvent{Type: "result", TurnID: active, SessionID: "native-persistent"}) } confirmed, reusable, reason := true, true, "" switch os.Getenv("SDK_EXECUTOR_MODE") { @@ -96,12 +96,12 @@ func runPersistentExecutorHelper() { } active = command.TurnID if os.Getenv("SDK_EXECUTOR_MODE") == "late" && old != "" { - encode(bridgeEvent{Type: "delta", TurnID: old, Delta: "late"}) + encode(bridgeEvent{Type: "delta", TurnID: old, ItemID: "message", Delta: "late"}) continue } encode(bridgeEvent{Type: "turn_started", TurnID: active}) encode(bridgeEvent{Type: "input_ready", TurnID: active, SessionID: "native-persistent"}) - encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "partial"}) + encode(bridgeEvent{Type: "delta", TurnID: active, ItemID: "message", Delta: "partial"}) if strings.HasPrefix(os.Getenv("SDK_EXECUTOR_MODE"), "pending_function") { encode(bridgeEvent{Type: "function_call", TurnID: active, Call: &proto.FunctionCallPayload{CallID: "call", Name: "lookup", Arguments: json.RawMessage("{}")}}) } @@ -115,7 +115,7 @@ func runPersistentExecutorHelper() { continue } // A full bridge write has happened, but no native input receipt exists. - encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "steer-written"}) + encode(bridgeEvent{Type: "delta", TurnID: active, ItemID: "message", Delta: "steer-written"}) case "turn_cancel": if active == command.TurnID { settle(true) diff --git a/apps/daemon/internal/agent/claudesdk/executor_turn.go b/apps/daemon/internal/agent/claudesdk/executor_turn.go index 97c6638cf..2e5e253f6 100644 --- a/apps/daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/daemon/internal/agent/claudesdk/executor_turn.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "strings" "time" ) @@ -46,7 +45,6 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { s.invalidate() } } - var content strings.Builder var result *bridgeEvent var classifiedFailure error var engineCode, failedResultID string @@ -98,17 +96,17 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { s.invalidate() } case "delta": - if start.ObserveMessages && event.ItemID == "" || !start.ObserveMessages && event.ItemID != "" { - failure = fmt.Errorf("claudesdk: invalid message delta identity") + sequence++ + delta := proto.DeltaPayload{ItemID: event.ItemID, Delta: event.Delta, Sequence: sequence} + if err := delta.Validate(); err != nil { + failure = fmt.Errorf("claudesdk: %w", err) s.invalidate() break } - content.WriteString(event.Delta) - sequence++ - emit(proto.TypeDelta, proto.DeltaPayload{ItemID: event.ItemID, Delta: event.Delta, Sequence: sequence}) + emit(proto.TypeDelta, delta) case "output_message": message := event.Message - if !start.ObserveMessages || message == nil || message.ID == "" || + if message == nil || message.ID == "" || (message.Status != "in_progress" && message.Status != "completed") || (message.Status == "completed") != (message.Text != nil) { failure = fmt.Errorf("claudesdk: invalid message observation") @@ -205,11 +203,9 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { metadata[proto.DoneMetaAgentSessionID] = id } if failure == nil { - content.Reset() - content.WriteString(result.Text) metadata[proto.DoneMetaAgentSessionID] = result.SessionID } - s.outcome = proto.DonePayload{Content: content.String(), Usage: usage, Metadata: metadata} + s.outcome = proto.DonePayload{Usage: usage, Metadata: metadata} // Publish the observed cancellation outcome before terminal delivery. if s.settlementErr != nil || !settlementReceived || !settlementConfirmed || !reusable || outputLost || s.process.Context().Err() != nil { s.owner.retire() diff --git a/apps/daemon/internal/agent/claudesdk/functions_test.go b/apps/daemon/internal/agent/claudesdk/functions_test.go index 05eedef18..133c4d23d 100644 --- a/apps/daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/daemon/internal/agent/claudesdk/functions_test.go @@ -124,7 +124,7 @@ func runFunctionHelper(request startRequest, mode string, scanner *bufio.Scanner results[value.CallID] = value } if mode == "functions-cancel" { - emit(bridgeEvent{Type: "delta", Delta: "waiting for confirmation"}) + emit(bridgeEvent{Type: "delta", ItemID: "message", Delta: "waiting for confirmation"}) if scanner.Scan() { emit(bridgeEvent{Type: "error", Code: "cancelled"}) } @@ -140,5 +140,5 @@ func runFunctionHelper(request startRequest, mode string, scanner *bufio.Scanner } emit(bridgeEvent{Type: "function_applied", CallID: id, DeliveryID: delivery}) } - emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "done"}) + emit(bridgeEvent{Type: "result", SessionID: request.Resume}) } diff --git a/apps/daemon/internal/agent/claudesdk/live_linux_test.go b/apps/daemon/internal/agent/claudesdk/live_linux_test.go index a1eafaac8..c02a24d33 100644 --- a/apps/daemon/internal/agent/claudesdk/live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/live_linux_test.go @@ -138,7 +138,7 @@ func TestLiveClaudeSDKTextResume(t *testing.T) { requestStart := len(requests) mu.Unlock() out := make(chan proto.Envelope, 64) - request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider, SystemPrompt: "Answer briefly and preserve the exact verification value in the conversation. Use no tools."} + request := proto.PromptRequestPayload{RunID: uuid.NewString(), Input: proto.TextInput(prompt), AgentSessionID: resume, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider, SystemPrompt: "Answer briefly and preserve the exact verification value in the conversation. Use no tools."} if success != nil { request.SystemPrompt = "Call lookup exactly once as requested, then report both result parts and any prior verification value. Never retry a failed tool." request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a synthetic verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} @@ -265,7 +265,7 @@ func TestLiveClaudeSDKTextResume(t *testing.T) { done = true var payload proto.DonePayload _ = json.Unmarshal(event.Payload, &payload) - proof.Text = payload.Content + proof.Text = messageText(proof.Events) proof.SessionID, _ = payload.Metadata[proto.DoneMetaAgentSessionID].(string) } } diff --git a/apps/daemon/internal/agent/claudesdk/messages_test.go b/apps/daemon/internal/agent/claudesdk/messages_test.go index 130bd5fb9..241b68176 100644 --- a/apps/daemon/internal/agent/claudesdk/messages_test.go +++ b/apps/daemon/internal/agent/claudesdk/messages_test.go @@ -15,12 +15,12 @@ import ( ) func TestMessageObservations(t *testing.T) { - for _, mode := range []string{"messages-success", "messages-partial", "messages-unrequested", "messages-missing-id", "messages-invalid-snapshot"} { + for _, mode := range []string{"messages-success", "messages-partial", "messages-missing-id", "messages-invalid-snapshot"} { t.Run(mode, func(t *testing.T) { root := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) config := Config{Node: os.Args[0], Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_SDK_HELPER=1", "SDK_HELPER_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}} - request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), RunID: "run", Input: proto.TextInput("hello"), ObserveMessages: mode != "messages-unrequested", AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} + request := proto.PromptRequestPayload{ModelProvider: fixtureProvider(), RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", Model: "fake-model", SystemPrompt: "instructions"} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out := make(chan proto.Envelope, 16) @@ -50,17 +50,17 @@ func TestMessageObservations(t *testing.T) { switch mode { case "messages-success": verifyMessageEvents(t, events, "partialfinal") - if done.Content != "partialfinal" || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" { + if done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" { t.Fatalf("bad Done: %+v", done) } case "messages-partial": - if done.Content != "partial" || done.Metadata[proto.DoneMetaAgentSessionID] != nil { + if done.Metadata[proto.DoneMetaAgentSessionID] != nil { t.Fatalf("bad partial Done: %+v", done) } if len(events) != 4 || events[0].Type != proto.TypeOutputMessage || events[1].Type != proto.TypeDelta { t.Fatalf("lost partial output: %+v", events) } - case "messages-unrequested", "messages-missing-id": + case "messages-missing-id": if len(events) != 2 { t.Fatalf("invalid bridge observation escaped: %+v", events) } @@ -77,13 +77,7 @@ func runMessageHelper(request startRequest, mode string, emit func(bridgeEvent)) emit(bridgeEvent{Type: "delta", Delta: "unidentified"}) return } - if mode != "messages-unrequested" && !request.ObserveMessages { - os.Exit(4) - } message("message-1", "in_progress", nil) - if mode == "messages-unrequested" { - return - } emit(bridgeEvent{Type: "delta", ItemID: "message-1", Delta: "partial"}) if mode == "messages-partial" { emit(bridgeEvent{Type: "error", Code: "execution_failed"}) @@ -100,7 +94,7 @@ func runMessageHelper(request startRequest, mode string, emit func(bridgeEvent)) emit(bridgeEvent{Type: "delta", ItemID: "message-2", Delta: "nal"}) text = "final" message("message-2", "completed", &text) - emit(bridgeEvent{Type: "result", Text: "partialfinal", SessionID: request.Resume}) + emit(bridgeEvent{Type: "result", SessionID: request.Resume}) } func verifyMessageEvents(t *testing.T, events []proto.Envelope, want string) { @@ -159,3 +153,15 @@ func verifyMessageEvents(t *testing.T, events []proto.Envelope, want string) { } } } + +// messageText joins the assistant message text a Turn's deltas carried. +func messageText(events []proto.Envelope) string { + var text strings.Builder + for _, event := range events { + var delta proto.DeltaPayload + if event.Type == proto.TypeDelta && event.DecodePayload(&delta) == nil { + text.WriteString(delta.Delta) + } + } + return text.String() +} diff --git a/apps/daemon/internal/agent/claudesdk/options.go b/apps/daemon/internal/agent/claudesdk/options.go index bcf23e384..880318ecd 100644 --- a/apps/daemon/internal/agent/claudesdk/options.go +++ b/apps/daemon/internal/agent/claudesdk/options.go @@ -33,7 +33,6 @@ type startRequest struct { SystemPrompt string `json:"system_prompt"` Cwd string `json:"cwd"` Resume string `json:"resume,omitempty"` - ObserveMessages bool `json:"observe_messages,omitempty"` Functions []proto.FunctionTool `json:"functions,omitempty"` MCPHTTPServers *[]mcpHTTPServer `json:"mcp_http_servers,omitempty"` Workspace *workspaceProfile `json:"workspace,omitempty"` @@ -97,7 +96,7 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR // an agent-host view takes from its Session rather than the request. func prepareOptions(req proto.PromptRequestPayload, mcp bool) (startRequest, []string, error) { skills := req.LocalEnvironment != nil && len(req.LocalEnvironment.Skills) != 0 - start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, ObserveMessages: req.ObserveMessages, Functions: req.FunctionTools} + start := startRequest{Type: "start", Resume: req.AgentSessionID, RequireHistory: req.RequireExistingNativeSession, Functions: req.FunctionTools} fail := func(reason string) (startRequest, []string, error) { return startRequest{}, nil, fmt.Errorf("claudesdk: %s", reason) } @@ -125,8 +124,8 @@ func prepareOptions(req proto.PromptRequestPayload, mcp bool) (startRequest, []s } if req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil { format := req.ExecutionControls.OutputFormat - if format.Type != "json_schema" || !req.ObserveMessages || !req.DisableSubagents || mcp || skills { - return fail("structured output requires the qualified message-observing single-agent function profile") + if format.Type != "json_schema" || !req.DisableSubagents || mcp || skills { + return fail("structured output requires the qualified single-agent function profile") } if err := proto.ValidateBinary64Schema(format.Schema); err != nil { return startRequest{}, nil, err diff --git a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go index 314aa5dbc..e387cfa17 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -103,15 +103,11 @@ func runPreparationHelper() { return } emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) - if request.ObserveMessages { - text := "completed" - emit(bridgeEvent{Type: "output_message", Message: &proto.OutputMessagePayload{ID: "native-message", Status: "completed", Text: &text}}) - } else { - emit(bridgeEvent{Type: "delta", Delta: "partial"}) - } + text := "completed" + emit(bridgeEvent{Type: "output_message", Message: &proto.OutputMessagePayload{ID: "native-message", Status: "completed", Text: &text}}) emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: request.Resume, Usage: json.RawMessage(usageFixture)}) emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) - emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "completed"}) + emit(bridgeEvent{Type: "result", SessionID: request.Resume}) _ = os.WriteFile(filepath.Join(state, "released"), nil, 0o600) } diff --git a/apps/daemon/internal/agent/claudesdk/preparation_test.go b/apps/daemon/internal/agent/claudesdk/preparation_test.go index 67fb78215..03e5b2557 100644 --- a/apps/daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/daemon/internal/agent/claudesdk/preparation_test.go @@ -88,7 +88,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } } } - if done.Content != "completed" || done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Usage.Raw["claude_sdk_result"] == nil { + if done.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || done.Usage.Raw["claude_sdk_result"] == nil { t.Fatal("prepared execution lost ordinary output or frozen resume", done) } if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { diff --git a/apps/daemon/internal/agent/claudesdk/restrictions_test.go b/apps/daemon/internal/agent/claudesdk/restrictions_test.go index 18055762a..caa50512f 100644 --- a/apps/daemon/internal/agent/claudesdk/restrictions_test.go +++ b/apps/daemon/internal/agent/claudesdk/restrictions_test.go @@ -41,7 +41,7 @@ func TestTextTurnAcceptsRestrictiveCapabilities(t *testing.T) { } if event.Type == proto.TypeDone { var payload proto.DonePayload - if err := event.DecodePayload(&payload); err != nil || payload.Content != "final" || payload.Metadata[proto.DoneMetaAgentSessionID] != "native-session" { + if err := event.DecodePayload(&payload); err != nil || payload.Metadata[proto.DoneMetaAgentSessionID] != "native-session" { t.Fatal(payload, err) } done = true diff --git a/apps/daemon/internal/agent/claudesdk/session.go b/apps/daemon/internal/agent/claudesdk/session.go index 7cfc8c59a..7bf1beb3d 100644 --- a/apps/daemon/internal/agent/claudesdk/session.go +++ b/apps/daemon/internal/agent/claudesdk/session.go @@ -46,7 +46,6 @@ type bridgeEvent struct { Type string `json:"type"` Delta string `json:"delta"` SessionID string `json:"session_id"` - Text string `json:"text"` Code string `json:"code"` ItemID string `json:"item_id"` Message *proto.OutputMessagePayload `json:"message"` diff --git a/apps/daemon/internal/agent/claudesdk/session_test.go b/apps/daemon/internal/agent/claudesdk/session_test.go index 0a85c3e6e..48cd21a66 100644 --- a/apps/daemon/internal/agent/claudesdk/session_test.go +++ b/apps/daemon/internal/agent/claudesdk/session_test.go @@ -42,12 +42,7 @@ func TestTextTurnCompletionAndFailures(t *testing.T) { case proto.TypeDelta: var payload proto.DeltaPayload _ = json.Unmarshal(event.Payload, &payload) - if payload.ItemID != "" { - t.Fatal("ordinary deltas acquired message identity") - } deltas += payload.Delta - case proto.TypeOutputMessage: - t.Fatal("ordinary requests acquired message observations") case proto.TypeError: failed = true case proto.TypeDone: @@ -55,7 +50,7 @@ func TestTextTurnCompletionAndFailures(t *testing.T) { var payload proto.DonePayload _ = json.Unmarshal(event.Payload, &payload) if mode == "success" { - if payload.Content != "final" || payload.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || deltas != "partial" { + if payload.Metadata[proto.DoneMetaAgentSessionID] != "native-session" || deltas != "partial" { t.Fatalf("bad completion: %+v, deltas %q", payload, deltas) } if _, err := os.Stat(filepath.Join(config.StateDir, "released")); err != nil { @@ -167,17 +162,17 @@ func runSDKHelper() { encode(bridgeEvent{Type: "error", Code: "execution_failed"}) return } - encode(bridgeEvent{Type: "delta", Delta: "partial"}) + encode(bridgeEvent{Type: "delta", ItemID: "message", Delta: "partial"}) id := request.Resume if mode == "wrong-resume" { id = "different-session" } - encode(bridgeEvent{Type: "result", Text: "final", SessionID: id}) + encode(bridgeEvent{Type: "result", SessionID: id}) if mode == "process-failed" { os.Exit(7) } if mode == "after-result" { - encode(bridgeEvent{Type: "delta", Delta: "too late"}) + encode(bridgeEvent{Type: "delta", ItemID: "message", Delta: "too late"}) return } time.Sleep(50 * time.Millisecond) diff --git a/apps/daemon/internal/agent/claudesdk/steering_test.go b/apps/daemon/internal/agent/claudesdk/steering_test.go index 523422115..ed3b12364 100644 --- a/apps/daemon/internal/agent/claudesdk/steering_test.go +++ b/apps/daemon/internal/agent/claudesdk/steering_test.go @@ -130,7 +130,7 @@ func TestSteeringReceiptsAndLifecycle(t *testing.T) { t.Fatalf("unexpected terminal failure=%v", failed) } if wantSuccess { - if measurements != 2 || done.Content != "final" || done.Metadata[proto.DoneMetaAgentSessionID] != "native" { + if measurements != 2 || done.Metadata[proto.DoneMetaAgentSessionID] != "native" { t.Fatalf("bad completion: %+v, measurements=%d", done, measurements) } snapshots, ok := done.Usage.Raw["claude_sdk_results"].([]any) @@ -170,7 +170,7 @@ func TestSteeringDoesNotSendBeforeReadiness(t *testing.T) { func runSteeringHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) - emit(bridgeEvent{Type: "delta", Delta: "ready"}) + emit(bridgeEvent{Type: "delta", ItemID: "message", Delta: "ready"}) if mode == "steering-blocked-write" { time.Sleep(time.Hour) return @@ -209,5 +209,5 @@ func runSteeringHelper(request startRequest, mode string, scanner *bufio.Scanner } emit(bridgeEvent{Type: "usage", SessionID: request.Resume, ResultID: id, Usage: json.RawMessage(`{"usage":{"input_tokens":7},"modelUsage":{"model":{"inputTokens":11}},"total_cost_usd":0.3}`)}) emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) - emit(bridgeEvent{Type: "result", SessionID: request.Resume, Text: "final"}) + emit(bridgeEvent{Type: "result", SessionID: request.Resume}) } diff --git a/apps/daemon/internal/agent/claudesdk/usage_test.go b/apps/daemon/internal/agent/claudesdk/usage_test.go index 82b845c05..94cd210bd 100644 --- a/apps/daemon/internal/agent/claudesdk/usage_test.go +++ b/apps/daemon/internal/agent/claudesdk/usage_test.go @@ -111,7 +111,7 @@ func runUsageHelper(request startRequest, mode string, encode func(bridgeEvent)) if mode == "changed-result" { id = "changed" } - encode(bridgeEvent{Type: "result", SessionID: id, Text: "final"}) + encode(bridgeEvent{Type: "result", SessionID: id}) } func verifyLiveUsageEvents(t *testing.T, events []proto.Envelope) { diff --git a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go index f9b109e90..2b6971175 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -29,7 +29,7 @@ case "$1" in IFS= read -r request printf '%s\n' '{"type":"executor_ready","protocol":3}' IFS= read -r request - printf '%s\n' '{"type":"result","turn_id":"run","session_id":"native","text":"completed"}' + printf '%s\n' '{"type":"result","turn_id":"run","session_id":"native"}' printf '%s\n' '{"type":"turn_settled","turn_id":"run","confirmed":true,"reusable":true,"reason":""}' ;; esac ` diff --git a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go index d4b9ab32b..778374292 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -89,7 +89,6 @@ func TestLiveClaudeWorkspaceTurns(t *testing.T) { out := make(chan proto.Envelope, 64) req := workspaceRequest() req.RunID, req.Input, req.AgentSessionID = uuid.NewString(), proto.TextInput(prompt), resume - req.ObserveMessages = true req.Model, req.SystemPrompt = "MiniMax-M3", "Follow the exact verification instructions using the requested native tools. Preserve conversation facts. No other files, network operations or background work." proof := evidence{RunID: req.RunID} running, err := startSingleTurn(ctx, config, req, out) @@ -197,8 +196,8 @@ func TestLiveClaudeWorkspaceTurns(t *testing.T) { liveWorkspaceCommands(t, second.RunID, second.Events, nil) final, err := os.ReadFile(artifact) if err != nil || string(final) != strings.TrimSpace(string(original))+"-resumed\n" || second.Failure != "" || - second.Done.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(second.Done.Content, nonce) || - !strings.Contains(second.Done.Content, strings.TrimSpace(string(original))) || first.BridgePID == second.BridgePID { + second.Done.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(messageText(second.Events), nonce) || + !strings.Contains(messageText(second.Events), strings.TrimSpace(string(original))) || first.BridgePID == second.BridgePID { t.Fatal("fresh-process native workspace/history continuation failed") } retained := config.StateDir + "-retained" diff --git a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go index ec627a49e..4dacb191c 100644 --- a/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -18,7 +18,6 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T t.Run(mode, func(t *testing.T) { config := preparationFixture(t, mode) req := preparationRequest() - req.ObserveMessages = true schema := `{"type":"object","properties":{"n":{"const":9007199254740992}}}` req.ExecutionControls = &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium", OutputFormat: &proto.OutputFormat{Type: "json_schema", Schema: json.RawMessage(schema)}} e, err := NewExecutorFactory(config)(t.Context(), req) diff --git a/apps/daemon/internal/agent/codex/cancellation_outcome.go b/apps/daemon/internal/agent/codex/cancellation_outcome.go index 61ed7ba6f..dfbdf1745 100644 --- a/apps/daemon/internal/agent/codex/cancellation_outcome.go +++ b/apps/daemon/internal/agent/codex/cancellation_outcome.go @@ -39,9 +39,6 @@ func (s *Session) CancellationOutcome() proto.DonePayload { outcome.Metadata[proto.DoneMetaAgentSessionID] = id outcome.Metadata[proto.DoneMetaAgentSessionType] = "codex_thread" } - s.finalTextMu.Lock() - outcome.Content = s.finalText - s.finalTextMu.Unlock() s.usageMu.Lock() if usage := s.latestUsage; usage != nil { outcome.Usage = s.usagePayload(*usage) diff --git a/apps/daemon/internal/agent/codex/declaration.go b/apps/daemon/internal/agent/codex/declaration.go index 440f255ab..b5d0c1684 100644 --- a/apps/daemon/internal/agent/codex/declaration.go +++ b/apps/daemon/internal/agent/codex/declaration.go @@ -16,7 +16,6 @@ var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{ Capabilities: proto.AgentKindCapabilities{ SubagentObservations: proto.CapabilitySupported, NativeSessionRecovery: proto.CapabilityUnsupported, - MessageItems: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, LocalEnvironment: proto.CapabilityUnsupported, WorkspaceReadPreparation: proto.CapabilityUnsupported, diff --git a/apps/daemon/internal/agent/codex/declaration_test.go b/apps/daemon/internal/agent/codex/declaration_test.go index af90218d3..6090695da 100644 --- a/apps/daemon/internal/agent/codex/declaration_test.go +++ b/apps/daemon/internal/agent/codex/declaration_test.go @@ -29,7 +29,7 @@ func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { // The declaration must retain the complete baseline capability descriptor. func TestDeclaredCapabilityBaseline(t *testing.T) { - expected := map[string]bool{"SubagentObservations": true, "MessageItems": true, "EnvironmentNone": true, "ProgrammaticToolCallingDisable": true, "WebSearchControl": true, "TextVerbosity": SupportsTextVerbosity, "MessageImages": true, "FunctionResultImages": true, "SubagentControl": true, "FunctionTools": true, "MCPHTTPTools": true, "MCPHTTPBearerAuth": true} + expected := map[string]bool{"SubagentObservations": true, "EnvironmentNone": true, "ProgrammaticToolCallingDisable": true, "WebSearchControl": true, "TextVerbosity": SupportsTextVerbosity, "MessageImages": true, "FunctionResultImages": true, "SubagentControl": true, "FunctionTools": true, "MCPHTTPTools": true, "MCPHTTPBearerAuth": true} value := reflect.ValueOf(Declaration.Info.Capabilities) for i := 0; i < value.NumField(); i++ { name := value.Type().Field(i).Name diff --git a/apps/daemon/internal/agent/codex/executor.go b/apps/daemon/internal/agent/codex/executor.go index 0748de54f..26f9a10e1 100644 --- a/apps/daemon/internal/agent/codex/executor.go +++ b/apps/daemon/internal/agent/codex/executor.go @@ -62,7 +62,7 @@ func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.Mess functions := &functionCalls{definitions: base.functions.definitions, names: base.functions.names, pending: map[string]*pendingFunction{}} turnCtx, cancel := context.WithCancel(base.cancelCtx) s := &Session{nativeHome: base.nativeHome, - functions: functions, observeMessages: base.observeMessages, observeSubagentIdentities: base.observeSubagentIdentities, + functions: functions, observeSubagentIdentities: base.observeSubagentIdentities, cfg: base.cfg, rpc: base.rpc, cancelCtx: turnCtx, cancelFn: cancel, waitDone: make(chan struct{}), outputDone: make(chan struct{}), bufs: NewItemBuffers(), resolvedModel: base.resolvedModel, runID: runID, out: out} diff --git a/apps/daemon/internal/agent/codex/executor_native_test.go b/apps/daemon/internal/agent/codex/executor_native_test.go index 405b2b066..ff76189ab 100644 --- a/apps/daemon/internal/agent/codex/executor_native_test.go +++ b/apps/daemon/internal/agent/codex/executor_native_test.go @@ -51,7 +51,7 @@ func TestExecutorNativeReuse(t *testing.T) { cfg.logger = slog.New(slog.DiscardHandler) req := proto.PromptRequestPayload{ AgentKind: "codex", AgentStateKey: "executor-native", - DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, + DisableExecutionEnvironment: true, DisableSubagents: true, Model: model, ModelProvider: &modelprovider.Provider{BaseURL: endpoint, Protocol: modelprovider.Responses, APIKey: strings.TrimSpace(string(key))}, FunctionTools: []proto.FunctionTool{{Name: "hold", Description: "Wait until the host supplies a result.", Parameters: json.RawMessage("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}")}}, @@ -103,10 +103,13 @@ func TestExecutorNativeReuse(t *testing.T) { default: } } + if event.Type == proto.TypeOutputMessage { + var message proto.OutputMessagePayload + if event.DecodePayload(&message) == nil && message.Text != nil { + text = *message.Text + } + } if event.Type == proto.TypeDone { - var result proto.DonePayload - _ = event.DecodePayload(&result) - text = result.Content terminal++ } } diff --git a/apps/daemon/internal/agent/codex/preparation.go b/apps/daemon/internal/agent/codex/preparation.go index 3a2d5651a..bbc8a7356 100644 --- a/apps/daemon/internal/agent/codex/preparation.go +++ b/apps/daemon/internal/agent/codex/preparation.go @@ -68,7 +68,6 @@ func newExecutor(parent context.Context, req proto.PromptRequestPayload, cfg ses s := &Session{ nativeHome: plan.home, functions: functions, - observeMessages: req.ObserveMessages, observeSubagentIdentities: req.ObserveSubagentIdentities && !req.DisableSubagents, cfg: cfg, rpc: rpc, diff --git a/apps/daemon/internal/agent/codex/resume_test.go b/apps/daemon/internal/agent/codex/resume_test.go index 698eb2cc7..81c068ab5 100644 --- a/apps/daemon/internal/agent/codex/resume_test.go +++ b/apps/daemon/internal/agent/codex/resume_test.go @@ -45,15 +45,14 @@ func TestFailedResumeDoesNotStartFresh(t *testing.T) { } } -func TestCancellationKeepsConsumedTerminalOutputAndUsage(t *testing.T) { +func TestCancellationKeepsConsumedUsageAndIdentity(t *testing.T) { out := make(chan proto.Envelope, 8) s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: sessionConfig{logger: log.With("component", "cancel-test")}} s.setThreadID("native") s.onTurnStarted(json.RawMessage(`{"threadId":"native","turn":{"id":"turn"}}`)) - s.appendFinalText("Already produced") s.onTurnCompleted(json.RawMessage(`{"threadId":"native","turn":{"id":"turn","status":"interrupted","usage":{"inputTokens":31,"outputTokens":7}}}`)) snapshot := s.CancellationOutcome() - if snapshot.Content != "Already produced" || snapshot.Usage.InputTokens != 31 || snapshot.Usage.OutputTokens != 7 || snapshot.Metadata[proto.DoneMetaAgentSessionID] != "native" { + if snapshot.Usage.InputTokens != 31 || snapshot.Usage.OutputTokens != 7 || snapshot.Metadata[proto.DoneMetaAgentSessionID] != "native" { t.Fatalf("incomplete terminal snapshot: %+v", snapshot) } } diff --git a/apps/daemon/internal/agent/codex/session.go b/apps/daemon/internal/agent/codex/session.go index a4488676b..2f6997c04 100644 --- a/apps/daemon/internal/agent/codex/session.go +++ b/apps/daemon/internal/agent/codex/session.go @@ -62,7 +62,6 @@ type Session struct { subagents *subagentObservations observeSubagentIdentities bool functions *functionCalls - observeMessages bool runID string cfg sessionConfig @@ -100,8 +99,7 @@ type Session struct { resumeUsageTotal *TurnUsage resolvedModel string - finalTextMu sync.Mutex - finalText string + errTextMu sync.Mutex lastErrText string outcome cancellationOutcomeState @@ -182,36 +180,28 @@ func (s *Session) onTurnCompleted(raw json.RawMessage) { } status := strings.ToLower(p.Turn.Status) - finalText := s.takeFinalText() errText := s.takeLastErrText() // Always log the turn outcome — operators need this when a prompt // "completes" with no agent message (e.g. codex bailed before the // model ran because the sandbox mode was misinterpreted as - // read-only) so the empty body in the upstream Done frame can be - // correlated with the turn status that produced it. + // read-only) so it can be correlated with the turn status that + // produced it. s.cfg.logger.Info("codex: turn/completed", "run_id", s.runID, "turn_id", p.Turn.ID, "status", p.Turn.Status, - "final_text_len", len(finalText), "buffered_err_text_len", len(errText), "raw_payload", string(raw)) if status == "failed" { // Body precedence on failure: - // 1. agent's final text (rare on hard failures but exists for - // partial completions that still surface a message) - // 2. codex's turn.error.message — this is where gateway / + // 1. codex's turn.error.message — this is where gateway / // provider errors land (e.g. an upstream gateway's "X-Sub-Module is // not allowed for this API key"). Without forwarding it - // the upstream connector reports "empty final output" and // operators can't see why a key was rejected. - // 3. buffered text from the "error" notification stream + // 2. buffered text from the "error" notification stream // (sandbox warnings, late stream packets) — last because // it's noisier than turn.error. - body := finalText - if turnErrMsg := turnErrorMessage(p.Turn.Error); turnErrMsg != "" { - body = appendOnNewline(body, turnErrMsg) - } + body := turnErrorMessage(p.Turn.Error) if errText != "" { body = appendOnNewline(body, errText) } @@ -222,7 +212,7 @@ func (s *Session) onTurnCompleted(raw json.RawMessage) { if status == "completed" { completedAt = nativeMilliseconds(p.Turn.CompletedAt) } - s.emitDoneAt(finalText, usage, completedAt) + s.emitDoneAt(usage, completedAt) s.finishAfterTerminal() } @@ -307,17 +297,17 @@ func (s *Session) onErrorNotif(raw json.RawMessage) { "run_id", s.runID, "thread_id", s.currentThreadID(), "message", p.Message) - s.finalTextMu.Lock() + s.errTextMu.Lock() s.lastErrText = p.Message - s.finalTextMu.Unlock() + s.errTextMu.Unlock() } // peekLastErrText reads lastErrText without consuming it, used by // loggers that want to record "we have a buffered upstream error" // without racing with the takeLastErrText path that emitDone uses. func (s *Session) peekLastErrText() string { - s.finalTextMu.Lock() - defer s.finalTextMu.Unlock() + s.errTextMu.Lock() + defer s.errTextMu.Unlock() return s.lastErrText } @@ -325,7 +315,7 @@ func (s *Session) peekLastErrText() string { // envelope emit helpers // --------------------------------------------------------------------------- -func (s *Session) emitDoneAt(content string, usage *TurnUsage, completedAt *int64) { +func (s *Session) emitDoneAt(usage *TurnUsage, completedAt *int64) { if !s.terminal.CompareAndSwap(false, true) { return } @@ -336,7 +326,7 @@ func (s *Session) emitDoneAt(content string, usage *TurnUsage, completedAt *int6 doneMeta[proto.DoneMetaAgentSessionID] = tid doneMeta[proto.DoneMetaAgentSessionType] = "codex_thread" } - payload := proto.DonePayload{Content: content, Metadata: doneMeta, SourceCompletedAtMS: completedAt} + payload := proto.DonePayload{Metadata: doneMeta, SourceCompletedAtMS: completedAt} if usage != nil { payload.Usage = s.usagePayload(*usage) } @@ -397,10 +387,7 @@ func (s *Session) emitTerminalFailure(message string, asError bool, failure prot doneMeta[proto.DoneMetaAgentSessionID] = tid doneMeta[proto.DoneMetaAgentSessionType] = "codex_thread" } - payload := proto.DonePayload{ - Content: message, - Metadata: doneMeta, - } + payload := proto.DonePayload{Metadata: doneMeta} payload = s.rememberOutcome(payload) env, err := proto.NewEnvelope(proto.TypeDone, s.runID, payload) if err != nil { @@ -446,27 +433,9 @@ func (s *Session) setThreadID(id string) { s.threadIDMu.Unlock() } -func (s *Session) appendFinalText(text string) { - s.finalTextMu.Lock() - if s.finalText != "" { - s.finalText = s.finalText + "\n\n" + text - } else { - s.finalText = text - } - s.finalTextMu.Unlock() -} - -func (s *Session) takeFinalText() string { - s.finalTextMu.Lock() - defer s.finalTextMu.Unlock() - t := s.finalText - s.finalText = "" - return t -} - func (s *Session) takeLastErrText() string { - s.finalTextMu.Lock() - defer s.finalTextMu.Unlock() + s.errTextMu.Lock() + defer s.errTextMu.Unlock() e := s.lastErrText s.lastErrText = "" return e diff --git a/apps/daemon/internal/agent/codex/session_items.go b/apps/daemon/internal/agent/codex/session_items.go index 3cb3db01d..b14cca2cb 100644 --- a/apps/daemon/internal/agent/codex/session_items.go +++ b/apps/daemon/internal/agent/codex/session_items.go @@ -14,7 +14,7 @@ import ( // {reasoning,agentMessage}/delta notifications, plus the final item // body on item/completed. We keep both: deltas drive incremental UI // (TypeDelta / TypeThinking), and completed-item bodies anchor the -// final text for the done event. +// message's completion text. type ItemBuffers struct { Reasoning map[string]string AgentText map[string]string @@ -69,18 +69,11 @@ func DispatchStartedItem(runID string, item ThreadItem) ([]proto.Envelope, error } // DispatchCompletedItem folds an item.completed payload into envelopes. -// Reasoning and agentMessage produce a final TypeThinking / TypeDelta -// (delta+sequence=0) so the buffer drained by upstream deltas can be -// flushed; tool-call variants produce an "after" envelope so the UI -// gets a stage transition. -// -// emitFinalDelta=true asks the dispatch to emit a synthetic full-text -// TypeDelta whose Sequence will be set by the caller using a session- -// level monotonic counter; this is only needed when no deltas were -// observed (item.completed arrived without any item/agentMessage/delta). +// Reasoning produces a final TypeThinking from its buffer; tool-call +// variants produce an "after" envelope so the UI gets a stage transition. // // Returns the agent text body for agentMessage items so the session can -// stamp it into DonePayload.Content. Empty string for everything else. +// complete the message with it. Empty string for everything else. func DispatchCompletedItem(runID string, item ThreadItem, bufs *ItemBuffers) (envelopes []proto.Envelope, agentText string, err error) { switch item.Type { case "reasoning": diff --git a/apps/daemon/internal/agent/codex/session_items_test.go b/apps/daemon/internal/agent/codex/session_items_test.go index 8ba3334b7..1e22d0dbe 100644 --- a/apps/daemon/internal/agent/codex/session_items_test.go +++ b/apps/daemon/internal/agent/codex/session_items_test.go @@ -122,7 +122,7 @@ func TestDispatchCompletedItem_AgentMessage_BufferIsFinalText(t *testing.T) { t.Fatalf("dispatch: %v", err) } if len(envs) != 0 { - t.Fatalf("agentMessage must not produce envelopes (final text emits via Done), got %+v", envs) + t.Fatalf("agentMessage must not produce envelopes (its completion carries the text), got %+v", envs) } if text != "Hello world" { // The buffered concatenation must win over item.Text fallback diff --git a/apps/daemon/internal/agent/codex/session_messages.go b/apps/daemon/internal/agent/codex/session_messages.go index 4e1061dc6..192267114 100644 --- a/apps/daemon/internal/agent/codex/session_messages.go +++ b/apps/daemon/internal/agent/codex/session_messages.go @@ -16,11 +16,7 @@ func (s *Session) onAgentDelta(raw json.RawMessage) { } _ = FoldDeltaIntoBuffer(s.bufs, "agent", p.ItemID, p.Delta) seq := s.deltaSeq.Add(1) - payload := proto.DeltaPayload{Delta: p.Delta, Sequence: seq} - if s.observeMessages { - payload.ItemID = p.ItemID - } - env, err := proto.NewEnvelope(proto.TypeDelta, s.runID, payload) + env, err := proto.NewEnvelope(proto.TypeDelta, s.runID, proto.DeltaPayload{ItemID: p.ItemID, Delta: p.Delta, Sequence: seq}) if err != nil { return } @@ -66,13 +62,10 @@ func (s *Session) onItemCompleted(raw json.RawMessage) { messageText = text } s.observeMessage(p.Item, "completed", &messageText) - if text != "" { - s.appendFinalText(text) - } } func (s *Session) observeMessage(item ThreadItem, status string, text *string) { - if !s.observeMessages || item.Type != "agentMessage" { + if item.Type != "agentMessage" { return } env, err := proto.NewEnvelope(proto.TypeOutputMessage, s.runID, proto.OutputMessagePayload{ID: item.ID, Status: status, Phase: item.Phase, Text: text}) diff --git a/apps/daemon/internal/agent/codex/session_messages_test.go b/apps/daemon/internal/agent/codex/session_messages_test.go index b17ed114e..424c46c36 100644 --- a/apps/daemon/internal/agent/codex/session_messages_test.go +++ b/apps/daemon/internal/agent/codex/session_messages_test.go @@ -8,65 +8,55 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" ) -func TestMessageObservationIsOptInAndKeepsNativeBoundaries(t *testing.T) { - for _, enabled := range []bool{false, true} { - t.Run(map[bool]string{false: "legacy", true: "observed"}[enabled], func(t *testing.T) { - out := make(chan proto.Envelope, 16) - s := &Session{runID: "run", observeMessages: enabled, out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} - s.setThreadID("thread") - s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) - s.onItemStarted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"commentary"}}`)) - s.onAgentDelta(json.RawMessage(`{"threadId":"thread","turnId":"turn","itemId":"a","delta":"first"}`)) - s.onItemStarted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"b","phase":"final_answer"}}`)) - s.onAgentDelta(json.RawMessage(`{"threadId":"thread","turnId":"turn","itemId":"b","delta":"second"}`)) - s.onItemCompleted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"commentary","text":"first complete"}}`)) - // b stays unfinished, as when the native request is cancelled before item/completed. - s.onItemCompleted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"c","phase":"final_answer","text":"without deltas"}}`)) - var deltas []proto.DeltaPayload - var messages []proto.OutputMessagePayload - for len(out) > 0 { - env := <-out - switch env.Type { - case proto.TypeDelta: - var p proto.DeltaPayload - if err := env.DecodePayload(&p); err != nil { - t.Fatal(err) - } - deltas = append(deltas, p) - case proto.TypeOutputMessage: - var p proto.OutputMessagePayload - if err := env.DecodePayload(&p); err != nil { - t.Fatal(err) - } - messages = append(messages, p) - default: - t.Fatalf("unexpected frame: %s", env.Type) - } - } - if len(deltas) != 2 || deltas[0].Delta != "first" || deltas[1].Delta != "second" || deltas[1].Sequence != 2 { - t.Fatalf("legacy text changed: %+v", deltas) - } - if !enabled { - if len(messages) != 0 || deltas[0].ItemID != "" || deltas[1].ItemID != "" { - t.Fatal("legacy request gained observations") - } - return - } - if deltas[0].ItemID != "a" || deltas[1].ItemID != "b" || len(messages) != 4 { - t.Fatalf("identity lost: %+v %+v", deltas, messages) - } - if messages[0].ID != "a" || messages[0].Phase != "commentary" || messages[0].Status != "in_progress" || messages[0].Text != nil { - t.Fatalf("start: %+v", messages[0]) - } - if messages[1].ID != "b" || messages[1].Phase != "final_answer" || messages[1].Status != "in_progress" { - t.Fatalf("second start: %+v", messages[1]) - } - if messages[2].ID != "a" || messages[2].Status != "completed" || messages[2].Text == nil || *messages[2].Text != "first complete" { - t.Fatalf("completion: %+v", messages[2]) - } - if messages[3].ID != "c" || messages[3].Text == nil || *messages[3].Text != "without deltas" { - t.Fatalf("non-streamed message lost: %+v", messages[3]) - } - }) +func TestMessageObservationKeepsNativeBoundaries(t *testing.T) { + out := make(chan proto.Envelope, 16) + s := &Session{runID: "run", out: out, cancelCtx: context.Background(), bufs: NewItemBuffers(), cfg: defaultSessionConfig()} + s.setThreadID("thread") + s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`)) + s.onItemStarted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"commentary"}}`)) + s.onAgentDelta(json.RawMessage(`{"threadId":"thread","turnId":"turn","itemId":"a","delta":"first"}`)) + s.onItemStarted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"b","phase":"final_answer"}}`)) + s.onAgentDelta(json.RawMessage(`{"threadId":"thread","turnId":"turn","itemId":"b","delta":"second"}`)) + s.onItemCompleted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"a","phase":"commentary","text":"first complete"}}`)) + // b stays unfinished, as when the native request is cancelled before item/completed. + s.onItemCompleted(json.RawMessage(`{"threadId":"thread","turnId":"turn","item":{"type":"agentMessage","id":"c","phase":"final_answer","text":"without deltas"}}`)) + var deltas []proto.DeltaPayload + var messages []proto.OutputMessagePayload + for len(out) > 0 { + env := <-out + switch env.Type { + case proto.TypeDelta: + var p proto.DeltaPayload + if err := env.DecodePayload(&p); err != nil { + t.Fatal(err) + } + deltas = append(deltas, p) + case proto.TypeOutputMessage: + var p proto.OutputMessagePayload + if err := env.DecodePayload(&p); err != nil { + t.Fatal(err) + } + messages = append(messages, p) + default: + t.Fatalf("unexpected frame: %s", env.Type) + } + } + if len(deltas) != 2 || deltas[0].Delta != "first" || deltas[1].Delta != "second" || deltas[1].Sequence != 2 { + t.Fatalf("text changed: %+v", deltas) + } + if deltas[0].ItemID != "a" || deltas[1].ItemID != "b" || len(messages) != 4 { + t.Fatalf("identity lost: %+v %+v", deltas, messages) + } + if messages[0].ID != "a" || messages[0].Phase != "commentary" || messages[0].Status != "in_progress" || messages[0].Text != nil { + t.Fatalf("start: %+v", messages[0]) + } + if messages[1].ID != "b" || messages[1].Phase != "final_answer" || messages[1].Status != "in_progress" { + t.Fatalf("second start: %+v", messages[1]) + } + if messages[2].ID != "a" || messages[2].Status != "completed" || messages[2].Text == nil || *messages[2].Text != "first complete" { + t.Fatalf("completion: %+v", messages[2]) + } + if messages[3].ID != "c" || messages[3].Text == nil || *messages[3].Text != "without deltas" { + t.Fatalf("non-streamed message lost: %+v", messages[3]) } } diff --git a/apps/daemon/internal/agent/codex/session_notifications_rpc_test.go b/apps/daemon/internal/agent/codex/session_notifications_rpc_test.go index f7b4d0f26..d6b616526 100644 --- a/apps/daemon/internal/agent/codex/session_notifications_rpc_test.go +++ b/apps/daemon/internal/agent/codex/session_notifications_rpc_test.go @@ -100,7 +100,17 @@ func TestRootRPCNotificationOrdering(t *testing.T) { <-barrier waitResult(result) var doneCount int + var answer string for env := range out { + if env.Type == proto.TypeOutputMessage { + var message proto.OutputMessagePayload + if err := env.DecodePayload(&message); err != nil { + t.Fatal(err) + } + if message.Text != nil { + answer = *message.Text + } + } if env.Type != proto.TypeDone { continue } @@ -113,7 +123,7 @@ func TestRootRPCNotificationOrdering(t *testing.T) { if resume { wantInput = 30 } - if done.Content != "root answer" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" || done.Usage.InputTokens != wantInput { + if answer != "root answer" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" || done.Usage.InputTokens != wantInput { t.Fatalf("reply ordering lost root output/usage: %+v", done) } } diff --git a/apps/daemon/internal/agent/codex/session_notifications_test.go b/apps/daemon/internal/agent/codex/session_notifications_test.go index 0ee2a9b94..5eb83a495 100644 --- a/apps/daemon/internal/agent/codex/session_notifications_test.go +++ b/apps/daemon/internal/agent/codex/session_notifications_test.go @@ -13,7 +13,7 @@ func TestRootNotificationIsolation(t *testing.T) { t.Run(map[bool]string{false: "child without thread started", true: "child thread started"}[childStarted], func(t *testing.T) { out := make(chan proto.Envelope, 64) s := &Session{runID: "run", out: out, cancelCtx: context.Background(), cfg: defaultSessionConfig(), - rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers(), observeMessages: true} + rpc: NewJSONRPCClient(JSONRPCConfig{}), bufs: NewItemBuffers()} s.registerHandlers() s.setThreadID("root") notify := func(method, params string) { t.Helper(); scopeNotification(t, s, method, params) } @@ -57,7 +57,7 @@ func TestRootNotificationIsolation(t *testing.T) { if s.terminal.Load() || s.currentThreadID() != "root" || len(out) != rootEvents { t.Fatalf("foreign notification changed Run ownership/output: terminal=%v thread=%q events=%d want=%d", s.terminal.Load(), s.currentThreadID(), len(out), rootEvents) } - if s.bufs.AgentText["shared"] != "root " || s.bufs.Reasoning["thought"] != "root thought" || s.peekLastErrText() != "root retry" || s.takeFinalText() != "" { + if s.bufs.AgentText["shared"] != "root " || s.bufs.Reasoning["thought"] != "root thought" || s.peekLastErrText() != "root retry" { t.Fatal("foreign notification changed root buffers") } if s.latestUsage == nil || s.latestUsage.InputTokens != 20 || s.latestUsage.OutputTokens != 5 || s.usageTurnID != "root-turn" { @@ -76,19 +76,29 @@ func TestRootNotificationIsolation(t *testing.T) { notify("turn/completed", `{"threadId":"root","turn":{"id":"root-turn","status":"completed"}}`) notify("turn/completed", `{"threadId":"root","turn":{"id":"root-turn","status":"completed","usage":{"inputTokens":999}}}`) var doneCount int + var answer string for env := range out { - if env.Type == proto.TypeDone { + switch env.Type { + case proto.TypeOutputMessage: + var message proto.OutputMessagePayload + if err := env.DecodePayload(&message); err != nil { + t.Fatal(err) + } + if message.Text != nil { + answer = *message.Text + } + case proto.TypeDone: doneCount++ var done proto.DonePayload if err := env.DecodePayload(&done); err != nil { t.Fatal(err) } - if done.Content != "root result" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" || done.Usage.Tokens == nil || done.Usage.Tokens.TotalTokens != 25 { + if done.Metadata[proto.DoneMetaAgentSessionID] != "root" || done.Usage.Tokens == nil || done.Usage.Tokens.TotalTokens != 25 { t.Fatalf("incorrect root completion: %+v", done) } } } - if doneCount != 1 || s.deltaSeq.Load() != 2 || s.thinkingSeq.Load() != 1 { + if doneCount != 1 || answer != "root result" || s.deltaSeq.Load() != 2 || s.thinkingSeq.Load() != 1 { t.Fatalf("root output duplicated or polluted: done=%d delta=%d thinking=%d", doneCount, s.deltaSeq.Load(), s.thinkingSeq.Load()) } }) @@ -159,7 +169,7 @@ func TestRootNativeErrorNotification(t *testing.T) { if err := env.DecodePayload(&done); err != nil { t.Fatal(err) } - if done.Content != "native provider failure" || done.Metadata[proto.DoneMetaAgentSessionID] != "root" { + if done.Metadata[proto.DoneMetaAgentSessionID] != "root" { t.Fatalf("native failure lost: %+v", done) } } diff --git a/apps/daemon/internal/agent/codex/session_usage_test.go b/apps/daemon/internal/agent/codex/session_usage_test.go index bdaeec6b0..e37b0d535 100644 --- a/apps/daemon/internal/agent/codex/session_usage_test.go +++ b/apps/daemon/internal/agent/codex/session_usage_test.go @@ -40,7 +40,6 @@ func TestNativeTokenUsageAcrossRuns(t *testing.T) { t.Fatal("new turn retained previous turn usage") } s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"next","tokenUsage":{"total":{"inputTokens":1700,"outputTokens":175},"last":{"inputTokens":200,"outputTokens":25}}}`)) - s.finalText = "done" s.onTurnCompleted(json.RawMessage(`{"threadId":"thread","turn":{"id":"next","status":"completed"}}`)) var usage proto.UsagePayload var done proto.DonePayload @@ -56,7 +55,7 @@ func TestNativeTokenUsageAcrossRuns(t *testing.T) { } } } - if usage.InputTokens != 200 || usage.OutputTokens != 25 || done.Usage.InputTokens != 200 || done.Content != "done" { + if usage.InputTokens != 200 || usage.OutputTokens != 25 || done.Usage.InputTokens != 200 { t.Fatalf("terminal usage=%+v done=%+v", usage, done) } } diff --git a/apps/daemon/internal/agent/codex/subagent_observations_test.go b/apps/daemon/internal/agent/codex/subagent_observations_test.go index 1056176a8..edd3fbc4d 100644 --- a/apps/daemon/internal/agent/codex/subagent_observations_test.go +++ b/apps/daemon/internal/agent/codex/subagent_observations_test.go @@ -192,7 +192,7 @@ func TestSubagentFactsPrecedeFrozenRootTerminal(t *testing.T) { func TestSubagentRootFirstRetainsChildUntilTerminal(t *testing.T) { s, f, out := observationSession(t, "inProgress") - s.emitDoneAt("frozen", nil, nil) + s.emitDoneAt(nil, nil) for i := 0; i < 4; i++ { select { case e := <-out: diff --git a/apps/daemon/internal/agent/mcode/declaration.go b/apps/daemon/internal/agent/mcode/declaration.go index b4a0762af..3ed63b4e6 100644 --- a/apps/daemon/internal/agent/mcode/declaration.go +++ b/apps/daemon/internal/agent/mcode/declaration.go @@ -14,7 +14,6 @@ import ( var Declaration = agent.Declaration{Info: proto.SupportedAgentKind{Kind: "mcode", Capabilities: proto.AgentKindCapabilities{ SubagentObservations: proto.CapabilityUnsupported, NativeSessionRecovery: proto.CapabilityUnsupported, - MessageItems: proto.CapabilityUnsupported, EnvironmentNone: proto.CapabilityUnsupported, LocalEnvironment: proto.CapabilityUnsupported, WorkspaceReadPreparation: proto.CapabilityUnsupported, diff --git a/apps/daemon/internal/agent/mcode/events.go b/apps/daemon/internal/agent/mcode/events.go index a41789fd1..4f16b22b4 100644 --- a/apps/daemon/internal/agent/mcode/events.go +++ b/apps/daemon/internal/agent/mcode/events.go @@ -42,10 +42,22 @@ func (s *Session) handle(frame rpcFrame) error { if event.Update.Content.Type != "text" { return fmt.Errorf("mcode: unsupported response content") } + if event.Update.MessageID == "" { + return fmt.Errorf("mcode: ACP message chunk has no message identity") + } + if event.Update.MessageID != s.message { + // Core keeps a completed message, so a resumed one would lose its text. + if s.completedMessages[event.Update.MessageID] { + return fmt.Errorf("mcode: ACP message resumed after it ended") + } + s.completeMessage() + s.message = event.Update.MessageID + s.emit(proto.TypeOutputMessage, proto.OutputMessagePayload{ID: s.message, Status: "in_progress"}) + } text := event.Update.Content.Text - s.content.WriteString(text) + s.messageText.WriteString(text) s.sequence++ - s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: text, Sequence: s.sequence}) + s.emit(proto.TypeDelta, proto.DeltaPayload{ItemID: s.message, Delta: text, Sequence: s.sequence}) case "agent_thought_chunk": s.sequence++ s.emit(proto.TypeThinking, proto.ThinkingPayload{Text: event.Update.Content.Text, Sequence: s.sequence}) @@ -55,6 +67,19 @@ func (s *Session) handle(frame rpcFrame) error { return nil } +// completeMessage completes the open assistant message with its text. ACP +// marks no message end: the next message or the end of the Turn ends it. +func (s *Session) completeMessage() { + if s.message == "" { + return + } + text := s.messageText.String() + s.emit(proto.TypeOutputMessage, proto.OutputMessagePayload{ID: s.message, Status: "completed", Text: &text}) + s.completedMessages[s.message] = true + s.message = "" + s.messageText.Reset() +} + func (s *Session) emitTool(update toolUpdate) error { if update.ID == "" || s.completedTools[update.ID] { return nil diff --git a/apps/daemon/internal/agent/mcode/executor_native_test.go b/apps/daemon/internal/agent/mcode/executor_native_test.go index 0daeb06e2..4cb515ed9 100644 --- a/apps/daemon/internal/agent/mcode/executor_native_test.go +++ b/apps/daemon/internal/agent/mcode/executor_native_test.go @@ -73,12 +73,15 @@ func TestNativeMCodeExecutorReuse(t *testing.T) { switch event.Type { case proto.TypeError: t.Fatal("native Turn reported an error") - case proto.TypeDone: - var done proto.DonePayload - if json.Unmarshal(event.Payload, &done) != nil { - t.Fatal("invalid completion") + case proto.TypeOutputMessage: + var message proto.OutputMessagePayload + if json.Unmarshal(event.Payload, &message) != nil { + t.Fatal("invalid message") + } + if message.Text != nil { + content += *message.Text } - content = done.Content + case proto.TypeDone: doneCount++ } } diff --git a/apps/daemon/internal/agent/mcode/executor_test.go b/apps/daemon/internal/agent/mcode/executor_test.go index 59a1f5d60..210cbffa9 100644 --- a/apps/daemon/internal/agent/mcode/executor_test.go +++ b/apps/daemon/internal/agent/mcode/executor_test.go @@ -160,7 +160,7 @@ func TestExecutorCancellationRetiresOwnerAndLateCancelCannotRetarget(t *testing. } select { case event := <-out: - if event.Type != proto.TypeDelta { + if event.Type != proto.TypeOutputMessage { t.Fatal(event.Type) } case <-time.After(3 * time.Second): @@ -187,7 +187,7 @@ func TestExecutorCancellationRetiresOwnerAndLateCancelCannotRetarget(t *testing. default: t.Fatal("nonreusable settlement preceded native process exit") } - if got := second.CancellationOutcome(); got.Content != "ready" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + if got := second.CancellationOutcome(); got.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { t.Fatal("cancel lost settled outcome", got) } for range out { diff --git a/apps/daemon/internal/agent/mcode/executor_turn.go b/apps/daemon/internal/agent/mcode/executor_turn.go index dc24cd17a..0f6bcb4cc 100644 --- a/apps/daemon/internal/agent/mcode/executor_turn.go +++ b/apps/daemon/internal/agent/mcode/executor_turn.go @@ -45,7 +45,7 @@ func (s *Session) runExecutorTurn(prompt string) { err = fmt.Errorf("mcode: native input outcome is unknown") } metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode", proto.DoneMetaAgentSessionID: s.sessionID} - s.outcome = proto.DonePayload{Content: s.content.String(), Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS} + s.outcome = proto.DonePayload{Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS} outcome := s.outcome s.mu.Unlock() if !reusable { diff --git a/apps/daemon/internal/agent/mcode/mcp_observations_test.go b/apps/daemon/internal/agent/mcode/mcp_observations_test.go index dba30bf7a..4a80eb133 100644 --- a/apps/daemon/internal/agent/mcode/mcp_observations_test.go +++ b/apps/daemon/internal/agent/mcode/mcp_observations_test.go @@ -18,7 +18,7 @@ func mcpObservationSession(t *testing.T) (*Session, chan proto.Envelope) { s := &Session{ctx: context.Background(), outputContext: context.Background(), opts: launchOptions{DataDir: t.TempDir()}, req: proto.PromptRequestPayload{RunID: "run", LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{environmentMCPFixture()}}}, - out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}, active: true, sessionID: "native-session"} + out: out, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}, completedMessages: map[string]bool{}, active: true, sessionID: "native-session"} resolveTestBindings(s) if err := writeMCPRegistry(s.opts.DataDir, mcpRegistryEntry("proof.server", "proof_server_2", "read.status", "read_status_2")); err != nil { t.Fatal(err) @@ -209,3 +209,14 @@ func usePublicMCP(s *Session) { func resolveTestBindings(s *Session) { s.opts.bindings, _ = agent.ResolveMCPBindings(s.req) } + +func TestResumedMessageFailsTheTurn(t *testing.T) { + s, _ := mcpObservationSession(t) + for i, id := range []string{"a", "b", "a"} { + raw := `{"sessionId":"native-session","update":{"sessionUpdate":"agent_message_chunk","messageId":"` + id + `","content":{"type":"text","text":"x"}}}` + err := s.handle(rpcFrame{Method: "session/update", Params: json.RawMessage(raw)}) + if (i == 2) != (err != nil) { + t.Fatalf("chunk %d of message %s: %v", i, id, err) + } + } +} diff --git a/apps/daemon/internal/agent/mcode/native_test.go b/apps/daemon/internal/agent/mcode/native_test.go index eb43e7cf3..ad21c9f94 100644 --- a/apps/daemon/internal/agent/mcode/native_test.go +++ b/apps/daemon/internal/agent/mcode/native_test.go @@ -50,16 +50,19 @@ func TestNativeMCodeACP(t *testing.T) { t.Fatal(err) } var done proto.DonePayload + var message proto.OutputMessagePayload for event := range out { - if event.Type == proto.TypeError { + switch event.Type { + case proto.TypeError: t.Fatalf("native ACP failure: %s", event.Payload) - } - if event.Type == proto.TypeDone { + case proto.TypeOutputMessage: + _ = json.Unmarshal(event.Payload, &message) + case proto.TypeDone: _ = json.Unmarshal(event.Payload, &done) } } - if done.Content != "OAC-MCODE-OK" { - t.Fatalf("native output=%q", done.Content) + if message.ID == "" || message.Status != "completed" || message.Text == nil || *message.Text != "OAC-MCODE-OK" { + t.Fatalf("native output=%+v", message) } if _, ok := done.Metadata[proto.DoneMetaAgentSessionID].(string); !ok { t.Fatal("native session ID missing") diff --git a/apps/daemon/internal/agent/mcode/protocol.go b/apps/daemon/internal/agent/mcode/protocol.go index 116d8c024..710a845f5 100644 --- a/apps/daemon/internal/agent/mcode/protocol.go +++ b/apps/daemon/internal/agent/mcode/protocol.go @@ -42,8 +42,9 @@ type toolUpdate struct { type sessionUpdate struct { SessionID string `json:"sessionId"` Update struct { - Kind string `json:"sessionUpdate"` - Content struct { + Kind string `json:"sessionUpdate"` + MessageID string `json:"messageId"` + Content struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` diff --git a/apps/daemon/internal/agent/mcode/session.go b/apps/daemon/internal/agent/mcode/session.go index 69bfebca1..eb8230dad 100644 --- a/apps/daemon/internal/agent/mcode/session.go +++ b/apps/daemon/internal/agent/mcode/session.go @@ -42,7 +42,9 @@ type Session struct { steeringTurn string sequence uint64 active bool - content strings.Builder + message string + messageText strings.Builder + completedMessages map[string]bool tools map[string]toolUpdate completedTools map[string]bool previousNativeTurns map[string]bool @@ -70,7 +72,7 @@ func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOpti } func newTurnSession(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, c *connection, out chan<- proto.Envelope) *Session { - return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} + return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), tools: map[string]toolUpdate{}, completedTools: map[string]bool{}, completedMessages: map[string]bool{}} } func (s *Session) prepareNative() error { @@ -149,6 +151,7 @@ func (s *Session) executePrompt(prompt string) error { if result.StopReason != "end_turn" { return fmt.Errorf("mcode: prompt stopped (%s)", result.StopReason) } + s.completeMessage() return nil } diff --git a/apps/daemon/internal/agent/mcode/session_test.go b/apps/daemon/internal/agent/mcode/session_test.go index 40d3b443d..4e9172b1f 100644 --- a/apps/daemon/internal/agent/mcode/session_test.go +++ b/apps/daemon/internal/agent/mcode/session_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -95,17 +96,27 @@ func TestSessionStreamsCurrentTurnAndResumes(t *testing.T) { for _, resume := range []bool{false, true} { t.Run(map[bool]string{false: "new", true: "resume"}[resume], func(t *testing.T) { _, out := helperSession(t, "happy", resume) - var content string + // Each ACP message is one identified message: its deltas, then its + // completion snapshot when the next message or the Turn ends it. + var messages []string tools := map[string]int{} doneCount := 0 for event := range out { switch event.Type { case proto.TypeError: t.Fatalf("error: %s", event.Payload) + case proto.TypeOutputMessage: + var p proto.OutputMessagePayload + _ = json.Unmarshal(event.Payload, &p) + text := "" + if p.Text != nil { + text = *p.Text + } + messages = append(messages, p.ID+" "+p.Status+" "+text) case proto.TypeDelta: var p proto.DeltaPayload _ = json.Unmarshal(event.Payload, &p) - content += p.Delta + messages = append(messages, p.ItemID+" delta "+p.Delta) case proto.TypeToolCall: var p proto.ToolCallPayload _ = json.Unmarshal(event.Payload, &p) @@ -114,7 +125,7 @@ func TestSessionStreamsCurrentTurnAndResumes(t *testing.T) { doneCount++ var p proto.DonePayload _ = json.Unmarshal(event.Payload, &p) - if p.Content != "Hello world" || p.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + if p.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { t.Fatalf("done = %#v", p) } if p.Usage.InputTokens != 0 || p.Usage.OutputTokens != 0 || p.Usage.CostUSD != 0 { @@ -122,8 +133,9 @@ func TestSessionStreamsCurrentTurnAndResumes(t *testing.T) { } } } - if content != "Hello world" || doneCount != 1 || tools["before"] != 1 || tools["after"] != 1 { - t.Fatalf("content=%q done=%d tools=%v", content, doneCount, tools) + want := []string{"m1 in_progress ", "m1 delta Hello ", "m1 delta world", "m1 completed Hello world", "m2 in_progress ", "m2 delta !", "m2 completed !"} + if !slices.Equal(messages, want) || doneCount != 1 || tools["before"] != 1 || tools["after"] != 1 { + t.Fatalf("messages=%q done=%d tools=%v", messages, doneCount, tools) } }) } @@ -264,7 +276,7 @@ func TestMCodeProcess(t *testing.T) { } } if frame.Method == "session/load" { - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "OLD HISTORY"}}) + update("agent_message_chunk", map[string]any{"messageId": "m1", "content": map[string]string{"type": "text", "text": "OLD HISTORY"}}) } model := "m:custom_provider%3Aoac:fixture:v:" if scenario == "unknown-model" { @@ -304,10 +316,10 @@ func TestMCodeProcess(t *testing.T) { os.Exit(0) } if input.Prompt[0]["text"] == "wait" { - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) + update("agent_message_chunk", map[string]any{"messageId": "m1", "content": map[string]string{"type": "text", "text": "ready"}}) continue } - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": input.Prompt[0]["text"]}}) + update("agent_message_chunk", map[string]any{"messageId": "m1", "content": map[string]string{"type": "text", "text": input.Prompt[0]["text"]}}) update("tool_call", map[string]any{"toolCallId": "repeated-call", "name": "mcp__oac_workspace__workspace_bash", "status": "in_progress", "rawInput": map[string]any{"command": "true"}}) update("tool_call_update", map[string]any{"toolCallId": "repeated-call", "status": "completed"}) raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) @@ -315,7 +327,7 @@ func TestMCodeProcess(t *testing.T) { // These frames precede the next control barrier on the wire and // must never become the following Turn's output. for range 100 { - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "OLD"}}) + update("agent_message_chunk", map[string]any{"messageId": "m1", "content": map[string]string{"type": "text", "text": "OLD"}}) } continue } @@ -326,12 +338,12 @@ func TestMCodeProcess(t *testing.T) { } if scenario == "steering" || scenario == "steer-rejected" || scenario == "steer-lost" || scenario == "cancel-wait" { promptID = frame.ID - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) + update("agent_message_chunk", map[string]any{"messageId": "m1", "content": map[string]string{"type": "text", "text": "ready"}}) continue } if scenario == "many-frames" || scenario == "prepared" { for range 100 { - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "x"}}) + update("agent_message_chunk", map[string]any{"messageId": "m1", "content": map[string]string{"type": "text", "text": "x"}}) } result = map[string]string{"stopReason": "end_turn"} break @@ -345,12 +357,13 @@ func TestMCodeProcess(t *testing.T) { send(map[string]any{"jsonrpc": "2.0", "id": "permission-1", "method": "session/request_permission", "params": map[string]any{"sessionId": "native-1", "toolCall": map[string]any{"toolCallId": "tool-1", "name": "Bash", "title": "Run fixture", "rawInput": map[string]any{"command": "echo fixture"}}, "options": []map[string]string{{"optionId": "once", "kind": "allow_once"}, {"optionId": "deny", "kind": "reject_once"}}}}) continue } - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "Hello "}}) - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "world"}}) + update("agent_message_chunk", map[string]any{"messageId": "m1", "content": map[string]string{"type": "text", "text": "Hello "}}) + update("agent_message_chunk", map[string]any{"messageId": "m1", "content": map[string]string{"type": "text", "text": "world"}}) update("tool_call", map[string]any{"toolCallId": "tool-1", "name": "mcp__oac_workspace__workspace_bash", "status": "in_progress", "rawInput": map[string]any{"command": "cat fixture.txt"}}) for range 2 { update("tool_call_update", map[string]any{"toolCallId": "tool-1", "status": "completed", "rawOutput": "fixture"}) } + update("agent_message_chunk", map[string]any{"messageId": "m2", "content": map[string]string{"type": "text", "text": "!"}}) update("usage_update", map[string]any{"used": 2000, "size": 64000, "cost": map[string]any{"amount": 2, "currency": "USD"}}) result = map[string]string{"stopReason": "end_turn"} case "mcode/session/delegation/stop": @@ -373,7 +386,7 @@ func TestMCodeProcess(t *testing.T) { } raw, _ := json.Marshal(map[string]string{"turnId": "native-turn", "mode": "steered"}) send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) - update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "-steered"}}) + update("agent_message_chunk", map[string]any{"messageId": "m2", "content": map[string]string{"type": "text", "text": "-steered"}}) raw, _ = json.Marshal(map[string]string{"stopReason": "end_turn"}) send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) continue diff --git a/apps/daemon/internal/agent/mcode/steering_test.go b/apps/daemon/internal/agent/mcode/steering_test.go index 1ecdb5af1..5ece6d280 100644 --- a/apps/daemon/internal/agent/mcode/steering_test.go +++ b/apps/daemon/internal/agent/mcode/steering_test.go @@ -19,7 +19,7 @@ func TestNativeSteeringReceipt(t *testing.T) { defer cancel() select { case event := <-out: - if event.Type != proto.TypeDelta { + if event.Type != proto.TypeOutputMessage { t.Fatalf("first event %s", event.Type) } case <-ctx.Done(): @@ -67,16 +67,20 @@ func TestNativeSteeringReceipt(t *testing.T) { func TestTerminalFollowsAllNativeFrames(t *testing.T) { _, out := helperSession(t, "many-frames", false) - count := 0 + count, completed := 0, 0 for e := range out { switch e.Type { case proto.TypeDelta: count++ + case proto.TypeOutputMessage: + var message proto.OutputMessagePayload + _ = json.Unmarshal(e.Payload, &message) + if message.Text != nil { + completed = len(*message.Text) + } case proto.TypeDone: - var done proto.DonePayload - _ = json.Unmarshal(e.Payload, &done) - if count != 100 || len(done.Content) != 100 { - t.Fatalf("terminal overtook frames: %d/%d", count, len(done.Content)) + if count != 100 || completed != 100 { + t.Fatalf("terminal overtook frames: %d/%d", count, completed) } case proto.TypeError: t.Fatalf("unexpected error %s", e.Payload) diff --git a/apps/daemon/internal/agent/registry_test.go b/apps/daemon/internal/agent/registry_test.go index 40f9b66ef..82f228169 100644 --- a/apps/daemon/internal/agent/registry_test.go +++ b/apps/daemon/internal/agent/registry_test.go @@ -55,7 +55,7 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { Available: false, Version: "missing", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - MessageItems: proto.CapabilitySupported, + FunctionTools: proto.CapabilitySupported, }), }, harnessconfig.Configuration{}) reg.RegisterKind(proto.SupportedAgentKind{ @@ -63,7 +63,7 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { Available: true, Version: "1.2.3", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{ - MessageItems: proto.CapabilitySupported, + FunctionTools: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, }), }, harnessconfig.Configuration{}) @@ -75,10 +75,10 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { if got[0].Kind != "fake_alpha" || got[1].Kind != "fake_beta" { t.Fatalf("SupportedAgentKinds sort = %#v, want fake_alpha then fake_beta", got) } - if !got[0].Available || got[0].Version != "1.2.3" || !got[0].Capabilities.MessageItems.IsSupported() || !got[0].Capabilities.EnvironmentNone.IsSupported() { + if !got[0].Available || got[0].Version != "1.2.3" || !got[0].Capabilities.FunctionTools.IsSupported() || !got[0].Capabilities.EnvironmentNone.IsSupported() { t.Fatalf("fake_alpha descriptor not preserved: %#v", got[0]) } - if got[1].Available || got[1].Version != "missing" || !got[1].Capabilities.MessageItems.IsSupported() { + if got[1].Available || got[1].Version != "missing" || !got[1].Capabilities.FunctionTools.IsSupported() { t.Fatalf("fake_beta descriptor not preserved: %#v", got[1]) } } diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index c6b21f4ca..5ff2562b2 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -152,9 +152,9 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, } k := newTicket(t) s := sb.session(h, cfg, env, configuration) - done, _ := s.turn(t, "qualify", prompt, k) - if !strings.Contains(done.Content, "VALUE="+value) || !strings.Contains(done.Content, fmt.Sprintf("EXIT=%d", exit)) { - t.Errorf("the answer %q does not report VALUE=%s EXIT=%d", done.Content, value, exit) + done, answer, _ := s.turn(t, "qualify", prompt, k) + if !strings.Contains(answer, "VALUE="+value) || !strings.Contains(answer, fmt.Sprintf("EXIT=%d", exit)) { + t.Errorf("the answer %q does not report VALUE=%s EXIT=%d", answer, value, exit) } if got := sb.read(t, cfg, workspace+"/"+name); strings.TrimRight(got, "\n") != content { t.Errorf("%s holds %q, want %q", name, got, content) @@ -167,27 +167,27 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, t.Fatal("the first Turn reported no native session to resume") } s.configuration.AgentSessionID = native - done, calls := s.turn(t, "resumed-function", k.prompt("Call the lookup_ticket function"), k) + done, answer, calls := s.turn(t, "resumed-function", k.prompt("Call the lookup_ticket function"), k) if resumed, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string); resumed != native { t.Errorf("the resumed Turn reported the native session %q, want %q", resumed, native) } - k.check(t, done, calls) + k.check(t, answer, calls) } if caps.ToolSearch.IsSupported() { deferred := lookupTicket deferred.DeferLoading = true configuration.ToolSearch, configuration.FunctionTools = true, []proto.FunctionTool{deferred} search := sb.session(h, cfg, env, configuration) - done, calls := search.turn(t, "tool-search", k.prompt("Search your tools for the function that looks up support tickets"), k) - k.check(t, done, calls) + _, answer, calls := search.turn(t, "tool-search", k.prompt("Search your tools for the function that looks up support tickets"), k) + k.check(t, answer, calls) } if caps.EnvironmentNone.IsSupported() { none := configuration none.LocalEnvironment, none.DisableExecutionEnvironment, none.FunctionTools, none.ToolSearch = nil, true, nil, false s := sb.session(h, cfg, agenthost.Environment{}, none) - done, _ := s.turn(t, "environment-none", "What is 17 times 23? Answer with exactly one line: PRODUCT=", k) - if !strings.Contains(done.Content, "PRODUCT=391") { - t.Errorf("the answer %q does not report PRODUCT=391", done.Content) + _, answer, _ := s.turn(t, "environment-none", "What is 17 times 23? Answer with exactly one line: PRODUCT=", k) + if !strings.Contains(answer, "PRODUCT=391") { + t.Errorf("the answer %q does not report PRODUCT=391", answer) } s.checkCwd(t, agent.ViewPrivateRoot+"/"+agent.ViewHomeName+"/"+agent.ViewWorkName) } @@ -197,9 +197,9 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, stdio.FunctionTools, stdio.ToolSearch = nil, false s := sb.session(h, cfg, env, stdio) s.mcp = []proto.EnvironmentMCP{{InstallationRoot: workspace, Server: agentplugin.MCPServer{Name: "qualify", Type: "stdio", Command: "python3", Args: []string{"-c", mcpServer, code}}}} - done, _ := s.turn(t, "stdio-mcp", "Call the reveal_code tool of the qualify MCP server once.\nAnswer with exactly one line: CODE=", k) - if !strings.Contains(done.Content, "CODE="+code) { - t.Errorf("the answer %q does not report CODE=%s", done.Content, code) + _, answer, _ := s.turn(t, "stdio-mcp", "Call the reveal_code tool of the qualify MCP server once.\nAnswer with exactly one line: CODE=", k) + if !strings.Contains(answer, "CODE="+code) { + t.Errorf("the answer %q does not report CODE=%s", answer, code) } } } @@ -257,13 +257,13 @@ func (k ticket) prompt(find string) string { // check checks that the Turn called lookup_ticket for the ticket and // answered with both codes. -func (k ticket) check(t *testing.T, done proto.DonePayload, calls []proto.FunctionCallPayload) { +func (k ticket) check(t *testing.T, answer string, calls []proto.FunctionCallPayload) { t.Helper() if len(calls) == 0 || calls[0].Name != lookupTicket.Name || !strings.Contains(string(calls[0].Arguments), k.number) { t.Errorf("the Turn made %d function calls, want the first to call %s for ticket %s", len(calls), lookupTicket.Name, k.number) } - if !strings.Contains(done.Content, "FIRST="+k.first) || !strings.Contains(done.Content, "SECOND="+k.second) { - t.Errorf("the answer %q does not report FIRST=%s SECOND=%s", done.Content, k.first, k.second) + if !strings.Contains(answer, "FIRST="+k.first) || !strings.Contains(answer, "SECOND="+k.second) { + t.Errorf("the answer %q does not report FIRST=%s SECOND=%s", answer, k.first, k.second) } } @@ -288,10 +288,11 @@ func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, env agenthos return s } -// turn prepares an Executor of the Session, runs prompt as its Turn run, -// answers each function call with k's result, and retires the Executor with -// the Router's Shutdown. It returns the Turn's Done and its function calls. -func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePayload, []proto.FunctionCallPayload) { +// turn binds the Session's assignment, prepares an Executor of the Session, +// runs prompt as its Turn run, answers each function call with k's result, +// and retires the Executor with the Router's Shutdown. It returns the Turn's Done, its answer and its +// function calls. +func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePayload, string, []proto.FunctionCallPayload) { t.Helper() out := make(sender, 256) reg := s.h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { @@ -304,21 +305,27 @@ func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePa if err != nil { t.Fatal(err) } + ref := proto.AssignmentRef{SessionID: s.id, AssignmentID: s.binding.AssignmentID.String(), Epoch: s.binding.AssignmentEpoch} + handle(t, router, ref, proto.TypeAssignmentBind, "bind-"+run, proto.AssignmentBindPayload{EnvironmentID: s.configuration.EnvironmentID()}) + var bound proto.AssignmentStatusPayload + if err := out.next(t, "bind-"+run, time.After(turnLimit)).DecodePayload(&bound); err != nil || bound.State != proto.AssignmentBound { + t.Fatalf("assignment_bind: %+v %v", bound, err) + } prepare := "prepare-" + run - handle(t, router, proto.TypeExecutionPrepare, prepare, proto.ExecutionPreparePayload{SessionID: s.id, Configuration: s.configuration}) + handle(t, router, ref, proto.TypeExecutionPrepare, prepare, proto.ExecutionPreparePayload{SessionID: s.id, Configuration: s.configuration}) ready := out.status(t, prepare) if ready.State != "ready" { t.Fatalf("the preparation is %s (%s), want ready; the log above says why", ready.State, ready.ErrorCode) } - handle(t, router, proto.TypeExecutionStart, prepare, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, + handle(t, router, ref, proto.TypeExecutionStart, prepare, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: run, Input: proto.TextInput(prompt)}) - done, calls := out.collect(t, router, run, k) + done, answer, calls := out.collect(t, router, ref, run, k) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if err := router.Shutdown(ctx); err != nil { t.Errorf("Shutdown: %v", err) } - return done, calls + return done, answer, calls } // withMCP wraps reg so that each request's Environment carries mcp. @@ -365,12 +372,14 @@ func (s *session) checkCwd(t *testing.T, cwd string) { t.Logf("%s names %s", found, want) } -func handle(t *testing.T, router *dispatch.Router, typ, id string, payload any) { +// handle sends router a frame of ref's work. +func handle(t *testing.T, router *dispatch.Router, ref proto.AssignmentRef, typ, id string, payload any) { t.Helper() e, err := proto.NewEnvelope(typ, id, payload) if err != nil { t.Fatal(err) } + e.Assignment = ref if err := router.Handle(context.Background(), e); err != nil { t.Fatalf("%s: %v", typ, err) } @@ -416,12 +425,41 @@ func (s sender) status(t *testing.T, id string) proto.PreparationStatusPayload { // collect reads the Turn's envelopes until its Done. It answers each function // call with k's result through router and checks that dispatch applied it. -func (s sender) collect(t *testing.T, router *dispatch.Router, run string, k ticket) (proto.DonePayload, []proto.FunctionCallPayload) { +// It checks that every delta names a started assistant message and every +// started message completes, and returns the completed messages' text as the +// answer. +func (s sender) collect(t *testing.T, router *dispatch.Router, ref proto.AssignmentRef, run string, k ticket) (proto.DonePayload, string, []proto.FunctionCallPayload) { t.Helper() deadline := time.After(turnLimit) var calls []proto.FunctionCallPayload + var answer strings.Builder + open := map[string]bool{} for { switch e := s.next(t, run, deadline); e.Type { + case proto.TypeOutputMessage: + var m proto.OutputMessagePayload + if err := e.DecodePayload(&m); err != nil { + t.Fatal(err) + } + if m.Status == "in_progress" { + open[m.ID] = true + continue + } + if !open[m.ID] || m.Text == nil { + t.Errorf("message %q completed without a start or text: %s", m.ID, e.Payload) + continue + } + delete(open, m.ID) + t.Logf("message %s: %s", m.ID, *m.Text) + answer.WriteString(*m.Text) + case proto.TypeDelta: + var d proto.DeltaPayload + if err := e.DecodePayload(&d); err != nil { + t.Fatal(err) + } + if !open[d.ItemID] { + t.Errorf("a delta names no started message: %s", e.Payload) + } case proto.TypeError: t.Errorf("Turn error: %s", e.Payload) case proto.TypeFunctionCall: @@ -431,7 +469,7 @@ func (s sender) collect(t *testing.T, router *dispatch.Router, run string, k tic } t.Logf("function call: %s %s", call.Name, call.Arguments) calls = append(calls, call) - handle(t, router, proto.TypeFunctionResult, run, proto.FunctionResultPayload{DeliveryID: "result-" + call.CallID, CallID: call.CallID, Success: true, Content: k.result()}) + handle(t, router, ref, proto.TypeFunctionResult, run, proto.FunctionResultPayload{DeliveryID: "result-" + call.CallID, CallID: call.CallID, Success: true, Content: k.result()}) case proto.TypeInteractionDecisionAck: var ack proto.InteractionDecisionAckPayload if err := e.DecodePayload(&ack); err != nil || !ack.Applied { @@ -442,8 +480,10 @@ func (s sender) collect(t *testing.T, router *dispatch.Router, run string, k tic if err := e.DecodePayload(&d); err != nil { t.Fatal(err) } - t.Logf("answer: %s", d.Content) - return d, calls + if len(open) != 0 { + t.Errorf("the Turn ended with incomplete messages: %v", open) + } + return d, answer.String(), calls } } } diff --git a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go index ad0bb7948..3feac68e6 100644 --- a/apps/daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/daemon/internal/cli/claude_sdk_live_linux_test.go @@ -64,6 +64,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { } type execution struct { Outcome proto.DonePayload `json:"outcome"` + Text string `json:"text"` Events []proto.Envelope `json:"events"` FunctionCalls int `json:"function_calls"` AppliedResults int `json:"applied_results"` @@ -90,7 +91,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 120*time.Second) defer cancel() id := uuid.NewString() - request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentStateKey: prototest.StateKey, AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider} + request := proto.PromptRequestPayload{AgentKind: "claude_sdk", AgentStateKey: prototest.StateKey, AgentSessionID: resume, DisableExecutionEnvironment: true, DisableSubagents: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, Model: "MiniMax-M3", ModelProvider: provider} if callFunction { request.FunctionTools = []proto.FunctionTool{{Name: "lookup", Description: "Return a verification value.", Parameters: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}},"required":["id"],"additionalProperties":false}`)}} } @@ -169,6 +170,11 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { cancelAck = true } case proto.TypeDelta: + var delta proto.DeltaPayload + if err := event.DecodePayload(&delta); err != nil { + t.Fatal(err) + } + proof.Text += delta.Delta if cancelOnText && !proof.Cancelled { proof.Cancelled = true handle(proto.TypePromptCancel, proto.PromptCancelPayload{DeliveryID: "cancel"}) @@ -184,14 +190,14 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { } } } - if proof.Outcome.Content == "" || proof.Cancelled != cancelOnText { + if proof.Text == "" || proof.Cancelled != cancelOnText { t.Fatal("missing registered text/cancellation outcome") } return proof } first := run(1, "Call lookup exactly once with id 42 as a string. Reply with its exact returned verification value.", "", true, false) id, _ := first.Outcome.Metadata[proto.DoneMetaAgentSessionID].(string) - if id == "" || !strings.Contains(first.Outcome.Content, nonce) || first.FunctionCalls != 1 || first.AppliedResults != 1 { + if id == "" || !strings.Contains(first.Text, nonce) || first.FunctionCalls != 1 || first.AppliedResults != 1 { t.Fatal("registered function flow failed") } second := run(2, "First repeat the verification value from the lookup result, then write two hundred numbered sentences about trees. Use no tools.", id, false, true) @@ -199,7 +205,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { t.Fatal("registered cancellation lost native identity") } third := run(3, "Return only the exact registered-function verification value from the earlier lookup result. Ignore the prior tree request.", id, false, false) - if third.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(third.Outcome.Content, nonce) { + if third.Outcome.Metadata[proto.DoneMetaAgentSessionID] != id || !strings.Contains(third.Text, nonce) { t.Fatal("registered cold continuation lost identity or history") } data, _ := json.MarshalIndent(map[string]any{"scope": "SDK-only readiness and production registration -> daemon router -> pinned SDK/native -> real MiniMax; function receipt, cancellation and cold continuation; public API admission remains separate", "descriptor": discovery[0].runtime.Info, "entrypoint": entrypoint, "verification_value": nonce, "executions": []execution{first, second, third}}, "", " ") diff --git a/apps/daemon/internal/dispatch/cancellation_test.go b/apps/daemon/internal/dispatch/cancellation_test.go index 23346ced7..61f0cd449 100644 --- a/apps/daemon/internal/dispatch/cancellation_test.go +++ b/apps/daemon/internal/dispatch/cancellation_test.go @@ -46,7 +46,7 @@ func TestCompletionWaitsForNativeWriterRelease(t *testing.T) { sess := &cancelReceiptSession{entered: make(chan struct{}), release: make(chan struct{})} registerCancelReceiptKind(h, sess) startRun(t, h.router, h.sender, "codex", "release") - sess.out <- mustEnv(t, proto.TypeDone, "release", proto.DonePayload{Content: "Finished"}) + sess.out <- mustEnv(t, proto.TypeDone, "release", proto.DonePayload{}) <-sess.entered if len(h.sender.typesFor("release")) != 0 { t.Fatal("completion acknowledged before native writer was released") @@ -59,7 +59,7 @@ func TestCompletionWaitsForNativeWriterRelease(t *testing.T) { } func TestCancellationReceiptFollowsAdapterOutcome(t *testing.T) { - observed := proto.DonePayload{Content: "partial output", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-cancelled"}} + observed := proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-cancelled"}} for _, test := range []struct { name string outcome proto.DonePayload diff --git a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go index 253c548e4..1518d3fba 100644 --- a/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go +++ b/apps/daemon/internal/dispatch/executor_cancel_receipt_test.go @@ -99,7 +99,7 @@ func (t *receiptCancelTurn) AwaitSettlement(ctx context.Context) (agent.TurnSett } } func (t *receiptCancelTurn) CancellationOutcome() proto.DonePayload { - return proto.DonePayload{Content: "observed", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} + return proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} } func (t *receiptCancelTurn) SubmitFunctionResult(context.Context, proto.FunctionResultPayload) error { return agent.ErrUnknownFunctionCall diff --git a/apps/daemon/internal/dispatch/executor_handoff_test.go b/apps/daemon/internal/dispatch/executor_handoff_test.go index 918c27bc0..004334b55 100644 --- a/apps/daemon/internal/dispatch/executor_handoff_test.go +++ b/apps/daemon/internal/dispatch/executor_handoff_test.go @@ -67,7 +67,7 @@ func (t *terminalHandoffTurn) finish() { } func (t *terminalHandoffTurn) Cancel(context.Context) error { t.finish(); return nil } func (t *terminalHandoffTurn) CancellationOutcome() proto.DonePayload { - return proto.DonePayload{Content: t.id, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native"}} + return proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native"}} } func (t *terminalHandoffTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { select { diff --git a/apps/daemon/internal/dispatch/executor_test.go b/apps/daemon/internal/dispatch/executor_test.go index 3a84bac60..6b97a6740 100644 --- a/apps/daemon/internal/dispatch/executor_test.go +++ b/apps/daemon/internal/dispatch/executor_test.go @@ -56,7 +56,7 @@ func (t *reusableTurn) finish() { } func (t *reusableTurn) Cancel(context.Context) error { t.cancels.Add(1); t.finish(); return nil } func (t *reusableTurn) CancellationOutcome() proto.DonePayload { - return proto.DonePayload{Content: t.id, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} + return proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} } func (t *reusableTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { select { diff --git a/apps/daemon/internal/dispatch/functions_native_test.go b/apps/daemon/internal/dispatch/functions_native_test.go index 8bae728de..d1a1a69f6 100644 --- a/apps/daemon/internal/dispatch/functions_native_test.go +++ b/apps/daemon/internal/dispatch/functions_native_test.go @@ -113,6 +113,8 @@ func TestNativeFunctionBridge(t *testing.T) { sender := make(nativeFunctionSender, 256) ctx, cancel := context.WithTimeout(t.Context(), 60*time.Second) defer cancel() + // answer is the text of the last completed assistant message. + var answer string await := func(kind string) proto.Envelope { t.Helper() for { @@ -121,6 +123,10 @@ func TestNativeFunctionBridge(t *testing.T) { if env.Type == proto.TypeError { t.Fatalf("native error: %s", env.Payload) } + var message proto.OutputMessagePayload + if env.Type == proto.TypeOutputMessage && env.DecodePayload(&message) == nil && message.Text != nil { + answer = *message.Text + } if env.Type == kind { return env } @@ -162,6 +168,7 @@ func TestNativeFunctionBridge(t *testing.T) { } }() run := fmt.Sprintf("run-%d", index) + answer = "" assign(t, router, session, "") request := noEnvironmentPreparation(session, proto.PromptRequestPayload{AgentKind: "codex", AgentSessionID: nativeID, FunctionTools: []proto.FunctionTool{{Name: "lookup_ticket", Description: "Read a synthetic ticket", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string"}},"required":["ticket"],"additionalProperties":false}`)}}}) @@ -210,7 +217,7 @@ func TestNativeFunctionBridge(t *testing.T) { var output proto.DonePayload _ = done.DecodePayload(&output) id, _ := output.Metadata[proto.DoneMetaAgentSessionID].(string) - if output.Content != "FUNCTION-OK" || id == "" || (nativeID != "" && id != nativeID) { + if answer != "FUNCTION-OK" || id == "" || (nativeID != "" && id != nativeID) { t.Fatal(output) } nativeID = id diff --git a/apps/daemon/internal/dispatch/preparation_cancel_test.go b/apps/daemon/internal/dispatch/preparation_cancel_test.go index 0502727e6..a5fede803 100644 --- a/apps/daemon/internal/dispatch/preparation_cancel_test.go +++ b/apps/daemon/internal/dispatch/preparation_cancel_test.go @@ -78,7 +78,7 @@ func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { cancelEntered := make(chan struct{}) cleanupEntered, cleanupReturn := make(chan struct{}), make(chan struct{}) p := &cancellationPreparation{controlledPreparation: &controlledPreparation{closed: make(chan struct{})}, outcome: proto.DonePayload{ - Content: "observed", Usage: proto.Usage{Tokens: &proto.TokenUsage{InputTokens: 7, OutputTokens: 3, TotalTokens: 10}}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "observed-native"}, + Usage: proto.Usage{Tokens: &proto.TokenUsage{InputTokens: 7, OutputTokens: 3, TotalTokens: 10}}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "observed-native"}, }} var session *fakeSession p.start = func(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (fixtureSession, error) { diff --git a/apps/daemon/internal/dispatch/preparation_test.go b/apps/daemon/internal/dispatch/preparation_test.go index 081ebd227..fa31d3bd6 100644 --- a/apps/daemon/internal/dispatch/preparation_test.go +++ b/apps/daemon/internal/dispatch/preparation_test.go @@ -250,7 +250,7 @@ func TestPreparationSingleTransferAndReleaseDoesNotCancelRun(t *testing.T) { t.Fatal("transferred preparation closed") default: } - session.out <- mustEnv(t, proto.TypeDone, "real-run", proto.DonePayload{Content: "complete"}) + session.out <- mustEnv(t, proto.TypeDone, "real-run", proto.DonePayload{}) deadline := time.Now().Add(time.Second) for r.ActiveRuns() != 0 && time.Now().Before(deadline) { time.Sleep(time.Millisecond) diff --git a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go index 18a0acbbe..d75a96dcd 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -101,7 +101,7 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("mutation receipt did not block") } - session.out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{Content: "complete"}) + session.out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{}) waitFor(t, func() bool { return r.SteeringClosedForTest("run") }, "release admission closure") switch operation { case "function": @@ -237,7 +237,7 @@ func TestPreparedHandoffEarlyDonePublishesAfterStarted(t *testing.T) { p := &controlledPreparation{closed: make(chan struct{})} p.start = func(ctx context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (fixtureSession, error) { session.out = out - out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{Content: "complete"}) + out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{}) close(emitted) select { case <-allowReturn: diff --git a/apps/daemon/internal/dispatch/prepared_handoff_test.go b/apps/daemon/internal/dispatch/prepared_handoff_test.go index efb9ccb3d..89e40d832 100644 --- a/apps/daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/daemon/internal/dispatch/prepared_handoff_test.go @@ -46,7 +46,7 @@ func TestPreparedHandoffDrainsBurstBeforeStartReturns(t *testing.T) { assertPreparedDeltaOrder(t, sender, "run", preparedBurstFrames) close(allowReturn) waitPreparationStatus(t, sender, "request", "started", "") - session.out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{Content: "complete"}) + session.out <- mustEnv(t, proto.TypeDone, "run", proto.DonePayload{}) waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "prepared burst completion") if session.cancels() != 1 { t.Fatalf("Session release calls = %d, want 1", session.cancels()) diff --git a/apps/daemon/internal/dispatch/receipt_order_test.go b/apps/daemon/internal/dispatch/receipt_order_test.go index 4ade9ce34..c54312c64 100644 --- a/apps/daemon/internal/dispatch/receipt_order_test.go +++ b/apps/daemon/internal/dispatch/receipt_order_test.go @@ -78,7 +78,7 @@ func TestDurableCompletionWaitsForSteeringReceiptSend(t *testing.T) { input := proto.PromptSteerPayload{InputID: "input-1", Input: proto.TextInput("original")} handle(proto.TypePromptSteer, input) <-sender.entered - session.out <- mustEnv(t, proto.TypeDone, "ordered", proto.DonePayload{Content: "finished"}) + session.out <- mustEnv(t, proto.TypeDone, "ordered", proto.DonePayload{}) waitFor(t, func() bool { return router.SteeringClosedForTest("ordered") }, "closed steering admission") // Native settlement precedes the receipt join; TestPreparedHandoffReleaseWaitsForMutationReceipt asserts that order. if len(sender.snapshot()) != base { diff --git a/apps/daemon/internal/dispatch/router_test.go b/apps/daemon/internal/dispatch/router_test.go index 966c6d7b9..1b997a908 100644 --- a/apps/daemon/internal/dispatch/router_test.go +++ b/apps/daemon/internal/dispatch/router_test.go @@ -215,7 +215,7 @@ func TestExecutionStartRunsInputAndForwardsOutput(t *testing.T) { // Session emits a delta + done; both should reach the sender. sess.out <- mustEnv(t, proto.TypeDelta, "run_1", proto.DeltaPayload{Delta: "hello", Sequence: 1}) - sess.out <- mustEnv(t, proto.TypeDone, "run_1", proto.DonePayload{Content: "hello"}) + sess.out <- mustEnv(t, proto.TypeDone, "run_1", proto.DonePayload{}) waitForTypes(t, h.sender, "run_1", []string{proto.TypeDelta, proto.TypeDone}) diff --git a/apps/daemon/testdata/onboarding/main.go b/apps/daemon/testdata/onboarding/main.go index 1edfede2a..93a2acf69 100644 --- a/apps/daemon/testdata/onboarding/main.go +++ b/apps/daemon/testdata/onboarding/main.go @@ -85,7 +85,8 @@ func (e *executor) StartTurn(ctx context.Context, run string, input proto.Messag e.mu.Unlock() } e.active = s - s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: "ready", Sequence: 1}) + s.emit(proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "answer", Status: "in_progress"}) + s.emit(proto.TypeDelta, proto.DeltaPayload{ItemID: "answer", Delta: "ready", Sequence: 1}) return s, nil } @@ -125,7 +126,7 @@ func (s *session) Cancel(context.Context) error { return nil } func (s *session) CancellationOutcome() proto.DonePayload { - return proto.DonePayload{Content: "cancelled", Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}} + return proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}} } func (s *session) SteerWithReceipt(_ context.Context, p proto.PromptSteerPayload, written func()) error { text, err := p.Input.TextOnly() @@ -138,8 +139,10 @@ func (s *session) SteerWithReceipt(_ context.Context, p proto.PromptSteerPayload return agent.ErrSteeringInactive } written() - s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: text, Sequence: 2}) - s.emit(proto.TypeDone, proto.DonePayload{Content: "ready" + text, Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}}) + s.emit(proto.TypeDelta, proto.DeltaPayload{ItemID: "answer", Delta: text, Sequence: 2}) + answer := "ready" + text + s.emit(proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "answer", Status: "completed", Text: &answer}) + s.emit(proto.TypeDone, proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}}) s.closed = true close(s.out) s.release() diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index 914b8b90e..3b79c74eb 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -68,7 +68,7 @@ For example, the Codex adapter keeps its app-server and thread, the Claude adapt | `Turn.Cancel`, `CancellationOutcome`, `AwaitSettlement` | Real implementation | Cancel the exact Turn, keep observed results and confirm settlement independently of cancellation requests | | `Turn.SteerWithReceipt` | Real implementation | Distinguish a complete write from the native application receipt; keep retry identity | | `Turn.SubmitFunctionResult` | Real implementation or Unsupported | Match native call and result identity and acknowledge application | -| Neutral messages, images, MCP, structured output and Subagent observations | Explicit capability decisions | Keep each operation's protocol semantics; reject unsupported input before submission | +| Images, MCP, structured output and Subagent observations | Explicit capability decisions | Keep each operation's protocol semantics; reject unsupported input before submission | Each adapter's `contracts.go` asserts at compile time that it implements `agent.Executor` and `agent.Turn`. Do not embed a default implementation that makes a new method appear implemented. The common completeness check follows the authored Harness catalog and rejects any other exported interface in `agent`. @@ -112,7 +112,7 @@ A Session owns one reusable Executor in its connected Runtime; a Turn owns one i - An error means settlement is unconfirmed and frees neither ownership nor capacity. Caller deadlines stop the wait, not the tracked cleanup. Retry the same cleanup target serially; a failed cleanup blocks replacement and keeps its resource slot. - `Executor.Close` confirms resource retirement independently of the Turn outcome: an immutable Turn error must not prevent closing the native transport once its work and output have stopped. - Include owned background work in settlement and keep the exact native cleanup target after a failure. Native termination belongs to the adapter; a bulk cleanup acknowledgement alone does not establish quiescence. -- Every Turn implements `CancellationOutcome`. The snapshot keeps observed native identity, Usage and output and remains readable after cancellation. Missing evidence stays unset; an empty `DonePayload` means nothing has been observed, not that cancellation succeeded or is unsupported. Reading the snapshot does not wait for settlement. +- Every Turn implements `CancellationOutcome`. The snapshot keeps observed native identity and Usage and remains readable after cancellation. Missing evidence stays unset; an empty `DonePayload` means nothing has been observed, not that cancellation succeeded or is unsupported. Reading the snapshot does not wait for settlement. - `Turn.Cancel` requests cancellation; output closure signals teardown. Turn settlement still requires `AwaitSettlement` and any required `Executor.Close`; neither a successful cancellation request nor its snapshot replaces those waits. **What the Runtime does around a Turn.** One output consumer starts before native Start, drains the bounded 64-frame channel and keeps the terminal observation until Start publication, Turn settlement and admitted operation receipts finish. Natural completion never calls Cancel. Input and function admission close before settlement; operations already admitted hold their barrier through native receipts and outbound acknowledgement. The Runtime sends cancellation to the Turn before waiting on that barrier, because a written input may need a native interrupt to produce its receipt. It joins native settlement, any required confirmed Executor close, output drain and all admitted operations before an applied acknowledgement or reuse, and only then forwards Done or an applied cancellation receipt. A failed Close can report failure while keeping the same Run and outstanding operations for retry; a closed caller wait cannot manufacture an applied input receipt. The Runtime commits native continuity and releases the old Run's admission before publishing Done, since the receiver may start another Turn at once; a late terminal-send failure belongs to the old Run and cannot invalidate a successor that already owns the Executor. Connection shutdown owns transport-loss cleanup. The settlement wait is ten seconds and the receipt send budget five seconds; a timeout is not proof of quiescence. @@ -121,6 +121,8 @@ A Session owns one reusable Executor in its connected Runtime; a Turn owns one i Use [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/agentdaemon/proto) for neutral requests, events and receipts. Each Turn emits only its own events with its Run ID, in order, and one terminal outcome. Native IDs and usage are observed, never invented; a missing measurement is unknown, not zero. +Every assistant message carries its native message ID. Emit `output_message` `in_progress` with that ID, each text fragment as a `delta` naming it in `item_id`, then `output_message` `completed` with the message's full text ([message order](../../docs/runtime-protocol.md#message-families)). A native protocol without a message end, such as ACP, completes a message when the next one starts or the Turn ends. `Done` and `CancellationOutcome` carry no answer text; a message left open by cancellation or failure stays open. + Initial input and steering use ordered `proto.MessageInput`. Keep user-message and content order. Text-only adapters reject images through `TextOnly()` instead of dropping them; image adapters translate each part natively and acknowledge an active batch only after all its messages are applied. A successful transport write is distinct from confirmed native application. Resume only the exact history bound to the Session; missing, ambiguous or foreign history fails before new model input. Device identity is not native session ownership. ### Required and extension operations diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 8993ef856..3d650435d 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "添加 Harness" source: contracts/agents-api/harness-onboarding.md -source_hash: f8fa825332c02a25340707485afe084d3d6a9cabf398975314589f299679b010 +source_hash: c8ffdd603108bace70451b09b123eb250e3a957358dc2d8c6b5af9601c044148 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -70,7 +70,7 @@ Environment 提供执行资源。受管 E2B、Docker 和 microsandbox 机器以 | `Turn.Cancel`、`CancellationOutcome`、`AwaitSettlement` | 真实实现 | 取消精确的 Turn,保留已观察结果,并独立于取消请求确认结算 | | `Turn.SteerWithReceipt` | 真实实现 | 区分完整写入与原生应用回执;保留重试身份 | | `Turn.SubmitFunctionResult` | 真实实现或 Unsupported | 匹配原生调用和结果身份,并确认应用 | -| 中立消息、图像、MCP、结构化输出和 Subagent 观察 | 明确作出能力决策 | 保持每项操作的协议语义;在提交前拒绝不受支持的输入 | +| 图像、MCP、结构化输出和 Subagent 观察 | 明确作出能力决策 | 保持每项操作的协议语义;在提交前拒绝不受支持的输入 | 每个适配器的 `contracts.go` 在编译时断言其实现了 `agent.Executor` 和 `agent.Turn`。不要嵌入会让新方法看起来已经实现的默认实现。通用完整性检查遵循已编写的 Harness 目录,并拒绝 `agent` 中的任何其他导出接口。 @@ -114,7 +114,7 @@ Session 在其已连接的 Runtime 中拥有一个可复用的 Executor;Turn - 错误表示结算尚未确认,既不释放所有权,也不释放容量。调用方截止时间只会停止等待,不会停止受跟踪的清理。必须串行重试同一个清理目标;清理失败会阻止替换并保留其资源槽位。 - `Executor.Close` 独立于 Turn 结果确认资源退役:不可变的 Turn 错误不得阻止在其工作和输出已经停止后关闭原生传输层。 - 结算必须包含所属的后台工作,并在失败后保留精确的原生清理目标。原生终止由适配器负责;仅有批量清理确认并不能证明已达到静默状态。 -- 每个 Turn 都实现 `CancellationOutcome`。快照保留已观察到的原生身份、Usage 和输出,并在取消后仍可读取。缺失的证据保持未设置;空的 `DonePayload` 表示未观察到任何内容,而不是表示取消成功或不受支持。读取快照不会等待结算。 +- 每个 Turn 都实现 `CancellationOutcome`。快照保留已观察到的原生身份和 Usage,并在取消后仍可读取。缺失的证据保持未设置;空的 `DonePayload` 表示未观察到任何内容,而不是表示取消成功或不受支持。读取快照不会等待结算。 - `Turn.Cancel` 请求取消;输出关闭表示拆卸开始。Turn 结算仍需要 `AwaitSettlement` 和所需的任何 `Executor.Close`;取消请求成功或其快照都不能替代这些等待。 **Runtime 在 Turn 前后执行的工作。** 一个输出消费者会在原生 Start 之前启动,耗尽有界的 64 帧通道,并将终态观察保留到 Start 发布、Turn 结算和已准入操作回执完成为止。正常完成绝不调用 Cancel。输入和函数准入会在结算前关闭;已准入的操作会持有其屏障,直至原生回执和出站确认完成。Runtime 会在等待该屏障之前向 Turn 发送取消,因为已写入的输入可能需要原生中断才能生成回执。Runtime 会汇合原生结算、所需的已确认 Executor 关闭、输出耗尽和所有已准入操作,然后应用确认或执行复用,之后才会转发 Done 或已应用的取消回执。Close 失败可以报告失败,同时保留同一 Run 和未完成操作以供重试;已关闭的调用方等待无法凭空生成已应用输入回执。Runtime 会在发布 Done 前提交原生连续性状态并释放旧 Run 的准入,因为接收方可能立即启动另一个 Turn;迟到的终态发送失败属于旧 Run,不能使已拥有 Executor 的后继对象失效。连接关闭负责传输丢失清理。结算等待时间为十秒,回执发送预算为五秒;超时不能证明已达到静默状态。 @@ -123,6 +123,8 @@ Session 在其已连接的 Runtime 中拥有一个可复用的 Executor;Turn 使用 [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/agentdaemon/proto) 处理中立请求、事件和回执。每个 Turn 只按顺序发出带有其 Run ID 的自身事件,并产生一个终态结果。原生 ID 和 usage 必须来自观察,绝不能虚构;缺失的度量值表示未知,而不是零。 +每条助手消息都携带其原生消息 ID。先发出带该 ID 的 `output_message` `in_progress`,再将每个文本片段作为在 `item_id` 中指向它的 `delta` 发出,最后发出带消息完整文本的 `output_message` `completed`([消息顺序](../../../docs/zh/runtime-protocol.md#message-families))。原生协议没有消息结束标记时(例如 ACP),在下一条消息开始或 Turn 结束时完成该消息。`Done` 和 `CancellationOutcome` 不携带回答文本;因取消或失败而未结束的消息保持未结束。 + 初始输入和引导使用有序的 `proto.MessageInput`。必须保持用户消息顺序和内容顺序。仅支持文本的适配器通过 `TextOnly()` 拒绝图像,而不是丢弃图像;图像适配器在原生环境中转换每个部分,并且只有在其所有消息均已应用后才确认活动批次。成功传输写入与确认原生应用是不同的事件。只能恢复绑定到 Session 的精确历史;缺失、含糊或外部历史会在新的模型输入之前导致失败。设备身份不代表原生 session 所有权。 ### 必需操作和扩展操作 {#required-and-extension-operations} diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index a99d60d33..7819fb632 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -41,7 +41,7 @@ A declaration describes what the Runtime can do. Core admits a public feature on | `workspace_read_preparation` | An idle Files directory read needs a read-only preparation | | `native_session_recovery` | A Session with a started Turn has no recorded native Session ID | | `web_search_control`, `text_verbosity` | The Harness's engine profile declares that control | -| `structured_output` and `message_items` | The Agent requests `json_schema` output | +| `structured_output` | The Agent requests `json_schema` output | | `subagent_observations` | `multi_agent.enabled` is true | | `subagent_control` | `multi_agent.enabled` is false | | `tool_search` | The Agent enables tool search or defers function loading | @@ -58,7 +58,6 @@ The `execution_prepare` configuration carries the Session's model configuration | --- | --- | | `model`, `system_prompt`, `model_provider`, `harness_config` | From the Session's frozen configuration: the Agent's model and instructions, the Session's provider bundle, and the [native model parameters](../contracts/agents-api/model-execution.md#native-model-parameters). The Harness validates them before any native effect | | `execution_controls` | Always: web search `disabled`, the resolved text verbosity (default `medium`), an explicit programmatic-tool-calling disable and any `json_schema` output format. Native option names belong to the adapter | -| `observe_messages` | When the Runtime declares `message_items`. Text deltas then carry the native item ID, and `output_message` frames report message start, completion, phase and the completion text | | `observe_subagent_identities`, `disable_subagents` | From the Agent's `multi_agent.enabled` | | `disable_execution_environment` | For an Environment of type `none` | | `local_environment` | For `openai_hosted` and `self_hosted`, with the exact Environment binding. The request carries no working directory; the Runtime checks `workspace_directory` against its binding | @@ -104,6 +103,8 @@ The linked source files define the required fields, validators, limits and finit Initial, prepared and active input use the same [ordered MessageInput](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/message_input.go). Adapters keep message and content order and reject unsupported content explicitly; a text-only transport rejects image content rather than dropping it. The [message input contract](../contracts/agents-api/message-content.md) owns the public image profile, whitespace rules and each Harness's native conversion. +Assistant text arrives only as identified messages. A message opens with an `output_message` of status `in_progress` and its ID, which may name its `phase`; each text fragment is a `delta` whose `item_id` names that message; an `output_message` of status `completed` carries the message's full text, which replaces its fragments. The shared validator rejects a `delta` without `item_id`, and `done` carries no answer text. A message still open when its Turn ends is marked `incomplete`. + Usage frames and the final usage snapshot each carry the cumulative measurement of the current execution and replace the previous snapshot; never add them. An absent measurement is unknown, not zero. ## Session assignments @@ -165,7 +166,7 @@ Neither `written` nor a send failure advances Core's input cursor. Once cancella | `interaction_decision_ack.applied=true` | The identified operation settled; a cancellation also requires native settlement | | `done` and the preceding execution events | The execution stream completed with its observed outcome | -No generic receipt exists for every envelope. A successful send does not prove that the peer received, accepted or completed a request. Process exit, a stop signal or a canceled local context does not prove cancellation. A `done` frame may also close a settled cancellation stream; it does not override the cancellation receipt or imply success. A cancellation receipt may keep partial content, native identity and usage in `outcome` even when no `done` is published. A failure to obtain settlement stays failed or unknown; it never becomes `applied=true`. +No generic receipt exists for every envelope. A successful send does not prove that the peer received, accepted or completed a request. Process exit, a stop signal or a canceled local context does not prove cancellation. A `done` frame may also close a settled cancellation stream; it does not override the cancellation receipt or imply success. A cancellation receipt may keep native identity and usage in `outcome` even when no `done` is published. A failure to obtain settlement stays failed or unknown; it never becomes `applied=true`. ## Failures, retries and cleanup diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index 1d593c4fb..b16b588ae 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: 30ea6710bb325ad4fb81a75ff870cb70c03cd099806f135eb92104c2616fd521 +source_hash: 0890cc82a6ab41096a81aebfee6024b22d8c298e82104b34f7ab194a54b05a55 --- 此协议在 Runtime daemon 获取机器凭据后连接 Core 与 daemon,定义 daemon 连接上消息的含义和顺序。wire 类型、限制和验证器仅在 [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/agentdaemon/proto) 中定义一次;Core 的 [gateway](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/runtimegateway) 与参考 Runtime 的 [dispatcher](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/dispatch) 都使用它们,因此无需同步第二套 payload schema。签发凭据和打开连接的 HTTP 路由见[机器连接 API](../../contracts/agents-api/zh/machine-api.md)。 @@ -43,7 +43,7 @@ wire 上每个字段都是 JSON boolean,所有字段都必须出现,包括 ` | `workspace_read_preparation` | 空闲 Files 目录读取需要只读 preparation | | `native_session_recovery` | Session 已启动过 Turn,但未记录原生 Session ID | | `web_search_control`, `text_verbosity` | Harness 的 engine profile 声明该控制 | -| `structured_output` 和 `message_items` | Agent 请求 `json_schema` 输出 | +| `structured_output` | Agent 请求 `json_schema` 输出 | | `subagent_observations` | `multi_agent.enabled` 为 true | | `subagent_control` | `multi_agent.enabled` 为 false | | `tool_search` | Agent 启用 tool search 或延迟 function 加载 | @@ -60,7 +60,6 @@ wire 上每个字段都是 JSON boolean,所有字段都必须出现,包括 ` | --- | --- | | `model`, `system_prompt`, `model_provider`, `harness_config` | 来自 Session 冻结的配置:Agent 的 model 和 instructions、Session 的 provider bundle,以及[原生模型参数](../../contracts/agents-api/zh/model-execution.md#native-model-parameters)。Harness 在产生任何原生效果之前验证它们 | | `execution_controls` | 始终设置:web search 为 `disabled`、解析后的 text verbosity(默认 `medium`)、明确禁用 programmatic tool calling,以及任何 `json_schema` 输出格式。原生选项名称由 adapter 负责 | -| `observe_messages` | Runtime 声明 `message_items` 时设置。文本 delta 随后携带原生 item ID,`output_message` frame 报告消息开始、完成、phase 和完成文本 | | `observe_subagent_identities`, `disable_subagents` | 根据 Agent 的 `multi_agent.enabled` 设置 | | `disable_execution_environment` | Environment 类型为 `none` 时设置 | | `local_environment` | 为 `openai_hosted` 和 `self_hosted` 设置,包含精确的 Environment 绑定。请求不携带 working directory;Runtime 按自身绑定检查 `workspace_directory` | @@ -106,6 +105,8 @@ wire 上每个字段都是 JSON boolean,所有字段都必须出现,包括 ` 初始、已准备和活动输入使用同一[有序 MessageInput](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/message_input.go)。Adapter 保留消息与内容顺序,并明确拒绝不支持的内容;仅文本 transport 拒绝图像内容而不丢弃它。[消息输入契约](../../contracts/agents-api/zh/message-content.md)负责公开图像 profile、空白规则和各 Harness 的原生转换。 +助手文本只以带身份的消息到达。一条消息以状态为 `in_progress`、带其 ID 的 `output_message` 开始,该 frame 可以指明 `phase`;每个文本片段是一个 `delta`,其 `item_id` 指向该消息;状态为 `completed` 的 `output_message` 携带消息的完整文本,并替换其片段。共享校验器拒绝没有 `item_id` 的 `delta`,`done` 不携带回答文本。Turn 结束时仍未结束的消息标记为 `incomplete`。 + Usage frame 和最终 usage snapshot 都携带当前执行的累计测量,替换之前的快照;不要相加。缺失的测量表示未知,不是零。 ## Session 分配 {#session-assignments} @@ -167,7 +168,7 @@ Core 通过 `prompt_steer` 交付活动输入,每个 Run 一次交付一个输 | `interaction_decision_ack.applied=true` | 指定操作已结算;取消还要求原生结算 | | `done` 和之前的执行事件 | 执行流以观测到的结果完成 | -不存在适用于每个 envelope 的通用回执。发送成功不证明对端已收到、接受或完成请求。进程退出、stop signal 或本地 context 取消不证明取消完成。`done` frame 也可能关闭已结算的取消流;它不覆盖取消回执,也不意味着成功。没有发布 `done` 时,取消回执仍可在 `outcome` 中保留部分内容、原生身份和 usage。无法取得结算结果时,状态继续为失败或未知,不会变为 `applied=true`。 +不存在适用于每个 envelope 的通用回执。发送成功不证明对端已收到、接受或完成请求。进程退出、stop signal 或本地 context 取消不证明取消完成。`done` frame 也可能关闭已结算的取消流;它不覆盖取消回执,也不意味着成功。没有发布 `done` 时,取消回执仍可在 `outcome` 中保留原生身份和 usage。无法取得结算结果时,状态继续为失败或未知,不会变为 `applied=true`。 ## 故障、重试与清理 {#failures-retries-and-cleanup} diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index 978bd5d59..9a24ad85e 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -1,5 +1,7 @@ package proto +import "errors" + // This package lives at the repo-root module so both the server-side // gateway/connector AND apps/daemon can import it. That rules out // importing server/internal/... (Go's internal-package rule), so wire @@ -11,12 +13,11 @@ package proto // connector.PromptEvent.Type 1:1 so the gateway can translate without // a per-event lookup table. const ( - // TypeDelta carries an incremental text fragment. Daemon - // accumulates these so the matching done frame can carry the - // full Final.Content. + // TypeDelta carries an incremental text fragment of one assistant message. TypeDelta = "delta" - // TypeOutputMessage carries opt-in native message boundaries and completion snapshots. + // TypeOutputMessage carries an assistant message's start and its + // completion snapshot. TypeOutputMessage = "output_message" // TypeThinking carries an internal-thinking fragment. Gateway @@ -50,13 +51,22 @@ const ( TypeHeartbeat = "heartbeat" ) -// DeltaPayload carries an incremental text fragment from the agent. +// DeltaPayload carries an incremental text fragment of the assistant message +// ItemID names. type DeltaPayload struct { - ItemID string `json:"item_id,omitempty"` + ItemID string `json:"item_id"` Delta string `json:"delta"` Sequence uint64 `json:"sequence"` } +// Validate requires the message identity of a delta. +func (p DeltaPayload) Validate() error { + if p.ItemID == "" { + return errors.New("delta requires item_id") + } + return nil +} + // OutputMessagePayload describes a native assistant message; Text is a completion snapshot. type OutputMessagePayload struct { ID string `json:"id"` @@ -137,7 +147,6 @@ type ErrorPayload struct { type DonePayload struct { // SourceCompletedAtMS freezes the native root completion before child settlement. SourceCompletedAtMS *int64 `json:"source_completed_at_ms,omitempty"` - Content string `json:"content"` Transcript string `json:"transcript,omitempty"` Usage Usage `json:"usage,omitzero"` Metadata map[string]any `json:"metadata,omitempty"` @@ -156,7 +165,6 @@ const ( type AgentKindCapabilities struct { SubagentObservations CapabilitySupport `json:"subagent_observations"` NativeSessionRecovery CapabilitySupport `json:"native_session_recovery"` - MessageItems CapabilitySupport `json:"message_items"` EnvironmentNone CapabilitySupport `json:"environment_none"` LocalEnvironment CapabilitySupport `json:"local_environment"` diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index 445055302..6320cdcf2 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -53,7 +53,6 @@ type PromptRequestPayload struct { WorkspaceReadOnly bool `json:"workspace_read_only,omitempty"` AgentStateKey string `json:"agent_state_key,omitempty"` RequireExistingNativeSession bool `json:"require_existing_native_session,omitempty"` - ObserveMessages bool `json:"observe_messages,omitempty"` ObserveSubagentIdentities bool `json:"observe_subagent_identities,omitempty"` FunctionTools []FunctionTool `json:"function_tools,omitempty"` ToolSearch bool `json:"tool_search,omitempty"` diff --git a/internal/agentdaemon/proto/prototest/capabilities.go b/internal/agentdaemon/proto/prototest/capabilities.go index 9bf034797..28129ff88 100644 --- a/internal/agentdaemon/proto/prototest/capabilities.go +++ b/internal/agentdaemon/proto/prototest/capabilities.go @@ -14,7 +14,6 @@ func Capabilities(overrides proto.AgentKindCapabilities) proto.AgentKindCapabili c := proto.AgentKindCapabilities{ SubagentObservations: proto.CapabilityUnsupported, NativeSessionRecovery: proto.CapabilityUnsupported, - MessageItems: proto.CapabilityUnsupported, EnvironmentNone: proto.CapabilityUnsupported, LocalEnvironment: proto.CapabilityUnsupported, WorkspaceReadPreparation: proto.CapabilityUnsupported, diff --git a/internal/agentdaemon/proto/prototest/wire.go b/internal/agentdaemon/proto/prototest/wire.go index cfc5ed041..b5d45b88d 100644 --- a/internal/agentdaemon/proto/prototest/wire.go +++ b/internal/agentdaemon/proto/prototest/wire.go @@ -105,7 +105,7 @@ type WireScenario struct { // CancellationOutcome is the continuity snapshot a cancelled Turn reports. func CancellationOutcome() proto.DonePayload { - return proto.DonePayload{Content: "partial", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} + return proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} } // WireScenarios returns every shared exchange. diff --git a/internal/agentdaemon/proto/workspace_read_preparation.go b/internal/agentdaemon/proto/workspace_read_preparation.go index 0ee0898d7..f4992df5b 100644 --- a/internal/agentdaemon/proto/workspace_read_preparation.go +++ b/internal/agentdaemon/proto/workspace_read_preparation.go @@ -8,6 +8,6 @@ func ValidWorkspaceReadPreparation(r PromptRequestPayload) bool { !r.RequireExistingNativeSession && !r.DisableExecutionEnvironment && r.Model == "" && r.SystemPrompt == "" && r.ModelProvider == nil && len(r.HarnessConfig) == 0 && r.ExecutionControls == nil && r.MCPHTTPServers == nil && - len(r.FunctionTools) == 0 && !r.ToolSearch && !r.ObserveMessages && + len(r.FunctionTools) == 0 && !r.ToolSearch && !r.ObserveSubagentIdentities } diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index 17a0c06bc..97f7e18b0 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -22,7 +22,7 @@ The package test compiles TypeScript before running its tests. The Make target a `packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK and native message translation. The Go `claudesdk.NewExecutorFactory` uses the shared owned process runner and emits the daemon's delta, error and Done frames. The SDK owns the model loop. Its narrow stdio protocol carries Executor preparation and identified Turn starts, text deltas, function calls/results/receipts, active input/receipts, usage snapshots and terminal result/error plus settlement; native translation stays inside the adapter. The private `native_model_options` input contains only the native options compiled by Go after shared Harness validation. The bridge checks object/field structure and maps those fields explicitly to SDK options; enum membership, budget ranges and thinking combinations belong solely to the Go adapter declaration. The public `harness_config` object does not cross this private boundary. -With `observe_messages`, it also emits the neutral `output_message` start/completion snapshots and tags deltas with the native Messages API message ID, not the SDK event UUID. Text blocks in one native message share that identity. The SDK's per-block assistant snapshots replace draft block text; only native `message_stop` completes the message, without replaying its text as another delta. Thinking/tool-only messages produce no text Items; interrupted messages retain their streamed partial text. No phase is inferred from the final result. Turn-owned native work and output draining precede reuse. Executor close releases the SDK Query and native process. The private `turn_settled` frame requires `confirmed` independently of `reusable`: confirmed native Turn/cancellation settlement, confirmed resource cleanup, and reuse eligibility are separate facts. Unknown or nonempty interrupt receipts and unsettled input/function/tool work remain unconfirmed even after successful teardown. Go rejects cancellation and AwaitSettlement when native confirmation is missing or false, or its own receipt ledger remains unsettled. A confirmed Turn may be non-reusable after cleanup; that state alone does not turn a verified cancellation into an error. Confirmed native cancellation may settle unanswered function calls after result admission closes and callbacks drain. A submitted function result still requires its native application receipt, including when the MCP request aborts. +It emits the neutral `output_message` start/completion snapshots and tags every delta with the native Messages API message ID, not the SDK event UUID. Text blocks in one native message share that identity. The SDK's per-block assistant snapshots replace draft block text; only native `message_stop` completes the message, without replaying its text as another delta. Thinking/tool-only messages produce no text Items; interrupted messages retain their streamed partial text. No phase is inferred from the final result. Turn-owned native work and output draining precede reuse. Executor close releases the SDK Query and native process. The private `turn_settled` frame requires `confirmed` independently of `reusable`: confirmed native Turn/cancellation settlement, confirmed resource cleanup, and reuse eligibility are separate facts. Unknown or nonempty interrupt receipts and unsettled input/function/tool work remain unconfirmed even after successful teardown. Go rejects cancellation and AwaitSettlement when native confirmation is missing or false, or its own receipt ledger remains unsettled. A confirmed Turn may be non-reusable after cleanup; that state alone does not turn a verified cancellation into an error. Confirmed native cancellation may settle unanswered function calls after result admission closes and callbacks drain. A submitted function result still requires its native application receipt, including when the MCP request aborts. ### Workspace execution diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index d4c655d54..003758a45 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -29,8 +29,7 @@ export type Event = | CommandEvent | { type: "prepared" } | { type: "usage"; session_id: string; result_id: string; usage: NativeUsage } - | { type: "delta"; delta: string } - | { type: "result"; session_id: string; text: string } + | { type: "result"; session_id: string } | { type: "error"; code: "invalid_request" | "history_unavailable" | "execution_failed" | "cancelled"; engine_error_code?: string; session_id?: string; result_id?: string }; export async function execute(request: Start | Prepare | ExecutorPrepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(immediateInput(request)), turns?: ExecutorTurns): Promise { @@ -73,7 +72,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: let failed = false; let cancellationFactsFailed = false; let structured = request.output_format ? new StructuredOutput() : undefined; - let messages = request.observe_messages ? new MessageObserver() : undefined; + let messages = new MessageObserver(); let stream: ReturnType | undefined; let warm: WarmQuery | undefined; let nativeAlive = false; @@ -138,7 +137,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: turns.configure(stream,(input,output)=>{ inputs=new Inputs(input); functions=new FunctionBridge(output); - messages=request.observe_messages ? new MessageObserver() : undefined; + messages=new MessageObserver(); structured=request.output_format ? new StructuredOutput() : undefined; commands=workspace ? new CommandObserver() : undefined; mcp=profile ? new MCPObserver(profile.identities) : undefined; @@ -171,7 +170,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: await functions.consume(message, nativeID, turns?.cancelled); if (mcp) for (const event of mcp.consume(message, nativeID)) await emit(event); if (commands) for (const event of commands.consume(message, nativeID, inputs.hasInput)) await emit(event); - if (messages) for (const event of messages.consume(message)) await emit(event); + for (const event of messages.consume(message)) await emit(event); if (message.type === "system" && message.subtype === "init") { nativeID = message.session_id; if (!nativeID || (request.resume && nativeID !== request.resume)) throw new Error("unexpected native session"); @@ -184,9 +183,6 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: } turns?.identify(nativeID); if(!turns || turns.id) for (const event of inputs.start(nativeID)) await emit(event); - } else if (!messages && message.type === "stream_event" && message.parent_tool_use_id === null && - message.event.type === "content_block_delta" && message.event.delta.type === "text_delta") { - await emit({ type: "delta", delta: message.event.delta.text }); } else if (message.type === "result") { if (!message.uuid || resultIDs.has(message.uuid) || !nativeID || message.session_id !== nativeID) throw new Error("invalid native result identity"); resultIDs.add(message.uuid); @@ -197,7 +193,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: throw nativeResultFailure; } if (structured && !turns?.cancelled) await emit(structured.complete(message)); - result = { type: "result", session_id: nativeID, text: message.subtype === "success" ? message.result : "" }; + result = { type: "result", session_id: nativeID }; if(turns?.cancelled && !inputs.complete) throw new Error("unconfirmed cancelled inputs"); } if (message.type !== "result" && (!turns || turns.id)) for (const event of inputs.consume(message)) await emit(event); diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 9cdd460ed..30fff5469 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -16,7 +16,6 @@ export type Start = { cwd: string; resume?: string; require_history?: boolean; - observe_messages?: boolean; subagents?: { max_concurrent: number }; tool_search?: boolean; functions?: { name: string; description: string; parameters: Tool["inputSchema"]; defer_loading?: boolean }[]; @@ -35,14 +34,13 @@ export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { const value: unknown = JSON.parse(line); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const request = value as Record; - const allowed = new Set(["native_model_options", "type", "input", "model", "system_prompt", "cwd", "resume", "require_history", "observe_messages", "output_format", "subagents", "functions", "tool_search", "mcp_http_servers", "workspace"]); + const allowed = new Set(["native_model_options", "type", "input", "model", "system_prompt", "cwd", "resume", "require_history", "output_format", "subagents", "functions", "tool_search", "mcp_http_servers", "workspace"]); if (Object.keys(request).some(key => !allowed.has(key)) || (request.type !== "start" && request.type !== "prepare" && request.type !== "executor_prepare") || (request.type === "start" ? !Array.isArray(request.input) : "input" in request) || typeof request.model !== "string" || !request.model.trim() || typeof request.system_prompt !== "string" || typeof request.cwd !== "string" || !isAbsolute(request.cwd) || - (request.observe_messages !== undefined && typeof request.observe_messages !== "boolean") || (request.require_history !== undefined && typeof request.require_history !== "boolean") || (request.resume !== undefined && (typeof request.resume !== "string" || !request.resume))) throw new Error("invalid_request"); if (request.functions !== undefined && (!Array.isArray(request.functions) || request.functions.some(tool => @@ -61,7 +59,7 @@ export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { if (request.output_format !== undefined) { const format = request.output_format as Start["output_format"]; if (!format || format.type !== "json_schema" || Object.keys(format).some(key => !["type", "schema"].includes(key)) || - !format.schema || format.schema.type !== "object" || !request.observe_messages || request.subagents || + !format.schema || format.schema.type !== "object" || request.subagents || request.mcp_http_servers !== undefined) throw new Error("invalid_request"); } if (request.type === "start") requestInput(request.input); diff --git a/packages/claude-sdk-adapter/tests/executor.test.mjs b/packages/claude-sdk-adapter/tests/executor.test.mjs index d30b7505d..a94de9510 100644 --- a/packages/claude-sdk-adapter/tests/executor.test.mjs +++ b/packages/claude-sdk-adapter/tests/executor.test.mjs @@ -78,7 +78,7 @@ async function launch(t,mode="normal") { const send=value=>child.stdin.write(JSON.stringify(value)+"\n"); const start=(id,text)=>send({type:"turn_start",turn_id:id,input:[{content:[{type:"input_text",text}]}]}); send({type:"executor_prepare",cwd:"/tmp",model:"fixture",system_prompt:"", - ...(mode==="features" || mode.startsWith("pending-function") ? {observe_messages:true,functions:[{name:"lookup",description:"lookup",parameters:{type:"object",properties:{text:{type:"string"}}}}]} : {})}); + ...(mode==="features" || mode.startsWith("pending-function") ? {functions:[{name:"lookup",description:"lookup",parameters:{type:"object",properties:{text:{type:"string"}}}}]} : {})}); await wait(()=>events.some(event=>event.type==="executor_ready")); assert.equal(observations.filter(event=>event.type==="input").length,0); return {child,events,observations,closed,wait,send,start}; diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index 1d9563c2d..4f0b5a296 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -31,7 +31,7 @@ function fixture(t, declarations = [stdio]) { test("installed MCP projection uses the common Runtime launcher", t => { const { request } = fixture(t); assert.deepEqual(parseStart(JSON.stringify(request)), request); - assert.throws(() => parseStart(JSON.stringify({ ...request, observe_messages: true, + assert.throws(() => parseStart(JSON.stringify({ ...request, output_format: { type: "json_schema", schema: { type: "object" } } })), /invalid_request/); assert.equal(immediateInput(request), undefined); assert.deepEqual(parseEnvironmentMCP([stdio]), [stdio]); diff --git a/packages/claude-sdk-adapter/tests/messages.test.mjs b/packages/claude-sdk-adapter/tests/messages.test.mjs index 35d5aaaf0..7a9328c2f 100644 --- a/packages/claude-sdk-adapter/tests/messages.test.mjs +++ b/packages/claude-sdk-adapter/tests/messages.test.mjs @@ -2,7 +2,6 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import test from "node:test"; import { MessageObserver } from "../dist/messages.js"; -import { parseStart } from "../dist/adapter.js"; const stream = (event, parent = null) => ({ type: "stream_event", uuid: randomUUID(), session_id: "session", parent_tool_use_id: parent, event, @@ -91,10 +90,3 @@ test("unmatched text cannot acquire an invented identity", () => { observer.consume(block(0)); assert.throws(() => observer.consume(snapshot("other", "wrong")), /unmatched/); }); - -test("observation opt-in is an optional boolean", () => { - const request = { type: "start", input: [{ content: [{ type: "input_text", text: "hello" }] }], model: "model", system_prompt: "", cwd: "/tmp" }; - assert.equal(parseStart(JSON.stringify(request)).observe_messages, undefined); - assert.equal(parseStart(JSON.stringify({ ...request, observe_messages: true })).observe_messages, true); - assert.throws(() => parseStart(JSON.stringify({ ...request, observe_messages: "true" })), /invalid_request/); -}); diff --git a/packages/claude-sdk-adapter/tests/structured_output.test.mjs b/packages/claude-sdk-adapter/tests/structured_output.test.mjs index 878c9fcd9..709edc3f3 100644 --- a/packages/claude-sdk-adapter/tests/structured_output.test.mjs +++ b/packages/claude-sdk-adapter/tests/structured_output.test.mjs @@ -22,7 +22,7 @@ test('unrelated or failed results cannot publish a candidate',()=>{ } }); test('output configuration is restricted to the qualified profile',()=>{ - const r={type:'start',input: [{ content: [{ type: "input_text", text: 'hello' }] }],model:'model',system_prompt:'',cwd:'/tmp',observe_messages:true,output_format:{type:'json_schema',schema:{type:'object'}}}; + const r={type:'start',input: [{ content: [{ type: "input_text", text: 'hello' }] }],model:'model',system_prompt:'',cwd:'/tmp',output_format:{type:'json_schema',schema:{type:'object'}}}; assert.deepEqual(parseStart(JSON.stringify(r)),r); - for(const change of [{observe_messages:false},{subagents:{max_concurrent:1}},{mcp_http_servers:[]},{output_format:{type:'text',schema:{}}}]) assert.throws(()=>parseStart(JSON.stringify({...r,...change}))); + for(const change of [{subagents:{max_concurrent:1}},{mcp_http_servers:[]},{output_format:{type:'text',schema:{}}}]) assert.throws(()=>parseStart(JSON.stringify({...r,...change}))); }); diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 750d578ba..b6cddded7 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -95,7 +95,7 @@ test("workspace native options bypass isolation and preserve selected tool inven test("structured workspace admits only its configured native terminal tool", async t => { const { dirs, config, request } = fixture(t); const output_format = { type: "json_schema", schema: { type: "object" } }; - const configured = { ...request, observe_messages: true, output_format }; + const configured = { ...request, output_format }; assert.deepEqual(parseStart(JSON.stringify(configured)), configured); const ordinary = new WorkspaceProfile(dirs.workspace, config); const structured = new WorkspaceProfile(dirs.workspace, config, [], undefined, undefined, true); diff --git a/services/core/internal/db/queries/session_items.sql b/services/core/internal/db/queries/session_items.sql index 269897731..f76e58bfd 100644 --- a/services/core/internal/db/queries/session_items.sql +++ b/services/core/internal/db/queries/session_items.sql @@ -52,10 +52,6 @@ SELECT * FROM turn_inputs WHERE session_id = $1 AND sequence = $2; -- name: ItemEventSources :many SELECT * FROM turn_events WHERE session_id = $1 AND turn_id = $2 AND ordinal >= $3 ORDER BY ordinal; --- name: HasNativeMessageItem :one -SELECT EXISTS(SELECT 1 FROM session_items WHERE turn_id = $1 - AND payload->>'role' = 'assistant' AND id <> $2); - -- name: ListTurnItemDiagnostics :many SELECT id, created_at, settled_at FROM session_items WHERE session_id = $1 AND turn_id = $2 diff --git a/services/core/internal/db/sqlc/session_items.sql.go b/services/core/internal/db/sqlc/session_items.sql.go index d7c498c15..3daaf19f2 100644 --- a/services/core/internal/db/sqlc/session_items.sql.go +++ b/services/core/internal/db/sqlc/session_items.sql.go @@ -57,23 +57,6 @@ func (q *Queries) GetSessionItem(ctx context.Context, arg GetSessionItemParams) return i, err } -const hasNativeMessageItem = `-- name: HasNativeMessageItem :one -SELECT EXISTS(SELECT 1 FROM session_items WHERE turn_id = $1 - AND payload->>'role' = 'assistant' AND id <> $2) -` - -type HasNativeMessageItemParams struct { - TurnID pgtype.UUID `json:"turn_id"` - ID pgtype.UUID `json:"id"` -} - -func (q *Queries) HasNativeMessageItem(ctx context.Context, arg HasNativeMessageItemParams) (bool, error) { - row := q.db.QueryRow(ctx, hasNativeMessageItem, arg.TurnID, arg.ID) - var exists bool - err := row.Scan(&exists) - return exists, err -} - const itemEventSources = `-- name: ItemEventSources :many SELECT session_id, turn_id, ordinal, kind, payload, created_at FROM turn_events WHERE session_id = $1 AND turn_id = $2 AND ordinal >= $3 ORDER BY ordinal ` diff --git a/services/core/internal/execution/delivery.go b/services/core/internal/execution/delivery.go index 31844e020..f1926cf15 100644 --- a/services/core/internal/execution/delivery.go +++ b/services/core/internal/execution/delivery.go @@ -373,9 +373,6 @@ func (r *Result) mergeDone(raw json.RawMessage) error { if done.Usage.Model == "" { done.Usage.Model = r.Done.Usage.Model } - if done.Content == "" { - done.Content = r.Done.Content - } if done.Metadata == nil { done.Metadata = r.Done.Metadata } diff --git a/services/core/internal/execution/journal_test.go b/services/core/internal/execution/journal_test.go index 1955836aa..94cf98a38 100644 --- a/services/core/internal/execution/journal_test.go +++ b/services/core/internal/execution/journal_test.go @@ -114,7 +114,7 @@ func TestJournalDrainRetainsTerminalContinuityAndUsageOnFailure(t *testing.T) { env, _ := proto.NewEnvelope(proto.TypeDelta, "run", proto.DeltaPayload{Delta: "partial", Sequence: uint64(i + 1)}) upstream <- env } - done, _ := proto.NewEnvelope(proto.TypeDone, "run", proto.DonePayload{Content: "Final", Usage: proto.Usage{InputTokens: 10, OutputTokens: 4}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-thread"}}) + done, _ := proto.NewEnvelope(proto.TypeDone, "run", proto.DonePayload{Usage: proto.Usage{InputTokens: 10, OutputTokens: 4}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-thread"}}) upstream <- done close(upstream) result := Result{ErrorCode: "event_persistence_failed"} diff --git a/services/core/internal/execution/request.go b/services/core/internal/execution/request.go index f12960af0..b0b66d14b 100644 --- a/services/core/internal/execution/request.go +++ b/services/core/internal/execution/request.go @@ -42,7 +42,6 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session sessions.Sess Model: snapshot.Agent.Model, SystemPrompt: instructions, ModelProvider: provider, HarnessConfig: harnessConfig, ExecutionControls: controls, AgentStateKey: "agents-api-" + session.ID, AgentSessionID: bound.NativeSessionID, RequireExistingNativeSession: recoverNativeSession, - ObserveMessages: caps.MessageItems.IsSupported(), ObserveSubagentIdentities: snapshot.Agent.MultiAgent.Enabled, MaxConcurrentSubagents: snapshot.Agent.MultiAgent.MaxConcurrentSubagents, DisableSubagents: !snapshot.Agent.MultiAgent.Enabled} diff --git a/services/core/internal/execution/structured_output_test.go b/services/core/internal/execution/structured_output_test.go index c5c8e7242..a2142f712 100644 --- a/services/core/internal/execution/structured_output_test.go +++ b/services/core/internal/execution/structured_output_test.go @@ -31,7 +31,7 @@ func TestStructuredOutputRequestKeepsFrozenSchemaAndInstructions(t *testing.T) { schema := json.RawMessage(`{"type":"object","properties":{"number":{"const":9007199254740992}}}`) instructions := "Keep these original instructions." snapshot := Snapshot{ModelProviderConfigured: true, Agent: v1.Agent{Model: "model", Instructions: &instructions, Text: v1.TextConfig{Format: v1.TextFormat{Type: "json_schema", Schema: schema}}}} - request, err := (&Dispatcher{SessionsReader: frozenProvider{engine: "claude_sdk"}}).executionRequest(context.Background(), sessions.Session{Engine: "claude_sdk"}, snapshot, proto.AgentKindCapabilities{MessageItems: proto.CapabilitySupported}, sessions.ExecutionBinding{}) + request, err := (&Dispatcher{SessionsReader: frozenProvider{engine: "claude_sdk"}}).executionRequest(context.Background(), sessions.Session{Engine: "claude_sdk"}, snapshot, proto.AgentKindCapabilities{StructuredOutput: proto.CapabilitySupported}, sessions.ExecutionBinding{}) if err != nil || request.ExecutionControls.OutputFormat == nil { t.Fatal(err) } diff --git a/services/core/internal/execution/support.go b/services/core/internal/execution/support.go index 8e83a75a1..cd4974f1d 100644 --- a/services/core/internal/execution/support.go +++ b/services/core/internal/execution/support.go @@ -96,8 +96,8 @@ func (p Policy) engineCapabilities(peer *runtimegateway.Session, engine string, if profile.WebSearchControl.IsSupported() && !caps.WebSearchControl.IsSupported() { return fail("device must advertise web_search_control") } - if snapshot.Agent.Text.Format.Type == "json_schema" && (!caps.StructuredOutput.IsSupported() || !caps.MessageItems.IsSupported()) { - return fail("device must support structured output and message observations") + if snapshot.Agent.Text.Format.Type == "json_schema" && !caps.StructuredOutput.IsSupported() { + return fail("device must support structured output") } if verbosity := snapshot.Agent.Text.Verbosity; verbosity != "" && verbosity != "medium" && !caps.TextVerbosity.IsSupported() { return fail("device must advertise text_verbosity") diff --git a/services/core/internal/items/changes.go b/services/core/internal/items/changes.go index adf6c6fc1..ffcc482c8 100644 --- a/services/core/internal/items/changes.go +++ b/services/core/internal/items/changes.go @@ -11,9 +11,6 @@ import ( type Stored struct { // Item is the stored Item, or the zero Item when the Update adds a new one. Item v1.Item - // NativeMessage reports that the Turn has an assistant message other than - // this Item. Observe reads it only when the Update NeedsNativeMessage. - NativeMessage bool // FunctionResult is the result the application saved for the call, if any. // Observe reads it only for an Update with a ResultCall. FunctionResult json.RawMessage @@ -32,12 +29,6 @@ type Change struct { Delta *string } -// NeedsNativeMessage reports whether Observe reads Stored.NativeMessage for u: -// a legacy aggregate applies only when the Turn recorded no native message. -func (u Update) NeedsNativeMessage() bool { - return u.LegacyFinal -} - // ResultCall returns the call whose saved result Observe reads for u, a // function result. func (u Update) ResultCall() (string, bool) { @@ -45,20 +36,16 @@ func (u Update) ResultCall() (string, bool) { } // Observe decides how an Update projected from an observation of kind changes -// its stored Item. It reports false when the Update is superseded and writes -// nothing. -func Observe(kind string, update Update, stored Stored) (Change, bool, error) { - if update.LegacyFinal && stored.NativeMessage { - return Change{}, false, nil - } +// its stored Item. +func Observe(kind string, update Update, stored Stored) (Change, error) { item, err := Merge(update, stored.Item) if err != nil { - return Change{}, false, err + return Change{}, err } if item.Type == "function_call_output" && len(stored.FunctionResult) > 0 { var fields map[string]json.RawMessage if err := json.Unmarshal(stored.FunctionResult, &fields); err != nil { - return Change{}, false, err + return Change{}, err } // Native results may normalize content; public Items retain the saved submission. item.Output, item.Error = nil, nil @@ -76,5 +63,5 @@ func Observe(kind string, update Update, stored Stored) (Change, bool, error) { case proto.TypeCommandOutput: change.Delta = update.CommandOutputDelta } - return change, true, nil + return change, nil } diff --git a/services/core/internal/items/changes_test.go b/services/core/internal/items/changes_test.go index 96f98766a..981212c7a 100644 --- a/services/core/internal/items/changes_test.go +++ b/services/core/internal/items/changes_test.go @@ -24,18 +24,8 @@ func TestObserveDecidesItemChanges(t *testing.T) { kind string update Update stored Stored - skip bool check func(Change) bool }{ - { - name: "a legacy aggregate yields to a native message", kind: "execution_completed", - update: project("execution_completed", `{"done":{"content":"final"}}`), stored: Stored{NativeMessage: true}, skip: true, - }, - { - name: "a legacy aggregate applies without a native message", kind: "execution_completed", - update: project("execution_completed", `{"done":{"content":"final"}}`), - check: func(c Change) bool { return c.Item.Status == "completed" && c.Output && c.Delta == nil }, - }, { name: "a text delta merges and keeps its own fragment", kind: "delta", update: project("delta", `{"item_id":"a","delta":" more"}`), stored: Stored{Item: draft}, @@ -62,11 +52,11 @@ func TestObserveDecidesItemChanges(t *testing.T) { }, } { t.Run(test.name, func(t *testing.T) { - change, ok, err := Observe(test.kind, test.update, test.stored) - if err != nil || ok == test.skip { - t.Fatal(ok, err) + change, err := Observe(test.kind, test.update, test.stored) + if err != nil { + t.Fatal(err) } - if !test.skip && !test.check(change) { + if !test.check(change) { t.Fatalf("%+v", change) } }) @@ -74,15 +64,12 @@ func TestObserveDecidesItemChanges(t *testing.T) { } func TestUpdatesDeclareTheFactsObserveReads(t *testing.T) { - legacy := Update{Item: v1.Item{Type: "message"}, LegacyFinal: true} + message := Update{Item: v1.Item{Type: "message"}} result := Update{Item: v1.Item{Type: "function_call_output", CallID: "call"}} - if !legacy.NeedsNativeMessage() || result.NeedsNativeMessage() { - t.Fatal("native message need") - } if call, ok := result.ResultCall(); !ok || call != "call" { t.Fatal("result call", call, ok) } - if _, ok := legacy.ResultCall(); ok { + if _, ok := message.ResultCall(); ok { t.Fatal("message asked for a function result") } } diff --git a/services/core/internal/items/messages.go b/services/core/internal/items/messages.go index 50c439f45..11d041038 100644 --- a/services/core/internal/items/messages.go +++ b/services/core/internal/items/messages.go @@ -14,8 +14,6 @@ type Update struct { Item v1.Item AppendText bool CommandOutputDelta *string - // A legacy aggregate is used only when no native message identity was recorded. - LegacyFinal bool } func Identity(turn, key string) string { @@ -40,11 +38,10 @@ func Project(turn, kind string, sequence int64, raw json.RawMessage) ([]Update, if err := json.Unmarshal(raw, &p); err != nil { return nil, err } - key := "message:" + p.ItemID - if p.ItemID == "" { - key = "legacy-message" + if err := p.Validate(); err != nil { + return nil, err } - return []Update{{Item: message(turn, key, "assistant", p.Delta, "in_progress"), AppendText: true}}, nil + return []Update{{Item: message(turn, "message:"+p.ItemID, "assistant", p.Delta, "in_progress"), AppendText: true}}, nil case proto.TypeOutputMessage: var p proto.OutputMessagePayload if err := json.Unmarshal(raw, &p); err != nil { @@ -62,34 +59,6 @@ func Project(turn, kind string, sequence int64, raw json.RawMessage) ([]Update, item.Phase = p.Phase } return []Update{{Item: item, AppendText: p.Text == nil}}, nil - case proto.TypeDone, "execution_failed": - // Legacy Done may contain adapter diagnostics. Only a successful Turn - // confirms aggregate answer text; failures retain observed message deltas. - return nil, nil - case "cancel_receipt", "execution_completed", "execution_cancelled": - var p struct { - Applied bool `json:"applied"` - Outcome *proto.DonePayload `json:"outcome"` - Done *proto.DonePayload `json:"done"` - } - if err := json.Unmarshal(raw, &p); err != nil { - return nil, err - } - final := p.Done - if kind == "cancel_receipt" { - if !p.Applied { - return nil, nil - } - final = p.Outcome - } - if final == nil || final.Content == "" { - return nil, nil - } - status := "incomplete" - if kind == "execution_completed" { - status = "completed" - } - return []Update{{Item: message(turn, "legacy-message", "assistant", final.Content, status), LegacyFinal: true}}, nil case proto.TypeToolCall: return projectTool(turn, raw) case proto.TypeCommandOutput: @@ -107,7 +76,7 @@ func Merge(update Update, previous v1.Item) (v1.Item, error) { if previous.ID == "" { return item, nil } - if previous.Status != "in_progress" && !(update.LegacyFinal && previous.Status == "incomplete") { + if previous.Status != "in_progress" { return previous, nil } if (item.Type == "function_call") && (item.Arguments == nil || string(encoded(item.Arguments)) == "null") { @@ -117,9 +86,6 @@ func Merge(update Update, previous v1.Item) (v1.Item, error) { item.Output = previous.Output } if update.AppendText { - if previous.Status != "in_progress" { - return previous, nil - } text := *previous.Content[0].Text + *item.Content[0].Text item.Content = slices.Clone(item.Content) item.Content[0].Text = &text diff --git a/services/core/internal/items/messages_test.go b/services/core/internal/items/messages_test.go index eb6e8acf2..1734365cb 100644 --- a/services/core/internal/items/messages_test.go +++ b/services/core/internal/items/messages_test.go @@ -34,32 +34,9 @@ func TestMessageSnapshotsReplaceDeltasAndDoNotRegress(t *testing.T) { } } -func TestLegacyDoneDoesNotConfirmSuccessfulAnswer(t *testing.T) { - var previous v1.Item - for _, event := range []struct{ kind, body string }{ - {"delta", `{"delta":"partial answer"}`}, - {"error", `{"error":"provider failure"}`}, - {"done", `{"content":"provider failure"}`}, - {"execution_failed", `{"done":{"content":"provider failure"}}`}, - } { - updates, err := Project(testTurn, event.kind, 1, []byte(event.body)) - if err != nil { - t.Fatal(err) - } - for _, update := range updates { - previous = mustMerge(t, update, previous) - } - } - if previous.Status != "in_progress" || *previous.Content[0].Text != "partial answer" { - t.Fatal(previous) - } - updates, err := Project(testTurn, "execution_completed", 1, []byte(`{"done":{"content":"complete answer"}}`)) - if err != nil { - t.Fatal(err) - } - previous = mustMerge(t, updates[0], previous) - if previous.Status != "completed" || *previous.Content[0].Text != "complete answer" { - t.Fatal(previous) +func TestMessageDeltaRequiresIdentity(t *testing.T) { + if _, err := Project(testTurn, "delta", 1, []byte(`{"delta":"unidentified"}`)); err == nil { + t.Fatal("a delta without message identity was projected") } } diff --git a/services/core/internal/persistence/postgres/sessionpg/items.go b/services/core/internal/persistence/postgres/sessionpg/items.go index 9c8072bc4..81a376814 100644 --- a/services/core/internal/persistence/postgres/sessionpg/items.go +++ b/services/core/internal/persistence/postgres/sessionpg/items.go @@ -15,8 +15,7 @@ import ( ) // LoadItem reads what the Session holds for update's Item: the stored Item and, -// when update asks for them, whether the Turn has another assistant message and -// the result the application saved for the call. +// when update asks for it, the result the application saved for the call. func (t *SessionTx) LoadItem(ctx context.Context, turnID string, update items.Update) (items.Stored, error) { var stored items.Stored turn, err := parseID(turnID) @@ -27,11 +26,6 @@ func (t *SessionTx) LoadItem(ctx context.Context, turnID string, update items.Up if err != nil { return stored, err } - if update.NeedsNativeMessage() { - if stored.NativeMessage, err = t.q.HasNativeMessageItem(ctx, sqlc.HasNativeMessageItemParams{TurnID: turn, ID: id}); err != nil { - return stored, err - } - } row, err := t.q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: t.session, ID: id}) if err == nil { err = json.Unmarshal(row.Payload, &stored.Item) diff --git a/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go b/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go index b8aca3bd6..f96856351 100644 --- a/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go @@ -127,10 +127,6 @@ func TestItemsTakePositionsAndOutputIndexes(t *testing.T) { if err != nil || !reflect.DeepEqual(stored, items.Stored{Item: answer}) { t.Fatalf("stored %+v, %v", stored, err) } - legacy, err := bound.LoadItem(t.Context(), turnID, items.Update{Item: v1.Item{ID: uuid.NewString()}, LegacyFinal: true}) - if err != nil || !legacy.NativeMessage || legacy.Item.ID != "" { - t.Fatalf("legacy aggregate %+v, %v", legacy, err) - } return nil }) } diff --git a/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go b/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go index b77ff7707..80c7bc54f 100644 --- a/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go +++ b/services/core/internal/runtimegateway/mcp_bearer_live_linux_test.go @@ -23,6 +23,7 @@ import ( type mcpBearerTurn struct { Events []proto.Envelope `json:"events"` Done proto.DonePayload `json:"done"` + Text string `json:"text"` NativeLaunches int `json:"native_launches"` BearerEnvironmentReference string `json:"bearer_environment_reference"` } @@ -113,7 +114,7 @@ func TestLiveMCPBearerGatewayColdContinuation(t *testing.T) { turn := &mcpBearerTurn{} turns = append(turns, turn) runID := uuid.NewString() - request := proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "agents-api-" + assignment.SessionID, AgentSessionID: resume, ObserveMessages: true, DisableExecutionEnvironment: true, DisableSubagents: true, MCPHTTPServers: &servers, Model: "MiniMax-M3", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Responses, BaseURL: "https://api.minimax.cn/v1", APIKey: provider}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} + request := proto.PromptRequestPayload{AgentKind: "codex", AgentStateKey: "agents-api-" + assignment.SessionID, AgentSessionID: resume, DisableExecutionEnvironment: true, DisableSubagents: true, MCPHTTPServers: &servers, Model: "MiniMax-M3", ModelProvider: &modelprovider.Provider{Protocol: modelprovider.Responses, BaseURL: "https://api.minimax.cn/v1", APIKey: provider}, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}} sub, err := peer.SubscribeDurable(runID, assignment) if err != nil { t.Fatal("cannot subscribe before real daemon dispatch") @@ -129,11 +130,11 @@ func TestLiveMCPBearerGatewayColdContinuation(t *testing.T) { } first := run("Call private_mcp remember exactly once with tag first. Also call anonymous_mcp ping exactly once with tag first. Reply with the exact remembered value and the ping result. Do not use any other tool.", "", map[string]string{"remember": fixture.memory, "ping": "ANONYMOUS_OK"}) nativeID, _ := first.Done.Metadata[proto.DoneMetaAgentSessionID].(string) - if nativeID == "" || !strings.Contains(first.Done.Content, fixture.memory) { + if nativeID == "" || !strings.Contains(first.Text, fixture.memory) { t.Fatal("first real model Turn did not return its native identity and unpredictable tool result") } second := run("Recall the exact remembered value from the preceding tool result. Call private_mcp fail exactly once with tag cold-followup. It intentionally reports an ordinary tool error; do not retry. Reply with the earlier remembered value and the exact error text. Do not call remember, ping or any other tool.", nativeID, map[string]string{"fail": "INTENTIONAL_MCP_TOOL_ERROR:cold-followup"}) - if second.Done.Metadata[proto.DoneMetaAgentSessionID] != nativeID || !strings.Contains(second.Done.Content, fixture.memory) || !strings.Contains(second.Done.Content, "INTENTIONAL_MCP_TOOL_ERROR:cold-followup") { + if second.Done.Metadata[proto.DoneMetaAgentSessionID] != nativeID || !strings.Contains(second.Text, fixture.memory) || !strings.Contains(second.Text, "INTENTIONAL_MCP_TOOL_ERROR:cold-followup") { t.Fatal("cold native continuation lost history, identity or ordinary error output") } if second.NativeLaunches <= first.NativeLaunches || second.BearerEnvironmentReference == first.BearerEnvironmentReference { @@ -229,6 +230,12 @@ func mcpBearerCollectTurn(t *testing.T, ctx context.Context, sub *Subscription, switch event.Type { case proto.TypeError: t.Fatal("unexpected execution failure during private MCP acceptance") + case proto.TypeDelta: + var delta proto.DeltaPayload + if event.DecodePayload(&delta) != nil { + t.Fatal("invalid native message delta") + } + turn.Text += delta.Delta case proto.TypeToolCall: var call proto.ToolCallPayload if event.DecodePayload(&call) != nil || call.Observation == nil { @@ -259,7 +266,7 @@ func mcpBearerCollectTurn(t *testing.T, ctx context.Context, sub *Subscription, } after[obs.Name]++ case proto.TypeDone: - if event.DecodePayload(&turn.Done) != nil || sub.Err() != nil || turn.Done.Content == "" || turn.Done.Metadata[proto.DoneMetaAgentSessionType] != "codex_thread" { + if event.DecodePayload(&turn.Done) != nil || sub.Err() != nil || turn.Text == "" || turn.Done.Metadata[proto.DoneMetaAgentSessionType] != "codex_thread" { t.Fatal("invalid native Done or incomplete gateway delivery") } for name := range expected { diff --git a/services/core/internal/runtimegateway/session_test.go b/services/core/internal/runtimegateway/session_test.go index 28e46dc37..a1952b035 100644 --- a/services/core/internal/runtimegateway/session_test.go +++ b/services/core/internal/runtimegateway/session_test.go @@ -218,7 +218,7 @@ func TestSession_DispatchDeliversToSubscriber(t *testing.T) { if err != nil { t.Fatalf("Subscribe: %v", err) } - env, _ := proto.NewEnvelope(proto.TypeDelta, "run-1", proto.DeltaPayload{Delta: "hi", Sequence: 1}) + env, _ := proto.NewEnvelope(proto.TypeDelta, "run-1", proto.DeltaPayload{ItemID: "message", Delta: "hi", Sequence: 1}) raw, _ := jsonMarshal(env) conn.Feed(raw) @@ -241,7 +241,7 @@ func TestSession_DoneFrameAutoUnsubscribes(t *testing.T) { sub, _ := sess.SubscribeDurable("run-1", proto.AssignmentRef{}) ch := sub.Events - env, _ := proto.NewEnvelope(proto.TypeDone, "run-1", proto.DonePayload{Content: "ok"}) + env, _ := proto.NewEnvelope(proto.TypeDone, "run-1", proto.DonePayload{}) raw, _ := jsonMarshal(env) conn.Feed(raw) @@ -336,7 +336,7 @@ func TestSession_HeartbeatTouchesRuntimeAndAdmitsDeclarations(t *testing.T) { defer sess.Close("test done") kinds := []proto.SupportedAgentKind{ - {Kind: "fake_beta", Available: false, Version: "missing", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{MessageItems: proto.CapabilitySupported})}, + {Kind: "fake_beta", Available: false, Version: "missing", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{FunctionTools: proto.CapabilitySupported})}, {Kind: "codex", Available: true, Version: "1.2.3", Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{MCPHTTPTools: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported})}, } env, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{HomeRemoval: proto.CapabilityUnsupported, SupportedAgentKinds: kinds}) diff --git a/services/core/internal/sessions/projection.go b/services/core/internal/sessions/projection.go index 0d1c155c3..b06ad8565 100644 --- a/services/core/internal/sessions/projection.go +++ b/services/core/internal/sessions/projection.go @@ -106,8 +106,8 @@ func projectItem(ctx context.Context, tx ItemProjectionTx, source Source, update if err != nil { return err } - change, ok, err := items.Observe(source.Kind, update, stored) - if err != nil || !ok { + change, err := items.Observe(source.Kind, update, stored) + if err != nil { return err } index, err := tx.PutItem(ctx, source.Turn, source.CreatedAt, change) diff --git a/services/core/tests/integration/claude_execution_test.go b/services/core/tests/integration/claude_execution_test.go index be610cf59..7ea22fcea 100644 --- a/services/core/tests/integration/claude_execution_test.go +++ b/services/core/tests/integration/claude_execution_test.go @@ -88,7 +88,7 @@ func TestClaudeWorkerSelectsStoredEngineAndRestrictiveCapabilities(t *testing.T) if prompt.AgentKind != "claude_sdk" || len(prompt.FunctionTools) != 1 || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents || prompt.ExecutionControls == nil || *prompt.ExecutionControls != (proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}) { t.Fatal(prompt) } - h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "claude-native"}}) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "claude-native"}}) waitTurn(t, h, input.TurnID, sessions.TurnCompleted) bound, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, h.session.ID) if err != nil || bound.NativeSessionID != "claude-native" { diff --git a/services/core/tests/integration/claude_mcp_test.go b/services/core/tests/integration/claude_mcp_test.go index 1eb291bfb..4020362d2 100644 --- a/services/core/tests/integration/claude_mcp_test.go +++ b/services/core/tests/integration/claude_mcp_test.go @@ -82,7 +82,7 @@ func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { if server.ServerURL != endpoint || (token != "" && (server.BearerToken == nil || *server.BearerToken != token)) || (token == "" && server.BearerToken != nil) { t.Fatal("dispatch lost scoped authentication or authenticated an anonymous server") } - h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{}) waitTurn(t, h, input.TurnID, sessions.TurnCompleted) }) } diff --git a/services/core/tests/integration/dispatch_test.go b/services/core/tests/integration/dispatch_test.go index ef3aa1b46..8480c2271 100644 --- a/services/core/tests/integration/dispatch_test.go +++ b/services/core/tests/integration/dispatch_test.go @@ -261,7 +261,7 @@ func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { t.Fatal("steering retry changed identity") } h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, Accepted: true}) - h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "Finished", Usage: proto.Usage{InputTokens: 7, OutputTokens: 3}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-thread-1"}}) + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Usage: proto.Usage{InputTokens: 7, OutputTokens: 3}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-thread-1"}}) done := h.finished(result, sessions.TurnCompleted) var outcome execution.Result _ = json.Unmarshal(done.Outcome, &outcome) @@ -287,7 +287,7 @@ func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { if prompt.AgentSessionID != "native-thread-1" || prompt.AgentStateKey != "agents-api-"+h.session.ID { t.Fatal("native continuity lost") } - h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "Continued"}) + h.write(next.TurnID, proto.TypeDone, proto.DonePayload{}) h.finished(result, sessions.TurnCompleted) } @@ -360,7 +360,7 @@ func TestExecutionFailureDoesNotBecomeSuccessOrReplay(t *testing.T) { h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) case "late-input": h.message("late", "Still unprocessed") - h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "Only first input finished"}) + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) case "unconfirmed-input": h.message("second", "Steer") h.read(proto.TypePromptSteer) diff --git a/services/core/tests/integration/environment_admission_test.go b/services/core/tests/integration/environment_admission_test.go index cfbc778fb..6a176135f 100644 --- a/services/core/tests/integration/environment_admission_test.go +++ b/services/core/tests/integration/environment_admission_test.go @@ -150,7 +150,7 @@ func TestEnvironmentAdmissionWaitsForPreparedClaimAndRetainsRetry(t *testing.T) } h.write(start.RunID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: steer.InputID, Accepted: true}) } - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "done", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "admitted-native"}}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "admitted-native"}}) completeEmptyArtifactExport(t, h) run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) if run.Turn.Status != sessions.TurnCompleted { diff --git a/services/core/tests/integration/environment_directory_active_test.go b/services/core/tests/integration/environment_directory_active_test.go index 6d63a0c74..b27f6a15b 100644 --- a/services/core/tests/integration/environment_directory_active_test.go +++ b/services/core/tests/integration/environment_directory_active_test.go @@ -34,7 +34,7 @@ func TestEnvironmentDirectoryActiveRunUsesExistingOwner(t *testing.T) { if got := awaitDirectoryResult(t, result); got.err != nil || len(got.value.Entries) != 1 { t.Fatal("active read", got.err) } - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "finished"}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{}) completeEmptyArtifactExport(t, h) run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) if run.Turn.Status != sessions.TurnCompleted { diff --git a/services/core/tests/integration/environment_expiry_dispatch_test.go b/services/core/tests/integration/environment_expiry_dispatch_test.go index e1a1f4598..2766540b1 100644 --- a/services/core/tests/integration/environment_expiry_dispatch_test.go +++ b/services/core/tests/integration/environment_expiry_dispatch_test.go @@ -39,7 +39,7 @@ func TestWorkerEnvironmentExpiryAtFullExecutionCapacity(t *testing.T) { } } for i, request := range requests { - h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "finished"}) + h.write(request.ID, proto.TypeDone, proto.DonePayload{}) h.session = active[i] waitTurn(t, h, request.ID, sessions.TurnCompleted) } @@ -74,7 +74,7 @@ func TestWorkerEnvironmentExpirySkipsBusySessionAndAllowsDispatch(t *testing.T) if request.ID != receipt[0].TurnID { t.Fatal("unrelated dispatch mismatch", request.ID) } - h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "finished"}) + h.write(request.ID, proto.TypeDone, proto.DonePayload{}) waitTurn(t, h, request.ID, sessions.TurnCompleted) var state string if err := pool.QueryRow(t.Context(), "SELECT state FROM environment_input_reservations WHERE id=$1", locked.ID).Scan(&state); err != nil || state != sessions.EnvironmentInputPending { diff --git a/services/core/tests/integration/environment_worker_scan_test.go b/services/core/tests/integration/environment_worker_scan_test.go index 5d0c408e2..500996e4f 100644 --- a/services/core/tests/integration/environment_worker_scan_test.go +++ b/services/core/tests/integration/environment_worker_scan_test.go @@ -29,7 +29,7 @@ func TestWorkerEnvironmentRetriesNewlyReadyAtNextScan(t *testing.T) { t.Fatal("unexpected scan barrier") } awaitFixtureCapabilities(t, runtime, workerEnvironmentCapabilities()) - h.write(barrier.ID, proto.TypeDone, proto.DonePayload{Content: "complete"}) + h.write(barrier.ID, proto.TypeDone, proto.DonePayload{}) waitTurn(t, h, barrier.ID, sessions.TurnCompleted) prepare := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) @@ -74,7 +74,7 @@ func TestWorkerEnvironmentPaginationReachesReadyTail(t *testing.T) { if barrier.ID != receipt.TurnID { t.Fatal("unexpected page barrier") } - h.write(barrier.ID, proto.TypeDone, proto.DonePayload{Content: "complete"}) + h.write(barrier.ID, proto.TypeDone, proto.DonePayload{}) waitTurn(t, h, barrier.ID, sessions.TurnCompleted) // The first 100 unbound inputs must not pin the cursor, and the ready // tail must wait for its own bounded page rather than an unbounded drain. diff --git a/services/core/tests/integration/environment_worker_test.go b/services/core/tests/integration/environment_worker_test.go index 3ba10d5ce..ae998860c 100644 --- a/services/core/tests/integration/environment_worker_test.go +++ b/services/core/tests/integration/environment_worker_test.go @@ -114,11 +114,11 @@ func TestWorkerEnvironmentSharesCapacityThroughClaimAndCleanup(t *testing.T) { } normal = append(normal, resumed) for _, request := range normal { - h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "ordinary complete"}) + h.write(request.ID, proto.TypeDone, proto.DonePayload{}) h.session = ordinary[request.ID] waitTurn(t, h, request.ID, sessions.TurnCompleted) } - firstRuntime.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "local complete"}) + firstRuntime.write(start.RunID, proto.TypeDone, proto.DonePayload{}) completeEmptyArtifactExport(t, firstRuntime, frames) nextWorkerFrame(t, frames, proto.TypeExecutionRelease) stop() @@ -144,7 +144,7 @@ func TestWorkerEnvironmentRetriesPendingWithoutExtendingDeadline(t *testing.T) { if request.ID != receipt.TurnID { t.Fatal("preparation failure blocked ordinary work") } - h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "complete"}) + h.write(request.ID, proto.TypeDone, proto.DonePayload{}) waitTurn(t, h, request.ID, sessions.TurnCompleted) second := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) if elapsed := time.Since(started); elapsed < 750*time.Millisecond || elapsed > 3*time.Second || first.ID == second.ID { @@ -172,7 +172,7 @@ func TestWorkerEnvironmentRetriesPendingWithoutExtendingDeadline(t *testing.T) { t.Fatal("restart changed retained preparation") } runtime.write(third.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) - runtime.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "resumed"}) + runtime.write(start.RunID, proto.TypeDone, proto.DonePayload{}) completeEmptyArtifactExport(t, runtime, frames) run := awaitWorkerEnvironmentRun(t, t.Context(), h.s, h.tenant, pending) if run.Turn.Status != sessions.TurnCompleted || !run.Reservation.Deadline.Equal(pending.Deadline) { diff --git a/services/core/tests/integration/execution_events_test.go b/services/core/tests/integration/execution_events_test.go index 9588652e5..bf04e5072 100644 --- a/services/core/tests/integration/execution_events_test.go +++ b/services/core/tests/integration/execution_events_test.go @@ -18,7 +18,7 @@ func TestExecutionPersistsLiveAndCancelledPartialOutput(t *testing.T) { input := h.message("start", "Stream then cancel") result := h.run(ctx, input.TurnID) h.read(testExecutionRequest) - h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{Delta: "已输出", Sequence: 1}) + h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{ItemID: "answer", Delta: "已输出", Sequence: 1}) h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: "tool-1", Name: "Bash", Stage: "before", Observation: &proto.ToolObservation{Kind: "command", Command: "pwd", Status: "in_progress"}}) h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: "tool-1", Name: "Bash", Stage: "after", Observation: &proto.ToolObservation{Kind: "command", Command: "pwd", Status: "completed"}}) h.write(input.TurnID, proto.TypeUsage, proto.UsagePayload{Usage: proto.Usage{InputTokens: 11, OutputTokens: 2}}) @@ -45,7 +45,7 @@ func TestExecutionPersistsLiveAndCancelledPartialOutput(t *testing.T) { var cancel proto.PromptCancelPayload _ = cancelEnv.DecodePayload(&cancel) for i := range 30 { - h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{Delta: "片段", Sequence: uint64(i + 2)}) + h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{ItemID: "answer", Delta: "片段", Sequence: uint64(i + 2)}) } h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) h.finished(result, sessions.TurnCancelled) @@ -85,8 +85,8 @@ func TestExecutionDoesNotCompleteAfterEventPersistenceFailure(t *testing.T) { if _, err := pool.Exec(ctx, "UPDATE turns SET event_bytes=33554432 WHERE id=$1", input.TurnID); err != nil { t.Fatal(err) } - h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{Delta: "cannot be stored", Sequence: 1}) - h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "Do not report success", Usage: proto.Usage{InputTokens: 13}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "failed-native"}}) + h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{ItemID: "answer", Delta: "cannot be stored", Sequence: 1}) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Usage: proto.Usage{InputTokens: 13}, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "failed-native"}}) turn := h.finished(result, sessions.TurnFailed) var outcome execution.Result _ = json.Unmarshal(turn.Outcome, &outcome) diff --git a/services/core/tests/integration/execution_messages_test.go b/services/core/tests/integration/execution_messages_test.go index 9a4b3f044..0b04b736a 100644 --- a/services/core/tests/integration/execution_messages_test.go +++ b/services/core/tests/integration/execution_messages_test.go @@ -2,83 +2,52 @@ package integration import ( "context" - "encoding/json" "testing" - "time" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -func TestExecutionNegotiatesAndPersistsMessageObservations(t *testing.T) { - h := newDispatchHarness(t) - ctx := context.Background() - first := h.message("legacy", "legacy observation policy") - result := h.run(ctx, first.TurnID) - var request proto.PromptRequestPayload - env := h.read(testExecutionRequest) - _ = env.DecodePayload(&request) - if request.ObserveMessages { - t.Fatal("unadvertised observation capability requested") - } - h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) - h.finished(result, sessions.TurnCompleted) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{HomeRemoval: proto.CapabilityUnsupported, SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: prototest.Capabilities(proto.AgentKindCapabilities{WebSearchControl: proto.CapabilitySupported, TextVerbosity: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, MessageItems: proto.CapabilitySupported, NativeSessionRecovery: proto.CapabilitySupported, EnvironmentNone: proto.CapabilitySupported})}}}) - deadline := time.Now().Add(3 * time.Second) - for { - peer, err := h.registry.LookupDevice(h.device.ID) - if err != nil { - t.Fatal(err) - } - info, _, _ := peer.AgentKindStatus("codex") - if info.Capabilities.MessageItems.IsSupported() { - break - } - if time.Now().After(deadline) { - t.Fatal("message capability lost in gateway") - } - time.Sleep(10 * time.Millisecond) - } - input := h.message("observed", "stream separate messages") - result = h.run(ctx, input.TurnID) - env = h.read(testExecutionRequest) - _ = env.DecodePayload(&request) - if !request.ObserveMessages { - t.Fatal("advertised capability was not requested") - } - text := "complete" - h.write(input.TurnID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "a", Status: "in_progress", Phase: "commentary"}) - h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{ItemID: "a", Delta: text, Sequence: 1}) - h.write(input.TurnID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "a", Status: "completed", Phase: "commentary", Text: &text}) - h.write(input.TurnID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "b", Status: "in_progress", Phase: "final_answer"}) - h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{ItemID: "b", Delta: "partial", Sequence: 2}) - if _, err := requestCancel(ctx, h.s, h.tenant, h.session.ID, "cancel"); err != nil { - t.Fatal(err) - } - env = h.read(proto.TypePromptCancel) - var cancel proto.PromptCancelPayload - _ = env.DecodePayload(&cancel) - h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) - h.finished(result, sessions.TurnCancelled) - reopened, pool := testStore(t) - defer pool.Close() - events, err := reopened.ListTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 0, 100) - if err != nil { - t.Fatal(err) - } - if len(events) != 7 { - t.Fatalf("lost message observations: %+v", events) - } - var complete proto.OutputMessagePayload - if err = json.Unmarshal(events[2].Payload, &complete); err != nil || complete.ID != "a" || complete.Text == nil || *complete.Text != "complete" || complete.Phase != "commentary" { - t.Fatalf("completed snapshot: %+v %v", complete, err) - } - var delta proto.DeltaPayload - if err = json.Unmarshal(events[4].Payload, &delta); err != nil || delta.ItemID != "b" || delta.Delta != "partial" { - t.Fatalf("cancelled partial identity lost: %+v %v", delta, err) - } - if events[5].Kind != "cancel_receipt" || events[6].Kind != "execution_cancelled" { - t.Fatal("terminal ordering changed") +// TestExecutionProjectsIdentifiedMessages checks that each assistant message +// becomes the Item its identity names: the completion snapshot replaces its +// deltas, and a message the Turn leaves open ends incomplete when the Turn is +// cancelled or fails. +func TestExecutionProjectsIdentifiedMessages(t *testing.T) { + for _, end := range []string{sessions.TurnCancelled, sessions.TurnFailed} { + t.Run(end, func(t *testing.T) { + h := newDispatchHarness(t) + ctx := context.Background() + input := h.message("observed", "stream separate messages") + result := h.run(ctx, input.TurnID) + h.read(testExecutionRequest) + text := "complete" + h.write(input.TurnID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "a", Status: "in_progress", Phase: "commentary"}) + h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{ItemID: "a", Delta: "draft", Sequence: 1}) + h.write(input.TurnID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "a", Status: "completed", Phase: "commentary", Text: &text}) + h.write(input.TurnID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "b", Status: "in_progress", Phase: "final_answer"}) + h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{ItemID: "b", Delta: "partial", Sequence: 2}) + if end == sessions.TurnCancelled { + if _, err := requestCancel(ctx, h.s, h.tenant, h.session.ID, "cancel"); err != nil { + t.Fatal(err) + } + var cancel proto.PromptCancelPayload + _ = h.read(proto.TypePromptCancel).DecodePayload(&cancel) + h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) + } else { + h.write(input.TurnID, proto.TypeError, proto.ErrorPayload{Error: "Engine failed"}) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{}) + } + h.finished(result, end) + page, err := sessionAdapter(h.s).ListItems(ctx, h.tenant, h.session.ID, "", 100, true) + if err != nil || len(page.Items) != 3 { + t.Fatal(page, err) + } + for i, want := range []struct{ status, phase, text string }{{"completed", "commentary", "complete"}, {"incomplete", "final_answer", "partial"}} { + item := page.Items[i+1] + if item.Type != "message" || item.Role != "assistant" || item.Status != want.status || item.Phase != want.phase || len(item.Content) != 1 || item.Content[0].Text == nil || *item.Content[0].Text != want.text { + t.Fatalf("message %d: %+v", i, item) + } + } + }) } } diff --git a/services/core/tests/integration/execution_test.go b/services/core/tests/integration/execution_test.go index 8e73078db..8e8f81218 100644 --- a/services/core/tests/integration/execution_test.go +++ b/services/core/tests/integration/execution_test.go @@ -162,10 +162,10 @@ func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { mustReject("binding", sessionExecution(t, writer.lease).BindSessionDevice(t.Context(), tenant, queued.ID, host.ID)) _, err = operations.TransitionTurn(t.Context(), tenant, queued.ID, pending.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) mustReject("claim", err) - mustReject("journal", operations.AppendTurnEvents(t.Context(), tenant, active.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"stale"}`)}})) + mustReject("journal", operations.AppendTurnEvents(t.Context(), tenant, active.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"stale"}`)}})) mustReject("callback", operations.RecordFunctionCall(t.Context(), tenant, active.ID, input.TurnID, functionCallFixture("late"))) mustReject("receipt", operations.ConfirmFunctionResult(t.Context(), tenant, waiting.ID, waitInput.TurnID, call.CallID)) - _, err = operations.CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, sessions.TurnCompleted, json.RawMessage(`{"done":{"content":"stale"}}`), "stale-native", input.Sequence) + _, err = operations.CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, sessions.TurnCompleted, json.RawMessage(`{"done":{}}`), "stale-native", input.Sequence) mustReject("completion", err) _, err = operations.TransitionTurn(t.Context(), tenant, active.ID, input.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed}) mustReject("reconciliation", err) @@ -201,7 +201,7 @@ func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { t.Fatal(err) } successorOperations := sessionExecution(t, successor.lease) - if _, err = successorOperations.CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, sessions.TurnCompleted, json.RawMessage(`{"done":{"content":"accepted"}}`), "successor-native", input.Sequence); err != nil { + if _, err = successorOperations.CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, sessions.TurnCompleted, json.RawMessage(`{"done":{}}`), "successor-native", input.Sequence); err != nil { t.Fatal(err) } bound, err := sessionAdapter(s).GetSessionExecutionBinding(t.Context(), tenant, active.ID) @@ -248,7 +248,7 @@ func TestExecutionWriterSerializesWritesOnItsLease(t *testing.T) { group.Go(func() { _, err := journal.TransitionTurn(t.Context(), task.tenant, task.session, task.turn, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err == nil { - err = journal.AppendTurnEvents(t.Context(), task.tenant, task.session, task.turn, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"accepted"}`)}}) + err = journal.AppendTurnEvents(t.Context(), task.tenant, task.session, task.turn, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"accepted"}`)}}) } results <- err }) diff --git a/services/core/tests/integration/execution_tools_test.go b/services/core/tests/integration/execution_tools_test.go index 77cf4c88f..9cd17ef96 100644 --- a/services/core/tests/integration/execution_tools_test.go +++ b/services/core/tests/integration/execution_tools_test.go @@ -16,12 +16,7 @@ func TestExecutionNegotiatesAndPersistsToolObservations(t *testing.T) { ctx := context.Background() input := h.message("observed", "run tools") result := h.run(ctx, input.TurnID) - env := h.read(testExecutionRequest) - var request proto.PromptRequestPayload - _ = env.DecodePayload(&request) - if request.ObserveMessages { - t.Fatal("unadvertised message items were requested") - } + h.read(testExecutionRequest) start := json.RawMessage(`{"kind":"mcp","server":"reference","name":"lookup","arguments":{"key":"value"},"status":"in_progress","output":null,"error":null}`) complete := json.RawMessage(`{"kind":"mcp","server":"reference","name":"lookup","arguments":{"key":"value"},"status":"completed","output":{"content":[{"type":"text","text":"answer"}],"structuredContent":{"version":9007199254740993}},"error":null}`) partial := json.RawMessage(`{"kind":"command","command":"long-running","status":"in_progress"}`) @@ -42,7 +37,7 @@ func TestExecutionNegotiatesAndPersistsToolObservations(t *testing.T) { if _, err := requestCancel(ctx, h.s, h.tenant, h.session.ID, "cancel"); err != nil { t.Fatal(err) } - env = h.read(proto.TypePromptCancel) + env := h.read(proto.TypePromptCancel) var cancel proto.PromptCancelPayload _ = env.DecodePayload(&cancel) h.write(input.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) diff --git a/services/core/tests/integration/executor_recovery_test.go b/services/core/tests/integration/executor_recovery_test.go index 6b29660a0..589055f0b 100644 --- a/services/core/tests/integration/executor_recovery_test.go +++ b/services/core/tests/integration/executor_recovery_test.go @@ -42,7 +42,7 @@ func TestExecutorRecoveryRetriesOnlyConfirmedUnsubmittedInput(t *testing.T) { t.Fatal("recovery changed input or reused retired control ownership") } h.write(next.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: replacement.Handle, ExecutorID: replacement.ExecutorID, Revision: 2, State: "started", RunID: replacement.RunID}) - h.write(replacement.RunID, proto.TypeDone, proto.DonePayload{Content: "once"}) + h.write(replacement.RunID, proto.TypeDone, proto.DonePayload{}) h.finished(result, sessions.TurnCompleted) }) } diff --git a/services/core/tests/integration/function_execution_test.go b/services/core/tests/integration/function_execution_test.go index 05df7bee4..f2b01ae72 100644 --- a/services/core/tests/integration/function_execution_test.go +++ b/services/core/tests/integration/function_execution_test.go @@ -93,7 +93,7 @@ func TestExecutionFunctionsWaitForEveryApplicationReceipt(t *testing.T) { t.Fatal(saved, reply, err) } if index == 1 { - h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-functions"}}) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-functions"}}) select { case got := <-result: t.Fatal("Done bypassed outstanding receipt", got) @@ -112,7 +112,7 @@ func TestExecutionFunctionsWaitForEveryApplicationReceipt(t *testing.T) { if prompt.AgentSessionID != "native-functions" || len(prompt.FunctionTools) != 1 { t.Fatal(prompt) } - h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "resumed"}) + h.write(next.TurnID, proto.TypeDone, proto.DonePayload{}) h.finished(result, sessions.TurnCompleted) } diff --git a/services/core/tests/integration/function_input_execution_test.go b/services/core/tests/integration/function_input_execution_test.go index e055b1d62..64c3b056f 100644 --- a/services/core/tests/integration/function_input_execution_test.go +++ b/services/core/tests/integration/function_input_execution_test.go @@ -53,7 +53,7 @@ func TestExecutionFunctionInputBatchStillSteersMessages(t *testing.T) { h.write(input.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: steer.InputID, Accepted: true}) } } - h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{}) h.finished(running, sessions.TurnCompleted) saved, err := FixtureFunctionCall(t.Context(), h.s.pool, h.tenant, h.session.ID, input.TurnID, state.RequiredActions[0].CallID) if err != nil || !saved.Applied { diff --git a/services/core/tests/integration/function_worker_test.go b/services/core/tests/integration/function_worker_test.go index 13d50167d..1880af529 100644 --- a/services/core/tests/integration/function_worker_test.go +++ b/services/core/tests/integration/function_worker_test.go @@ -108,7 +108,7 @@ func TestWorkerWaitsForToolCapabilities(t *testing.T) { } else if len(prompt.FunctionTools) != 1 || prompt.FunctionTools[0].Name != "lookup_ticket" { t.Fatal(prompt) } - h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "done"}) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{}) waitTurn(t, h, input.TurnID, sessions.TurnCompleted) }) } diff --git a/services/core/tests/integration/harness_onboarding_test.go b/services/core/tests/integration/harness_onboarding_test.go index 6dcef7f2a..cdb5f25ca 100644 --- a/services/core/tests/integration/harness_onboarding_test.go +++ b/services/core/tests/integration/harness_onboarding_test.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" goruntime "runtime" + "slices" "strings" "sync" "testing" @@ -108,9 +109,15 @@ func TestThirdHarnessPublicOnboarding(t *testing.T) { if inputErr != nil || len(inputs) != 2 { t.Fatal(inputs, inputErr) } - if err = json.Unmarshal(turn.Outcome, &result); err != nil || result.AppliedThrough != inputs[1].Sequence || result.Done.Content != "readyfinish" { + if err = json.Unmarshal(turn.Outcome, &result); err != nil || result.AppliedThrough != inputs[1].Sequence { t.Fatal(string(turn.Outcome), err) } + page, err := sessionAdapter(h.s).ListItems(ctx, h.tenant, created.ID, "", 100, true) + if err != nil || !slices.ContainsFunc(page.Items, func(item v1.Item) bool { + return item.Role == "assistant" && item.Status == "completed" && len(item.Content) == 1 && item.Content[0].Text != nil && *item.Content[0].Text == "readyfinish" + }) { + t.Fatal("the answer is not a completed message Item", page, err) + } bound, err := sessionAdapter(h.s).GetSessionExecutionBinding(ctx, h.tenant, created.ID) if err != nil || bound.NativeSessionID == "" { t.Fatal(bound, err) diff --git a/services/core/tests/integration/item_reads_test.go b/services/core/tests/integration/item_reads_test.go index 27585bf45..e0be28ca3 100644 --- a/services/core/tests/integration/item_reads_test.go +++ b/services/core/tests/integration/item_reads_test.go @@ -39,7 +39,7 @@ func TestItemsRecoverSnapshotsPartialResultsPaginationAndIsolation(t *testing.T) {Kind: "tool_call", Payload: json.RawMessage(`{"id":"mcp","stage":"after","observation":{"status":"completed","kind":"mcp","server":"reference","name":"lookup","arguments":{},"output":{"structuredContent":{"number":9007199254740993}}}}`)}, {Kind: "delta", Payload: json.RawMessage(`{"item_id":"partial","delta":"unfinished"}`)}, {Kind: "tool_call", Payload: json.RawMessage(`{"id":"waiting","stage":"before","observation":{"status":"in_progress","kind":"command","command":"sleep 10"}}`)}, - {Kind: "done", Payload: json.RawMessage(`{"content":"corrected answer","metadata":{"agent_session_id":"PRIVATE"}}`)}, + {Kind: "done", Payload: json.RawMessage(`{"metadata":{"agent_session_id":"PRIVATE"}}`)}, } for range 2 { if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { @@ -116,12 +116,12 @@ func TestItemsRecoverSnapshotsPartialResultsPaginationAndIsolation(t *testing.T) } } -func TestItemProjectionFailureRollsBackJournalAndAggregateRecovers(t *testing.T) { +func TestItemProjectionFailureRollsBackJournal(t *testing.T) { ctx := context.Background() s, _ := testStore(t) journal := executionOwner(t, s).Sessions tenant := uuid.NewString() - session, _ := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "legacy"}) + session, _ := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "rollback"}) input, err := sendMessage(ctx, s, tenant, session.ID, "input", messageText("test")) if err != nil { t.Fatal(err) @@ -130,7 +130,7 @@ func TestItemProjectionFailureRollsBackJournalAndAggregateRecovers(t *testing.T) if err != nil { t.Fatal(err) } - bad := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"must roll back"}`)}, {Kind: "tool_call", Payload: json.RawMessage(`{"id":"mismatch","stage":"after","observation":{"status":"completed","kind":"invalid"}}`)}} + bad := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"must roll back"}`)}, {Kind: "tool_call", Payload: json.RawMessage(`{"id":"mismatch","stage":"after","observation":{"status":"completed","kind":"invalid"}}`)}} if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, bad); err == nil { t.Fatal("invalid snapshot accepted") } @@ -142,49 +142,9 @@ func TestItemProjectionFailureRollsBackJournalAndAggregateRecovers(t *testing.T) if err != nil || len(page.Items) != 1 { t.Fatal(page, err) } - _, err = transitionTurn(ctx, s, tenant, session.ID, input.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnCompleted, Outcome: json.RawMessage(`{"done":{"content":"legacy answer","metadata":{"private":"SECRET"}}}`)}) - if err != nil { - t.Fatal(err) - } - current, err := sessionAdapter(s).ListItems(ctx, tenant, session.ID, "", 100, true) - if err != nil || len(current.Items) != 2 || *current.Items[1].Content[0].Text != "legacy answer" { - t.Fatal(current, err) - } -} - -func TestReceiptOnlyTextRecoversWithoutInventingCompletion(t *testing.T) { - ctx := context.Background() - s, _ := testStore(t) - journal := executionOwner(t, s).Sessions - tenant := uuid.NewString() - for _, receiptOnly := range []bool{true, false} { - session, _ := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString()}) - input, err := sendMessage(ctx, s, tenant, session.ID, "first", messageText("test")) - if err != nil { - t.Fatal(err) - } - _, err = transitionTurn(ctx, s, tenant, session.ID, input.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) - if err != nil { - t.Fatal(err) - } - if receiptOnly { - err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "cancel_receipt", Payload: json.RawMessage(`{"applied":true,"outcome":{"content":"retained cancellation text"}}`)}}) - if err != nil { - t.Fatal(err) - } - } - _, err = journal.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{"done":{"content":"retained cancellation text"}}`), "", input.Sequence) - if err != nil { - t.Fatal(err) - } - page, err := sessionAdapter(s).ListItems(ctx, tenant, session.ID, "", 100, true) - if err != nil || len(page.Items) != 2 || page.Items[1].Status != "incomplete" || *page.Items[1].Content[0].Text != "retained cancellation text" { - t.Fatal(page, err) - } - } } -func TestLegacyFailureRetainsPartialAnswerAcrossRecovery(t *testing.T) { +func TestFailureRetainsPartialAnswerAcrossRecovery(t *testing.T) { ctx := context.Background() s, _ := testStore(t) journal := executionOwner(t, s).Sessions @@ -202,16 +162,16 @@ func TestLegacyFailureRetainsPartialAnswerAcrossRecovery(t *testing.T) { t.Fatal(err) } batch := []sessions.ExecutionEvent{ - {Kind: "delta", Payload: json.RawMessage(`{"delta":"partial answer"}`)}, + {Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"partial answer"}`)}, {Kind: "tool_call", Payload: json.RawMessage(`{"id":"open","stage":"after","observation":{"status":"completed","kind":"web_search","action":{"type":"open_page","url":"https://example.com"}}}`)}, {Kind: "tool_call", Payload: json.RawMessage(`{"id":"find","stage":"after","observation":{"status":"completed","kind":"web_search","action":{"type":"find_in_page","url":"https://example.com","pattern":"needle"}}}`)}, {Kind: "error", Payload: json.RawMessage(`{"error":"provider failure"}`)}, - {Kind: "done", Payload: json.RawMessage(`{"content":"provider failure"}`)}, + {Kind: "done", Payload: json.RawMessage(`{}`)}, } if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { t.Fatal(err) } - _, err = journal.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnFailed, json.RawMessage(`{"done":{"content":"provider failure"},"error_code":"engine_failed"}`), "", input.Sequence) + _, err = journal.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnFailed, json.RawMessage(`{"done":{},"error_code":"engine_failed"}`), "", input.Sequence) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/local_environment_worker_test.go b/services/core/tests/integration/local_environment_worker_test.go index 2bcf9d83d..cdc91ddf6 100644 --- a/services/core/tests/integration/local_environment_worker_test.go +++ b/services/core/tests/integration/local_environment_worker_test.go @@ -135,7 +135,7 @@ func TestLocalEnvironmentWorkerSchedulesPreparationWithoutRemoteResolver(t *test t.Fatal("local Start changed reservation identity") } h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "complete", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "local-native-history"}}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "local-native-history"}}) completeLocalArtifactExport(t, h, worker, environment) awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "local completion", func() bool { turn, err := sessionAdapter(h.s).GetTurn(t.Context(), h.tenant, h.session.ID, start.RunID) diff --git a/services/core/tests/integration/prepared_dispatch_failure_test.go b/services/core/tests/integration/prepared_dispatch_failure_test.go index 6f486bf47..357a9f224 100644 --- a/services/core/tests/integration/prepared_dispatch_failure_test.go +++ b/services/core/tests/integration/prepared_dispatch_failure_test.go @@ -94,7 +94,7 @@ func TestPreparedDispatchHandlesStartRejectionAndPendingStartCancellation(t *tes if frame.ID != start.RunID || frame.DecodePayload(&cancel) != nil || cancel.DeliveryID == "" { t.Fatal("pending Start cancellation lost Run ownership", frame.ID, cancel) } - ack := proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{Content: "stopped"}} + ack := proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}} if action == "cancel-no-outcome" { // The current daemon can cancel a starting owner before a Session supplies an outcome. ack.Outcome = nil @@ -160,7 +160,7 @@ func TestPreparedDispatchCancellationReceiptSurvivesStartFailure(t *testing.T) { ack := proto.InteractionDecisionAckPayload{DeliveryID: cancel.DeliveryID, Applied: true} if withOutcome { // Also verify preservation when a native adapter can supply a complete outcome. - ack.Outcome = &proto.DonePayload{Content: "retained cancellation", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "cancelled-prepared-native"}} + ack.Outcome = &proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "cancelled-prepared-native"}} } h.write(start.RunID, proto.TypeInteractionDecisionAck, ack) got := awaitPreparedDispatch(t, result) @@ -190,8 +190,8 @@ func TestPreparedDispatchCancellationReceiptSurvivesStartFailure(t *testing.T) { t.Fatal("cancellation receipt was not journaled once", receipts) } bound, err := sessionAdapter(h.s).GetSessionExecutionBinding(t.Context(), h.tenant, h.session.ID) - if err != nil || (withOutcome && (bound.NativeSessionID != "cancelled-prepared-native" || outcome.Done.Content != "retained cancellation")) { - t.Fatal("cancellation lost native continuation or final output", err) + if err != nil || (withOutcome && bound.NativeSessionID != "cancelled-prepared-native") { + t.Fatal("cancellation lost native continuation", err) } }) } diff --git a/services/core/tests/integration/prepared_dispatch_test.go b/services/core/tests/integration/prepared_dispatch_test.go index 4e5f6eb86..86d45f84a 100644 --- a/services/core/tests/integration/prepared_dispatch_test.go +++ b/services/core/tests/integration/prepared_dispatch_test.go @@ -98,7 +98,7 @@ func TestPreparedDispatchPromotesOriginalBatchAndPersistsCompletion(t *testing.T t.Fatal("later input bypassed ordinary steering", steer) } h.write(start.RunID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: steer.InputID, Accepted: true}) - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "answer", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "retained-prepared-native"}}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "retained-prepared-native"}}) completeEmptyArtifactExport(t, h) got := awaitPreparedDispatch(t, result) if got.err != nil || got.run.Turn.Status != sessions.TurnCompleted || len(got.run.Reservation.Receipts) != 2 || got.run.Reservation.Receipts[0].Replayed || got.run.Reservation.Receipts[1].Sequence >= late.Sequence { @@ -132,7 +132,7 @@ func TestPreparedDispatchOwnerOutlivesReservationDeadline(t *testing.T) { t.Fatal("admitted execution lost its owner to the pending-input deadline", err) } h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "completed after the reservation deadline"}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{}) completeEmptyArtifactExport(t, h) got := awaitPreparedDispatch(t, result) if got.err != nil || got.run.Turn.Status != sessions.TurnCompleted { diff --git a/services/core/tests/integration/public_execution_test.go b/services/core/tests/integration/public_execution_test.go index 0054a1690..d1a568665 100644 --- a/services/core/tests/integration/public_execution_test.go +++ b/services/core/tests/integration/public_execution_test.go @@ -71,7 +71,9 @@ func TestExecutionWorkerAdmissionBindingAndRecovery(t *testing.T) { if err != nil || active.LastTurn == nil || active.LastTurn.Status != sessions.TurnInProgress { t.Fatal(active, err) } - h.write(request.ID, proto.TypeDone, proto.DonePayload{Content: "Answer", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "worker-native"}}) + answer := "Answer" + h.write(request.ID, proto.TypeOutputMessage, proto.OutputMessagePayload{ID: "answer", Status: "completed", Text: &answer}) + h.write(request.ID, proto.TypeDone, proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "worker-native"}}) waitTurn(t, h, receipts[0].TurnID, sessions.TurnCompleted) items, err := sessionAdapter(h.s).ListItems(ctx, h.tenant, h.session.ID, "", 100, true) if err != nil || len(items.Items) != 3 { diff --git a/services/core/tests/integration/runtime_worker_recovery_test.go b/services/core/tests/integration/runtime_worker_recovery_test.go index 0a8b08e51..e7dd3e87b 100644 --- a/services/core/tests/integration/runtime_worker_recovery_test.go +++ b/services/core/tests/integration/runtime_worker_recovery_test.go @@ -116,7 +116,7 @@ func TestWorkerWaitsForComputeAndSurvivesPromotionConflict(t *testing.T) { t.Fatal("retry changed pending work", start) } h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 3, State: "started", RunID: start.RunID}) - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "completed once"}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{}) completeEmptyArtifactExport(t, h, frames) if release := nextWorkerFrame(t, frames, proto.TypeExecutionRelease); release.ID != prepare.ID { t.Fatal("completed preparation was not released") diff --git a/services/core/tests/integration/session_deletion_execution_test.go b/services/core/tests/integration/session_deletion_execution_test.go index fb1872d74..44496dce5 100644 --- a/services/core/tests/integration/session_deletion_execution_test.go +++ b/services/core/tests/integration/session_deletion_execution_test.go @@ -60,7 +60,7 @@ func TestDeletedSessionWaitingTurnSettlesWithoutStoppingWorker(t *testing.T) { h.session = publicSession(t, h, "unrelated") next := h.message("next", "Unrelated work") h.read(testExecutionRequest) - h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "unaffected"}) + h.write(next.TurnID, proto.TypeDone, proto.DonePayload{}) waitTurn(t, h, next.TurnID, sessions.TurnCompleted) } @@ -156,6 +156,6 @@ func TestWaitingSessionCancelsThenDeletesThroughWorker(t *testing.T) { h.session = publicSession(t, h, "unrelated") next := h.message("next", "Unrelated work") h.read(testExecutionRequest) - h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "unaffected"}) + h.write(next.TurnID, proto.TypeDone, proto.DonePayload{}) waitTurn(t, h, next.TurnID, sessions.TurnCompleted) } diff --git a/services/core/tests/integration/steering_receipts_test.go b/services/core/tests/integration/steering_receipts_test.go index ac020f4b5..ed8b3e5f2 100644 --- a/services/core/tests/integration/steering_receipts_test.go +++ b/services/core/tests/integration/steering_receipts_test.go @@ -35,10 +35,10 @@ func TestExecutionDurableInputReceiptLifetime(t *testing.T) { case <-time.After(31 * time.Second): } h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, Accepted: true}) - h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "completed"}) + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) status = sessions.TurnCompleted case "missing-at-done": - h.write(first.TurnID, proto.TypeDone, proto.DonePayload{Content: "unconfirmed input"}) + h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) case "retry-after-write": h.write(first.TurnID, proto.TypePromptSteerAck, proto.PromptSteerAckPayload{InputID: input.InputID, ErrorCode: "not_ready"}) case "cancel-unknown-first": @@ -55,7 +55,7 @@ func TestExecutionDurableInputReceiptLifetime(t *testing.T) { t.Fatalf("input preempted cancellation: %+v", got) case <-time.After(300 * time.Millisecond): } - h.write(first.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, Applied: true, Outcome: &proto.DonePayload{Content: "partial"}}) + h.write(first.TurnID, proto.TypeInteractionDecisionAck, proto.InteractionDecisionAckPayload{DeliveryID: request.DeliveryID, Applied: true, Outcome: &proto.DonePayload{}}) status = sessions.TurnCancelled } done := h.finished(result, status) diff --git a/services/core/tests/integration/structured_output_dispatch_test.go b/services/core/tests/integration/structured_output_dispatch_test.go index 989f49429..650970942 100644 --- a/services/core/tests/integration/structured_output_dispatch_test.go +++ b/services/core/tests/integration/structured_output_dispatch_test.go @@ -30,7 +30,7 @@ func TestStructuredOutputDispatchRechecksOperationQualification(t *testing.T) { if err = bindSessionDevice(t, h.s, h.tenant, h.session.ID, h.device.ID); err != nil { t.Fatal(err) } - caps := prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, StructuredOutput: proto.CapabilitySupported, MessageItems: proto.CapabilitySupported}) + caps := prototest.Capabilities(proto.AgentKindCapabilities{EnvironmentNone: proto.CapabilitySupported, SubagentControl: proto.CapabilitySupported, StructuredOutput: proto.CapabilitySupported}) h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{HomeRemoval: proto.CapabilityUnsupported, SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "fixture_harness", Available: true, Capabilities: caps}}}) peer, _ := h.registry.LookupDevice(h.device.ID) for deadline := time.Now().Add(3 * time.Second); ; { diff --git a/services/core/tests/integration/subagent_dispatch_test.go b/services/core/tests/integration/subagent_dispatch_test.go index 58ad23e75..346e88fa5 100644 --- a/services/core/tests/integration/subagent_dispatch_test.go +++ b/services/core/tests/integration/subagent_dispatch_test.go @@ -49,7 +49,7 @@ func TestSubagentIdentityUsesLeasedDispatchJournal(t *testing.T) { childTurn.Status, childTurn.CompletedAtMS = sessions.TurnCompleted, &completed h.write(input.TurnID, proto.TypeSubagentTurn, childTurn) h.write(input.TurnID, proto.TypeSubagentLifecycle, proto.SubagentLifecyclePayload{NativeID: "child", EffectID: "native-close", Status: "closed", OccurredAtMS: 102000}) - h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Content: "root result", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "root"}}) + h.write(input.TurnID, proto.TypeDone, proto.DonePayload{Metadata: map[string]any{proto.DoneMetaAgentSessionID: "root"}}) h.finished(running, sessions.TurnCompleted) saved, err := h.s.GetSubagentIdentity(ctx, h.tenant, h.session.ID, "child") if err != nil || saved.NativeID != "child" || saved.ParentNativeID != "root" || saved.FirstTurnID != input.TurnID { diff --git a/services/core/tests/integration/subagent_identities_test.go b/services/core/tests/integration/subagent_identities_test.go index f84c309fa..4b2d47ce4 100644 --- a/services/core/tests/integration/subagent_identities_test.go +++ b/services/core/tests/integration/subagent_identities_test.go @@ -69,7 +69,7 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { } { // A preceding new identity and public output must roll back with the conflict. bad := []sessions.ExecutionEvent{subagentIdentityEvent("rollback-child", "root", 105), - {Kind: proto.TypeDelta, Payload: json.RawMessage(`{"delta":"must roll back","sequence":1}`)}, conflict} + {Kind: proto.TypeDelta, Payload: json.RawMessage(`{"item_id":"answer","delta":"must roll back","sequence":1}`)}, conflict} if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, bad); !errors.Is(err, sessions.ErrIdempotencyConflict) { t.Fatal("conflicting facts accepted", err) } diff --git a/services/core/tests/integration/token_usage_integration_test.go b/services/core/tests/integration/token_usage_integration_test.go index 434b4f869..6946e3899 100644 --- a/services/core/tests/integration/token_usage_integration_test.go +++ b/services/core/tests/integration/token_usage_integration_test.go @@ -65,7 +65,7 @@ func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) { } check(rolledBack.Usage, 20) // Completion without usage retains the last persisted measurement. - completed, err := journal.CompleteExecution(ctx, tenant, session.ID, admission.TurnID, status, json.RawMessage(`{"done":{"content":"partial"}}`), "", admission.Sequence) + completed, err := journal.CompleteExecution(ctx, tenant, session.ID, admission.TurnID, status, json.RawMessage(`{"done":{}}`), "", admission.Sequence) if err != nil { t.Fatal(err) } diff --git a/services/core/tests/integration/turn_events_test.go b/services/core/tests/integration/turn_events_test.go index 5591fd3ac..6ab52db0f 100644 --- a/services/core/tests/integration/turn_events_test.go +++ b/services/core/tests/integration/turn_events_test.go @@ -28,7 +28,7 @@ func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { if err != nil { t.Fatal(err) } - batch := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"部分内容","sequence":1}`)}, {Kind: "usage", Payload: json.RawMessage(`{"input_tokens":10}`)}} + batch := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"部分内容","sequence":1}`)}, {Kind: "usage", Payload: json.RawMessage(`{"input_tokens":10}`)}} var wg sync.WaitGroup errs := make(chan error, 8) for range 8 { @@ -45,7 +45,7 @@ func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { t.Fatal(err) } } - conflict := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"changed"}`)}} + conflict := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"changed"}`)}} if err := journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, conflict); !errors.Is(err, sessions.ErrIdempotencyConflict) { t.Fatal(err) } @@ -72,7 +72,7 @@ func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { if len(events) != 2 { t.Fatal("terminal event survived rollback") } - if _, err = journal.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{"done":{"content":""}}`), "", input.Sequence); err != nil { + if _, err = journal.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{"done":{}}`), "", input.Sequence); err != nil { t.Fatal(err) } if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { @@ -106,7 +106,7 @@ func TestEventLimitStillAllowsTerminalFailure(t *testing.T) { if _, err := pool.Exec(ctx, "UPDATE turns SET event_bytes=33554432 WHERE id=$1", input.TurnID); err != nil { t.Fatal(err) } - events := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"more"}`)}} + events := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"more"}`)}} if err = h.owner().Sessions.AppendTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 1, events); !errors.Is(err, sessions.ErrEventLimit) { t.Fatal(err) } diff --git a/services/core/tests/integration/worker_capacity_test.go b/services/core/tests/integration/worker_capacity_test.go index 957cd8ccd..6932380f5 100644 --- a/services/core/tests/integration/worker_capacity_test.go +++ b/services/core/tests/integration/worker_capacity_test.go @@ -136,7 +136,7 @@ func TestWorkerDefersPreparationCapacityUntilCleanupReleasesSlot(t *testing.T) { observeCapacity() // After completion, Runtime can retire the idle Executor. Its cleanup // still owns a slot and reports the same capacity rejection. - h.write(turns[first], proto.TypeDone, proto.DonePayload{Content: "done"}) + h.write(turns[first], proto.TypeDone, proto.DonePayload{}) awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "first completed Turn", func() bool { turn, err := sessionAdapter(h.s).GetTurn(t.Context(), h.tenant, first, turns[first]) return err == nil && turn.Status == sessions.TurnCompleted @@ -148,7 +148,7 @@ func TestWorkerDefersPreparationCapacityUntilCleanupReleasesSlot(t *testing.T) { } for session, turn := range turns { if session != first { - h.write(turn, proto.TypeDone, proto.DonePayload{Content: "done"}) + h.write(turn, proto.TypeDone, proto.DonePayload{}) } } awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "all five Turns completed", func() bool { diff --git a/services/core/tests/integration/worker_input_race_test.go b/services/core/tests/integration/worker_input_race_test.go index ba17a919c..0b68bc067 100644 --- a/services/core/tests/integration/worker_input_race_test.go +++ b/services/core/tests/integration/worker_input_race_test.go @@ -99,7 +99,7 @@ func TestWorkerInputReadSkipsConcurrentlyCancelledCandidate(t *testing.T) { if frame.ID != healthy.TurnID { t.Fatal("cancelled candidate reached the Runtime", frame.ID) } - h.write(healthy.TurnID, proto.TypeDone, proto.DonePayload{Content: "continued"}) + h.write(healthy.TurnID, proto.TypeDone, proto.DonePayload{}) waitTurn(t, h, healthy.TurnID, sessions.TurnCompleted) }) } diff --git a/services/core/tests/integration/worker_preparation_failure_test.go b/services/core/tests/integration/worker_preparation_failure_test.go index 9d1223560..027a51939 100644 --- a/services/core/tests/integration/worker_preparation_failure_test.go +++ b/services/core/tests/integration/worker_preparation_failure_test.go @@ -73,7 +73,7 @@ func TestWorkerSettlesConfirmedPreparationFailureAndAcceptsNewInput(t *testing.T if err != nil || current.State != sessions.EnvironmentInputAdmitted { t.Fatal("new input state", err) } - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "complete"}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{}) }) } } @@ -164,5 +164,5 @@ func TestWorkerPreparationRejectionPreservesCancellationAndNewerInput(t *testing if startFrame.DecodePayload(&start) != nil || start.RunID == "" || inputTextForTest(t, start.Input) != "next" { t.Fatal("stale rejection prevented the newer input from starting") } - h.write(start.RunID, proto.TypeDone, proto.DonePayload{Content: "complete"}) + h.write(start.RunID, proto.TypeDone, proto.DonePayload{}) }