tasks ready 1

This commit is contained in:
2026-06-09 09:43:34 +03:00
parent 8e8a3c3235
commit a7fb06cd8e
23 changed files with 2179 additions and 45 deletions

View File

@@ -9,6 +9,7 @@ Auto-generated from all feature plans. Last updated: 2026-05-08
- PostgreSQL 16 (dedicated ss-tools DB — not Superset metadata DB per ADR-0003) (031-maintenance-banner)
- Python 3.9+ (backend), TypeScript (frontend Svelte 5 runes-only) + FastAPI 0.126, SQLAlchemy, APScheduler 3.11, httpx 0.28 (already present), anyio 4.12 (already present) (032-translate-requests-httpx)
- PostgreSQL 16 (unchanged — DB operations via asyncio.to_thread) (032-translate-requests-httpx)
- Python 3.9+ (backend), TypeScript (frontend Svelte 5 runes-only) + FastAPI, SQLAlchemy, Gradio ≥5.0, LangChain ≥0.3, langchain-openai (backend); SvelteKit 5, Vite, Tailwind CSS, @gradio/client (frontend) (033-gradio-agent-chat)
- Python 3.13+ (backend), JavaScript/TypeScript (frontend Svelte 5) + FastAPI 0.115+, SQLAlchemy 2.0+, APScheduler 3.x, Pydantic v2 (backend); SvelteKit 2.x, Svelte 5.43+, Vite 7.x, Tailwind CSS 3.x (frontend) (028-llm-datasource-supeset)
@@ -29,9 +30,9 @@ cd src [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLO
Python 3.13+ (backend), JavaScript/TypeScript (frontend Svelte 5): Follow standard conventions
## Recent Changes
- 033-gradio-agent-chat: Added Python 3.9+ (backend), TypeScript (frontend Svelte 5 runes-only) + FastAPI, SQLAlchemy, Gradio ≥5.0, LangChain ≥0.3, langchain-openai (backend); SvelteKit 5, Vite, Tailwind CSS, @gradio/client (frontend)
- 032-translate-requests-httpx: Added Python 3.9+ (backend), TypeScript (frontend Svelte 5 runes-only) + FastAPI 0.126, SQLAlchemy, APScheduler 3.11, httpx 0.28 (already present), anyio 4.12 (already present)
- 031-maintenance-banner: Added Python 3.9+ (backend), JavaScript/TypeScript (frontend Svelte 5 runes) + FastAPI 0.126, SQLAlchemy 2.0, APScheduler 3.11 (backend); SvelteKit 2.x, Svelte 5.43, Vite 7.x, Tailwind CSS 3.x (frontend)
- 030-dataset-lifecycle-workspace: Added Python 3.9+ (backend), JavaScript/TypeScript — Svelte 5 runes (frontend) + FastAPI 0.104+, Pydantic v2, SQLAlchemy (backend); SvelteKit 2.x, Svelte 5.x, Vite 7.x, Tailwind CSS 3.x (frontend)
<!-- MANUAL ADDITIONS START -->

View File

@@ -36,6 +36,9 @@ You **MUST** consider the user input before proceeding (if not empty).
- `.opencode/skills/semantics-svelte/SKILL.md`
- `.opencode/skills/semantics-testing/SKILL.md`
- `.specify/templates/plan-template.md`
- `FEATURE_DIR/contracts/ux/screen-models.md` (if `/speckit.ux` was run)
- `FEATURE_DIR/contracts/ux/api-ux.md` (if `/speckit.ux` was run)
- `FEATURE_DIR/contracts/ux/*-ux.md` (per-screen UX contracts)
- relevant `docs/adr/*.md`
3. **Execute the planning workflow** using the template structure:
@@ -44,6 +47,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- ERROR if a blocking constitutional or semantic conflict is discovered and cannot be justified.
- Phase 0: generate `research.md` in `FEATURE_DIR`, resolving all material unknowns.
- Phase 1: generate `data-model.md`, `contracts/modules.md`, optional machine-readable contract artifacts, and `quickstart.md` in `FEATURE_DIR`.
- Phase 1: if UX contracts exist, generate `traceability.md` — a requirements traceability matrix mapping Story → Model → API → Task → Test.
- Materialize blocking ADR references and planning decisions inside the plan and downstream contracts.
- Run `.specify/scripts/bash/update-agent-context.sh kilocode` after planning artifacts are written.
@@ -63,6 +67,12 @@ Research must resolve only implementation-shaping unknowns that matter for this
- belief runtime instrumentation for C4/C5 flows
- semantic validation boundaries and static verification workflow
**If `/speckit.ux` was run before plan:**
- `screen-models.md` defines Model inventory → use directly, don't re-discover
- `api-ux.md` defines API shapes → use as @DATA_CONTRACT source for backend Pydantic schemas
- `<screen>-ux.md` defines UX contracts → use as @UX_STATE/@UX_FEEDBACK source for component contracts
- Generated `.svelte.ts` model files in `frontend/src/lib/models/` → DO NOT regenerate; reference them via `@RELATION BINDS_TO` from component contracts
Write `research.md` with concise sections:
- Decision
- Rationale
@@ -230,6 +240,93 @@ Generate its full `#region` header in `contracts/modules.md` under its parent mo
If a planned contract depends on unknown schema, relation target, or ADR identity, emit `[NEED_CONTEXT: target]` instead of fabricating placeholders.
### Fixture Generation (MANDATORY for C3+ contracts with @TEST_EDGE)
For every C3+ contract that declares `@TEST_EDGE`, `@POST`, or `@REJECTED` guardrails, generate **canonical test fixtures** in `FEATURE_DIR/fixtures/`. Canonical fixtures live beside the spec — they are the design-time source of truth. Executable fixtures are materialized into `tests/` later by `/speckit.tasks`.
**Output structure:**
```text
specs/<feature>/fixtures/
├── manifest.md # Fixture index with GRACE contracts
├── api/
│ ├── <contract>_valid.json
│ ├── <contract>_missing_field.json
│ ├── <contract>_invalid_type.json
│ ├── <contract>_external_fail.json
│ └── <contract>_rejected_path.json
└── model/
├── <model>_valid.json
├── <model>_edge_case.json
└── <model>_invariant.json
```
**`manifest.md` — fixture index with GRACE contracts:**
```markdown
#region FixtureManifest [C:3] [TYPE ADR] [SEMANTICS test,fixture,[DOMAIN]]
@defgroup Fixtures Canonical test fixtures for [FEATURE].
## @{ Fixture FX_Auth.Login.Valid [C:2] [TYPE Block] [SEMANTICS test,auth,fixture]
@BRIEF Valid login request/response pair.
@RELATION VERIFIES -> [Api.Auth.Login]
@TEST_FIXTURE: valid_login -> fixtures/api/auth_login_valid.json
@TEST_INVARIANT: TokenIssued -> VERIFIED_BY: [Test.Api.Auth]
## @} Fixture FX_Auth.Login.Valid
## @{ Fixture FX_Auth.Login.MissingPassword [C:2] [TYPE Block] [SEMANTICS test,auth,fixture]
@BRIEF Missing password field — @TEST_EDGE: missing_field.
@RELATION VERIFIES -> [Api.Auth.Login]
@TEST_EDGE: missing_field -> 422 VALIDATION_ERROR
@TEST_FIXTURE: missing_password -> fixtures/api/auth_login_missing_field.json
## @} Fixture FX_Auth.Login.MissingPassword
## @{ Fixture FX_Migration.EnvReset [C:2] [TYPE Block] [SEMANTICS test,migration,fixture]
@BRIEF Model invariant: changing source env resets selection.
@RELATION VERIFIES -> [Migration.Model]
@TEST_INVARIANT: env_reset_selection -> VERIFIED_BY: [Test.Migration.Model]
@TEST_FIXTURE: env_reset -> fixtures/model/migration_env_reset.json
## @} Fixture FX_Migration.EnvReset
```
**JSON fixture format:**
```json
{
"fixture_id": "FX_Auth.Login.MissingPassword",
"verifies": "Api.Auth.Login",
"edge": "missing_field",
"input": {
"username": "admin"
},
"expected": {
"status": 422,
"error_code": "VALIDATION_ERROR",
"error_detail": "Field 'password' is required"
}
}
```
**Generation rules:**
- **One JSON file per fixture** — named `<contract_snake>_<edge>.json`
- **Minimum 5 per C3+ contract**: valid, missing_field, invalid_type, external_fail, rejected_path
- **Expected values ALWAYS hardcoded** — never derived from implementation (anti-tautology)
- **Input values are concrete** — real strings, numbers, objects, not pseudocode
- **Fixture ID format**: `FX_<Domain>.<Name>` — hierarchical, matches contract hierarchy
- **@TEST_FIXTURE in manifest** points to the JSON file path
- **@RELATION VERIFIES** links fixture to production contract
- For `@REJECTED` paths: expected MUST include error/failure, proving the path is unreachable
- For model invariants: input = state before action, expected = state after action
- Do NOT generate executable test files here — only canonical JSON fixtures
### Fixture Traceability
Extend `traceability.md` with a Fixture column:
| Story | Model | Fixture | Task | Test |
|-------|-------|---------|------|------|
| US1 | Api.Auth.Login | FX_Auth.Login.Valid | T017 | Test.Api.Auth
### Quickstart Output
Generate `quickstart.md` using real repository verification paths:
@@ -239,6 +336,41 @@ Generate `quickstart.md` using real repository verification paths:
- Frontend lint: `cd frontend && npm run lint`
- Docker: `docker compose up --build`
### Traceability Matrix Output
If UX contracts exist (`contracts/ux/` was generated by `/speckit.ux`), generate `traceability.md` — a requirements traceability matrix (RTM) mapping every user story through its implementation chain:
```markdown
#region Traceability [C:3] [TYPE ADR] [SEMANTICS traceability,rtm,[DOMAIN]]
@defgroup Trace Matrix Requirements → Model → API → Task → Test for [FEATURE].
## Traceability Matrix
| Story | Screen | Model | Fixture | API Endpoint | Backend Task | Frontend Task | Test |
|-------|--------|-------|---------|-------------|-------------|--------------|------|
| US1: [Title] | /route | Domain.Model | FX_Domain.Valid | GET /api/... | T017 | T015 | Test.Domain |
| US1: [Title] | /route | Domain.Model | FX_Domain.MissingField | POST /api/... | T018 | T019 | Test.Domain.Edge |
## Impact Analysis Quick Reference
| If you change... | These fixtures verify it | These tests verify it | These screens depend |
|-----------------|------------------------|----------------------|---------------------|
| `GET /api/dashboards` | FX_Dashboards.Hub.* | Test.Dashboards.Hub | /dashboards, /migration |
| `Dashboards.Hub` model | FX_Dashboards.EnvReset | Test.Dashboards.Hub | /dashboards |
#endregion Traceability
```
**Generation rules:**
- One row per unique (Story, API Endpoint, Screen) tuple
- Model column: `[TYPE Model]` contract ID from `screen-models.md`
- API column: endpoint from `api-ux.md` or `contracts/modules.md`
- Task columns: task IDs from `tasks.md` (to be filled after `/speckit.tasks` — leave as `T???` if tasks not yet generated)
- Test column: test contract ID pattern `Test.<Domain>.<Name>`
- Impact table: derived from `@RELATION` edges in contracts — invert the dependency graph
- Grep-friendly: `grep "Dashboards.Hub" traceability.md` → all rows for that model
- Agent zombie mode: without MCP tools, `grep "<contract>" traceability.md` replaces `impact_analysis`
## Key Rules
- Use absolute paths in workflow execution.

View File

@@ -173,3 +173,29 @@ Before finalizing `tasks.md`, verify that:
- no task text schedules a rejected path
- story tasks remain executable within the actual Python/Svelte project structure
- at least one explicit verification task protects against rejected-path regression
### Fixture Materialization Tasks
If `/speckit.plan` generated canonical fixtures in `specs/<feature>/fixtures/`, create materialization tasks that copy them into the repo-native test directories before writing test code:
**Backend fixtures:**
```text
- [ ] TXXX [P] [US1] Materialize fixtures from specs/<feature>/fixtures/api/ into backend/tests/fixtures/<domain>/
Source: fixtures/api/auth_login_*.json
Target: backend/tests/fixtures/auth/
Each fixture → one JSON file. Do NOT modify fixture content — copy as-is.
```
**Frontend fixtures:**
```text
- [ ] TXXX [P] [US1] Materialize fixtures from specs/<feature>/fixtures/model/ into frontend/src/lib/models/__fixtures__/<model>/
Source: fixtures/model/migration_*.json
Target: frontend/src/lib/models/__fixtures__/migration/
```
**Rules:**
- Materialization tasks are [P] (parallel, different directories)
- Materialize BEFORE test-writing tasks — tests import fixtures
- Fixtures are copied as-is from canonical source — no adaptation at this stage
- If canonical fixture shape doesn't match test framework expectations, create a separate adapter task
- Every fixture in `manifest.md` gets exactly one materialization task

View File

@@ -0,0 +1,366 @@
---
description: Interactive UX design session — asks questions, presents alternatives, exhaustively designs every screen state, then generates Screen Model code and UX contracts.
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a Python/Svelte implementation plan using the UX contracts
send: true
- label: Create Tasks
agent: speckit.tasks
prompt: Break the plan into executable tasks referencing UX contracts
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Principle
You are a UX designer, not a contract generator. Your job is to **ask questions the spec didn't answer**, present **visual and interaction alternatives**, and work through **every screen state exhaustively** before writing a single contract. Contracts are the OUTPUT of design decisions, not the input.
## Outline
### Phase 0: Load Context
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json``FEATURE_DIR`.
2. **Load**:
- `FEATURE_DIR/spec.md` — user stories, acceptance criteria
- `FEATURE_DIR/ux_reference.md` — high-level narrative (if exists)
- `.opencode/skills/semantics-svelte/SKILL.md` — §VI canonical template, §VII design tokens
- `frontend/src/lib/ui/` — available atoms (Button, Card, Input, Select, PageHeader...)
- `frontend/src/lib/components/` — available widgets (MultiSelect, SearchableMultiSelect...)
- `frontend/src/lib/models/` — existing Screen Models (reuse or extend)
### Phase 1: Screen Decomposition — ASK, don't assume
For EACH user story in `spec.md` that has a UI surface, ask:
```
## Screen: [Story Title]
**1. Navigation structure**
How does the user reach this screen?
A) Separate route: /feature-name
B) Modal/drawer over existing page
C) Tab/section within existing page: /existing#feature
D) Other: [describe]
**2. Layout strategy**
A) Single column, full width — simple CRUD
B) Two-column: list + detail panel
C) Wizard: multi-step with progress indicator
D) Dashboard: cards/grid with filters
E) Other: [describe]
**3. Data density**
How much data does the user see at once?
A) Few items (<20): simple list, no pagination
B) Medium (20-200): paginated table with search
C) Large (200+): paginated table + filters + search
D) Real-time stream: WebSocket updates, auto-scroll
```
Present 2-3 concrete alternatives with tradeoffs. Wait for user response before continuing to the next question.
### Phase 2: State Exhaustion — EVERY screen state
For each screen, work through ALL states exhaustively. This is where most UX bugs hide — the states between "loading" and "loaded".
```
## States for: [Screen]
For each state, define: Visual → ARIA → User can...
**Happy path:**
- **idle** → [what user sees before any action]
- **loading** → skeleton? spinner? progress bar? partial data?
- **loaded** → data visible, actions available
**Empty states:**
- **empty (first use)** → guided onboarding or empty state with CTA?
- **empty (filtered)** → "No results match" + clear filters?
- **empty (no permissions)** → 403 with explanation?
**Error states:**
- **error (network)** → toast + retry? full error page? degraded mode?
- **error (validation)** → inline field errors? modal? which fields?
- **error (timeout)** → retry with countdown? cancel?
- **error (server 500)** → generic message? retry? contact support?
**Edge states:**
- **stale data** → show cached with "refresh" indicator?
- **partial data** → some rows loaded, some failed?
- **background update** → data changed by another user? WebSocket notification?
- **rate limited** → "Too many requests" + countdown?
```
For EACH state, ask: "Is this state possible? If yes, what does the user see?"
### Phase 3: Interaction Design — choices with tradeoffs
For each user action, present alternatives:
```
## Interaction: [Action Name]
**1. Trigger**
A) Button (primary, visible immediately)
B) Button in toolbar (secondary, contextual)
C) Inline action (icon per row, hover reveal)
D) Keyboard shortcut (power users)
E) Context menu (right-click)
**2. Feedback**
A) Optimistic update (UI changes before API confirms)
B) Loading state on element (button spinner, row skeleton)
C) Full page overlay (block all interactions)
D) Background (toast on completion)
**3. Confirmation**
A) No confirmation (action is safe/undoable)
B) `confirm()` dialog (simple yes/no)
C) Custom modal (shows affected items, requires explicit confirm)
D) Undo toast (action executes, toast offers undo for 5s)
**4. Multi-select**
If user can act on multiple items:
A) Checkbox per row + bulk action bar
B) Shift-click range selection
C) Select-all + deselect individually
```
Present the tradeoff for each alternative — don't just list options. E.g.: "Optimistic update feels faster but requires rollback logic on failure. Loading spinner is simpler but adds perceived latency."
### Phase 4: API UX Design
For each endpoint this feature touches:
```
## API: [METHOD] /api/[endpoint]
**Request:**
- Shape: { field: Type, ... }
- Validation errors → HTTP 422, inline per-field messages
**Response shapes — ALL variants:**
- Success (200/201): { data: {...}, meta?: {...} }
- Empty (200): { data: [], meta: { total: 0 } }
- Not found (404): { error: { code: "NOT_FOUND", detail: "..." } }
- Permission denied (403): { error: { code: "FORBIDDEN", detail: "..." } }
- Validation (422): { error: { code: "VALIDATION", fields: { field: "message" } } }
- Conflict (409): { error: { code: "CONFLICT", detail: "..." } }
- Server error (500): { error: { code: "INTERNAL", detail: "..." } }
**Loading UX:**
- Debounce before showing loader? (ms)
- Skeleton or spinner?
- Partial data during load or blank?
**Sequence (Mermaid — for complex multi-step flows):**
```mermaid
sequenceDiagram
User->>+Frontend: Click "[Action]"
Frontend->>+Backend: POST /api/...
Backend->>+External: [call]
External-->>-Backend: [response]
Backend-->>-Frontend: { status: "ok", data: {...} }
Frontend->>User: [feedback]
```
Use ONLY for flows with 3+ participants or async callbacks. Skip for simple CRUD.
**WebSocket (if applicable):**
- Channel: task.{id}.progress
- Payload shape
- How does UI react to each message type?
```
### Phase 5: Mobile & Accessibility
```
**Mobile behavior:**
- Responsive breakpoint strategy?
- Stacked layout on mobile? Which columns collapse?
- Touch targets: minimum 44×44px per WCAG
**Accessibility:**
- Screen reader flow for each state
- Focus management: where does focus go after modal opens/closes?
- Keyboard navigation: Tab order, Enter/Space for actions
- Color contrast: semantic tokens guarantee WCAG AA? Check destructive/success on surface.
```
### Phase 6: Record Decisions & Alternatives
After all questions are answered, create TWO artifacts:
**`contracts/ux/alternatives.md`** — all options considered, BEFORE final choice:
```markdown
#region UxAlternatives [C:3] [TYPE ADR] [SEMANTICS ux,alternatives,[DOMAIN]]
@defgroup Ux Design alternatives explored for [FEATURE].
## Screen: [Name]
### Navigation
- ✅ CHOSEN: Separate route /feature — clean URL, direct linkable, full focus
- ❌ Rejected: Modal over dashboard — loses context when modal closes, can't deep-link
- ❌ Rejected: Tab within settings — buried, users won't discover
### Layout
- ✅ CHOSEN: Two-column (list + detail) — best scanability for 20+ items
- ❌ Rejected: Single table — no preview without navigation, repetitive clicks
- ❌ Rejected: Cards grid — doesn't scale past 12 items, inconsistent card heights
### Data Loading
- ✅ CHOSEN: Paginated table (20 per page) + search — predictable, fast
- ❌ Rejected: Infinite scroll — breaks "select all", hard to find specific item
- ❌ Rejected: Load all at once — 200+ items freeze UI
### Action Feedback (for destructive actions)
- ✅ CHOSEN: Undo toast (5s) — feels instant, recoverable
- ❌ Rejected: Confirm dialog — extra click on every action, annoying at scale
- ❌ Rejected: No confirmation — dangerous for delete/migrate
#endregion UxAlternatives
```
**`contracts/ux/decisions.md`** — only the final choices:
```markdown
#region UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,[DOMAIN]]
@defgroup Ux Final UX design decisions for [FEATURE].
## Screen: [Name]
- Navigation: Separate route /feature
- Layout: Two-column (list + detail)
- Data: Paginated (20/page) + search
- Feedback: Undo toast (5s) for destructive actions
#endregion UxDecisions
```
**Rule:** `alternatives.md` shows the DESIGN SPACE — agent can see WHY each path was rejected. `decisions.md` is the compact reference for `/speckit.plan`.
### Phase 7: Generate Artifacts
ONLY after all design decisions are made:
1. **`contracts/ux/screen-models.md`** — Model inventory from Phase 1-2 decisions
2. **`contracts/ux/api-ux.md`** — API shapes from Phase 4
3. **`contracts/ux/<screen>-ux.md`** × N — per-screen UX contracts from Phase 2-3
4. **`contracts/ux/design-tokens.md`** — token application from Phase 3
5. **`frontend/src/lib/models/<Domain>Model.svelte.ts`** — generated model code
For artifacts 3-5, use the templates defined below. Do NOT generate contracts before design decisions are recorded.
### Phase 8: Confirmation Gate
Before writing model files to `frontend/src/lib/models/`, present:
| File | Path | Atoms | Actions | Dependencies |
|------|------|-------|---------|-------------|
Ask: "Write these model files? (yes/no)"
## Artifact Templates
### `<screen>-ux.md`
```markdown
#region <Screen>Ux [C:3] [TYPE ADR] [SEMANTICS ux,<domain>,<screen>]
@defgroup Ux UX contract for <Screen>.
## FSM (from Phase 2 decisions)
idle → [trigger] → loading → [success] → loaded
→ [empty] → empty
→ [failure] → error → [retry] → loading
## State Mappings (from Phase 2-3 decisions)
| @UX_STATE | Visual | ARIA | User Can |
|-----------|--------|------|----------|
## Feedback (from Phase 3 decisions)
| Trigger | Feedback | Rationale |
## Recovery (from Phase 2 edge states)
| From | Action | To |
## Reactivity (from Phase 1-2 decisions)
- Model atoms → Component props → DOM
- Store subscriptions → $effect (browser-side only)
## UX Tests (minimum: happy, empty, error, edge)
| @UX_TEST | Given | When | Then |
```
### `<Domain>Model.svelte.ts` — generated code
```typescript
// frontend/src/lib/models/<Domain>Model.svelte.ts
// #region <Domain>.Model [C:4] [TYPE Model] [SEMANTICS <domain>,<feature>,screen-model]
// @defgroup <Domain> <One-line from decisions>.
// @INVARIANT <from Phase 2-3 decisions>
// @STATE <FSM states from Phase 2>
// @ACTION <from Phase 3 interaction decisions>
// @RELATION DEPENDS_ON -> [api]
// @RATIONALE Model-first: extracted to enable L1 testing without DOM.
// @REJECTED Inline state rejected — scatters logic across event handlers.
import { requestApi } from "$lib/api";
import { log } from "$lib/cot-logger";
// ── Types (from Phase 2-4 decisions) ──
type ScreenState = "idle" | "loading" | "loaded" | "empty" | "error";
interface Entity { id: string; /* from spec + API shape */ }
interface ListResponse { data: Entity[]; meta: { total: number }; }
export class <Domain>Model {
// ── Atoms ──
items: Entity[] = $state([]);
screenState: ScreenState = $state("idle");
error: string | null = $state(null);
// ── Derived ──
isEmpty = $derived(this.items.length === 0 && this.screenState === "loaded");
// ── Actions ──
async load(): Promise<void> {
this.screenState = "loading";
this.error = null;
log("<Domain>.Model", "REASON", "Loading items");
try {
const res: ListResponse = await requestApi("/api/...");
this.items = res.data;
this.screenState = this.items.length === 0 ? "empty" : "loaded";
log("<Domain>.Model", "REFLECT", "Items loaded", { count: this.items.length });
} catch (e: unknown) {
this.error = e instanceof Error ? e.message : "Load failed";
this.screenState = "error";
log("<Domain>.Model", "EXPLORE", "Load failed", {}, this.error);
}
}
async retry(): Promise<void> { await this.load(); }
// TODO: implement remaining actions from Phase 3 decisions
// Each action throws until implemented — L1-testable immediately
}
// #endregion <Domain>.Model
```
## Stop & Report
After Phase 8, report:
- Screens designed: N
- Design decisions recorded: N
- UX contracts generated: N files
- Model files generated: N (if confirmed)
- Total @UX_STATE mappings: N
- Total @UX_TEST scenarios: N
- Every screen state from Phase 2 covered: yes/no
- Every API response variant from Phase 4 covered: yes/no
- Readiness for `/speckit.plan`

View File

@@ -67,11 +67,12 @@ All generated contracts (specs, code, tests) MUST be optimized for the attention
## Development Workflow
```text
/speckit.specify → /speckit.clarify → /speckit.plan → /speckit.tasks → /speckit.implement
/speckit.specify → /speckit.clarify → /speckit.ux → /speckit.plan → /speckit.tasks → /speckit.implement
```
**Rules:**
- No phase is skipped when `[NEEDS CLARIFICATION]` markers remain
- `/speckit.ux` produces UX contracts AND generates Screen Model `.svelte.ts` files — these are the design contract for `/speckit.plan`
- Contract mutation: preview → apply, never immediate apply
- All feature artifacts in `specs/<feature>/`, never in `.kilo/` or `.ai/`

View File

@@ -44,8 +44,17 @@ specs/[###-feature]/
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── traceability.md # Phase 1 output — RTM: Story → Model → API → Task → Test
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
│ ├── modules.md # Module & function contracts
│ └── ux/ # UX contracts (if /speckit.ux was run)
│ ├── alternatives.md # Design space explored
│ ├── decisions.md # Final UX choices
│ ├── screen-models.md # Model inventory
│ ├── api-ux.md # API interaction shapes
│ ├── <screen>-ux.md # Per-screen UX contracts
│ └── design-tokens.md # Applied tokens & reuse
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)

View File

@@ -68,6 +68,8 @@ Examples of foundational tasks (adjust based on your project):
- [ ] T007 Create base models/entities that all stories depend on
- [ ] T008 Configure error handling and logging infrastructure
- [ ] T009 Setup environment configuration management
- [ ] T00A [P] Materialize canonical fixtures from specs/<feature>/fixtures/api/ into backend/tests/fixtures/
- [ ] T00B [P] Materialize canonical fixtures from specs/<feature>/fixtures/model/ into frontend/src/lib/models/__fixtures__/
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
@@ -279,3 +281,5 @@ With multiple developers:
- **Screen Models use `.svelte.ts` extension** — create them in `frontend/src/lib/models/`
- **Two-layer testing: L1 (model invariants, no render, ~27ms) comes BEFORE L2 (UX contracts, with render)**
- **Backend tasks are pinned to `backend/src/`, frontend tasks to `frontend/src/`** — cross-stack tasks reference both
- **Fixture materialization tasks come BEFORE test-writing tasks** — tests import fixtures, not generate data
- **Canonical fixtures live in `specs/<feature>/fixtures/`** — materialize to `tests/` via tasks, never edit canonical source in test directories

View File

@@ -0,0 +1,325 @@
// frontend/src/lib/models/AgentChatModel.svelte.ts
// #region AgentChat.Model [C:4] [TYPE Model] [SEMANTICS agent-chat,model,gradio,langgraph]
// @defgroup AgentChat State model for Gradio-powered agent chat — submit() streaming, structured metadata, LangGraph HITL resume, no WebSocket.
// @INVARIANT Streaming state gates input: input disabled in streaming, awaiting_confirmation, disconnected states.
// @INVARIANT Connection state gates send: send rejected when connectionState !== "connected".
// @INVARIANT HITL resume: confirmation via second submit() with additional_inputs=[conversationId, "confirm"|"deny"]. Primary stream TERMINATES on confirm_required, second stream resumes from checkpoint.
// @INVARIANT Optimistic cancel: submission.cancel() stops generation, partial text preserved.
// @INVARIANT Conversation list loaded from FastAPI REST — Gradio serves only live agent streaming.
// @STATE idle | streaming | awaiting_confirmation | error | disconnected | disconnected_permanent
// @ACTION sendMessage(text, files?) — submit("/chat", {text, files}, [conversationId, null]), iterate stream events, process metadata.
// @ACTION cancelGeneration() — submission.cancel(), optimistic UI reset.
// @ACTION resumeConfirm(action) — second submit("/chat", {text:"confirm"}, [conversationId, action]) for HITL resume.
// @ACTION loadConversations(reset?) — fetch list from REST, infinite scroll.
// @ACTION loadHistory(conversationId?) — fetch messages from REST.
// @ACTION deleteConversation(id) — optimistic archive, rollback on failure.
// @ACTION createConversation() — clear state, start new.
// @ACTION retryConnection() — Client.connect() retry.
// @RELATION BINDS_TO -> [AssistantChatStore]
// @RELATION DEPENDS_ON -> [GradioClient:@gradio/client]
// @RELATION DEPENDS_ON -> [AssistantApi]
// @RATIONALE Model-first: extracted from AssistantChatPanel.svelte (1029 lines). Centralizes Gradio submit() lifecycle, metadata parsing, HITL resume, and reconnect logic in a testable unit. Without a model, streaming + metadata dispatch + confirmation + reconnect logic scattered across event handlers would be untestable.
// @REJECTED Inline $state in AssistantChatPanel.svelte — rejected because component already exceeds 400-line guideline.
// @REJECTED WebSocket-based model — rejected with custom WebSocket protocol.
// @REJECTED Active/follower multi-tab — rejected in favor of client-side gate.
import { Client, type Client as GradioClient } from "@gradio/client";
import {
assistantChatStore,
setAssistantConversationId,
} from "$lib/stores/assistantChat.svelte.js";
import {
getAssistantConversations,
getAssistantHistory,
deleteAssistantConversation,
} from "$lib/api/assistant.js";
import { addToast } from "$lib/toasts.svelte.js";
import { log } from "$lib/cot-logger";
// ── Type definitions ─────────────────────────────────────────────
type StreamingState =
| "idle"
| "streaming"
| "awaiting_confirmation"
| "error"
| "disconnected"
| "disconnected_permanent";
type ConnectionState =
| "connected"
| "disconnected"
| "disconnected_permanent";
interface StreamMetadata {
type?: "stream_token" | "tool_start" | "tool_end" | "tool_error"
| "confirm_required" | "confirm_resolved" | "error";
token?: string;
tool?: string;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
error?: string;
prompt?: string;
thread_id?: string;
result?: "confirmed" | "denied";
code?: string;
detail?: string;
}
interface ToolCall {
tool: string;
input: Record<string, unknown>;
output?: Record<string, unknown>;
error?: string;
status: "executing" | "completed" | "failed";
}
interface AgentMessage {
id: string;
conversation_id: string;
role: "user" | "assistant" | "system" | "tool";
text: string;
metadata?: StreamMetadata;
state?: string;
task_id?: string;
toolCalls: ToolCall[];
created_at: string;
}
interface Conversation {
id: string;
title: string;
updated_at: string;
message_count: number;
}
// ── Reconnect constants ──────────────────────────────────────────
const RECONNECT_MAX_ATTEMPTS = 5;
const RECONNECT_INTERVAL_MS = 5_000;
export class AgentChatModel {
// ── Atoms ──────────────────────────────────────────────────────
messages: AgentMessage[] = $state([]);
conversations: Conversation[] = $state([]);
currentConversationId: string | null = $state(null);
streamingState: StreamingState = $state("idle");
connectionState: ConnectionState = $state("connected");
error: string | null = $state(null);
partialText: string = $state("");
activeToolCalls: ToolCall[] = $state([]);
isLoadingHistory: boolean = $state(false);
inputText: string = $state("");
pendingThreadId: string | null = $state(null); // for HITL resume
// ── Private atoms ──────────────────────────────────────────────
private _client: GradioClient | null = null;
private _submission: ReturnType<GradioClient["submit"]> | null = null;
private _conversationsPage: number = 1;
private _conversationsHasNext: boolean = $state(false);
private _historyPage: number = 1;
private _historyHasNext: boolean = $state(false);
private _reconnectAttempts: number = 0;
private _reconnectTimer: ReturnType<typeof setInterval> | null = null;
// ── Derived ────────────────────────────────────────────────────
isStreaming = $derived(
this.streamingState === "streaming" ||
this.streamingState === "awaiting_confirmation",
);
isInputLocked = $derived(
this.isStreaming ||
this.streamingState === "disconnected" ||
this.streamingState === "disconnected_permanent",
);
connectionDotColor = $derived(
this.connectionState === "connected" ? "success"
: this.connectionState === "disconnected" ? "warning"
: "destructive",
);
// ── Actions — P1 ───────────────────────────────────────────────
async sendMessage(text: string, files?: File[]): Promise<void> {
if (this.streamingState !== "idle" || this.connectionState !== "connected") return;
if (!text.trim() && (!files || files.length === 0)) return;
const convId = this.currentConversationId;
this.streamingState = "streaming";
this.error = null;
this.partialText = "";
this.activeToolCalls = [];
log("AgentChat.Model", "REASON", "Sending message", { text: text.slice(0, 100) });
try {
this._submission = this._client!.submit("/chat",
{ text, files },
[convId, null], // additional_inputs: [conversation_id, action]
);
for await (const event of this._submission) {
const msg: AgentMessage = event.data;
this._onStreamData(msg);
}
this.streamingState = "idle";
this._submission = null;
} catch (e: unknown) {
this.streamingState = "error";
this.error = e instanceof Error ? e.message : "Stream failed";
log("AgentChat.Model", "EXPLORE", "Stream failed", {}, this.error);
}
}
cancelGeneration(): void {
if (this.streamingState === "idle") return;
this._submission?.cancel();
this.streamingState = "idle";
this._submission = null;
log("AgentChat.Model", "REASON", "Generation cancelled");
}
async resumeConfirm(action: "confirm" | "deny"): Promise<void> {
if (this.streamingState !== "awaiting_confirmation") return;
if (!this.pendingThreadId || !this.currentConversationId) return;
log("AgentChat.Model", "REASON", `HITL resume: ${action}`, { threadId: this.pendingThreadId });
try {
const submission = this._client!.submit("/chat",
{ text: action === "confirm" ? "confirm" : "deny" },
[this.currentConversationId, action],
);
for await (const event of submission) {
const msg: AgentMessage = event.data;
this._onStreamData(msg);
}
this.streamingState = "idle";
this.pendingThreadId = null;
} catch (e: unknown) {
this.streamingState = "error";
this.error = e instanceof Error ? e.message : "Resume failed";
log("AgentChat.Model", "EXPLORE", "HITL resume failed", {}, this.error);
}
}
async loadConversations(reset: boolean = false): Promise<void> {
log("AgentChat.Model", "REASON", "Loading conversations", { reset });
throw new Error("TODO: implement loadConversations — GET /api/assistant/conversations, infinite scroll, merge or replace list");
}
async loadHistory(conversationId: string | null = null): Promise<void> {
this.isLoadingHistory = true;
this.error = null;
log("AgentChat.Model", "REASON", "Loading history", { conversationId });
throw new Error("TODO: implement loadHistory — GET /api/assistant/history, render messages");
}
async retryConnection(): Promise<void> {
log("AgentChat.Model", "REASON", "Manual reconnect");
this._reconnectAttempts = 0;
throw new Error("TODO: implement retryConnection — Client.connect() to Gradio, reset reconnect counter");
}
createConversation(): void {
this.messages = [];
this.currentConversationId = null;
this.streamingState = "idle";
this.error = null;
this.partialText = "";
this.activeToolCalls = [];
this.pendingThreadId = null;
this._historyPage = 1;
this._historyHasNext = false;
log("AgentChat.Model", "REASON", "New conversation created");
}
// ── Actions — P2 ───────────────────────────────────────────────
async deleteConversation(id: string): Promise<void> {
this.conversations = this.conversations.filter((c) => c.id !== id);
log("AgentChat.Model", "REASON", "Archiving conversation (optimistic)", { id });
throw new Error("TODO: implement deleteConversation — DELETE /api/assistant/conversations/{id}, soft-delete (archive), rollback on failure");
}
// ── Private — Stream metadata handler ──────────────────────────
private _onStreamData(msg: AgentMessage): void {
const meta = msg.metadata;
if (!meta || !meta.type) {
// Plain text token — append to last assistant message
this.partialText += msg.text;
return;
}
switch (meta.type) {
case "stream_token":
this.partialText += meta.token ?? "";
break;
case "tool_start":
this.activeToolCalls = [...this.activeToolCalls, {
tool: meta.tool ?? "unknown",
input: meta.input ?? {},
status: "executing",
}];
break;
case "tool_end":
this.activeToolCalls = this.activeToolCalls.map((tc) =>
tc.tool === meta.tool
? { ...tc, output: meta.output, status: "completed" }
: tc
);
break;
case "tool_error":
this.activeToolCalls = this.activeToolCalls.map((tc) =>
tc.tool === meta.tool
? { ...tc, error: meta.error, status: "failed" }
: tc
);
break;
case "confirm_required":
this.streamingState = "awaiting_confirmation";
this.pendingThreadId = meta.thread_id ?? null;
// Primary stream terminates — model enters awaiting_confirmation
// Second submit() will call resumeConfirm()
break;
case "confirm_resolved":
this.streamingState = meta.result === "confirmed" ? "streaming" : "idle";
this.pendingThreadId = null;
break;
case "error":
this.streamingState = "error";
this.error = meta.detail ?? meta.code ?? "Unknown error";
break;
default:
log("AgentChat.Model", "EXPLORE", "Unknown metadata type", { type: meta.type });
}
}
// ── Private — Connection lifecycle ─────────────────────────────
private _onDisconnect(): void {
this.connectionState = "disconnected";
this._reconnectAttempts = 0;
log("AgentChat.Model", "EXPLORE", "Gradio disconnected");
throw new Error("TODO: implement _onDisconnect — start reconnect interval (5×5s), transition to connected or disconnected_permanent");
}
private _onReconnect(): void {
this.connectionState = "connected";
this.streamingState = "idle";
this._reconnectAttempts = 0;
log("AgentChat.Model", "REFLECT", "Gradio reconnected");
throw new Error("TODO: implement _onReconnect — clear timer, restore connected state");
}
}
// #endregion AgentChat.Model

View File

@@ -9,22 +9,48 @@ from datetime import datetime
from pathlib import Path
REVIEW_PROMPT = (
"Другая LLM создала этот feature-пакет. Твоя задача - провести независимое "
"ортогональное spec review, оценить готовность спецификации, найти противоречия, "
"пробелы, риски реализации и подготовить структурированный отчет с корректировками. "
"Сфокусируйся именно на review пакета спецификации, а не на переписывании реализации."
"Another LLM created this feature package. Your task: conduct an independent "
"orthogonal spec review. Evaluate readiness, find contradictions, gaps, "
"implementation risks, and prepare a structured report with corrections. "
"Focus on spec review, not rewriting the implementation."
)
# Canonical artifact order for the merged output.
# Each stage: (match_type, value, section_label)
# match_type "exact" = exact filename match
# match_type "prefix" = directory prefix — matches all files under that dir
CANONICAL_MD_STAGES = (
("exact", "spec.md"),
("exact", "ux_reference.md"),
("prefix", "checklists/"),
("exact", "plan.md"),
("exact", "research.md"),
("exact", "data-model.md"),
("prefix", "contracts/"),
("exact", "quickstart.md"),
("exact", "tasks.md"),
# Layer 1: Requirements & UX narrative
("exact", "spec.md", "SPEC — Feature Specification"),
("exact", "ux_reference.md", "UX REFERENCE — Interaction Narrative"),
("prefix", "checklists/", "CHECKLISTS — Requirements Quality"),
# Layer 2: UX Design (from /speckit.ux)
("exact", "contracts/ux/alternatives.md", "UX ALTERNATIVES — Design Space Explored"),
("exact", "contracts/ux/decisions.md", "UX DECISIONS — Final Choices"),
("exact", "contracts/ux/screen-models.md", "UX SCREEN MODELS — Model Inventory"),
("exact", "contracts/ux/api-ux.md", "UX API CONTRACT — Endpoints & Shapes"),
("prefix", "contracts/ux/", "UX DESIGN — Per-Screen Contracts"),
("exact", "contracts/ux/design-tokens.md", None), # force-first within prefix
# Layer 3: Implementation Plan
("exact", "plan.md", "PLAN — Implementation Plan"),
("exact", "research.md", "RESEARCH — Technical Decisions"),
("exact", "data-model.md", "DATA MODEL — Entities & Relations"),
("prefix", "contracts/", "CONTRACTS — Module & Function Contracts"),
("exact", "quickstart.md", "QUICKSTART — Dev Onboarding"),
# Layer 4: Traceability
("exact", "traceability.md", "TRACEABILITY — Requirements Matrix"),
# Layer 5: Execution
("exact", "tasks.md", "TASKS — Implementation Tasks"),
)
ORDER_HINT = (
"Artifact order: spec → ux_reference → checklists → "
"UX (alternatives → decisions → screen-models → api-ux → per-screen → design-tokens) → "
"plan → research → data-model → contracts → quickstart → traceability → tasks → remaining."
)
@@ -33,44 +59,60 @@ def relative_key(path: Path, root: Path) -> str:
def ordered_markdown_files(target_dir: Path) -> list[Path]:
"""Order markdown files according to CANONICAL_MD_STAGES."""
markdown_files = [path for path in target_dir.rglob("*.md") if path.is_file()]
remaining = {relative_key(path, target_dir): path for path in markdown_files}
ordered: list[Path] = []
# Track which stages have already been matched to avoid duplicates
seen = set()
for stage_type, stage_value in CANONICAL_MD_STAGES:
for stage_type, stage_value, _section_label in CANONICAL_MD_STAGES:
if stage_type == "exact":
path = remaining.pop(stage_value, None)
if path is not None:
# Exact: only match if the file exists and hasn't been collected yet
rel = f"contracts/ux/{stage_value}" if stage_value.startswith("contracts/ux/") else stage_value
if stage_value.startswith("contracts/ux/"):
path = remaining.pop(stage_value, None)
else:
path = remaining.pop(stage_value, None)
if path is not None and path not in seen:
ordered.append(path)
seen.add(path)
continue
# Prefix: match all files whose relative path starts with stage_value
stage_matches = sorted(
[
path
for relative_path, path in remaining.items()
if relative_path.startswith(stage_value)
for rel_path, path in remaining.items()
if rel_path.startswith(stage_value)
],
key=lambda path: relative_key(path, target_dir),
key=lambda p: relative_key(p, target_dir),
)
ordered.extend(stage_matches)
for path in stage_matches:
remaining.pop(relative_key(path, target_dir), None)
if path not in seen:
ordered.append(path)
seen.add(path)
rel = relative_key(path, target_dir)
remaining.pop(rel, None)
ordered.extend(
sorted(remaining.values(), key=lambda path: relative_key(path, target_dir))
)
# Remaining files — append at end
remaining_paths = sorted(remaining.values(), key=lambda p: relative_key(p, target_dir))
for path in remaining_paths:
if path not in seen:
ordered.append(path)
return ordered
def merge_specs(feature_number):
def merge_specs(feature_number: str) -> str | None:
"""Merge all spec artifacts for a feature into one review file."""
specs_dir = Path("specs")
if not specs_dir.exists():
print("Error: 'specs' directory not found.")
return
return None
# Find the directory starting with the feature number
# Find directory starting with feature number
target_dir = None
for item in specs_dir.iterdir():
if item.is_dir() and item.name.startswith(f"{feature_number}-"):
@@ -78,10 +120,8 @@ def merge_specs(feature_number):
break
if not target_dir:
print(
f"Error: No directory found for feature number '{feature_number}' in 'specs/'."
)
return
print(f"Error: No directory found for feature number '{feature_number}' in 'specs/'.")
return None
feature_name = target_dir.name
now = datetime.now().strftime("%Y%m%d-%H%M%S")
@@ -90,7 +130,11 @@ def merge_specs(feature_number):
content_blocks = [
REVIEW_PROMPT,
"",
"Порядок артефактов: spec -> ux_reference -> checklist -> plan -> research -> data-model -> contracts -> quickstart -> tasks -> remaining markdown.",
"=" * 80,
ORDER_HINT,
f"Feature: {feature_name}",
f"Generated: {datetime.now().isoformat()}",
"=" * 80,
"",
]
@@ -99,19 +143,44 @@ def merge_specs(feature_number):
for file_path in files_to_merge:
relative_path = file_path.relative_to(target_dir)
try:
with open(file_path, "r", encoding="utf-8") as f:
file_content = f.read()
content_blocks.append(f"--- FILE: {relative_path} ---\n")
content_blocks.append(file_content)
content_blocks.append("\n")
content = file_path.read_text(encoding="utf-8")
except Exception as e:
print(f"Skipping file {file_path} due to error: {e}")
print(f"Skipping {file_path}: {e}")
continue
with open(output_filename, "w", encoding="utf-8") as f:
f.write("\n".join(content_blocks))
# Determine section label
section_label = str(relative_path)
for stage_type, stage_value, label in CANONICAL_MD_STAGES:
if label is None:
continue
if stage_type == "exact" and str(relative_path) == stage_value:
section_label = label
break
if stage_type == "prefix" and str(relative_path).startswith(stage_value):
section_label = f"{label}{relative_path.name}"
break
print(f"Successfully created: {output_filename}")
content_blocks.append("")
content_blocks.append("-" * 60)
content_blocks.append(f"## {section_label}")
content_blocks.append(f"Source: {relative_path}")
content_blocks.append("-" * 60)
content_blocks.append("")
content_blocks.append(content)
content_blocks.append("")
# Summary
content_blocks.append("")
content_blocks.append("=" * 80)
content_blocks.append(f"Files merged: {len(files_to_merge)}")
content_blocks.append("=" * 80)
merged = "\n".join(content_blocks)
output_filename = output_filename
Path(output_filename).write_text(merged, encoding="utf-8")
print(f"Successfully created: {output_filename} ({len(files_to_merge)} files)")
return output_filename
if __name__ == "__main__":
@@ -120,6 +189,7 @@ if __name__ == "__main__":
sys.exit(1)
merge_specs(sys.argv[1])
# #endregion merge_spec
# #endregion MergeSpec

View File

@@ -0,0 +1,85 @@
# Requirements Quality Checklist: Gradio Agent Chat
**Purpose**: Validate spec.md against the constitution, ADRs, and ss-tools repository reality. Surface gaps before `/speckit.clarify` or `/speckit.plan`.
**Created**: 2026-06-08
**Feature**: `specs/033-gradio-agent-chat/spec.md`
## 1. No Implementation Leakage
- [x] CHK001 Spec describes WHAT the user needs — not Python module names, file paths, or Svelte component internals.
- [x] CHK002 No Pydantic schema names, SQLAlchemy table names, or internal function signatures appear in spec.
- [x] CHK003 Architecture diagram is conceptual (shows systems, not classes) — appropriate for spec level.
- [x] CHK004 Functional requirements use domain language: "agent", "tool", "streaming", "conversation" — not "WebSocket handler", "SSE endpoint", "gr.ChatInterface".
## 2. ss-tools Stack Compatibility
- [x] CHK005 Gradio integration reuses existing `@assistant_tool` registry (ADR-0008) — no duplicate tool registration.
- [x] CHK006 Svelte frontend remains the primary application shell (ADR-0006: Svelte 5 runes mandate).
- [x] CHK007 `@gradio/client` npm package is the bridge between Svelte and Python Gradio — documented and feasible.
- [x] CHK008 Authentication model reuses existing JWT tokens (ADR-0005: RBAC).
- [x] CHK009 Gradio backend runs within same Docker network — no new external dependencies exposed publicly.
- [x] CHK010 Existing assistant REST endpoints (`/api/assistant/*`) preserved during transition (FR-020).
## 3. Measurable Success Criteria
- [x] CHK011 SC-001: Tool selection accuracy ≥85% — measurable via acceptance test suite.
- [x] CHK012 SC-002: First token latency ≤1.5s for 90% of queries — measurable via client-side timing.
- [x] CHK013 SC-003: Dangerous ops confirmation rate 100% — measurable via audit log verification.
- [x] CHK014 SC-004: Permission bypass rate 0% — measurable via security test suite.
- [x] CHK015 SC-005: Conversation persistence across restart — measurable via integration test.
- [x] CHK016 SC-006: Speed improvement ≥30% vs manual UI — measurable via usability benchmark.
- [x] CHK017 SC-007: File upload ≤10MB without responsiveness degradation — measurable via load test.
- [x] CHK018 SC-008: Existing UX patterns preserved — measurable via regression test suite.
## 4. Edge Cases & Recovery
- [x] CHK019 Gradio backend unreachable → connection error with retry (FR-014).
- [x] CHK020 Malformed LLM tool-call JSON → retry + clarification fallback (Edge Cases).
- [x] CHK021 Message exceeds token limit → truncation + warning (Edge Cases).
- [x] CHK022 Tool execution timeout → async progress indicator (FR-012, Edge Cases).
- [x] CHK023 Rapid-fire messages → sequential queue with position display (Edge Cases).
- [x] CHK024 Gradio restart mid-conversation → persisted history survives, streaming session lost (Edge Cases).
- [x] CHK025 Confirmation token expiry → inform user, offer re-issue (Edge Cases).
- [x] CHK026 Multiple browser tabs with same conversation → reject concurrent writes (Edge Cases).
- [x] CHK027 Network loss during streaming → detect disconnection, offer reconnect (Edge Cases).
## 5. Decision-Memory Readiness
- [x] CHK028 `@RATIONALE` in spec documents why Gradio was chosen over custom SSE/WebSocket implementation.
- [x] CHK029 `@REJECTED` documents why full Svelte replacement was rejected (ADR-0006 violation).
- [x] CHK030 `@REJECTED` documents why building streaming from scratch was rejected.
- [x] CHK031 Phase 1 research.md will need to resolve: Gradio `gr.ChatInterface` vs `gr.Blocks` + `gr.Chatbot` choice.
- [x] CHK032 Phase 1 research.md will need to resolve: `@gradio/client` streaming event API surface for Svelte 5 runes integration.
- [x] CHK033 Phase 1 research.md will need to resolve: Agent framework choice (LangChain agent executor vs custom loop vs smolagents).
## 6. Completeness
- [x] CHK034 All 6 user stories are independently testable (each has an "Independent Test" sentence).
- [x] CHK035 P1 stories cover the MVP: streaming chat + autonomous tool use.
- [x] CHK036 P2 stories cover UX quality: tool visibility + conversation context.
- [x] CHK037 P3 stories cover polish: persistence + file upload.
- [x] CHK038 Key entities defined: AgentConversation, AgentMessage, AgentToolCall, AgentStreamingSession, AgentMemory.
- [x] CHK039 20 functional requirements cover: streaming, tool use, RBAC, persistence, UX preservation, backward compatibility.
- [x] CHK040 Dependencies section names specific existing artifacts to integrate with.
## 7. Constitution Alignment
- [x] CHK041 Principle I (Semantic Contract First): Spec uses `#region`/`#endregion` anchors with complexity tiers.
- [x] CHK042 Principle II (Decision Memory): `@RATIONALE` and `@REJECTED` annotations present for architectural choices.
- [x] CHK043 Principle III (External Orchestrator): Gradio backend calls existing APIs — does not embed in Superset.
- [x] CHK044 Principle IV (Module Discipline): New Gradio code placed in clear module structure (planning phase will define).
- [x] CHK045 Principle V (RBAC): FR-005, FR-006, SC-004 enforce existing RBAC on all agent tool calls.
- [x] CHK046 Principle VI (Svelte 5 Runes): Frontend changes use Svelte 5 runes; Gradio is backend-only.
- [x] CHK047 Principle VIII (Attention-Optimized): Spec uses hierarchical IDs (`AgentChat.Spec`), one-line anchors with @SEMANTICS tags, ATTN_2 domain prefixes.
## 8. Unresolved Markers
- [x] CHK048 No `[NEEDS CLARIFICATION]` markers remain in spec.md. All ambiguities resolved with informed defaults.
- [x] CHK049 No `[NEED_CONTEXT: ...]` markers — all dependencies identified in the Dependencies section.
## Notes
- **Agent framework choice** (LangChain vs smolagents vs custom) is deferred to research.md in Phase 1 — this is an implementation detail, not a spec ambiguity.
- **Gradio auth bridge** (how JWT is forwarded to Gradio) needs resolution in planning phase — `@gradio/client` options include auth headers; exact mechanism TBD.
- **Gradio backend deployment** (separate container vs co-located with FastAPI) is an infrastructure decision for plan.md, not spec.md.
- All checklist items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.

View File

@@ -0,0 +1,30 @@
#region AgentChat.Contracts [C:4] [TYPE ADR] [SEMANTICS contracts,modules,agent-chat,final]
@BRIEF Final module contracts — LangChain v1, PostgreSQL checkpointer, structured JSON metadata, dual-identity RBAC.
## Backend
| Contract | C | File | Notes |
|----------|---|------|-------|
| AgentChat.GradioApp | C4 | `backend/src/agent/app.py` | `gr.ChatInterface(type="messages", multimodal=True, additional_inputs=[gr.Textbox("conversation_id")], examples=[["Покажи дашборды",null],["Статус системы",null]], concurrency_limit=1)`. Handler: `def handler(message, history, request: gr.Request, conversation_id=None)` |
| AgentChat.GradioApp.Handler | C4 | ↑ | Creates `create_react_agent()` with PostgresSaver. `astream_events(config={"configurable":{"thread_id":conversation_id}})`. Resume: detects `"confirm"/"deny"` in additional_inputs, invokes `Command(resume=...)` |
| AgentChat.LangChain.Setup | C4 | `backend/src/agent/langchain_setup.py` | `create_agent(model, tools=ALL_TOOLS, middleware=[LoggingMiddleware(), HumanInTheLoopMiddleware(interrupt_on=DANGEROUS)], checkpointer=PostgresSaver(conn_string))`. Wrapped in `RunnableWithMessageHistory` |
| AgentChat.Tools | C4 | `backend/src/agent/tools.py` | Native `@tool` functions. Each: Pydantic `BaseModel` args_schema, calls FastAPI REST with dual-identity headers |
| AgentChat.Middleware | C3 | `backend/src/agent/middleware.py` | `LoggingMiddleware`, risk config dict |
| AgentChat.Document.Parser | C3 | `backend/src/agent/document_parser.py` | pdfplumber + openpyxl |
| AgentChat.Api.Conversations | C3 | `backend/src/api/routes/agent_conversations.py` | CRUD + multi-tab gate |
| AgentChat.Api.ServiceToken | C3 | `backend/src/api/routes/auth.py` | `POST /api/auth/service-token` |
| Models.Agent | C2 | `backend/src/models/agent.py` | AgentConversation, AgentMessage |
| Schemas.Agent | C1 | `backend/src/schemas/agent.py` | Pydantic |
## Frontend
| Contract | C | File | Notes |
|----------|---|------|-------|
| AgentChat.Model | C4 | `AgentChatModel.svelte.ts` | `submit()` lifecycle, metadata parsing, resume logic. No tabRole, no follower |
| AgentChat.Panel | C4 | `AssistantChatPanel.svelte` | `Client.connect("/api/agent/gradio")`, `submit()`, metadata-based rendering |
| AgentChat.Page | C3 | `frontend/src/routes/agent/+page.svelte` | Two-column |
| AgentChat.ConversationList | C3 | `ConversationList.svelte` | Search, infinite scroll, date grouping |
| AgentChat.ToolCallCard | C2 | `ToolCallCard.svelte` | Props from metadata |
| AgentChat.ConfirmationCard | C3 | `ConfirmationCard.svelte` | Calls second `submit()` with `__resume__` |
#endregion AgentChat.Contracts

View File

@@ -0,0 +1,88 @@
#region AgentChat.Ux [C:3] [TYPE ADR] [SEMANTICS ux,agent-chat,panel,native-stack]
@defgroup Ux UX contract — Gradio submit() + LangChain v1 astream_events(). Structured JSON metadata (ChatMessage.metadata). No REST, no WebSocket, no follower.
## FSM
```
idle ──[submit("/chat")]──→ streaming ──[metadata.type: stream_token]──→ (tokens appear)
streaming ──[metadata.type: tool_start]──→ (tool card + spinner)
streaming ──[metadata.type: tool_end]──→ (tool done, checkmark)
streaming ──[metadata.type: tool_error]──→ (tool error, cross)
streaming ──[metadata.type: confirm_required]──→ awaiting_confirmation
awaiting_confirmation ──[2nd submit(__resume__,"confirm")]──→ streaming (confirm_resolved)
awaiting_confirmation ──[2nd submit(__resume__,"deny")]──→ idle (confirm_resolved: denied)
awaiting_confirmation ──[timeout 5min]──→ idle (expired)
streaming ──[final chunk]──→ idle
streaming ──[submission.cancel()]──→ idle (partial)
streaming ──[metadata.type: error]──→ error
idle ──[load history]──→ loading_history ──[success]──→ idle
loading_history ──[failure]──→ error
idle ──[connect fail]──→ disconnected ──[retry 5×5s]──→ idle
disconnected ──[retry exhausted]──→ disconnected_permanent
```
## State Mappings
| @UX_STATE | Visual | ARIA | User Can |
|-----------|--------|------|----------|
| idle (with msgs) | Input enabled, Send active, Stop hidden. Connection dot green. | — | Send, switch/delete convos, expand to /agent |
| idle (new convo) | Welcome card + examples chips (Gradio `examples`). | `aria-label="Новый диалог"` | Click chip, type |
| streaming | Tokens appear in real time. Tool cards inline. Send→Stop (destructive). Input locked. | `aria-live="polite" aria-busy="true"` | Stop, expand tool cards |
| awaiting_confirmation | Streaming pauses. Warning card: `bg-warning-light border-warning-DEFAULT`. Prompt text + Confirm/Deny buttons. Timer 5:00→0:00. | `role="alertdialog"` | Confirm, Deny |
| awaiting_confirmation (expired) | Card stays. Buttons disabled. Timer=0. "Повторить запрос?" | — | Retry |
| error | Card: `bg-destructive-light border-destructive-ring`. Error + Retry. Partial text preserved. | `role="alert"` | Retry, new message |
| disconnected | Dot red. Banner: "Агент недоступен. Переподключение через Xс..." | `role="alert"` | Read history, manual reconnect |
| disconnected_permanent | Dot red. Manual reconnect button only. | `role="alert"` | Manual reconnect |
| loading_history | Skeleton cards (3-5, `animate-pulse bg-surface-muted`). Input locked. | `aria-busy="true"` | Wait |
## Feedback
| Trigger | Feedback |
|---------|----------|
| `metadata.type: stream_token` | Append token to assistant message |
| `metadata.type: tool_start` | Tool card: name (mono) + spinner |
| `metadata.type: tool_end` | Spinner→checkmark, result in tooltip |
| `metadata.type: tool_error` | Spinner→cross, error in tooltip |
| `metadata.type: confirm_required` | Confirmation card + timer, Confirm/Deny |
| `metadata.type: confirm_resolved(result=confirmed)` | Card collapses, streaming resumes |
| `metadata.type: confirm_resolved(result=denied)` | Card collapses, agent responds |
| Stream complete | Stop→Send, input enabled |
| Connection lost | Dot red. Banner. Retry countdown |
| Connection restored | Dot green. Banner dismissed |
## Recovery
| From | Action | To |
|------|--------|-----|
| error | Retry (re-sends last msg) | streaming |
| disconnected | Auto-retry 5×5s or manual | idle |
| awaiting_confirmation (expired) | "Повторить запрос" | streaming |
| streaming (cancelled) | Type new msg | idle→streaming |
| empty (no convos) | Click chip or type | streaming |
## Reactivity
- `AgentChat.Model` atoms → component props → DOM
- `@gradio/client` `submit()``for await (event)``model._onStreamData()``$state` update
- No `$effect` for data loading — all via stream events
- `assistantChatStore.conversationId` → model reads (read-only)
## UX Tests
| @UX_TEST | Given | When | Then |
|----------|-------|------|------|
| Happy path | idle (new) | Type "привет", Send | streaming, tokens appear, idle |
| Tool call | streaming | Agent calls tool | Card with spinner→checkmark |
| Dangerous confirm | streaming | Agent wants deploy | "⏸️" card, confirm_id, timer |
| Confirm accepted | awaiting_confirmation | 2nd submit(__resume__) | "▶️", streaming resumes |
| Confirm denied | awaiting_confirmation | 2nd submit(deny) | "⏹️", idle |
| Stream cancel | streaming | submission.cancel() | idle, partial text |
| LLM error | streaming | LLM fails | "Ошибка: ..." + retry |
| Gradio disconnect | idle | Gradio dies | Dot red, 5×5s retry |
| Multi-tab gate | idle | Tab 2 opens same convo | Tab 2: send rejected |
| Empty convo | idle (new) | Load | Examples chips visible |
| History load error | idle | API fails | Error + retry |
#endregion AgentChat.Ux

View File

@@ -0,0 +1,90 @@
#region AgentChat.ApiUx [C:3] [TYPE ADR] [SEMANTICS ux,api,agent-chat,final]
@defgroup Ux Final API contract — Gradio `submit()`, LangChain v1 HITL via second submit(), PostgreSQL checkpointer, structured metadata.
## Gradio Transport
**Connection**: `Client.connect("/api/agent/gradio")` → nginx proxy → Gradio. JWT forwarded.
**Primary submit**: `app.submit("/chat", {message, conversation_id})` — two-arg API. `conversation_id` is an `additional_inputs` key.
**Examples on UI** (for `additional_inputs`): `[["Покажи дашборды", null], ["Статус системы", null]]`.
**Cancel**: `submission.cancel()`.
### Streaming Events
`for await (event of submission)``event.data` is a `ChatMessage` with structured `metadata`:
| `metadata.type` | `metadata` fields | UX Reaction |
|-----------------|-------------------|-------------|
| `stream_token` | `{token: "При"}` | Append token to last assistant msg |
| `tool_start` | `{tool: "search_dashboards", input: {...}}` | Tool card: name + spinner |
| `tool_end` | `{tool: "search_dashboards", output: {...}}` | Spinner→checkmark |
| `tool_error` | `{tool: "search_dashboards", error: "..."}` | Spinner→cross + detail |
| `confirm_required` | `{prompt: "Подтвердить деплой?", thread_id: "..."}` | Confirmation card + timer |
| `confirm_resolved` | `{result: "confirmed"/"denied"}` | Card collapses |
| `error` | `{code: "LLM_UNAVAILABLE", detail: "..."}` | Error card + retry |
| (none) | plain `{content: "..."}` | Normal text |
### HITL Resume Protocol
```
Primary: submit("/chat", {message, conversation_id})
→ handler: agent.astream_events(config={"thread_id": conversation_id})
→ HITL fires → checkpoint saved (PostgreSQL)
→ handler: yield {metadata: {type: "confirm_required", thread_id: conversation_id}}
→ Svelte: renders confirmation card (stream still open)
Confirm: submit("/chat", message, [conversation_id, "confirm"])
→ handler: detects "confirm" in additional_inputs[1]
→ loads checkpoint by conversation_id as thread_id
→ invokes graph with Command(resume={"action": "confirm"}, config={"thread_id": conversation_id})
Deny: submit("/chat", message, [conversation_id, "deny"])
→ handler: Command(resume={"action": "deny"}, config=...)
```
---
## REST Endpoints
| Method | Endpoint | Purpose |
|--------|----------|---------|
| `POST` | `/api/assistant/conversations` | Create → `{id, title, created_at}` |
| `GET` | `/api/assistant/conversations` | Paginated list + search |
| `GET` | `/api/assistant/history` | Paginated messages |
| `POST` | `/api/assistant/conversations/{id}/messages` | Append after stream_end |
| `DELETE` | `/api/assistant/conversations/{id}` | Soft-delete (is_archived=true) |
| `GET` | `/api/agent/conversations/{id}/active` | Multi-tab gate |
| `POST` | `/api/auth/service-token` | Gradio obtains service JWT |
---
## Dual Identity RBAC
```
Gradio handler:
service_jwt = POST /api/auth/service-token (authenticates the agent service)
user_jwt = request.headers["Authorization"] (browser user)
@tool function:
call FastAPI with:
Authorization: Bearer {service_jwt} (agent is authorized to call)
X-User-JWT: {user_jwt} (operation authorized under user)
FastAPI endpoint:
validates service JWT → agent is legitimate
validates X-User-JWT → user has RBAC permission for this operation
audit: {service_actor: "agent", user_actor: user_id, operation, ...}
```
## Error Taxonomy
| Code | Source | UX |
|------|--------|-----|
| `UNAUTHORIZED` | JWT | Redirect /login |
| `FORBIDDEN` | RBAC | metadata.type="error" in stream |
| `LLM_UNAVAILABLE` | LangChain | metadata.type="error" → card + retry |
| `GRADIO_UNREACHABLE` | connect | Dot red, retry 5×5s |
| `TOOL_FAILURE` | FastAPI | metadata.type="tool_error" |
#endregion AgentChat.ApiUx

View File

@@ -0,0 +1,48 @@
#region AgentChat.UxDecisions [C:3] [TYPE ADR] [SEMANTICS ux,decisions,agent-chat,native-stack]
@defgroup Ux UX design decisions — revised for LangChain v1 native stack + Gradio native features. Replaces prior decisions.md.
## Screen: Agent Chat Panel
### Phase 1 — Screen Decomposition
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Navigation** | C — Гибрид: drawer 420px + кнопка «Развернуть» → `/agent` | Быстрые вопросы в drawer; сложные на `/agent` |
| **Layout** | B — Две колонки на `/agent`, одно колоночное в drawer | Структурированно для `/agent`, drawer компактный |
| **Data density** | B — Infinite scroll для сообщений и диалогов + поиск | Для активного оператора с 50+ диалогами |
### Phase 2 — State Exhaustion
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Чипсы примеров** | A — Только в новом пустом диалоге | Welcome-зона. Gradio `examples` рендерит их нативно |
| **Cancel** | A — Единая кнопка Stop с момента отправки. `submission.cancel()` | Нативный Gradio cancel |
| **Confirmation timeout** | A — Карточка остаётся, кнопки заблокированы, «Повторить» | Явный контроль |
| **Reconnect** | A — 5×5s, затем disconnected_permanent + ручная кнопка | Предсказуемо |
| **Ошибка истории** | A — Сразу ошибка, без кэша | Консистентность |
| **Multi-tab** | A — Client-side gate: `GET /api/agent/conversations/{id}/active` перед отправкой. Отклоняет вторую вкладку | Просто, без server-push. `concurrency_limit=1` на Gradio исключает гонки |
### Phase 3 — Interaction Design
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Отправка** | Gradio `submit()` с `additional_inputs=[conversation_id]` | Нативный механизм. `RunnableWithMessageHistory` загружает историю автоматически |
| **Stop** | `submission.cancel()` — optimistic UI | Нативный Gradio cancel. Частичный текст сохранён |
| **Подтверждение** | Second `submit()` с `__resume__` + `__action__`. In-memory pending interrupts в handler | Два submit() к одному стриму. HumanInTheLoopMiddleware + чекпоинт |
### Phase 4 — API Design
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **Транспорт** | Gradio `submit()` через nginx proxy. Structured JSON metadata (`ChatMessage.metadata.type`) | Нулевой custom код. LangGraph события → структурированные объекты |
| **Диалоги REST** | Плоский список с сервера, группировка на клиенте | API не зависит от визуализации |
## Rejected Alternatives
- **Custom WebSocket protocol** — rejected: @gradio/client не raw WebSocket
- **REST confirmation endpoints** — rejected: HumanInTheLoopMiddleware + resume protocol проще
- **Ручная передача history** — rejected: RunnableWithMessageHistory делает автоматически
- **Active/follower multi-tab** — rejected: client-side gate + concurrency_limit=1 достаточно
- **@assistant_tool registry для новых тулов** — rejected: нативные @tool функции LangChain
#endregion AgentChat.UxDecisions

View File

@@ -0,0 +1,113 @@
#region AgentChat.DesignTokens [C:2] [TYPE ADR] [SEMANTICS ux,tokens,agent-chat]
@defgroup Ux Design tokens and component reuse for Gradio Agent Chat (drawer + /agent page).
## Component Reuse Scan
| Need | Existing Asset | Action |
|------|---------------|--------|
| Primary button (send, confirm, retry) | `$lib/ui/Button.svelte` variant="primary" | Reuse |
| Secondary/ghost (cancel, delete) | `$lib/ui/Button.svelte` variant="ghost" | Reuse |
| Destructive (stop, delete confirm) | `$lib/ui/Button.svelte` variant="destructive" | Reuse |
| Empty state (new convo, no history) | `$lib/ui/EmptyState.svelte` | Reuse |
| Page header (/agent page) | `$lib/ui/PageHeader.svelte` | Reuse |
| Icons (send, stop, paperclip, expand) | `$lib/ui/Icon.svelte` | Reuse — name="send"/"stop"/"paperclip"/"chevron-down" |
| Toast notifications | `addToast()` from `$lib/toasts` | Reuse |
| Confirmation (convo delete) | `confirm()` (native browser) | Pattern |
| Custom confirmation (in-chat) | New `ConfirmationCard` inline within message | New component |
| Tool-call card | New `ToolCallCard` inline within message | New component |
---
## Semantic Tokens — Drawer (420px)
### Chat Panel Shell
| Element | Token |
|---------|-------|
| Drawer background | `bg-surface-page` |
| Drawer left border | `border-l border-border` |
| Header (convo switcher + expand btn) | `bg-surface-card border-b border-border` |
| Message scroll area | `bg-surface-page` |
| Input area | `border-t border-border bg-surface-card` |
### Messages
| Element | Token |
|---------|-------|
| User bubble | `bg-primary text-white rounded-2xl` |
| Assistant bubble | `bg-surface-card border border-border rounded-2xl` |
| Streaming cursor | `animate-pulse text-primary` (blinking block, 0.3s) |
| System/info message | `bg-surface-muted text-text-muted text-sm rounded-lg` |
### Tool-Call Cards
| Element | Token |
|---------|-------|
| Card | `bg-surface-muted border border-border rounded-lg px-3 py-2` |
| Tool name | `text-text-muted text-xs font-mono` |
| Spinner (executing) | `animate-spin text-primary` |
| Checkmark (done) | `text-success` |
| Cross (error) | `text-destructive` |
| Expanded content | `text-text-muted text-xs font-mono overflow-auto max-h-32` |
### Confirmation Cards
| Element | Token |
|---------|-------|
| Card | `bg-warning-light border border-warning-DEFAULT rounded-lg p-3` |
| Warning icon | `text-warning` |
| Op summary | `text-text font-medium` |
| Timer | `text-text-muted text-xs` |
### Connection Status
| Element | Token |
|---------|-------|
| Connected (green) | `bg-success w-2 h-2 rounded-full` |
| Reconnecting (yellow) | `bg-warning w-2 h-2 rounded-full animate-pulse` |
| Disconnected (red) | `bg-destructive w-2 h-2 rounded-full` |
### Input Area
| Element | Token |
|---------|-------|
| Textarea | `border border-border-strong rounded-xl focus:border-primary focus:ring-1 focus:ring-primary-ring` |
| Send button | `bg-primary text-white rounded-full` |
| Stop button | `bg-destructive text-white rounded-full` |
---
## Semantic Tokens — `/agent` Page
### Page Layout
| Element | Token |
|---------|-------|
| Page background | `bg-surface-page` |
| Page shell | `max-w-7xl mx-auto px-4 py-6 h-[calc(100vh-4rem)]` |
| Two-column container | `flex gap-0 h-full` |
### Conversation Sidebar (240px)
| Element | Token |
|---------|-------|
| Sidebar | `bg-surface-card border-r border-border w-60 shrink-0` |
| Search input | `border border-border-strong rounded-lg focus:border-primary` |
| Active convo highlight | `bg-surface-muted` |
| Convo title | `text-text text-sm font-medium truncate` |
| Convo date group header | `text-text-muted text-xs font-semibold uppercase` |
| Convo date | `text-text-muted text-xs` |
| Delete button (hover) | `text-text-muted hover:text-destructive` |
### Main Chat Area
| Element | Token |
|---------|-------|
| Chat container | `flex-1 flex flex-col` |
| Messages area | `flex-1 overflow-y-auto bg-surface-page` |
| Input area | `border-t border-border bg-surface-card p-4` |
### Welcome / Empty
| Element | Token |
|---------|-------|
| Welcome card (new convo) | `bg-surface-card border border-border rounded-xl p-6 max-w-md mx-auto` |
| Chip (example) | `border border-border rounded-full px-3 py-1.5 text-sm text-text-muted hover:bg-surface-muted hover:text-text cursor-pointer transition` |
---
## No Raw Tailwind Colors
All tokens reference `tailwind.config.js` semantic palette. Zero `blue-*`, `gray-*`, `red-*`, `green-*`, `amber-*`, `slate-*`, `indigo-*` in page/component code. Verification: `scripts/audit-frontend-style.mjs`.
#endregion AgentChat.DesignTokens

View File

@@ -0,0 +1,29 @@
#region AgentChat.ScreenModels [C:3] [TYPE ADR] [SEMANTICS ux,screen-models,agent-chat]
@BRIEF Screen model inventory — revised for LangChain v1 native stack. No active/follower. No WebSocket.
@RATIONALE Model-first per ADR-0010. AgentChatModel handles Gradio submit() lifecycle, streaming state, tool-call tracking, multi-tab gate.
## Screens Requiring Models
| Screen | Route | Model ID | Complexity | Rationale |
|--------|-------|----------|------------|-----------|
| AssistantChatPanel (drawer + embedded) | overlay | `AgentChat.Model` | C4 | Gradio `submit()` lifecycle, streaming, tool-call parsing, confirmation card state, connection health |
| Agent Chat Page (full) | `/agent` | `AgentChat.Model` (shared) | C4 | Same model, two-column layout |
## Screens Using Inline State
| Screen | Reason |
|--------|--------|
| ConversationList | `conversations[]` из модели. Поиск — локальный. Группировка — `$derived` |
| ToolCallCard | Пропсы от родителя |
| ConfirmationCard | Пропсы от модели (`confirm_id`, `prompt`) |
| MessageBubble | Рендерит одно сообщение. Стриминг — через `$derived` |
| ConnectionIndicator | `model.connectionState` → dot color |
## Model Decomposition Gate
| Threshold | Est. | Action |
|-----------|:----:|--------|
| Lines > 400 | ~220 | Within limit |
| Methods > 40 | ~12 | Within limit |
#endregion AgentChat.ScreenModels

View File

@@ -0,0 +1,52 @@
#region AgentChat.DataModel [C:3] [TYPE ADR] [SEMANTICS data-model,agent-chat,final]
@BRIEF Final data model — structured JSON metadata, PostgreSQL checkpointer, dual-identity RBAC, no custom WebSocket types.
## 1. Streaming Metadata Protocol
Each `event.data` is a `ChatMessage` with optional structured `metadata`:
```typescript
interface StreamMetadata {
type?: "stream_token" | "tool_start" | "tool_end" | "tool_error"
| "confirm_required" | "confirm_resolved" | "error";
token?: string;
tool?: string;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
error?: string;
prompt?: string;
thread_id?: string;
result?: "confirmed" | "denied";
code?: string;
detail?: string;
}
```
No `agent-ws.ts`. No custom WebSocket types. No emoji prefix parsing.
## 2. SQLAlchemy
Tables: `agent_conversations`, `agent_messages`. Legacy `assistant_messages` preserved.
## 3. Pydantic Schemas
`ConversationItem`, `ConversationListResponse`, `MessageItem`, `HistoryResponse`, `DeleteResponse`.
## 4. TypeScript DTOs
`frontend/src/types/agent.ts` — matches Pydantic schemas. Includes `StreamMetadata`.
## 5. PostgreSQL Checkpointer
LangGraph checkpointer: `langgraph-checkpoint-postgres`. Thread ID = conversation_id. Survives container restarts. Same PostgreSQL instance as FastAPI.
## 6. Dual Identity RBAC
```
Authorization: Bearer {service_jwt}
X-User-JWT: {user_jwt}
```
Audit: `{service_actor: "agent", user_actor: user_id}`.
#endregion AgentChat.DataModel

View File

@@ -0,0 +1,71 @@
# Implementation Plan: Gradio Agent Chat (LangChain v1 Native Stack)
**Branch**: `033-gradio-agent-chat` | **Date**: 2026-06-08 | **Spec**: `spec.md`
## Summary
Integrate Gradio as agent backend, Svelte frontend via `@gradio/client submit()`. LangGraph agent: `create_react_agent()` + `@tool` + `interrupt()`/`Command(resume=...)` + `RunnableWithMessageHistory` + `PostgresSaver`. Structured JSON metadata streaming. No REST confirmations, no custom WebSocket.
## Technical Context
**Language**: Python 3.9+/TypeScript Svelte 5 runes
**Key Dependencies**: Gradio≥5.0, LangChain≥1.0, langchain-openai≥1.0, langgraph-checkpoint, pdfplumber, openpyxl (back); @gradio/client (front)
**Storage**: PostgreSQL 16 (persistence + checkpoints via langgraph-checkpoint-postgres)
**Testing**: pytest (back), vitest L1 model + L2 UX (front)
**Frontend**: SvelteKit SPA, model-first (AgentChatModel.svelte.ts C4), runes-only
**Target**: Linux/Docker, modern browsers
**Performance**: First token <1.5s, streaming 60fps, file upload 10MB
## Constitution Check — All 8 Principles Passed
| Principle | Compliance |
|-----------|-----------|
| I. Semantic Contract First | All 18 contracts use #region/#endregion, hierarchical IDs, C1-C4 tiers |
| II. Decision Memory | @RATIONALE/@REJECTED on all 8 research decisions; T030 deprecates @assistant_tool |
| III. External Orchestrator | Gradio agent calls FastAPI services; no Superset plugin coupling |
| IV. Module Discipline | All modules <400 lines; model ~220 lines |
| V. RBAC | User JWT forwarded to each tool call (closure factory); permission checks in FastAPI |
| VI. Svelte 5 Runes | $state/$derived only; no legacy syntax |
| VII. Test-Driven C3+ | @TEST_EDGE on all C3+ contracts; 10 test tasks |
| VIII. Attention-Optimized | ATTN_1-4 validated; one-line anchors, hierarchical IDs, shared @SEMANTICS, 150 lines |
## Project Structure
### New/Changed Files
```
backend/src/agent/
├── app.py # gr.ChatInterface + handler + resume protocol
├── langchain_setup.py # create_agent() + middleware + checkpointer
├── tools.py # @tool functions → FastAPI REST
├── middleware.py # LoggingMiddleware, risk config
├── document_parser.py # PDF + XLSX parsing
└── run.py # entrypoint
backend/src/api/routes/
├── agent_conversations.py # CRUD + multi-tab gate
└── auth.py # POST /api/auth/service-token
backend/src/models/agent.py # AgentConversation, AgentMessage
backend/src/schemas/agent.py # Pydantic schemas
frontend/src/lib/models/AgentChatModel.svelte.ts # submit() lifecycle
frontend/src/lib/components/assistant/
├── AssistantChatPanel.svelte # adapted for @gradio/client submit()
├── ConversationList.svelte # new
├── ToolCallCard.svelte # new
├── ConfirmationCard.svelte # new
└── ConnectionIndicator.svelte # new
frontend/src/routes/agent/+page.svelte # new
frontend/src/types/agent.ts # TypeScript DTOs
docker/Dockerfile.agent # new
docker/docker-compose.yml # updated
docker/nginx.conf # updated: proxy /api/agent/gradio
```
## Deprecated
`backend/src/api/routes/assistant/_tool_registry.py` + `_tool_*.py` `@DEPRECATED` Tombstone. Old `@assistant_tool` registry preserved for FR-020.
#endregion (plan.md)

View File

@@ -0,0 +1,57 @@
#region AgentChat.Quickstart [C:2] [TYPE ADR] [SEMANTICS quickstart,agent-chat,langchain-v1]
@BRIEF Developer quickstart — Gradio + LangChain v1 native stack.
## Quick Start (Docker)
```bash
docker compose build ss-tools-agent
docker compose up -d
# Frontend: http://localhost:5173 → login → assistant toggle
# Gradio proxy: http://localhost:8000/api/agent/gradio
```
## Quick Start (Local)
```bash
# Terminal 1: FastAPI
cd backend && source .venv/bin/activate
uvicorn src.app:app --reload --port 8000
# Terminal 2: Gradio Agent
cd backend && source .venv/bin/activate
pip install gradio langchain langchain-openai langgraph-checkpoint pdfplumber
python -m src.agent.run # Port 7860
# Terminal 3: Svelte
cd frontend && npm install @gradio/client && npm run dev -- --port 5173
```
## Verification
```bash
# Backend tests
cd backend && source .venv/bin/activate && python -m pytest tests/ -v
# Frontend tests
cd frontend && npm run test
# Lint
cd backend && python -m ruff check src/agent/
cd frontend && npm run lint
# Gradio health
curl http://localhost:7860/health
```
## Environment
```bash
LLM_API_KEY=sk-...
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o
JWT_SECRET=<shared with FastAPI>
FASTAPI_URL=http://ss-tools-api:8000
SERVICE_TOKEN_SECRET=<for POST /api/auth/service-token>
```
#endregion AgentChat.Quickstart

View File

@@ -0,0 +1,52 @@
#region AgentChat.Research [C:3] [TYPE ADR] [SEMANTICS research,agent-chat,langgraph,gradio]
@BRIEF Research — LangGraph agent with interrupt/resume, PostgresSaver, structured metadata. Final architecture choices.
## 1. Agent Framework: LangGraph `create_react_agent()`
**Decision**: `create_react_agent(model, tools, checkpointer=PostgresSaver)` from `langgraph.prebuilt`. Uses `interrupt()` for HITL, `Command(resume=...)` for resume.
**Rationale**: Native HITL support, PostgresSaver checkpointer, `astream_events()` for structured streaming.
**Rejected**: LangChain AgentExecutor — no native interrupt/checkpointer, more boilerplate.
## 2. Tools: Native `@tool`
**Decision**: `@tool`-decorated functions with Pydantic `args_schema`. Each tool calls FastAPI REST via HTTP with user JWT.
**Rationale**: Single source of metadata. Auto OpenAI function schema. Old `@assistant_tool``@DEPRECATED`.
**Rejected**: Dual registration (`@assistant_tool` + `StructuredTool`) — redundant.
## 3. Confirmation: `HumanInTheLoopMiddleware`
**Decision**: Second `submit()` with `__resume__` protocol. In-memory pending interrupts in handler.
**Rationale**: Zero REST endpoints. LangChain checkpoint ensures safe resume.
**Rejected**: REST confirmation endpoints + polling — more code, more latency.
## 4. History: `RunnableWithMessageHistory`
**Decision**: LangChain auto-loads/saves message history. Svelte does NOT pass `history` — only `conversation_id`.
**Rationale**: Eliminates manual history management on frontend and backend.
**Rejected**: Manual `history` parameter in `submit()` — fragile.
## 5. Checkpoints: `PostgresSaver`
**Decision**: PostgreSQL via `langgraph-checkpoint-postgres`. Same instance as FastAPI. Survives all container restarts. Thread ID = conversation_id.
**Rationale**: Agent state persists across container restarts.
**Rejected**: In-memory only — lost on restart.
## 6. Gradio Native Features
**Decision**: `additional_inputs`, `gr.Request`, `concurrency_limit=1`, `examples`. All used.
**Rationale**: Eliminates custom code for conversation_id passing, JWT extraction, message queuing, welcome chips.
**Rejected**: Custom headers, manual queues — redundant.
## 7. Tool Execution: HTTP to FastAPI
**Decision**: All `@tool` functions call FastAPI REST with user JWT (not service JWT).
**Rationale**: RBAC enforced by FastAPI under user identity. Service JWT only for bootstrap.
**Rejected**: Direct import — Gradio has no DB connection.
## 8. Streaming: Structured JSON Metadata
**Decision**: LangGraph events yield `ChatMessage` objects with `metadata.type` (stream_token, tool_start, tool_end, tool_error, confirm_required, confirm_resolved, error). Frontend parses structured JSON from `event.data`, not emoji strings.
**Rationale**: Structured metadata is deterministic, typed, and localizable. Emoji string parsing is brittle and collides with model output.
**Rejected**: Plain-text prefix protocol — rejected because emoji prefix parsing is fragile, hard to localize, and error-prone.
#endregion AgentChat.Research

View File

@@ -0,0 +1,133 @@
#region AgentChat.Spec [C:3] [TYPE ADR] [SEMANTICS spec,requirements,agent-chat,gradio,langgraph]
@BRIEF Feature specification — Gradio `submit()` + LangGraph agent with `interrupt()`/`Command(resume=...)`. No custom WebSocket, no REST confirmations, no @assistant_tool for new code.
@RATIONALE LangGraph chosen over LangChain AgentExecutor: `interrupt()` + `Command(resume=...)` are native HITL features, `PostgresSaver` is LangGraph-native checkpointer, `create_react_agent` from `langgraph.prebuilt` provides standard tool-calling. Gradio `submit()` for streaming, nginx reverse proxy for browser access.
@REJECTED Replacing Svelte frontend with Gradio UI — violates ADR-0006.
@REJECTED Custom SSE/WebSocket — @gradio/client `submit()` is the native transport.
@REJECTED Custom bidirectional WebSocket — @gradio/client is request/job-oriented, not raw WS.
@REJECTED REST confirmation endpoints — LangGraph `interrupt()` eliminates them.
@REJECTED @assistant_tool for new tools — native LangChain `@tool` is SSOT. Old registry → @DEPRECATED for FR-020.
**Feature Branch**: `033-gradio-agent-chat`
**Created**: 2026-06-08 | **Status**: Draft | **Last revised**: 2026-06-08 (consistency sweep)
## Architecture Overview
```
Browser (SvelteKit) Docker
┌──────────────────────┐ nginx ┌─────────────────────┐
│ @gradio/client │──→ /api/agent/gradio─→│ Gradio Agent :7860 │
│ submit("/chat", │ proxy+JWT │ gr.ChatInterface │
│ {message}, │ │ create_agent(model, │
│ conversation_id) │ │ tools=[@tool...], │
│ for await(event) {} │ │ middleware=[HitL], │
│ submission.cancel() │ │ checkpointer=PG) │
│ │ │ @tool→HTTP→FastAPI │
│ REST /api/assistant/*│──────────────────────→│_____________________│
└──────────────────────┘ │ HTTP
┌─────────▼───────────┐
│ FastAPI :8000 │
│ PostgreSQL │
│ /api/assistant (leg.)│
│ /api/auth/svc-token │
└─────────────────────┘
```
## User Stories
### Story 1 — Gradio Agent Engine with Streaming Chat (P1) 🎯
**Independent Test**: `Client.connect("/api/agent/gradio")`, `submit("/chat", {message}, conversation_id)`, verify tokens stream via `for await (event)`.
**Acceptance**:
1. **Given** Gradio running, **When** Svelte calls `submit()`, **Then** tokens stream in real time via `event.data` chunks.
2. **Given** streaming in progress, **When** user calls `submission.cancel()`, **Then** generation stops, partial text preserved.
### Story 2 — Autonomous Tool Selection (P1) 🎯
**Independent Test**: "покажи дашборды с проблемами валидации" → agent calls search + health tools.
**Acceptance**:
1. **Given** user query, **When** agent processes it, **Then** agent selects and invokes `@tool` functions from `backend/src/agent/tools.py`.
2. **Given** tool requires confirmation, **When** agent calls it, **Then** HumanInTheLoopMiddleware pauses execution, UI shows confirmation card.
3. **Given** tool fails, **When** error returned, **Then** agent explains failure and suggests recovery.
### Story 3 — Tool-Call Visibility (P2)
### Story 4 — Multi-Turn Context (P2) — via `RunnableWithMessageHistory`
### Story 5 — Conversation Persistence (P3) — PostgreSQL SSOT, REST CRUD
### Story 6 — File Upload & Document Analysis (P2) — multimodal=True, pdfplumber/openpyxl
### Edge Cases
- Gradio unreachable → reconnect 5×5s
- LLM malformed → retry once, then error
- Message too long → truncate + warn
- Tool >30s → async, result when ready
- Multi-tab → REST gate: `GET /api/agent/conversations/{id}/active` rejects second tab
- Network lost → detect disconnect, offer retry
## Functional Requirements
### Transport & Core
- **FR-001**: Gradio `gr.ChatInterface(type="messages", multimodal=True)`. Consumable via `@gradio/client` `submit("/chat", payload)` with `additional_inputs=conversation_id`.
- **FR-002**: Svelte frontend uses `@gradio/client submit()` exclusively. No `predict()`, no custom WebSocket.
- **FR-003**: Streaming via LangGraph `agent.astream_events()` → yield → `event.data` chunks. Frontend iterates `for await (event of submission)`.
### Agent & Tools
- **FR-004**: LangGraph `create_react_agent(model, tools, checkpointer=PostgresSaver)` from `langgraph.prebuilt`. One-line creation.
- **FR-005**: Native `@tool` functions in `backend/src/agent/tools.py`. Each calls FastAPI REST. Old `@assistant_tool` registry → `@DEPRECATED` Tombstone.
- **FR-006**: `@tool` functions use Pydantic `BaseModel` for `args_schema`. Docstring = description. Single source of metadata.
- **FR-007**: Tools call FastAPI with **dual identity**: service JWT authenticates the agent, user JWT (forwarded from browser via closure) authorizes the operation. RBAC enforced by FastAPI under user identity.
### Confirmation (HumanInTheLoop)
- **FR-008**: LangGraph `interrupt()` in dangerous tool nodes (`deploy_dashboard`, `execute_migration`, `commit_changes`). Handled entirely by LangGraph — **zero REST confirmation endpoints**.
- **FR-009**: Resume mechanism: graph pauses at `interrupt_before` node → primary `submit()` stream yields `metadata.type="confirm_required"` with `thread_id` and **terminates** (stream ends, does NOT wait). User confirms → second `submit("/chat", msg, [conversation_id, "confirm"])` → handler loads checkpoint by thread_id → invokes graph with `Command(resume={"action":"confirm"}, config={"thread_id":conversation_id})` → graph resumes from checkpoint → new stream begins.
### Persistence
- **FR-010**: Conversation history in PostgreSQL (`agent_conversations`, `agent_messages`). REST: `POST /api/assistant/conversations`, `GET .../conversations`, `GET .../history`, `DELETE .../{id}`.
- **FR-011**: Soft-delete: `is_archived=true`. Renamed to "Archive" in UX.
- **FR-012**: LangGraph checkpointer uses **PostgreSQL** (same instance as FastAPI) via `langgraph-checkpoint-postgres`. Survives container restarts. Thread ID = conversation_id.
- **FR-013**: `RunnableWithMessageHistory` auto-loads context from PostgreSQL on resume. Svelte does NOT pass `history` — only `conversation_id` via `additional_inputs`.
### UX & Cancel
- **FR-014**: `submission.cancel()` — native cancel. Partial text preserved.
- **FR-015**: `concurrency_limit=1` on handler. Server-side per-user lock prevents concurrent sends. `GET /api/agent/conversations/{id}/active` as additional gate for multi-tab.
- **FR-016**: Tool-call visibility via **structured JSON metadata in `ChatMessage.metadata`**, not emoji string parsing. `{type: "tool_start", tool: "...", input: {...}}`.
- **FR-017**: Confirmation card renders from `ChatMessage.metadata.confirm_required`.
### Auth
- **FR-018**: Browser → nginx → Gradio. nginx forwards `Authorization` header. Gradio handler extracts via `request: gr.Request.headers`.
- **FR-019**: Gradio → FastAPI: service JWT from `POST /api/auth/service-token`. Dual identity: service JWT authenticates agent, user JWT authorizes tool calls.
- **FR-020**: Stateless JWT validation in Gradio using shared `JWT_SECRET`. No DB required for auth.
### File Upload
- **FR-021**: PDF (pdfplumber), XLSX (openpyxl), JSON, CSV, PNG, JPEG. `multimodal=True` handles upload. Parser in `backend/src/agent/document_parser.py`.
### Backward Compat
- **FR-022**: Existing `/api/assistant` REST preserved. Old `@assistant_tool``@DEPRECATED` Tombstone.
- **FR-023**: Existing Svelte UX patterns preserved: drawer/embedded variants, focus-target sync.
### Additional
- **FR-024**: Audit logging via `LoggingMiddleware` — all interactions to `assistant_audit`.
- **FR-025**: RU/EN i18n keys for all new UI strings.
## Success Criteria
- **SC-001**: Tool selection ≥85% accuracy
- **SC-002**: First token <1.5s
- **SC-003**: Dangerous ops 100% confirmation
- **SC-004**: RBAC bypass 0%
- **SC-005**: History survives restart
- **SC-006**: Multi-step 30% faster than manual UI
- **SC-007**: File upload 10MB, no degradation
## Dependencies
- `langgraph>=0.2`, `langchain-core>=0.3`, `langchain-openai>=0.3`, `langgraph-checkpoint-postgres` (NEW replaces sqlite)
- `gradio>=5.0`, `pdfplumber`, `openpyxl`
- `@gradio/client` npm
- Docker: new `ss-tools-agent` container, nginx proxy `/api/agent/gradio`
- Deprecated: `backend/src/api/routes/assistant/_tool_registry.py` Tombstone
#endregion AgentChat.Spec

View File

@@ -0,0 +1,319 @@
#region AgentChat.Tasks [C:3] [TYPE ADR] [SEMANTICS tasks,implementation,agent-chat]
@BRIEF Implementation tasks for Gradio Agent Chat — phased by user story, dependency-ordered, with contract inlining for C3+ functions.
## Phase 1: Setup (shared infrastructure)
- [ ] T001 [P] Add `gradio>=5.0`, `langgraph>=0.2`, `langchain-core>=0.3`, `langchain-openai>=0.3`, `langgraph-checkpoint-postgres`, `pdfplumber` to `backend/requirements.txt`
- [ ] T002 [P] Add `@gradio/client` to `frontend/package.json``npm install`
- [ ] T003 [P] Create `backend/src/agent/` directory with `__init__.py`
- [ ] T004 [P] Create `backend/tests/test_agent/` directory with `__init__.py`
- [ ] T005 [P] Create `frontend/src/routes/agent/` directory
- [ ] T006 [P] Create `frontend/src/types/agent.ts` — TypeScript DTOs for conversation/message/stream metadata. No `agent-ws.ts`.
- [ ] T007 Create `docker/Dockerfile.agent` — Python 3.11-slim, installs gradio/langchain/langchain-openai/pdfplumber from requirements. No shared volume mount needed — Gradio calls FastAPI tools via HTTP.
RATIONALE Tool execution via REST (FR-004 revised) — Gradio container has no DB connection
- [ ] T008 Update `docker/docker-compose.yml` — add `ss-tools-agent` service (port 7860 internal, NOT exposed to host). Env vars: LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, FASTAPI_URL=http://ss-tools-api:8000, SERVICE_JWT, JWT_SECRET (shared with FastAPI for stateless JWT validation).
- [ ] T009 Update `docker/nginx.conf` — add reverse proxy location `/api/agent/gradio``http://ss-tools-agent:7860`. Forward `Authorization` header from browser request.
RATIONALE Browser cannot resolve Docker service names; Gradio must be proxied through nginx
## Phase 2: Foundational (data layer — blocking for all stories)
- [ ] T010 Implement `backend/src/models/agent.py` — SQLAlchemy models `AgentConversation` (id, user_id FK→users.id, title, is_archived, created_at, updated_at) and `AgentMessage` (id, conversation_id FK→agent_conversations.id, role, text, state, tool_calls JSON, attachments JSON, created_at). Use `uuid4_str` for primary keys.
@DATA_CONTRACT Models.Agent — see data-model.md §2
- [ ] T011 Implement `backend/src/schemas/agent.py` — Pydantic schemas: `ConversationItem`, `ConversationListResponse`, `MessageItem`, `HistoryResponse`, `DeleteResponse`. Match `data-model.md` §3 exactly.
@DATA_CONTRACT Schemas.Agent
- [ ] T012 [P] Implement `frontend/src/types/agent.ts` — TypeScript interfaces: `ConversationItem`, `ConversationListResponse`, `MessageItem`, `ToolCall`, `AttachmentMeta`, `HistoryResponse`, `DeleteResponse`. MUST match `backend/src/schemas/agent.py` field-for-field.
@DATA_CONTRACT AgentTypes ↔ Schemas.Agent (cross-stack)
- [ ] T013 [P] Implement `frontend/src/types/agent.ts` — TypeScript interfaces for conversation/message DTOs matching `backend/src/schemas/agent.py`. `agent-ws.ts` NOT created — no custom WebSocket protocol.
- [ ] T014 Generate Alembic migration for `agent_conversations` and `agent_messages` tables → `cd backend && source .venv/bin/activate && alembic revision --autogenerate -m "add agent conversations"`
## Phase 3: Story 1 — Gradio Agent Engine with Streaming Chat (P1) 🎯 MVP
### Backend
- [ ] T015 [US1] Implement `backend/src/agent/app.py` — Gradio `gr.ChatInterface(fn=agent_handler, type="messages", multimodal=True, additional_inputs=[gr.Textbox("conversation_id"), gr.Textbox("action")], examples=[["Покажи дашборды", null, null], ["Статус системы", null, null]], concurrency_limit=10)`. Handler: `agent_handler(message, history, request: gr.Request, conversation_id=None, action=None)`. On `action="confirm"/"deny"`: resume graph from checkpoint. Per-user lock via in-memory dict keyed by user_id.
- [ ] T016 [US1] Implement `backend/src/agent/langgraph_setup.py``create_react_agent(model, tools, checkpointer=PostgresSaver(os.getenv("DATABASE_URL")))`. Define dangerous tools in `DANGEROUS_TOOLS` set. Compile graph with `interrupt_before=DANGEROUS_TOOLS`. Wrap with `RunnableWithMessageHistory` for auto history load/save (thread_id = conversation_id).
@POST Compiled StateGraph with checkpointer, interrupt_before, message history
RATIONALE langgraph provides native interrupt/resume; no custom HITL middleware needed
- [ ] T017 [US1] Implement agent_handler yield loop in `app.py``async for event in graph.astream_events({"messages": [HumanMessage(content=message["text"])]}, config={"configurable": {"thread_id": conversation_id}}): yield to_chatmessage(event)`. Tool events → `ChatMessage(metadata={"type":"tool_start", "tool":..., "input":...})`. Interrupt events → `ChatMessage(metadata={"type":"confirm_required", "prompt":..., "thread_id":conversation_id})`.
@POST Each LangGraph event converted to ChatMessage with structured metadata; frontend receives typed objects
### Frontend
- [ ] T018 [US1] Adapt `AssistantChatPanel.svelte``Client.connect("/api/agent/gradio")`, `submit("/chat", message, [conversationId, null])`. Iterate `for await (event)`: `event.data` is `ChatMessage` with structured `metadata`. Render tokens, tool cards, confirmation cards based on `metadata.type`.
@UX_STATE idle, streaming, awaiting_confirmation, error, disconnected
- [ ] T019 [US1] Implement `AgentChatModel.sendMessage()``client.submit("/chat", {text: message, files}, [conversationId, null])`, iterate events, append tokens. `streamingState`: idle→streaming.
- [ ] T020 [US1] Implement `AgentChatModel.cancelGeneration()``submission.cancel()`.
- [ ] T021 [US1] Implement metadata handler in `AgentChatModel._onStreamData(msg)` — switch on `msg.metadata.type`: stream_token→append, tool_start→card+spinner, tool_end→checkmark, tool_error→cross, confirm_required→confirmation card, confirm_resolved→collapse card, error→error card.
- [ ] T022 [US1] Implement Gradio connection lifecycle — `Client.connect()`, retry on disconnect (5×5s), `connectionState` atom.
### Verification
- [ ] T024 [US1] Write `backend/tests/test_agent/test_agent_handler.py` — test Gradio handler: empty message returns immediately, streaming yields tokens, cancel stops generator, LLM unavailable yields error event.
- [ ] T025 [US1] Write `frontend/src/lib/models/__tests__/AgentChatModel.test.ts` — L1 model tests (no DOM render): sendMessage transitions state, cancelGeneration resets, disconnect/reconnect state machine, invalid state transitions rejected.
- [ ] T026 [US1] Verify: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_agent/ -v`
- [ ] T027 [US1] Verify: `cd frontend && npm run test -- --reporter=verbose`
- [ ] T028 [US1] Verify UX against `ux_reference.md`: streaming tokens appear in real time, Stop button halts generation, connection dot green/red/yellow.
---
## Phase 4: Story 2 — Autonomous Tool Selection via Agent (P1) 🎯 MVP
### Backend
- [ ] T026 [US2] Implement `backend/src/agent/tools.py` — native LangChain `@tool`-декорированные функции. Каждый `@tool`: Pydantic `BaseModel` для args_schema, docstring для description, внутри → HTTP-вызов к FastAPI REST с service JWT. Пример: `@tool async def search_dashboards(query: str, environment: str = None) → str`. Список тулов: все существующие @assistant_tool операции.
@DATA_CONTRACT Each @tool: Pydantic args → FastAPI REST call → JSON string result
RATIONALE Native @tool replaces dual @assistant_tool + StructuredTool wrapping
- [ ] T027 [US2] Implement `backend/src/agent/middleware.py``LoggingMiddleware` (логирует tool-call события в audit trail), `ConfirmationRiskMiddleware` (определяет interrupt_on список из конфига TOOL_RISK_LEVELS).
- [ ] T028 [US2] Register tools in `langgraph_setup.py``DANGEROUS_TOOLS = {"deploy_dashboard", "execute_migration", "commit_changes"}`. Compile graph with `interrupt_before=DANGEROUS_TOOLS`. All tools from `tools.py` registered as graph nodes.
@POST Agent with tools, interrupt, logging, checkpoints, history — all LangChain-native
- [ ] T029 [US2] Implement interrupt handler in `app.py` — when graph pauses at `interrupt_before` node: yield `ChatMessage(metadata={"type":"confirm_required", ...})`. On second submit with `additional_inputs[1]="confirm"/"deny"`: invoke graph with `Command(resume={"action": action}, config={"thread_id": conversation_id})`. Graph resumes from checkpoint.
- [ ] T031 [US2] Implement confirmation rendering in `AssistantChatPanel.svelte` — on `metadata.type="confirm_required"`: warning card + Confirm/Deny. Confirm → `submit("/chat", {text:"confirm"}, [conversationId, "confirm"])`. Deny → `submit("/chat", {text:"deny"}, [conversationId, "deny"])`. On `metadata.type="confirm_resolved"`: collapse card.
### Verification
- [ ] T034 [US2] Write `backend/tests/test_agent/test_langchain_tools.py` — test: REST-calling tools wrap correctly, agent selects tool, agent handles tool failure gracefully.
- [ ] T035 [US2] Write `backend/tests/test_agent/test_confirmations.py` — test: confirmation created, confirmed, denied, expired flows.
- [ ] T036 [US2] Verify: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_agent/ -v -k "tool or confirm"`
- [ ] T037 [US2] Verify UX: confirmation card appears for dangerous ops, confirm via second `submit()` with `__resume__`, deny yields `⏹️`.
---
## Phase 5: Story 3 — Tool-Call Visibility in Chat (P2)
### Frontend
- [ ] T039 [US3] Implement `frontend/src/lib/components/assistant/ToolCallCard.svelte` — inline card within assistant message. Props: `{tool, input, output?, error?, status}`. Visual: tool name in mono font, spinner (executing)/checkmark (done)/cross (error). Expandable to show input params and output result.
@UX_STATE executing (spinner), completed (checkmark, expandable result), failed (cross, error detail)
@RELATION DEPENDS_ON -> $lib/ui/Icon (existing)
@RELATION DEPENDS_ON -> $lib/ui/Button variant="ghost" (existing, for expand/collapse)
Design tokens: card=`bg-surface-muted border border-border rounded-lg`, tool name=`text-text-muted text-xs font-mono`, spinner=`animate-spin text-primary`, checkmark=`text-success`, cross=`text-destructive`
- [ ] T040 [US3] Integrate ToolCallCard into `AssistantChatPanel.svelte` — render ToolCallCard when `metadata.type="tool_start"/"tool_end"/"tool_error"`. On tool_start: card+spinner. On tool_end: checkmark+result. On tool_error: cross+error.
- [ ] T041 [US3] Implement metadata dispatch in `AgentChatModel._onStreamData(msg)` — handle `tool_start` (push card), `tool_end` (update status), `tool_error` (update status). Update `activeToolCalls[]`.
### Verification
- [ ] T042 [US3] Write `frontend/src/lib/components/assistant/__tests__/ToolCallCard.test.ts` — test: renders tool name, shows spinner in executing state, shows checkmark on completed, shows cross on error, expand/collapse toggles detail visibility.
- [ ] T043 [US3] Verify: `cd frontend && npm run test -- --reporter=verbose`
- [ ] T044 [US3] Verify UX: multi-tool query → each tool card appears inline, can be expanded, shows real-time status.
---
## Phase 6: Story 4 — Multi-Turn Conversation Context (P2)
### Backend
- [ ] T045 [US4] Verify `RunnableWithMessageHistory` auto-loads context from PostgreSQL per `thread_id=conversation_id`. No manual `history` injection needed.
### Frontend
- [ ] T047 [US4] Implement `AgentChatModel.createConversation()` in `frontend/src/lib/models/AgentChatModel.svelte.ts` — clear messages, reset streamingState→idle, set currentConversationId=null. Existing conversation is NOT lost (persisted in DB).
@ACTION createConversation(): starts clean conversation
@POST messages=[], streamingState="idle", partialText=""
@TEST_EDGE: create_during_streaming→cancel_first_then_create
### Verification
- [ ] T048 [US4] Write integration test: send message 1 referencing entity, send message 2 with pronoun → agent resolves pronoun from context.
- [ ] T049 [US4] Write test: conversation exceeds 4000 tokens → older messages summarized, recent detail preserved.
- [ ] T050 [US4] Verify: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_agent/ -v -k "memory or context"`
- [ ] T051 [US4] Verify UX: follow-up question resolves prior context, new conversation starts clean.
---
## Phase 7: Story 5 — Conversation Persistence and Management (P3)
### Backend
- [ ] T052 [US5] Implement `backend/src/api/routes/agent_conversations.py` — FastAPI router: `POST /api/assistant/conversations` (create), `GET /api/assistant/conversations` (paginated list + search), `GET /api/assistant/history` (paginated messages), `POST /api/assistant/conversations/{id}/messages` (append batch after stream_end), `DELETE /api/assistant/conversations/{id}` (soft-delete: is_archived=true).
- [ ] T053 [US5] Implement `POST /api/assistant/conversations`:
@PRE User authenticated, title from first message (truncated 60 chars)
@POST ConversationItem(id, title, created_at) — HTTP 201
@TEST_EDGE: unauthenticated→401
@PRE User authenticated (via existing FastAPI auth dependency)
@POST ConversationListResponse(items[], has_next, active_total, archived_total)
@DATA_CONTRACT Input: (page, page_size, include_archived?, search?) → Output: ConversationListResponse
@TEST_EDGE: empty_result→items=[], totals=0, no_error — filter_invalid→ignored
- [ ] T054 [US5] Implement `AgentChat.Api.GetHistory` in `backend/src/api/routes/agent_conversations.py`:
@PRE conversation_id valid
@POST HistoryResponse(items[], has_next, conversation_id)
@TEST_EDGE: invalid_conversation_id→404 — empty_conversation→items=[], no_error
- [ ] T055 [US5] Implement `AgentChat.Api.DeleteConversation` in `backend/src/api/routes/agent_conversations.py`:
@PRE conversation_id valid, user owns conversation
@POST DeleteResponse(deleted=true), conversation is_archived=true
@SIDE_EFFECT Sets is_archived=true, cascades to messages (soft delete)
@TEST_EDGE: already_deleted→404 — not_owner→403
- [ ] T056 [US5] Register agent_conversations router in `backend/src/app.py` — prefix `/api/assistant` (shares namespace with existing assistant routes for backward compat FR-020).
### Frontend
- [ ] T057 [US5] Implement `AgentChatModel.loadConversations()` and `loadHistory()` in `frontend/src/lib/models/AgentChatModel.svelte.ts` — call existing `getAssistantConversations()` and `getAssistantHistory()` from `frontend/src/lib/api/assistant.ts`. Infinite scroll for both: append on next page, reset on new filter.
@ACTION loadConversations(reset?): fetches from REST, manages pagination
@POST conversations array updated, conversationsHasNext flag set
@ACTION loadHistory(conversationId?): fetches messages from REST
@POST messages array populated, historyHasNext flag set
@TEST_EDGE: api_error→error state with retry, empty_response→empty state
- [ ] T058 [US5] Implement `AgentChatModel.deleteConversation()` in `frontend/src/lib/models/AgentChatModel.svelte.ts` — optimistic removal from conversations array, DELETE via REST, rollback on failure with toast.
@ACTION deleteConversation(id): optimistic delete
@POST removed from list, toast on success/error
@TEST_EDGE: api_failure→rollback_reinsert, active_conversation_deleted→switch_to_next
- [ ] T059 [US5] Implement `frontend/src/lib/components/assistant/ConversationList.svelte` — sidebar list (240px) with: `<Input>` for search (debounced 300ms), grouped by date on client (`$derived` from `conversations`), infinite scroll (IntersectionObserver), delete button (with `confirm()` before calling model.deleteConversation), active state highlight (`bg-surface-muted`). Skeleton on load.
@UX_STATE loading (skeleton), loaded (grouped list), empty ("Нет диалогов"), error (retry)
@RELATION DEPENDS_ON -> $lib/ui/Input (existing), $lib/ui/Button variant="ghost" (existing), $lib/ui/Icon (existing)
@RELATION BINDS_TO -> AgentChat.Model
Design tokens: sidebar=`bg-surface-card border-r border-border`, skeleton=`animate-pulse bg-surface-muted`
- [ ] T060 [US5] Integrate ConversationList into `AssistantChatPanel.svelte` (drawer header as dropdown) and into `/agent` page (left sidebar).
### Verification
- [ ] T061 [US5] Write `backend/tests/test_agent/test_conversation_api.py` — test: list returns paginated, search filters by title, history returns messages, delete archives conversation, 404 on invalid id, 403 on other user's conversation.
- [ ] T062 [US5] Write `frontend/src/lib/components/assistant/__tests__/ConversationList.test.ts` — test: renders conversations, groups by date, search filters, delete shows confirm and removes, error shows retry.
- [ ] T063 [US5] Verify: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_agent/test_conversation_api.py -v`
- [ ] T064 [US5] Verify: `cd frontend && npm run test -- --reporter=verbose`
---
## Phase 8: Story 6 — File Upload and Document Analysis (P2)
### Backend
- [ ] T065 [US6] Implement `backend/src/agent/document_parser.py``parse_pdf(file_path)` using pdfplumber (primary, extracts text + tables), PyPDF2 (fallback for encrypted). `parse_xlsx(file_path)` using openpyxl (sheet names → 2D cell arrays).
@PRE File exists on disk, valid extension
@POST Returns extracted text (PDF) or structured dict (XLSX)
@TEST_EDGE: encrypted_pdf→ParseError with message, password_xlsx→ParseError, empty_file→empty_string (no crash), unsupported_extension→ParseError
- [ ] T066 [US6] Integrate document parser into agent_handler in `backend/src/agent/app.py` — before running agent, detect file attachments (Gradio `gr.File` component), parse content via T065, inject as system message: `"Вот содержимое файла {name}:\n{parsed_text}"`.
@PRE File uploaded via Gradio multimodal input
@POST Parsed content injected as system message in conversation
- [ ] T067 [US6] Add file size validation (10MB limit) in agent_handler — reject oversized files before parsing, return error message to user.
@PRE File attached to message
@POST File accepted if ≤10MB, rejected with error message if exceeded
### Frontend
- [ ] T068 [US6] Add file upload UI to `AssistantChatPanel.svelte` — paperclip icon button (using `$lib/ui/Icon name="paperclip"`), hidden `<input type="file" accept=".pdf,.xlsx,.json,.csv,.txt,.png,.jpeg">`, preview chip below input showing filename + size. Validate format and size before upload (10MB client-side check).
@UX_FEEDBACK file chip appears below input, invalid format→toast error, oversized→toast error
@RELATION DEPENDS_ON -> $lib/ui/Icon (existing)
- [ ] T069 [US6] Pass file attachments through `AgentChatModel.sendMessage()` — append file data to WebSocket message payload, show parsing indicator on file chip while agent processes.
@TEST_EDGE: upload_during_streaming→rejected, upload_empty_file→validation_error
### Verification
- [ ] T070 [US6] Write `backend/tests/test_agent/test_document_parser.py` — test: PDF extracts text, PDF with tables preserves structure, XLSX parses all sheets, encrypted/empty/invalid files handled gracefully.
- [ ] T071 [US6] Verify: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/test_agent/test_document_parser.py -v`
- [ ] T072 [US6] Verify UX: upload PDF → agent analyzes content, upload XLSX → agent reads tabular data, oversized file → rejected.
---
## Phase 9: Polish & Cross-Cutting Verification
### Frontend
- [ ] T073 Implement `frontend/src/routes/agent/+page.svelte` — full-page agent chat at `/agent` route. Two-column layout: `<ConversationList>` (240px left sidebar) + `<AssistantChatPanel variant="embedded">` (right area). `<PageHeader>` title="Агент-чат". Protected route (auth check in +layout.svelte).
@RELATION BINDS_TO -> AgentChat.Model (same instance)
@RELATION DEPENDS_ON -> $lib/ui/PageHeader (existing)
@RELATION DEPENDS_ON -> AgentChat.ConversationList
@RELATION DEPENDS_ON -> AgentChat.Panel
Design tokens: page=`bg-surface-page max-w-7xl mx-auto`, two-column=`flex`, sidebar=`w-60 shrink-0 bg-surface-card border-r border-border`, chat=`flex-1`
- [ ] T074 Implement `frontend/src/lib/components/assistant/ConnectionIndicator.svelte` — green/red dot binding to `model.connectionDotColor` `$derived`. Tooltip: "Подключено"/"Недоступно".
@RELATION BINDS_TO -> AgentChat.Model
Design tokens: green=`bg-success w-2 h-2 rounded-full`, red=`bg-destructive w-2 h-2 rounded-full`
- [ ] T075 Implement multi-tab gate in `AgentChatModel.sendMessage()` — before sending, call `POST /api/agent/conversations/{id}/active` to check for existing session. If active → reject with toast "Диалог активен в другой вкладке".
@TEST_EDGE: second_tab_send→rejected_with_toast, first_tab_send→proceeds
- [ ] T076 Add "Expand to /agent" button in drawer header — navigates to `/agent` route preserving current conversation context via store.
@UX_FEEDBACK smooth navigation, conversation state preserved
- [ ] T077 Implement conversation auto-title — on first agent response, set `AgentConversation.title` from truncated first user message (60 chars) during conversation creation in T052. No separate PATCH endpoint needed — title is set at insert time.
@POST Conversation.title populated at creation
### Backend
- [ ] T078 Add audit logging to agent_handler — every user message and agent response writes to existing `assistant_audit` table: `{user_id, conversation_id, decision: "message_sent"/"tool_called"/"confirmed"/"denied", message, payload}`.
@SIDE_EFFECT Writes to assistant_audit table
RATIONALE FR-010: all agent interactions logged for auditability
- [ ] T079 Implement Gradio healthcheck endpoint — `GET /health` returns `{"status":"ok","uptime":...}` for Docker healthcheck.
- [ ] T080 [P] Implement `backend/src/api/routes/auth.py` — internal endpoint `POST /api/auth/service-token` that accepts a static service secret (env `SERVICE_TOKEN_SECRET`) and returns a long-lived JWT with role `agent`. Used by Gradio container at startup for Gradio→FastAPI HTTP calls.
@DATA_CONTRACT Input: {service_secret} → Output: {access_token, expires_in}
@TEST_EDGE: invalid_secret→401, expired_token→401_on_next_call
RATIONALE FR-023: Gradio→FastAPI calls authenticated via JWT, not separate API key mechanism
- [ ] T081 [P] Add i18n keys to `frontend/src/lib/i18n/locales/ru/assistant.json` and `frontend/src/lib/i18n/locales/en/assistant.json` — new keys: `stop`, `tool_executing`, `tool_completed`, `tool_failed`, `confirm_required`, `confirm_expired`, `confirm_retry`, `file_upload`, `file_parse_error`, `file_unsupported`, `file_too_large`, `connection_lost`, `reconnecting`, `manual_reconnect`, `archive_dialog`.
RATIONALE FR-013: Russian + English i18n for all new UX strings (8+ in UX reference §3-4)
- [ ] T082 Implement LLM malformed JSON retry in `backend/src/agent/app.py` agent_handler — catch `OutputParserException` from LangChain, retry once with `"Respond with valid JSON only. Previous response was malformed."` appended to prompt. If still malformed, yield `stream_error` with clarification request.
@TEST_EDGE: first_retry_succeeds→normal_flow, second_failure→stream_error_with_clarification
RATIONALE Spec Edge Cases: "LLM returns malformed tool-call JSON → retry once with stricter prompt"
- [ ] T083 Implement rapid-fire message queue in `AgentChatModel.sendMessage()` in `frontend/src/lib/models/AgentChatModel.svelte.ts` — if `streamingState !== "idle"`, enqueue message; process queue sequentially when state returns to idle. Show queue position badge if >1 pending.
@TEST_EDGE: send_during_streaming→queued, send_during_sending→queued, queue_drains_on_idle
RATIONALE Spec Edge Cases: "Simultaneous rapid-fire messages → queue and process sequentially"
### Verification (cross-cutting)
- [ ] T084 [P] Backend full test suite: `cd backend && source .venv/bin/activate && python -m pytest backend/tests/ -v`
- [ ] T085 [P] Frontend full test suite: `cd frontend && npm run test -- --reporter=verbose`
- [ ] T086 [P] Backend lint: `cd backend && python -m ruff check src/agent/ src/api/routes/agent_conversations.py src/models/agent.py src/schemas/agent.py`
- [ ] T087 [P] Frontend lint: `cd frontend && npm run lint`
- [ ] T088 [P] Frontend build: `cd frontend && npm run build`
- [ ] T089 Semantic audit — agent contracts: `axiom_semantic_validation audit_contracts file_path="backend/src/agent/"`
- [ ] T090 UX reference validation — verify all states from `contracts/ux/agent-chat-ux.md` (15 FSM states) are reachable and rendered correctly via browser or vitest @UX_TEST scenarios.
- [ ] T091 Rejected-path regression test — verify that `/api/assistant` REST endpoints still function (FR-020 backward compat). Existing `backend/tests/test_assistant_api.py` passes unmodified.
- [ ] T092 Verify ADR guardrails: `@REJECTED full Svelte replacement` → SvelteKit routes intact; `@REJECTED custom SSE/WebSocket`@gradio/client used exclusively; `@REJECTED separate auth mechanism` → JWT reused.
---
## Task Summary
| Phase | Stories | Tasks | Parallel |
|-------|---------|:-----:|:--------:|
| P1 — Setup | — | T001-T009 | 6 |
| P2 — Foundational | — | T010-T014 | 2 |
| P3 — US1 (Streaming) | P1 🎯 | T015-T028 | — |
| P4 — US2 (Tools) | P1 🎯 | T029-T038 | — |
| P5 — US3 (Visibility) | P2 | T039-T044 | — |
| P6 — US4 (Context) | P2 | T045-T051 | — |
| P7 — US5 (Persistence) | P3 | T052-T064 | — |
| P8 — US6 (Files) | P2 | T065-T072 | — |
| P9 — Polish + Gaps | — | T073-T092 | 8 |
| **Total** | | **92** | **16** |
## Story Independence
| Story | Independent Test |
|-------|------------------|
| **US1** | Launch Gradio, connect from Svelte, send message → streaming tokens appear |
| **US2** | Send multi-tool query → agent selects correct tools autonomously |
| **US3** | Multi-tool query → tool-call cards visible inline, expandable |
| **US4** | Follow-up with pronoun → agent resolves from prior context |
| **US5** | 3 conversations, restart app → all 3 appear, resume works |
| **US6** | Upload PDF → agent extracts and analyzes content |
#endregion AgentChat.Tasks

View File

@@ -0,0 +1,33 @@
#region AgentChat.UxReference [C:3] [TYPE ADR] [SEMANTICS ux,reference,agent-chat,final]
@BRIEF Final UX reference — Gradio `submit()`, LangChain v1 HITL, structured metadata, Archive UX.
## 1. Persona
Platform operator in ss-tools Svelte interface. Opens chat (drawer or `/agent` page). Streams responses, confirms dangerous ops via inline card, uploads files for analysis.
## 2. Happy Path
`submit("/chat", {message}, conversation_id)`. Tokens stream in real time. Tool cards appear from structured metadata. Confirmation card for dangerous ops — Confirm via second `submit()`. Archive conversations via delete (soft-delete).
## 3. Key Elements
- **Input**: Textarea + paperclip (PDF/XLSX/JSON/CSV/PNG/JPEG)
- **Streaming**: Text character-by-character. Tool cards from `metadata.type`.
- **Stop**: `submission.cancel()` — native.
- **Confirmation**: Warning card from `metadata.type="confirm_required"`. Confirm → second `submit()`.
- **Conversation Switcher**: Search, date-grouped, infinite scroll.
- **Connection**: Green/red dot.
## 4. Error Scenarios
- **Gradio down**: Dot red, retry 5×5s.
- **LLM error**: Stream `metadata.type="error"` → card + retry.
- **Tool failure**: `metadata.type="tool_error"` → cross + detail.
- **File parse**: Error chip: "Не удалось прочитать".
- **Multi-tab**: REST gate rejects second tab.
## 5. Gradual Transition
Phase 1: Gradio + Svelte submit(). Phase 2: Old registry → @DEPRECATED. Phase 3: REST preserved (FR-022).
#endregion AgentChat.UxReference