Commit Diff


commit - d140bf3b9088768c6532da38f50345ebb99d2378
commit + 6c01aa8ef14a038f0203c91d985154e6a7c0de2e
blob - e7bd92cb920dd68546ea6a1d8deac22b2c3532b0
blob + da3fdea0e9b780c913bad0a001b23cd333553120
--- docs/adr/0010-experimental-codex-subscription-compatibility.md
+++ docs/adr/0010-experimental-codex-subscription-compatibility.md
@@ -101,9 +101,14 @@ the pinned Codex source already modeled optional `comm
 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.
+terminal output array only after all streamed items are complete. The same
+pinned schema gives reasoning completion through the enclosing final-item event
+rather than an item `status`, and classifies `response.metadata` as advisory.
+Fugu therefore accepts absent status on final reasoning items, normalizes its
+retained copy to `status:"completed"`, and validates then discards only the
+exact response-bound HTTP metadata envelope. Metadata never becomes text, Tool
+authority, usage, or Provider state. 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
blob - ca2572930a445a2b62273b0b075053e007fd8864
blob + 3afdcaa1250a614764bb45f2620f8e0a3d647d40
--- docs/design/codex-subscription.md
+++ docs/design/codex-subscription.md
@@ -340,13 +340,15 @@ The hostile path is explicit:
 
 1. `codex_sub_stream` accepts only final reasoning and function-call metadata;
    partial items are never retained. A reasoning item requires nonempty
-   `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. A terminal
-   empty output array is accepted only after every contiguous streamed item is
-   already complete. Duplicate, unfinished, missing, or conflicting copies
-   fail.
+   `encrypted_content`; its enclosing final-item event supplies completion, so
+   `status` may be absent but, when present, must be exactly `completed`. The
+   retained canonical copy always contains `status:"completed"`. 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 canonical content must agree; 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
@@ -416,6 +418,12 @@ The decoder must:
 - retain only completed reasoning items and function-call identity records,
   require nonempty encrypted content and distinct bounded ids, and preserve
   their response order in one bounded continuity object;
+- accept `response.metadata` only as the exact observed HTTP SSE envelope:
+  matching bounded `response_id`, nonnegative `INT_MAX`-bounded integer
+  `sequence_number`, and a nonempty object-valued `metadata`, with no duplicate
+  or additional root fields. Treat the nested object as opaque advisory data,
+  discard it inside `fugu-api`, and emit no text, Tool, usage, or provider-state
+  event from it;
 - ignore only well-formed informational events explicitly classified by the
   profile; unknown terminal or output-item semantics fail closed;
 - reject data after a terminal event, duplicate terminals, conflicting item
@@ -548,9 +556,11 @@ Add `regress/codex_subscription/` with fixture-driven 
 3. every SSE byte split, multi-event reads, interleaved function calls,
    fragmented names/arguments, usage, normal completion, explicit failure,
    incomplete response, duplicate/conflicting items, output after terminal,
-   completed versus partial reasoning items, multiple reasoning items,
-   `call_id`/item-id pairing and conflicts, unknown Tool types, malformed JSON,
-   embedded NUL, and every byte/count cap;
+   statusless final versus partial or non-completed reasoning items, canonical
+   completed-state normalization, multiple reasoning items, exact interleaved
+   `response.metadata`, malformed metadata envelopes, `call_id`/item-id pairing
+   and conflicts, unknown Tool types, malformed JSON, embedded NUL, and every
+   byte/count cap;
 4. credential reflection split across adjacent text, Tool, reasoning-state,
    error, and HTTP-body fragments;
 5. config acceptance/redaction/wiping for implicit and named `codex` routes,
blob - b6688a8e29c683b247344bf718b995c3912bd5cb
blob + 9495d2581e7d7a66b6af3b4d89e9b21a8f2ab6e1
--- regress/codex_subscription/codex_subscription_test.c
+++ regress/codex_subscription/codex_subscription_test.c
@@ -773,6 +773,154 @@ test_live_message_stream_shape(void)
 	capture_free(&capture);
 }
 
