Skip to content

Stream the model completions - #71

Open
maxime-pharmania wants to merge 10 commits into
seoes:mainfrom
pharmania:feat/streaming-model-calls
Open

maxime-pharmania wants to merge 10 commits into
seoes:mainfrom
pharmania:feat/streaming-model-calls

Conversation

@maxime-pharmania

@maxime-pharmania maxime-pharmania commented Oct 9, 2026 •

Copy link
Copy Markdown

Adds streaming to the OpenAI-compatible sender, with a switch in the model provider screen.

  • request stream: true with stream_options.include_usage; the completion is rebuilt from the chunks (content, tool calls merged by index, usage from the final chunk)
  • new exported accumulateChatCompletionStream() helper
  • provider errors delivered inside the stream now throw
  • new stream setting per model provider (default on, migration 0046) and a "Stream responses" toggle in the create and edit form; when it is off the original buffered path runs
  • unit tests for both paths (content, tool calls, usage, stream error, split SSE frame)

Comment thread apps/api/src/agent/llm/openai.ts Outdated
},
finishReason: choice.finish_reason,
requestId: completion.id ?? null,
finishReason: accumulated.finishReason ?? "stop",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚫️ [PROBLEM] Empty provider stream is silently reported as a successful completion

⚫ [PROBLEM] A stream that never yields a choice chunk is reported as a successful completion.

In accumulateChatCompletionStream, if no chunk carries choices[0], finishReason stays null and error stays null (the if (!choice) continue; path at apps/api/src/agent/llm/openai.ts:112). send() then coalesces the missing finish reason with finishReason: accumulated.finishReason ?? "stop" at this line and returns content: null with no error.

Impact: runAgentLoop sees no tool calls (apps/api/src/agent/llm/loop.ts:227) and returns a "completed" result with finalMessage: null (loop.ts:245) — no logAgentError, no retry, and usage stays at zero. The old buffered code did completion.choices[0].message, which threw a loud TypeError on an empty/invalid body, so this is a regression in failure signaling. Users get an empty answer that looks like a successful run.

Suggested fix: after accumulation in send(), fail loudly when no choice chunk ever arrived:

if (accumulated.finishReason === null && !accumulated.error) {
    throw new Error("Provider stream ended without a completion");
}

This routes the degenerate stream through the loop's existing catch path (including context-length handling and retries).

@proval-0e6d07

proval-0e6d07 Bot commented Oct 9, 2026

Copy link
Copy Markdown

Thanks for this change — moving the OpenAI sender to streaming directly addresses the silent-origin problem, and the rationale comment at apps/api/src/agent/llm/openai.ts:24 explains why clearly.

a. Summary

This PR replaces the buffered chat.completions.create call with stream: true plus a new accumulateChatCompletionStream helper that rebuilds content, tool-call fragments (ordered by index), and usage from SSE chunks, and it removes the OpenRouter-specific error-JSON check in favor of a generic in-stream error frame. A new test file exercises the request contract through the injected fetch seam and unit-tests the accumulator directly. One failure-signaling gap needs attention before merge.

b. Good points

  • 🟢 The accumulator unit tests pin the hardest part of a streaming rewrite — tool-call reassembly — covering index-based reordering of interleaved calls and id/name preservation across fragments (apps/api/src/agent/llm/openai.streaming.test.ts:201).
  • 🟢 Behavioral tests run through the real OpenAI SDK via the injected fetch, asserting the outgoing stream: true and stream_options: { include_usage: true } contract, plus split-frame SSE and in-stream error cases (apps/api/src/agent/llm/openai.streaming.test.ts:119).
  • 🟢 The comment above client.chat.completions.create documents the operational reason for streaming — Cloudflare 524 cutting silent buffered origins — which guards against a well-meaning "simplification" back to buffered (apps/api/src/agent/llm/openai.ts:24).

Main Issues:

  • ⚫️ apps/api/src/agent/llm/openai.ts, line 55 — a stream that yields no choice chunks returns finishReason: null, content: null, error: null, and send() coalesces it with ?? "stop", so the agent loop reports a "completed" run with finalMessage: null, no error log, and zero usage; the old buffered code threw a loud TypeError in this case. Fix: throw when accumulated.finishReason === null && !accumulated.error after accumulation.

Additional Notes:

  • apps/api/src/agent/llm/openai.ts, line 98, the in-stream error branch keeps only chunkError.message and discards the numeric code (the test payload at openai.streaming.test.ts:177 includes code: 502), so thrown errors never carry status and runAgentLoop's 429/400 retry check (apps/api/src/agent/llm/loop.ts:196) never fires for in-stream rate-limit/overload errors; consider attaching the code as error.status.

The model provider screen now carries a "Stream responses" switch. The value
is stored per provider (default on, migration 0046) and passed to the sender,
which keeps the original buffered path when the switch is off.
A stream that yields no choice chunks produced a null finish reason, a null
content and no error, so the loop reported a completed run with an empty
message. The buffered call raised a TypeError on that shape; the streaming path
now raises an explicit error. The accumulator also keeps the provider error code
as a status for the case where a chunk carrying an error reaches it.
@maxime-pharmania

Copy link
Copy Markdown
Author

Fixed in 5628e86.

Empty stream. The streaming path now throws when the accumulated finish reason is null, so a stream without choice chunks fails loudly instead of reporting a completed run with an empty message. Covered by a test that feeds only data: [DONE].

In-stream error status. I looked at where that error actually comes from: the OpenAI SDK converts an error frame into an APIError before the accumulator receives the chunk, and that error carries no status, so the code cannot be recovered there for the retry check in runAgentLoop. The accumulator still keeps a provider code as status when a chunk carrying an error reaches it, with a unit test for that path. A real fix for in-stream rate limits would have to read the error frame before the SDK does; happy to do it if you want that behaviour.

baseURL: modelProvider.baseUrl,
model: repository.modelName,
timeoutSecond: modelProvider.timeoutSecond,
stream: modelProvider.stream,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚫️ [PROBLEM] Misindented stream property breaks prettier --check

This added stream property is indented with 8 spaces while every sibling in the same object literal uses 12. The api package's lint script is prettier --check . && eslint . (apps/api/package.json:15), so bun run lint fails on this file until the line is reformatted.

            timeoutSecond: modelProvider.timeoutSecond,
            stream: modelProvider.stream,
            reasoningEffort: repository.reasoningEffort,

Comment thread apps/api/src/agent/llm/openai.ts Outdated
Comment on lines +54 to +62
const accumulated = await accumulateChatCompletionStream(stream);

