feat(chat): concurrent agents on highlighted parts of a response

🟢 open · #385 · open-legal-products/mike ← amal66/mike · opened 12d ago by amal66 · +8,947-1,426 across 49 files · ↗ on GitHub

From the PR description

Summary

Highlight part of an assistant response, hand it to an agent with an instruction, and the agent answers in a side panel while you keep working. Multiple agents run at once. Each is a real, persisted sub-chat, so it gets the whole existing chat stack for free - streaming, history, access control, deletion cascade. Agents can also propose concrete rewrites of the response they were spawned from, which land as accept/reject cards.

Builds on #384 (highlight → "Add to Chat"): the same selection popup gains a second action, and an agent is seeded with the excerpt using #384's own quoted- excerpt encoding, so its thread renders the quote with no agent-specific message format.

What changed

Schema (20260826_02_chat_agents.sql)

  • chats gains parent_chat_id (self-FK, on delete cascade), agent_instruction, source_message_id, source_excerpt, and a partial index on parent_chat_id.
  • chat_messages gains edited_at.
  • get_chats_overview and both get_projects_overview overloads exclude agents, so they neither appear in the recent-chats list nor inflate a project's chat count.

Backend

  • POST /chat/create accepts an assignment. Validated: parent reachable, parent is not itself an agent (depth-1), at most six per response.
  • GET /chat/:chatId/agents - the dock's source of truth. status is derived from stored messages (ready / empty), never stored.
  • PATCH /chat/:chatId/messages/:messageId - replaces an assistant message's events and stamps edited_at.
  • PATCH /chat/:chatId/proposals/:proposalId - resolves one proposal card.
  • propose_edit, a chat-scoped tool offered only to agents via a new routeTools adapter on runLLMStream.
  • GET /projects/:projectId/chats excludes agents.

Frontend

  • hooks/assistantStream.ts - AssistantEventBuffer, applyAssistantStreamFrame, consumeAssistantSseStream, extracted out of useAssistantChat so one stream is an object rather than the only stream.
  • hooks/useChatAgents.ts - the concurrent runtime: one buffer and one abort controller per agent id.
  • lib/chatAgents.ts - card labelling, status overlay, proposal application and stale detection, source-message resolution.
  • components/assistant/agents/ - the dock, the side panel (assign + thread), proposal cards, pending-region markers, and useAgentSurface, the single integration point both chat surfaces use.
  • QuoteSelectionPopup gains "Assign to agent".

Why

Reading a long answer, the useful next question is usually about one part of it - "is that clause enforceable?", "tighten this paragraph". Today that means either derailing the main thread or losing the context. Agents let several of those run at once, beside the response, without touching the conversation.

Three decisions are worth calling out.

An agent is a chat. The alternative - a bespoke chat_agents table - would have meant re-deriving streaming, message persistence, access control and deletion for a thing that is a conversation in every respect. Four additive columns buy all of it. The cost is that agents must be filtered out of three listing queries, which this PR does.

propose_edit is gated by absence, not by a check. Rather than a tool that asks "am I in a sub-chat?" at call time, the tool is handed to the turn only when the turn belongs to an agent. A normal chat is never told it exists, which is a stronger guarantee than a runtime branch.

The streaming refactor was necessary, not incidental. useAssistantChat had no module-level state - but it had a single event buffer and a dozen closures writing to "the last assistant message", which is correct while one stream can be open and unfixable-in-place once seven can. Making the per-stream state an ordinary value, and routing the application-touching effects (current chat id, sidebar title, URL) out through explicit handlers, is what lets an agent render exactly like the main transcript without navigating the page out from under the user.

Replication

Base case (before this PR, on olp-pr/chat-highlight-quote)

  1. Open an assistant chat and get a multi-paragraph answer.
  2. Highlight a sentence inside it. The popup offers one action, "Add to Chat".
  3. There is no way to ask a question about that sentence without sending it as a turn in the main conversation.
  4. GET /chat returns every chat the user can see; there is no notion of a chat belonging to another chat.

With this PR

  1. Open an assistant chat and get a multi-paragraph answer.
  2. Highlight a sentence. The popup now offers Assign to agent.
  3. Click it - a side panel opens showing the excerpt and an instruction field. Type something ("is this enforceable?") and press Assign.
  4. A card appears above the composer with a spinner and "Processing"; the agent's answer streams in the panel.
  5. Repeat on a different sentence while the first is still streaming. Both cards spin, both threads fill independently, and the main conversation is still usable.
  6. When an agent answers, its card flips to a green check. If the agent called propose_edit, the card shows a badge with the number of pending edits and the panel shows accept/reject cards.
  7. Accept one: the original response updates in place, gains a "Revised" marker, and the card reads "Applied to the response."
  8. Accept a second proposal whose target text the first one already rewrote: the card reads "This part has changed, so the edit no longer applies" and Accept is disabled. Nothing is written.
  9. Click a card to reopen its thread and reply - the composer continues the agent's conversation.
  10. Reload the page. Cards rebuild from GET /chat/:id/agents. An agent whose stream was interrupted shows "Needs rerun" with a rerun control, not a spinner.
  11. Check the sidebar and the project chat list: no agent appears in either.
  12. Try to assign a seventh agent to the same response - refused, with the cap named in the message.

