From e4907ceb6495ffb4bfdc67ecae5b0cde70813924 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Wed, 19 Aug 2026 20:21:55 -0400 Subject: [PATCH 1/3] =?UTF-8?q?th-ef78d0:=20WIP=20=E2=80=94=20emit=20inter?= =?UTF-8?q?action=5Frequired=20before=20the=20raise=20tool's=20chunk=20(Go?= =?UTF-8?q?,=20Python,=20TS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserved by the team lead at 3.2Gi free disk so the work survives a possible ENOSPC. Go, Python and TypeScript are changed; .NET is NOT started. Markers in spec/conformance/scenarios/ are untouched — CI will report which ports now pass via the xpass check ("remove from knownDivergences — it now passes"). Co-Authored-By: Claude Fable 5 --- go/server/identity_intake_e2e_test.go | 11 ++++---- go/server/interaction_e2e_test.go | 16 ++++++----- go/server/turn_runner.go | 16 ++++++++--- .../interaction_tools.py | 18 +++++++++++- .../src/smooth_operator_server/turn_runner.py | 18 ++++++++---- .../server/tests/test_identity_intake_e2e.py | 25 +++++++++++------ .../server/tests/test_submit_interaction.py | 25 +++++++++++------ python/server/uv.lock | 2 +- typescript/server/src/frameDispatcher.ts | 4 +++ typescript/server/src/interaction.ts | 18 ++++++++++-- typescript/server/src/turnRunner.ts | 28 ++++++++++++++----- 11 files changed, 131 insertions(+), 50 deletions(-) diff --git a/go/server/identity_intake_e2e_test.go b/go/server/identity_intake_e2e_test.go index 42b45083..d086bf12 100644 --- a/go/server/identity_intake_e2e_test.go +++ b/go/server/identity_intake_e2e_test.go @@ -91,15 +91,16 @@ func TestIdentityIntakeRichPathStampsSessionIdentity(t *testing.T) { if ack := expectType(t, transport, "immediate_response"); mustStatus(t, ack) != 202 { t.Fatalf("expected 202 ack, got %v", ack["status"]) } - call := expectType(t, transport, "stream_chunk") - if name, _ := dot(t, call, "data.state.rawResponse.toolCall.name"); name != "request_identity_intake" { - t.Fatalf("expected request_identity_intake toolCall, got %v", name) - } - + // The park event precedes the raise tool's toolCall chunk (the reference order). req := expectType(t, transport, "interaction_required") if kind, _ := dot(t, req, "data.data.kind"); kind != "identity_intake" { t.Fatalf("interaction_required kind = %v, want identity_intake (event=%s)", kind, mustJSON(req)) } + + call := expectType(t, transport, "stream_chunk") + if name, _ := dot(t, call, "data.state.rawResponse.toolCall.name"); name != "request_identity_intake" { + t.Fatalf("expected request_identity_intake toolCall, got %v", name) + } iid, _ := mustDotString(t, req, "data.data.interactionId") if key, _ := dot(t, req, "data.data.spec.fields.1.key"); key != "email" { t.Fatalf("spec fields[1].key = %v, want email (event=%s)", key, mustJSON(req)) diff --git a/go/server/interaction_e2e_test.go b/go/server/interaction_e2e_test.go index ce84eb50..12d33b3f 100644 --- a/go/server/interaction_e2e_test.go +++ b/go/server/interaction_e2e_test.go @@ -87,13 +87,9 @@ func TestSubmitInteractionRichPathResumes(t *testing.T) { t.Fatalf("expected 202 ack, got %v", ack["status"]) } - // The raise tool's toolCall chunk is emitted (deterministically) before the park. - call := expectType(t, transport, "stream_chunk") - if name, _ := dot(t, call, "data.state.rawResponse.toolCall.name"); name != "request_choices" { - t.Fatalf("expected request_choices toolCall chunk, got %v (event=%s)", name, mustJSON(call)) - } - // The turn PARKS: interaction_required carries the kind, the spec, and an interactionId. + // It precedes the raise tool's toolCall chunk (the reference order, and the same order + // this server already uses for the write-confirmation park). req := expectType(t, transport, "interaction_required") if rid, _ := req["requestId"].(string); rid != "r-msg" { t.Fatalf("interaction_required requestId = %q, want r-msg", rid) @@ -111,6 +107,12 @@ func TestSubmitInteractionRichPathResumes(t *testing.T) { t.Fatalf("interaction_required spec question header = %v, want Plan (event=%s)", header, mustJSON(req)) } + // Only now does the raise tool's toolCall chunk land. + call := expectType(t, transport, "stream_chunk") + if name, _ := dot(t, call, "data.state.rawResponse.toolCall.name"); name != "request_choices" { + t.Fatalf("expected request_choices toolCall chunk, got %v (event=%s)", name, mustJSON(call)) + } + // Submit a valid pick → the server acks and the parked raise resumes. The ack and the // resumed tool-result chunk come from different goroutines, so collect the tail and // assert on its contents rather than a strict interleaving. @@ -150,8 +152,8 @@ func TestSubmitInteractionInvalidStaysParked(t *testing.T) { "action": "send_message", "requestId": "r-msg", "sessionId": sessionID, "message": "sign me up", }) expectType(t, transport, "immediate_response") // 202 - expectType(t, transport, "stream_chunk") // request_choices toolCall req := expectType(t, transport, "interaction_required") + expectType(t, transport, "stream_chunk") // request_choices toolCall, after the park iid, _ := mustDotString(t, req, "data.data.interactionId") // Invalid pick (Platinum isn't offered) → interaction_invalid, turn STAYS parked. diff --git a/go/server/turn_runner.go b/go/server/turn_runner.go index a7b0fb0e..ec8ca3b7 100644 --- a/go/server/turn_runner.go +++ b/go/server/turn_runner.go @@ -784,14 +784,20 @@ func (r *TurnRunner) raiseTool(kind InteractionKind, rich bool, sessionID, reque Desc: schema.Description, Params: schema.Parameters, Fn: func(ctx context.Context, args map[string]any) (string, error) { - // Emit the deferred toolCall chunk here (the stream loop skipped it), so it - // deterministically precedes interaction_required / the fallback result. - if argsJSON, err := json.Marshal(args); err == nil { - sink(streamChunk(requestID, schema.Name, toolCallState(schema.Name, string(argsJSON)))) + // The stream loop skipped this tool's toolCall chunk; emit it here. On the + // rich path it must follow interaction_required (the reference order — a + // client that renders tool calls would otherwise show "calling + // request_identity_intake…" before the card). On every non-park path there + // is no park event, so it goes out immediately. + emitCall := func() { + if argsJSON, err := json.Marshal(args); err == nil { + sink(streamChunk(requestID, schema.Name, toolCallState(schema.Name, string(argsJSON)))) + } } req, err := kind.ParseRequest(args) if err != nil { + emitCall() return "", err } @@ -800,6 +806,7 @@ func (r *TurnRunner) raiseTool(kind InteractionKind, rich bool, sessionID, reque // model collects the answer turn by turn and submits via submit_interaction. // Stash the raised spec so that same-turn submit validates required-ness. r.stashRaisedSpec(req.Kind, req.Spec) + emitCall() return marshalInteractionResult(map[string]any{ "mode": "conversational", "kind": req.Kind, @@ -814,6 +821,7 @@ func (r *TurnRunner) raiseTool(kind InteractionKind, rich bool, sessionID, reque interactionID := uuid.NewString() outcome := r.interactions.Register(sessionID, interactionID, req.Kind, req.Spec) sink(interactionRequired(requestID, interactionID, req.Kind, req.Spec, req.Reason)) + emitCall() select { case oc := <-outcome: diff --git a/python/server/src/smooth_operator_server/interaction_tools.py b/python/server/src/smooth_operator_server/interaction_tools.py index d3320df6..c5257efb 100644 --- a/python/server/src/smooth_operator_server/interaction_tools.py +++ b/python/server/src/smooth_operator_server/interaction_tools.py @@ -59,11 +59,26 @@ def _request_tool( schema = kind.tool_schema() async def _run(args: dict[str, Any]) -> str: - request = kind.parse_request(args) # ValueError → surfaced to the model + # The stream loop DEFERRED this tool's toolCall chunk (see + # ``TurnRunner._is_interaction_raise``) so the park path can emit it AFTER + # ``interaction_required`` — the reference order, and the same order the + # write-confirmation park already uses. Every non-park exit emits it here. + # (the state shape is inlined rather than imported from `turn_runner`, which + # imports this module — `_tool_call_state_from`'s one-line dict, not a cycle.) + def emit_call() -> None: + state = {"rawResponse": {"toolCall": {"name": schema["name"], "arguments": args}}} + sink(protocol.stream_chunk(request_id, schema["name"], state)) + + try: + request = kind.parse_request(args) # ValueError → surfaced to the model + except Exception: + emit_call() + raise if not rich: # Fallback: no card can render — hand the model the conversational directive # and stash the spec so the submit tool validates with full required-ness. raised_specs[request.kind] = request.spec + emit_call() return json.dumps( { "mode": "conversational", @@ -77,6 +92,7 @@ async def _run(args: dict[str, Any]) -> str: interaction_id = str(uuid.uuid4()) future = pending.register(session_id, interaction_id, request.kind, request.spec) sink(protocol.interaction_required(request_id, interaction_id, request.kind, request.spec, request.reason)) + emit_call() try: outcome = await asyncio.wait_for(future, INTERACTION_TIMEOUT) except (asyncio.TimeoutError, asyncio.CancelledError): diff --git a/python/server/src/smooth_operator_server/turn_runner.py b/python/server/src/smooth_operator_server/turn_runner.py index 11bdddda..f9a354ef 100644 --- a/python/server/src/smooth_operator_server/turn_runner.py +++ b/python/server/src/smooth_operator_server/turn_runner.py @@ -304,6 +304,14 @@ def _is_gated(self, tool_name: str) -> bool: return False return any(pattern in tool_name for pattern in self._confirm_tools) + def _is_interaction_raise(self, tool_name: str) -> bool: + """True when ``tool_name`` is one of this turn's ``request_`` raise tools. + Their toolCall chunk is deferred out of the stream loop and re-emitted by the + tool itself — after ``interaction_required`` on the park path.""" + if self._interactions is None: + return False + return any(kind.tool_schema()["name"] == tool_name for kind in self._interactions.kinds()) + async def run( self, conversation_id: str, @@ -530,11 +538,11 @@ async def _gate(req: HumanApprovalRequest) -> HumanApprovalResponse: # child span). Emitted for gated tools too — the span is # independent of the deferred wire chunk below. _emit_tool_span(event, conversation_id, self._org_id) - # DEFER a confirmation-gated tool's toolCall chunk: it is emitted - # from the gate AFTER `write_confirmation_required`, so the wire - # order matches the reference (Rust) server. Non-gated tools emit - # their chunk inline as before. - if self._is_gated(event.name): + # DEFER a parking tool's toolCall chunk: it is emitted from the + # park path AFTER `write_confirmation_required` / + # `interaction_required`, so the wire order matches the reference + # (Rust) server. Ungated tools emit their chunk inline as before. + if self._is_gated(event.name) or self._is_interaction_raise(event.name): continue sink(protocol.stream_chunk(request_id, event.name, _tool_call_state(event))) elif isinstance(event, ToolResultEvent): diff --git a/python/server/tests/test_identity_intake_e2e.py b/python/server/tests/test_identity_intake_e2e.py index 128e1188..62843c94 100644 --- a/python/server/tests/test_identity_intake_e2e.py +++ b/python/server/tests/test_identity_intake_e2e.py @@ -61,6 +61,19 @@ async def _recv(ws): return event +async def _recv_park(ws): + """The park event plus the raise tool's deferred toolCall chunk that follows it. + + The reference order is ``interaction_required`` FIRST, then the raise tool's + ``stream_chunk`` — same as this server's write-confirmation park. + """ + event = await _recv(ws) + assert event["type"] == "interaction_required", event + chunk = await _recv(ws) + assert chunk["type"] == "stream_chunk", chunk + return event + + async def _send_message(ws) -> None: await ws.send( json.dumps( @@ -87,11 +100,8 @@ async def test_rich_path_parks_resumes_and_stamps_contacts() -> None: ack = await _recv(ws) assert ack["type"] == "immediate_response" and ack["status"] == 202 - # Park: an interaction_required arrives (a toolCall chunk may precede it). - event = await _recv(ws) - if event["type"] == "stream_chunk": - event = await _recv(ws) - assert event["type"] == "interaction_required" + # Park: interaction_required, then the raise tool's deferred toolCall chunk. + event = await _recv_park(ws) inner = event["data"]["data"] assert inner["kind"] == "identity_intake" assert inner["spec"]["fields"][1]["key"] == "email" @@ -160,10 +170,7 @@ async def test_invalid_submit_stays_parked_and_does_not_stamp() -> None: _SID = await _create_session(ws, supports=["identity_form"]) await _send_message(ws) assert (await _recv(ws))["status"] == 202 - event = await _recv(ws) - if event["type"] == "stream_chunk": - event = await _recv(ws) - assert event["type"] == "interaction_required" + event = await _recv_park(ws) interaction_id = event["data"]["data"]["interactionId"] # A bad email → interaction_invalid, turn stays parked, nothing stamped. diff --git a/python/server/tests/test_submit_interaction.py b/python/server/tests/test_submit_interaction.py index c9368f47..8317d80d 100644 --- a/python/server/tests/test_submit_interaction.py +++ b/python/server/tests/test_submit_interaction.py @@ -65,6 +65,19 @@ async def _recv(ws): return event +async def _recv_park(ws): + """The park event plus the raise tool's deferred toolCall chunk that follows it. + + The reference order is ``interaction_required`` FIRST, then the raise tool's + ``stream_chunk`` — same as this server's write-confirmation park. + """ + event = await _recv(ws) + assert event["type"] == "interaction_required", event + chunk = await _recv(ws) + assert chunk["type"] == "stream_chunk", chunk + return event + + async def _send_message(ws) -> None: await ws.send( json.dumps({"action": "send_message", "requestId": "r-msg", "sessionId": _SID, "message": "help me pick"}) @@ -89,11 +102,8 @@ async def test_rich_path_parks_emits_interaction_required_and_resumes() -> None: ack = await _recv(ws) assert ack["type"] == "immediate_response" and ack["status"] == 202 - # Park: an interaction_required arrives (a toolCall chunk may precede it). - event = await _recv(ws) - if event["type"] == "stream_chunk": - event = await _recv(ws) - assert event["type"] == "interaction_required" + # Park: interaction_required, then the raise tool's deferred toolCall chunk. + event = await _recv_park(ws) assert event["requestId"] == "r-msg" inner = event["data"]["data"] assert inner["kind"] == "choices" @@ -155,10 +165,7 @@ async def test_invalid_submit_stays_parked_then_resubmit_resumes() -> None: _SID = await _create_session(ws, supports=["choice_chips"]) await _send_message(ws) assert (await _recv(ws))["status"] == 202 - event = await _recv(ws) - if event["type"] == "stream_chunk": - event = await _recv(ws) - assert event["type"] == "interaction_required" + event = await _recv_park(ws) interaction_id = event["data"]["data"]["interactionId"] # A bad pick (not an offered option) → interaction_invalid, turn stays parked. diff --git a/python/server/uv.lock b/python/server/uv.lock index 634e5b26..33c53b57 100644 --- a/python/server/uv.lock +++ b/python/server/uv.lock @@ -846,7 +846,7 @@ wheels = [ [[package]] name = "smooai-smooth-operator-server" -version = "1.55.0" +version = "1.56.2" source = { editable = "." } dependencies = [ { name = "opentelemetry-api" }, diff --git a/typescript/server/src/frameDispatcher.ts b/typescript/server/src/frameDispatcher.ts index 4f6cb054..47f0075f 100644 --- a/typescript/server/src/frameDispatcher.ts +++ b/typescript/server/src/frameDispatcher.ts @@ -721,6 +721,10 @@ export class FrameDispatcher { toolHooks: this.toolHooks, confirmTools: this.confirmTools, confirmations: this.confirmations, + // Raise tools only — NOT the generic `submit_interaction` tool that + // `buildInteractionTools` may also add; that one emits no park event, so + // deferring its chunk would drop it. + interactionRaiseTools: this.interactions.all().map((k) => k.toolSchema().name), sessionId, workflow: agentConfig?.conversationWorkflow, currentStepId: session.currentStepId, diff --git a/typescript/server/src/interaction.ts b/typescript/server/src/interaction.ts index 7e5dc50c..260c7351 100644 --- a/typescript/server/src/interaction.ts +++ b/typescript/server/src/interaction.ts @@ -27,7 +27,7 @@ import { randomUUID } from 'node:crypto'; import type { Tool } from '@smooai/smooth-operator-core'; import * as protocol from './protocol.js'; -import type { Sink } from './turnRunner.js'; +import { toolCallStateFrom, type Sink } from './turnRunner.js'; /** Wire name of the generic conversational submit tool (same verb as the resume action). */ export const SUBMIT_INTERACTION_TOOL = 'submit_interaction'; @@ -239,13 +239,26 @@ export function requestInteractionTool(opts: { description: schema.description, parameters: schema.parameters, async execute(args: Record): Promise { + // The stream loop DEFERRED this tool's toolCall chunk (see + // `TurnRunner.isInteractionRaise`) so the park path can emit it AFTER + // `interaction_required` — the reference order, and the same order the + // write-confirmation park already uses. Every non-park exit emits it here. + const emitCall = (): void => sink(protocol.streamChunk(requestId, schema.name, toolCallStateFrom(schema.name, args))); + // A malformed raise throws; the engine surfaces it to the model as a tool // error (never crashes the turn), so the model can correct and retry. - const request = kind.parseRequest(args); + let request: ReturnType; + try { + request = kind.parseRequest(args); + } catch (err) { + emitCall(); + throw err; + } if (!rich) { // Text-only channel: degrade to the kind's conversational directive. raisedSpecs.set(request.kind, request.spec); + emitCall(); return JSON.stringify({ mode: 'conversational', kind: request.kind, @@ -260,6 +273,7 @@ export function requestInteractionTool(opts: { const interactionId = randomUUID(); const outcome = park.park(sessionId, { interactionId, kind: request.kind, spec: request.spec }); sink(protocol.interactionRequired(requestId, interactionId, request.kind, request.spec, request.reason)); + emitCall(); const resolved = await withTimeout(outcome, timeoutMs, () => park.clear(sessionId)); switch (resolved.status) { diff --git a/typescript/server/src/turnRunner.ts b/typescript/server/src/turnRunner.ts index 59f2c8bb..3fd33650 100644 --- a/typescript/server/src/turnRunner.ts +++ b/typescript/server/src/turnRunner.ts @@ -185,6 +185,13 @@ export interface TurnRunnerOptions { confirmTools?: string[]; /** The session-keyed pending-confirmation registry the gate parks on (shared with the dispatcher). */ confirmations?: ConfirmationRegistry; + /** + * Names of the Rich Interaction raise tools (`request_`) registered for this + * turn. Their `toolCall` chunk is DEFERRED out of the stream loop and re-emitted by + * the tool itself — after `interaction_required` on the park path — so the wire order + * matches the reference (Rust) server and this server's own write-confirmation park. + */ + interactionRaiseTools?: string[]; /** The session id a parked confirmation is keyed by (so a `confirm_tool_action` frame routes here). */ sessionId?: string; /** @@ -243,6 +250,7 @@ export class TurnRunner { private readonly toolHooks: ToolHook[]; private readonly confirmTools: string[]; private readonly confirmations?: ConfirmationRegistry; + private readonly interactionRaiseTools: string[]; private readonly sessionId?: string; private readonly workflow?: ConversationWorkflow; private readonly currentStepId?: string; @@ -268,6 +276,7 @@ export class TurnRunner { this.toolHooks = options.toolHooks ?? []; this.confirmTools = options.confirmTools ?? []; this.confirmations = options.confirmations; + this.interactionRaiseTools = options.interactionRaiseTools ?? []; this.sessionId = options.sessionId; this.workflow = options.workflow; this.currentStepId = options.currentStepId; @@ -285,6 +294,11 @@ export class TurnRunner { return this.confirmTools.some((pattern) => name.includes(pattern)); } + /** True when `name` is one of this turn's `request_` raise tools, which emit their own chunk. */ + private isInteractionRaise(name: string): boolean { + return this.interactionRaiseTools.includes(name); + } + /** * Run the turn, streaming events to `sink`. * @@ -426,10 +440,10 @@ export class TurnRunner { // BEFORE the gated `continue` below, so a confirmation-gated tool is // traced too (parity with the Rust runner collecting all tool records). if (event.type === 'tool_call') recordToolSpan(turnSpan, event.name, event.arguments, conversationId, this.orgId); - // DEFER a confirmation-gated tool's toolCall chunk: it is emitted from - // the gate AFTER `write_confirmation_required`, so the wire order matches - // the reference (Rust) server. Non-gated tools emit their chunk inline. - if (event.type === 'tool_call' && this.isGated(event.name)) continue; + // DEFER a parking tool's toolCall chunk: it is emitted from the park path + // AFTER `write_confirmation_required` / `interaction_required`, so the wire + // order matches the reference (Rust) server. Ungated tools emit inline. + if (event.type === 'tool_call' && (this.isGated(event.name) || this.isInteractionRaise(event.name))) continue; // Mark the answer as started BEFORE emitting, so a preamble that resolves // in this same tick is dropped rather than landing after the reply. if (event.type === 'text') answerStarted.started = true; @@ -527,10 +541,10 @@ function toolCallState(name: string, args: string): Record { /** * The `stream_chunk` toolCall state built from an already-parsed `arguments` object * (the shape the engine's {@link HumanApprovalRequest} carries). Used to emit a gated - * tool's deferred toolCall chunk from the HumanGate — the TS analog of the Python - * `_tool_call_state_from`. + * tool's deferred toolCall chunk from the HumanGate, and an interaction raise tool's + * from the park path — the TS analog of the Python `_tool_call_state_from`. */ -function toolCallStateFrom(name: string, args: Record): Record { +export function toolCallStateFrom(name: string, args: Record): Record { return { rawResponse: { toolCall: { name, arguments: args } } }; } From 3022b9ce8ee1ab9b1fe3dddf95a31ad0134d8b2b Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Wed, 19 Aug 2026 20:25:37 -0400 Subject: [PATCH 2/3] th-ef78d0: .NET ordering + drop every knownDivergences marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI confirmed Go, Python and TypeScript now pass all five interaction scenarios — each xpass'd with 'remove from knownDivergences — it now passes', which is the marker mechanism working as designed. .NET is now changed too, so all markers are dropped and CI adjudicates whether that claim holds: a real assertion failure (not an xpass) means .NET is not done and its marker goes back. Co-Authored-By: Claude Fable 5 --- dotnet/server/src/Interactions.cs | 17 +- dotnet/server/src/TurnRunner.cs | 14 +- .../interaction-choices-park-resume.json | 148 +++++++++++++----- .../scenarios/interaction-declined.json | 2 - .../interaction-invalid-retryable.json | 2 - .../scenarios/interaction-park-resume.json | 2 - .../interaction-stale-id-rejected.json | 2 - 7 files changed, 138 insertions(+), 49 deletions(-) diff --git a/dotnet/server/src/Interactions.cs b/dotnet/server/src/Interactions.cs index 5427ceb9..9488ee3a 100644 --- a/dotnet/server/src/Interactions.cs +++ b/dotnet/server/src/Interactions.cs @@ -286,15 +286,28 @@ public RequestInteractionTool( protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) { + var args = Interactions.ArgsToObject(arguments); + // The stream loop DEFERRED this tool's toolCall chunk (see TurnRunner.IsInteractionRaise) so + // the park path can emit it AFTER interaction_required — the canonical (Rust) order, and the + // same order the write-confirmation park already uses. Every non-park exit emits it here. + void EmitCall() => _sink(ProtocolEvents.StreamChunk(_requestId, Name, new JsonObject + { + ["rawResponse"] = new JsonObject + { + ["toolCall"] = new JsonObject { ["name"] = Name, ["arguments"] = args.DeepClone() }, + }, + })); + InteractionRequest request; try { - request = _kind.ParseRequest(Interactions.ArgsToObject(arguments)); + request = _kind.ParseRequest(args); } catch (InteractionParseException ex) { // Contract violation (count/length/uniqueness) — hand the reason back so the model can fix // and re-call, exactly like Rust returning the parse error as the tool result. + EmitCall(); return ex.Message; } @@ -303,6 +316,7 @@ public RequestInteractionTool( // Text-only channel: no card. Stash the spec for a format-only submit, and return the // conversational directive telling the model to ask + submit via submit_interaction. _raised[request.Kind] = request.Spec; + EmitCall(); return new JsonObject { ["mode"] = "conversational", @@ -318,6 +332,7 @@ public RequestInteractionTool( var interactionId = Guid.NewGuid().ToString(); var parked = _park.Register(_sessionId, interactionId, request.Kind, request.Spec); _sink(ProtocolEvents.InteractionRequired(_requestId, interactionId, request.Kind, request.Spec.DeepClone(), request.Reason)); + EmitCall(); var outcome = await AwaitOutcome(parked, cancellationToken).ConfigureAwait(false); return outcome.Status switch diff --git a/dotnet/server/src/TurnRunner.cs b/dotnet/server/src/TurnRunner.cs index f2b8645c..ef5753f0 100644 --- a/dotnet/server/src/TurnRunner.cs +++ b/dotnet/server/src/TurnRunner.cs @@ -220,6 +220,12 @@ private string BuildSystemPrompt(string? currentStepId, bool isFirstTurn, string private bool IsGated(string toolName) => _confirmations is not null && _confirmTools.Any(pattern => toolName.Contains(pattern, StringComparison.Ordinal)); + /// True when is one of this turn's request_<kind> + /// raise tools. Their toolCall chunk is deferred out of the stream loop and re-emitted by the tool + /// itself — after interaction_required on the park path. + private bool IsInteractionRaise(string toolName) => + _interactions is not null && _interactions.Kinds.Any(kind => kind.ToolName == toolName); + public Task RunAsync(string conversationId, string requestId, string userMessage, Action sink, CancellationToken cancellationToken = default) => RunAsync(conversationId, requestId, userMessage, sink, sessionId: conversationId, cancellationToken); @@ -496,10 +502,10 @@ public async Task RunAsync(string conversationId, string requestId, // `gen_ai.tool` child span (nests under the turn span), mirroring the Rust // runner emitting one gen_ai.tool span per tool call with redacted args. EmitToolSpan(call, conversationId); - // DEFER a confirmation-gated tool's toolCall chunk: it is emitted from the - // gate AFTER write_confirmation_required, so the wire order matches the - // canonical (Rust) server. Non-gated tools emit their chunk inline as before. - if (IsGated(call.Name)) + // DEFER a parking tool's toolCall chunk: it is emitted from the park path + // AFTER write_confirmation_required / interaction_required, so the wire + // order matches the canonical (Rust) server. Ungated tools emit inline. + if (IsGated(call.Name) || IsInteractionRaise(call.Name)) { break; } diff --git a/spec/conformance/scenarios/interaction-choices-park-resume.json b/spec/conformance/scenarios/interaction-choices-park-resume.json index 4751a107..8ef11ef3 100644 --- a/spec/conformance/scenarios/interaction-choices-park-resume.json +++ b/spec/conformance/scenarios/interaction-choices-park-resume.json @@ -1,44 +1,120 @@ { - "name": "interaction-choices-park-resume", - "description": "The second Rich Interaction kind, `choices` (the AskUserQuestion-shaped multiple-choice ask), through the SAME generic envelope: `request_choices` parks behind the `choice_chips` capability, the server emits `interaction_required` with the normalized questions/options spec, and one `submit_interaction` resumes it. Adding a kind must require no new protocol action and no client release — this scenario is what proves that claim holds identically in every server, and that the kind catalog is not identity_intake-shaped by accident.", - "knownDivergences": ["go", "typescript", "python", "dotnet"], - "knownDivergencesReason": "th-eae69d — the park-ordering divergence, not the choices kind itself: these four emit the raise tool's `toolCall` stream_chunk BEFORE `interaction_required`, so they fail in step 2 and never reach the choices spec/resume assertions. See interaction-park-resume for the full ruling.", - "mockLlmScript": [ - { "kind": "toolCall", "name": "request_choices", "arguments": "{\"questions\": [{\"question\": \"Which plan fits best?\", \"header\": \"Plan\", \"options\": [{\"label\": \"Starter\"}, {\"label\": \"Pro\"}]}], \"reason\": \"to route you to the right quote\"}" }, - { "kind": "text", "text": "Pro it is — pulling that quote up." } - ], - "steps": [ - { - "send": { "action": "create_conversation_session", "requestId": "r-create", "agentId": "11111111-1111-1111-1111-111111111111", "userName": "Alice", "userEmail": "alice@example.com", "supports": ["choice_chips"] }, - "expect": [ { "type": "immediate_response", "status": 200, "capture": { "sessionId": "data.sessionId" } } ] + "name": "interaction-choices-park-resume", + "description": "The second Rich Interaction kind, `choices` (the AskUserQuestion-shaped multiple-choice ask), through the SAME generic envelope: `request_choices` parks behind the `choice_chips` capability, the server emits `interaction_required` with the normalized questions/options spec, and one `submit_interaction` resumes it. Adding a kind must require no new protocol action and no client release \u2014 this scenario is what proves that claim holds identically in every server, and that the kind catalog is not identity_intake-shaped by accident.", + "mockLlmScript": [ + { + "kind": "toolCall", + "name": "request_choices", + "arguments": "{\"questions\": [{\"question\": \"Which plan fits best?\", \"header\": \"Plan\", \"options\": [{\"label\": \"Starter\"}, {\"label\": \"Pro\"}]}], \"reason\": \"to route you to the right quote\"}" + }, + { + "kind": "text", + "text": "Pro it is \u2014 pulling that quote up." + } + ], + "steps": [ + { + "send": { + "action": "create_conversation_session", + "requestId": "r-create", + "agentId": "11111111-1111-1111-1111-111111111111", + "userName": "Alice", + "userEmail": "alice@example.com", + "supports": [ + "choice_chips" + ] + }, + "expect": [ + { + "type": "immediate_response", + "status": 200, + "capture": { + "sessionId": "data.sessionId" + } + } + ] + }, + { + "send": { + "action": "send_message", + "requestId": "r-msg", + "sessionId": "{{sessionId}}", + "message": "i want a quote", + "stream": true + }, + "expect": [ + { + "type": "immediate_response", + "status": 202 }, { - "send": { "action": "send_message", "requestId": "r-msg", "sessionId": "{{sessionId}}", "message": "i want a quote", "stream": true }, - "expect": [ - { "type": "immediate_response", "status": 202 }, - { - "type": "interaction_required", - "assert": { - "requestId": "r-msg", - "data.data.kind": "choices", - "data.data.reason": "to route you to the right quote", - "data.data.spec.questions.0.header": "Plan", - "data.data.spec.questions.0.question": "Which plan fits best?", - "data.data.spec.questions.0.options.1.label": "Pro" - }, - "capture": { "interactionId": "data.data.interactionId" } - }, - { "type": "stream_chunk", "assert": { "data.state.rawResponse.toolCall.name": "request_choices" } } - ] + "type": "interaction_required", + "assert": { + "requestId": "r-msg", + "data.data.kind": "choices", + "data.data.reason": "to route you to the right quote", + "data.data.spec.questions.0.header": "Plan", + "data.data.spec.questions.0.question": "Which plan fits best?", + "data.data.spec.questions.0.options.1.label": "Pro" + }, + "capture": { + "interactionId": "data.data.interactionId" + } + }, + { + "type": "stream_chunk", + "assert": { + "data.state.rawResponse.toolCall.name": "request_choices" + } + } + ] + }, + { + "send": { + "action": "submit_interaction", + "requestId": "r-msg", + "sessionId": "{{sessionId}}", + "interactionId": "{{interactionId}}", + "kind": "choices", + "values": { + "answers": [ + { + "header": "Plan", + "options": [ + "Pro" + ] + } + ] + } + }, + "expect": [ + { + "type": "immediate_response", + "status": 200 + }, + { + "type": "stream_chunk", + "assert": { + "data.state.rawResponse.toolResult.name": "request_choices", + "data.state.rawResponse.toolResult.isError": false + } + }, + { + "type": "stream_token", + "repeat": true, + "accumulate": "token", + "assertAccumulated": "Pro it is \u2014 pulling that quote up." }, { - "send": { "action": "submit_interaction", "requestId": "r-msg", "sessionId": "{{sessionId}}", "interactionId": "{{interactionId}}", "kind": "choices", "values": { "answers": [{ "header": "Plan", "options": ["Pro"] }] } }, - "expect": [ - { "type": "immediate_response", "status": 200 }, - { "type": "stream_chunk", "assert": { "data.state.rawResponse.toolResult.name": "request_choices", "data.state.rawResponse.toolResult.isError": false } }, - { "type": "stream_token", "repeat": true, "accumulate": "token", "assertAccumulated": "Pro it is — pulling that quote up." }, - { "type": "eventual_response", "status": 200, "assert": { "data.data.response.responseParts": ["Pro it is — pulling that quote up."] } } + "type": "eventual_response", + "status": 200, + "assert": { + "data.data.response.responseParts": [ + "Pro it is \u2014 pulling that quote up." ] + } } - ] + ] + } + ] } diff --git a/spec/conformance/scenarios/interaction-declined.json b/spec/conformance/scenarios/interaction-declined.json index bcea4d3f..f0a3e5f7 100644 --- a/spec/conformance/scenarios/interaction-declined.json +++ b/spec/conformance/scenarios/interaction-declined.json @@ -1,8 +1,6 @@ { "name": "interaction-declined", "description": "`declined: true` resolves the park WITHOUT values. The visitor refusing a Rich Interaction is a first-class outcome, not an error and not a dead turn: the server acks 200, the raise tool returns a declined payload to the model, and the turn resumes and finishes normally. A server that required `values`, or that left the turn parked on a decline, fails here. Only `status` is asserted on the ack — the five servers put different fields in the decline ack's `data` (see the README's divergence note), so asserting more would pin one language's shape rather than the protocol's.", - "knownDivergences": ["go", "typescript", "python", "dotnet"], - "knownDivergencesReason": "th-eae69d — the park-ordering divergence, not the decline behavior: these four emit the raise tool's `toolCall` stream_chunk BEFORE `interaction_required`, so they fail in step 2 and never reach the decline assertions. See interaction-park-resume for the full ruling.", "mockLlmScript": [ { "kind": "toolCall", "name": "request_identity_intake", "arguments": "{\"fields\": [{\"key\": \"email\", \"required\": true}], \"reason\": \"to send you the quote\"}" }, { "kind": "text", "text": "No problem — I'll keep helping without that." } diff --git a/spec/conformance/scenarios/interaction-invalid-retryable.json b/spec/conformance/scenarios/interaction-invalid-retryable.json index 20a3c31e..ab0ecf43 100644 --- a/spec/conformance/scenarios/interaction-invalid-retryable.json +++ b/spec/conformance/scenarios/interaction-invalid-retryable.json @@ -1,8 +1,6 @@ { "name": "interaction-invalid-retryable", "description": "Invalid interaction values are RETRYABLE, never terminal. A `submit_interaction` whose values fail the kind's server-side validator must emit `interaction_invalid` (per-field errors + a card-level summary) and leave the turn PARKED — mirroring `otp_invalid`. This scenario proves the park survives the rejection twice over: a second bad submit is rejected the same way, and only then does a corrected submit ack 200 and resume the turn. A server that terminated the turn with an `error` event, or that dropped the park after the first rejection, fails here.", - "knownDivergences": ["go", "typescript", "python", "dotnet"], - "knownDivergencesReason": "th-eae69d — the park-ordering divergence, not the retry behavior: these four emit the raise tool's `toolCall` stream_chunk BEFORE `interaction_required`, so they fail in step 2 and never reach the retryable-invalid assertions. See interaction-park-resume for the full ruling.", "mockLlmScript": [ { "kind": "toolCall", "name": "request_identity_intake", "arguments": "{\"fields\": [{\"key\": \"email\", \"required\": true}], \"reason\": \"to send you the quote\"}" }, { "kind": "text", "text": "Got it — sending the quote now." } diff --git a/spec/conformance/scenarios/interaction-park-resume.json b/spec/conformance/scenarios/interaction-park-resume.json index 14fb4138..50f24c84 100644 --- a/spec/conformance/scenarios/interaction-park-resume.json +++ b/spec/conformance/scenarios/interaction-park-resume.json @@ -1,8 +1,6 @@ { "name": "interaction-park-resume", "description": "Rich Interactions, the happy path. A session that DECLARED the `identity_form` render capability in `supports` gets the real card: the agent's server-registered `request_identity_intake` raise tool PARKS the turn and the server emits `interaction_required` (kind `identity_intake`, a server-minted `interactionId`, the canonical `spec.fields`, and the agent's `reason`). A matching `submit_interaction` — same requestId, same interactionId — acks 200 and RESUMES the parked turn, which streams the wrap-up reply. The raise tools are server-registered in every server, so this needs no `server.*` directive; only the mock names the tool. ⚠️ The park event is asserted BEFORE the raise tool's `stream_chunk`, matching the Rust reference and the same ordering the corpus already pins for write-confirmation HITL (`hitl-write-confirmation`). Go / TypeScript / Python / .NET emit the chunk first; see the README's divergence note.", - "knownDivergences": ["go", "typescript", "python", "dotnet"], - "knownDivergencesReason": "th-eae69d — these four emit the raise tool's `toolCall` stream_chunk BEFORE `interaction_required`; Rust emits the park event first. Ruled a port bug, not a protocol variant: all five already defer the gated tool's chunk until after the prompt for the OTHER park type (`hitl-write-confirmation`), so the four are internally inconsistent between their two park paths while Rust is consistent — and a client that renders tool calls would show \"calling request_identity_intake…\" before the card appears, leaking framework internals ahead of the semantic event. The ports change, not this scenario.", "mockLlmScript": [ { "kind": "toolCall", "name": "request_identity_intake", "arguments": "{\"fields\": [{\"key\": \"name\", \"required\": false}, {\"key\": \"email\", \"required\": true}], \"reason\": \"to send you the quote\"}" }, { "kind": "text", "text": "Thanks — I've got your details." } diff --git a/spec/conformance/scenarios/interaction-stale-id-rejected.json b/spec/conformance/scenarios/interaction-stale-id-rejected.json index ce858816..a9a911ae 100644 --- a/spec/conformance/scenarios/interaction-stale-id-rejected.json +++ b/spec/conformance/scenarios/interaction-stale-id-rejected.json @@ -1,8 +1,6 @@ { "name": "interaction-stale-id-rejected", "description": "A `submit_interaction` carrying a stale or mismatched `interactionId` must be REJECTED with error/INTERACTION_MISMATCH and must leave the turn parked — the interactionId exists precisely so a stale submit can never resolve a newer park (spec/actions/submit-interaction.schema.json). The park's survival is proved without depending on resume ordering: a follow-up submit with the correct id gets its 200 ack, which a dropped park could not produce.", - "knownDivergences": ["go", "typescript", "python", "dotnet"], - "knownDivergencesReason": "th-eae69d — the park-ordering divergence, not the mismatch behavior: these four emit the raise tool's `toolCall` stream_chunk BEFORE `interaction_required`, so they fail in step 2 and never reach the INTERACTION_MISMATCH assertion. See interaction-park-resume for the full ruling.", "mockLlmScript": [ { "kind": "toolCall", "name": "request_identity_intake", "arguments": "{\"fields\": [{\"key\": \"email\", \"required\": true}], \"reason\": \"to send you the quote\"}" }, { "kind": "text", "text": "Got it — sending the quote now." } From 1f611cc1930c3f43edc82d6e630c5c64d157ab8b Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Wed, 19 Aug 2026 20:26:25 -0400 Subject: [PATCH 3/3] =?UTF-8?q?th-ef78d0:=20restore=20the=20go=20marker=20?= =?UTF-8?q?on=20choices-park-resume=20=E2=80=94=20it=20is=20a=20DIFFERENT?= =?UTF-8?q?=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I stripped this marker with a bulk script that only preserved 'dotnet'; that was wrong. The authoring agent had deliberately kept it and rewritten its reason: Go's ORDERING is fixed, and fixing it unmasked a byte-vs-rune slicing bug in smooth-operator-core's splitIntoChunks. Restoring the marker with that reason so the scenario keeps failing honestly against the real cause. Co-Authored-By: Claude Fable 5 --- .../scenarios/interaction-choices-park-resume.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/conformance/scenarios/interaction-choices-park-resume.json b/spec/conformance/scenarios/interaction-choices-park-resume.json index 8ef11ef3..33972bb2 100644 --- a/spec/conformance/scenarios/interaction-choices-park-resume.json +++ b/spec/conformance/scenarios/interaction-choices-park-resume.json @@ -116,5 +116,9 @@ } ] } - ] + ], + "knownDivergences": [ + "go" + ], + "knownDivergencesReason": "th-CORE-runeslice \u2014 NOT an ordering divergence. Go's interaction_required ordering is FIXED (th-ef78d0); fixing it let this scenario run far enough to expose a second, previously-masked bug in smooth-operator-core: splitIntoChunks (go/core/llm_provider.go) slices the reply by BYTES, not runes. This scenario's reply contains an em-dash, and 36 bytes / 3 parts puts a chunk boundary mid-rune, so the pieces are invalid UTF-8 and each stray byte surfaces as U+FFFD. interaction-park-resume's reply is 33 bytes and its boundaries miss the rune, which is why only this scenario trips. Fix is one line upstream (slice []rune) in smooth-operator-core plus a Go module release. Do NOT weaken this scenario to make it pass." }