jwadow commited on
Commit
1dca186
·
1 Parent(s): 947cbff

fix(converters): handle orphaned tool_results and strip tool content when no tools defined

Browse files
kiro/converters_anthropic.py CHANGED
@@ -153,7 +153,6 @@ def extract_tool_results_from_anthropic_content(content: Any) -> List[Dict[str,
153
  "tool_use_id": tool_use_id,
154
  "content": result_content or "(empty result)"
155
  })
156
- logger.debug(f"Extracted tool result for tool_use_id={tool_use_id}")
157
 
158
  return tool_results
159
 
@@ -201,7 +200,6 @@ def extract_tool_uses_from_anthropic_content(content: Any) -> List[Dict[str, Any
201
  "arguments": tool_input if isinstance(tool_input, str) else tool_input
202
  }
203
  })
204
- logger.debug(f"Extracted tool use: {tool_name} (id={tool_id})")
205
 
206
  return tool_calls
207
 
@@ -222,6 +220,8 @@ def convert_anthropic_messages(messages: List[AnthropicMessage]) -> List[Unified
222
  List of messages in unified format
223
  """
224
  unified_messages = []
 
 
225
 
226
  for msg in messages:
227
  role = msg.role
@@ -238,13 +238,13 @@ def convert_anthropic_messages(messages: List[AnthropicMessage]) -> List[Unified
238
  # Assistant messages may contain tool_use blocks
239
  tool_calls = extract_tool_uses_from_anthropic_content(content)
240
  if tool_calls:
241
- logger.debug(f"Found {len(tool_calls)} tool calls in assistant message")
242
 
243
  elif role == "user":
244
  # User messages may contain tool_result blocks
245
  tool_results = extract_tool_results_from_anthropic_content(content)
246
  if tool_results:
247
- logger.debug(f"Found {len(tool_results)} tool results in user message")
248
 
249
  unified_msg = UnifiedMessage(
250
  role=role,
@@ -254,6 +254,13 @@ def convert_anthropic_messages(messages: List[AnthropicMessage]) -> List[Unified
254
  )
255
  unified_messages.append(unified_msg)
256
 
 
 
 
 
 
 
 
257
  return unified_messages
258
 
259
 
 
153
  "tool_use_id": tool_use_id,
154
  "content": result_content or "(empty result)"
155
  })
 
156
 
157
  return tool_results
158
 
 
200
  "arguments": tool_input if isinstance(tool_input, str) else tool_input
201
  }
202
  })
 
203
 
204
  return tool_calls
205
 
 
220
  List of messages in unified format
221
  """
222
  unified_messages = []
223
+ total_tool_calls = 0
224
+ total_tool_results = 0
225
 
226
  for msg in messages:
227
  role = msg.role
 
238
  # Assistant messages may contain tool_use blocks
239
  tool_calls = extract_tool_uses_from_anthropic_content(content)
240
  if tool_calls:
241
+ total_tool_calls += len(tool_calls)
242
 
243
  elif role == "user":
244
  # User messages may contain tool_result blocks
245
  tool_results = extract_tool_results_from_anthropic_content(content)
246
  if tool_results:
247
+ total_tool_results += len(tool_results)
248
 
249
  unified_msg = UnifiedMessage(
250
  role=role,
 
254
  )
255
  unified_messages.append(unified_msg)
256
 
257
+ # Log summary if any tool content was found
258
+ if total_tool_calls > 0 or total_tool_results > 0:
259
+ logger.debug(
260
+ f"Converted {len(messages)} Anthropic messages: "
261
+ f"{total_tool_calls} tool_calls, {total_tool_results} tool_results"
262
+ )
263
+
264
  return unified_messages
265
 
266
 
kiro/converters_core.py CHANGED
@@ -492,6 +492,124 @@ def extract_tool_uses_from_message(
492
  # Message Merging
493
  # ==================================================================================================
494
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495
  def merge_adjacent_messages(messages: List[UnifiedMessage]) -> List[UnifiedMessage]:
496
  """
497
  Merges adjacent messages with the same role.
@@ -655,8 +773,19 @@ def build_kiro_payload(
655
  if thinking_system_addition:
656
  full_system_prompt = full_system_prompt + thinking_system_addition if full_system_prompt else thinking_system_addition.strip()
657
 
 
 
 
 
 
 
 
 
 
 
 
658
  # Merge adjacent messages with the same role
659
- merged_messages = merge_adjacent_messages(messages)
660
 
661
  if not merged_messages:
662
  raise ValueError("No messages to send")
@@ -716,12 +845,8 @@ def build_kiro_payload(
716
  user_input_context["toolResults"] = tool_results
717
 
718
  # Inject thinking tags if enabled (only for the current/last user message)
719
- # Skip injection when toolResults are present - Kiro API rejects this combination
720
- has_tool_results = "toolResults" in user_input_context
721
- if inject_thinking and current_message.role == "user" and not has_tool_results:
722
  current_content = inject_thinking_tags(current_content)
723
- elif has_tool_results:
724
- logger.debug("Skipping thinking tag injection: toolResults present in current message")
725
 
726
  # Build userInputMessage
727
  user_input_message = {
 
492
  # Message Merging
493
  # ==================================================================================================
494
 
495
+ def strip_all_tool_content(messages: List[UnifiedMessage]) -> Tuple[List[UnifiedMessage], bool]:
496
+ """
497
+ Strips ALL tool-related content from messages.
498
+
499
+ This is used when no tools are defined in the request. Kiro API rejects
500
+ requests that have toolResults but no tools defined.
501
+
502
+ Args:
503
+ messages: List of messages in unified format
504
+
505
+ Returns:
506
+ Tuple of:
507
+ - List of messages with all tool content stripped
508
+ - Boolean indicating whether any tool content was stripped
509
+ """
510
+ if not messages:
511
+ return [], False
512
+
513
+ result = []
514
+ total_tool_calls_stripped = 0
515
+ total_tool_results_stripped = 0
516
+
517
+ for msg in messages:
518
+ # Check if this message has any tool content
519
+ has_tool_calls = bool(msg.tool_calls)
520
+ has_tool_results = bool(msg.tool_results)
521
+
522
+ if has_tool_calls or has_tool_results:
523
+ if has_tool_calls:
524
+ total_tool_calls_stripped += len(msg.tool_calls)
525
+ if has_tool_results:
526
+ total_tool_results_stripped += len(msg.tool_results)
527
+
528
+ # Create a copy of the message without tool content
529
+ cleaned_msg = UnifiedMessage(
530
+ role=msg.role,
531
+ content=msg.content,
532
+ tool_calls=None,
533
+ tool_results=None
534
+ )
535
+ result.append(cleaned_msg)
536
+ else:
537
+ result.append(msg)
538
+
539
+ had_tool_content = total_tool_calls_stripped > 0 or total_tool_results_stripped > 0
540
+
541
+ # Log summary once (DEBUG level - this is normal for clients like Cline/Roo)
542
+ if had_tool_content:
543
+ logger.debug(
544
+ f"Stripped tool content (no tools defined): "
545
+ f"{total_tool_calls_stripped} tool_calls, {total_tool_results_stripped} tool_results"
546
+ )
547
+
548
+ return result, had_tool_content
549
+
550
+
551
+ def ensure_assistant_before_tool_results(messages: List[UnifiedMessage]) -> Tuple[List[UnifiedMessage], bool]:
552
+ """
553
+ Ensures that messages with tool_results have a preceding assistant message with tool_calls.
554
+
555
+ Kiro API requires that when toolResults are present, there must be a preceding
556
+ assistantResponseMessage with toolUses. Some clients (like Cline/Roo) may send
557
+ truncated conversations where the assistant message is missing.
558
+
559
+ Since we don't know the original tool name and arguments when the assistant message
560
+ is missing, we cannot create a valid synthetic assistant message. Instead, we strip
561
+ the tool_results from such messages to avoid Kiro API rejection.
562
+
563
+ Args:
564
+ messages: List of messages in unified format
565
+
566
+ Returns:
567
+ Tuple of:
568
+ - List of messages with orphaned tool_results stripped
569
+ - Boolean indicating whether any tool_results were stripped (used to skip thinking tag injection)
570
+ """
571
+ if not messages:
572
+ return [], False
573
+
574
+ result = []
575
+ stripped_any_tool_results = False
576
+
577
+ for msg in messages:
578
+ # Check if this message has tool_results
579
+ if msg.tool_results:
580
+ # Check if the previous message is an assistant with tool_calls
581
+ has_preceding_assistant = (
582
+ result and
583
+ result[-1].role == "assistant" and
584
+ result[-1].tool_calls
585
+ )
586
+
587
+ if not has_preceding_assistant:
588
+ # We cannot create a valid synthetic assistant message because we don't know
589
+ # the original tool name and arguments. Kiro API validates tool names.
590
+ # Strip the tool_results to avoid "Improperly formed request" error.
591
+ logger.warning(
592
+ f"Stripping {len(msg.tool_results)} orphaned tool_results "
593
+ f"(no preceding assistant message with tool_calls). "
594
+ f"Tool IDs: {[tr.get('tool_use_id', 'unknown') for tr in msg.tool_results]}"
595
+ )
596
+
597
+ # Create a copy of the message without tool_results
598
+ cleaned_msg = UnifiedMessage(
599
+ role=msg.role,
600
+ content=msg.content,
601
+ tool_calls=msg.tool_calls,
602
+ tool_results=None # Strip orphaned tool_results
603
+ )
604
+ result.append(cleaned_msg)
605
+ stripped_any_tool_results = True
606
+ continue
607
+
608
+ result.append(msg)
609
+
610
+ return result, stripped_any_tool_results
611
+
612
+
613
  def merge_adjacent_messages(messages: List[UnifiedMessage]) -> List[UnifiedMessage]:
614
  """
615
  Merges adjacent messages with the same role.
 
773
  if thinking_system_addition:
774
  full_system_prompt = full_system_prompt + thinking_system_addition if full_system_prompt else thinking_system_addition.strip()
775
 
776
+ # If no tools are defined, strip ALL tool-related content from messages
777
+ # Kiro API rejects requests with toolResults but no tools
778
+ if not tools:
779
+ messages_without_tools, had_tool_content = strip_all_tool_content(messages)
780
+ messages_with_assistants = messages_without_tools
781
+ stripped_tool_results = had_tool_content
782
+ else:
783
+ # Ensure assistant messages exist before tool_results (Kiro API requirement)
784
+ # Also returns flag if any tool_results were stripped (to skip thinking tag injection)
785
+ messages_with_assistants, stripped_tool_results = ensure_assistant_before_tool_results(messages)
786
+
787
  # Merge adjacent messages with the same role
788
+ merged_messages = merge_adjacent_messages(messages_with_assistants)
789
 
790
  if not merged_messages:
791
  raise ValueError("No messages to send")
 
845
  user_input_context["toolResults"] = tool_results
846
 
847
  # Inject thinking tags if enabled (only for the current/last user message)
848
+ if inject_thinking and current_message.role == "user":
 
 
849
  current_content = inject_thinking_tags(current_content)
 
 
850
 
851
  # Build userInputMessage
852
  user_input_message = {
kiro/converters_openai.py CHANGED
@@ -130,6 +130,8 @@ def convert_openai_messages_to_unified(messages: List[ChatMessage]) -> Tuple[str
130
  # Process tool messages - convert to user messages with tool_results
131
  processed = []
132
  pending_tool_results = []
 
 
133
 
134
  for msg in non_system_messages:
135
  if msg.role == "tool":
@@ -140,7 +142,7 @@ def convert_openai_messages_to_unified(messages: List[ChatMessage]) -> Tuple[str
140
  "content": extract_text_content(msg.content) or "(empty result)"
141
  }
142
  pending_tool_results.append(tool_result)
143
- logger.debug(f"Collected tool result for tool_call_id={msg.tool_call_id}")
144
  else:
145
  # If there are accumulated tool results, create user message with them
146
  if pending_tool_results:
@@ -151,7 +153,6 @@ def convert_openai_messages_to_unified(messages: List[ChatMessage]) -> Tuple[str
151
  )
152
  processed.append(unified_msg)
153
  pending_tool_results.clear()
154
- logger.debug(f"Created user message with {len(unified_msg.tool_results)} tool results")
155
 
156
  # Convert regular message
157
  tool_calls = None
@@ -159,8 +160,12 @@ def convert_openai_messages_to_unified(messages: List[ChatMessage]) -> Tuple[str
159
 
160
  if msg.role == "assistant":
161
  tool_calls = _extract_tool_calls_from_openai(msg) or None
 
 
162
  elif msg.role == "user":
163
  tool_results = _extract_tool_results_from_openai(msg.content) or None
 
 
164
 
165
  unified_msg = UnifiedMessage(
166
  role=msg.role,
@@ -178,7 +183,13 @@ def convert_openai_messages_to_unified(messages: List[ChatMessage]) -> Tuple[str
178
  tool_results=pending_tool_results.copy()
179
  )
180
  processed.append(unified_msg)
181
- logger.debug(f"Created final user message with {len(pending_tool_results)} tool results")
 
 
 
 
 
 
182
 
183
  return system_prompt, processed
184
 
 
130
  # Process tool messages - convert to user messages with tool_results
131
  processed = []
132
  pending_tool_results = []
133
+ total_tool_calls = 0
134
+ total_tool_results = 0
135
 
136
  for msg in non_system_messages:
137
  if msg.role == "tool":
 
142
  "content": extract_text_content(msg.content) or "(empty result)"
143
  }
144
  pending_tool_results.append(tool_result)
145
+ total_tool_results += 1
146
  else:
147
  # If there are accumulated tool results, create user message with them
148
  if pending_tool_results:
 
153
  )
154
  processed.append(unified_msg)
155
  pending_tool_results.clear()
 
156
 
157
  # Convert regular message
158
  tool_calls = None
 
160
 
161
  if msg.role == "assistant":
162
  tool_calls = _extract_tool_calls_from_openai(msg) or None
163
+ if tool_calls:
164
+ total_tool_calls += len(tool_calls)
165
  elif msg.role == "user":
166
  tool_results = _extract_tool_results_from_openai(msg.content) or None
167
+ if tool_results:
168
+ total_tool_results += len(tool_results)
169
 
170
  unified_msg = UnifiedMessage(
171
  role=msg.role,
 
183
  tool_results=pending_tool_results.copy()
184
  )
185
  processed.append(unified_msg)
186
+
187
+ # Log summary if any tool content was found
188
+ if total_tool_calls > 0 or total_tool_results > 0:
189
+ logger.debug(
190
+ f"Converted {len(messages)} OpenAI messages: "
191
+ f"{total_tool_calls} tool_calls, {total_tool_results} tool_results"
192
+ )
193
 
194
  return system_prompt, processed
195
 
kiro/routes_anthropic.py CHANGED
@@ -47,7 +47,6 @@ from kiro.converters_anthropic import anthropic_to_kiro
47
  from kiro.streaming_anthropic import (
48
  stream_kiro_to_anthropic,
49
  collect_anthropic_response,
50
- stream_with_first_token_retry_anthropic,
51
  )
52
  from kiro.http_client import KiroHttpClient
53
  from kiro.utils import generate_conversation_id
@@ -142,10 +141,7 @@ async def messages(
142
  Raises:
143
  HTTPException: On validation or API errors
144
  """
145
- logger.info(
146
- f"Request to /v1/messages (model={request_data.model}, "
147
- f"stream={request_data.stream}, max_tokens={request_data.max_tokens})"
148
- )
149
 
150
  if anthropic_version:
151
  logger.debug(f"Anthropic-Version header: {anthropic_version}")
@@ -212,29 +208,70 @@ async def messages(
212
  tools_for_tokenizer = [tool.model_dump() for tool in request_data.tools] if request_data.tools else None
213
 
214
  try:
215
- if request_data.stream:
216
- # Streaming mode with first token retry
217
- # Request is made inside retry loop, not here
218
- async def make_request():
219
- """Create new request to Kiro API."""
220
- return await http_client.request_with_retry(
221
- "POST",
222
- url,
223
- kiro_payload,
224
- stream=True
225
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  async def stream_wrapper():
228
  streaming_error = None
229
  client_disconnected = False
230
  try:
231
- async for chunk in stream_with_first_token_retry_anthropic(
232
- make_request=make_request,
233
- model=request_data.model,
234
- model_cache=model_cache,
235
- auth_manager=auth_manager,
236
- request_messages=messages_for_tokenizer,
237
- request_tools=tools_for_tokenizer
238
  ):
239
  yield chunk
240
  except GeneratorExit:
@@ -242,13 +279,12 @@ async def messages(
242
  logger.debug("Client disconnected during streaming (GeneratorExit in routes)")
243
  except Exception as e:
244
  streaming_error = e
245
- # Send error event
246
  try:
247
  error_event = f'event: error\ndata: {json.dumps({"type": "error", "error": {"type": "api_error", "message": str(e)}})}\n\n'
248
  yield error_event
249
  except Exception:
250
  pass
251
- raise
252
  finally:
253
  await http_client.close()
254
  if streaming_error:
@@ -276,56 +312,7 @@ async def messages(
276
  )
277
 
278
  else:
279
- # Non-streaming mode - make request here
280
- response = await http_client.request_with_retry(
281
- "POST",
282
- url,
283
- kiro_payload,
284
- stream=True
285
- )
286
-
287
- if response.status_code != 200:
288
- try:
289
- error_content = await response.aread()
290
- except Exception:
291
- error_content = b"Unknown error"
292
-
293
- await http_client.close()
294
- error_text = error_content.decode('utf-8', errors='replace')
295
- logger.error(f"Error from Kiro API: {response.status_code} - {error_text}")
296
-
297
- # Try to parse JSON response from Kiro
298
- error_message = error_text
299
- try:
300
- error_json = json.loads(error_text)
301
- if "message" in error_json:
302
- error_message = error_json["message"]
303
- if "reason" in error_json:
304
- error_message = f"{error_message} (reason: {error_json['reason']})"
305
- except (json.JSONDecodeError, KeyError):
306
- pass
307
-
308
- # Log access log for error
309
- logger.warning(
310
- f"HTTP {response.status_code} - POST /v1/messages - {error_message[:100]}"
311
- )
312
-
313
- # Flush debug logs on error
314
- if debug_logger:
315
- debug_logger.flush_on_error(response.status_code, error_message)
316
-
317
- # Return error in Anthropic format
318
- return JSONResponse(
319
- status_code=response.status_code,
320
- content={
321
- "type": "error",
322
- "error": {
323
- "type": "api_error",
324
- "message": error_message
325
- }
326
- }
327
- )
328
-
329
  anthropic_response = await collect_anthropic_response(
330
  response,
331
  request_data.model,
 
47
  from kiro.streaming_anthropic import (
48
  stream_kiro_to_anthropic,
49
  collect_anthropic_response,
 
50
  )
51
  from kiro.http_client import KiroHttpClient
52
  from kiro.utils import generate_conversation_id
 
141
  Raises:
142
  HTTPException: On validation or API errors
143
  """
144
+ logger.info(f"Request to /v1/messages (model={request_data.model}, stream={request_data.stream})")
 
 
 
145
 
146
  if anthropic_version:
147
  logger.debug(f"Anthropic-Version header: {anthropic_version}")
 
208
  tools_for_tokenizer = [tool.model_dump() for tool in request_data.tools] if request_data.tools else None
209
 
210
  try:
211
+ # Make request to Kiro API (for both streaming and non-streaming modes)
212
+ # Important: we wait for Kiro response BEFORE returning StreamingResponse,
213
+ # so that we can return proper HTTP error codes if Kiro fails
214
+ response = await http_client.request_with_retry(
215
+ "POST",
216
+ url,
217
+ kiro_payload,
218
+ stream=True
219
+ )
220
+
221
+ if response.status_code != 200:
222
+ try:
223
+ error_content = await response.aread()
224
+ except Exception:
225
+ error_content = b"Unknown error"
226
+
227
+ await http_client.close()
228
+ error_text = error_content.decode('utf-8', errors='replace')
229
+ logger.error(f"Error from Kiro API: {response.status_code} - {error_text}")
230
+
231
+ # Try to parse JSON response from Kiro to extract error message
232
+ error_message = error_text
233
+ try:
234
+ error_json = json.loads(error_text)
235
+ if "message" in error_json:
236
+ error_message = error_json["message"]
237
+ if "reason" in error_json:
238
+ error_message = f"{error_message} (reason: {error_json['reason']})"
239
+ except (json.JSONDecodeError, KeyError):
240
+ pass
241
+
242
+ # Log access log for error (before flush, so it gets into app_logs)
243
+ logger.warning(
244
+ f"HTTP {response.status_code} - POST /v1/messages - {error_message[:100]}"
245
+ )
246
+
247
+ # Flush debug logs on error
248
+ if debug_logger:
249
+ debug_logger.flush_on_error(response.status_code, error_message)
250
 
251
+ # Return error in Anthropic format
252
+ return JSONResponse(
253
+ status_code=response.status_code,
254
+ content={
255
+ "type": "error",
256
+ "error": {
257
+ "type": "api_error",
258
+ "message": error_message
259
+ }
260
+ }
261
+ )
262
+
263
+ if request_data.stream:
264
+ # Streaming mode - Kiro already returned 200, now stream the response
265
  async def stream_wrapper():
266
  streaming_error = None
267
  client_disconnected = False
268
  try:
269
+ async for chunk in stream_kiro_to_anthropic(
270
+ response,
271
+ request_data.model,
272
+ model_cache,
273
+ auth_manager,
274
+ request_messages=messages_for_tokenizer
 
275
  ):
276
  yield chunk
277
  except GeneratorExit:
 
279
  logger.debug("Client disconnected during streaming (GeneratorExit in routes)")
280
  except Exception as e:
281
  streaming_error = e
282
+ # Send error event to client, then gracefully end the stream
283
  try:
284
  error_event = f'event: error\ndata: {json.dumps({"type": "error", "error": {"type": "api_error", "message": str(e)}})}\n\n'
285
  yield error_event
286
  except Exception:
287
  pass
 
288
  finally:
289
  await http_client.close()
290
  if streaming_error:
 
312
  )
313
 
314
  else:
315
+ # Non-streaming mode - collect entire response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  anthropic_response = await collect_anthropic_response(
317
  response,
318
  request_data.model,
kiro/streaming_core.py CHANGED
@@ -468,9 +468,10 @@ async def stream_with_first_token_retry(
468
 
469
  except Exception as e:
470
  # Other errors - no retry, propagate
471
- # Use repr() to avoid loguru interpreting curly braces in error message as format placeholders
472
- error_msg = repr(str(e))
473
- logger.error(f"Unexpected error during streaming: {error_msg}", exc_info=True)
 
474
  if response:
475
  try:
476
  await response.aclose()
 
468
 
469
  except Exception as e:
470
  # Other errors - no retry, propagate
471
+ # Use positional argument to avoid loguru interpreting curly braces in error message as format placeholders
472
+ # f-string with repr() doesn't work because loguru still sees {type} inside the string
473
+ error_msg = str(e) if str(e) else "(empty message)"
474
+ logger.error("Unexpected error during streaming: {}", error_msg, exc_info=True)
475
  if response:
476
  try:
477
  await response.aclose()
tests/README.md CHANGED
@@ -865,6 +865,54 @@ Unit tests for **Anthropic Messages API → Kiro** converters. **45 tests.**
865
  - **What it does**: Verifies conversion of other types to string
866
  - **Purpose**: Ensure numbers and other types are converted
867
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
868
  #### `TestExtractToolResultsFromAnthropicContent`
869
 
870
  - **`test_extracts_tool_result_from_dict()`**:
@@ -1021,9 +1069,9 @@ Main entry point tests for anthropic_to_kiro function.
1021
  - **What it does**: Verifies that thinking tags are injected when enabled
1022
  - **Purpose**: Ensure fake reasoning feature works with Anthropic API
1023
 
1024
- - **`test_skips_thinking_tags_when_tool_results_present()`**:
1025
- - **What it does**: Verifies that thinking tags are skipped when tool results are present
1026
- - **Purpose**: Ensure Kiro API compatibility (rejects thinking tags with tool results)
1027
 
1028
  ---
1029
 
@@ -1171,6 +1219,54 @@ Tests for sanitize_json_schema function that cleans JSON Schema from fields not
1171
  - **What it does**: Verifies extraction of multiple tool results
1172
  - **Purpose**: Ensure all tool_result elements are extracted
1173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1174
  #### `TestExtractToolUses`
1175
 
1176
  - **`test_extracts_from_tool_calls_field()`**:
@@ -1443,9 +1539,9 @@ Unit tests for **OpenAI Chat API → Kiro** converters. **47 tests.**
1443
  - **What it does**: Verifies including tools in userInputMessageContext
1444
  - **Purpose**: Ensure tools are converted and included
1445
 
1446
- - **`test_skips_thinking_tags_when_tool_results_present()`**:
1447
- - **What it does**: Verifies thinking tags are NOT injected when toolResults are present
1448
- - **Purpose**: Fix GitHub issue #20 - OpenCode compaction returns 400 error
1449
 
1450
  - **`test_injects_thinking_tags_when_no_tool_results()`**:
1451
  - **What it does**: Verifies thinking tags ARE injected for normal user messages
 
865
  - **What it does**: Verifies conversion of other types to string
866
  - **Purpose**: Ensure numbers and other types are converted
867
 
868
+ #### `TestExtractSystemPrompt`
869
+
870
+ Tests for extract_system_prompt function (Support System commit - prompt caching support).
871
+
872
+ - **`test_extracts_from_string()`**:
873
+ - **What it does**: Verifies extraction from simple string
874
+ - **Purpose**: Ensure string system prompt is returned as-is
875
+
876
+ - **`test_extracts_from_list_with_text_blocks()`**:
877
+ - **What it does**: Verifies extraction from list of content blocks
878
+ - **Purpose**: Ensure Anthropic prompt caching format is handled
879
+
880
+ - **`test_extracts_from_list_with_cache_control()`**:
881
+ - **What it does**: Verifies extraction ignores cache_control field
882
+ - **Purpose**: Ensure cache_control is stripped (not supported by Kiro)
883
+
884
+ - **`test_extracts_from_pydantic_system_content_blocks()`**:
885
+ - **What it does**: Verifies extraction from Pydantic SystemContentBlock objects
886
+ - **Purpose**: Ensure Pydantic models are handled correctly
887
+
888
+ - **`test_handles_none()`**:
889
+ - **What it does**: Verifies None handling
890
+ - **Purpose**: Ensure None returns empty string
891
+
892
+ - **`test_handles_empty_list()`**:
893
+ - **What it does**: Verifies empty list handling
894
+ - **Purpose**: Ensure empty list returns empty string
895
+
896
+ - **`test_handles_mixed_content_blocks()`**:
897
+ - **What it does**: Verifies handling of list with non-text blocks
898
+ - **Purpose**: Ensure only text blocks are extracted
899
+
900
+ - **`test_converts_other_types_to_string()`**:
901
+ - **What it does**: Verifies conversion of other types to string
902
+ - **Purpose**: Ensure numbers and other types are converted
903
+
904
+ - **`test_handles_single_text_block()`**:
905
+ - **What it does**: Verifies extraction from single text block in list
906
+ - **Purpose**: Ensure single block list works correctly
907
+
908
+ - **`test_handles_empty_text_in_block()`**:
909
+ - **What it does**: Verifies handling of empty text in content block
910
+ - **Purpose**: Ensure empty text doesn't cause errors
911
+
912
+ - **`test_handles_missing_text_key()`**:
913
+ - **What it does**: Verifies handling of content block without text key
914
+ - **Purpose**: Ensure missing text key doesn't cause errors
915
+
916
  #### `TestExtractToolResultsFromAnthropicContent`
917
 
918
  - **`test_extracts_tool_result_from_dict()`**:
 
1069
  - **What it does**: Verifies that thinking tags are injected when enabled
1070
  - **Purpose**: Ensure fake reasoning feature works with Anthropic API
1071
 
1072
+ - **`test_injects_thinking_tags_even_when_tool_results_present()`**:
1073
+ - **What it does**: Verifies that thinking tags ARE injected even when tool results are present
1074
+ - **Purpose**: Extended thinking should work in all scenarios including tool use flows
1075
 
1076
  ---
1077
 
 
1219
  - **What it does**: Verifies extraction of multiple tool results
1220
  - **Purpose**: Ensure all tool_result elements are extracted
1221
 
1222
+ #### `TestConvertToolResultsToKiroFormat`
1223
+
1224
+ Tests for convert_tool_results_to_kiro_format function that converts unified tool results format (snake_case) to Kiro API format (camelCase). This is a critical function for fixing the 400 "Improperly formed request" bug.
1225
+
1226
+ - **`test_converts_single_tool_result()`**:
1227
+ - **What it does**: Verifies conversion of a single tool result
1228
+ - **Purpose**: Ensure basic conversion from unified to Kiro format works
1229
+
1230
+ - **`test_converts_multiple_tool_results()`**:
1231
+ - **What it does**: Verifies conversion of multiple tool results
1232
+ - **Purpose**: Ensure all tool results are converted correctly
1233
+
1234
+ - **`test_returns_empty_list_for_empty_input()`**:
1235
+ - **What it does**: Verifies empty list handling
1236
+ - **Purpose**: Ensure empty input returns empty output
1237
+
1238
+ - **`test_replaces_empty_content_with_placeholder()`**:
1239
+ - **What it does**: Verifies empty content is replaced with placeholder
1240
+ - **Purpose**: Ensure Kiro API receives non-empty content (required by API)
1241
+
1242
+ - **`test_replaces_none_content_with_placeholder()`**:
1243
+ - **What it does**: Verifies None content is replaced with placeholder
1244
+ - **Purpose**: Ensure Kiro API receives non-empty content when content is None
1245
+
1246
+ - **`test_handles_missing_content_key()`**:
1247
+ - **What it does**: Verifies handling of missing content key
1248
+ - **Purpose**: Ensure function doesn't crash when content key is missing
1249
+
1250
+ - **`test_handles_missing_tool_use_id()`**:
1251
+ - **What it does**: Verifies handling of missing tool_use_id
1252
+ - **Purpose**: Ensure function returns empty string for missing tool_use_id
1253
+
1254
+ - **`test_extracts_text_from_list_content()`**:
1255
+ - **What it does**: Verifies extraction of text from list content
1256
+ - **Purpose**: Ensure multimodal content format is handled correctly
1257
+
1258
+ - **`test_preserves_long_content()`**:
1259
+ - **What it does**: Verifies long content is preserved
1260
+ - **Purpose**: Ensure large tool results are not truncated
1261
+
1262
+ - **`test_all_results_have_success_status()`**:
1263
+ - **What it does**: Verifies all results have status="success"
1264
+ - **Purpose**: Ensure Kiro API receives correct status field
1265
+
1266
+ - **`test_handles_unicode_content()`**:
1267
+ - **What it does**: Verifies Unicode content is preserved
1268
+ - **Purpose**: Ensure non-ASCII characters are handled correctly
1269
+
1270
  #### `TestExtractToolUses`
1271
 
1272
  - **`test_extracts_from_tool_calls_field()`**:
 
1539
  - **What it does**: Verifies including tools in userInputMessageContext
1540
  - **Purpose**: Ensure tools are converted and included
1541
 
1542
+ - **`test_injects_thinking_tags_even_when_tool_results_present()`**:
1543
+ - **What it does**: Verifies thinking tags ARE injected even when toolResults are present
1544
+ - **Purpose**: Extended thinking should work in all scenarios including tool use flows
1545
 
1546
  - **`test_injects_thinking_tags_when_no_tool_results()`**:
1547
  - **What it does**: Verifies thinking tags ARE injected for normal user messages
tests/unit/test_converters_anthropic.py CHANGED
@@ -17,6 +17,7 @@ from unittest.mock import patch, MagicMock
17
 
18
  from kiro.converters_anthropic import (
19
  convert_anthropic_content_to_text,
 
20
  extract_tool_results_from_anthropic_content,
21
  extract_tool_uses_from_anthropic_content,
22
  convert_anthropic_messages,
@@ -31,6 +32,7 @@ from kiro.models_anthropic import (
31
  TextContentBlock,
32
  ToolUseContentBlock,
33
  ToolResultContentBlock,
 
34
  )
35
 
36
 
@@ -149,6 +151,183 @@ class TestConvertAnthropicContentToText:
149
  assert result == "42"
150
 
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  # ==================================================================================================
153
  # Tests for extract_tool_results_from_anthropic_content
154
  # ==================================================================================================
@@ -857,7 +1036,15 @@ class TestAnthropicToKiro:
857
  ]
858
  )
859
  ],
860
- max_tokens=1024
 
 
 
 
 
 
 
 
861
  )
862
 
863
  print("Action: Converting to Kiro payload...")
@@ -926,14 +1113,10 @@ class TestAnthropicToKiro:
926
  assert "<thinking_mode>enabled</thinking_mode>" in current_content
927
  assert "What is 2+2?" in current_content
928
 
929
- def test_skips_thinking_tags_when_tool_results_present(self):
930
  """
931
- What it does: Verifies that thinking tags are skipped when tool results are present.
932
- Purpose: Ensure Kiro API compatibility (rejects thinking tags with tool results).
933
-
934
- Note: The system prompt addition contains `<thinking_mode>` as documentation text
935
- (in backticks), but the actual thinking tags injection is skipped. We check that
936
- the content doesn't START with the thinking tags prefix.
937
  """
938
  print("Setup: Request with tool results and fake reasoning enabled...")
939
  request = AnthropicMessagesRequest(
@@ -946,7 +1129,15 @@ class TestAnthropicToKiro:
946
  ]
947
  )
948
  ],
