From 8009df42aae1ce4ac947d0ad3ae3addb5546e821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Schr=C3=B6der?= <1188270+cwschroeder@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:54:40 -0700 Subject: [PATCH] fix(server): keep late research context refusals streamed A streamed research turn can start, prepare a compressed context and then refuse at the final context-window check before the response sink opens. The run's catch block treated every unopened failure as a whole-reply error, so that refusal came back as a JSON status even though the request was a stream and the same refusal on the opened path is streamed. Route error.Refused with a context-window depth through the streamed context error when the sink has not opened. Every other unopened failure keeps the existing whole-reply path. The regression drives both research windows that prepare a compressed prompt against the stub engine: the search runs, the response opens, no whole-reply status or body follows, the stream carries context_length_exceeded, and the preparing count drains to zero. With the openai.zig hunk removed the test fails at cortheon_test.zig:369 on `try std.testing.expect(got.opened)`, so it covers the failure it corrects. --- zig/src/server/cortheon/cortheon_test.zig | 23 +++++++++++++++++++++++ zig/src/server/openai.zig | 5 ++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/zig/src/server/cortheon/cortheon_test.zig b/zig/src/server/cortheon/cortheon_test.zig index 15413efef1..250126f2ff 100644 --- a/zig/src/server/cortheon/cortheon_test.zig +++ b/zig/src/server/cortheon/cortheon_test.zig @@ -354,3 +354,26 @@ test "the web budget ends the research and the reply still comes back without Co try std.testing.expect(m.get("tool_calls") == null); try std.testing.expect(std.mem.indexOf(u8, m.strField("content").?, "cortheon_search") == null); } + +test "research expansion keeps a context-window refusal streamed" { + for ([_]u32{ 2000, 2400 }) |window| { + var rig: Rig = .{ .eng = .{ .script = &.{"Zig latest release notes"} } }; + try rig.start(true); + defer rig.stop(); + rig.srv.info.context_window = window; + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const got = try rig.ask(arena.allocator(), "{\"stream\":true,\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"What is the latest release of Zig?\"}]}"); + try std.testing.expect(rig.web.searches > 0); + try std.testing.expectEqual(@as(usize, 1), rig.eng.prompts.items.len); + try std.testing.expect(got.opened); + try std.testing.expectEqual(@as(?u16, null), got.status); + try std.testing.expect(got.body == null); + var context_error = false; + for (got.events.items) |event| if (event.get("error")) |err| { + if (err.strField("code")) |code| context_error = std.mem.eql(u8, code, "context_length_exceeded"); + }; + try std.testing.expect(context_error); + try std.testing.expectEqual(@as(i64, 0), rig.srv.preparing.load(.acquire)); + } +} diff --git a/zig/src/server/openai.zig b/zig/src/server/openai.zig index 5b73d55f2c..60f6b2b843 100644 --- a/zig/src/server/openai.zig +++ b/zig/src/server/openai.zig @@ -521,7 +521,10 @@ const Run = struct { live.out = r.out; live.every_ns = @intFromFloat(r.srv.config.sse_keepalive_s * std.time.ns_per_s); var reply = (if (r.turn) |t| cortheon.loop.stream(t, r.srv, &cx, &r.plan.input, prepared, sink, live) else chat.generate(r.srv, &cx, prepared, sink, live)) catch |e| { - if (!sink_state.opened) return r.unsent(&cx, e, field); + if (!sink_state.opened) { + if (e == error.Refused and cx.kind == .context_length) return r.streamedContext(&cx, tools, field); + return r.unsent(&cx, e, field); + } switch (e) { error.Cancelled => return, error.Refused => if (cx.kind != .other and cx.kind != .server) {