From 3429dce461a16fbf9a6380c359044b427b5e49be Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 28 Sep 2026 17:40:32 +0300 Subject: [PATCH 1/2] fix(ui): export Activity rows with session filter (Spec 109-k) --- frontend/src/services/api.ts | 4 ++ frontend/src/views/Activity.vue | 3 ++ .../unit/activity-views-and-writeback.spec.ts | 40 +++++++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 47d727d8d..044ab16ea 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1054,6 +1054,10 @@ class APIService { server?: string tool?: string status?: string + /** Raw MCP transport session id (see useScopeQuery's sessionRestParam). */ + session_id?: string + /** Work session id (`ws-` prefix; see useScopeQuery's sessionRestParam). */ + work_session_id?: string /** Export only the sub-calls of one code_execution run. */ parent_id?: string start_time?: string diff --git a/frontend/src/views/Activity.vue b/frontend/src/views/Activity.vue index 898a09908..58db49a8c 100644 --- a/frontend/src/views/Activity.vue +++ b/frontend/src/views/Activity.vue @@ -2538,6 +2538,9 @@ const exportActivities = (format: 'json' | 'csv') => { type: effectiveTypes.value.length > 0 ? effectiveTypes.value.join(',') : undefined, server: filterServer.value || undefined, tool: filterTool.value || undefined, + // Same session narrowing as loadActivities() (sessionRestParam), so a + // session-filtered table exports only that session's rows. + ...(filterSession.value ? { [sessionRestParam(filterSession.value)]: filterSession.value } : {}), // "Other / internal" is a client-side residual, not a stored status: the // export endpoint matches `status` exactly against the closed vocabulary, // so passing it would hand back an empty file. Export unfiltered by status diff --git a/frontend/tests/unit/activity-views-and-writeback.spec.ts b/frontend/tests/unit/activity-views-and-writeback.spec.ts index 63c26e2d2..35d778ae5 100644 --- a/frontend/tests/unit/activity-views-and-writeback.spec.ts +++ b/frontend/tests/unit/activity-views-and-writeback.spec.ts @@ -362,3 +362,43 @@ describe('Activity sends the session filter to REST (url-filter-contract.md "ses expect(lastCall.work_session_id).toBeUndefined() }) }) + +// Export must carry the same session narrowing as the table: a +// session-filtered view that exports every session's rows hands the user a +// wider file than what they were looking at. +describe('Activity export carries the session filter (url-filter-contract.md "session" row)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.spyOn(window, 'open').mockImplementation(() => null) + }) + + async function exportJSON(wrapper: Awaited>['wrapper']) { + const link = wrapper.findAll('a').find(a => a.text() === 'Export as JSON') + expect(link, 'Export as JSON menu item').toBeTruthy() + await link!.trigger('click') + const api = (await import('@/services/api')).default + return (api.getActivityExportUrl as ReturnType).mock.calls.at(-1)![0] + } + + it('a work session id (ws- prefix) is exported as work_session_id', async () => { + const { wrapper } = await mountActivityAt('/activity?view=calls&session=ws-aaaaa') + const params = await exportJSON(wrapper) + expect(params.work_session_id).toBe('ws-aaaaa') + expect(params.session_id).toBeUndefined() + }) + + it('a raw transport session id (no ws- prefix) is exported as session_id', async () => { + const { wrapper } = await mountActivityAt('/activity?view=calls&session=raw-transport-123') + const params = await exportJSON(wrapper) + expect(params.session_id).toBe('raw-transport-123') + expect(params.work_session_id).toBeUndefined() + }) + + it('no session filter sends neither session param', async () => { + const { wrapper } = await mountActivityAt('/activity?view=calls') + const params = await exportJSON(wrapper) + expect(params.session_id).toBeUndefined() + expect(params.work_session_id).toBeUndefined() + }) +}) From f86b52500d467f448b48bfc7d3f19b5587b535b3 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 28 Sep 2026 18:03:16 +0300 Subject: [PATCH 2/2] test(ui): cover Activity session links and exports --- ROADMAP.md | 2 +- .../activity-sessions-scope-params.spec.ts | 55 ++++++++++++++++++- specs/109-ux-navigation-consistency/tasks.md | 2 +- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ddf9d1930..28ba8b4a2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1036,4 +1036,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) | | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) | | [108-profiles-v3](./specs/108-profiles-v3/) | `in-flight` | 23/153 (15%) | -| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `in-flight` | 3/180 (2%) | +| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `in-flight` | 4/180 (2%) | diff --git a/frontend/tests/unit/activity-sessions-scope-params.spec.ts b/frontend/tests/unit/activity-sessions-scope-params.spec.ts index 888ba0f95..238552c46 100644 --- a/frontend/tests/unit/activity-sessions-scope-params.spec.ts +++ b/frontend/tests/unit/activity-sessions-scope-params.spec.ts @@ -13,6 +13,8 @@ import { setAvailableFeatures } from '@/composables/useScopeQuery' // /sessions even once the feature was listed. let getSessionsMock: ReturnType +let getActivitiesMock: ReturnType +let sessionsFixture: Array> = [] vi.mock('@/services/api', () => { const ok = (data: unknown) => Promise.resolve({ success: true, data }) @@ -22,7 +24,7 @@ vi.mock('@/services/api', () => { getActivitySummary: vi.fn(() => ok({ period: '24h', total_count: 0, success_count: 0, error_count: 0, blocked_count: 0, rejected_count: 0 }) ), - getSessions: vi.fn(() => ok({ sessions: [] })), + getSessions: vi.fn(() => ok({ sessions: sessionsFixture })), getActivityExportUrl: vi.fn(() => 'http://localhost/api/v1/activity/export?format=json'), }, } @@ -31,6 +33,7 @@ vi.mock('@/services/api', () => { async function mountActivityAt(path: string) { const api = (await import('@/services/api')).default getSessionsMock = api.getSessions as unknown as ReturnType + getActivitiesMock = api.getActivities as unknown as ReturnType const Activity = (await import('@/views/Activity.vue')).default const router = createRouter({ history: createWebHistory(), @@ -51,6 +54,7 @@ describe('Activity Sessions view — scope params to GET /sessions (Spec 108 FR- beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() + sessionsFixture = [] }) afterEach(() => { @@ -84,4 +88,53 @@ describe('Activity Sessions view — scope params to GET /sessions (Spec 108 FR- } wrapper.unmount() }) + + it('a Sessions row links and filters by its work-session id', async () => { + sessionsFixture = [{ + id: 'transport-S1', + work_session_id: 'ws-W1', + client_name: 'Test client', + status: 'active', + tool_call_count: 1, + total_tokens: 10, + start_time: '2026-09-28T10:00:00Z', + last_activity: '2026-09-28T10:01:00Z', + }] + const { wrapper, router } = await mountActivityAt('/activity?view=sessions') + + const link = wrapper.find('[data-test="session-view-activity"]') + expect(link.attributes('href')).toBe('/activity?view=calls&session=ws-W1') + await link.trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.query).toMatchObject({ view: 'calls', session: 'ws-W1' }) + expect(getActivitiesMock).toHaveBeenCalledWith(expect.objectContaining({ work_session_id: 'ws-W1' })) + expect(getActivitiesMock.mock.calls.at(-1)?.[0].session_id).toBeUndefined() + wrapper.unmount() + }) + + it('a legacy Sessions row falls back to its raw transport id', async () => { + sessionsFixture = [{ + id: 'legacy-S1', + client_name: 'Legacy client', + status: 'closed', + tool_call_count: 1, + total_tokens: 10, + start_time: '2026-09-28T10:00:00Z', + last_activity: '2026-09-28T10:01:00Z', + }] + const { wrapper, router } = await mountActivityAt('/activity?view=sessions') + + const link = wrapper.find('[data-test="session-view-activity"]') + expect(link.attributes('href')).toBe('/activity?view=calls&session=legacy-S1') + await link.trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.query).toMatchObject({ view: 'calls', session: 'legacy-S1' }) + expect(getActivitiesMock).toHaveBeenCalledWith(expect.objectContaining({ session_id: 'legacy-S1' })) + expect(getActivitiesMock.mock.calls.at(-1)?.[0].work_session_id).toBeUndefined() + wrapper.unmount() + }) }) diff --git a/specs/109-ux-navigation-consistency/tasks.md b/specs/109-ux-navigation-consistency/tasks.md index 92f1db4cb..92c4bcb2d 100644 --- a/specs/109-ux-navigation-consistency/tasks.md +++ b/specs/109-ux-navigation-consistency/tasks.md @@ -249,7 +249,7 @@ description: "Task list for Spec 109 — Navigation, scope filters and cross-sur ### Failing tests - [ ] T111 [P] [US6] vitest `scope-query.spec.ts`: registry, read-before-first-fetch, `router.replace`, sticky `from/to` via `linkTo`, unknown params preserved, REST mapping (`session`→`work_session_id`, `from/to`→`start_time/end_time` on Activity, relative time resolution; on **Usage** `from=-24h`→`window=24h`, `from=-7d`→`window=7d`, none→`window=all`, any other range → not sent and rendered as a disabled "not applied on Usage" chip; `status` on Tools and Servers never appears in `toRest()`; `server` on Tools and Review never appears in `toRest()`; `tool=github:create_issue` → `toRest()` = `{server: "github", tool: "create_issue"}` on Activity and Usage, an agreeing URL `server` (`server=github&tool=github:create_issue`) sent once; a disagreeing one (`server=notion&tool=github:create_issue`) makes `toRest()` return `null`: no request in the recorded network log, the conflict empty state, both chips marked conflicting, and removing either chip issues exactly one request for the remaining server (contract rule 8), a value without `:` sent as the bare `tool`; `session=ws-abc` → `work_session_id`, `session=` → `session_id`), `?risk=read` → `tier` alias on the Tools page (moved here from T016); **`profile`, `client`, `token`** (merged here from Spec 108's former 108-j, the owner rule): registered and sticky, but with a status stub lacking `features.scope_filters` they render no control or chip, are not sent to REST and stay untouched in the URL; with the stub listing them they behave like any sticky parameter; the link-map rows that need them (client row, profile card, token row, blocked row, view-as "Why?") are hidden then shown the same way -- [ ] T112 [P] [US6] vitest `activity-views.spec.ts` (default `calls`; `view` in the URL; `/sessions?x` → `/activity?view=sessions&x`; the url-filter-contract `view` → REST table: `view=sessions` issues `GET /sessions` and no `/activity` request, with `from`/`server` shown as disabled chips; `status=rejected` reaches REST and `status=other` stays client-side; a Sessions-view row whose `id` is `S1` and `work_session_id` is `ws-W1` links `/activity?view=calls&session=ws-W1`, which issues `work_session_id=ws-W1`, and a legacy row without a `work_session_id` links its `id`, which issues `session_id`), `activity-system-folding.spec.ts` (fold rule, expand, export unfolded), `activity-empty-columns.spec.ts`, `activity-block-reason.spec.ts` (from metadata; falls back cleanly when absent) +- [x] T112 [P] [US6] vitest `activity-views.spec.ts` (default `calls`; `view` in the URL; `/sessions?x` → `/activity?view=sessions&x`; the url-filter-contract `view` → REST table: `view=sessions` issues `GET /sessions` and no `/activity` request, with `from`/`server` shown as disabled chips; `status=rejected` reaches REST and `status=other` stays client-side; a Sessions-view row whose `id` is `S1` and `work_session_id` is `ws-W1` links `/activity?view=calls&session=ws-W1`, which issues `work_session_id=ws-W1`, and a legacy row without a `work_session_id` links its `id`, which issues `session_id`), `activity-sessions-scope-params.spec.ts` (work-session and legacy row links preserve the expected mapping), and `activity-views-and-writeback.spec.ts` (filtered JSON export sends the same session parameter as the table), `activity-system-folding.spec.ts` (fold rule, expand, export unfolded), `activity-empty-columns.spec.ts`, `activity-block-reason.spec.ts` (from metadata; falls back cleanly when absent) - [ ] T113 [P] [US6] vitest `tools-scope.spec.ts` (incl. the row "Calls" link → `/activity?view=calls&tool=`, and the Activity page it opens issues `GET /activity?…&server=&tool=` — with a seeded record for `github:create_issue`, the page shows it (the codex round 4 regression: `tool=github:create_issue` sent verbatim matched nothing); `?server=notes` filters rows client-side and the recorded `GET /tools` request carries no `server`; `?status=disabled` filters rows client-side and the recorded `GET /tools` request carries no `status`), `usage-scope.spec.ts` (chart bar → Activity link with the bucket window; `?from=-7d` issues `GET /activity/usage?window=7d`, `?from=-3d` issues `window=all` with a disabled "not applied on Usage" chip, never a request the backend would ignore), `servers-scope.spec.ts` (`status` client-side — the `GET /servers` request carries no `status` — and `q`) - [ ] T114 [P] [US6] `internal/httpapi/token_metrics_estimate_test.go`: `estimated:true` + a non-zero estimate before any retrieve; `false` after real calls - [ ] T115 [P] [US6] `cmd/mcpproxy/activity_view_test.go`: `--view calls|system|all` (default all) on list/watch; `--from -1h`/`--to` on **list, watch, summary and export** (FR-075): aliases of `--start-time/--end-time` on list/export; on watch a streamed record outside the range is not printed and the command exits once `--to` has passed (fake clock); on summary `--from -24h` sends `period=24h` and `--from -3d` exits 1 with the FR-075 message; `--tool github:create_issue` sends `server=github&tool=create_issue` (the URL contract's split rule); `--server github --tool github:create_issue` sends `server=github` once; `--server notion --tool github:create_issue` exits 1 with `--server notion conflicts with the server in --tool github:create_issue` and the recorded transport sees no request (contract rule 8)