Tradeoffs and design decisions

  • Pending regions are marked below the response, not underlined inside it. The contract asks for a subtle inline marker. The prose is produced by the markdown renderer, and wrapping a substring in place means splitting text nodes React owns - a reliable way to make a later re-render throw. A row of underlined region chips under the response says the same thing, stays inside React's tree, and gives the region something to click. This is the one deliberate deviation from the contract.
  • Proposal status lives on the event, not in a new table. document_edits is the precedent for a status table, but it exists because an edit is shared across chats and documents. A proposal belongs to exactly one message in one agent's thread, so a table would have added RLS, grants and a lifecycle for no reachability the event does not already have. Cost: resolving a proposal is a read-modify-write of a jsonb array.
  • The client computes the accepted content, and PATCH /chat/:chatId/messages/:messageId stays a generic replace. Cost: the substitution logic lives on the client only. Benefit: the endpoint is one clear thing, and stale detection happens where the user can see it - the card says "this part has changed" before the click, not after a failed request.
  • Accepting is two writes (rewrite the response, then resolve the card), not a transaction. Order is chosen so a failure of the second leaves a card that still says pending over a response that is correct - the recoverable direction. A failed first write resolves nothing at all.
  • The agent panel and the document panel share the right-hand column. Opening an agent hides the document panel; its tabs survive in state, so a citation clicked afterwards re-mounts it unchanged. Splitting the column three ways was the alternative.
  • AgentSidePanel does not join AssistantSidePanel's tab strip. Every tab there is a document version, and an agent thread has no document; sharing would mean making document optional. It borrows the frame, resize handle and glass surface instead.
  • Agents lose ask_inputs. Their thread renders in a narrow panel with a plain composer and no picker to answer with, so offering the tool would strand the conversation.
  • get_projects_overview is re-created in full (both overloads) to fix the chat count. This is ~200 lines of copied SQL and may conflict with in-flight org work that touches the same functions - accepted, because a feature that makes project chat counts wrong is not finished.
  • chatAgents.ts is in lib/ and assistantStream.ts is in hooks/. The former is pure and floor-gated at 100%; the latter is a 25-branch event dispatcher whose exhaustive line coverage would be an unwinnable ratchet in lib/. It is tested through the hook's existing SSE suite plus the new concurrency test.
  • Not in scope: tabular-review chat (its chat is bound to review semantics), the Word add-in, and any team/multi-user semantics beyond the existing chat access rules.

Demo

Recorded live against this branch (dev stack, real Claude responses actually calling propose_edit): highlight → Assign to agent → instruction in the side panel → a second agent assigned while the first shows ready + a 1-pending-edit badge (processing spinner on camera) → the Delaware proposal's Accept rewrites the Governing Law sentence of the original response in place → clicking the first agent's card opens its thread with its own pending proposal.

Parallel agents live demo

Recording the demo also caught a real bug, fixed in the final commit: agent turns sent no model, so every agent ran on the server's default provider regardless of what the parent conversation used - and failed outright on stacks where that provider's key is unconfigured. Agents now inherit the model from the parent's newest model-carrying message, falling back to the composer's persisted selection (the same localStorage value the picker reads), on seeds, panel follow-ups, and reruns alike.

Testing performed

  • npm test --prefix backend - 871 passed, 25 skipped
  • npm run build --prefix backend - clean
  • npm test --prefix frontend - 790 passed across 108 files
  • npm run lint --prefix frontend - 0 errors (34 warnings, down from 36 on the base branch)
  • npm run build --prefix frontend - compiled successfully
  • npm run test:coverage --prefix frontend - 100 / 98 / 100 / 100; floors raised from 99/97/100/100 in the same change
  • Backend coverage ratchet - 56.44 / 50.03 / 57.92 / 57.93; floors raised from 52/46/53/54
  • git diff --check - clean

New tests, by the contract's list:

  • Backend: sub-chat creation validation (depth-1, cap, access, project inheritance, missing instruction); history-list exclusion; agents listing + status derivation (ready / empty, reservations, error-only turns, whitespace-only answers) and pending-proposal counting; propose_edit tool gating (present for an agent, absent for a normal chat); message PATCH guard (unreachable chat, wrong chat, user message, malformed content).
  • Frontend: dock states (processing / ready / needs-rerun, badge singular/plural/hidden, pressed card, live region); assign flow (excerpt shown, empty instruction disabled, Enter vs Shift+Enter, server refusal); proposal accept / reject / stale target / in-flight / resolved; popup's second action including the disabled-at-cap state; and a concurrency test that interleaves two live agent streams frame by frame and asserts neither writes into the other's thread.

The migration was applied twice to a scratch database built from a schema-only dump of a main-shaped Postgres instance. The second pass produced only already exists, skipping notices. Verified against live rows that get_chats_overview returns the parent and not its agent, and that deleting the parent cascades the agent away.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PaBfaTyuVJPTdYkMYd3w2S

Our analysis

Add parallel chat agents for response excerpts — read the full analysis →

Think the analysis missed something the PR description covers?

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-385.md from inside the repo you want the changes in.

⬇ Download capture-pull-385.md