// A provider can answer 200 and report the failure inside the stream.
if (accumulated.error) {
const error = new Error(accumulated.error.message) as Error & { status?: number };
if (accumulated.error.status !== undefined) {
error.status = accumulated.error.status;
}
throw error;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚫️ [PROBLEM] In-stream 429 status never attaches on the real SDK path

Attaching the provider code as status here only runs when an error chunk reaches the accumulator, but your own test comment at apps/api/src/agent/llm/openai.streaming.test.ts:191-193 says the OpenAI SDK converts the error frame into an APIError before the accumulator sees it, and that error carries no status. On the path the comment describes, these lines never execute, and the sender-level test (lines 194-196) asserts only error.message, so the retry contract stays unpinned: runAgentLoop's check at apps/api/src/agent/llm/loop.ts:196 requires status === 429 || status === 400, so an in-stream rate limit fails the run outright instead of getting the retry/backoff that HTTP-level 429s get. Could be wrong if the SDK actually lets the chunk through to the accumulator — but then the sender-level test should assert status to pin that contract either way.

Fix: wrap stream accumulation, attach the numeric code from the error payload when status is absent, and assert the status in the sender-level test.

let accumulated: AccumulatedChatCompletion;
try {
    accumulated = await accumulateChatCompletionStream(stream);
} catch (err) {
    const apiError = err as OpenAI.APIError & { status?: number; error?: { code?: number } };
    if (apiError.status === undefined && typeof apiError.error?.code === "number") {
        apiError.status = apiError.error.code;
    }
    throw err;
}

And in the test: expect((error as { status?: number }).status).toBe(429);

let accumulated: AccumulatedChatCompletion;
try {
    accumulated = await accumulateChatCompletionStream(stream);
} catch (err) {
    const apiError = err as OpenAI.APIError & { status?: number; error?: { code?: number } };
    if (apiError.status === undefined && typeof apiError.error?.code === "number") {
        apiError.status = apiError.error.code;
    }
    throw err;
}

Comment thread apps/api/src/agent/llm/openai.ts Outdated
Comment on lines 89 to 90
if (client.baseURL?.includes("openrouter.ai")) {
const result = openRouterErrorSchema.safeParse(completion);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚫️ [PROBLEM] Buffered OpenRouter error drops retry status and can fall through to a TypeError

In the restored buffered path, throw new Error(result.data.error.message) at openai.ts:92 (just below this hunk) drops the numeric result.data.error.code, so apps/api/src/agent/llm/loop.ts:196 (status === 429 || status === 400) can never retry a rate limit reported in a 200 body, even though the streaming branch now carefully carries status. Separately, openRouterErrorSchema at openai.ts:8 requires code to be a number — if a provider sends a string or missing code, safeParse fails, execution falls through to completion.choices[0] at openai.ts:96, and because an error body has no choices this throws Cannot read properties of undefined (reading '0'), masking the provider's real message. (This mirrors the pre-PR buffered code, but it is re-entered under the new stream: false option alongside the streaming side's status handling.)

Fix: attach the code as error.status, make the schema tolerant of non-numeric/missing codes, and guard a missing choice before dereferencing it.

if (result.success) {
    const error = new Error(result.data.error.message) as Error & { status?: number };
    error.status = result.data.error.code;
    throw error;
}

const choice = completion.choices[0];
if (!choice) {
    throw new Error("The provider returned a completion without choices");
}
if (result.success) {
    const error = new Error(result.data.error.message) as Error & { status?: number };
    error.status = result.data.error.code;
    throw error;
}

const choice = completion.choices[0];
if (!choice) {
    throw new Error("The provider returned a completion without choices");
}

@proval-0e6d07

proval-0e6d07 Bot commented Oct 9, 2026

Copy link
Copy Markdown

Thanks for the follow-up — this push lands both fixes from the last review (the empty-stream completion now fails loudly, and in-stream errors carry a numeric status) and adds the per-provider Stream toggle end to end.

a. Summary

This push adds a NOT NULL stream column to model_provider (migration 0046), exposes it as a "Stream responses" switch in the provider form, plumbs stream through every createSender call site, and restores the buffered completion path behind config.stream while keeping streaming the default. The prior review's silent-success gap is closed with a dedicated throw and test, and the error-status carry is a direct response to the earlier note. Two status-related retry gaps and one lint-breaking indentation slip are annotated inline.

b. Good points

  • 🟢 The silent-success gap from the previous review is fully closed: apps/api/src/agent/llm/openai.ts:67 throws when a stream ends with no finish reason, and apps/api/src/agent/llm/openai.streaming.test.ts:199 adds a dedicated [DONE]-only test for exactly that shape.
  • 🟢 One shared request object (apps/api/src/agent/llm/openai.ts:33) feeds both the streaming and buffered calls, so model/messages/tools/reasoning_effort cannot drift between the two branches; the buffered test also pins that stream/stream_options stay off the wire.
  • 🟢 The migration adds the column as ADD stream integer DEFAULT true NOT NULL (packages/db/src/migration/0046_dazzling_mulholland_black.sql:1), satisfying SQLite's NOT NULL-with-default rule and backfilling existing providers to streaming-enabled in one statement.
  • 🟢 apps/client/src/lib/components/organism/ModelForm.svelte:33 hydrates with ?? rather than ||, so a persisted stream: false stays Buffered on edit instead of being silently flipped back to Streaming.

c. Main Issues

  • ⚫️ apps/api/src/agent/llm/openai.ts:54 — the code→status attach only runs when an error chunk reaches the accumulator, but the test comment at openai.streaming.test.ts:191 says the SDK throws an APIError (no status) first and the sender test never asserts status, so loop.ts:196's 429/400 retry still won't fire for in-stream rate limits on the documented real path.
  • ⚫️ apps/api/src/agent/llm/openai.ts:89 — the restored buffered path throws the OpenRouter 200-body error without attaching code as status (no loop retry), and a non-numeric/missing code fails safeParse and falls through to completion.choices[0] at line 96, turning the provider's error into a Cannot read properties of undefined TypeError.
  • ⚫️ apps/api/src/webhook/github/github.controller.ts:372 — the added stream line is indented 8 spaces against its 12-space siblings, which fails prettier --check in the api lint script (apps/api/package.json:15).

d. Additional Notes

  • apps/api/src/agent/llm/factory.ts:12 now requires stream for every sender, but apps/api/src/agent/llm/anthropic.ts:35 always issues a buffered client.messages.create and never reads the flag, so the new toggle is a silent no-op for Anthropic providers — worth honoring it there or hiding the control in the form.

};
}