949
- max_tokens=1024
 
 
 
 
 
 
 
 
950
  )
951
 
952
  print("Action: Converting to Kiro payload...")
@@ -959,14 +1150,10 @@ class TestAnthropicToKiro:
959
  current_content = result["conversationState"]["currentMessage"]["userInputMessage"]["content"]
960
  print(f"Current content (first 100 chars): {current_content[:100]}...")
961
 
962
- print("Checking that content does NOT start with thinking tags prefix...")
963
- # The actual thinking tags prefix starts with "<thinking_mode>enabled"
964
- # The system prompt addition contains this text in backticks as documentation
965
- # We check that the content doesn't start with the actual tag (not in backticks)
966
- assert not current_content.startswith("<thinking_mode>enabled</thinking_mode>"), \
967
- "Content should not start with thinking tags when tool results are present"
968
-
969
- print("Checking that <max_thinking_length> tag is NOT present...")
970
- # This tag is only present in the actual injection, not in documentation
971
- assert "<max_thinking_length>4000</max_thinking_length>" not in current_content, \
972
- "max_thinking_length tag should not be present when tool results are present"
 
17
 
18
  from kiro.converters_anthropic import (
19
  convert_anthropic_content_to_text,
20
+ extract_system_prompt,
21
  extract_tool_results_from_anthropic_content,
22
  extract_tool_uses_from_anthropic_content,
23
  convert_anthropic_messages,
 
32
  TextContentBlock,
33
  ToolUseContentBlock,
34
  ToolResultContentBlock,
35
+ SystemContentBlock,
36
  )
