Compare commits
1 Commits
c0ed6d9840
...
fix/e2e-te
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c4e0c99e |
@@ -1,20 +1,33 @@
|
|||||||
# CURRENT_TASK.md
|
# CURRENT_TASK.md
|
||||||
|
|
||||||
**Task:** No active task — pick from [`TODO.md`](TODO.md).
|
**Task:** none — replace this file when starting the next real task.
|
||||||
|
|
||||||
**Status:** ready for next pickup.
|
**Status:** not-started
|
||||||
|
|
||||||
## Recommended next moves
|
**Definition of Done:** n/a
|
||||||
|
|
||||||
1. **Promote `CI / e2e (pull_request)` to required on `main`.** Two consecutive PR runs (#150 and #153) have now finished green on the e2e job. That was the threshold the prior CI-recovery task set for promoting it. Branch protection update only — no code change.
|
**Assumptions:** n/a
|
||||||
2. **Pick a backlog item.** Top of `TODO.md` "Up next" is the `data-testid` e2e-stability work (PR #152 spent five one-line selector updates chasing UI churn — adding stable test IDs to a small set of high-value elements would make those tests immune to copy/route renames). The new `currentChatRef` silent-return audit added in #153's session is in Backlog and is a natural pairing with the bug fix that was just shipped.
|
|
||||||
|
|
||||||
## Previous task — closed out
|
**Out of scope:** n/a
|
||||||
|
|
||||||
**Task:** Land PR #153 — fix the `AssistantChatPage` prefill `currentChatRef` bug that silently dropped AI follow-up responses in the task lane.
|
---
|
||||||
|
|
||||||
**Status:** complete (2026-04-26).
|
<!-- When you start a real task, replace the block above with:
|
||||||
|
|
||||||
- PR #153 merged as commit `68fcdc6` on `main`. Backend, frontend, and e2e all green on the merged SHA after the env-var fix.
|
**Task:** One-sentence goal.
|
||||||
- E2e CI needed a stub `ANTHROPIC_API_KEY` in the workflow so the AI-gated `POST /api/v1/ai-sessions` endpoint stops returning 503; the Playwright `page.route` stub still intercepts the actual `/chat` call in the browser, so no real Anthropic traffic occurs.
|
|
||||||
- Regression test `frontend/e2e/assistant-chat-prefill.spec.ts` is part of the e2e suite going forward.
|
**Status:** not-started | in-progress | blocked | ready-for-review | complete
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
- [ ] Testable criterion 1
|
||||||
|
- [ ] Testable criterion 2
|
||||||
|
- [ ] Tests added or updated
|
||||||
|
- [ ] `npm run build` passes (frontend) / `pytest` passes (backend)
|
||||||
|
|
||||||
|
**Assumptions:**
|
||||||
|
- What we're treating as given
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- What this task explicitly does NOT cover
|
||||||
|
|
||||||
|
-->
|
||||||
|
|||||||
@@ -2,27 +2,34 @@
|
|||||||
|
|
||||||
# HANDOFF.md
|
# HANDOFF.md
|
||||||
|
|
||||||
**Last updated:** 2026-04-26 04:55 EDT
|
**Last updated:** 2026-04-24 (America/New_York)
|
||||||
|
|
||||||
**Active task:** None — pick from [`TODO.md`](TODO.md). See [`CURRENT_TASK.md`](CURRENT_TASK.md) for recommended next moves.
|
**Active task:** None — see [CURRENT_TASK.md](CURRENT_TASK.md). Replace it when picking up the next real task.
|
||||||
|
|
||||||
**Branch:** `main` is the home position. Recent merges: PR #150 (CI recovery, `87bb20b`), PR #153 (prefill `currentChatRef` fix, `68fcdc6`).
|
**Branch:** `feat/flowpilot-migration` — a long-running FlowPilot Phase 9 feature branch. The recent AI-handoff migration commits ride on this branch (not on their own branch); they'll merge to `main` whenever Phase 9 does.
|
||||||
|
|
||||||
## Where things stand
|
**Branch state:** 3 commits ahead of `origin/feat/flowpilot-migration`:
|
||||||
|
|
||||||
- CI is healthy on `main`: backend, frontend, and e2e all green on the latest commits.
|
- `b3be1e0 chore: ignore .remember/ skill runtime state`
|
||||||
- Branch protection on `main`: PR-only merges, force-push blocked, **`CI / frontend (pull_request)` required**, **`CI / backend (pull_request)` required**, `CI / e2e (pull_request)` not yet required.
|
- `b3506b5 docs(pilot): phase 9 review issues`
|
||||||
- Two consecutive PR runs (#150, #153) finished green on e2e. The "promote e2e to required" gate from the prior task is now satisfiable.
|
- `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`
|
||||||
- Backend AI-gated endpoints (`POST /ai-sessions`, `/chat`, `/respond`, etc.) call `_require_ai_enabled()` and return 503 if no provider key is set. The e2e CI job now sets a stub `ANTHROPIC_API_KEY` so any future test that exercises those flows can rely on it; tests should still stub the actual AI calls in the browser via `page.route` so no real Anthropic traffic occurs.
|
|
||||||
|
|
||||||
## Immediate next steps
|
Earlier in this session (already pushed to origin):
|
||||||
|
|
||||||
1. (Optional, ops-only) Promote `CI / e2e (pull_request)` to required on `main` in Gitea branch protection.
|
- `9c8ba29 fix(ai): correct stale role-hierarchy and file-listing claims`
|
||||||
2. Pick the next backlog item from `TODO.md`. Top of "Up next" is the `data-testid` e2e-stability audit; the new `currentChatRef` silent-return audit (added to backlog in this session) is a natural pairing with the bug fix that just shipped.
|
- `bee8690 chore(ai): migrate to dual-agent handoff system`
|
||||||
|
- `e110fed chore: snapshot CLAUDE.md before ai-handoff migration` (tag: `pre-ai-handoff`)
|
||||||
|
|
||||||
## Useful breadcrumbs
|
**Where I left off:**
|
||||||
|
- File: n/a — nothing mid-edit.
|
||||||
|
- Next intended action: push the 3 unpushed commits when ready (`git push`), then start the next real task (replace `CURRENT_TASK.md`, update this file).
|
||||||
|
|
||||||
- The fix that just landed: [`frontend/src/pages/AssistantChatPage.tsx`](../frontend/src/pages/AssistantChatPage.tsx) — `currentChatRef.current = session.session_id` after `setActiveChatId` in the dashboard prefill effect.
|
**Uncommitted state:**
|
||||||
- Regression test: [`frontend/e2e/assistant-chat-prefill.spec.ts`](../frontend/e2e/assistant-chat-prefill.spec.ts).
|
- Working tree is clean.
|
||||||
- E2e env convention: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml) — `ANTHROPIC_API_KEY` is stubbed in the e2e job env. Tests that exercise AI-gated endpoints should stub the actual AI calls in the browser, not rely on a real key.
|
|
||||||
- Silent-return follow-up entry: [`.ai/TODO.md`](TODO.md), Backlog section.
|
**Immediate next steps:**
|
||||||
|
1. `git push` to publish the 3 local commits (cleanup batch).
|
||||||
|
2. When starting the next real feature task: replace `CURRENT_TASK.md` with actual goal/DoD, rewrite this file's resume section.
|
||||||
|
|
||||||
|
**Open questions / blockers:**
|
||||||
|
- None. The dual-agent handoff system is live and has survived one Codex review round (see DECISIONS.md 2026-04-24 entry; corrections in `9c8ba29`).
|
||||||
|
|||||||
@@ -12,63 +12,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-04-26 03:50 EDT — Claude Code — Ship AssistantChatPage prefill `currentChatRef` fix; close out PR #150
|
|
||||||
|
|
||||||
- User reported a troubleshooting-session bug: after answering a subset of task-lane questions and clicking *Send N of M Responses*, no AI response appeared. Traced to `AssistantChatPage`: the dashboard prefill effect set `activeChatId` after creating a new chat session but never updated `currentChatRef.current`. The `currentChatRef.current !== sentForChatId` guard in `handleSend` and `handleTaskSubmit` then bailed silently on every later request and discarded the AI's reply. The user message was already pushed to the chat before the await, so the user saw their answers but nothing else.
|
|
||||||
- Fix: one-line addition mirroring `handleNewChat` and `handleResumeNew` — assign `currentChatRef.current = session.session_id` immediately after `setActiveChatId(session.session_id)` in the prefill effect. Branched off `origin/main` as `fix/tasklane-prefill-ref`; PR #153 opened on Gitea.
|
|
||||||
- Authored a Playwright regression test `frontend/e2e/assistant-chat-prefill.spec.ts` that drives the real dashboard prefill flow against the real backend, stubs `/ai-sessions/*/chat` with `page.route` for deterministic turn-1/turn-2 responses, and asserts the second AI message renders. Confirmed the test fails on unfixed code at the exact assertion (`Got it — based on your answer…` never appears) and passes once the fix is restored.
|
|
||||||
- Verified locally inside `mcr.microsoft.com/playwright:v1.58.2-noble` against the running dev stack: new spec passes, adjacent `flowpilot-chat` spec still passes, `tsc -b` clean. `resume.spec` and `history.spec` failures observed are pre-existing real-backend fixture collisions, unrelated to this change.
|
|
||||||
- First CI run on PR #153 failed on infrastructure issues already addressed by PR #150: backend hit `Bind for 0.0.0.0:5432 failed: port is already allocated`, frontend hit `actions/upload-artifact@v4 not supported on GHES`. PR #150 was already merged (commit `87bb20b` on `main`). Rebased `fix/tasklane-prefill-ref` onto new `main` (force-push `1a8cb06` → `1559feb`), resolved a `.ai/TODO.md` conflict by keeping both backlog item sets, kicked off CI on the rebased SHA.
|
|
||||||
- Confirmed `CI / backend (pull_request)` is now in branch protection's required-status-checks list (added during PR #150 close-out). `CI / e2e (pull_request)` left as not-required pending one more clean PR run as the threshold.
|
|
||||||
- Recorded the broader silent-return concern in TODO backlog: the `currentChatRef.current !== sentForChatId` guard is applied across `handleSend`, `handleTaskSubmit`, `selectChat`, `refreshFacts`, `refreshActiveFix`, and `refreshPreview`. PR #153 fixes one symptom but the same pattern can mask other drift. Either log a Sentry breadcrumb on the mismatch path or distinguish "expected stale" (chat switch) from "unexpected stale" (ref never updated) so the latter alerts.
|
|
||||||
- First CI run on the rebased SHA passed backend and frontend but failed e2e: the new prefill regression test couldn't render the task-lane question text. Diagnosed via the job log: `POST /api/v1/ai-sessions` calls `_require_ai_enabled()` and returns 503 when no provider key is set. The e2e CI job had neither `ANTHROPIC_API_KEY` nor `GOOGLE_AI_API_KEY` in env. Locally the dev backend has a real key, hence the local pass. The Playwright `page.route` stub on `/chat` was correct but never had a chance to fire because the upstream session-creation call was 503-ing.
|
|
||||||
- Fix: added a stub `ANTHROPIC_API_KEY: ci-stub-key-not-used-by-tests` to the e2e job env in `.gitea/workflows/ci.yml`. The Playwright stub still intercepts the actual `/chat` call in the browser, so the backend never contacts Anthropic — the gate just needs to clear. Documented the convention in a workflow comment so future AI-touching e2e tests know what to expect. Pushed `11fe32f`; CI went all-green.
|
|
||||||
- Merged PR #153 as `68fcdc6` on `main`. Local feature branch and remote both deleted via Gitea's `delete_branch_after_merge`.
|
|
||||||
- Opened a small follow-up `chore/post-153-handoff` PR to refresh the now-stale `.ai/` files (this entry, plus `CURRENT_TASK.md` rolling forward to "no active task — pick from `TODO.md`" and `HANDOFF.md` updating to the post-merge home position). The `data-testid` audit at the top of `TODO.md` "Up next" or the `currentChatRef` silent-return audit added in this session's backlog are the natural next pickups.
|
|
||||||
- Files touched: `frontend/src/pages/AssistantChatPage.tsx` (the one-line fix + comment), `frontend/e2e/assistant-chat-prefill.spec.ts` (new regression test), `.gitea/workflows/ci.yml` (stub `ANTHROPIC_API_KEY` for e2e), `.ai/TODO.md` (silent-return follow-up entry, plus conflict resolution preserving PR #150's backlog additions), `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md` (this entry).
|
|
||||||
|
|
||||||
## 2026-04-25 16:41 EDT — Codex — Stabilize PR #150 e2e selectors
|
|
||||||
|
|
||||||
- Investigated the remaining PR #150 failure after backend and frontend CI were green. The e2e resume smoke test was not failing because of product behavior; it used `.bg-card` plus text filtering and matched the tree filter `<select>` before the intended session card.
|
|
||||||
- Added stable test IDs to flow session, tree, and share cards, then updated affected e2e tests to target those cards instead of Tailwind class names.
|
|
||||||
- Hardened the CI workflow by making Postgres healthchecks authenticate as `postgres` and baking `VITE_API_URL="${PLAYWRIGHT_API_ORIGIN}"` into the e2e frontend build.
|
|
||||||
- Verified with `git diff --check`, frontend build in Docker, no remaining `.bg-card` e2e selectors, and focused Playwright runs in an Actions-like Ubuntu container: resume spec passed, then history/library/library-start/resume/shares passed (`6 passed`).
|
|
||||||
- Left for next session: push this WIP commit to PR #150, watch CI, merge when all three jobs are green, then enable backend branch protection and consider the e2e gate after a reliable green run.
|
|
||||||
- Files touched: `.gitea/workflows/ci.yml`, `frontend/e2e/history.spec.ts`, `frontend/e2e/library-start.spec.ts`, `frontend/e2e/library.spec.ts`, `frontend/e2e/resume.spec.ts`, `frontend/e2e/shares.spec.ts`, `frontend/src/components/library/TreeGridView.tsx`, `frontend/src/components/library/TreeListView.tsx`, `frontend/src/pages/MySharesPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md`.
|
|
||||||
|
|
||||||
## 2026-04-25 12:00 America/New_York — Claude Code — Mock final AI-provider test, cache CI deps, parallelize backend with pytest-xdist
|
|
||||||
|
|
||||||
- Diagnosed why CI was still red despite Codex's local 1076 passed: a single test (`test_record_decision_persists_and_bumps_state_version`) needed `ANTHROPIC_API_KEY` because the `decision: draft_template` path calls `TemplateExtractionService` → AI provider. Patched `_extract_template_parameters` with an `AsyncMock` so the test no longer depends on AI availability. Verified.
|
|
||||||
- Pushed Codex's WIP commit `49f8856` to PR #150 (had been local-only per handoff protocol).
|
|
||||||
- PR #150 (`fix/ci-workflow-config`) extended with cheap CI wins: `actions/cache@v3` for pip + npm in all three jobs; dropped `--cov-report=term-missing` (the custom display step parses JSON); added `--maxfail=10` so structural breakage exits fast.
|
|
||||||
- PR #151 (`fix/ci-pytest-xdist`) opened, stacked on #150: pytest-xdist with per-worker DB isolation. `conftest.py` reads `PYTEST_XDIST_WORKER`, computes a per-worker DB URL like `…_gw0`, and synchronously CREATEs the DB on first import. The per-test `DROP SCHEMA public CASCADE` then operates on the worker's isolated DB. Verified locally: backend suite went from 22m 27s serial → 4m 28s parallel (8 workers), 1076 passed in both cases. ~5× speedup.
|
|
||||||
- Decided NOT to do per-test transactional rollback (bigger refactor); captured for future TODO consideration.
|
|
||||||
- Left for next session: watch CI on both PRs, merge in order (#150 first, #151 second), then enable `CI / backend (pull_request)` as a required status check on main.
|
|
||||||
- Files touched: `backend/tests/test_session_suggested_fixes_api.py`, `backend/tests/conftest.py`, `backend/requirements-dev.txt`, `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/TODO.md`.
|
|
||||||
|
|
||||||
## 2026-04-25 06:12 EDT — Codex — Fix backend suite to green
|
|
||||||
|
|
||||||
- Fixed the real backend failures left after the CI-infra cleanup: tenant-scoped seed drift, missing production `account_id` writes, public route mounting for survey/share links, Script Builder library saves, resolution output async loading, AI search schema metadata, disabled-AI fixture leakage, and prompt marker guardrails.
|
|
||||||
- Added backend CI/dev system packages required by WeasyPrint PDF export.
|
|
||||||
- Stabilized the pytest harness for pytest-asyncio/asyncpg teardown ResourceWarnings under `filterwarnings = error`.
|
|
||||||
- Verified `pytest --override-ini="addopts=" -q` inside `resolutionflow_backend`: `1076 passed, 35 deselected in 1347.41s`.
|
|
||||||
- Left for next session: commit/push if needed, check and merge PR #150 when Gitea CI is green, add backend CI as a required branch-protection check, and rerun frontend lint if final DoD requires it.
|
|
||||||
- Files touched: `.gitea/workflows/ci.yml`, `backend/Dockerfile.dev`, `backend/app/api/endpoints/folders.py`, `backend/app/api/endpoints/script_builder.py`, `backend/app/api/endpoints/shares.py`, `backend/app/api/router.py`, `backend/app/models/ai_session.py`, `backend/app/schemas/user.py`, `backend/app/services/assistant_chat_service.py`, `backend/app/services/resolution_output_generator.py`, `backend/app/services/script_builder_service.py`, `backend/pytest.ini`, `backend/tests/conftest.py`, and focused backend tests.
|
|
||||||
|
|
||||||
## 2026-04-25 02:00 America/New_York — Claude Code — Land FlowPilot + PSA, recover CI from 488 errors to ~4
|
|
||||||
|
|
||||||
- Started session by completing pending FlowPilot Phase 9 QA: ran `/qa` against the seeded fixtures, found and fixed four latent layout/state bugs (`ResolutionNotePreview` off-screen, `TemplateMatchPanel` deadlock when TaskLane closed, `EscalateInterceptDialog` clipped above viewport, `seed_test_users.py` `cancel_at_period_end` NOT NULL crash). Added a new fixture seeder `backend/scripts/seed_phase9_qa_fixtures.py` that pre-bakes the four backend states the AI orchestrator needs to emit, so future QA can exercise all 7 conditional Phase 9 components without depending on stochastic AI behavior.
|
|
||||||
- Discovered PR #141 (PSA ticket management) and `feat/flowpilot-migration` had 5 overlapping files but only 2 real conflicts (`CLAUDE.md`, `AssistantChatPage.tsx`). Conflicts were both additive — concatenated rather than chose-a-side.
|
|
||||||
- Merged PSA first (PR #141), then merged FlowPilot (PR #147), each through Gitea API. `tsc -b` clean and visual smoke-test confirmed PSA's Tickets sidebar coexists with Phase 9 ProposalBanner.
|
|
||||||
- Discovered main had been merging through a broken CI gate for several merges. Initially recommended "stop the line, fix CI before shipping." After scoping the actual rot (~50% of tests red, ~600 errors on a clean run), reversed the recommendation: ship the queue first because FlowPilot itself carried significant test-infra repairs that would be duplicated work on a fresh recovery branch.
|
|
||||||
- PR #148: two surgical fixes to main (network_diagrams JSONB `server_default` triple-quote bug, deprecated session-scoped `event_loop` fixture in conftest). +78 passing / -114 errors.
|
|
||||||
- PR #149: frontend lint `20 errors → 0`, `requirements-dev.txt` pytest pin bumped to satisfy `pytest-asyncio==0.24.0`'s `pytest>=8.2`, and a one-line `from app import models as _models` in conftest that registers all ~60 models with `Base.metadata` before `create_all`. The conftest fix collapsed 484 of the remaining 488 backend errors. `1018 passed / 4 errors / 54 failed` after.
|
|
||||||
- Enabled Gitea branch protection on `main`: PR-only merges, `CI / frontend (pull_request)` required, force-push blocked, no review required.
|
|
||||||
- Discovered CI on the merge commit STILL showed red despite local pytest being mostly green. Root cause: workflow only set `DATABASE_URL`, but conftest reads only `DATABASE_TEST_URL` (per `dab740d`'s safety hardening). 638 connection-refused errors on every fixture setup. Plus `actions/upload-artifact@v4` not supported by Gitea Actions. PR #150 fixes both.
|
|
||||||
- Left for next session: merge PR #150 once CI confirms green, add `CI / backend (pull_request)` to required status checks, then root-cause and fix the 54 real backend test failures (one sample seen — `test_user` fixture leaking across calls causing duplicate-email violations).
|
|
||||||
- Files touched (committed): `backend/scripts/seed_test_users.py`, `backend/scripts/seed_phase9_qa_fixtures.py` (new), `backend/app/models/network_diagram.py`, `backend/tests/conftest.py`, `backend/requirements-dev.txt`, `frontend/src/components/pilot/ResolutionNotePreview.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/components/pilot/ScriptBuilderTab.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/FlowPilotSessionPage.tsx`, `frontend/src/pages/TicketsPage.tsx`, `frontend/src/hooks/useFlowPilotSession.ts`, `frontend/src/hooks/useMediaQuery.ts`, `frontend/src/components/dashboard/TicketQueue.tsx`, `frontend/src/components/network/nodes/DeviceNode.tsx`, `frontend/src/components/network/nodes/GroupNode.tsx`, `frontend/src/components/routing/AssistantSessionRedirect.tsx` (new), `frontend/src/router.tsx`, `.gitea/workflows/ci.yml`, `.claude/settings.json` (new), `.claude/hooks/check-gstack.sh` (new), `.gitignore`, `CLAUDE.md`, `.gstack/qa-reports/phase9-*/` (QA artifacts).
|
|
||||||
- Net merges to main: PR #141 (PSA), PR #147 (FlowPilot), PR #148 (CI fixes part 1), PR #149 (CI fixes part 2). PR #150 still open at session end.
|
|
||||||
|
|
||||||
## 2026-04-24 — Claude Code — Migrate to dual-agent handoff system
|
## 2026-04-24 — Claude Code — Migrate to dual-agent handoff system
|
||||||
|
|
||||||
- Split CLAUDE.md into `.ai/PROJECT_CONTEXT.md` + shared-protocol root files (`CLAUDE.md`, `AGENTS.md`).
|
- Split CLAUDE.md into `.ai/PROJECT_CONTEXT.md` + shared-protocol root files (`CLAUDE.md`, `AGENTS.md`).
|
||||||
|
|||||||
@@ -5,13 +5,8 @@
|
|||||||
|
|
||||||
## Up next
|
## Up next
|
||||||
|
|
||||||
- [ ] **Parallelize backend pytest with pytest-xdist.** ✅ landing as PR #151. Verified locally: backend suite 22 min → 4m 28s with `-n auto` on the 8-core homelab runner. Per-worker DB isolation via `PYTEST_XDIST_WORKER` in conftest.py.
|
- [ ] No queued backlog yet.
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
- [ ] **Frontend lint warnings cleanup.** 23 `react-hooks/exhaustive-deps` warnings remain after PR #149 (mostly missing-deps in useEffect). Either fix them or audit them for known-safe ones and add eslint-disable comments. Not blocking CI today.
|
- [ ] No queued backlog yet.
|
||||||
- [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced.
|
|
||||||
- [ ] **Add `data-testid` attributes to e2e-critical interactive elements.** PR #152 fixed five Playwright tests by chasing UI-text changes (`Sessions` → `Session History`, `Account Settings` → `Account Management`, `/assistant` → `/pilot`, "Flow Sessions" tab, Resume button on session cards). Each was a one-line selector update, but every UI churn re-breaks them. Adding stable `data-testid` attributes on the targeted elements (page heading wrappers, tab nav, primary action buttons) and switching tests to `getByTestId` would make these immune to copy/route renames. Scope it small — start with `SessionHistoryPage` heading, the AI/Flow Sessions tab buttons, the per-session `Resume` button, and the command-palette FlowPilot option.
|
|
||||||
- [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently.
|
|
||||||
- [ ] **Consider `pytest-testmon` for PR-time test selection.** Tracks which tests touched which source files and only re-runs affected ones. Best for small PRs touching ~few files. Adds cache-invalidation complexity; only worth it if the suite stays painfully long even after xdist.
|
|
||||||
- [ ] **AssistantChatPage `currentChatRef` guard is a silent return** — `handleSend`, `handleTaskSubmit`, `selectChat`, `refreshFacts`, `refreshActiveFix`, and `refreshPreview` all bail with `if (currentChatRef.current !== sentForChatId) return` when stale. This is by design for chat switching, but it also silently masked the prefill-ref bug fixed in PR #153 — the user just saw "no AI response" with no log, no toast, no Sentry event. Either (a) log a `console.warn`/Sentry breadcrumb on the mismatch path so future drift is visible, or (b) split "expected stale" (chat switch) from "unexpected stale" (ref never updated) so only the latter alerts. Pair with an audit of every `currentChatRef.current = ...` assignment vs every `setActiveChatId(...)` call to make sure they're paired everywhere.
|
|
||||||
|
|||||||
@@ -17,13 +17,10 @@ jobs:
|
|||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: resolutionflow_test
|
POSTGRES_DB: resolutionflow_test
|
||||||
# No host port mapping. Tests connect to `postgres:5432` (the service
|
ports:
|
||||||
# container's docker-network DNS name), not `localhost:5432`. With
|
- 5432:5432
|
||||||
# multiple Gitea runners on the same homelab box, host-port mapping
|
|
||||||
# would race — two backend/e2e jobs both binding 0.0.0.0:5432 → the
|
|
||||||
# second fails with "port is already allocated".
|
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "pg_isready -U postgres"
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
@@ -31,12 +28,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
||||||
DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
|
DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
|
||||||
# conftest.py reads DATABASE_TEST_URL only (DATABASE_URL is intentionally
|
|
||||||
# not consulted after the dab740d test-isolation hardening). The CI test
|
|
||||||
# DB is the same postgres service, so point DATABASE_TEST_URL at it
|
|
||||||
# explicitly — without this, conftest falls back to localhost:5432 and
|
|
||||||
# all tests fail at fixture setup with "connection refused".
|
|
||||||
DATABASE_TEST_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
|
||||||
SECRET_KEY: ci-test-secret-key-not-for-production
|
SECRET_KEY: ci-test-secret-key-not-for-production
|
||||||
DEBUG: "true"
|
DEBUG: "true"
|
||||||
APP_NAME: ResolutionFlow
|
APP_NAME: ResolutionFlow
|
||||||
@@ -46,19 +37,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Cache pip
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pip
|
|
||||||
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
|
|
||||||
restore-keys: |
|
|
||||||
pip-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y libpango1.0-dev libcairo2-dev libgdk-pixbuf-2.0-dev libffi-dev libjpeg-dev zlib1g-dev
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||||
|
|
||||||
@@ -69,15 +47,7 @@ jobs:
|
|||||||
run: cd backend && python scripts/check_tenant_filters.py
|
run: cd backend && python scripts/check_tenant_filters.py
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
# `-n auto` parallelizes across all runner cores via pytest-xdist.
|
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50
|
||||||
# conftest.py creates a per-worker DB (resolutionflow_test_gw0,
|
|
||||||
# resolutionflow_test_gw1, …) so the per-test DROP SCHEMA doesn't
|
|
||||||
# race across workers. Master/serial runs keep the base DB.
|
|
||||||
# term-missing dropped — the custom "Display coverage summary" step
|
|
||||||
# below parses coverage.json and prints the same info more concisely.
|
|
||||||
# --maxfail=10 short-circuits on structural breakage so we don't burn
|
|
||||||
# 25 minutes when a fixture explodes.
|
|
||||||
run: cd backend && python -m pytest --override-ini="addopts=" -n auto --maxfail=10 --cov=app --cov-report=json:coverage.json --cov-fail-under=50
|
|
||||||
|
|
||||||
- name: Display coverage summary
|
- name: Display coverage summary
|
||||||
if: always()
|
if: always()
|
||||||
@@ -105,14 +75,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Cache npm
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ~/.npm
|
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
npm-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd frontend && npm ci
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
@@ -125,14 +87,15 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||||
|
|
||||||
# Build artifact intentionally NOT uploaded. The e2e job below builds
|
- name: Upload build artifact
|
||||||
# its own frontend rather than downloading one from this job, so there
|
uses: actions/upload-artifact@v4
|
||||||
# is no need for the cross-job artifact handoff (which previously broke
|
with:
|
||||||
# on actions/upload-artifact@v4 GHES support and forced a v3 pin).
|
name: frontend-dist
|
||||||
# Decoupling also lets e2e start immediately rather than waiting for
|
path: frontend/dist
|
||||||
# this job to finish — important on a multi-runner setup.
|
retention-days: 1
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
|
needs: [frontend]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -142,13 +105,10 @@ jobs:
|
|||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: resolutionflow_test
|
POSTGRES_DB: resolutionflow_test
|
||||||
# No host port mapping. Tests connect to `postgres:5432` (the service
|
ports:
|
||||||
# container's docker-network DNS name), not `localhost:5432`. With
|
- 5432:5432
|
||||||
# multiple Gitea runners on the same homelab box, host-port mapping
|
|
||||||
# would race — two backend/e2e jobs both binding 0.0.0.0:5432 → the
|
|
||||||
# second fails with "port is already allocated".
|
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "pg_isready -U postgres"
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
@@ -161,45 +121,21 @@ jobs:
|
|||||||
PLAYWRIGHT_SECRET_KEY: ci-playwright-secret-key
|
PLAYWRIGHT_SECRET_KEY: ci-playwright-secret-key
|
||||||
PLAYWRIGHT_TEST_EMAIL: teamadmin@resolutionflow.example.com
|
PLAYWRIGHT_TEST_EMAIL: teamadmin@resolutionflow.example.com
|
||||||
PLAYWRIGHT_TEST_PASSWORD: TestPass123!
|
PLAYWRIGHT_TEST_PASSWORD: TestPass123!
|
||||||
# AI-touching endpoints (POST /ai-sessions, /chat, /respond, etc.) are
|
|
||||||
# gated by `_require_ai_enabled()`, which returns 503 when no provider
|
|
||||||
# key is set. Tests that exercise those flows stub the AI calls in the
|
|
||||||
# browser via `page.route`, so the backend never actually contacts
|
|
||||||
# Anthropic — but the gate still has to pass. A stub value is enough.
|
|
||||||
ANTHROPIC_API_KEY: ci-stub-key-not-used-by-tests
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Cache pip
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pip
|
|
||||||
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
|
|
||||||
restore-keys: |
|
|
||||||
pip-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Cache npm
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ~/.npm
|
|
||||||
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
npm-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: cd frontend && npm ci
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Download frontend build
|
||||||
# Building inline (instead of downloading an artifact from the
|
uses: actions/download-artifact@v4
|
||||||
# frontend job) drops the cross-job dependency, so e2e can start
|
with:
|
||||||
# immediately on a free runner. Adds ~1-2 min of build time, but
|
name: frontend-dist
|
||||||
# eliminates the artifact-upload mechanism entirely (no more
|
path: frontend/dist
|
||||||
# v3/v4 GHES headaches) and saves ~5 min of waiting.
|
|
||||||
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" VITE_API_URL="${PLAYWRIGHT_API_ORIGIN}" npm run build
|
|
||||||
|
|
||||||
- name: Install Playwright browser
|
- name: Install Playwright browser
|
||||||
run: cd frontend && npx playwright install --with-deps chromium
|
run: cd frontend && npx playwright install --with-deps chromium
|
||||||
@@ -209,7 +145,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ WORKDIR /app
|
|||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
gcc \
|
gcc \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
libpango1.0-dev \
|
|
||||||
libcairo2-dev \
|
|
||||||
libgdk-pixbuf-2.0-dev \
|
|
||||||
libffi-dev \
|
|
||||||
libjpeg-dev \
|
|
||||||
zlib1g-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt requirements-dev.txt ./
|
COPY requirements.txt requirements-dev.txt ./
|
||||||
|
|||||||
@@ -194,7 +194,6 @@ async def create_folder(
|
|||||||
|
|
||||||
new_folder = UserFolder(
|
new_folder = UserFolder(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
account_id=current_user.account_id,
|
|
||||||
name=folder_data.name,
|
name=folder_data.name,
|
||||||
color=folder_data.color,
|
color=folder_data.color,
|
||||||
icon=folder_data.icon,
|
icon=folder_data.icon,
|
||||||
|
|||||||
@@ -260,7 +260,6 @@ async def save_to_library(
|
|||||||
category_id=data.category_id,
|
category_id=data.category_id,
|
||||||
share_with_team=data.share_with_team,
|
share_with_team=data.share_with_team,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
account_id=current_user.account_id,
|
|
||||||
team_id=current_user.team_id,
|
team_id=current_user.team_id,
|
||||||
script_body=data.script_body,
|
script_body=data.script_body,
|
||||||
parameters_schema=data.parameters_schema,
|
parameters_schema=data.parameters_schema,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from app.core.audit import log_audit
|
|||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
|
|
||||||
router = APIRouter(tags=["shares"])
|
router = APIRouter(tags=["shares"])
|
||||||
public_router = APIRouter(tags=["shares"])
|
|
||||||
|
|
||||||
|
|
||||||
def build_share_response(share: SessionShare) -> ShareResponse:
|
def build_share_response(share: SessionShare) -> ShareResponse:
|
||||||
@@ -207,7 +206,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@public_router.get("/share/{share_token}", response_model=SharePublicView)
|
@router.get("/share/{share_token}", response_model=SharePublicView)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def access_share(
|
async def access_share(
|
||||||
share_token: str,
|
share_token: str,
|
||||||
|
|||||||
@@ -78,11 +78,9 @@ api_router = APIRouter()
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(shared.router) # Public share links (no auth)
|
api_router.include_router(shared.router) # Public share links (no auth)
|
||||||
api_router.include_router(shares.public_router) # Public session share links (optional auth)
|
|
||||||
api_router.include_router(beta_signup.router)
|
api_router.include_router(beta_signup.router)
|
||||||
api_router.include_router(webhooks.router) # Stripe webhook receiver
|
api_router.include_router(webhooks.router) # Stripe webhook receiver
|
||||||
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
|
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
|
||||||
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Admin endpoints — super_admin only
|
# Admin endpoints — super_admin only
|
||||||
@@ -127,6 +125,7 @@ api_router.include_router(ai_fix.router, dependencies=_tenant_deps)
|
|||||||
api_router.include_router(ai_chat.router, dependencies=_tenant_deps)
|
api_router.include_router(ai_chat.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(copilot.router, dependencies=_tenant_deps)
|
api_router.include_router(copilot.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(assistant_chat.router, dependencies=_tenant_deps)
|
api_router.include_router(assistant_chat.router, dependencies=_tenant_deps)
|
||||||
|
api_router.include_router(survey.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(tree_transfer.router, dependencies=_tenant_deps)
|
api_router.include_router(tree_transfer.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps)
|
api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(kb_accelerator.router, dependencies=_tenant_deps)
|
api_router.include_router(kb_accelerator.router, dependencies=_tenant_deps)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Optional, Any, TYPE_CHECKING
|
|||||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint
|
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB, TSVECTOR
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
||||||
@@ -46,7 +46,6 @@ class AISession(Base):
|
|||||||
"confidence_tier IN ('guided', 'exploring', 'discovery')",
|
"confidence_tier IN ('guided', 'exploring', 'discovery')",
|
||||||
name="ck_ai_sessions_confidence_tier",
|
name="ck_ai_sessions_confidence_tier",
|
||||||
),
|
),
|
||||||
sa.Index("idx_ai_sessions_search", "search_vector", postgresql_using="gin"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
@@ -151,18 +150,6 @@ class AISession(Base):
|
|||||||
Text, nullable=True,
|
Text, nullable=True,
|
||||||
comment="Why escalated (set on escalation)",
|
comment="Why escalated (set on escalation)",
|
||||||
)
|
)
|
||||||
search_vector: Mapped[Optional[str]] = mapped_column(
|
|
||||||
TSVECTOR,
|
|
||||||
sa.Computed(
|
|
||||||
"to_tsvector('english', "
|
|
||||||
"coalesce(problem_summary, '') || ' ' || "
|
|
||||||
"coalesce(resolution_summary, '') || ' ' || "
|
|
||||||
"coalesce(escalation_reason, '') || ' ' || "
|
|
||||||
"coalesce(problem_domain, ''))",
|
|
||||||
persisted=True,
|
|
||||||
),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
escalation_package: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
escalation_package: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||||
JSONB, nullable=True,
|
JSONB, nullable=True,
|
||||||
comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions",
|
comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions",
|
||||||
|
|||||||
@@ -68,6 +68,4 @@ class RoleUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AccountRoleUpdate(BaseModel):
|
class AccountRoleUpdate(BaseModel):
|
||||||
# Ownership changes must go through the explicit transfer-ownership flow so
|
account_role: str = Field(..., pattern="^(owner|admin|engineer|viewer)$")
|
||||||
# account.owner_id stays consistent with user.account_role.
|
|
||||||
account_role: str = Field(..., pattern="^(admin|engineer|viewer)$")
|
|
||||||
|
|||||||
@@ -300,14 +300,13 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
|
|||||||
When you identify a second distinct issue that is clearly separate from the primary topic \
|
When you identify a second distinct issue that is clearly separate from the primary topic \
|
||||||
of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \
|
of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \
|
||||||
Use this sparingly — only when the issue is genuinely independent, not for every tangential mention.
|
Use this sparingly — only when the issue is genuinely independent, not for every tangential mention.
|
||||||
Use `create_spin_off_ticket` as the command value for this action.
|
|
||||||
|
|
||||||
Format:
|
Format:
|
||||||
[ACTIONS]
|
[ACTIONS]
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"label": "Create ticket: <brief issue title>",
|
"label": "Create ticket: <brief issue title>",
|
||||||
"command": "<spin-off ticket action command>",
|
"command": "create_spin_off_ticket",
|
||||||
"description": "<one sentence description of the separate issue>"
|
"description": "<one sentence description of the separate issue>"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from uuid import UUID
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.models.ai_session import AISession
|
from app.models.ai_session import AISession
|
||||||
from app.models.session_resolution_output import SessionResolutionOutput
|
from app.models.session_resolution_output import SessionResolutionOutput
|
||||||
@@ -22,9 +21,7 @@ class ResolutionOutputGenerator:
|
|||||||
|
|
||||||
async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]:
|
async def generate_all(self, session_id: UUID) -> list[SessionResolutionOutput]:
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(AISession)
|
select(AISession).where(AISession.id == session_id)
|
||||||
.options(selectinload(AISession.steps))
|
|
||||||
.where(AISession.id == session_id)
|
|
||||||
)
|
)
|
||||||
session = result.scalar_one_or_none()
|
session = result.scalar_one_or_none()
|
||||||
if not session:
|
if not session:
|
||||||
|
|||||||
@@ -360,7 +360,6 @@ async def save_to_library(
|
|||||||
category_id: UUID | None,
|
category_id: UUID | None,
|
||||||
share_with_team: bool,
|
share_with_team: bool,
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
account_id: UUID,
|
|
||||||
team_id: UUID | None,
|
team_id: UUID | None,
|
||||||
script_body: str | None = None,
|
script_body: str | None = None,
|
||||||
parameters_schema: dict | None = None,
|
parameters_schema: dict | None = None,
|
||||||
@@ -402,7 +401,6 @@ async def save_to_library(
|
|||||||
id=uuid_mod.uuid4(),
|
id=uuid_mod.uuid4(),
|
||||||
category_id=resolved_category_id,
|
category_id=resolved_category_id,
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
account_id=account_id,
|
|
||||||
team_id=team_id if share_with_team else None,
|
team_id=team_id if share_with_team else None,
|
||||||
name=name,
|
name=name,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ testpaths = tests
|
|||||||
# Warnings
|
# Warnings
|
||||||
filterwarnings =
|
filterwarnings =
|
||||||
error
|
error
|
||||||
ignore:unclosed <socket\.socket.*:ResourceWarning
|
|
||||||
ignore:unclosed transport .*:ResourceWarning
|
|
||||||
ignore:unclosed event loop .*:ResourceWarning
|
|
||||||
ignore::DeprecationWarning
|
ignore::DeprecationWarning
|
||||||
ignore::PendingDeprecationWarning
|
ignore::PendingDeprecationWarning
|
||||||
ignore::pluggy.PluggyTeardownRaisedWarning
|
ignore::pluggy.PluggyTeardownRaisedWarning
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
# Testing — pytest-asyncio 0.24+ requires pytest>=8.2
|
# Testing — pytest-asyncio 0.24+ requires pytest>=8.2
|
||||||
pytest==8.4.2
|
pytest==8.4.2
|
||||||
pytest-asyncio==0.24.0
|
pytest-asyncio==0.24.0
|
||||||
pytest-xdist==3.6.1
|
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
pytest-cov==5.0.0
|
pytest-cov==5.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Provides test database setup, client fixtures, and authentication helpers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import asyncio
|
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -35,64 +34,11 @@ settings.REQUIRE_INVITE_CODE = False
|
|||||||
# would silently nuke the dev database. Only DATABASE_TEST_URL is honored,
|
# would silently nuke the dev database. Only DATABASE_TEST_URL is honored,
|
||||||
# and the safety assertion below refuses to run against a DB whose name
|
# and the safety assertion below refuses to run against a DB whose name
|
||||||
# doesn't contain "test".
|
# doesn't contain "test".
|
||||||
_BASE_TEST_DATABASE_URL = os.environ.get(
|
TEST_DATABASE_URL = os.environ.get(
|
||||||
"DATABASE_TEST_URL",
|
"DATABASE_TEST_URL",
|
||||||
"postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test",
|
"postgresql+asyncpg://postgres:postgres@localhost:5432/resolutionflow_test",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _worker_db_url(base_url: str) -> str:
|
|
||||||
"""Per-worker DB URL for pytest-xdist parallelization.
|
|
||||||
|
|
||||||
pytest-xdist sets PYTEST_XDIST_WORKER to 'gw0', 'gw1', ... per worker
|
|
||||||
process. Each worker needs its own database so the per-test
|
|
||||||
`DROP SCHEMA public CASCADE` doesn't race across workers. Master/serial
|
|
||||||
runs (no xdist) keep the base DB. The base DB is created by the postgres
|
|
||||||
service container; per-worker DBs are CREATE DATABASE-d on first import
|
|
||||||
by `_ensure_worker_db_exists` below.
|
|
||||||
"""
|
|
||||||
worker = os.environ.get("PYTEST_XDIST_WORKER")
|
|
||||||
if not worker or worker == "master":
|
|
||||||
return base_url
|
|
||||||
head, tail = base_url.rsplit("/", 1)
|
|
||||||
db_name, _, query = tail.partition("?")
|
|
||||||
suffix = f"?{query}" if query else ""
|
|
||||||
return f"{head}/{db_name}_{worker}{suffix}"
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_worker_db_exists(worker_url: str, base_url: str) -> None:
|
|
||||||
"""Create the per-worker DB if it doesn't exist. Runs synchronously at
|
|
||||||
conftest import time (before any async test machinery), using psycopg2
|
|
||||||
against the postgres maintenance DB. No-op when not running under xdist.
|
|
||||||
"""
|
|
||||||
if worker_url == base_url:
|
|
||||||
return
|
|
||||||
head, tail = worker_url.rsplit("/", 1)
|
|
||||||
worker_db = tail.partition("?")[0]
|
|
||||||
# Strip the +asyncpg dialect for sync psycopg2 + connect to 'postgres'.
|
|
||||||
sync_head = head.replace("+asyncpg", "")
|
|
||||||
admin_url = f"{sync_head}/postgres"
|
|
||||||
# Lazy import — psycopg2 is a transitive backend dep; not imported at
|
|
||||||
# module top to keep the conftest light when xdist isn't in use.
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
engine = create_engine(admin_url, isolation_level="AUTOCOMMIT")
|
|
||||||
try:
|
|
||||||
with engine.begin() as conn:
|
|
||||||
exists = conn.execute(
|
|
||||||
sa.text("SELECT 1 FROM pg_database WHERE datname = :n"),
|
|
||||||
{"n": worker_db},
|
|
||||||
).scalar()
|
|
||||||
if not exists:
|
|
||||||
# Identifier interpolation is safe — worker_db is built from
|
|
||||||
# the trusted base URL + 'gw\d+' worker suffix.
|
|
||||||
conn.execute(sa.text(f'CREATE DATABASE "{worker_db}"'))
|
|
||||||
finally:
|
|
||||||
engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
TEST_DATABASE_URL = _worker_db_url(_BASE_TEST_DATABASE_URL)
|
|
||||||
_ensure_worker_db_exists(TEST_DATABASE_URL, _BASE_TEST_DATABASE_URL)
|
|
||||||
|
|
||||||
# Belt-and-suspenders: refuse to run tests against a DB whose name doesn't
|
# Belt-and-suspenders: refuse to run tests against a DB whose name doesn't
|
||||||
# contain "test". Parses the last path segment of the URL (everything after
|
# contain "test". Parses the last path segment of the URL (everything after
|
||||||
# the final '/', with query string stripped) so credentials / hosts that
|
# the final '/', with query string stripped) so credentials / hosts that
|
||||||
@@ -127,20 +73,6 @@ def pytest_collection_modifyitems(config, items):
|
|||||||
items[:] = selected
|
items[:] = selected
|
||||||
|
|
||||||
|
|
||||||
@pytest.hookimpl(trylast=True, hookwrapper=True)
|
|
||||||
def pytest_runtest_teardown(item, nextitem):
|
|
||||||
"""Close pytest-asyncio's post-test clean loop before warnings collect it."""
|
|
||||||
yield
|
|
||||||
policy = asyncio.get_event_loop_policy()
|
|
||||||
try:
|
|
||||||
loop = policy.get_event_loop()
|
|
||||||
except RuntimeError:
|
|
||||||
return
|
|
||||||
if not loop.is_running() and not loop.is_closed():
|
|
||||||
loop.close()
|
|
||||||
policy.set_event_loop(None)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""
|
"""
|
||||||
@@ -205,7 +137,6 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
# Dispose engine first so all pooled connections are released,
|
# Dispose engine first so all pooled connections are released,
|
||||||
# then reconnect to perform the schema teardown cleanly.
|
# then reconnect to perform the schema teardown cleanly.
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
|
|
||||||
# Drop all tables after test (CASCADE for circular FKs)
|
# Drop all tables after test (CASCADE for circular FKs)
|
||||||
teardown_engine = create_async_engine(
|
teardown_engine = create_async_engine(
|
||||||
@@ -219,7 +150,6 @@ async def test_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
await conn.execute(sa.text("CREATE SCHEMA public"))
|
await conn.execute(sa.text("CREATE SCHEMA public"))
|
||||||
finally:
|
finally:
|
||||||
await teardown_engine.dispose()
|
await teardown_engine.dispose()
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -74,25 +74,19 @@ def _mock_ai_provider(text: str, input_tokens: int = 100, output_tokens: int = 2
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def enable_ai():
|
def enable_ai():
|
||||||
"""Temporarily enable AI by setting a fake API key."""
|
"""Temporarily enable AI by setting a fake API key."""
|
||||||
original_anthropic = settings.ANTHROPIC_API_KEY
|
original = settings.ANTHROPIC_API_KEY
|
||||||
original_google = settings.GOOGLE_AI_API_KEY
|
|
||||||
settings.ANTHROPIC_API_KEY = "test-key-fake"
|
settings.ANTHROPIC_API_KEY = "test-key-fake"
|
||||||
settings.GOOGLE_AI_API_KEY = None
|
|
||||||
yield
|
yield
|
||||||
settings.ANTHROPIC_API_KEY = original_anthropic
|
settings.ANTHROPIC_API_KEY = original
|
||||||
settings.GOOGLE_AI_API_KEY = original_google
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def disable_ai():
|
def disable_ai():
|
||||||
"""Ensure AI is disabled."""
|
"""Ensure AI is disabled."""
|
||||||
original_anthropic = settings.ANTHROPIC_API_KEY
|
original = settings.ANTHROPIC_API_KEY
|
||||||
original_google = settings.GOOGLE_AI_API_KEY
|
|
||||||
settings.ANTHROPIC_API_KEY = None
|
settings.ANTHROPIC_API_KEY = None
|
||||||
settings.GOOGLE_AI_API_KEY = None
|
|
||||||
yield
|
yield
|
||||||
settings.ANTHROPIC_API_KEY = original_anthropic
|
settings.ANTHROPIC_API_KEY = original
|
||||||
settings.GOOGLE_AI_API_KEY = original_google
|
|
||||||
|
|
||||||
|
|
||||||
# ── Quota endpoint ──
|
# ── Quota endpoint ──
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db
|
|||||||
|
|
||||||
step = AISessionStep(
|
step = AISessionStep(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
account_id=session.account_id,
|
|
||||||
step_order=0,
|
step_order=0,
|
||||||
step_type="question",
|
step_type="question",
|
||||||
content={"text": "What's the issue?"},
|
content={"text": "What's the issue?"},
|
||||||
@@ -120,7 +119,7 @@ async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_
|
|||||||
root = await manager.create_root_branch(session.id)
|
root = await manager.create_root_branch(session.id)
|
||||||
|
|
||||||
step = AISessionStep(
|
step = AISessionStep(
|
||||||
session_id=session.id, account_id=session.account_id, step_order=0, step_type="question",
|
session_id=session.id, step_order=0, step_type="question",
|
||||||
content={"text": "test"}, confidence_at_step=0.5,
|
content={"text": "test"}, confidence_at_step=0.5,
|
||||||
)
|
)
|
||||||
test_db.add(step)
|
test_db.add(step)
|
||||||
@@ -198,7 +197,7 @@ async def test_get_branch_tree(client: AsyncClient, test_user, auth_headers, tes
|
|||||||
root = await manager.create_root_branch(session.id)
|
root = await manager.create_root_branch(session.id)
|
||||||
|
|
||||||
step = AISessionStep(
|
step = AISessionStep(
|
||||||
session_id=session.id, account_id=session.account_id, step_order=0, step_type="question",
|
session_id=session.id, step_order=0, step_type="question",
|
||||||
content={"text": "test"}, confidence_at_step=0.5,
|
content={"text": "test"}, confidence_at_step=0.5,
|
||||||
)
|
)
|
||||||
test_db.add(step)
|
test_db.add(step)
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ async def _make_session(test_db, user, *, with_psa: bool = False) -> AISession:
|
|||||||
conn = PsaConnection(
|
conn = PsaConnection(
|
||||||
account_id=user["user_data"]["account_id"],
|
account_id=user["user_data"]["account_id"],
|
||||||
provider="connectwise",
|
provider="connectwise",
|
||||||
display_name="Test ConnectWise",
|
|
||||||
site_url="https://fake.cw.local",
|
site_url="https://fake.cw.local",
|
||||||
company_id="TEST",
|
company_id="TEST",
|
||||||
credentials_encrypted=encrypt_credentials({"public_key": "x", "private_key": "y"}),
|
credentials_encrypted=encrypt_credentials({"public_key": "x", "private_key": "y"}),
|
||||||
|
|||||||
@@ -472,20 +472,19 @@ class TestScriptBuilderSlugCollision:
|
|||||||
# Pre-create a template with slug "test-script" to cause collision
|
# Pre-create a template with slug "test-script" to cause collision
|
||||||
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||||
user_id = user_resp.json()["id"]
|
user_id = user_resp.json()["id"]
|
||||||
account_id = user_resp.json()["account_id"]
|
|
||||||
await test_db.execute(
|
await test_db.execute(
|
||||||
sa.text("""
|
sa.text("""
|
||||||
INSERT INTO script_templates
|
INSERT INTO script_templates
|
||||||
(id, category_id, created_by, account_id, name, slug, script_body,
|
(id, category_id, created_by, name, slug, script_body,
|
||||||
parameters_schema, default_values, validation_rules, tags,
|
parameters_schema, default_values, validation_rules, tags,
|
||||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:id, 'a0000000-0000-0000-0000-000000000001'::uuid, :uid, :account_id,
|
(:id, 'a0000000-0000-0000-0000-000000000001'::uuid, :uid,
|
||||||
'Test Script', 'test-script', 'echo hello',
|
'Test Script', 'test-script', 'echo hello',
|
||||||
'{"parameters": []}', '{}', '{}', '["powershell"]',
|
'{"parameters": []}', '{}', '{}', '["powershell"]',
|
||||||
'beginner', true, 1, 0, NOW(), NOW())
|
'beginner', true, 1, 0, NOW(), NOW())
|
||||||
"""),
|
"""),
|
||||||
{"id": str(uuid_mod.uuid4()), "uid": user_id, "account_id": account_id},
|
{"id": str(uuid_mod.uuid4()), "uid": user_id},
|
||||||
)
|
)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
|
|
||||||
@@ -562,7 +561,6 @@ class TestScriptTemplateFilters:
|
|||||||
"""mine=true returns only templates created by the current user."""
|
"""mine=true returns only templates created by the current user."""
|
||||||
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||||
user_id = user_resp.json()["id"]
|
user_id = user_resp.json()["id"]
|
||||||
account_id = user_resp.json()["account_id"]
|
|
||||||
|
|
||||||
second_resp = await client.get("/api/v1/auth/me", headers=second_user_headers)
|
second_resp = await client.get("/api/v1/auth/me", headers=second_user_headers)
|
||||||
second_user_id = second_resp.json()["id"]
|
second_user_id = second_resp.json()["id"]
|
||||||
@@ -573,32 +571,32 @@ class TestScriptTemplateFilters:
|
|||||||
await test_db.execute(
|
await test_db.execute(
|
||||||
sa.text("""
|
sa.text("""
|
||||||
INSERT INTO script_templates
|
INSERT INTO script_templates
|
||||||
(id, category_id, created_by, account_id, team_id, name, slug, script_body,
|
(id, category_id, created_by, team_id, name, slug, script_body,
|
||||||
parameters_schema, default_values, validation_rules, tags,
|
parameters_schema, default_values, validation_rules, tags,
|
||||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:id, :cat, :uid, :account_id, NULL,
|
(:id, :cat, :uid, NULL,
|
||||||
'My Script', 'my-script', 'echo mine',
|
'My Script', 'my-script', 'echo mine',
|
||||||
'{"parameters": []}', '{}', '{}', '[]',
|
'{"parameters": []}', '{}', '{}', '[]',
|
||||||
'beginner', true, 1, 0, NOW(), NOW())
|
'beginner', true, 1, 0, NOW(), NOW())
|
||||||
"""),
|
"""),
|
||||||
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "account_id": account_id},
|
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create template owned by second user (no team_id, so visible to all)
|
# Create template owned by second user (no team_id, so visible to all)
|
||||||
await test_db.execute(
|
await test_db.execute(
|
||||||
sa.text("""
|
sa.text("""
|
||||||
INSERT INTO script_templates
|
INSERT INTO script_templates
|
||||||
(id, category_id, created_by, account_id, team_id, name, slug, script_body,
|
(id, category_id, created_by, team_id, name, slug, script_body,
|
||||||
parameters_schema, default_values, validation_rules, tags,
|
parameters_schema, default_values, validation_rules, tags,
|
||||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:id, :cat, :uid, :account_id, NULL,
|
(:id, :cat, :uid, NULL,
|
||||||
'Other Script', 'other-script', 'echo other',
|
'Other Script', 'other-script', 'echo other',
|
||||||
'{"parameters": []}', '{}', '{}', '[]',
|
'{"parameters": []}', '{}', '{}', '[]',
|
||||||
'beginner', true, 1, 0, NOW(), NOW())
|
'beginner', true, 1, 0, NOW(), NOW())
|
||||||
"""),
|
"""),
|
||||||
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": second_user_id, "account_id": account_id},
|
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": second_user_id},
|
||||||
)
|
)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
|
|
||||||
@@ -619,7 +617,6 @@ class TestScriptTemplateFilters:
|
|||||||
"""shared=true returns only templates shared with the user's team."""
|
"""shared=true returns only templates shared with the user's team."""
|
||||||
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||||
user_id = user_resp.json()["id"]
|
user_id = user_resp.json()["id"]
|
||||||
account_id = user_resp.json()["account_id"]
|
|
||||||
|
|
||||||
cat_id = "b0000000-0000-0000-0000-000000000001"
|
cat_id = "b0000000-0000-0000-0000-000000000001"
|
||||||
|
|
||||||
@@ -642,32 +639,32 @@ class TestScriptTemplateFilters:
|
|||||||
await test_db.execute(
|
await test_db.execute(
|
||||||
sa.text("""
|
sa.text("""
|
||||||
INSERT INTO script_templates
|
INSERT INTO script_templates
|
||||||
(id, category_id, created_by, account_id, team_id, name, slug, script_body,
|
(id, category_id, created_by, team_id, name, slug, script_body,
|
||||||
parameters_schema, default_values, validation_rules, tags,
|
parameters_schema, default_values, validation_rules, tags,
|
||||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:id, :cat, :uid, :account_id, :tid,
|
(:id, :cat, :uid, :tid,
|
||||||
'Team Script', 'team-script', 'echo team',
|
'Team Script', 'team-script', 'echo team',
|
||||||
'{"parameters": []}', '{}', '{}', '[]',
|
'{"parameters": []}', '{}', '{}', '[]',
|
||||||
'beginner', true, 1, 0, NOW(), NOW())
|
'beginner', true, 1, 0, NOW(), NOW())
|
||||||
"""),
|
"""),
|
||||||
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "account_id": account_id, "tid": team_id},
|
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "tid": team_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Template NOT shared (no team_id)
|
# Template NOT shared (no team_id)
|
||||||
await test_db.execute(
|
await test_db.execute(
|
||||||
sa.text("""
|
sa.text("""
|
||||||
INSERT INTO script_templates
|
INSERT INTO script_templates
|
||||||
(id, category_id, created_by, account_id, team_id, name, slug, script_body,
|
(id, category_id, created_by, team_id, name, slug, script_body,
|
||||||
parameters_schema, default_values, validation_rules, tags,
|
parameters_schema, default_values, validation_rules, tags,
|
||||||
complexity, is_active, version, usage_count, created_at, updated_at)
|
complexity, is_active, version, usage_count, created_at, updated_at)
|
||||||
VALUES
|
VALUES
|
||||||
(:id, :cat, :uid, :account_id, NULL,
|
(:id, :cat, :uid, NULL,
|
||||||
'Personal Script', 'personal-script', 'echo personal',
|
'Personal Script', 'personal-script', 'echo personal',
|
||||||
'{"parameters": []}', '{}', '{}', '[]',
|
'{"parameters": []}', '{}', '{}', '[]',
|
||||||
'beginner', true, 1, 0, NOW(), NOW())
|
'beginner', true, 1, 0, NOW(), NOW())
|
||||||
"""),
|
"""),
|
||||||
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "account_id": account_id},
|
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id},
|
||||||
)
|
)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async def test_create_fork(client: AsyncClient, test_user, auth_headers, test_db
|
|||||||
await test_db.flush()
|
await test_db.flush()
|
||||||
|
|
||||||
step = AISessionStep(
|
step = AISessionStep(
|
||||||
session_id=session.id, account_id=session.account_id, step_order=0, step_type="question",
|
session_id=session.id, step_order=0, step_type="question",
|
||||||
content={"text": "test"}, confidence_at_step=0.5,
|
content={"text": "test"}, confidence_at_step=0.5,
|
||||||
)
|
)
|
||||||
test_db.add(step)
|
test_db.add(step)
|
||||||
@@ -88,7 +88,7 @@ async def test_switch_branch(client: AsyncClient, test_user, auth_headers, test_
|
|||||||
await test_db.flush()
|
await test_db.flush()
|
||||||
|
|
||||||
step = AISessionStep(
|
step = AISessionStep(
|
||||||
session_id=session.id, account_id=session.account_id, step_order=0, step_type="question",
|
session_id=session.id, step_order=0, step_type="question",
|
||||||
content={"text": "test"}, confidence_at_step=0.5,
|
content={"text": "test"}, confidence_at_step=0.5,
|
||||||
)
|
)
|
||||||
test_db.add(step)
|
test_db.add(step)
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ async def test_edit_output_api(client: AsyncClient, test_user, auth_headers, tes
|
|||||||
|
|
||||||
output = SessionResolutionOutput(
|
output = SessionResolutionOutput(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
account_id=session.account_id,
|
|
||||||
output_type="psa_ticket_notes",
|
output_type="psa_ticket_notes",
|
||||||
generated_content="Original",
|
generated_content="Original",
|
||||||
status="draft",
|
status="draft",
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ class TestSessionSharing:
|
|||||||
json={"visibility": "public"},
|
json={"visibility": "public"},
|
||||||
headers=other_headers
|
headers=other_headers
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 403
|
||||||
|
|
||||||
async def test_share_nonexistent_session(self, client: AsyncClient, auth_headers):
|
async def test_share_nonexistent_session(self, client: AsyncClient, auth_headers):
|
||||||
"""Creating a share for nonexistent session returns 404."""
|
"""Creating a share for nonexistent session returns 404."""
|
||||||
|
|||||||
@@ -213,23 +213,10 @@ async def test_record_decision_persists_and_bumps_state_version(
|
|||||||
title="x",
|
title="x",
|
||||||
description="y",
|
description="y",
|
||||||
confidence_pct=50,
|
confidence_pct=50,
|
||||||
ai_drafted_script="Write-Output 'ok'",
|
|
||||||
)
|
)
|
||||||
test_db.add(fix)
|
test_db.add(fix)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
|
|
||||||
# The draft_template path calls TemplateExtractionService, which needs an
|
|
||||||
# AI provider configured. CI doesn't set ANTHROPIC_API_KEY/GOOGLE_AI_API_KEY,
|
|
||||||
# and this test isn't exercising the AI integration — patch the extractor
|
|
||||||
# with a minimal valid response so the rest of the decision flow runs.
|
|
||||||
extractor_stub = AsyncMock(return_value={
|
|
||||||
"templated_body": "Write-Output 'ok'",
|
|
||||||
"parameters": [],
|
|
||||||
})
|
|
||||||
with patch(
|
|
||||||
"app.api.endpoints.session_suggested_fixes._extract_template_parameters",
|
|
||||||
extractor_stub,
|
|
||||||
):
|
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
f"/api/v1/ai-sessions/{session.id}/suggested-fixes/{fix.id}/decision",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async def _create_account_and_user(db: AsyncSession, prefix: str):
|
|||||||
async def _login(client: AsyncClient, email: str, password: str) -> dict:
|
async def _login(client: AsyncClient, email: str, password: str) -> dict:
|
||||||
"""Log in and return Authorization headers."""
|
"""Log in and return Authorization headers."""
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/auth/login/json",
|
"/api/v1/auth/login",
|
||||||
json={"email": email, "password": password},
|
json={"email": email, "password": password},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
||||||
@@ -101,11 +101,11 @@ async def test_category_tree_count_scoped_to_account(
|
|||||||
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "cat-a")
|
acct_a, user_a, pass_a = await _create_account_and_user(test_db, "cat-a")
|
||||||
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "cat-b")
|
acct_b, user_b, pass_b = await _create_account_and_user(test_db, "cat-b")
|
||||||
|
|
||||||
# Categories are tenant-scoped; the endpoint must only count account A's trees.
|
# Shared category (account_id=None means global)
|
||||||
category = TreeCategory(
|
category = TreeCategory(
|
||||||
name="Shared Category",
|
name="Shared Category",
|
||||||
slug=f"shared-cat-{uuid.uuid4().hex[:6]}",
|
slug=f"shared-cat-{uuid.uuid4().hex[:6]}",
|
||||||
account_id=acct_a.id,
|
account_id=None,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
test_db.add(category)
|
test_db.add(category)
|
||||||
@@ -270,7 +270,6 @@ async def test_get_session_returns_404_not_403_for_other_user(
|
|||||||
session_b = Session(
|
session_b = Session(
|
||||||
tree_id=tree_b.id,
|
tree_id=tree_b.id,
|
||||||
user_id=user_b.id,
|
user_id=user_b.id,
|
||||||
account_id=acct_b.id,
|
|
||||||
tree_snapshot={"id": "root", "type": "start", "children": []},
|
tree_snapshot={"id": "root", "type": "start", "children": []},
|
||||||
path_taken=[],
|
path_taken=[],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
@@ -385,7 +384,6 @@ async def test_share_revoke_returns_404_not_403_for_other_user(
|
|||||||
session_b = Session(
|
session_b = Session(
|
||||||
tree_id=tree_b.id,
|
tree_id=tree_b.id,
|
||||||
user_id=user_b.id,
|
user_id=user_b.id,
|
||||||
account_id=acct_b.id,
|
|
||||||
tree_snapshot={"id": "root", "type": "start", "children": []},
|
tree_snapshot={"id": "root", "type": "start", "children": []},
|
||||||
path_taken=[],
|
path_taken=[],
|
||||||
decisions=[],
|
decisions=[],
|
||||||
@@ -536,7 +534,6 @@ async def test_maintenance_schedule_returns_404_for_other_team(
|
|||||||
# Create a schedule for that tree
|
# Create a schedule for that tree
|
||||||
schedule_b = MaintenanceSchedule(
|
schedule_b = MaintenanceSchedule(
|
||||||
tree_id=tree_b.id,
|
tree_id=tree_b.id,
|
||||||
account_id=acct_b.id,
|
|
||||||
created_by=user_b.id,
|
created_by=user_b.id,
|
||||||
cron_expression="0 2 * * 0",
|
cron_expression="0 2 * * 0",
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from datetime import datetime, timezone, timedelta
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.models.account import Account
|
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.models.tree_share import TreeShare
|
from app.models.tree_share import TreeShare
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -288,17 +287,13 @@ class TestTreeSharing:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_migration_defaults_visibility_to_team(test_db):
|
async def test_migration_defaults_visibility_to_team(test_db):
|
||||||
"""Test that existing trees default to 'team' visibility after migration."""
|
"""Test that existing trees default to 'team' visibility after migration."""
|
||||||
account = Account(name="Migration Default Test", display_code=uuid4().hex[:8])
|
|
||||||
test_db.add(account)
|
|
||||||
await test_db.flush()
|
|
||||||
|
|
||||||
# Create a tree without specifying visibility
|
# Create a tree without specifying visibility
|
||||||
tree = Tree(
|
tree = Tree(
|
||||||
name="Old Tree",
|
name="Old Tree",
|
||||||
description="Created before migration",
|
description="Created before migration",
|
||||||
tree_structure={"id": "root", "type": "decision", "question": "Test?", "children": []},
|
tree_structure={"id": "root", "type": "decision", "question": "Test?", "children": []},
|
||||||
author_id=None,
|
author_id=None,
|
||||||
account_id=account.id
|
account_id=None
|
||||||
)
|
)
|
||||||
test_db.add(tree)
|
test_db.add(tree)
|
||||||
await test_db.commit()
|
await test_db.commit()
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ async def test_delete_upload_forbidden_for_non_owner(client, auth_headers, test_
|
|||||||
f"/api/v1/uploads/{upload.id}", headers=other_headers
|
f"/api/v1/uploads/{upload.id}", headers=other_headers
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression test for the prefill-handoff `currentChatRef` bug.
|
|
||||||
*
|
|
||||||
* Symptom: a chat session created via the dashboard prefill flow
|
|
||||||
* looked fine on the first AI turn, but submitting partial answers
|
|
||||||
* from the task lane silently dropped the AI's follow-up response.
|
|
||||||
* The user saw their answers in the chat, no assistant reply, no
|
|
||||||
* toast.
|
|
||||||
*
|
|
||||||
* Root cause: the prefill effect in `AssistantChatPage` set
|
|
||||||
* `activeChatId` without also updating `currentChatRef.current`, so
|
|
||||||
* the `currentChatRef.current !== sentForChatId` guard in
|
|
||||||
* `handleTaskSubmit` (and `handleSend`) tripped on every subsequent
|
|
||||||
* request and discarded the AI response.
|
|
||||||
*
|
|
||||||
* Strategy: drive the real prefill flow against the real backend, but
|
|
||||||
* intercept the `/chat` endpoint with `page.route` so we get
|
|
||||||
* deterministic question payloads on turn 1 and a deterministic
|
|
||||||
* follow-up on turn 2. The fix is what makes turn 2 visible.
|
|
||||||
*/
|
|
||||||
test.describe('AssistantChatPage — prefill handoff regression', () => {
|
|
||||||
test('AI follow-up renders after submitting partial task lane answers', async ({ page }) => {
|
|
||||||
let chatCallCount = 0
|
|
||||||
|
|
||||||
// Clear any persisted active-chat-id so the page does not auto-resume a
|
|
||||||
// stale session left behind by a sibling spec.
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
try {
|
|
||||||
sessionStorage.removeItem('rf-active-chat-id')
|
|
||||||
sessionStorage.removeItem('rf-tasklane-meta')
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
})
|
|
||||||
|
|
||||||
// Intercept only the chat endpoint. Session creation, listSessions,
|
|
||||||
// facts, suggested-fixes, etc. all hit the real backend so the page
|
|
||||||
// renders normally — only the LLM call is deterministic. The pattern
|
|
||||||
// matches `/ai-sessions/<uuid>/chat` and nothing nested beneath it.
|
|
||||||
await page.route(/\/api\/v1\/ai-sessions\/[^/]+\/chat$/, async (route) => {
|
|
||||||
if (route.request().method() !== 'POST') {
|
|
||||||
await route.fallback()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatCallCount += 1
|
|
||||||
if (chatCallCount === 1) {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
content: 'Initial diagnostic plan. Please answer the questions in the task lane.',
|
|
||||||
suggested_flows: [],
|
|
||||||
fork: null,
|
|
||||||
actions: [],
|
|
||||||
questions: [
|
|
||||||
{ text: 'Has the user recently changed their password?' },
|
|
||||||
{ text: 'Is the lockout happening at a consistent time of day?' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
content: 'Got it — based on your answer, here is what to check next.',
|
|
||||||
suggested_flows: [],
|
|
||||||
fork: null,
|
|
||||||
actions: [],
|
|
||||||
questions: [],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Drive the prefill flow exactly the way the dashboard does. The textarea
|
|
||||||
// is keyed by its placeholder copy on QuickStartPage.
|
|
||||||
await page.goto('/')
|
|
||||||
const prefillBox = page.getByPlaceholder(/Describe the issue/i)
|
|
||||||
await expect(prefillBox).toBeVisible({ timeout: 10_000 })
|
|
||||||
await prefillBox.fill('User locked out of AD weekly')
|
|
||||||
await prefillBox.press('Enter')
|
|
||||||
|
|
||||||
// After the prefill submits we land on /pilot and the first stubbed AI
|
|
||||||
// turn surfaces the task-lane question text.
|
|
||||||
await expect(page).toHaveURL(/\/pilot/)
|
|
||||||
await expect(
|
|
||||||
page.getByText('Has the user recently changed their password?'),
|
|
||||||
).toBeVisible({ timeout: 15_000 })
|
|
||||||
|
|
||||||
// Answer the first question. UI flow: click "Answer" to open the
|
|
||||||
// textarea, type, click the inline "Answer" button to mark done.
|
|
||||||
await page.getByRole('button', { name: /^Answer$/ }).first().click()
|
|
||||||
await page.getByPlaceholder('Type your answer...').fill('No, password is months old')
|
|
||||||
await page.getByRole('button', { name: /^Answer$/ }).first().click()
|
|
||||||
|
|
||||||
// Submit the partial response. Pre-fix: the response was silently dropped
|
|
||||||
// here because `currentChatRef.current` still held the mount-time value.
|
|
||||||
await page.getByRole('button', { name: /Send 1 of 2 Responses/ }).click()
|
|
||||||
|
|
||||||
// Bug repro: the assistant message must render. Pre-fix this assertion
|
|
||||||
// fails because `handleTaskSubmit` early-returns at the
|
|
||||||
// `currentChatRef.current !== sentForChatId` guard.
|
|
||||||
await expect(
|
|
||||||
page.getByText('Got it — based on your answer, here is what to check next.'),
|
|
||||||
).toBeVisible({ timeout: 15_000 })
|
|
||||||
|
|
||||||
// Both chat calls must have actually happened.
|
|
||||||
expect(chatCallCount).toBe(2)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -34,11 +34,7 @@ test.describe('session history smoke tests', () => {
|
|||||||
await page.getByPlaceholder('Search by ticket number...').fill(ticketNumber)
|
await page.getByPlaceholder('Search by ticket number...').fill(ticketNumber)
|
||||||
await page.getByPlaceholder('Search by client name...').fill(clientName)
|
await page.getByPlaceholder('Search by client name...').fill(clientName)
|
||||||
|
|
||||||
const sessionCard = page
|
const sessionCard = page.locator('.bg-card').filter({ hasText: ticketNumber }).filter({ hasText: clientName }).first()
|
||||||
.getByTestId('flow-session-card')
|
|
||||||
.filter({ hasText: ticketNumber })
|
|
||||||
.filter({ hasText: clientName })
|
|
||||||
.first()
|
|
||||||
await expect(sessionCard).toBeVisible()
|
await expect(sessionCard).toBeVisible()
|
||||||
await expect(sessionCard.getByText(tree.name)).toBeVisible()
|
await expect(sessionCard.getByText(tree.name)).toBeVisible()
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ test.describe('flow library start-session smoke tests', () => {
|
|||||||
await page.getByPlaceholder('Search flows...').fill(tree.name)
|
await page.getByPlaceholder('Search flows...').fill(tree.name)
|
||||||
await page.getByRole('button', { name: 'Search', exact: true }).click()
|
await page.getByRole('button', { name: 'Search', exact: true }).click()
|
||||||
|
|
||||||
const treeCard = page.getByTestId('tree-card').filter({ hasText: tree.name }).first()
|
const treeCard = page.locator('.bg-card').filter({ hasText: tree.name }).first()
|
||||||
await expect(treeCard).toBeVisible()
|
await expect(treeCard).toBeVisible()
|
||||||
await treeCard.getByRole('button', { name: /^Start(?: Session)?$/ }).click()
|
await treeCard.getByRole('button', { name: /^Start(?: Session)?$/ }).click()
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ test.describe('flow library smoke tests', () => {
|
|||||||
await page.getByPlaceholder('Search flows...').fill(tree.name)
|
await page.getByPlaceholder('Search flows...').fill(tree.name)
|
||||||
await page.getByRole('button', { name: 'Search', exact: true }).click()
|
await page.getByRole('button', { name: 'Search', exact: true }).click()
|
||||||
|
|
||||||
await expect(page.getByTestId('tree-card').filter({ hasText: tree.name }).first()).toBeVisible()
|
await expect(page.getByText(tree.name)).toBeVisible()
|
||||||
} finally {
|
} finally {
|
||||||
await disposeApiContext(api)
|
await disposeApiContext(api)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test.describe('session resume smoke tests', () => {
|
|||||||
await page.getByRole('button', { name: 'Flow Sessions' }).click()
|
await page.getByRole('button', { name: 'Flow Sessions' }).click()
|
||||||
// Active sub-tab is the default and surfaces in-progress sessions.
|
// Active sub-tab is the default and surfaces in-progress sessions.
|
||||||
|
|
||||||
const resumeCard = page.getByTestId('flow-session-card').filter({ hasText: tree.name }).first()
|
const resumeCard = page.locator('.bg-card').filter({ hasText: tree.name }).first()
|
||||||
await expect(resumeCard).toBeVisible()
|
await expect(resumeCard).toBeVisible()
|
||||||
await resumeCard.getByRole('button', { name: 'Resume' }).first().click()
|
await resumeCard.getByRole('button', { name: 'Resume' }).first().click()
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ test.describe('shared session management smoke tests', () => {
|
|||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
await expect(page.getByText(share.share_name || '')).toBeVisible()
|
await expect(page.getByText(share.share_name || '')).toBeVisible()
|
||||||
|
|
||||||
const shareCard = page.getByTestId('share-card').filter({ hasText: share.share_name || '' }).first()
|
const shareCard = page.locator('.bg-card').filter({ hasText: share.share_name || '' }).first()
|
||||||
await shareCard.getByRole('button', { name: 'Revoke' }).click()
|
await shareCard.getByRole('button', { name: 'Revoke' }).click()
|
||||||
|
|
||||||
const confirmDialog = page.getByRole('dialog', { name: 'Revoke Share Link' })
|
const confirmDialog = page.getByRole('dialog', { name: 'Revoke Share Link' })
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ export function TreeGridView({
|
|||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<div
|
<div
|
||||||
key={tree.id}
|
key={tree.id}
|
||||||
data-testid="tree-card"
|
|
||||||
data-tree-id={tree.id}
|
|
||||||
className="relative bg-card border border-border rounded-2xl p-4 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
className="relative bg-card border border-border rounded-2xl p-4 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-md sm:p-6"
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export function TreeListView({
|
|||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<div
|
<div
|
||||||
key={tree.id}
|
key={tree.id}
|
||||||
data-testid="tree-card"
|
|
||||||
data-tree-id={tree.id}
|
|
||||||
className="flex items-center gap-4 bg-card border border-border rounded-2xl p-4 transition-all hover:border-primary/30 hover:shadow-xs"
|
className="flex items-center gap-4 bg-card border border-border rounded-2xl p-4 transition-all hover:border-primary/30 hover:shadow-xs"
|
||||||
>
|
>
|
||||||
{/* Left: Name and Description */}
|
{/* Left: Name and Description */}
|
||||||
|
|||||||
@@ -255,12 +255,6 @@ export default function AssistantChatPage() {
|
|||||||
}
|
}
|
||||||
setChats(prev => [chatItem, ...prev])
|
setChats(prev => [chatItem, ...prev])
|
||||||
setActiveChatId(session.session_id)
|
setActiveChatId(session.session_id)
|
||||||
// Keep the in-flight guard ref in sync. Without this, currentChatRef
|
|
||||||
// stays at its mount-time value (often a stale id from sessionStorage
|
|
||||||
// or null), so subsequent handleSend / handleTaskSubmit calls bail at
|
|
||||||
// their `currentChatRef.current !== sentForChatId` check and the AI
|
|
||||||
// response is silently dropped.
|
|
||||||
currentChatRef.current = session.session_id
|
|
||||||
setMessages([{ role: 'user', content: prefill }])
|
setMessages([{ role: 'user', content: prefill }])
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
|
|||||||
@@ -161,12 +161,7 @@ export default function MySharesPage() {
|
|||||||
const isCopied = copiedId === share.id
|
const isCopied = copiedId === share.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={share.id} className="bg-card border border-border rounded-xl p-5">
|
||||||
key={share.id}
|
|
||||||
data-testid="share-card"
|
|
||||||
data-share-id={share.id}
|
|
||||||
className="bg-card border border-border rounded-xl p-5"
|
|
||||||
>
|
|
||||||
{/* Top row: badge + name */}
|
{/* Top row: badge + name */}
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs rounded-full px-2 py-0.5 bg-accent text-muted-foreground">
|
<span className="inline-flex items-center gap-1.5 text-xs rounded-full px-2 py-0.5 bg-accent text-muted-foreground">
|
||||||
|
|||||||
@@ -533,11 +533,7 @@ export default function SessionHistoryPage() {
|
|||||||
)}
|
)}
|
||||||
style={{ '--stagger-index': i } as React.CSSProperties}
|
style={{ '--stagger-index': i } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<div
|
<div className="bg-card border border-border rounded-xl p-4 transition-all hover:border-[var(--color-border-hover)]">
|
||||||
data-testid="flow-session-card"
|
|
||||||
data-session-id={session.id}
|
|
||||||
className="bg-card border border-border rounded-xl p-4 transition-all hover:border-[var(--color-border-hover)]"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user