Skip to content

feat(chat): direct-chat PWA (v1) — sessions, SSE streaming, /chat app - #3

Draft
tariqismail wants to merge 24 commits into
rb81:mainfrom
tariqismail:chat-pwa-upstream
Draft

feat(chat): direct-chat PWA (v1) — sessions, SSE streaming, /chat app#3
tariqismail wants to merge 24 commits into
rb81:mainfrom
tariqismail:chat-pwa-upstream

Conversation

@tariqismail

Copy link
Copy Markdown
Contributor

Why

The email interface is great for most tasks, but there are times you want to work side by side with the assistant or quickly run something by it without composing an email and waiting on the job round-trip. This adds a direct-chat fast path for exactly those moments, while leaving the email/job workflow as the path for heavier work.

Summary

Adds an optional, installable /chat PWA with live streaming responses. Lightweight turns are answered directly; anything that needs real work escalates into the existing workspace-job pipeline and the reply folds back into the conversation.

What's included

Backend (by module boundary)

  • chat_store.pychat_sessions / chat_messages tables + store (migration 003_chat_sessions.sql)
  • chat_responder.py — fast-path streaming responder
  • api.py — SSE session/message endpoints; escalation wiring (create_workspace_job gains a source tag, create_manual_job an optional thread_id) so job replies fold back into chat context
  • config.py / config/agent.yamlagent.chat config block (documented in docs/env-overrides.md)

Frontend (agent/frontend/src/chat/, second Vite entry)

  • Preact /chat app: thread grouping, conversation view, composer, live progress, streaming cursor, markdown rendering, design tokens
  • Installable PWA: manifest, service worker, icons; "Chat" link in the admin nav

Scope — this is v1

Deliberately kept focused. Known gaps, likely follow-ups:

  • No way to archive/delete chats from the UI yet
  • No attachments in chat
  • No stop/cancel from the chat interface — to halt an escalated turn you currently stop the underlying job

Tests

  • Python: chat endpoints, store, responder, escalation source, workspace/manual thread wiring, plus test_config / test_ui_pages additions — 51 passed via pytest.
  • Frontend: node --test suite (stream, threads) — 18 passed.
  • npm run build (vite) builds cleanly.

Notes

  • Additive and self-contained — no changes outside the chat feature and its escalation wiring; the UI is only reachable via the new /chat route + admin link.
  • Generated chat.bundle.* are gitignored and not included (mirrors the existing workspace.bundle.js convention).

tariqismail and others added 23 commits July 23, 2026 12:14
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gress

Includes smoke-test fixes: flex min-width for text truncation, poll
sequence guard against duplicate logs, back button hidden on desktop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l, retry 409 follow-ups

The backend writes final_response ~15s before the job flips to completed
(wrap-up work). The UI previously stopped polling at the reply and kept
the composer locked on 'Arqis is working' forever. Now: polling continues
until a terminal status, the composer and working indicators clear as soon
as the reply text exists, and follow-ups sent during wrap-up quietly retry
past the API's 409 instead of failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement data model foundation for direct-chat feature: two new Postgres
tables (chat_sessions, chat_messages) via 003_chat_sessions.sql migration,
plus ChatStore class wrapping raw SQL access to them. All 8 methods specified
by Task 5 interface are implemented. Covered by 6 passing unit tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add three new configuration knobs under agent.chat for the direct-chat
fast path: model (string, empty default), max_history_messages (int,
default 20), and rate_limit_per_minute (int, default 20). Environment
variable overrides and documentation included.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the LLM fast-path for direct chat: turns session history +
new user message into a stream of reply events (delta/escalated/done/error).
Makes streamed OpenRouter chat-completion calls with escalate_to_job tool.
Includes full SSE parsing, tool-call accumulation across chunks, and
fallback to non-streamed LlmClient on stream setup failure.

