File size: 11,677 Bytes
f15fb1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
from __future__ import annotations

import asyncio
import base64
from concurrent.futures import ThreadPoolExecutor

from app_server_harness import AppServerHarness
from app_server_helpers import TINY_PNG_BYTES, streaming_response

from openai_codex import (
    AsyncCodex,
    Codex,
    ExternalMessage,
    ImageInput,
    LocalImageInput,
    SkillInput,
    TextInput,
)


def _external_items(request) -> list[dict]:
    """Select model-visible external content without generated item identifiers."""
    return [
        {key: value for key, value in item.items() if key != "id"}
        for item in request.input()
        if item.get("type") == "function_call_output"
    ]


def test_external_message_preserves_tool_authority_through_resume(tmp_path) -> None:
    content = "External update: deployment completed."
    expected = {
        "type": "function_call_output",
        "name": "notifications",
        "namespace": "slack",
        "output": content,
    }
    with AppServerHarness(tmp_path) as harness:
        harness.responses.enqueue_assistant_message("Update received", response_id="external")
        harness.responses.enqueue_assistant_message("Still available", response_id="resumed")
        with Codex(config=harness.app_server_config()) as codex:
            thread = codex.thread_start()
            result = thread.run(
                ExternalMessage(tool_name="notifications", namespace="slack", content=content)
            )
            external_item = next(
                item for item in result.items if item.root.type == "functionCallOutput"
            )
        with Codex(config=harness.app_server_config()) as codex:
            resumed = codex.thread_resume(thread.id, include_turns=False)
            history = resumed.read(include_turns=True)
            assert external_item in history.thread.turns[0].items
            resumed.run("Summarize the external update.")
        requests = harness.responses.requests()

    assert result.final_response == "Update received"
    assert [_external_items(request) for request in requests] == [[expected], [expected]]
    assert [
        text
        for request in requests
        for role in ("user", "developer")
        for text in request.message_input_texts(role)
        if content in text
    ] == []


def test_external_message_joins_active_turn_with_tool_authority(tmp_path) -> None:
    content = "External update while the agent is running."
    with AppServerHarness(tmp_path) as harness:
        harness.responses.enqueue_sse(
            streaming_response("external-first", "msg-first", ["Working"]),
            delay_between_events_s=0.2,
        )
        harness.responses.enqueue_assistant_message(
            "Update processed", response_id="external-second"
        )
        with ThreadPoolExecutor(max_workers=2) as consumers:
            with Codex(config=harness.app_server_config()) as codex:
                thread = codex.thread_start()
                original = thread.turn("Monitor deployment updates.")
                original_result = consumers.submit(original.run)
                harness.responses.wait_for_requests(1)
                joined = thread.turn(ExternalMessage(tool_name="notifications", content=content))
                result = consumers.submit(joined.run).result(timeout=15)
                first = original_result.result(timeout=15)
                assert first.final_response == result.final_response
                assert first.items[0].root.type == "userMessage"
                assert all(item.root.type != "userMessage" for item in result.items)
                assert codex._client._router._turn_states == {}
        requests = harness.responses.requests()

    assert result.usage is not None
    assert (joined.id, result.final_response) == (original.id, "Update processed")
    assert [_external_items(request) for request in requests] == [
        [],
        [
            {
                "type": "function_call_output",
                "name": "notifications",
                "output": content,
            }
        ],
    ]
    assert [request.message_input_texts("user")[-1] for request in requests] == [
        "Monitor deployment updates.",
        "Monitor deployment updates.",
    ]


def test_async_external_message_reaches_model_with_tool_authority(tmp_path) -> None:
    async def scenario() -> None:
        with AppServerHarness(tmp_path) as harness:
            harness.responses.enqueue_assistant_message(
                "Async update received", response_id="external-async"
            )
            async with AsyncCodex(config=harness.app_server_config()) as codex:
                thread = await codex.thread_start()
                result = await thread.run(
                    ExternalMessage(tool_name="notifications", content="External async update")
                )
            request = harness.responses.single_request()

        assert result.final_response == "Async update received"
        assert _external_items(request) == [
            {
                "type": "function_call_output",
                "name": "notifications",
                "output": "External async update",
            }
        ]
        assert "External async update" not in request.message_input_texts("user")

    asyncio.run(scenario())


