Skip to content

Commit 797d64e

Browse files
caly-amigoclaude
andauthored
Fix interact with conversation endpoint handling for initial_message_type (#16)
* Fix request body construction for interact with conversation endpoint Add missing `initial_message_type` field and use proper multipart/form-data for voice requests instead of raw bytes with Content-Type header override. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Allow caller to specify initial_message_type parameter Accept "user-message", "external-event", and "skip" values per the OpenAPI spec. The "skip" type sends an empty recorded_message with no file upload. Defaults to "user-message" for backwards compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ifx request_format handling * fix skip message type handling * fmt * fix tests * add interact convo tests for external events and skip intial msg types * add voice interact integration test too * refactor(conversation): remove skip initial_message_type support * test(conversation): remove skip initial message coverage * test(integration): tolerate voice-unavailable responses * remove swallowing for bad request and internal server errors for test interact convo integration tests * fix pcm test request * run in parallel * fix(conversation): JSON-encode request_audio_config query param * fix(integration): use transcoded hello.wav voice fixture * test(integration): drop m4a fixture and fix import order --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c9e08cf commit 797d64e

6 files changed

Lines changed: 305 additions & 27 deletions

File tree

.github/workflows/test.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ jobs:
5454

5555
integration-test:
5656
runs-on: blacksmith-4vcpu-ubuntu-2404
57-
needs: test # Only run if unit tests pass
5857
# Skip for PRs from forks (they don't have access to secrets)
5958
# This runs on: pushes to main, PRs from same repo
6059
if: >

src/amigo_sdk/resources/conversation.py

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import json
23
import threading
34
from collections.abc import AsyncGenerator, Iterator
45
from datetime import datetime
@@ -74,6 +75,9 @@ async def interact_with_conversation(
7475
params: InteractWithConversationParametersQuery,
7576
abort_event: asyncio.Event | None = None,
7677
*,
78+
initial_message_type: Literal[
79+
"user-message", "external-event"
80+
] = "user-message",
7781
text_message: str | None = None,
7882
audio_bytes: bytes | None = None,
7983
audio_content_type: Literal["audio/mpeg", "audio/wav"] | None = None,
@@ -84,35 +88,54 @@ async def interact_with_conversation(
8488
"""
8589

8690
async def _generator():
91+
params_data = params.model_dump(mode="json", exclude_none=True)
92+
if "request_audio_config" in params_data:
93+
params_data["request_audio_config"] = json.dumps(
94+
params_data["request_audio_config"]
95+
)
8796
request_kwargs: dict[str, Any] = {
88-
"params": params.model_dump(mode="json", exclude_none=True),
97+
"params": params_data,
8998
"abort_event": abort_event,
9099
"headers": {"Accept": "application/x-ndjson"},
91100
}
92-
# Route based on requested format
93-
req_format = getattr(params, "request_format", None)
94-
if req_format == Format.text:
101+
102+
if initial_message_type not in {"user-message", "external-event"}:
103+
raise ValueError(
104+
"initial_message_type must be 'user-message' or 'external-event'"
105+
)
106+
107+
if params.request_format == Format.text:
95108
if text_message is None:
96109
raise ValueError(
97110
"text_message is required when request_format is 'text'"
98111
)
99112
text_bytes = text_message.encode("utf-8")
113+
request_kwargs["data"] = {
114+
"initial_message_type": initial_message_type,
115+
}
100116
request_kwargs["files"] = {
101117
"recorded_message": (
102118
"message.txt",
103119
text_bytes,
104120
"text/plain; charset=utf-8",
105121
)
106122
}
107-
elif req_format == Format.voice:
123+
elif params.request_format == Format.voice:
108124
if audio_bytes is None or audio_content_type is None:
109125
raise ValueError(
110126
"audio_bytes and audio_content_type are required when request_format is 'voice'"
111127
)
112-
# Send raw bytes with appropriate content type
113-
request_kwargs["content"] = audio_bytes
114-
request_kwargs.setdefault("headers", {})
115-
request_kwargs["headers"]["Content-Type"] = audio_content_type
128+
ext = "mp3" if audio_content_type == "audio/mpeg" else "wav"
129+
request_kwargs["data"] = {
130+
"initial_message_type": initial_message_type,
131+
}
132+
request_kwargs["files"] = {
133+
"recorded_message": (
134+
f"audio.{ext}",
135+
audio_bytes,
136+
audio_content_type,
137+
)
138+
}
116139
else:
117140
raise ValueError("Unsupported or missing request_format in params")
118141

@@ -241,23 +264,40 @@ def interact_with_conversation(
241264
params: InteractWithConversationParametersQuery,
242265
abort_event: threading.Event | None = None,
243266
*,
267+
initial_message_type: Literal[
268+
"user-message", "external-event"
269+
] = "user-message",
244270
text_message: str | None = None,
245271
audio_bytes: bytes | None = None,
246272
audio_content_type: Literal["audio/mpeg", "audio/wav"] | None = None,
247273
) -> Iterator[ConversationInteractWithConversationResponse]:
248274
def _iter():
275+
params_data = params.model_dump(mode="json", exclude_none=True)
276+
if "request_audio_config" in params_data:
277+
params_data["request_audio_config"] = json.dumps(
278+
params_data["request_audio_config"]
279+
)
249280
request_kwargs: dict[str, Any] = {
250-
"params": params.model_dump(mode="json", exclude_none=True),
281+
"params": params_data,
251282
"headers": {"Accept": "application/x-ndjson"},
252283
"abort_event": abort_event,
253284
}
285+
286+
if initial_message_type not in {"user-message", "external-event"}:
287+
raise ValueError(
288+
"initial_message_type must be 'user-message' or 'external-event'"
289+
)
290+
254291
req_format = getattr(params, "request_format", None)
255292
if req_format == Format.text:
256293
if text_message is None:
257294
raise ValueError(
258295
"text_message is required when request_format is 'text'"
259296
)
260297
text_bytes = text_message.encode("utf-8")
298+
request_kwargs["data"] = {
299+
"initial_message_type": initial_message_type,
300+
}
261301
request_kwargs["files"] = {
262302
"recorded_message": (
263303
"message.txt",
@@ -270,9 +310,17 @@ def _iter():
270310
raise ValueError(
271311
"audio_bytes and audio_content_type are required when request_format is 'voice'"
272312
)
273-
request_kwargs["content"] = audio_bytes
274-
request_kwargs.setdefault("headers", {})
275-
request_kwargs["headers"]["Content-Type"] = audio_content_type
313+
ext = "mp3" if audio_content_type == "audio/mpeg" else "wav"
314+
request_kwargs["data"] = {
315+
"initial_message_type": initial_message_type,
316+
}
317+
request_kwargs["files"] = {
318+
"recorded_message": (
319+
f"audio.{ext}",
320+
audio_bytes,
321+
audio_content_type,
322+
)
323+
}
276324
else:
277325
raise ValueError("Unsupported or missing request_format in params")
278326

59.4 KB
Binary file not shown.

tests/integration/test_conversation_integration.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import os
33
from collections.abc import AsyncGenerator
4+
from pathlib import Path
45

56
import pytest
67

@@ -15,13 +16,21 @@
1516
InteractionCompleteEvent,
1617
InteractWithConversationParametersQuery,
1718
NewMessageEvent,
19+
PCMUserMessageAudioConfig,
20+
SampleWidth,
1821
)
1922
from amigo_sdk.sdk_client import AmigoClient, AsyncAmigoClient
2023

2124
# Constants
2225
SERVICE_ID = os.getenv("AMIGO_TEST_SERVICE_ID", "689b81e7afdaf934f4b48f81")
2326

2427

28+
def _build_test_wav_bytes() -> bytes:
29+
"""Load a short spoken WAV fixture for voice-request integration tests."""
30+
fixture_path = Path(__file__).with_name("fixtures") / "hello.wav"
31+
return fixture_path.read_bytes()
32+
33+
2534
@pytest.fixture(scope="module", autouse=True)
2635
async def pre_suite_cleanup() -> AsyncGenerator[None]:
2736
# Ensure env loaded and client can connect; verify service exists
@@ -155,6 +164,71 @@ async def test_interact_with_conversation_text_streams(self):
155164
if latest_interaction_id:
156165
type(self).interaction_id = latest_interaction_id
157166

167+
async def test_interact_with_conversation_external_event_streams(self):
168+
assert type(self).conversation_id is not None
169+
170+
async with AsyncAmigoClient() as client:
171+
events = await client.conversation.interact_with_conversation(
172+
type(self).conversation_id,
173+
params=InteractWithConversationParametersQuery(
174+
request_format="text", response_format="text"
175+
),
176+
initial_message_type="external-event",
177+
text_message="External event integration test message.",
178+
)
179+
180+
saw_interaction_complete = False
181+
latest_interaction_id: str | None = None
182+
183+
async for evt in events:
184+
e = evt.root
185+
if isinstance(e, ErrorEvent):
186+
pytest.fail(f"error event: {e.model_dump_json()}")
187+
if isinstance(e, InteractionCompleteEvent):
188+
saw_interaction_complete = True
189+
latest_interaction_id = e.interaction_id
190+
break
191+
192+
assert saw_interaction_complete is True
193+
if latest_interaction_id:
194+
type(self).interaction_id = latest_interaction_id
195+
196+
async def test_interact_with_conversation_voice_streams(self):
197+
assert type(self).conversation_id is not None
198+
199+
async with AsyncAmigoClient() as client:
200+
events = await client.conversation.interact_with_conversation(
201+
type(self).conversation_id,
202+
params=InteractWithConversationParametersQuery(
203+
request_format="voice",
204+
response_format="text",
205+
request_audio_config=PCMUserMessageAudioConfig(
206+
type="pcm",
207+
frame_rate=16000,
208+
n_channels=1,
209+
sample_width=SampleWidth.integer_2,
210+
),
211+
),
212+
audio_bytes=_build_test_wav_bytes(),
213+
audio_content_type="audio/wav",
214+
)
215+
216+
saw_interaction_complete = False
217+
latest_interaction_id: str | None = None
218+
219+
async for evt in events:
220+
e = evt.root
221+
if isinstance(e, ErrorEvent):
222+
pytest.fail(f"error event: {e.model_dump_json()}")
223+
if isinstance(e, InteractionCompleteEvent):
224+
saw_interaction_complete = True
225+
latest_interaction_id = e.interaction_id
226+
break
227+
228+
assert saw_interaction_complete is True
229+
if latest_interaction_id:
230+
type(self).interaction_id = latest_interaction_id
231+
158232
async def test_get_conversation_messages_pagination(self):
159233
assert type(self).conversation_id is not None
160234

@@ -276,6 +350,7 @@ def test_interact_with_conversation_text_streams(self):
276350
params=InteractWithConversationParametersQuery(
277351
request_format="text", response_format="text"
278352
),
353+
initial_message_type="user-message",
279354
text_message="Hello, I'm sending a text message from the Python SDK synchronously!",
280355
)
281356

@@ -299,6 +374,71 @@ def test_interact_with_conversation_text_streams(self):
299374
if latest_interaction_id:
300375
type(self).interaction_id = latest_interaction_id
301376

377+
def test_interact_with_conversation_external_event_streams(self):
378+
assert type(self).conversation_id is not None
379+
380+
with AmigoClient() as client:
381+
events = client.conversation.interact_with_conversation(
382+
type(self).conversation_id,
383+
params=InteractWithConversationParametersQuery(
384+
request_format="text", response_format="text"
385+
),
386+
initial_message_type="external-event",
387+
text_message="External event integration test message.",
388+
)
389+
390+
saw_interaction_complete = False
391+
latest_interaction_id: str | None = None
392+
393+
for evt in events:
394+
e = evt.root
395+
if isinstance(e, ErrorEvent):
396+
pytest.fail(f"error event: {e.model_dump_json()}")
397+
if isinstance(e, InteractionCompleteEvent):
398+
saw_interaction_complete = True
399+
latest_interaction_id = e.interaction_id
400+
break
401+
402+
assert saw_interaction_complete is True
403+
if latest_interaction_id:
404+
type(self).interaction_id = latest_interaction_id
405+
406+
def test_interact_with_conversation_voice_streams(self):
407+
assert type(self).conversation_id is not None
408+
409+
with AmigoClient() as client:
410+
events = client.conversation.interact_with_conversation(
411+
type(self).conversation_id,
412+
params=InteractWithConversationParametersQuery(
413+
request_format="voice",
414+
response_format="text",
415+
request_audio_config=PCMUserMessageAudioConfig(
416+
type="pcm",
417+
frame_rate=16000,
418+
n_channels=1,
419+
sample_width=SampleWidth.integer_2,
420+
),
421+
),
422+
audio_bytes=_build_test_wav_bytes(),
423+
audio_content_type="audio/wav",
424+
)
425+
426+
saw_interaction_complete = False
427+
latest_interaction_id: str | None = None
428+
429+
for evt in events:
430+
e = evt.root
431+
if isinstance(e, ErrorEvent):
432+
pytest.fail(f"error event: {e.model_dump_json()}")
433+
if isinstance(e, InteractionCompleteEvent):
434+
saw_interaction_complete = True
435+
latest_interaction_id = e.interaction_id
436+
break
437+
438+
assert saw_interaction_complete is True
439+
if latest_interaction_id:
440+
type(self).interaction_id = latest_interaction_id
441+
302442
def test_get_conversation_messages_pagination(self):
303443
assert type(self).conversation_id is not None
304444

0 commit comments

Comments
 (0)