Commit Diff


commit - 00df5eedf8e92b78e7db6d28f85cce5988525b62
commit + 013bbef42945efe028e39007e2c4b85909c42092
blob - b5023a2431dac3c9ef2c8c84710e5f84baf8e910
blob + e7bd92cb920dd68546ea6a1d8deac22b2c3532b0
--- docs/adr/0010-experimental-codex-subscription-compatibility.md
+++ docs/adr/0010-experimental-codex-subscription-compatibility.md
@@ -96,6 +96,15 @@ fixtures, all hermetic and hardened gates, and an expl
 check. HTTP SSE is the only v1 transport; a WebSocket requirement is a new
 ADR/ticket, not an adaptive fallback.
 
+The 2026-07-21 compatibility correction keeps the original profile identity:
+the pinned Codex source already modeled optional `commentary` / `final_answer`
+message phases and treated `response.completed` as terminal metadata rather
+than a required second copy of output items. Fugu now accepts those exact
+phases, array-shaped output-text `annotations` / `logprobs`, and an empty
+terminal output array only after all streamed items are complete. These are
+decoder corrections within the pinned event vocabulary, not a new request,
+route, model, or transport profile.
+
 The opt-in live gate never runs under `make check` and never asks a developer
 or agent to copy a credential. It proves one text response, one harmless
 `read` Tool continuation, continuity capture, honest identity, and absence of
blob - c22dd7c09bb3884d673c9a8d485196b39b36e143
blob + ca2572930a445a2b62273b0b075053e007fd8864
--- docs/design/codex-subscription.md
+++ docs/design/codex-subscription.md
@@ -343,8 +343,10 @@ The hostile path is explicit:
    `encrypted_content`; a call record requires bounded, distinct `call_id` and
    provider item id. It normally takes `response.output_item.done`. If
    `response.completed.output` repeats an item, identity and content must agree;
-   if terminal output is the only final copy, it supplies the item. Duplicate
-   or conflicting copies fail.
+   if terminal output is the only final copy, it supplies the item. A terminal
+   empty output array is accepted only after every contiguous streamed item is
+   already complete. Duplicate, unfinished, missing, or conflicting copies
+   fail.
 2. It emits `ASTREAM_PROVIDER_STATE` records to `fugu-api`. The Custodian
    validates each complete record, applies credential-reflection filtering,
    and builds one deterministic continuity object under a 2 MiB
@@ -391,6 +393,13 @@ the seam.
 The decoder must:
 
 - accept output-text deltas with explicit lengths;
+- accept absent or exact `commentary` / `final_answer` message phases while
+  preserving Fugu's existing canonical text ordering;
+- accept only array-shaped optional `annotations` and `logprobs` metadata on
+  output-text parts; neither metadata field crosses the canonical Provider
+  seam;
+- require a text delta's optional bounded `item_id`, when present, to identify
+  the same message as its `output_index`;
 - correlate function calls by bounded output index and call identifier;
 - buffer fragmented function names and argument JSON under the existing
   per-call and aggregate Tool caps;
blob - 96efc6a322c04ca4a0d98b6178ca4dba1959c005
blob + b6688a8e29c683b247344bf718b995c3912bd5cb
--- regress/codex_subscription/codex_subscription_test.c
+++ regress/codex_subscription/codex_subscription_test.c
@@ -582,7 +582,8 @@ static const char complete_stream[] =
     "event: response.output_item.added\n"
     "data: {\"type\":\"response.output_item.added\",\"output_index\":1,"
     "\"item\":{\"type\":\"message\",\"id\":\"msg_1\",\"role\":"
-    "\"assistant\",\"content\":[],\"status\":\"in_progress\"}}\n\n"
+    "\"assistant\",\"content\":[],\"status\":\"in_progress\",\"phase\":"
+    "\"commentary\"}}\n\n"
     "event: response.output_text.delta\n"
     "data: {\"type\":\"response.output_text.delta\",\"output_index\":1,"
     "\"content_index\":0,\"delta\":\"hello\"}\n\n"