Includes 11 unit tests covering plain text, split tool args, malformed JSON,
SSE parsing, message building, transcript condensing, and fallback path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires Tasks 1-4 (ChatStore, agent.chat config, chat_responder,
create_workspace_job source tagging) together behind three endpoints:
POST /api/chat/sessions/{session_id}/messages (streaming SSE, rate-limited,
escalates to a workspace job when chat_responder signals it), GET
/api/chat/sessions, and GET /api/chat/sessions/{session_id}/messages.
Also folds chat_messages costs into usage_cost_summary()'s lifetime/month
totals alongside task_logs and deep_research_events.
- parseSSE async generator consumes ReadableStream body and yields JSON events
- Correctly handles SSE frames split across multiple stream reads
- Skips blank/comment lines per SSE spec
- streamChatMessage POSTs to /api/chat/sessions/{sessionId}/messages
- Throws ApiError on non-2xx responses, matching existing request() pattern

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Appended listChatSessions() and getChatSessionMessages(sessionId) to
agent/frontend/src/chat/api.js for listing chat sessions and fetching
transcript messages. Both use the existing request() helper, matching
the style of listJobs and pollJob.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds mergeConversations(sessions, jobs) to combine chat sessions and job
threads into a unified, activity-sorted conversation list. Includes session
helpers (sessionTitle, sessionProcessing, sessionSnippet, sessionStatus)
mirroring job-thread accessors. Sorts by most-recent activity using each
session's last_message_at or job thread's latest job created_at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…reads

New conversations now always go through streamChatMessage (POST
/api/chat/sessions/{id}/messages) instead of the old createJob/sendFollowUp
workspace-job endpoints, which are no longer called from the UI at all.

- Unified thread list merges chat sessions and legacy job threads via
  mergeConversations, sorted newest-activity-first.
- Legacy job threads (metadata.source === "workspace") render read-only:
  full history + live-poll-to-terminal as before, composer replaced with a
  static notice.
- Chat session messages render as plain bubbles (kind: "chat") or job turns
  with progress steps + reply (kind: "job_ref"), reusing the existing
  /api/jobs/{id}/poll loop.
- Single pollingJobId derivation covers both a legacy thread's active job
  and a session's active job_ref, feeding one poll effect instead of two.
- Composer disables only while the latest job_ref in the active session is
  still processing without a final_response (awaitingSessionReply).
- Streaming replies show token-by-token via Bubble's streaming prop while
  accumulating, then settle into a persisted bubble once the transcript is
  refetched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…take them for its own

Prefix job final_response content with "[Completed by the full task pipeline]"
when folding it into job_ref history rows, and tell the quick-chat model (via
QUICK_CHAT_INSTRUCTIONS) to treat such prefixed turns as real completed actions
rather than something it said. Fixes a live bug where the tool-less quick-chat
model saw an unmarked assistant turn describing a tool outcome, concluded it
had hallucinated, apologized, and needlessly re-escalated a duplicate job.
Chat already links back to Admin via its panel header — this completes
the round trip so you can jump to /chat from the dashboard too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@rb81

rb81 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

@tariqismail - I've gone through the chat PWA PR in detail — really solid work. The architecture is clean: quick replies stay fast, escalation to the job pipeline is seamless, and the job results folding back into conversation context is a nice touch. Code quality is high across the board (streaming state machine, SSE edge-case handling, DOMPurify for rendering, proper rate limiting, conflict detection on active jobs).

A few small things I noticed during review:

  1. condense_transcript hardcodes "Arqis" as the assistant label (~line 42 in chat_responder.py). Not a blocker since it's only internal LLM context, but worth pulling from app_display_name(config) for consistency if you get a chance.

  2. No index on chat_messages(role, created_at) for the rate-limit query. Fine at current scale, but might be worth adding to the migration while it's fresh — cheaper now than later.

  3. Blocking urllib in the streaming generator — totally acceptable for v1/single-user, just flagging it as something to revisit if we ever need concurrent chat streams.

None of these are blockers. The tests are thorough, the feature is well-scoped, and the known gaps (no archive/delete, no attachments, no cancel) are all reasonable v1 trade-offs.

When you're ready to take it out of draft, I'm happy to merge it. Just let me know if there's anything else you're still iterating on.

Nice one 👍

Addresses review feedback on rb81#3:

- condense_transcript() hardcoded Arqis as the assistant label. It now
  takes an optional config and derives the label from app_display_name(),
  falling back to a neutral Assistant when no config is passed.
- The chat rate-limit query (ChatStore.count_recent_user_messages) filters
  on role + created_at, which no index covered — it seq-scanned. Adds
  chat_messages(role, created_at) to migration 003 while it is still
  unreleased. Verified against Postgres: seq scan -> index-only scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tariqismail

Copy link
Copy Markdown
Contributor Author

All three looked at — pushed in cd887c4.

1. Hardcoded assistant label — fixed. condense_transcript now derives the label from app_display_name(config), and the upstream caller in api.py passes config through. I made the parameter optional with a neutral "Assistant" fallback rather than making config required: the function is otherwise pure, and a required positional would break callers that legitimately have no config in scope. Two tests cover both paths (configured name, and the no-config default).

2. Index — fixed, and it was measurable. Added chat_messages(role, created_at) to migration 003 while it is still unreleased. Verified against Postgres with the actual rate-limit query from ChatStore.count_recent_user_messages:

before:  Seq Scan on chat_messages  (cost=0.00..33.08)
after:   Index Only Scan using chat_messages_role_created_idx  (cost=0.28..8.30)

Worth noting for anyone already running this branch: migrations are tracked by filename in schema_migrations, so editing 003 in place only helps fresh installs. Existing deployments need the CREATE INDEX run separately.

3. Blocking urllib — agreed it's real, deliberately not changing it here. Making the streaming generator non-blocking means either an async HTTP client or moving the read to a thread, and both ripple into the SSE endpoint and its tests. That's a meaningful refactor to carry for a concurrency requirement that doesn't exist yet at single-user scale, so I'd rather it land on its own merits when concurrent chat streams are actually needed than bolt it onto this PR. Tracked on my side.

Test suite: 220 passing, up from 218, no regressions.

Separately, while verifying the above I hit a pre-existing failure that also reproduces on clean main, unrelated to this PR: PollEndpointShapeTest::test_poll_endpoint_returns_404_for_missing_job. Root cause is that api.py calls db.ensure_feature_schema() at import time, so importing assistant_agent.api tries to open a real Postgres connection — whether the test passes depends on pytest's collection order (it passes when that file is run alone, fails in the full suite). Happy to open a separate issue or PR for it if useful.

Still marked draft — say the word and I'll flip it to ready.

@rb81
rb81 marked this pull request as ready for review September 6, 2026 07:31
@rb81
rb81 marked this pull request as draft September 6, 2026 11:16
@rb81

rb81 commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Thanks for the chat work. I’m not merging this — two of these will break a fresh deploy or leak every chat transcript.

High — 003_chat_sessions.sql never runs on the API service

api.py only calls ensure_feature_schema(). FEATURE_SCHEMA_SQL does not create chat_sessions / chat_messages. main.py returns immediately after uvicorn.run(...) when AGENT_ROLE=api, so it never reaches run_migrations(). Compose agent-api is AGENT_ROLE: api.

Fresh deployments will fail as soon as chat endpoints are hit. The API process needs to apply file migrations (same path as the other roles), and that needs a test so it doesn’t regress.

High — no ownership or auth on chat

GET /api/chat/sessions lists every session. Get/send are ID-only. There is no owner column and no check. Compose still sets AGENT_API_ALLOW_PUBLIC_BIND=true.

The rest of the API being unauthenticated does not make this acceptable. Chat is a persistent inbox: anyone who can reach the port can read and append every conversation.

Please add an ownership model with tests that two clients cannot list, read, or append each other’s sessions. Unknown sessions should 404, not leak existence. I don’t need a specific design — cookie-scoped owner token, shared API token, or something else — as long as that invariant holds.

Medium — truncated upstream SSE is treated as a completed reply

_consume_stream yields {"type": "done"} when finish_reason is missing. chat_message_events then persists whatever was accumulated as a successful assistant message.

A dropped upstream connection must yield error and must not insert a completed row. Add a responder/endpoint test for a stream that ends without finish_reason.

Medium — frontend clears the draft after streamed errors

send() always setDraft("") after streamChatMessage returns. HTTP failures already preserve the draft via catch; SSE error events do not throw, so the user’s message is wiped.

Treat error (and a stream that ends without done/escalated) as failure and keep the draft. Add a frontend test for that.

Frontend tests passed here; I could not run the backend suite in the review environment. GitHub shows the PR as mergeable with no check runs — please make sure CI actually runs these tests.

Happy to re-review once those four are addressed.

@tariqismail

Copy link
Copy Markdown
Contributor Author