+/* Sanitized ChatGPT Codex SSE shape observed on 2026-07-21.  Reasoning
+ * items omit status, and advisory response.metadata can arrive between the
+ * final text delta and its boundary events. */
+static const char live_reasoning_metadata_stream[] =
+    "event: response.created\n"
+    "data: {\"type\":\"response.created\",\"sequence_number\":0,"
+    "\"response\":{\"id\":\"resp_reasoning_meta\"}}\n\n"
+    "event: response.in_progress\n"
+    "data: {\"type\":\"response.in_progress\",\"sequence_number\":1,"
+    "\"response\":{\"id\":\"resp_reasoning_meta\",\"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\":\"reasoning\",\"id\":"
+    "\"rs_reasoning_meta\",\"summary\":[],\"content\":[],"
+    "\"encrypted_content\":\"cipher_added\"}}\n\n"
+    "event: response.output_item.done\n"
+    "data: {\"type\":\"response.output_item.done\",\"output_index\":0,"
+    "\"sequence_number\":3,\"item\":{\"type\":\"reasoning\",\"id\":"
+    "\"rs_reasoning_meta\",\"summary\":[],\"content\":[],"
+    "\"encrypted_content\":\"cipher_done\"}}\n\n"
+    "event: response.output_item.added\n"
+    "data: {\"type\":\"response.output_item.added\",\"output_index\":1,"
+    "\"sequence_number\":4,\"item\":{\"type\":\"message\",\"id\":"
+    "\"msg_reasoning_meta\",\"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\":1,"
+    "\"item_id\":\"msg_reasoning_meta\",\"content_index\":0,"
+    "\"sequence_number\":5,\"part\":{\"type\":\"output_text\","
+    "\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}\n\n"
+    "event: response.output_text.delta\n"
+    "data: {\"type\":\"response.output_text.delta\",\"output_index\":1,"
+    "\"item_id\":\"msg_reasoning_meta\",\"content_index\":0,"
+    "\"sequence_number\":6,\"delta\":\"hello\",\"logprobs\":[],"
+    "\"obfuscation\":\"noise\"}\n\n"
+    "event: response.metadata\n"
+    "data: {\"type\":\"response.metadata\",\"response_id\":"
+    "\"resp_reasoning_meta\",\"sequence_number\":7,\"metadata\":{"
+    "\"opaque\":\"scrubbed\"}}\n\n"
+    "event: response.output_text.done\n"
+    "data: {\"type\":\"response.output_text.done\",\"output_index\":1,"
+    "\"item_id\":\"msg_reasoning_meta\",\"content_index\":0,"
+    "\"sequence_number\":8,\"text\":\"hello\",\"logprobs\":[]}\n\n"
+    "event: response.content_part.done\n"
+    "data: {\"type\":\"response.content_part.done\",\"output_index\":1,"
+    "\"item_id\":\"msg_reasoning_meta\",\"content_index\":0,"
+    "\"sequence_number\":9,\"part\":{\"type\":\"output_text\","
+    "\"annotations\":[],\"logprobs\":[],\"text\":\"hello\"}}\n\n"
+    "event: response.output_item.done\n"
+    "data: {\"type\":\"response.output_item.done\",\"output_index\":1,"
+    "\"sequence_number\":10,\"item\":{\"type\":\"message\",\"id\":"
+    "\"msg_reasoning_meta\",\"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\":11,"
+    "\"response\":{\"id\":\"resp_reasoning_meta\",\"status\":"
+    "\"completed\",\"output\":[],\"usage\":{\"input_tokens\":12,"
+    "\"output_tokens\":3,\"total_tokens\":15}}}\n\n";
+
+static void
+check_live_reasoning_metadata_state(const struct capture *capture)
+{
+	static const char want[] =
+	    "{\"reasoning\":[{\"index\":0,\"item\":{\"type\":\"reasoning\","
+	    "\"id\":\"rs_reasoning_meta\",\"summary\":[],\"content\":[],"
+	    "\"encrypted_content\":\"cipher_done\",\"status\":\"completed\"}}],"
+	    "\"calls\":[]}";
+	struct provider_state *state = NULL;
+
+	CHECK(provider_state_decode(capture->state.data, capture->state.len,
+	    &state) == 0);
+	CHECK(state != NULL && state->provider_type == PROVIDER_CODEX &&
+	    strcmp(state->profile, "chatgpt-codex-2026-07-17") == 0 &&
+	    strcmp(state->model, "gpt-5.5") == 0 &&
+	    strcmp(state->kind, "responses-continuity") == 0 &&
+	    state->data_len == sizeof(want) - 1 &&
+	    memcmp(state->data, want, sizeof(want) - 1) == 0);
+	provider_state_free(state);
+}
+
+static void
+test_live_reasoning_metadata_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_reasoning_metadata_stream,
+	    sizeof(live_reasoning_metadata_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.ncalls == 0 && capture.nstate == 1 && capture.ndone == 1 &&
+	    capture.nerror == 0 && capture.nevents == 4 &&
+	    capture.events[0] == ASTREAM_TEXT &&
+	    capture.events[1] == ASTREAM_USAGE &&
+	    capture.events[2] == ASTREAM_PROVIDER_STATE &&
+	    capture.events[3] == ASTREAM_DONE &&
+	    strcmp(capture.stop, "stop") == 0);
+	if (capture.nstate == 1)
+		check_live_reasoning_metadata_state(&capture);
+	codex_sub_stream_free(stream);
+	capture_free(&capture);
+}
+
+static const char terminal_statusless_reasoning_stream[] =
+    "event: response.created\n"
+    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+    "\"resp_terminal_reasoning\"}}\n\n"
+    "event: response.completed\n"
+    "data: {\"type\":\"response.completed\",\"response\":{\"id\":"
+    "\"resp_terminal_reasoning\",\"status\":\"completed\",\"output\":[{"
+    "\"type\":\"reasoning\",\"id\":\"rs_reasoning_meta\",\"summary\":[],"
+    "\"content\":[],\"encrypted_content\":\"cipher_done\"},{\"type\":"
+    "\"message\",\"id\":\"msg_terminal_reasoning\",\"role\":"
+    "\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":"
+    "\"ok\"}],\"status\":\"completed\"}],\"usage\":null}}\n\n";
+
+static void
+test_terminal_statusless_reasoning_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,
+	    terminal_statusless_reasoning_stream,
+	    sizeof(terminal_statusless_reasoning_stream) - 1) == 0 &&
+	    codex_sub_stream_error(stream) == NULL && capture.text.len == 2 &&
+	    memcmp(capture.text.data, "ok", 2) == 0 && capture.nusage == 0 &&
+	    capture.ncalls == 0 && capture.nstate == 1 && capture.ndone == 1 &&
+	    capture.nerror == 0 && capture.nevents == 3 &&
+	    capture.events[0] == ASTREAM_TEXT &&
+	    capture.events[1] == ASTREAM_PROVIDER_STATE &&
+	    capture.events[2] == ASTREAM_DONE &&
+	    strcmp(capture.stop, "stop") == 0);
+	if (capture.nstate == 1)
+		check_live_reasoning_metadata_state(&capture);
+	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"
@@ -885,7 +1033,134 @@ decode_error_is(const char *fixture, const char *want)
 	return (matched);
 }
 