const completion = await client.chat.completions.create({ ...request });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚫️ [PROBLEM] Buffered path dereferences completion.choices[0] without a guard

⚫ [PROBLEM] The buffered branch introduced in this push reads completion.choices[0] at apps/api/src/agent/llm/openai.ts:86 (then choice.message at line 87) with no guard. The only protection is the OpenRouter check at lines 79–84, which runs solely when client.baseURL contains openrouter.ai and the body matches openRouterErrorSchema exactly — the schema requires code: z.number() (line 8), so an error body with a missing or string code fails safeParse and falls through. For any other baseURL (custom gateways are a first-class config in this product), a 200 error-shaped JSON leaves completion.choices undefined and the sender throws TypeError: Cannot read properties of undefined (reading '0') instead of the provider's actual error message.

Impact: as soon as someone opts into buffered mode, provider failures surface as an inscrutable TypeError, and the provider's own message plus any machine-readable code are lost — exactly the debuggability the OpenRouter check was written to preserve.

Fix: surface the body error when choices is absent:

const choice = completion.choices?.[0];
if (!choice) {
    const bodyError = (completion as { error?: { message?: string } }).error;
    throw new Error(bodyError?.message ?? "Provider returned a completion without choices");
}
const message = choice.message;

@proval-0e6d07

proval-0e6d07 Bot commented Oct 9, 2026

Copy link
Copy Markdown

Thanks for the follow-up work — this round turns the streaming rewrite from last time into a deliberate per-provider choice, and the flag travels cleanly from the model-provider form through the schema to every sender.

a. Summary

This push adds a stream column (migration 0046, DEFAULT true NOT NULL), makes stream: boolean a required field on SenderConfig, splits the OpenAI sender into streaming and buffered branches behind one shared request payload, wires the flag through all createSender call sites, and exposes a Streaming/Buffered toggle in ModelForm.svelte with matching demo fixtures and a buffered-mode wire-contract test. One buffering-path error-handling gap needs attention before merge; the empty-stream guard flagged in the previous round (openai.ts, line 67) is still unaddressed, so that earlier thread remains open.