37
 
38
 
 
151
  assert result == "42"
152
 
153
 
154
+ # ==================================================================================================
155
+ # Tests for extract_system_prompt
156
+ # ==================================================================================================
157
+
158
+ class TestExtractSystemPrompt:
159
+ """Tests for extract_system_prompt function (Support System commit)."""
160
+
161
+ def test_extracts_from_string(self):
162
+ """
163
+ What it does: Verifies extraction from simple string.
164
+ Purpose: Ensure string system prompt is returned as-is.
165
+ """
166
+ print("Setup: Simple string system prompt...")
167
+ system = "You are a helpful assistant."
168
+
169
+ print("Action: Extracting system prompt...")
170
+ result = extract_system_prompt(system)
171
+
172
+ print(f"Comparing result: Expected 'You are a helpful assistant.', Got '{result}'")
173
+ assert result == "You are a helpful assistant."
174
+
175
+ def test_extracts_from_list_with_text_blocks(self):
176
+ """
177
+ What it does: Verifies extraction from list of content blocks.
178
+ Purpose: Ensure Anthropic prompt caching format is handled.
179
+ """
180
+ print("Setup: List with text content blocks (prompt caching format)...")
181
+ system = [
182
+ {"type": "text", "text": "You are helpful."},
183
+ {"type": "text", "text": "Be concise."}
184
+ ]
185
+
186
+ print("Action: Extracting system prompt...")
187
+ result = extract_system_prompt(system)
188
+
189
+ print(f"Comparing result: Expected 'You are helpful.\\nBe concise.', Got '{result}'")
190
+ assert result == "You are helpful.\nBe concise."
191
+
192
+ def test_extracts_from_list_with_cache_control(self):
193
+ """
194
+ What it does: Verifies extraction ignores cache_control field.
195
+ Purpose: Ensure cache_control is stripped (not supported by Kiro).
196
+ """
197
+ print("Setup: List with cache_control (prompt caching format)...")
198
+ system = [
199
+ {
200
+ "type": "text",
201
+ "text": "You are a helpful assistant.",
202
+ "cache_control": {"type": "ephemeral"}
203
+ }
204
+ ]
205
+
206
+ print("Action: Extracting system prompt...")
207
+ result = extract_system_prompt(system)
208
+
209
+ print(f"Comparing result: Expected 'You are a helpful assistant.', Got '{result}'")
210
+ assert result == "You are a helpful assistant."
211
+
212
+ def test_extracts_from_pydantic_system_content_blocks(self):
213
+ """
214
+ What it does: Verifies extraction from Pydantic SystemContentBlock objects.
215
+ Purpose: Ensure Pydantic models are handled correctly.
216
+ """
217
+ print("Setup: List with Pydantic SystemContentBlock objects...")
218
+ system = [
219
+ SystemContentBlock(type="text", text="Part 1"),
220
+ SystemContentBlock(type="text", text="Part 2")
221
+ ]
222
+
223
+ print("Action: Extracting system prompt...")
224
+ result = extract_system_prompt(system)
225
+
226
+ print(f"Comparing result: Expected 'Part 1\\nPart 2', Got '{result}'")
227
+ assert result == "Part 1\nPart 2"
228
+
229
+ def test_handles_none(self):
230
+ """
231
+ What it does: Verifies None handling.
232
+ Purpose: Ensure None returns empty string.
233
+ """
234
+ print("Setup: None system prompt...")
235
+
236
+ print("Action: Extracting system prompt...")
237
+ result = extract_system_prompt(None)
238
+
239
+ print(f"Comparing result: Expected '', Got '{result}'")
240
+ assert result == ""
241
+
242
+ def test_handles_empty_list(self):
243
+ """
244
+ What it does: Verifies empty list handling.
245
+ Purpose: Ensure empty list returns empty string.
246
+ """
247
+ print("Setup: Empty list...")
248
+ system = []
249
+
250
+ print("Action: Extracting system prompt...")
251
+ result = extract_system_prompt(system)
252
+
253
+ print(f"Comparing result: Expected '', Got '{result}'")
254
+ assert result == ""
255
+
256
+ def test_handles_mixed_content_blocks(self):
257
+ """
258
+ What it does: Verifies handling of list with non-text blocks.
259
+ Purpose: Ensure only text blocks are extracted.
260
+ """
261
+ print("Setup: List with mixed content blocks...")
262
+ system = [
263
+ {"type": "text", "text": "Hello"},
264
+ {"type": "image", "source": {"type": "base64", "data": "..."}},
265
+ {"type": "text", "text": "World"}
266
+ ]
267
+
268
+ print("Action: Extracting system prompt...")
269
+ result = extract_system_prompt(system)
270
+
271
+ print(f"Comparing result: Expected 'Hello\\nWorld', Got '{result}'")
272
+ assert result == "Hello\nWorld"
273
+
274
+ def test_converts_other_types_to_string(self):
275
+ """
276
+ What it does: Verifies conversion of other types to string.
277
+ Purpose: Ensure numbers and other types are converted.
278
+ """
279
+ print("Setup: Number as system prompt...")
280
+ system = 42
281
+
282
+ print("Action: Extracting system prompt...")
283
+ result = extract_system_prompt(system)
284
+
285
+ print(f"Comparing result: Expected '42', Got '{result}'")
286
+ assert result == "42"
287
+
288
+ def test_handles_single_text_block(self):
289
+ """
290
+ What it does: Verifies extraction from single text block in list.
291
+ Purpose: Ensure single block list works correctly.
292
+ """
293
+ print("Setup: Single text block in list...")
294
+ system = [{"type": "text", "text": "Single block"}]
295
+
296
+ print("Action: Extracting system prompt...")
297
+ result = extract_system_prompt(system)
298
+
299
+ print(f"Comparing result: Expected 'Single block', Got '{result}'")
300
+ assert result == "Single block"
301
+
302
+ def test_handles_empty_text_in_block(self):
303
+ """
304
+ What it does: Verifies handling of empty text in content block.
305
+ Purpose: Ensure empty text doesn't cause errors.
306
+ """
307
+ print("Setup: Content block with empty text...")
308
+ system = [{"type": "text", "text": ""}]
309
+
310
+ print("Action: Extracting system prompt...")
311
+ result = extract_system_prompt(system)
312
+
313
+ print(f"Comparing result: Expected '', Got '{result}'")
314
+ assert result == ""
315
+
316
+ def test_handles_missing_text_key(self):
317
+ """
318
+ What it does: Verifies handling of content block without text key.
319
+ Purpose: Ensure missing text key doesn't cause errors.
320
+ """
321
+ print("Setup: Content block without text key...")
322
+ system = [{"type": "text"}]
323
+
324
+ print("Action: Extracting system prompt...")
325
+ result = extract_system_prompt(system)
326
+
327
+ print(f"Comparing result: Expected '', Got '{result}'")
328
+ assert result == ""
329
+
330
+
331
  # ==================================================================================================
332
  # Tests for extract_tool_results_from_anthropic_content
333
  # ==================================================================================================
 
1036
  ]
1037
  )
1038
  ],
1039
+ max_tokens=1024,
1040
+ # Tools must be defined for tool_results to be preserved
1041
+ tools=[
1042
+ AnthropicTool(
1043
+ name="get_weather",
1044
+ description="Get weather for a location",
1045
+ input_schema={"type": "object", "properties": {"location": {"type": "string"}}}
1046
+ )
1047
+ ]
1048
  )
1049
 
1050
  print("Action: Converting to Kiro payload...")
 
1113
  assert "<thinking_mode>enabled</thinking_mode>" in current_content
1114
  assert "What is 2+2?" in current_content
1115
 
1116
+ def test_injects_thinking_tags_even_when_tool_results_present(self):
1117
  """
1118
+ What it does: Verifies that thinking tags ARE injected even when tool results are present.
1119
+ Purpose: Extended thinking should work in all scenarios including tool use flows.
 
 
 
 
1120
  """
1121
  print("Setup: Request with tool results and fake reasoning enabled...")
1122
  request = AnthropicMessagesRequest(
 
1129
  ]
1130
  )
1131
  ],
1132
+ max_tokens=1024,
1133
+ # Tools must be defined for tool_results to be preserved
1134
+ tools=[
1135
+ AnthropicTool(
1136
+ name="test_tool",
1137
+ description="A test tool",
1138
+ input_schema={"type": "object", "properties": {}}
1139
+ )
1140
+ ]
1141
  )