All four reproduce. I checked each against the branch before writing this, and I'm not disputing any of them:

  1. main.py hits return right after uvicorn.run(...) for role == "api", so the run_migrations() call below it is unreachable — and FEATURE_SCHEMA_SQL creates neither chat table.
  2. list_sessions(limit) has no owner filter, both message endpoints are ID-only, and 003 has no owner column.
  3. _consume_stream yields done unconditionally — the finish_reason check only distinguishes tool_calls — and the done branch commits accumulated through create_message.
  4. error events don't throw, so execution reaches setDraft(""); the catch that preserves the draft is never entered.

One nuance on (1), which I think supports your point rather than softening it: in the full compose stack the tables do get created, because every non-api role falls through to run_migrations() and task-agent creates them as a side effect. So it presents as a startup race there, while an api-only deployment is broken outright. Either way the API is relying on another service to create its tables, which I don't want to leave standing.

Before I start, four things I'd rather settle than guess at:

Migrations (1). Inline run_migrations() in the api branch of run_role, or a separate one-shot migration step the services depend on? Inline is the smaller diff and matches the other roles, but it means a web process mutating schema at startup, which gets awkward if the API is ever replicated. Related: do you want db.ensure_feature_schema() moved off module scope in api.py at the same time? Importing assistant_agent.api currently opens a real Postgres connection — the same root cause as the PollEndpointShapeTest order-dependence I mentioned earlier. Natural to fix together, but it widens this PR.

Ownership (2). The one where guessing costs most:

  • One owner across several devices, or genuinely multiple distinct users? A single owner is satisfied by a shared token; multiple users needs per-identity scoping, and the two give different schemas.
  • Existing sessions have no owner. Is a breaking change fine (drop or orphan them), or should they be adopted by the first authenticated caller?
  • Is chat a special case, or the first step toward scoping the rest of the API? Jobs and workspace are equally reachable on that port today, so if this is the start of an API-wide boundary I'd rather build it that way once than retrofit chat-shaped auth twice.
  • Should AGENT_API_ALLOW_PUBLIC_BIND stop defaulting to true as part of this?

Truncated stream (3). "Must not insert a completed row" — discard the partial text entirely, or persist it as an explicitly incomplete row so the user can see what arrived and retry? And on the client: leave the partial text on screen alongside the error, or clear it?

CI (4). There's no workflow in the repo at all — no .github/, and nothing for any other CI system on main. So nothing stopped running; it has never existed. Do you want me to add one here (backend + frontend suites on push/PR), or set it up yourself given it's repo-wide rather than specific to this branch?

Holding implementation until I hear back. Three of these are small enough that I'd have pushed them already, but (2) is a schema plus a trust boundary and I'd rather not build the wrong one twice.

@rb81

rb81 commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Good questions. In order:

1 — Migrations. Inline run_migrations() in the api branch of run_role, before the uvicorn.run() call. Matches the other roles, smallest diff, and replication is not on the roadmap. If it ever is, we'd move to a dedicated migration container anyway — no point designing for that now.

Leave ensure_feature_schema() on module scope for this PR. You're right that it's worth fixing, but it's a separate concern and I don't want this PR to grow. File an issue or a follow-up PR for it if you want.

2 — Ownership. This is a single-owner personal assistant. One user, multiple devices. A shared secret token is the right fit — no per-identity scoping, no user accounts.

  • Config-defined token (env var or agent.yaml), checked on chat endpoints. Simple Authorization: Bearer <token> or a cookie set on first load — your call, just make sure it's constant across devices and not generated per-session.
  • Existing sessions: adopt them. They belong to the owner. No migration needed beyond adding the check to the endpoints.
  • Chat only. Jobs and workspace are equally open, but that's a separate project. Scoping the entire API is not the goal of this PR. Just make sure a client without the token cannot list, read, or append chat sessions.
  • Leave AGENT_API_ALLOW_PUBLIC_BIND alone. It's a Compose default for Docker networking, not a chat auth decision.

3 — Truncated stream. Discard the partial text — do not persist an incomplete row. On the client: keep the partial text visible so the user can see what arrived, show the error alongside it, and preserve the draft in the composer so they can retry. The key constraint is that the database should not contain a row that looks like a completed reply when it isn't one.

4 — CI. Not this PR. It's repo-wide and I'll set it up separately. Just make sure the existing test suites pass locally before pushing.

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