Skip to content

feat(ai): SSE chat streaming + exhaustive today-listing prompt rules - #199

Merged
thomasluizon merged 1 commit into
mainfrom
feature/159-chat-sse-streaming
Jun 10, 2026
Merged

feat(ai): SSE chat streaming + exhaustive today-listing prompt rules#199
thomasluizon merged 1 commit into
mainfrom
feature/159-chat-sse-streaming

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Refs thomasluizon/orbit-ui-mobile#159. Paired frontend PR: thomasluizon/orbit-ui-mobile#160 (merge THIS one first — the SSE endpoint must be live before the clients consume it).

SSE chat streaming

New POST /api/chat/stream next to the unchanged buffered POST /api/chat (kept — it's public surface for API-key/agent consumers). Event protocol (data: frames, camelCase, enums as strings, nulls omitted):

event payload meaning
started headers flushed, feeds client watchdog
round iteration tool round running (keepalive during silent tool work)
delta text answer fragment
reset round emitted text but ended in tool calls — client clears the draft
final full ChatResponse authoritative result (actions, correlationId, relatedSurfaces)
error status, error, code? same shapes the buffered endpoint returns (PAY_GATE→403+code, Result failure→400+code, validation→400, unhandled→500)
  • AiIntentService switches to CompleteChatStreamingAsync when a sink is provided: content deltas flush immediately (First content token after Nms logged for the ~2s TTFT acceptance check), tool-call deltas accumulate via an index-keyed builder (args joined across chunks, empty args normalized to {}), and the AssistantChatMessage is rebuilt so the tool loop's conversation context is identical to the buffered path. Null sink → the exact pre-existing code path.
  • A round isn't known to be final until it ends, so every round streams; tool rounds simply emit no content. The rare content-before-tool-calls case emits reset.
  • Pre-stream failures (validation, auth, 429 rate limit) stay plain HTTP status codes — SSE only starts after validation. Paygate check, quota counting (background increment), correlationId, persistence: all untouched.
  • 15s per-attempt NetworkTimeout + retry policy from Harden calendar auto-sync event dedupe #156 still apply; for unbuffered responses the timeout acts per-read (server-side idle guard), and retries only cover the pre-headers leg — mid-stream drops surface as an error event.

Today-listing completeness (user-reported regression, prompt-only)

"liste meus hábitos de hoje" was dropping due-today habits (entire Before Bed group skipped despite being fully labeled [TODAY] in the index). The index data was already correct — done-today habits drop out naturally via DueDate advancement (desired), and remaining habits carry TODAY/OVERDUE. The failure was pure model listing decay, encouraged by rule 10's "keep responses concise".

Fix: the index instruction now demands enumerating EVERY labeled entry and verifying the list against the heading's exact counts (N due today, M overdue — computed with the same predicates as the labels, so they're a ready-made checksum); rule 12 forbids omitting/sampling/summarizing listing answers; rule 10 scopes conciseness to prose, never lists. No data changes, no extra LLM round — the single-round speed from #156 is preserved, and done-today habits stay excluded.

Tests (+16; suite now 3,143)

  • AiIntentServiceStreamingTests — real ChatClient pipeline over scripted OpenAI SSE bytes: text-round deltas, tool-args split across chunks, content-then-tools reset, mid-stream drop → failure, buffered path untouched.
  • ChatStreamEventTests — serialization contract locks (camelCase, string enums, null omission) matching the shared Zod union.
  • Handler: round per iteration + Domain→Application sink bridging; null-sink passthrough.
  • Prompt sections: exhaustiveness lines locked (gated on UserToday so the null-today contract holds); GlobalRules additions locked.
  • AgentCatalog guard test forced the ProcessChatStream capability mapping (included).

🤖 Generated with Claude Code

…pt rules

- POST /api/chat/stream emits started/round/delta/reset/final/error SSE events; buffered /api/chat unchanged for API-key consumers
- AiIntentService streams the completion when a sink is provided: content deltas flush immediately (TTFT logged), tool-call deltas accumulate across chunks, reset covers content-before-tool-calls
- ProcessUserChatCommand carries an optional StreamSink; the handler emits round keepalives per tool iteration
- Prompt rules force exhaustive index listings checksummed against the heading counts; done-today habits stay excluded from today answers

Refs thomasluizon/orbit-ui-mobile#159

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 Security Hotspot
65.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SSE streaming lands cleanly. The implementation is solid across all the areas that matter:

Authorization & rate-limiting are both class-level attributes on ChatController, so the new /stream endpoint inherits them without any extra decoration needed. Pre-stream validation (message length, image, history, clientContext) correctly returns plain HTTP errors before headers are committed; post-commit failures route through SSE error events — exactly the right split.

Streaming pipeline in AiIntentService is well-structured: buffered and streaming paths share the CompletedRound abstraction, tool-call args are accumulated correctly across chunks via StreamingToolCallBuilder, and the content-before-tool-calls reset case is handled and tested. The AssistantChatMessage reconstruction ensures the tool-loop conversation context is identical on both paths.

Tests are thorough: real ChatClient pipeline over scripted SSE bytes covers text deltas, split tool-args, content-then-reset, and mid-stream drop; serialization contract tests lock the camelCase/string-enum/null-omit wire format the shared Zod schema depends on; handler tests verify round keepalives and Domain→Application sink bridging.

Prompt fix for today-listing is minimal and targeted: the exhaustive-enumeration instruction is gated on UserToday.HasValue (correct null contract), and rule 10/12 changes are tested.

@thomasluizon
thomasluizon merged commit 5c3bd25 into main Jun 10, 2026
5 of 6 checks passed
@thomasluizon
thomasluizon deleted the feature/159-chat-sse-streaming branch June 10, 2026 18:27
thomasluizon added a commit that referenced this pull request Jun 30, 2026
…) (#273)

* feat(api): general cheer + social award triggers + profile handle (#199)

Relax Cheer.HabitId to optional so cheers are general encouragement, not
habit-specific: nullable entity property + factory, optional command/body/DTO,
validator drops the NotEmpty rule, EF config switches the habit FK to
IsRequired(false) + OnDelete SetNull, and a RelaxCheerHabitId migration alters
the column and FK. SendCheer skips the habit-ownership check when no habitId is
supplied.

Surface Handle + SocialOptIn on ProfileResponse so the client can render a
first-run opt-in gate instead of a raw 403.

Wire the three social-achievement award triggers via the existing TryGrant
funnel using conventional achievement ids ("first_friend", "squad_goals",
"cheerleader"): First Friend + Squad Goals (at 5) award BOTH participants on
accept; Cheerleader on the 10th sent cheer. These stay dormant until the
definitions ship in #196, then activate automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(api): integrate main, rescaffold RelaxCheerHabitId, merge profile response fields

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant