Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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%) |
4 changes: 4 additions & 0 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/views/Activity.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 54 additions & 1 deletion frontend/tests/unit/activity-sessions-scope-params.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { setAvailableFeatures } from '@/composables/useScopeQuery'
// /sessions even once the feature was listed.

let getSessionsMock: ReturnType<typeof vi.fn>
let getActivitiesMock: ReturnType<typeof vi.fn>
let sessionsFixture: Array<Record<string, unknown>> = []

vi.mock('@/services/api', () => {
const ok = (data: unknown) => Promise.resolve({ success: true, data })
Expand All @@ -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'),
},
}
Expand All @@ -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<typeof vi.fn>
getActivitiesMock = api.getActivities as unknown as ReturnType<typeof vi.fn>
const Activity = (await import('@/views/Activity.vue')).default
const router = createRouter({
history: createWebHistory(),
Expand All @@ -51,6 +54,7 @@ describe('Activity Sessions view — scope params to GET /sessions (Spec 108 FR-
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
sessionsFixture = []
})

afterEach(() => {
Expand Down Expand Up @@ -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()
})
})
40 changes: 40 additions & 0 deletions frontend/tests/unit/activity-views-and-writeback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof mountActivityAt>>['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<typeof vi.fn>).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()
})
})
2 changes: 1 addition & 1 deletion specs/109-ux-navigation-consistency/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<any other id>` → `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=<server:tool>`, and the Activity page it opens issues `GET /activity?…&server=<server>&tool=<bare 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)
Expand Down
Loading