b. Good points

  • 🟢 All 15 createSender sites now supply stream (14× modelProvider.stream plus the review CLI's stream: true), enforced by the required field at apps/api/src/agent/llm/factory.ts:12 — no runtime path silently keeps the old behavior.
  • 🟢 Schema and migration mirror each other exactly (packages/db/src/schema.ts:51 .notNull().default(true) ↔ packages/db/src/migration/0046_dazzling_mulholland_black.sql:1 DEFAULT true NOT NULL), so existing providers keep anti-524 streaming immediately after upgrade and no call site needs null checks.
  • 🟢 The new buffered test pins the opposite wire contract from the streaming tests — stream and stream_options asserted toBeUndefined() alongside full result mapping (apps/api/src/agent/llm/openai.streaming.test.ts:276), locking both branches of the dual path.

Main Issues:

  • ⚫️ apps/api/src/agent/llm/openai.ts, line 77 — the buffered branch dereferences completion.choices[0] (line 86) with no guard, and the only protection is the OpenRouter-only schema check (lines 79–84) that requires code: z.number(), so a 200 error-shaped body from any other gateway — or with a string/missing code — throws a cryptic TypeError instead of the provider's error message (inline comment above).

Additional Notes:

  • apps/api/src/webhook/github/github.controller.ts, line 372, the new stream property is indented with 8 spaces while its siblings use 12; apps/api lint runs prettier --check . (apps/api/package.json:15), so this will fail the lint gate until reformatted.
  • apps/api/src/agent/llm/openai.ts, line 82, the buffered OpenRouter throw keeps only error.message and drops code, so the error never carries status and the 429/400 retry check in apps/api/src/agent/llm/loop.ts:196 cannot fire for in-body rate-limit errors (the same gap previously noted on the in-stream throw at line 58).
  • apps/client/src/lib/components/organism/ModelForm.svelte, line 253, the Stream toggle renders for every provider, but Anthropic providers route to createAnthropicSender (apps/api/src/agent/llm/factory.ts:36) whose sender never reads config.stream (apps/api/src/agent/llm/anthropic.ts:35 uses non-streaming messages.create), so the control reads "Streaming" while Claude requests stay buffered.

maxime-pharmania and others added 5 commits October 9, 2026 10:58
The limit is Proval's own constant (USER_PROMPT_MAX_LENGTH); the model provider
and repository screens enforce it. The API test now derives its bound from the
constant instead of hardcoding 2000.
…LENGTH

The limit was a build-time constant. It now resolves at runtime from
PROVAL_USER_PROMPT_MAX_LENGTH (any value that is not a positive integer is
ignored), the API enforces the resolved value and announces it on /api/auth/me,
and the repository form uses the announced limit instead of the bundled one.
@maxime-pharmania

Copy link
Copy Markdown
Author
  • In-stream error never carries a status on the real path: fixed in e2a2e33 — the streaming branch now catches the SDK's APIError around accumulation and copies a numeric code onto status when the SDK left it undefined, so the 429/400 retry in runAgentLoop fires; the sender test now asserts status 429.
  • Buffered path dropped code and fell through to a TypeError on non-numeric/missing codes or non-OpenRouter gateways: fixed in e2a2e33 — the 200 error-body check now applies to every provider, the schema accepts a numeric, string or missing code, a numeric code is attached as status, and a completion without choices throws "The model provider returned a completion without choices" instead of dereferencing undefined; covered by three new buffered tests.
  • github.controller.ts:372 indentation: fixed in af2c595, prettier --check passes on the file.
  • Stream toggle shown for Anthropic providers whose sender ignores it: fixed in c39935e — ModelForm.svelte no longer renders the toggle when the provider is anthropic.
  • Empty-stream guard reported as still open: not applicable — it landed in 5628e86 and is in the current branch: the sender throws "The model stream ended without a finish reason" when accumulation ends with finishReason === null, with a [DONE]-only test.

@proval-0e6d07

proval-0e6d07 Bot commented Oct 9, 2026

Copy link
Copy Markdown

Thanks for the follow-up — this push lands the fixes for every open thread from the last round: the buffered path now guards missing choices and attaches the provider status, the GitHub indent is reformatted, the Anthropic toggle is hidden, and the empty-stream hole is closed with a real guard.

a. Summary

The OpenAI sender's error/status contract is now uniform: completionErrorSchema runs for every provider (not just OpenRouter), numeric codes attach as status for the retry check in loop.ts, and both streaming and buffered paths fail loudly on incomplete results — each shape pinned by new tests. A configurable prompt-length limit (resolveUserPromptMaxLength + PROVAL_USER_PROMPT_MAX_LENGTH) is announced via /auth/me and enforced on both repository create and update, with the client store hydrating safely from the response. Only test-coverage and hardening nits remain; nothing blocks merge.

b. Good points

  • 🟢 The in-stream error test now drives a {code: 429} frame through the real SDK fetch path and asserts both the message and status === 429 (apps/api/src/agent/llm/openai.streaming.test.ts:202) — the exact values loop.ts reads for retries, so SDK drift that drops the status fails CI.
  • 🟢 The buffered branch's three error shapes (numeric code, string code, empty choices) each get a dedicated test asserting message and status (apps/api/src/agent/llm/openai.streaming.test.ts:344), closing both prior threads — dropped retry status and TypeError fallthrough — with observable behavior.
  • 🟢 The Stream toggle is hidden exactly when the flag has no effect — {#if provider !== "anthropic"} (apps/client/src/lib/components/organism/ModelForm.svelte:253) matches the factory.ts routing and anthropic.ts's non-streaming messages.create, while the value still persists untouched for Anthropic rows.

Main Issues: none — no critical or problem-level findings survived verification this round.

Additional Notes:

  • apps/api/src/agent/llm/openai.ts, line 115, completion.choices[0] still dereferences choices itself, so a 200 body with no choices key at all (e.g. {"id": "x", "object": "error"}) throws a raw TypeError instead of the new "completion without choices" error — completion.choices?.[0] would cover that shape too.
  • apps/api/src/api/auth/auth.service.ts, line 76, nothing asserts that getMe() returns the resolved userPromptMaxLength; the enforcement side is well tested in repository.service.test.ts:315-336, but the announced side has no test file at all, so the announce/enforce parity currently rests on both sites keeping the identical resolveUserPromptMaxLength(...) call.

A 200 body without a choices key (an error shaped object for instance) made the
buffered path dereference undefined and throw a raw TypeError instead of the
explicit completion-without-choices error.
The repository update and GET /api/auth/me resolved PROVAL_USER_PROMPT_MAX_LENGTH
separately, so the announced and the enforced limit could drift. Both now call
currentUserPromptMaxLength().
@maxime-pharmania

Copy link
Copy Markdown
Author

Both addressed.

  • apps/api/src/agent/llm/openai.ts:117 — fixed in 0398f25: the buffered path reads (completion as { choices?: … }).choices?.[0], so a 200 body with no choices key (your {"id":"x","object":"error"} shape) now reaches the "The model provider returned a completion without choices" error instead of a TypeError. The new test feeds exactly that body, and it fails when the optional chain is removed, so the shape is pinned.
  • apps/api/src/api/auth/auth.service.ts:76 — instead of duplicating the resolution, both sites now read the limit from one call site, currentUserPromptMaxLength() in apps/api/src/util/user-prompt-limit.ts (924d911), so the announced and the enforced value cannot drift apart. The assertion on getMe() exists in Read the custom instructions limit from an environment variable #73 (apps/api/src/api/auth/auth.service.test.ts, commit 7c880c2: default, custom 9000, and an invalid value), because the announcement ships with that pull request. Say the word and I move that test into this branch instead of keeping it there.

Validation on this branch: bun test 72 pass / 0 fail, tsc --noEmit clean, eslint clean on the touched files.

@proval-0e6d07

proval-0e6d07 Bot commented Oct 9, 2026

Copy link
Copy Markdown

Thanks for the follow-up — this push closes both remaining notes from the last round: the buffered path now survives a 200 body with no choices key at all, and the announce/enforce pair reads the prompt limit from one shared helper.

a. Summary

Two small, surgical fixes. openai.ts widens the choices read so an error-shaped 200 body without the key hits the explicit "completion without choices" guard instead of a raw TypeError, pinned by a test that uses the exact offending body shape. On the prompt limit, a new currentUserPromptMaxLength() helper (apps/api/src/util/user-prompt-limit.ts) becomes the single call site, with getMe() and normalizeUserPrompt both migrated in the same push — a repo-wide grep confirms no other PROVAL_USER_PROMPT_MAX_LENGTH / resolveUserPromptMaxLength reads remain in apps/api. No issues to act on; nothing blocks merge.

b. Good points

  • 🟢 The new test drives the exact key-less error body { id: "chatcmpl-error", object: "error" } and asserts the explicit guard message rather than just "something threw" (apps/api/src/agent/llm/openai.streaming.test.ts:359) — so removing the defensive read fails CI with a clear diff instead of silently regressing to a TypeError.
  • 🟢 Announce/enforce parity now lives in one documented entry point, currentUserPromptMaxLength() (apps/api/src/util/user-prompt-limit.ts:8), and both auth.service.ts:76 and repository.service.ts:462 were switched atomically — a future env-name or default change can't drift between the two sites.
  • 🟢 The cast at apps/api/src/agent/llm/openai.ts:117 widens only the nullability of choices while reusing the real Choice[] type, so downstream reads stay fully typed with no any introduced, and the comment above it documents the non-obvious provider behavior that justifies it.

Main Issues: none.

Additional Notes: none this round — both prior notes are resolved in this push.

maxime-pharmania added a commit to pharmania/proval that referenced this pull request Oct 9, 2026
Brings in the review fixes of seoes#71 (choices guard on the buffered
path, single call site for the custom instructions limit) and of
seoes#73 (typed layout props, .env.example entry, environment cleanup in
the repository tests, getMe announcement test, inclusive boundary test). The
fork keeps 9000 as its default limit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants