Feat notification system (#2472)

* feat: SSE notification system

Adds a per-user SSE pipe (GET /api/events) plus a per-message
chat-stream reconnect endpoint (GET /api/messages/<id>/events).

Backend substrate:
- application/events/ — durable journal (Redis Streams) + live
  pub/sub for user-scoped events, with publish_user_event() as
  the worker-side entrypoint.
- application/streaming/ — broadcast_channel for pub/sub fanout
  and event_replay for the per-message snapshot+tail path.
- application/storage/db/repositories/message_events.py +
  alembic 0007 — Postgres journal for chat-stream events.
- application/worker.py — ingest/reingest/remote/connector/
  attachment/mcp_oauth tasks publish queued/progress/completed/
  failed envelopes alongside their existing status updates.

Frontend client:
- frontend/src/events/ — connect/reconnect, Last-Event-ID cursor,
  backoff with jitter. Each tab runs its own connection; no
  cross-tab dedup (future work).
- frontend/src/notifications/ — recentEvents ring, cursor
  tracking, tool-approval toast.
- frontend/src/upload/uploadSlice.ts — extraReducers for
  source.ingest.* and attachment.* events.

Coverage: 132 SSE tests across events substrate, replay, journal,
routes, and worker publishes.

* refactor(attachments): remove polling, SSE-only

frontend/src/components/MessageInput.tsx no longer runs a 2s
setInterval against getTaskStatus for every processing
attachment. The attachment.* SSE reducers in uploadSlice.ts are
now the sole driver of attachment state transitions.

* feat(connector): consume source.ingest.* SSE, remove polling

frontend/src/components/ConnectorTree.tsx now mirrors FileTree's
slice-walking pattern: it watches notifications.recentEvents
for source.ingest.{completed,failed} envelopes matching the
sync's source id, and no longer polls /task_status every 2s.

* refactor(source-ingest): remove polling, SSE-only

frontend/src/upload/Upload.tsx and
frontend/src/components/FileTree.tsx no longer run getTaskStatus
polling fallbacks. The source.ingest.* SSE reducers in
uploadSlice.ts and FileTree's slice walk are now the sole
drivers of upload/reingest state transitions.

* refactor(mcp-oauth): carry authorization_url in SSE, remove polling

application/worker.py::mcp_oauth now publishes
authorization_url on the mcp.oauth.awaiting_redirect envelope.
frontend/src/modals/MCPServerModal.tsx consumes it from SSE
instead of polling /oauth_status/<task_id> every 1s.

The URL is generated inside DocsGPTOAuth.redirect_handler when
the FastMCP client triggers OAuth. The worker now plumbs a
publish callback through tool_config -> MCPTool -> DocsGPTOAuth
so the awaiting_redirect publish fires from inside the handler
at the exact point the URL becomes known. The legacy Redis
mcp_oauth_status setex writes and the GET
/api/mcp_server/oauth_status/<task_id> endpoint are kept as
belt-and-suspenders; nothing in the frontend reads them now.

* feat(source-ingest): plumb limited flag through SSE for token-cap UX

application/worker.py::ingest_worker and remote_worker now publish
``limited: bool`` on the source.ingest.completed envelope.
uploadSlice routes ``payload.limited === true`` to a failed status
with a ``tokenLimitReached`` flag, and UploadToast surfaces the
translated tokenLimit i18n string. No worker code path sets
limited=true today; this is a forward-looking contract so when
token-cap detection lands, the UX is already wired.

* refactor(mcp-oauth): read status from SSE journal, drop polling endpoint

MCPOAuthManager.get_oauth_status now walks the per-user SSE Streams
journal (user:{user_id}:stream) for the latest mcp.oauth.* envelope
matching the task id, returning the status string derived from the
event type suffix and the payload fields. The worker is the single
source of truth — its publish_user_event calls write the same
record the SSE client receives live.

Removed:
- /api/mcp_server/oauth_status/<task_id> route in
  application/api/user/tools/mcp.py
- mcp_oauth_status worker function and mcp_oauth_status_task Celery
  wrapper
- All mcp_oauth_status:{task_id} Redis setex writes (4 in mcp_oauth,
  2 in DocsGPTOAuth.redirect_handler / callback_handler)
- The update_status closure in mcp_oauth that wrote the polling
  payload

Tests updated:
- get_oauth_status now takes (task_id, user_id); new coverage walks
  a fake xrevrange response for the completed envelope, the no-match
  case, and a Redis-down case
- Removed TestMCPOAuthStatus route tests and TestMcpOauthStatusTask
  celery-wrapper test
- Removed the two oauth_status methods from the integration runner

mcp_oauth:auth_url/state/code/error Redis keys remain — they are
the OAuth flow's own state (not the dropped polling payload).

* chore(mcp-oauth): delete orphaned getMCPOAuthStatus client

The /api/mcp_server/oauth_status/<task_id> endpoint was removed in
the prior commit; the corresponding userService method and the
MCP_OAUTH_STATUS endpoint constant had no remaining callers in the
frontend, so they're deleted along with it.

* fix(events): drop live publish when journal write fails

application/events/publisher.py returned an envelope to live
pubsub subscribers even when the XADD to the durable journal
failed. The envelope had no ``id`` field, which bypassed the SSE
route's dedup floor and broke ``Last-Event-ID`` semantics for any
reconnecting client.

Best-effort delivery means dropping consistently, not delivering
inconsistent state. Now: if the journal write fails the publisher
returns None and skips the live publish entirely.

* fix(notifications): dedupe sseEventReceived against immediate dupes

Snapshot replay + live tail can both deliver the same id when the
live pubsub frame and the replay XRANGE overlap. The route's own
dedup floor catches the common case, but consumers walking
``recentEvents`` (FileTree, ConnectorTree, MCPServerModal,
ToolApprovalToast) would otherwise act on the same envelope
twice when a duplicate slipped through.

Belt-and-suspenders: short-circuit when the most recent id in
the ring matches the incoming one.

* fix(events): skip replay budget INCR when no snapshot work possible

_allow_replay incremented the per-user counter on every
/api/events GET, including no-op connects from a fresh client
with no cursor against an empty backlog. React StrictMode dev
double-mounts plus a few tabs trivially tripped the default
30-per-60s budget on idle reconnects.

XLEN pre-check: when last_event_id is None and the user stream
is empty, the connect can't do snapshot work — return True
without INCR. Cursor-bearing connects still INCR unconditionally
(probing the cursor's relationship to stream contents would
require a redundant XRANGE).

* fix(streaming): tighten journal contract + recover from seq collisions

Two related fixes to application/streaming/message_journal.py.

1. record_event now rejects non-dict payloads at the gate. The
   live path (base.py::_emit) wrapped non-dicts as
   {"value": payload}; the replay path in event_replay synthesized
   {"type": event_type}. A reconnecting client would receive a
   different envelope than the one originally streamed. Now both
   paths see byte-identical envelopes because non-dicts can't be
   journaled at all. The corresponding event_replay fallback is
   replaced with a warn-and-skip for any legacy rows.

2. record_event handles IntegrityError on (message_id, sequence_no)
   collisions by reading latest_sequence_no and retrying once with
   latest+1. The most likely cause is a stale seq seed on a
   continuation retry where the route read MAX(seq) from a
   separate connection before another writer committed past it.
   Previously the error was swallowed and the event silently
   dropped from the journal; now it lands at the next available
   seq. The live pubsub publish uses the materialised seq so the
   journal row and the live frame agree.

* perf(streaming): batch message_events INSERTs per stream

complete_stream previously opened a fresh db_session() per yielded
event, doing one Postgres INSERT + commit per chunk on the WSGI
thread. Streaming answers emit ~100s of answer chunks per response,
so the route was paying ~100 PG roundtrips per stream serialized on
commit latency.

New BatchedJournalWriter in application/streaming/message_journal.py
accumulates rows per stream and flushes on three triggers:
- size: buffer reaches 16 entries
- time: 100ms elapsed since the last flush
- lifecycle: close() at end-of-stream

Live pubsub publishes still fire synchronously per record(), so
subscribers see events in real time — only the durable journal write
is amortized. On bulk INSERT IntegrityError the writer falls back to
per-row record() with the existing seq+1 retry so a single colliding
seq doesn't drop the rest of the batch.

complete_stream wires journal_writer.close() into every exit path
(happy end, tool-approval-paused end, GeneratorExit, error handler)
so the terminal event is committed before the generator returns —
otherwise a reconnecting client could snapshot up to the last flush
boundary and live-tail waiting for an end that's still in memory.

Repository gets bulk_record() — one SQLAlchemy executemany INSERT
for the bulk path. All-or-nothing on collision (Postgres aborts the
whole batch); the writer's per-row fallback handles recovery.

* chore(upload): drop dead UploadTask.lastEventAt field

The lastEventAt field on UploadTask had no remaining consumers — the
matching Attachment.lastEventAt was cleaned up earlier. Remove the
field declaration and the slice write site.

* chore(frontend): drop orphaned getTaskStatus client

After the polling-removal sweep no caller in frontend/src/ references
userService.getTaskStatus or endpoints.USER.TASK_STATUS. The backend
route /api/task_status itself stays — agents, webhooks, e2e specs,
and the public docs still depend on it.

* docs(repo): remove stale planning docs from repo root

notification-channel-design.md, plan.md, and reminder-tool-design.md
were leftover Claude planning artifacts from the SSE substrate work
that landed accidentally. CLAUDE.md prohibits creating planning docs
unless asked — delete them.

* docs(message-events): clarify repo vs wrapper payload contract

MessageEventsRepository.record accepts any JSONB-compatible value; the
streaming wrapper record_event tightens this to dicts only because the
live and replay paths reconstruct non-dict payloads differently. Spell
the split out so the next reader of the repo method doesn't assume the
wrapper's contract applies here.

* refactor(events): raise on malformed stream id instead of lex fallback

stream_id_compare's lex-fallback branch was a footgun: a malformed id
that sorts lex-greater than a real one would pin live-tail dedup
forever, dropping every subsequent legitimate event silently. Both
current callers in application/api/events/routes.py pre-validate
inputs against _STREAM_ID_RE before calling, so changing the function
to raise ValueError is a no-op on the happy path and turns the future-
caller footgun into a loud failure.

* test(tasks): cover cleanup_message_events task body

Adds skipped-when-no-POSTGRES_URI and happy-path coverage for the
Celery janitor. The skipped path returns the documented short-circuit
shape without touching the repo. The happy path seeds a backdated
row, runs the task against the pg_conn fixture, and asserts the
retention window's row is deleted while in-window rows survive.
Mirrors the TestCleanupPendingToolState pattern.

* fix(notifications): treat /c/new as no current conversation

useMatch('/c/:conversationId') treats the literal URL /c/new as a
real conversation id, so the toast suppression check confused
'user is on /c/new' with 'user is on the conversation needing
approval'. Explicit guard: when the matched id is 'new', fall
through to the no-match case so approval toasts still surface.

* docs(events): enumerate publish_user_event None-return paths

The function returns Optional[str] today, with None conflating five
distinct outcomes (missing args / push disabled / unserialisable /
Redis down / XADD failed). Every current call site is fire-and-
forget and ignores the return, so the right move is to document the
five cases rather than promote to an enum return — keeps the API
small while making the diagnostic surface (logs) obvious. If a
future caller needs to react differently per reason, promote then.

* refactor(sources): move source-id derivation out of worker module

application/api/user/sources/upload.py imported _derive_source_id
from application.worker — pulling the entire Celery worker module
into the API process at import time just for a two-line helper.

Move DOCSGPT_INGEST_NAMESPACE and the derivation function to a
new application/storage/db/source_ids.py module that both layers
can import without that dependency edge. worker.py re-exports the
old names (_derive_source_id, DOCSGPT_INGEST_NAMESPACE) for
backward-compatible imports from tests and any other in-tree
callers; new code should import from the new module directly.

* fix(cache): enable Redis health_check_interval to surface half-open TCP

Without health_check_interval, a half-open TCP socket (NAT silently
dropped state, ELB idle-close) can leave pubsub.get_message hanging
past the SSE generator's keepalive cadence — the kernel never
surfaces the dead socket because no payload is in flight. Setting
health_check_interval=10 makes redis-py ping every 10s when
otherwise idle, so the next get_message after the dead window
raises and the SSE loop falls into its reconnect path instead of
silently freezing on the user.

* chore(events): rename attachment.processing.progress to attachment.progress

The event-type taxonomy was inconsistent: source ingest emits
source.ingest.progress (three segments) while attachments emitted
attachment.processing.progress (four segments). Drops the
.processing. infix for parity. Worker publish sites, the slice
reducer's match, and the worker tests all flip together.

No external consumers — the event type is purely internal between
the publisher and the in-tab slice; safe to rename in one commit.

* feat: events cleanup

* fix: better docs

* fix: e2e tests
This commit is contained in:
Alex authored and GitHub committed 2026-05-15 12:23:31 +01:00
1 parent 4d6f360e3a
commit e351f45d88
135 files changed
+14394 -1253

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
/**
* Phase 2 helper — shared agent provisioning for specs that need a
* published agent (with a real api_key) for subsequent /stream or /search
* Shared agent provisioning for specs that need a published agent
* (with a real api_key) for subsequent /stream or /search
* calls. A PUBLISHED classic agent requires name, description, chunks,
* retriever, prompt_id AND a source — otherwise `/api/create_agent`
* returns 400.
-1
View File
@@ -1,5 +1,4 @@
/**
* Phase 1 helper — see e2e-plan.md §P1-B.
* Pre-authenticated Playwright APIRequestContext pointed at the e2e Flask.
*/
-1
View File
@@ -1,5 +1,4 @@
/**
* Phase 1 helper — see e2e-plan.md §P1-B.
* JWT signing + per-test BrowserContext seeding for DocsGPT e2e.
*/
-1
View File
@@ -1,5 +1,4 @@
/**
* Phase 1 helper — see e2e-plan.md §P1-B.
* Thin pg wrapper + typed row helpers for DB assertions in specs.
*/
-1
View File
@@ -1,5 +1,4 @@
/**
* Phase 1 helper — see e2e-plan.md §P1-B.
* Per-test TRUNCATE — preserves `alembic_version`, wipes every other table.
*/
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 2 helper — shared streaming primitives.
* Shared streaming primitives for SSE specs.
*
* The backend exposes two streaming endpoints:
* - POST /stream (answer_ns path="/" + route "/stream") — SSE body
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 3 helper — upload / task_status polling primitives for Tier-B specs.
* Upload / task_status polling primitives shared across upload specs.
*
* These helpers wrap the two patterns that recur across B1/B3/B4/B5:
*
+1 -1
View File
@@ -2,7 +2,7 @@
// mock LLM, Postgres reset) is handled by `scripts/e2e/up.sh` and
// `scripts/e2e/down.sh`. Playwright's built-in `webServer` can only manage one
// process and would fight with the four-service native setup this suite needs.
// See `e2e-plan.md` → "Phase 0 — Foundation" → P0-A for the orchestration
// See `e2e-plan.md` → "Foundation" → P0-A for the orchestration
// contract.
import { defineConfig, devices } from '@playwright/test';
+4 -3
View File
@@ -1,8 +1,9 @@
/**
* Phase 1 — P1-C · cross-user isolation.
* P1-C · cross-user isolation.
*
* Covers the Phase 1 acceptance criterion: two freshly-minted users (A and
* B) must not see each other's writes. User A creates a prompt; user B's
* Covers the cross-user isolation acceptance criterion: two
* freshly-minted users (A and B) must not see each other's writes.
* User A creates a prompt; user B's
* `/api/get_prompts` must not include it, and the DB must report zero
* `prompts` rows for user B.
*
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 1 — P1-C · session_jwt auth flow.
* P1-C · session_jwt auth flow.
*
* Covers e2e-plan.md §7.2: with `AUTH_TYPE=session_jwt` the backend issues a
* fresh token via `/api/generate_token` on first load, the frontend stashes
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 1 — P1-C · simple_jwt auth flow.
* P1-C · simple_jwt auth flow.
*
* Covers e2e-plan.md §7.3: with `AUTH_TYPE=simple_jwt` the backend does NOT
* issue tokens — the frontend shows `JWTModal`, the user pastes a
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 2 — P2-11 · agent pin/unpin.
* P2-11 · agent pin/unpin.
*
* Writes to `users.agent_preferences.pinned` (JSONB text[]) via
* `POST /api/pin_agent?id=<agent_id>`. Reads via `GET /api/pinned_agents`.
@@ -1,5 +1,5 @@
/**
* Phase 2 — P2-12 · Cross-user agent share.
* P2-12 · Cross-user agent share.
*
* Writes to `users.agent_preferences.shared_with_me` (JSONB) and
* `agents.shared_token` (CITEXT UNIQUE). Exercises the full cross-user
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 2 — P2-10 · Agent CRUD (classic type).
* P2-10 · Agent CRUD (classic type).
*
* Writes to `agents`, reads `prompts`, touches `users`. Exercises the full
* create→publish→update→delete lifecycle plus cross-tenant isolation and a
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 2 — P2-04 · Token usage / message / feedback analytics.
* P2-04 · Token usage / message / feedback analytics.
*
* Silent-break covered: analytics bucket keys use YYYY-MM-DD HH24:00 format
* (Postgres `to_char`). A drift to the Python `%Y-%m-%d %H:00` strftime
+3 -3
View File
@@ -1,7 +1,7 @@
/**
* Phase 2 — P2-14 · Attachment upload bound to conversation message.
* P2-14 · Attachment upload bound to conversation message.
*
* Writes to the Tier-1 `attachments` table (via `/api/store_attachment` → Celery
* Writes to the `attachments` table (via `/api/store_attachment` → Celery
* worker `attachment_worker` → `AttachmentsRepository.create`) and, via the
* subsequent `/stream` call, to `conversation_messages.attachments[]`
* (UUID[]).
@@ -32,7 +32,7 @@
* flow is flakier here than a raw multipart POST, and the P2 brief explicitly
* permits API-direct when the upload surface is Celery-dispatched). DB
* assertions on BOTH `attachments` and `conversation_messages.attachments[]`
* are non-negotiable per the Phase 2 invariant.
* are non-negotiable per the migration invariant.
*/
import { readFile } from 'node:fs/promises';
+3 -3
View File
@@ -1,7 +1,7 @@
/**
* Phase 2 — P2-07 · chat turn save (the HOTTEST migration path).
* P2-07 · chat turn save (the HOTTEST migration path).
*
* A single /stream call is responsible for writes to FOUR Tier-1 tables:
* A single /stream call is responsible for writes to FOUR core tables:
*
* - `conversations` (1 row per new conversation)
* - `conversation_messages` (1 row per turn — prompt AND response in the
@@ -186,7 +186,7 @@ test.describe('tier-a · chat turn save', () => {
await resetDb();
});
test('single chat turn via UI writes to all four Tier-1 tables and reload rehydrates the conversation', async ({
test('single chat turn via UI writes to all four core tables and reload rehydrates the conversation', async ({
browser,
}) => {
const { context, sub } = await newUserContext(browser);
@@ -1,5 +1,5 @@
/**
* Phase 2 — P2-08 · Conversation CRUD (Tier-A migration-critical).
* P2-08 · Conversation CRUD (Tier-A migration-critical).
*
* Covers e2e-plan.md §P2 row P2-08: writes/deletes against `conversations`
* and the cascading child tables (`conversation_messages`,
+9 -9
View File
@@ -1,14 +1,14 @@
/**
* Phase 2 — P2-05 · Message feedback.
* P2-05 · Message feedback.
*
* Writes to the JSONB `conversation_messages.feedback` column. The separate
* `feedback` table planned in early Phase 1 was dropped during Phase 3
* hardening — see migration-postgres.md §2.2:
* "A dedicated `feedback` table was originally planned for this tier
* (and built in early Phase 1) but dropped during Phase 3 hardening:
* per-message feedback lives on `conversation_messages.feedback` (JSONB)
* via `ConversationsRepository.set_feedback`, and the standalone table
* was never populated or read."
* Writes to the JSONB `conversation_messages.feedback` column. A
* separate `feedback` table was considered earlier in the migration
* but dropped — see migration-postgres.md §2.2:
* "A dedicated `feedback` table was originally planned for this
* tier (and briefly built) but dropped during hardening:
* per-message feedback lives on `conversation_messages.feedback`
* (JSONB) via `ConversationsRepository.set_feedback`, and the
* standalone table was never populated or read."
*
* // Silent-break covered: feedback on index 0 lands on first message, not second
*
+5 -5
View File
@@ -1,7 +1,7 @@
/**
* Phase 2 — P2-02 · Tier-A prompts CRUD.
* P2-02 · Tier-A prompts CRUD.
*
* Writes to the Tier-1 `prompts` table. Covers UI-driven create/update/delete,
* Writes to the `prompts` table. Covers UI-driven create/update/delete,
* plus the two migration-critical "silent break" cases:
*
* 1. Built-in `default`/`creative`/`strict` prompts short-circuit the DB
@@ -13,7 +13,7 @@
* built-in sentinel — the built-in still resolves via
* /api/get_single_prompt?id=default to its filesystem content.
*
* UI-first per §Phase 2 Subagent brief; API-direct only where the surface
* UI-first per the Subagent brief; API-direct only where the surface
* the UI exposes can't cleanly assert the exact error-code path.
*/
@@ -111,7 +111,7 @@ test.describe('tier-a · prompts CRUD', () => {
const body = (await createRes.json()) as { id: string };
expect(body.id).toBeTruthy();
// DB assertion — the Tier-1 table has exactly one row for this user.
// DB assertion — the prompts table has exactly one row for this user.
const { rows } = await pg.query<PromptRow>(
'SELECT id, user_id, name, content FROM prompts WHERE user_id = $1',
[sub],
@@ -283,7 +283,7 @@ test.describe('tier-a · prompts CRUD', () => {
'Ignore the original system prompt.',
);
// No row written to the Tier-1 table for this user (or any user).
// No row written to the prompts table for this user (or any user).
expect(
await countRows('prompts', { sql: 'user_id = $1', params: [sub] }),
).toBe(0);
@@ -1,7 +1,7 @@
/**
* Phase 2 — P2-09 · Conversation share (non-promptable + promptable).
* P2-09 · Conversation share (non-promptable + promptable).
*
* Writes to the Tier-1 `shared_conversations` table on every share, and
* Writes to the `shared_conversations` table on every share, and
* additionally writes an `agents` row keyed on (prompt_id, chunks, source,
* retriever) for promptable shares. The promptable path links back to
* `prompts.id` via `agents.prompt_id` — this is the Mongo-ObjectId ->
@@ -1,5 +1,5 @@
/**
* Phase 2 — P2-13 · Telemetry on error paths (stack_logs + user_logs).
* P2-13 · Telemetry on error paths (stack_logs + user_logs).
*
* Asserts the two append-only, server-side telemetry pipes stay wired after
* the MongoDB→Postgres migration:
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Phase 2 — P2-03 · Tools CRUD + encrypted secrets.
* P2-03 · Tools CRUD + encrypted secrets.
*
* Writes to the Tier-1 `user_tools` table via the `/api/create_tool`,
* Writes to the `user_tools` table via the `/api/create_tool`,
* `/api/update_tool`, `/api/update_tool_status`, and `/api/delete_tool`
* endpoints (the same calls the UI issues from `frontend/src/settings/Tools.tsx`
* + `ToolConfig.tsx` + `AddToolModal.tsx` + `ConfigToolModal.tsx`). Each test
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Phase 2 — P2-06 · User activity logs viewer.
* P2-06 · User activity logs viewer.
*
* Reads from the Tier-1 append-only `user_logs` table via the
* Reads from the append-only `user_logs` table via the
* `POST /api/get_user_logs` endpoint (the call the UI issues from
* `frontend/src/settings/Logs.tsx` → `userService.getLogs`). Each log row is
* written server-side by `complete_stream` in
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 3 Tier-B · agent preview does NOT persist conversations
* Tier-B · agent preview does NOT persist conversations
*
* Covers B14. The preview flow in `frontend/src/agents/agentPreviewSlice.ts`
* calls `handleFetchAnswerSteaming(..., save_conversation=false)` — see the
+1 -1
View File
@@ -1,4 +1,4 @@
// Phase 3 Tier-B · B15 artifact sidebar (todos / notes / memory) + legacy fallback.
// Tier-B · B15 artifact sidebar (todos / notes / memory) + legacy fallback.
/**
* The UI's artifact sidebar (`frontend/src/components/ArtifactSidebar.tsx`)
* opens on a question that triggered a tool call that wrote a `note` or a
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 3 Tier-B · chunks (B3) — get / add / update / delete chunks.
* Tier-B · chunks (B3) — get / add / update / delete chunks.
*
* Exercises `application/api/user/sources/chunks.py`. All chunk endpoints go
* through `get_vector_store(str(doc["id"]))` (application/api/user/base.py:
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 3 Tier-B · agent folders CRUD + nesting + move/bulk-move
* Tier-B · agent folders CRUD + nesting + move/bulk-move
*
* Covers B10: create/list/rename/delete folders, nest via parent_id, cycle
* prevention, and single/bulk agent-move into folders. API-only — folder
+1 -1
View File
@@ -1,4 +1,4 @@
// Phase 3 Tier-B · B17 image serve endpoint.
// Tier-B · B17 image serve endpoint.
/**
* Exercises `GET /api/images/<path:image_path>` in
* `application/api/user/attachments/routes.py` — the static-ish endpoint
+2 -2
View File
@@ -1,6 +1,6 @@
// Phase 3 Tier-B · /api/search via agent api_key
// Tier-B · /api/search via agent api_key
/**
* Phase 3 — B8 · `/api/search` via agent api_key.
* B8 · `/api/search` via agent api_key.
*
* `application/api/answer/routes/search.py` is the fast-path retrieval
* endpoint used by embed/widget integrations: it takes a body with
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 3 Tier-B · source-files (B5) — POST /api/manage_source_files.
* Tier-B · source-files (B5) — POST /api/manage_source_files.
*
* Exercises the three `operation` branches in
* `application/api/user/sources/upload.py::ManageSourceFiles`:
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 3 Tier-B · source-sync (B4) — sync frequency + manual sync.
* Tier-B · source-sync (B4) — sync frequency + manual sync.
*
* Two endpoints:
*
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 3 Tier-B · sources-list (B2) — list / paginate / search / delete /
* Tier-B · sources-list (B2) — list / paginate / search / delete /
* directory-structure.
*
* Exercises the read-side endpoints the UI calls from
+34 -20
View File
@@ -1,6 +1,6 @@
// Phase 3 Tier-B · streaming UX (SSE delivery, research progress, tool approval resume)
// Tier-B · streaming UX (SSE delivery, research progress, tool approval resume)
/**
* Phase 3 — B7 · streaming UX + tool-state plumbing.
* B7 · streaming UX + tool-state plumbing.
*
* Focus: the `/stream` SSE surface that `frontend/src/conversation/Conversation.tsx`
* consumes. Unlike the migration-critical chat-turn spec (P2-07), these tests
@@ -103,11 +103,19 @@ test.describe('tier-b · streaming UX', () => {
isNoneDoc: true,
});
expect(status).toBe(200);
// Every non-empty line in the body must be `data: ...` framed.
// Every non-empty line in the body must be a valid SSE field —
// ``data:`` for payload, ``id:`` for the per-chunk replay cursor
// added by the snapshot-tail branch, or ``event:`` for typed
// events. Anything else (e.g. a raw JSON line) is a wire-format
// bug.
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
expect(trimmed.startsWith('data:')).toBe(true);
expect(
trimmed.startsWith('data:') ||
trimmed.startsWith('id:') ||
trimmed.startsWith('event:'),
).toBe(true);
}
// At least one answer delta landed and the stream ended cleanly.
expect(frames.length).toBeGreaterThan(1);
@@ -160,14 +168,20 @@ test.describe('tier-b · streaming UX', () => {
}
});
test('pending_tool_state round-trip: SQL-inserted row is cleared by a tool_actions resume /stream', async ({
test('pending_tool_state round-trip: SQL-inserted row is claimed (resuming) or cleared by a tool_actions resume /stream', async ({
browser,
}) => {
// We model the tool-approval flow at the table level — the contract is
// that the route path with `tool_actions` + `conversation_id` calls
// `StreamProcessor.resume_from_tool_actions`, which loads from PG and
// DELETEs the row after success (stream_processor.py:978-979). No real
// agent wiring is needed to test that contract.
// `StreamProcessor.resume_from_tool_actions`, which loads from PG, then
// `mark_resuming` flips status `pending`→`resuming` so the row cannot
// be re-claimed (see continuation_service.py + repositories/pending_tool_state.py).
// On clean completion the route layer DELETEs the row
// (routes/base.py: `delete_state` after stream success); on a crash the
// row stays in `resuming` for the janitor to revert after the grace
// window. Either outcome proves the replay footgun is closed — what
// must not happen is the row remaining in `pending` status.
// No real agent wiring is needed to test that contract.
const { sub, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
@@ -248,9 +262,9 @@ test.describe('tier-b · streaming UX', () => {
}),
).toBe(1);
// 3. Fire the resume request. Even if the agent crashes partway, the
// state must be deleted — the route layer guarantees that by calling
// delete_state *before* invoking gen_continuation.
// 3. Fire the resume request. The route layer claims the row via
// `mark_resuming` before invoking gen_continuation, so even if the
// agent crashes mid-stream the row's status flips off `pending`.
const resume = await api.post('/stream', {
data: {
conversation_id: convId,
@@ -264,15 +278,15 @@ test.describe('tier-b · streaming UX', () => {
expect(resume.status()).toBe(200);
await resume.text();
// 4. Row must be gone. Proves delete_state ran during the resume
// code path. If this fails, the resume is replayable — a footgun
// that was a Mongo-era bug #2088.
expect(
await countRows('pending_tool_state', {
sql: 'user_id = $1 AND conversation_id = CAST($2 AS uuid)',
params: [sub, convId],
}),
).toBe(0);
// 4. Row must be either deleted (clean completion) or marked
// `resuming` (crash partway). What must not happen is the row
// remaining in `pending` — that would mean the resume is
// re-fireable, a footgun mirroring Mongo-era bug #2088.
const pendingCount = await countRows('pending_tool_state', {
sql: "user_id = $1 AND conversation_id = CAST($2 AS uuid) AND status = 'pending'",
params: [sub, convId],
});
expect(pendingCount).toBe(0);
} finally {
await api.dispose();
}
+4 -3
View File
@@ -1,12 +1,13 @@
/**
* Phase 3 Tier-B · agent template gallery + adopt
* Tier-B · agent template gallery + adopt
*
* Covers B12: `GET /api/template_agents` lists rows owned by
* `user_id = '__system__'`, and `POST /api/adopt_agent?id=…` creates a
* user-owned copy with the same shape but a fresh `id`/`user_id`/`key`.
*
* The e2e DB starts with zero template rows — Phase 0 only applies the
* schema, not template seeding. We insert a `__system__`-owned row via
* The e2e DB starts with zero template rows — the bootstrap only
* applies the schema, not template seeding. We insert a
* `__system__`-owned row via
* direct SQL to simulate a seeded template. This matches the brief's
* explicit guidance ("if empty, INSERT a fake __system__ template agent
* via SQL for the test") and avoids coupling to a future seed script
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 3 Tier-B · upload (B1) — core upload happy paths.
* Tier-B · upload (B1) — core upload happy paths.
*
* Exercises the two ingest entry points the UI drives from
* `frontend/src/upload/Upload.tsx`:
@@ -1,6 +1,6 @@
// Phase 3 Tier-B · /v1/chat/completions + /v1/models (OpenAI-compatible)
// Tier-B · /v1/chat/completions + /v1/models (OpenAI-compatible)
/**
* Phase 3 — B9 · OpenAI-compatible endpoints.
* B9 · OpenAI-compatible endpoints.
*
* `application/api/v1/routes.py` exposes `/v1/chat/completions` (POST)
* and `/v1/models` (GET). Both read an agent API key from
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Phase 3 Tier-B · agent incoming webhook
* Tier-B · agent incoming webhook
*
* Covers B11: minting of `agents.incoming_webhook_token` via
* `GET /api/agent_webhook?id=…`, POSTing the resolved URL triggers a
@@ -1,5 +1,5 @@
/**
* Phase 3 Tier-B · workflow builder (nodes / edges / versioning)
* Tier-B · workflow builder (nodes / edges / versioning)
*
* Covers B13: `POST /api/workflows`, `GET /api/workflows/:id`,
* `PUT /api/workflows/:id`, `DELETE /api/workflows/:id`, and the
+7 -5
View File
@@ -1,7 +1,7 @@
import * as playwright from '@playwright/test';
// Phase 4 Tier-C · UI smoke (surface coverage, no DB assertions)
// Tier-C · UI smoke (surface coverage, no DB assertions)
/**
* Phase 4 — P4 · Tier-C UI smoke.
* P4 · Tier-C UI smoke.
*
* Cheap surface coverage for UI-only behaviors. One combined spec, many
* small tests. Scope:
@@ -292,8 +292,10 @@ test.describe('tier-c · UI smoke', () => {
const newChat = page.getByRole('link', { name: /new chat/i }).first();
await expect(newChat).toBeVisible();
await newChat.click();
// Clicking "New Chat" must not navigate us off the root route.
await expect(page).toHaveURL(/127\.0\.0\.1:5179\/?$/);
// Clicking "New Chat" lands on the empty-conversation sentinel
// route. Root or /c/new are both acceptable — the SSE branch made
// /c/new the canonical fresh-chat URL.
await expect(page).toHaveURL(/127\.0\.0\.1:5179\/(c\/new)?$/);
} finally {
await context.close();
}
@@ -305,7 +307,7 @@ test.describe('tier-c · UI smoke', () => {
// them set (see scripts/e2e/env.sh), so App.tsx's `<Notification>` is
// never rendered and there is nothing observable to assert.
//
// If/when we add a prod-bundle opt-in (Phase 5-C), revive this test with
// If/when we add a prod-bundle opt-in, revive this test with
// those env vars set and check the banner + localStorage-dismiss flow.
});