@@ -590,7 +591,7 @@ static const char complete_stream[] =
     "data: {\"type\":\"response.output_item.done\",\"output_index\":1,"
     "\"item\":{\"type\":\"message\",\"id\":\"msg_1\",\"role\":"
     "\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":"
-    "\"hello\"}],\"status\":\"completed\"}}\n\n"
+    "\"hello\"}],\"status\":\"completed\",\"phase\":\"commentary\"}}\n\n"
     "event: response.output_item.added\n"
     "data: {\"type\":\"response.output_item.added\",\"output_index\":2,"
     "\"item\":{\"type\":\"function_call\",\"id\":\"fc_1\",\"name\":"
@@ -617,7 +618,8 @@ static const char complete_stream[] =
     "\"rs_1\",\"summary\":[],\"encrypted_content\":\"cipher\",\"status\":"
     "\"completed\"},{\"type\":\"message\",\"id\":\"msg_1\",\"role\":"
     "\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":"
-    "\"hello\"}],\"status\":\"completed\"},{\"type\":\"function_call\","
+    "\"hello\"}],\"status\":\"completed\",\"phase\":\"commentary\"},{"
+    "\"type\":\"function_call\","
     "\"id\":\"fc_1\",\"name\":\"read\",\"arguments\":"
     "\"{\\\"path\\\":\\\"a\\\"}\",\"call_id\":\"call_1\",\"status\":"
     "\"completed\"}],\"usage\":{\"input_tokens\":100,"
@@ -706,6 +708,71 @@ test_complete_stream_and_every_split(void)
 	capture_free(&capture);
 }
 
+/* Sanitized ChatGPT Codex SSE shape observed on 2026-07-21.  The terminal
+ * response carries usage but leaves output empty; output_item.done is the
+ * authoritative completed item. */
+static const char live_message_stream[] =
+    "event: response.created\n"
+    "data: {\"type\":\"response.created\",\"sequence_number\":0,"
+    "\"response\":{\"id\":\"resp_live\"}}\n\n"
+    "event: response.in_progress\n"
+    "data: {\"type\":\"response.in_progress\",\"sequence_number\":1,"
+    "\"response\":{\"id\":\"resp_live\",\"status\":\"in_progress\"}}\n\n"
+    "event: response.output_item.added\n"
+    "data: {\"type\":\"response.output_item.added\",\"output_index\":0,"
+    "\"sequence_number\":2,\"item\":{\"type\":\"message\",\"id\":"
+    "\"msg_live\",\"role\":\"assistant\",\"content\":[],\"status\":"
+    "\"in_progress\",\"phase\":\"final_answer\"}}\n\n"
+    "event: response.content_part.added\n"
+    "data: {\"type\":\"response.content_part.added\",\"output_index\":0,"
+    "\"item_id\":\"msg_live\",\"content_index\":0,\"sequence_number\":3,"
+    "\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":"
+    "[],\"text\":\"\"}}\n\n"
+    "event: response.output_text.delta\n"
+    "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,"
+    "\"item_id\":\"msg_live\",\"content_index\":0,\"sequence_number\":4,"
+    "\"delta\":\"hello\",\"logprobs\":[],\"obfuscation\":\"noise\"}\n\n"
+    "event: response.output_text.done\n"
+    "data: {\"type\":\"response.output_text.done\",\"output_index\":0,"
+    "\"item_id\":\"msg_live\",\"content_index\":0,\"sequence_number\":5,"
+    "\"text\":\"hello\",\"logprobs\":[]}\n\n"
+    "event: response.content_part.done\n"
+    "data: {\"type\":\"response.content_part.done\",\"output_index\":0,"
+    "\"item_id\":\"msg_live\",\"content_index\":0,\"sequence_number\":6,"
+    "\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":"
+    "[],\"text\":\"hello\"}}\n\n"
+    "event: response.output_item.done\n"
+    "data: {\"type\":\"response.output_item.done\",\"output_index\":0,"
+    "\"sequence_number\":7,\"item\":{\"type\":\"message\",\"id\":"
+    "\"msg_live\",\"role\":\"assistant\",\"content\":[{\"type\":"
+    "\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":"
+    "\"hello\"}],\"status\":\"completed\",\"phase\":"
+    "\"final_answer\"}}\n\n"
+    "event: response.completed\n"
+    "data: {\"type\":\"response.completed\",\"sequence_number\":8,"
+    "\"response\":{\"id\":\"resp_live\",\"status\":\"completed\","
+    "\"output\":[],\"usage\":{\"input_tokens\":12,\"output_tokens\":3,"
+    "\"total_tokens\":15}}}\n\n";
+
+static void
+test_live_message_stream_shape(void)
+{
+	struct codex_sub_stream *stream;
+	struct capture capture;
+
+	capture_init(&capture);
+	stream = codex_sub_stream_new("gpt-5.5", capture_event, &capture);
+	CHECK(stream != NULL && codex_sub_stream_feed(stream,
+	    live_message_stream, sizeof(live_message_stream) - 1) == 0 &&
+	    codex_sub_stream_error(stream) == NULL && capture.text.len == 5 &&
+	    memcmp(capture.text.data, "hello", 5) == 0 && capture.nusage == 1 &&
+	    capture.input_tokens == 12 && capture.output_tokens == 3 &&
+	    capture.nstate == 0 && capture.ndone == 1 && capture.nerror == 0 &&
+	    strcmp(capture.stop, "stop") == 0);
+	codex_sub_stream_free(stream);
+	capture_free(&capture);
+}
+
 static const char terminal_text_stream[] =
     "event: response.created\n"
     "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_2\"}}\n\n"
@@ -748,13 +815,7 @@ static const char call_only_stream[] =
     "\"call_id\":\"call_codex_1\",\"status\":\"completed\"}}\n\n"
     "event: response.completed\n"
     "data: {\"type\":\"response.completed\",\"response\":{\"id\":"
-    "\"resp_codex_1\",\"status\":\"completed\",\"output\":[{\"type\":"
-    "\"reasoning\",\"id\":\"rs_codex_1\",\"summary\":[],"
-    "\"encrypted_content\":\"cipher_1\",\"status\":\"completed\"},{"
-    "\"type\":\"function_call\",\"id\":\"fc_codex_1\",\"name\":"
-    "\"shell\",\"arguments\":"
-    "\"{\\\"command\\\":\\\"printf CODEX-TOOL-RESULT\\\"}\","
-    "\"call_id\":\"call_codex_1\",\"status\":\"completed\"}],\"usage\":{"
+    "\"resp_codex_1\",\"status\":\"completed\",\"output\":[],\"usage\":{"
     "\"input_tokens\":100,\"input_tokens_details\":{\"cached_tokens\":40,"
     "\"cache_write_tokens\":5},\"output_tokens\":10,"
     "\"total_tokens\":110}}}\n\n";
@@ -805,6 +866,25 @@ decode_rejected(const char *fixture)
 	return (decode_bytes_rejected(fixture, strlen(fixture)));
 }
 
+static int
+decode_error_is(const char *fixture, const char *want)
+{
+	struct codex_sub_stream *stream;
+	struct capture capture;
+	const char *error;
+	int matched;
+
+	capture_init(&capture);
+	stream = codex_sub_stream_new("gpt-5.5", capture_event, &capture);
+	matched = stream != NULL && codex_sub_stream_feed(stream, fixture,
+	    strlen(fixture)) == -1 &&
+	    (error = codex_sub_stream_error(stream)) != NULL &&
+	    strcmp(error, want) == 0 && capture.ndone == 0;
+	codex_sub_stream_free(stream);
+	capture_free(&capture);
+	return (matched);
+}
+
 static void
 test_malformed_event_bytes(void)
 {
@@ -1152,6 +1232,103 @@ test_sequence_conflicts_and_index_bounds(void)
 	    "data: {\"type\":\"response.incomplete\",\"response\":{\"id\":"
 	    "\"resp_x\",\"status\":\"completed\",\"incomplete_details\":{"
 	    "\"reason\":\"unknown\"}}}\n\n";
+	static const char unknown_phase[] =
+	    "event: response.created\n"
+	    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+	    "\"resp_phase\"}}\n\n"
+	    "event: response.output_item.added\n"
+	    "data: {\"type\":\"response.output_item.added\",\"output_index\":0,"
+	    "\"item\":{\"type\":\"message\",\"id\":\"msg_phase\",\"role\":"
+	    "\"assistant\",\"content\":[],\"status\":\"in_progress\",\"phase\":"
+	    "\"analysis\"}}\n\n";
+	static const char null_phase[] =
+	    "event: response.created\n"
+	    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+	    "\"resp_phase\"}}\n\n"
+	    "event: response.output_item.added\n"
+	    "data: {\"type\":\"response.output_item.added\",\"output_index\":0,"
+	    "\"item\":{\"type\":\"message\",\"id\":\"msg_phase\",\"role\":"
+	    "\"assistant\",\"content\":[],\"status\":\"in_progress\",\"phase\":"
+	    "null}}\n\n";
+	static const char conflicting_phase[] =
+	    "event: response.created\n"
+	    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+	    "\"resp_phase\"}}\n\n"
+	    "event: response.output_item.added\n"
+	    "data: {\"type\":\"response.output_item.added\",\"output_index\":0,"
+	    "\"item\":{\"type\":\"message\",\"id\":\"msg_phase\",\"role\":"
+	    "\"assistant\",\"content\":[],\"status\":\"in_progress\",\"phase\":"
+	    "\"commentary\"}}\n\n"
+	    "event: response.output_item.done\n"
+	    "data: {\"type\":\"response.output_item.done\",\"output_index\":0,"
+	    "\"item\":{\"type\":\"message\",\"id\":\"msg_phase\",\"role\":"
+	    "\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":"
+	    "\"x\"}],\"status\":\"completed\",\"phase\":\"final_answer\"}}\n\n";
+	static const char malformed_output_metadata[] =
+	    "event: response.created\n"
+	    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+	    "\"resp_meta\"}}\n\n"
+	    "event: response.output_item.added\n"
+	    "data: {\"type\":\"response.output_item.added\",\"output_index\":0,"
+	    "\"item\":{\"type\":\"message\",\"id\":\"msg_meta\",\"role\":"
+	    "\"assistant\",\"content\":[],\"status\":\"in_progress\"}}\n\n"
+	    "event: response.content_part.added\n"
+	    "data: {\"type\":\"response.content_part.added\",\"output_index\":0,"
+	    "\"item_id\":\"msg_meta\",\"content_index\":0,\"part\":{\"type\":"
+	    "\"output_text\",\"text\":\"\",\"annotations\":{},\"logprobs\":[]}}"
+	    "\n\n";
+	static const char unfinished_empty_terminal[] =
+	    "event: response.created\n"
+	    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+	    "\"resp_empty\"}}\n\n"
+	    "event: response.output_item.added\n"
+	    "data: {\"type\":\"response.output_item.added\",\"output_index\":0,"
+	    "\"item\":{\"type\":\"message\",\"id\":\"msg_empty\",\"role\":"
+	    "\"assistant\",\"content\":[],\"status\":\"in_progress\"}}\n\n"
+	    "event: response.completed\n"
+	    "data: {\"type\":\"response.completed\",\"response\":{\"id\":"
+	    "\"resp_empty\",\"status\":\"completed\",\"output\":[],\"usage\":"
+	    "null}}\n\n";
+	static const char control_event_before_created[] =
+	    "event: response.\033[31m\n"
+	    "data: {\"type\":\"response.\\u001b[31m\"}\n\n";
+	static const char mismatched_text_item_id[] =
+	    "event: response.created\n"
+	    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+	    "\"resp_text_id\"}}\n\n"
+	    "event: response.output_item.added\n"
+	    "data: {\"type\":\"response.output_item.added\",\"output_index\":0,"
+	    "\"item\":{\"type\":\"message\",\"id\":\"msg_text_a\",\"role\":"
+	    "\"assistant\",\"content\":[],\"status\":\"in_progress\"}}\n\n"
+	    "event: response.output_text.delta\n"
+	    "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,"
+	    "\"item_id\":\"msg_text_b\",\"content_index\":0,\"delta\":\"x\"}"
+	    "\n\n";
+	static const char duplicate_text_item_id[] =
+	    "event: response.created\n"
+	    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+	    "\"resp_text_id\"}}\n\n"
+	    "event: response.output_item.added\n"
+	    "data: {\"type\":\"response.output_item.added\",\"output_index\":0,"
+	    "\"item\":{\"type\":\"message\",\"id\":\"msg_text_a\",\"role\":"
+	    "\"assistant\",\"content\":[],\"status\":\"in_progress\"}}\n\n"
+	    "event: response.output_text.delta\n"
+	    "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,"
+	    "\"item_id\":\"msg_text_a\",\"item_id\":\"msg_text_a\","
+	    "\"content_index\":0,\"delta\":\"x\"}\n\n";
+	static const char noncontiguous_empty_terminal[] =
+	    "event: response.created\n"
+	    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+	    "\"resp_gap\"}}\n\n"
+	    "event: response.output_item.done\n"
+	    "data: {\"type\":\"response.output_item.done\",\"output_index\":1,"
+	    "\"item\":{\"type\":\"message\",\"id\":\"msg_gap\",\"role\":"
+	    "\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":"
+	    "\"x\"}],\"status\":\"completed\"}}\n\n"
+	    "event: response.completed\n"
+	    "data: {\"type\":\"response.completed\",\"response\":{\"id\":"
+	    "\"resp_gap\",\"status\":\"completed\",\"output\":[],\"usage\":"
+	    "null}}\n\n";
 	struct codex_sub_stream *stream;
 	struct capture capture;
 
@@ -1176,6 +1353,17 @@ test_sequence_conflicts_and_index_bounds(void)
 	CHECK(decode_rejected(duplicate_in_progress));
 	CHECK(decode_rejected(mismatched_failed));
 	CHECK(decode_rejected(bad_incomplete_status));
+	CHECK(decode_error_is(unknown_phase,
+	    "invalid Responses event sequence at response.output_item.added"));
+	CHECK(decode_rejected(null_phase));
+	CHECK(decode_rejected(conflicting_phase));
+	CHECK(decode_rejected(malformed_output_metadata));
+	CHECK(decode_rejected(unfinished_empty_terminal));
+	CHECK(decode_error_is(control_event_before_created,
+	    "invalid Responses event sequence"));
+	CHECK(decode_rejected(mismatched_text_item_id));
+	CHECK(decode_rejected(duplicate_text_item_id));
+	CHECK(decode_rejected(noncontiguous_empty_terminal));
 }
 
 static void
@@ -1637,6 +1825,7 @@ main(void)
 	test_reasoning_state_requires_completed_status();
 	test_empty_instructions_and_partial_state_preserve_call_order();
 	test_complete_stream_and_every_split();
+	test_live_message_stream_shape();
 	test_call_only_stream();
 	test_malformed_event_bytes();
 	test_terminal_only_and_terminal_guards();
blob - 1a9db99a7700f48fd853e43ed61bb013b7f5f090
blob + 9555c42cb873660d6bb9b0bb0cec02d3d7016a47
--- src/common/codex_sub_stream.c
+++ src/common/codex_sub_stream.c
@@ -42,6 +42,12 @@ enum output_kind {
 	OUTPUT_CALL
 };
 
+enum message_phase {
+	MESSAGE_PHASE_NONE,
+	MESSAGE_PHASE_COMMENTARY,
+	MESSAGE_PHASE_FINAL_ANSWER
+};
+
 struct output_item {
 	int		 used;
 	int		 index;
@@ -62,6 +68,7 @@ struct output_item {
 	int		 content_done;
 	int		 text_done;
 	int		 done;
+	enum message_phase phase;
 };
 
 struct codex_sub_stream {
@@ -92,6 +99,25 @@ stream_fail(struct codex_sub_stream *stream, const cha
 	}
 }
 
+static void
+stream_fail_event(struct codex_sub_stream *stream, const char *type)
+{
+	const unsigned char *p;
+
+	for (p = (const unsigned char *)type; *p != '\0'; p++)
+		if (!((*p >= 'a' && *p <= 'z') || (*p >= '0' && *p <= '9') ||
+		    *p == '.' || *p == '_')) {
+			stream_fail(stream, "invalid Responses event sequence");
+			return;
+		}
+	if (!stream->broken) {
+		stream->broken = 1;
+		strlcpy(stream->err, "invalid Responses event sequence at ",
+		    sizeof(stream->err));
+		strlcat(stream->err, type, sizeof(stream->err));
+	}
+}
+
 static int
 json_is_null_value(const struct json *json, int token)
 {
@@ -401,19 +427,41 @@ parse_call(const struct json *json, int object, const 
 }
 
 static int
+parse_message_phase(const struct json *json, int token,
+    enum message_phase *phase)
+{
+	char value[32];
+
+	*phase = MESSAGE_PHASE_NONE;
+	if (token == -1)
+		return (0);
+	if (token < 0 || copy_ident(json, token, value, sizeof(value)) == -1)
+		return (-1);
+	if (strcmp(value, "commentary") == 0)
+		*phase = MESSAGE_PHASE_COMMENTARY;
+	else if (strcmp(value, "final_answer") == 0)
+		*phase = MESSAGE_PHASE_FINAL_ANSWER;
+	else
+		return (-1);
+	return (0);
+}
+
+static int
 parse_message_text(const struct json *json, int object, const char *status,
-    char *id, size_t idsz, struct buf *text)
+    char *id, size_t idsz, struct buf *text, enum message_phase *phase)
 {
 	static const char *const names[] = {
-		"type", "id", "role", "content", "status"
+		"type", "id", "role", "content", "status", "phase"
 	};
-	static const char *const content_names[] = { "type", "text" };
-	int fields[5], content_fields[2], i, n, part;
-	char type[32], role[32], content_type[32], *value;
+	static const char *const content_names[] = {
+		"type", "text", "annotations", "logprobs"
+	};
+	int fields[6], content_fields[4], i, n, part;
+	char type[32], role[32], content_type[32], *value = NULL;
 	size_t len;
 
 	buf_init(text);
-	if (strict_fields(json, object, names, 5, fields) == -1 ||
+	if (strict_fields(json, object, names, 6, fields) == -1 ||
 	    copy_ident(json, fields[0], type, sizeof(type)) == -1 ||
 	    strcmp(type, "message") != 0 ||
 	    copy_ident(json, fields[1], id, idsz) == -1 ||
@@ -421,20 +469,28 @@ parse_message_text(const struct json *json, int object
 	    copy_ident(json, fields[2], role, sizeof(role)) == -1 ||
 	    strcmp(role, "assistant") != 0 || fields[3] == -1 ||
 	    !json_is_array(json, fields[3]) || fields[4] == -1 ||
-	    parse_status(json, fields[4], status) == -1)
+	    parse_status(json, fields[4], status) == -1 ||
+	    parse_message_phase(json, fields[5], phase) == -1)
 		goto fail;
 	n = json_arr_len(json, fields[3]);
 	if (n < 0)
 		goto fail;
 	for (i = 0; i < n; i++) {
 		part = json_arr_get(json, fields[3], i);
-		if (strict_fields(json, part, content_names, 2,
+		value = NULL;
+		if (strict_fields(json, part, content_names, 4,
 		    content_fields) == -1 ||
 		    copy_ident(json, content_fields[0], content_type,
 		    sizeof(content_type)) == -1 ||
 		    strcmp(content_type, "output_text") != 0 ||
-		    (value = get_string(json, content_fields[1], &len, 1)) == NULL)
+		    (value = get_string(json, content_fields[1], &len, 1)) == NULL ||
+		    (content_fields[2] != -1 &&
+		    !json_is_array(json, content_fields[2])) ||
+		    (content_fields[3] != -1 &&
+		    !json_is_array(json, content_fields[3]))) {
+			free(value);
 			goto fail;
+		}
 		if (len > FUGU_RESPONSE_MAX - text->len) {
 			free(value);
 			goto fail;
@@ -543,12 +599,18 @@ reasoning_apply(struct codex_sub_stream *stream, struc
 
 static int
 message_apply(struct codex_sub_stream *stream, struct output_item *item,
-    const char *id, const struct buf *text, int terminal_copy)
+    const char *id, const struct buf *text, enum message_phase phase,
+    int terminal_copy)
 {
 	struct astream_event event;
 
 	if (item_set_id(stream, item, id, "msg_") == -1)
 		return (-1);
+	if (item->phase != MESSAGE_PHASE_NONE && phase != MESSAGE_PHASE_NONE &&
+	    item->phase != phase)
+		return (-1);
+	if (phase != MESSAGE_PHASE_NONE)
+		item->phase = phase;
 	if (item->done && !terminal_copy)
 		return (-1);
 	if (item->content_added && (!item->text_done || !item->content_done))
@@ -580,6 +642,7 @@ handle_final_item(struct codex_sub_stream *stream, con
 	struct buf text, reasoning;
 	char type[32], id[CODEX_SUB_ITEM_ID_MAX + 1];
 	enum output_kind kind;
+	enum message_phase phase;
 	int result;
 
 	if (item_type(json, object, type, sizeof(type)) == -1)
@@ -613,9 +676,9 @@ handle_final_item(struct codex_sub_stream *stream, con
 		return (result);
 	}
 	if (parse_message_text(json, object, "completed", id, sizeof(id),
-	    &text) == -1)
+	    &text, &phase) == -1)
 		return (-1);
-	result = message_apply(stream, item, id, &text, terminal_copy);
+	result = message_apply(stream, item, id, &text, phase, terminal_copy);
 	buf_free(&text);
 	return (result);
 }
@@ -626,10 +689,11 @@ handle_added(struct codex_sub_stream *stream, const st
 {
 	struct output_item *item;
 	struct parsed_call call;
-	int index, object, fields[5];
+	int index, object, fields[6];
 	char type[32], id[CODEX_SUB_ITEM_ID_MAX + 1], role[32];
+	enum message_phase phase;
 	static const char *const message_names[] = {
-		"type", "id", "role", "content", "status"
+		"type", "id", "role", "content", "status", "phase"
 	};
 	static const char *const reasoning_names[] = {
 		"type", "id", "summary", "content", "encrypted_content", "status"
@@ -661,17 +725,19 @@ handle_added(struct codex_sub_stream *stream, const st
 		return (0);
 	}
 	if (strcmp(type, "message") == 0) {
-		if (strict_fields(json, object, message_names, 5, fields) == -1 ||
+		if (strict_fields(json, object, message_names, 6, fields) == -1 ||
 		    copy_ident(json, fields[1], id, sizeof(id)) == -1 ||
 		    strncmp(id, "msg_", 4) != 0 ||
 		    copy_ident(json, fields[2], role, sizeof(role)) == -1 ||
 		    strcmp(role, "assistant") != 0 || fields[3] == -1 ||
 		    !json_is_array(json, fields[3]) || fields[4] == -1 ||
 		    parse_status(json, fields[4], "in_progress") == -1 ||
+		    parse_message_phase(json, fields[5], &phase) == -1 ||
 		    (item = item_new(stream, index, OUTPUT_MESSAGE)) == NULL ||
 		    item_set_id(stream, item, id, "msg_") == -1)
 			return (-1);
 		item->added = 1;
+		item->phase = phase;
 		return (0);
 	}
 	if (strcmp(type, "reasoning") == 0) {
@@ -693,14 +759,17 @@ handle_text_delta(struct codex_sub_stream *stream, con
 {
 	struct output_item *item;
 	struct astream_event event;
-	char *delta;
+	char *delta, item_id[CODEX_SUB_ITEM_ID_MAX + 1];
 	size_t len;
-	int index, token;
+	int index, item_token, token;
 	int64_t content_index;
 
 	if (get_index(json, root, &index) == -1 ||
 	    (item = item_find(stream, index)) == NULL ||
 	    item->kind != OUTPUT_MESSAGE || item->done || item->text_done ||
+	    (item_token = obj_unique(json, root, "item_id")) == -2 ||
+	    (item_token >= 0 && (copy_ident(json, item_token, item_id,
+	    sizeof(item_id)) == -1 || strcmp(item_id, item->id) != 0)) ||
 	    (token = obj_unique(json, root, "content_index")) < 0 ||
 	    json_get_num(json, token, &content_index) == -1 ||
 	    content_index != 0 || (token = obj_unique(json, root, "delta")) < 0 ||
@@ -912,8 +981,10 @@ handle_completed(struct codex_sub_stream *stream, cons
 		if (handle_final_item(stream, json, item, i, 1) == -1)
 			return (-1);
 	}
-	if (stream->nitems != n)
+	if (n != 0 && stream->nitems != n)
 		return (-1);
+	if (n == 0)
+		n = stream->nitems;
 	for (i = 0; i < n; i++) {
 		struct output_item *out = item_find(stream, i);
 
@@ -1129,17 +1200,23 @@ informational_item(struct codex_sub_stream *stream, co
 static int
 parse_output_part(const struct json *json, int part, struct buf *text)
 {
-	static const char *const names[] = { "type", "text" };
-	int fields[2];
-	char type[32], *value;
+	static const char *const names[] = {
+		"type", "text", "annotations", "logprobs"
+	};
+	int fields[4];
+	char type[32], *value = NULL;
 	size_t len;
 
 	buf_init(text);
-	if (strict_fields(json, part, names, 2, fields) == -1 ||
+	if (strict_fields(json, part, names, 4, fields) == -1 ||
 	    copy_ident(json, fields[0], type, sizeof(type)) == -1 ||
 	    strcmp(type, "output_text") != 0 ||
-	    (value = get_string(json, fields[1], &len, 1)) == NULL)
+	    (value = get_string(json, fields[1], &len, 1)) == NULL ||
+	    (fields[2] != -1 && !json_is_array(json, fields[2])) ||
+	    (fields[3] != -1 && !json_is_array(json, fields[3]))) {
+		free(value);
 		return (-1);
+	}
 	if (len > FUGU_RESPONSE_MAX) {
 		free(value);
 		return (-1);
@@ -1346,7 +1423,7 @@ handle_json_event(struct codex_sub_stream *stream, con
 	else if ((result = handle_informational(stream, &json, root, type)) == -2)
 		stream_fail(stream, "unknown Responses event type");
 	if (result == -1 && !stream->broken)
-		stream_fail(stream, "invalid Responses event sequence");
+		stream_fail_event(stream, type);
 
 done:
 	json_done(&json);