1142
 
1143
  print("Action: Converting to Kiro payload...")
 
1150
  current_content = result["conversationState"]["currentMessage"]["userInputMessage"]["content"]
1151
  print(f"Current content (first 100 chars): {current_content[:100]}...")
1152
 
1153
+ print("Checking that thinking tags ARE present...")
1154
+ assert "<thinking_mode>enabled</thinking_mode>" in current_content, \
1155
+ "thinking tags SHOULD be injected even with tool results"
1156
+
1157
+ print("Checking that <max_thinking_length> tag IS present...")
1158
+ assert "<max_thinking_length>4000</max_thinking_length>" in current_content, \
1159
+ "max_thinking_length tag SHOULD be present even with tool results"
 
 
 
 
tests/unit/test_converters_core.py CHANGED
@@ -17,6 +17,7 @@ from unittest.mock import patch
17
  from kiro.converters_core import (
18
  extract_text_content,
19
  merge_adjacent_messages,
 
20
  build_kiro_history,
21
  process_tools_with_long_descriptions,
22
  inject_thinking_tags,
@@ -24,6 +25,7 @@ from kiro.converters_core import (
24
  extract_tool_uses_from_message,
25
  sanitize_json_schema,
26
  convert_tools_to_kiro_format,
 
27
  UnifiedMessage,
28
  UnifiedTool,
29
  )
@@ -393,6 +395,352 @@ class TestMergeAdjacentMessages:
393
  assert len(result[0].tool_results) == 2
394
 
395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  # ==================================================================================================
397
  # Tests for sanitize_json_schema
398
  # ==================================================================================================
@@ -686,6 +1034,243 @@ class TestExtractToolResults:
686
  assert result[1]["toolUseId"] == "call_2"
687
 
688
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
  # ==================================================================================================
690
  # Tests for extract_tool_uses_from_message
691
  # ==================================================================================================
 
17
  from kiro.converters_core import (
18
  extract_text_content,
19
  merge_adjacent_messages,
20
+ ensure_assistant_before_tool_results,
21
  build_kiro_history,
22
  process_tools_with_long_descriptions,
23
  inject_thinking_tags,
 
25
  extract_tool_uses_from_message,
26
  sanitize_json_schema,
27
  convert_tools_to_kiro_format,
28
+ convert_tool_results_to_kiro_format,
29
  UnifiedMessage,
30
  UnifiedTool,
31
  )
 
395
  assert len(result[0].tool_results) == 2
396
 
397
 
398
+ # ==================================================================================================
399
+ # Tests for ensure_assistant_before_tool_results
400
+ # ==================================================================================================
401
+
402
+ class TestEnsureAssistantBeforeToolResults:
403
+ """
404
+ Tests for ensure_assistant_before_tool_results function.
405
+
406
+ This function handles the case when clients (like Cline/Roo) send truncated
407
+ conversations with tool_results but without the preceding assistant message
408
+ that contains the tool_calls. Since we don't know the original tool name,
409
+ we strip the orphaned tool_results to avoid Kiro API rejection.
410
+ """
411
+
412
+ def test_returns_empty_list_for_empty_input(self):
413
+ """
414
+ What it does: Verifies empty list handling.
415
+ Purpose: Ensure empty input returns empty output.
416
+ """
417
+ print("Setup: Empty list...")
418
+
419
+ print("Action: Processing messages...")
420
+ result, stripped = ensure_assistant_before_tool_results([])
421
+
422
+ print(f"Comparing result: Expected [], Got {result}")
423
+ assert result == []
424
+ assert stripped is False
425
+
426
+ def test_preserves_messages_without_tool_results(self):
427
+ """
428
+ What it does: Verifies messages without tool_results are unchanged.
429
+ Purpose: Ensure regular messages pass through unmodified.
430
+ """
431
+ print("Setup: Messages without tool_results...")
432
+ messages = [
433
+ UnifiedMessage(role="user", content="Hello"),
434
+ UnifiedMessage(role="assistant", content="Hi there"),
435
+ UnifiedMessage(role="user", content="How are you?")
436
+ ]
437
+
438
+ print("Action: Processing messages...")
439
+ result, stripped = ensure_assistant_before_tool_results(messages)
440
+
441
+ print(f"Comparing length: Expected 3, Got {len(result)}")
442
+ assert len(result) == 3
443
+ assert result[0].content == "Hello"
444
+ assert result[1].content == "Hi there"
445
+ assert result[2].content == "How are you?"
446
+ assert stripped is False
447
+
448
+ def test_preserves_tool_results_with_preceding_assistant(self):
449
+ """
450
+ What it does: Verifies tool_results are preserved when assistant with tool_calls precedes.
451
+ Purpose: Ensure valid tool_results are not stripped.
452
+ """
453
+ print("Setup: Valid conversation with assistant tool_calls followed by user tool_results...")
454
+ messages = [
455
+ UnifiedMessage(role="user", content="Call a tool"),
456
+ UnifiedMessage(
457
+ role="assistant",
458
+ content="",
459
+ tool_calls=[{
460
+ "id": "call_123",
461
+ "type": "function",
462
+ "function": {"name": "get_weather", "arguments": '{"location": "Moscow"}'}
463
+ }]
464
+ ),
465
+ UnifiedMessage(
466
+ role="user",
467
+ content="",
468
+ tool_results=[{
469
+ "type": "tool_result",
470
+ "tool_use_id": "call_123",
471
+ "content": "Weather is sunny"
472
+ }]
473
+ )
474
+ ]
475
+
476
+ print("Action: Processing messages...")
477
+ result, stripped = ensure_assistant_before_tool_results(messages)
478
+
479
+ print(f"Result: {result}")
480
+ print(f"Comparing length: Expected 3, Got {len(result)}")
481
+ assert len(result) == 3
482
+
483
+ print("Checking that tool_results are preserved...")
484
+ assert result[2].tool_results is not None
485
+ assert len(result[2].tool_results) == 1
486
+ assert result[2].tool_results[0]["tool_use_id"] == "call_123"
487
+ assert stripped is False
488
+
489
+ def test_strips_orphaned_tool_results_at_start(self):
490
+ """
491
+ What it does: Verifies orphaned tool_results at the start are stripped.
492
+ Purpose: Ensure tool_results without preceding assistant are removed.
493
+
494
+ This is the critical bug fix test - when a client sends a truncated
495
+ conversation starting with tool_results, they should be stripped.
496
+ """
497
+ print("Setup: Conversation starting with orphaned tool_results...")
498
+ messages = [
499
+ UnifiedMessage(
500
+ role="user",
501
+ content="",
502
+ tool_results=[{
503
+ "type": "tool_result",
504
+ "tool_use_id": "call_orphan",
505
+ "content": "Orphaned result"
506
+ }]
507
+ ),
508
+ UnifiedMessage(role="user", content="Continue the conversation")
509
+ ]
510
+
511
+ print("Action: Processing messages...")
512
+ result, stripped = ensure_assistant_before_tool_results(messages)
513
+
514
+ print(f"Result: {result}")
515
+ print(f"Comparing length: Expected 2, Got {len(result)}")
516
+ assert len(result) == 2
517
+
518
+ print("Checking that orphaned tool_results are stripped...")
519
+ assert result[0].tool_results is None
520
+ assert result[0].content == "" # Content preserved
521
+ assert result[1].content == "Continue the conversation"
522
+ assert stripped is True
523
+
524
+ def test_strips_tool_results_after_assistant_without_tool_calls(self):
525
+ """
526
+ What it does: Verifies tool_results are stripped when preceding assistant has no tool_calls.
527
+ Purpose: Ensure tool_results require assistant with tool_calls, not just any assistant.
528
+ """
529
+ print("Setup: Assistant without tool_calls followed by user with tool_results...")
530
+ messages = [
531
+ UnifiedMessage(role="user", content="Hello"),
532
+ UnifiedMessage(role="assistant", content="Let me think...", tool_calls=None),
533
+ UnifiedMessage(
534
+ role="user",
535
+ content="",
536
+ tool_results=[{
537
+ "type": "tool_result",
538
+ "tool_use_id": "call_123",
539
+ "content": "Result"
540
+ }]
541
+ )
542
+ ]
543
+
544
+ print("Action: Processing messages...")
545
+ result, stripped = ensure_assistant_before_tool_results(messages)
546
+
547
+ print(f"Result: {result}")
548
+ print("Checking that tool_results are stripped...")
549
+ assert result[2].tool_results is None
550
+ assert stripped is True
551
+
552
+ def test_strips_tool_results_after_user_message(self):
553
+ """
554
+ What it does: Verifies tool_results are stripped when preceded by user message.
555
+ Purpose: Ensure tool_results require assistant, not user.
556
+ """
557
+ print("Setup: User message followed by user with tool_results...")
558
+ messages = [
559
+ UnifiedMessage(role="user", content="First message"),
560
+ UnifiedMessage(
561
+ role="user",
562
+ content="",
563
+ tool_results=[{
564
+ "type": "tool_result",
565
+ "tool_use_id": "call_123",
566
+ "content": "Result"
567
+ }]
568
+ )
569
+ ]
570
+
571
+ print("Action: Processing messages...")
572
+ result, stripped = ensure_assistant_before_tool_results(messages)
573
+
574
+ print(f"Result: {result}")
575
+ print("Checking that tool_results are stripped...")
576
+ assert result[1].tool_results is None
577
+ assert stripped is True
578
+
579
+ def test_preserves_content_when_stripping_tool_results(self):
580
+ """
581
+ What it does: Verifies message content is preserved when tool_results are stripped.
582
+ Purpose: Ensure only tool_results are removed, not the entire message.
583
+ """
584
+ print("Setup: Message with both content and orphaned tool_results...")
585
+ messages = [
586
+ UnifiedMessage(
587
+ role="user",
588
+ content="Here is some context",
589
+ tool_results=[{
590
+ "type": "tool_result",
591
+ "tool_use_id": "call_123",
592
+ "content": "Result"
593
+ }]
594
+ )
595
+ ]
596
+
597
+ print("Action: Processing messages...")
598
+ result, stripped = ensure_assistant_before_tool_results(messages)
599
+
600
+ print(f"Result: {result}")
601
+ print("Checking that content is preserved...")
602
+ assert result[0].content == "Here is some context"
603
+ assert result[0].tool_results is None
604
+ assert stripped is True
605
+
606
+ def test_preserves_tool_calls_when_stripping_tool_results(self):
607
+ """
608
+ What it does: Verifies tool_calls are preserved when tool_results are stripped.
609
+ Purpose: Ensure only tool_results are removed, tool_calls stay.
610
+ """
611
+ print("Setup: Message with tool_calls and orphaned tool_results...")
612
+ messages = [
613
+ UnifiedMessage(
614
+ role="assistant",
615
+ content="",
616
+ tool_calls=[{
617
+ "id": "call_new",
618
+ "type": "function",
619
+ "function": {"name": "new_tool", "arguments": "{}"}
620
+ }],
621
+ tool_results=[{ # This shouldn't happen but let's test it
622
+ "type": "tool_result",
623
+ "tool_use_id": "call_old",
624
+ "content": "Old result"
625
+ }]
626
+ )
627
+ ]
628
+
629
+ print("Action: Processing messages...")
630
+ result, stripped = ensure_assistant_before_tool_results(messages)
631
+
632
+ print(f"Result: {result}")
633
+ print("Checking that tool_calls are preserved...")
634
+ assert result[0].tool_calls is not None
635
+ assert len(result[0].tool_calls) == 1
636
+ assert result[0].tool_results is None
637
+ assert stripped is True
638
+
639
+ def test_handles_multiple_orphaned_tool_results(self):
640
+ """
641
+ What it does: Verifies multiple orphaned tool_results are all stripped.
642
+ Purpose: Ensure all tool_results in the list are removed.
643
+ """
644
+ print("Setup: Message with multiple orphaned tool_results...")
645
+ messages = [
646
+ UnifiedMessage(
647
+ role="user",
648
+ content="",
649
+ tool_results=[
650
+ {"type": "tool_result", "tool_use_id": "call_1", "content": "Result 1"},
651
+ {"type": "tool_result", "tool_use_id": "call_2", "content": "Result 2"},
652
+ {"type": "tool_result", "tool_use_id": "call_3", "content": "Result 3"}
653
+ ]
654
+ )
655
+ ]
656
+
657
+ print("Action: Processing messages...")
658
+ result, stripped = ensure_assistant_before_tool_results(messages)
659
+
660
+ print(f"Result: {result}")
661
+ print("Checking that all tool_results are stripped...")
662
+ assert result[0].tool_results is None
663
+ assert stripped is True
664
+
665
+ def test_mixed_valid_and_orphaned_tool_results(self):
666
+ """
667
+ What it does: Verifies correct handling of mixed valid and orphaned tool_results.
668
+ Purpose: Ensure valid tool_results are preserved while orphaned are stripped.
669
+ """
670
+ print("Setup: Mixed conversation with valid and orphaned tool_results...")
671
+ messages = [
672
+ # Orphaned tool_results at start
673
+ UnifiedMessage(
674
+ role="user",
675
+ content="",
676
+ tool_results=[{
677
+ "type": "tool_result",
678
+ "tool_use_id": "call_orphan",
679
+ "content": "Orphaned"
680
+ }]
681
+ ),
682
+ # Valid assistant with tool_calls
683
+ UnifiedMessage(
684
+ role="assistant",
685
+ content="",
686
+ tool_calls=[{
687
+ "id": "call_valid",
688
+ "type": "function",
689
+ "function": {"name": "valid_tool", "arguments": "{}"}
690
+ }]
691
+ ),
692
+ # Valid tool_results
693
+ UnifiedMessage(
694
+ role="user",
695
+ content="",
696
+ tool_results=[{
697
+ "type": "tool_result",
698
+ "tool_use_id": "call_valid",
699
+ "content": "Valid result"
700
+ }]
701
+ )
702
+ ]
703
+
704
+ print("Action: Processing messages...")
705
+ result, stripped = ensure_assistant_before_tool_results(messages)
706
+
707
+ print(f"Result: {result}")
708
+ print("Checking orphaned tool_results are stripped...")
709
+ assert result[0].tool_results is None
710
+
711
+ print("Checking valid tool_results are preserved...")
712
+ assert result[2].tool_results is not None
713
+ assert result[2].tool_results[0]["tool_use_id"] == "call_valid"
714
+ assert stripped is True # Because orphaned ones were stripped
715
+
716
+ def test_single_message_with_tool_results(self):
717
+ """
718
+ What it does: Verifies handling of single message with tool_results.
719
+ Purpose: Ensure single orphaned message is handled correctly.
720
+ """
721
+ print("Setup: Single message with tool_results...")
722
+ messages = [
723
+ UnifiedMessage(
724
+ role="user",
725
+ content="",
726
+ tool_results=[{
727
+ "type": "tool_result",
728
+ "tool_use_id": "call_123",
729
+ "content": "Result"
730
+ }]
731
+ )
732
+ ]
733
+
734
+ print("Action: Processing messages...")
735
+ result, stripped = ensure_assistant_before_tool_results(messages)
736
+
737
+ print(f"Result: {result}")
738
+ print("Checking that tool_results are stripped...")
739
+ assert len(result) == 1
740
+ assert result[0].tool_results is None
741
+ assert stripped is True
742
+
743
+
744
  # ==================================================================================================
745
  # Tests for sanitize_json_schema
746
  # ==================================================================================================
 
1034
  assert result[1]["toolUseId"] == "call_2"
1035
 
1036
 
1037
+ # ==================================================================================================
1038
+ # Tests for convert_tool_results_to_kiro_format
1039
+ # ==================================================================================================
1040
+
1041
+ class TestConvertToolResultsToKiroFormat:
1042
+ """
1043
+ Tests for convert_tool_results_to_kiro_format function.
1044
+
1045
+ This function converts unified tool results format (snake_case) to Kiro API format (camelCase).
1046
+
1047
+ Unified format: {"type": "tool_result", "tool_use_id": "...", "content": "..."}
1048
+ Kiro format: {"content": [{"text": "..."}], "status": "success", "toolUseId": "..."}
1049
+
1050
+ This is a critical function for fixing the 400 "Improperly formed request" bug
1051
+ where tool_results were sent in unified format instead of Kiro format.
1052
+ """
1053
+
1054
+ def test_converts_single_tool_result(self):
1055
+ """
1056
+ What it does: Verifies conversion of a single tool result.
1057
+ Purpose: Ensure basic conversion from unified to Kiro format works.
1058
+ """
1059
+ print("Setup: Single tool result in unified format...")
1060
+ tool_results = [
1061
+ {"type": "tool_result", "tool_use_id": "call_123", "content": "Result text"}
1062
+ ]
1063
+
1064
+ print("Action: Converting to Kiro format...")
1065
+ result = convert_tool_results_to_kiro_format(tool_results)
1066
+
1067
+ print(f"Result: {result}")
1068
+ print("Checking structure...")
1069
+ assert len(result) == 1
1070
+
1071
+ print("Checking toolUseId (camelCase)...")
1072
+ assert result[0]["toolUseId"] == "call_123"
1073
+
1074
+ print("Checking status...")
1075
+ assert result[0]["status"] == "success"
1076
+
1077
+ print("Checking content structure...")
1078
+ assert "content" in result[0]
1079
+ assert isinstance(result[0]["content"], list)
1080
+ assert len(result[0]["content"]) == 1
1081
+ assert result[0]["content"][0]["text"] == "Result text"
1082
+
1083
+ def test_converts_multiple_tool_results(self):
1084
+ """
1085
+ What it does: Verifies conversion of multiple tool results.
1086
+ Purpose: Ensure all tool results are converted correctly.
1087
+ """
1088
+ print("Setup: Multiple tool results...")
1089
+ tool_results = [
1090
+ {"type": "tool_result", "tool_use_id": "call_1", "content": "Result 1"},
1091
+ {"type": "tool_result", "tool_use_id": "call_2", "content": "Result 2"},
1092
+ {"type": "tool_result", "tool_use_id": "call_3", "content": "Result 3"}
1093
+ ]
1094
+
1095
+ print("Action: Converting to Kiro format...")
1096
+ result = convert_tool_results_to_kiro_format(tool_results)
1097
+
1098
+ print(f"Result: {result}")
1099
+ print(f"Comparing count: Expected 3, Got {len(result)}")
1100
+ assert len(result) == 3
1101
+
1102
+ print("Checking all toolUseIds...")
1103
+ assert result[0]["toolUseId"] == "call_1"
1104
+ assert result[1]["toolUseId"] == "call_2"
1105
+ assert result[2]["toolUseId"] == "call_3"
1106
+
1107
+ print("Checking all contents...")
1108
+ assert result[0]["content"][0]["text"] == "Result 1"
1109
+ assert result[1]["content"][0]["text"] == "Result 2"
1110
+ assert result[2]["content"][0]["text"] == "Result 3"
1111
+
1112
+ def test_returns_empty_list_for_empty_input(self):
1113
+ """
1114
+ What it does: Verifies empty list handling.
1115
+ Purpose: Ensure empty input returns empty output.
1116
+ """
1117
+ print("Setup: Empty list...")
1118
+
1119
+ print("Action: Converting to Kiro format...")
1120
+ result = convert_tool_results_to_kiro_format([])
1121
+
1122
+ print(f"Comparing result: Expected [], Got {result}")
1123
+ assert result == []
1124
+
1125
+ def test_replaces_empty_content_with_placeholder(self):
1126
+ """
1127
+ What it does: Verifies empty content is replaced with placeholder.
1128
+ Purpose: Ensure Kiro API receives non-empty content (required by API).
1129
+ """
1130
+ print("Setup: Tool result with empty content...")
1131
+ tool_results = [
1132
+ {"type": "tool_result", "tool_use_id": "call_123", "content": ""}
1133
+ ]
1134
+
1135
+ print("Action: Converting to Kiro format...")
1136
+ result = convert_tool_results_to_kiro_format(tool_results)
1137
+
1138
+ print(f"Result: {result}")
1139
+ print("Checking that empty content is replaced with placeholder...")
1140
+ assert result[0]["content"][0]["text"] == "(empty result)"
1141
+
1142
+ def test_replaces_none_content_with_placeholder(self):
1143
+ """
1144
+ What it does: Verifies None content is replaced with placeholder.
1145
+ Purpose: Ensure Kiro API receives non-empty content when content is None.
1146
+ """
1147
+ print("Setup: Tool result with None content...")
1148
+ tool_results = [
1149
+ {"type": "tool_result", "tool_use_id": "call_123", "content": None}
1150
+ ]
1151
+
1152
+ print("Action: Converting to Kiro format...")
1153
+ result = convert_tool_results_to_kiro_format(tool_results)
1154
+
1155
+ print(f"Result: {result}")
1156
+ print("Checking that None content is replaced with placeholder...")
1157
+ assert result[0]["content"][0]["text"] == "(empty result)"
1158
+
1159
+ def test_handles_missing_content_key(self):
1160
+ """
1161
+ What it does: Verifies handling of missing content key.
1162
+ Purpose: Ensure function doesn't crash when content key is missing.
1163
+ """
1164
+ print("Setup: Tool result without content key...")
1165
+ tool_results = [
1166
+ {"type": "tool_result", "tool_use_id": "call_123"}
1167
+ ]
1168
+
1169
+ print("Action: Converting to Kiro format...")
1170
+ result = convert_tool_results_to_kiro_format(tool_results)
1171
+
1172
+ print(f"Result: {result}")
1173
+ print("Checking that missing content is replaced with placeholder...")
1174
+ assert result[0]["content"][0]["text"] == "(empty result)"
1175
+
1176
+ def test_handles_missing_tool_use_id(self):
1177
+ """
1178
+ What it does: Verifies handling of missing tool_use_id.
1179
+ Purpose: Ensure function returns empty string for missing tool_use_id.
1180
+ """
1181
+ print("Setup: Tool result without tool_use_id...")
1182
+ tool_results = [
1183
+ {"type": "tool_result", "content": "Result text"}
1184
+ ]
1185
+
1186
+ print("Action: Converting to Kiro format...")
1187
+ result = convert_tool_results_to_kiro_format(tool_results)
1188
+
1189
+ print(f"Result: {result}")
1190
+ print("Checking that missing tool_use_id becomes empty string...")
1191
+ assert result[0]["toolUseId"] == ""
1192
+ assert result[0]["content"][0]["text"] == "Result text"
1193
+
1194
+ def test_extracts_text_from_list_content(self):
1195
+ """
1196
+ What it does: Verifies extraction of text from list content.
1197
+ Purpose: Ensure multimodal content format is handled correctly.
1198
+ """
1199
+ print("Setup: Tool result with list content...")
1200
+ tool_results = [
1201
+ {
1202
+ "type": "tool_result",
1203
+ "tool_use_id": "call_123",
1204
+ "content": [
1205
+ {"type": "text", "text": "Part 1"},
1206
+ {"type": "text", "text": " Part 2"}
1207
+ ]
1208
+ }
1209
+ ]
1210
+
1211
+ print("Action: Converting to Kiro format...")
1212
+ result = convert_tool_results_to_kiro_format(tool_results)
1213
+
1214
+ print(f"Result: {result}")
1215
+ print("Checking that list content is extracted correctly...")
1216
+ assert result[0]["content"][0]["text"] == "Part 1 Part 2"
1217
+
1218
+ def test_preserves_long_content(self):
1219
+ """
1220
+ What it does: Verifies long content is preserved.
1221
+ Purpose: Ensure large tool results are not truncated.
1222
+ """
1223
+ print("Setup: Tool result with long content...")
1224
+ long_content = "A" * 10000
1225
+ tool_results = [
1226
+ {"type": "tool_result", "tool_use_id": "call_123", "content": long_content}
1227
+ ]
1228
+
1229
+ print("Action: Converting to Kiro format...")
1230
+ result = convert_tool_results_to_kiro_format(tool_results)
1231
+
1232
+ print(f"Result content length: {len(result[0]['content'][0]['text'])}")
1233
+ print("Checking that long content is preserved...")
1234
+ assert result[0]["content"][0]["text"] == long_content
1235
+ assert len(result[0]["content"][0]["text"]) == 10000
1236
+
1237
+ def test_all_results_have_success_status(self):
1238
+ """
1239
+ What it does: Verifies all results have status="success".
1240
+ Purpose: Ensure Kiro API receives correct status field.
1241
+ """
1242
+ print("Setup: Multiple tool results...")
1243
+ tool_results = [
1244
+ {"type": "tool_result", "tool_use_id": "call_1", "content": "Result 1"},
1245
+ {"type": "tool_result", "tool_use_id": "call_2", "content": "Result 2"}
1246
+ ]
1247
+
1248
+ print("Action: Converting to Kiro format...")
1249
+ result = convert_tool_results_to_kiro_format(tool_results)
1250
+
1251
+ print("Checking all statuses...")
1252
+ for i, r in enumerate(result):
1253
+ print(f"Result {i}: status = {r['status']}")
1254
+ assert r["status"] == "success"
1255
+
1256
+ def test_handles_unicode_content(self):
1257
+ """
1258
+ What it does: Verifies Unicode content is preserved.
1259
+ Purpose: Ensure non-ASCII characters are handled correctly.
1260
+ """
1261
+ print("Setup: Tool result with Unicode content...")
1262
+ tool_results = [
1263
+ {"type": "tool_result", "tool_use_id": "call_123", "content": "Привет мир! 你好世界! 🎉"}
1264
+ ]
1265
+
1266
+ print("Action: Converting to Kiro format...")
1267
+ result = convert_tool_results_to_kiro_format(tool_results)
1268
+
1269
+ print(f"Result: {result}")
1270
+ print("Checking that Unicode content is preserved...")
1271
+ assert result[0]["content"][0]["text"] == "Привет мир! 你好世界! 🎉"
1272
+
1273
+
1274
  # ==================================================================================================
1275
  # Tests for extract_tool_uses_from_message
1276
  # ==================================================================================================
tests/unit/test_converters_openai.py CHANGED
@@ -463,10 +463,10 @@ class TestBuildKiroPayload:
463
  assert len(context["tools"]) == 1
464
  assert context["tools"][0]["toolSpecification"]["name"] == "get_weather"
465
 
466
- def test_skips_thinking_tags_when_tool_results_present(self):
467
  """
468
- What it does: Verifies thinking tags are NOT injected when toolResults are present.
469
- Purpose: Fix GitHub issue #20 - OpenCode compaction returns 400 error.
470
  """
471
  print("Setup: Request where last message is a tool result...")
472
  request = ChatCompletionRequest(
@@ -483,6 +483,17 @@ class TestBuildKiroPayload:
483
  }]
484
  ),
485
  ChatMessage(role="tool", content="Command output here", tool_call_id="tool_1"),
 
 
 
 
 
 
 
 
 
 
 
486
  ]
487
  )
488
 
@@ -499,8 +510,8 @@ class TestBuildKiroPayload:
499
  print(f"Has toolResults: {'toolResults' in context}")
500
 
501
  assert "toolResults" in context, "toolResults should be present"
502
- assert "<thinking_mode>" not in content, "thinking tags should NOT be injected when toolResults present"
503
- assert content == "Continue", "Content should be 'Continue' (no thinking tags)"
504
 
505
  def test_injects_thinking_tags_when_no_tool_results(self):
506
  """
@@ -846,7 +857,7 @@ class TestBuildKiroPayloadToolCallsIntegration:
846
  """
847
  What it does: Verifies full scenario with multiple assistant tool_calls and their results.
848
  Purpose: Ensure all toolUses and toolResults are correctly linked in Kiro payload.
849
-
850
  This is an integration test for a Codex CLI bug where multiple assistant
851
  messages with tool_calls were sent in a row, followed by tool results.
852
  """
@@ -878,6 +889,17 @@ class TestBuildKiroPayloadToolCallsIntegration:
878
  # Results of both tool_calls
879
  ChatMessage(role="tool", content="file1.txt\nfile2.txt", tool_call_id="tooluse_first"),
880
  ChatMessage(role="tool", content="/home/user", tool_call_id="tooluse_second")
 
 
 
 
 
 
 
 
 
 
 
881
  ]
882
  )
883
 
 
463
  assert len(context["tools"]) == 1
464
  assert context["tools"][0]["toolSpecification"]["name"] == "get_weather"
465
 
466
+ def test_injects_thinking_tags_even_when_tool_results_present(self):
467
  """
