Skip to content

Commit bb18040

Browse files
fix: carry tool_call/tool_result parts through live agents/chat reply (#2831)
* fix: carry tool_call/tool_result parts through live agents/chat reply The synchronous agents/chat reply projection in AgentsChatHandler::toCanonicalMessages() dropped every tool_call and tool_result message before returning canonical messages[]. Interactive tool parts (present_question choice cards, confirmation/DiffCard buttons) therefore never reached the frontend on the turn they were produced, so the model referenced a card the client never received and the flow dead-looped. Carry tool_call/tool_result envelopes through into messages[] with their type, tool_name payload, and metadata preserved — matching the shape the persisted transcript and the frontend renderer already consume on session reload. The live turn is now projection-equivalent to a reload. Plain {role, content} text messages are unchanged; tool envelopes with no renderable tool_name are still omitted. Fixes #2830 * test: use block-style comment in tool-continuation smoke for WPCS * style: align canonical tool message array arrows per WPCS --------- Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent d12b29f commit bb18040

2 files changed

Lines changed: 154 additions & 4 deletions

File tree

inc/Abilities/Chat/AgentsChatHandler.php

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,10 +353,18 @@ static function ( $result ): ?array {
353353
}
354354

355355
/**
356-
* Project Data Machine envelopes to the simple canonical message list.
356+
* Project Data Machine envelopes to the canonical message list.
357+
*
358+
* Text messages project to the plain `{role, content}` contract. Interactive
359+
* tool parts (`tool_call`/`tool_result` — e.g. a `present_question` choice
360+
* card or a confirmation/DiffCard button) are carried through with their
361+
* `type`, `payload`, and `metadata` preserved, matching the shape the
362+
* persisted transcript and the frontend renderer already consume on session
363+
* reload. This makes the live-turn projection equivalent to a reload so the
364+
* card renders on the turn it is produced instead of only after a reload.
357365
*
358366
* @param array $conversation Conversation messages.
359-
* @return array<int,array{role:string,content:string}>
367+
* @return array<int,array<string,mixed>>
360368
*/
361369
private function toCanonicalMessages( array $conversation ): array {
362370
$messages = array();
@@ -366,7 +374,13 @@ private function toCanonicalMessages( array $conversation ): array {
366374
continue;
367375
}
368376

369-
if ( in_array( (string) ( $message['type'] ?? '' ), array( 'tool_call', 'tool_result' ), true ) ) {
377+
$type = (string) ( $message['type'] ?? '' );
378+
379+
if ( in_array( $type, array( 'tool_call', 'tool_result' ), true ) ) {
380+
$tool_message = $this->toCanonicalToolMessage( $message, $type );
381+
if ( null !== $tool_message ) {
382+
$messages[] = $tool_message;
383+
}
370384
continue;
371385
}
372386

@@ -382,4 +396,44 @@ private function toCanonicalMessages( array $conversation ): array {
382396

383397
return $messages;
384398
}
399+
400+
/**
401+
* Project a tool_call/tool_result envelope into the canonical message shape.
402+
*
403+
* Preserves the interactive payload (tool_name, parameters, model-facing
404+
* tool_data, success) so the frontend can render the clickable card on the
405+
* live turn. Mirrors the persisted-transcript / session-reload projection so
406+
* both paths feed the renderer an identical envelope.
407+
*
408+
* @param array $message Tool envelope from the conversation transcript.
409+
* @param string $type Envelope type: `tool_call` or `tool_result`.
410+
* @return array<string,mixed>|null Canonical tool message, or null when unrenderable.
411+
*/
412+
private function toCanonicalToolMessage( array $message, string $type ): ?array {
413+
$payload = is_array( $message['payload'] ?? null ) ? $message['payload'] : array();
414+
$metadata = is_array( $message['metadata'] ?? null ) ? $message['metadata'] : array();
415+
416+
$tool_name = (string) ( $payload['tool_name'] ?? $metadata['tool_name'] ?? '' );
417+
if ( '' === $tool_name ) {
418+
return null;
419+
}
420+
421+
$content = $message['content'];
422+
if ( ! is_string( $content ) && ! is_array( $content ) ) {
423+
return null;
424+
}
425+
426+
$canonical = array(
427+
'role' => (string) $message['role'],
428+
'content' => $content,
429+
'type' => $type,
430+
'payload' => $payload,
431+
);
432+
433+
if ( ! empty( $metadata ) ) {
434+
$canonical['metadata'] = $metadata;
435+
}
436+
437+
return $canonical;
438+
}
385439
}

tests/agents-chat-tool-continuation-smoke.php

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,103 @@ function wp_json_encode( $data, int $flags = 0 ) {
103103
'content' => 'Here is the answer.',
104104
),
105105
) === $messages,
106-
'Canonical Agents API chat output should omit internal tool call/result messages.'
106+
'Canonical Agents API chat output should omit tool envelopes that carry no renderable tool_name.'
107+
);
108+
++ $assertions;
109+
110+
/*
111+
* Interactive tool parts that carry a tool_name (e.g. present_question choice
112+
* cards, confirmation/DiffCard buttons) must be projected through to the
113+
* canonical messages[] on the live turn — preserving type, payload, and
114+
* metadata — so the frontend renders the clickable card on the sending turn
115+
* instead of only after a session reload. This is the fix for the dead-loop
116+
* where the model references a card the client never received.
117+
*/
118+
$interactive_messages = $method->invoke(
119+
$handler,
120+
array(
121+
array(
122+
'role' => 'user',
123+
'type' => 'text',
124+
'content' => 'Should I proceed?',
125+
),
126+
array(
127+
'role' => 'assistant',
128+
'type' => 'tool_call',
129+
'content' => 'AI ACTION (Turn 1): Executing Present Question.',
130+
'payload' => array(
131+
'tool_name' => 'present_question',
132+
'parameters' => array(
133+
'question' => 'Which surface?',
134+
),
135+
'turn' => 1,
136+
),
137+
'metadata' => array(
138+
'timestamp' => '2026-07-03T00:00:00+00:00',
139+
),
140+
),
141+
array(
142+
'role' => 'user',
143+
'type' => 'tool_result',
144+
'content' => 'TOOL RESPONSE (Turn 1): SUCCESS.',
145+
'payload' => array(
146+
'tool_name' => 'present_question',
147+
'success' => true,
148+
'tool_data' => array(
149+
'question' => 'Which surface?',
150+
'choices' => array(
151+
array(
152+
'label' => 'Platform',
153+
'message' => 'Platform',
154+
),
155+
array(
156+
'label' => 'UX',
157+
'message' => 'UX',
158+
),
159+
),
160+
),
161+
),
162+
),
163+
array(
164+
'role' => 'assistant',
165+
'type' => 'text',
166+
'content' => 'Click a choice on the card above.',
167+
),
168+
)
169+
);
170+
171+
datamachine_agents_chat_tool_continuation_assert(
172+
4 === count( $interactive_messages ),
173+
'Interactive tool parts with a tool_name must be carried through into canonical messages[] on the live turn.'
174+
);
175+
++ $assertions;
176+
177+
datamachine_agents_chat_tool_continuation_assert(
178+
'tool_call' === ( $interactive_messages[1]['type'] ?? null )
179+
&& 'present_question' === ( $interactive_messages[1]['payload']['tool_name'] ?? null )
180+
&& 'assistant' === ( $interactive_messages[1]['role'] ?? null ),
181+
'Live-turn tool_call projection preserves type, role, and tool_name for the frontend renderer.'
182+
);
183+
++ $assertions;
184+
185+
datamachine_agents_chat_tool_continuation_assert(
186+
'tool_result' === ( $interactive_messages[2]['type'] ?? null )
187+
&& true === ( $interactive_messages[2]['payload']['success'] ?? null )
188+
&& 2 === count( $interactive_messages[2]['payload']['tool_data']['choices'] ?? array() ),
189+
'Live-turn tool_result projection preserves the interactive tool_data payload (question/choices).'
190+
);
191+
++ $assertions;
192+
193+
datamachine_agents_chat_tool_continuation_assert(
194+
array( 'timestamp' => '2026-07-03T00:00:00+00:00' ) === ( $interactive_messages[1]['metadata'] ?? null ),
195+
'Live-turn tool projection preserves envelope metadata when present.'
196+
);
197+
++ $assertions;
198+
199+
datamachine_agents_chat_tool_continuation_assert(
200+
'Click a choice on the card above.' === ( $interactive_messages[3]['content'] ?? null )
201+
&& ! isset( $interactive_messages[3]['type'] ),
202+
'Plain text messages retain the {role, content} contract unchanged alongside tool parts.'
107203
);
108204
++ $assertions;
109205

0 commit comments

Comments
 (0)