def test_async_external_message_allows_both_handles_to_consume(tmp_path) -> None:
    async def scenario() -> None:
        with AppServerHarness(tmp_path) as harness:
            harness.responses.enqueue_sse(
                streaming_response("async-first", "msg-first", ["Working"]),
                delay_between_events_s=0.2,
            )
            harness.responses.enqueue_assistant_message(
                "Update processed", response_id="async-second"
            )
            async with AsyncCodex(config=harness.app_server_config()) as codex:
                thread = await codex.thread_start()
                original = await thread.turn("Monitor deployment updates.")
                original_result = asyncio.create_task(original.run())
                await asyncio.to_thread(harness.responses.wait_for_requests, 1)
                joined = await thread.turn(
                    ExternalMessage(tool_name="notifications", content="Update")
                )
                first, second = await asyncio.wait_for(
                    asyncio.gather(original_result, joined.run()), timeout=15
                )
                assert (first.final_response, first.usage) == (second.final_response, second.usage)
                assert first.final_response == "Update processed"
                assert first.usage is not None
                assert codex._client._sync._router._turn_states == {}

    asyncio.run(scenario())


def test_external_message_uses_core_tool_output_truncation(tmp_path) -> None:
    content = "External observation. " * 500
    with AppServerHarness(tmp_path) as harness:
        harness.responses.enqueue_assistant_message(
            "Context received", response_id="truncated-external"
        )
        with Codex(config=harness.app_server_config()) as codex:
            thread = codex.thread_start(config={"tool_output_token_limit": 32})
            thread.run(ExternalMessage(tool_name="notifications", content=content))
        request = harness.responses.single_request()

    [item] = _external_items(request)
    assert (item["name"], item["type"]) == ("notifications", "function_call_output")
    assert len(item["output"]) < len(content)
    assert "truncated" in item["output"].lower()


def test_data_url_image_input_reaches_responses_api(
    tmp_path,
) -> None:
    """Data URL image inputs should survive the SDK and app-server boundary."""
    image_data_url = "data:image/png;base64," + base64.b64encode(TINY_PNG_BYTES).decode("ascii")

    with AppServerHarness(tmp_path) as harness:
        harness.responses.enqueue_assistant_message(
            "data URL image received",
            response_id="data-url-image",
        )

        with Codex(config=harness.app_server_config()) as codex:
            result = codex.thread_start().run(
                [
                    TextInput("Describe the data URL image."),
                    ImageInput(image_data_url),
                ]
            )
            request = harness.responses.single_request()

    assert {
        "final_response": result.final_response,
        "contains_user_prompt": "Describe the data URL image."
        in request.message_input_texts("user"),
        "image_url_is_png_data_url": request.message_image_urls("user")[-1].startswith(
            "data:image/png;base64,"
        ),
    } == {
        "final_response": "data URL image received",
        "contains_user_prompt": True,
        "image_url_is_png_data_url": True,
    }


def test_local_image_input_reaches_responses_api(
    tmp_path,
) -> None:
    """Local image inputs should become data URLs after crossing the app-server."""
    local_image = tmp_path / "local.png"
    local_image.write_bytes(TINY_PNG_BYTES)

    with AppServerHarness(tmp_path) as harness:
        harness.responses.enqueue_assistant_message(
            "local image received",
            response_id="local-image",
        )

        with Codex(config=harness.app_server_config()) as codex:
            result = codex.thread_start().run(
                [
                    TextInput("Describe the local image."),
                    LocalImageInput(str(local_image)),
                ]
            )
            request = harness.responses.single_request()

    assert {
        "final_response": result.final_response,
        "contains_user_prompt": "Describe the local image." in request.message_input_texts("user"),
        "image_url_is_png_data_url": request.message_image_urls("user")[-1].startswith(
            "data:image/png;base64,"
        ),
    } == {
        "final_response": "local image received",
        "contains_user_prompt": True,
        "image_url_is_png_data_url": True,
    }


def test_skill_input_injects_loaded_skill_body(tmp_path) -> None:
    """SkillInput should inject the selected loaded skill into model input."""
    skill_body = "Use the word cobalt."

    with AppServerHarness(tmp_path) as harness:
        skill_file = harness.workspace / ".agents" / "skills" / "demo" / "SKILL.md"
        skill_file.parent.mkdir(parents=True)
        skill_file.write_text(f"---\nname: demo\ndescription: demo skill\n---\n\n{skill_body}\n")
        skill_path = skill_file.resolve()
        harness.responses.enqueue_assistant_message(
            "skill received",
            response_id="skill-input",
        )

        with Codex(config=harness.app_server_config()) as codex:
            result = codex.thread_start().run(
                [
                    TextInput("Use the selected skill."),
                    SkillInput("demo", str(skill_path)),
                ]
            )
            request = harness.responses.single_request()

    skill_blocks = [
        text for text in request.message_input_texts("user") if text.startswith("<skill>")
    ]
    assert {
        "final_response": result.final_response,
        "skill_blocks": [
            {
                "has_name": "<name>demo</name>" in text,
                "has_path": f"<path>{skill_path}</path>" in text,
                "has_body": skill_body in text,
            }
            for text in skill_blocks
        ],
    } == {
        "final_response": "skill received",
        "skill_blocks": [
            {
                "has_name": True,
                "has_path": True,
                "has_body": True,
            }
        ],
    }