468
+ What it does: Verifies thinking tags ARE injected even when toolResults are present.
469
+ Purpose: Extended thinking should work in all scenarios including tool use flows.
470
  """
471
  print("Setup: Request where last message is a tool result...")
472
  request = ChatCompletionRequest(
 
483
  }]
484
  ),
485
  ChatMessage(role="tool", content="Command output here", tool_call_id="tool_1"),
486
+ ],
487
+ # Tools must be defined for tool_results to be preserved
488
+ tools=[
489
+ Tool(
490
+ type="function",
491
+ function=ToolFunction(
492
+ name="bash",
493
+ description="Run a bash command",
494
+ parameters={"type": "object", "properties": {}}
495
+ )
496
+ )
497
  ]
498
  )
499
 
 
510
  print(f"Has toolResults: {'toolResults' in context}")
511
 
512
  assert "toolResults" in context, "toolResults should be present"
513
+ assert "<thinking_mode>enabled</thinking_mode>" in content, "thinking tags SHOULD be injected even with toolResults"
514
+ assert "<max_thinking_length>4000</max_thinking_length>" in content, "max_thinking_length should be present"
515
 
516
  def test_injects_thinking_tags_when_no_tool_results(self):
517
  """
 
857
  """
858
  What it does: Verifies full scenario with multiple assistant tool_calls and their results.
859
  Purpose: Ensure all toolUses and toolResults are correctly linked in Kiro payload.
860
+
861
  This is an integration test for a Codex CLI bug where multiple assistant
862
  messages with tool_calls were sent in a row, followed by tool results.
863
  """
 
