feat(api-client): conversation + agent hooks + pure SSE streaming transport #344

Closed
opened 2026-06-29 02:20:26 +00:00 by james · 0 comments
Owner

The built-in agent's full API surface now exists (conversations CRUD #338, streaming SSE #340, proposal read #343, commit/reject #51) but @carol/api-client has no hooks for it — the chat UI (next ticket) needs them. This adds the data layer: TanStack Query hooks over the typed client for everything non-streaming, plus a pure, injectable SSE streaming transport the UI will wire to token-by-token output.

Scope boundary: the runtime base-URL resolution + auth + platform fetch (expo/fetch on native) live in the app (apps/client/lib/apiClient.ts), and the package must stay platform-neutral (web bundles can't pull react-native). So the streaming transport here is a standalone function with an injected fetch — the app wires expo/fetch + the runtime URL in the chat-UI ticket. No expo/react-native import in the package.

Scope

  • Query/mutation hooks (packages/api-client/src/hooks/conversations.ts), via useApiClient() exactly like hooks/notes.ts:
    • useConversations() (first page) + useConversationsInfinite()GET /api/conversations is cursor-paginated ({ data, next_cursor }).
    • useConversation(id)GET /api/conversations/{id}ConversationDetailDto.
    • useCreateConversation()POST /api/conversations (201).
    • useSendMessageJson()POST /api/conversations/{id}/messages non-streaming (the existing JSON turn; works on every platform) → TurnResponseDto; invalidates the conversation on success.
    • useResumeConversation()POST /api/conversations/{id}/resumeTurnResponseDto.
    • useProposal(id)GET /api/agent/proposals/{id}ProposalDto (#343).
    • useCommitProposal() / useRejectProposal() — the existing commit/reject endpoints.
  • Pure SSE streaming transport (e.g. packages/api-client/src/agent-stream.ts): streamConversationTurn(opts) taking { url, getAuthHeader?, fetchImpl?, body, signal, onEvent, onError? } (or a clean equivalent) that POSTs with Accept: text/event-stream, reads response.body via getReader(), parses event: <type>\ndata: <json>\n\n frames, and invokes onEvent(ev) per parsed ConversationStreamEvent. Tolerates frames split across reads, comment/keepalive lines, and a malformed data line (skip it, don't throw). Declares a ConversationStreamEvent union mirroring the backend wire shape (text_delta, tool_call, tool_result, message carrying MessageDto, awaiting_confirmation, done, error) — these aren't OpenAPI DTOs, so the type lives in the package; reuse components["schemas"]["MessageDto"] for the message payload. Injectable fetchImpl defaults to global fetch; no react-native/expo import.
  • Keys (keys.ts): conversations: { all, detail(id), infinite }, proposals: { detail(id) }.
  • Barrel (hooks/index.ts + package index.ts): export the hooks, streamConversationTurn, and the ConversationStreamEvent type.

Tests (package vitest — node env, no React render)

  • Hook tests: thin structural, mirroring tests/contracts.test.ts (each hook exported from the top barrel; key namespaces registered).
  • SSE transport tests (the valuable ones — real behavioral coverage): inject a fetchImpl returning a Response whose body is a ReadableStream of canned SSE chunks. Assert: ordered text_deltas; a tool_call/tool_result; an awaiting_confirmation; message events; terminal done; an error event; a frame split across two reads reassembles; comment/blank lines ignored; a malformed data: line is skipped without throwing. No real network.

Out of scope

  • App wiring of the transport (expo/fetch, runtime URL, the streaming useSendMessage-from-context) — chat-UI ticket.
  • The chat UI itself.

No openapi.json change (all endpoints already in the committed spec). Part of epic #47; unblocks the chat UI.

The built-in agent's full API surface now exists (conversations CRUD #338, streaming SSE #340, proposal read #343, commit/reject #51) but `@carol/api-client` has **no hooks** for it — the chat UI (next ticket) needs them. This adds the data layer: TanStack Query hooks over the typed client for everything non-streaming, plus a **pure, injectable SSE streaming transport** the UI will wire to token-by-token output. Scope boundary: the runtime base-URL resolution + auth + platform fetch (`expo/fetch` on native) live in the **app** (`apps/client/lib/apiClient.ts`), and the package must stay platform-neutral (web bundles can't pull react-native). So the streaming **transport** here is a standalone function with an injected `fetch` — the app wires `expo/fetch` + the runtime URL in the chat-UI ticket. No `expo`/`react-native` import in the package. ## Scope - **Query/mutation hooks** (`packages/api-client/src/hooks/conversations.ts`), via `useApiClient()` exactly like `hooks/notes.ts`: - `useConversations()` (first page) + `useConversationsInfinite()` — `GET /api/conversations` is cursor-paginated (`{ data, next_cursor }`). - `useConversation(id)` — `GET /api/conversations/{id}` → `ConversationDetailDto`. - `useCreateConversation()` — `POST /api/conversations` (201). - `useSendMessageJson()` — `POST /api/conversations/{id}/messages` **non-streaming** (the existing JSON turn; works on every platform) → `TurnResponseDto`; invalidates the conversation on success. - `useResumeConversation()` — `POST /api/conversations/{id}/resume` → `TurnResponseDto`. - `useProposal(id)` — `GET /api/agent/proposals/{id}` → `ProposalDto` (#343). - `useCommitProposal()` / `useRejectProposal()` — the existing commit/reject endpoints. - **Pure SSE streaming transport** (e.g. `packages/api-client/src/agent-stream.ts`): `streamConversationTurn(opts)` taking `{ url, getAuthHeader?, fetchImpl?, body, signal, onEvent, onError? }` (or a clean equivalent) that POSTs with `Accept: text/event-stream`, reads `response.body` via `getReader()`, parses `event: <type>\ndata: <json>\n\n` frames, and invokes `onEvent(ev)` per parsed `ConversationStreamEvent`. Tolerates frames split across reads, comment/keepalive lines, and a malformed `data` line (skip it, don't throw). Declares a `ConversationStreamEvent` union mirroring the backend wire shape (`text_delta`, `tool_call`, `tool_result`, `message` carrying `MessageDto`, `awaiting_confirmation`, `done`, `error`) — these aren't OpenAPI DTOs, so the type lives in the package; reuse `components["schemas"]["MessageDto"]` for the message payload. Injectable `fetchImpl` defaults to global `fetch`; **no** react-native/expo import. - **Keys** (`keys.ts`): `conversations: { all, detail(id), infinite }`, `proposals: { detail(id) }`. - **Barrel** (`hooks/index.ts` + package `index.ts`): export the hooks, `streamConversationTurn`, and the `ConversationStreamEvent` type. ## Tests (package vitest — `node` env, no React render) - Hook tests: thin structural, mirroring `tests/contracts.test.ts` (each hook exported from the top barrel; key namespaces registered). - **SSE transport tests (the valuable ones — real behavioral coverage):** inject a `fetchImpl` returning a `Response` whose `body` is a `ReadableStream` of canned SSE chunks. Assert: ordered `text_delta`s; a `tool_call`/`tool_result`; an `awaiting_confirmation`; `message` events; terminal `done`; an `error` event; a frame **split across two reads** reassembles; comment/blank lines ignored; a malformed `data:` line is skipped without throwing. No real network. ## Out of scope - App wiring of the transport (`expo/fetch`, runtime URL, the streaming `useSendMessage`-from-context) — chat-UI ticket. - The chat UI itself. No `openapi.json` change (all endpoints already in the committed spec). Part of epic #47; unblocks the chat UI.
james closed this issue 2026-06-29 09:43:20 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#344
No description provided.