+static int
+metadata_event_error_is(const char *data, const char *want)
+{
+	static const char created[] =
+	    "event: response.created\n"
+	    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+	    "\"resp_metadata_guard\"}}\n\n"
+	    "event: response.metadata\n"
+	    "data: ";
+	struct buf fixture;
+	int matched;
+
+	buf_init(&fixture);
+	buf_add(&fixture, created, sizeof(created) - 1);
+	buf_addstr(&fixture, data);
+	buf_addstr(&fixture, "\n\n");
+	buf_addc(&fixture, '\0');
+	matched = decode_error_is((const char *)fixture.data, want);
+	buf_free(&fixture);
+	return (matched);
+}
+
+static int
+reasoning_status_rejected(const char *status)
+{
+	static const char start[] =
+	    "event: response.created\n"
+	    "data: {\"type\":\"response.created\",\"response\":{\"id\":"
+	    "\"resp_reasoning_guard\"}}\n\n"
+	    "event: response.output_item.done\n"
+	    "data: {\"type\":\"response.output_item.done\",\"output_index\":0,"
+	    "\"item\":{\"type\":\"reasoning\",\"id\":\"rs_reasoning_guard\","
+	    "\"summary\":[],\"content\":[],\"encrypted_content\":\"cipher\"";
+	static const char end[] = "}}\n\n";
+	struct buf fixture;
+	int rejected;
+
+	buf_init(&fixture);
+	buf_add(&fixture, start, sizeof(start) - 1);
+	buf_addstr(&fixture, status);
+	buf_add(&fixture, end, sizeof(end) - 1);
+	buf_addc(&fixture, '\0');
+	rejected = decode_rejected((const char *)fixture.data);
+	buf_free(&fixture);
+	return (rejected);
+}
+
 static void
+test_reasoning_and_metadata_guards(void)
+{
+	static const char want[] =
+	    "invalid Responses event sequence at response.metadata";
+
+	CHECK(reasoning_status_rejected(",\"status\":null"));
+	CHECK(reasoning_status_rejected(",\"status\":\"in_progress\""));
+	CHECK(reasoning_status_rejected(",\"status\":\"unknown\""));
+	CHECK(reasoning_status_rejected(",\"status\":true"));
+	CHECK(reasoning_status_rejected(",\"status\":\"completed\","
+	    "\"status\":\"completed\""));
+
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"sequence_number\":7,"
+	    "\"metadata\":{\"opaque\":\"scrubbed\"}}", want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_other\",\"sequence_number\":7,\"metadata\":{"
+	    "\"opaque\":\"scrubbed\"}}", want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":null,"
+	    "\"sequence_number\":7,\"metadata\":{\"opaque\":\"scrubbed\"}}",
+	    want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\\u0000suffix\",\"sequence_number\":7,"
+	    "\"metadata\":{\"opaque\":\"scrubbed\"}}", want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"response_id\":\"resp_metadata_guard\","
+	    "\"sequence_number\":7,\"metadata\":{\"opaque\":\"scrubbed\"}}",
+	    want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"metadata\":{\"opaque\":\"scrubbed\"}}",
+	    want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"sequence_number\":-1,\"metadata\":{"
+	    "\"opaque\":\"scrubbed\"}}", want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"sequence_number\":1.5,\"metadata\":{"
+	    "\"opaque\":\"scrubbed\"}}", want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"sequence_number\":2147483648,"
+	    "\"metadata\":{\"opaque\":\"scrubbed\"}}", want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"sequence_number\":7,"
+	    "\"sequence_number\":7,\"metadata\":{\"opaque\":\"scrubbed\"}}",
+	    want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"sequence_number\":7}", want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"sequence_number\":7,\"metadata\":null}",
+	    want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"sequence_number\":7,\"metadata\":[]}",
+	    want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"sequence_number\":7,\"metadata\":{}}",
+	    want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"sequence_number\":7,\"metadata\":{"
+	    "\"opaque\":\"scrubbed\"},\"metadata\":{\"opaque\":"
+	    "\"scrubbed\"}}", want));
+	CHECK(metadata_event_error_is(
+	    "{\"type\":\"response.metadata\",\"response_id\":"
+	    "\"resp_metadata_guard\",\"sequence_number\":7,\"metadata\":{"
+	    "\"opaque\":\"scrubbed\"},\"headers\":{}}", want));
+}
+
+static void
 test_malformed_event_bytes(void)
 {
 	static const char malformed_json[] =
@@ -1826,8 +2101,11 @@ main(void)
 	test_empty_instructions_and_partial_state_preserve_call_order();
 	test_complete_stream_and_every_split();
 	test_live_message_stream_shape();
+	test_live_reasoning_metadata_stream_shape();
+	test_terminal_statusless_reasoning_shape();
 	test_call_only_stream();
 	test_malformed_event_bytes();
+	test_reasoning_and_metadata_guards();
 	test_terminal_only_and_terminal_guards();
 	test_provider_failures();
 	test_malformed_terminal_semantics();
blob - 9555c42cb873660d6bb9b0bb0cec02d3d7016a47
blob + 110d1b32c843f11c6656bd7ec2e80be16bcee5cc
--- src/common/codex_sub_stream.c
+++ src/common/codex_sub_stream.c
@@ -347,7 +347,8 @@ parse_reasoning(const struct json *json, int object, c
 	if (fields[3] != -1 && !json_is_null_value(json, fields[3]) &&
 	    !json_is_array(json, fields[3]))
 		goto fail;
-	if (fields[5] == -1 || parse_status(json, fields[5], "completed") == -1)
+	if (fields[5] != -1 &&
+	    parse_status(json, fields[5], "completed") == -1)
 		goto fail;
 	json_out_init(&out, canonical);
 	json_obj_begin(&out);
@@ -1166,6 +1167,30 @@ handle_error_event(struct codex_sub_stream *stream, co
 }
 
 static int
+handle_metadata(struct codex_sub_stream *stream, const struct json *json,
+    int root)
+{
+	static const char *const names[] = {
+		"type", "response_id", "sequence_number", "metadata"
+	};
+	int fields[4];
+	int64_t sequence;
+	char type[32], response_id[CODEX_SUB_ITEM_ID_MAX + 1];
+
+	if (strict_fields(json, root, names, 4, fields) == -1 ||
+	    copy_ident(json, fields[0], type, sizeof(type)) == -1 ||
+	    strcmp(type, "response.metadata") != 0 ||
+	    copy_ident(json, fields[1], response_id, sizeof(response_id)) == -1 ||
+	    strcmp(response_id, stream->response_id) != 0 || fields[2] == -1 ||
+	    json_get_num(json, fields[2], &sequence) == -1 || sequence < 0 ||
+	    sequence > INT_MAX || fields[3] == -1 ||
+	    !json_is_object(json, fields[3]) ||
+	    json_obj_len(json, fields[3]) < 1)
+		return (-1);
+	return (0);
+}
+
+static int
 small_index_field(const struct json *json, int root, const char *name,
     int *value)
 {
@@ -1420,6 +1445,8 @@ handle_json_event(struct codex_sub_stream *stream, con
 		result = handle_failed(stream, &json, root);
 	else if (strcmp(type, "response.incomplete") == 0)
 		result = handle_incomplete(stream, &json, root);
+	else if (strcmp(type, "response.metadata") == 0)
+		result = handle_metadata(stream, &json, root);
 	else if ((result = handle_informational(stream, &json, root, type)) == -2)
 		stream_fail(stream, "unknown Responses event type");
 	if (result == -1 && !stream->broken)