889
  # Results of both tool_calls
890
  ChatMessage(role="tool", content="file1.txt\nfile2.txt", tool_call_id="tooluse_first"),
891
  ChatMessage(role="tool", content="/home/user", tool_call_id="tooluse_second")
892
+ ],
893
+ # Tools must be defined for tool_results to be preserved
894
+ tools=[
895
+ Tool(
896
+ type="function",
897
+ function=ToolFunction(
898
+ name="shell",
899
+ description="Run a shell command",
900
+ parameters={"type": "object", "properties": {"command": {"type": "array"}}}
901
+ )
902
+ )
903
  ]
904
  )
905
 
tests/unit/test_routes_anthropic.py CHANGED
@@ -720,7 +720,12 @@ class TestMessagesOptionalParams:
720
  yield 'event: message_start\ndata: {"type":"message_start"}\n\n'
721
  yield 'event: message_stop\ndata: {"type":"message_stop"}\n\n'
722
 
723
- with patch('kiro.routes_anthropic.stream_with_first_token_retry_anthropic', mock_stream):
 
 
 
 
 
724
  response = test_client.post(
725
  "/v1/messages",
726
  headers={"x-api-key": valid_proxy_api_key},
 
720
  yield 'event: message_start\ndata: {"type":"message_start"}\n\n'
721
  yield 'event: message_stop\ndata: {"type":"message_stop"}\n\n'
722
 
723
+ # Create mock response for HTTP client
724
+ mock_response = MagicMock()
725
+ mock_response.status_code = 200
726
+
727
+ with patch('kiro.routes_anthropic.stream_kiro_to_anthropic', mock_stream), \
728
+ patch('kiro.http_client.KiroHttpClient.request_with_retry', return_value=mock_response):
729
  response = test_client.post(
730
  "/v1/messages",
731
  headers={"x-api-key": valid_proxy_api_key},