From 677244cfbff75f89141201255da8efe24f27a1ce Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Fri, 12 Jun 2026 21:18:32 +0000 Subject: [PATCH 01/37] feat(coc): read-only native Copilot CLI sessions dashboard view Add a disabled-by-default features.nativeCopilotSessions flag that exposes a workspace-scoped, read-only "Copilot Sessions" repo tab over the server user's native GitHub Copilot CLI session store (~/.copilot/session-store.db). Server: - New native-copilot-sessions query service: short-lived read-only SQLite connections, workspace scoping by native cwd (normalized prefix) or repository (origin-remote owner/repo, case-insensitive), parameterized metadata filters, literal-quoted FTS text search with snippets, newest updated_at sorting, pagination, turn counts, and typed db-missing/db-invalid unavailable states that never crash the dashboard. - GET /api/workspaces/:id/native-copilot-sessions (+ /:sessionId detail with ordered turns, char counts, and search-index diagnostics), live feature-guarded; disabled flag returns 200 {enabled:false}. Client/SPA: - coc-client nativeCopilotSessions domain with typed contracts. - Copilot Sessions tab gated by the runtime flag; two-pane read-only panel with search/sessionId/branch/date filters, disabled/unavailable/loading/ empty/error states, "Native Copilot CLI session" labels, read-only badge with helper copy, "No assistant response stored" labeling, plain pre-wrapped text rendering (stored HTML/scripts never execute), and no CoC chat actions. Tests: - Server route + service tests using synthetic temp SQLite fixtures only: disabled flag, missing/invalid DB, two-repo workspace filtering, sorting, pagination, turn counts, FTS hits/snippets, combined filters, no-result state, hostile filter input parameterization, detail ordering/diagnostics, and workspace-scoped 404s. - SPA panel tests for all UX states, read-only labeling, inert script text, filter wiring, and absence of CoC chat action controls. Docs: coc-knowledge references (rest-api, server-architecture, dashboard-spa) and packages/coc/AGENTS.md read-only invariant. Co-Authored-By: Claude Opus 4.8 --- .../coc-knowledge/references/dashboard-spa.md | 5 + .../coc-knowledge/references/rest-api.md | 9 + .../references/server-architecture.md | 2 + packages/coc-client/src/client.ts | 4 +- packages/coc-client/src/contracts/admin.ts | 3 + packages/coc-client/src/contracts/index.ts | 1 + .../src/contracts/native-copilot-sessions.ts | 83 +++ packages/coc-client/src/domains/index.ts | 1 + .../src/domains/native-copilot-sessions.ts | 46 ++ packages/coc/AGENTS.md | 6 + packages/coc/src/config.ts | 5 + .../src/config/admin-setting-definitions.ts | 8 + packages/coc/src/server/index.ts | 1 + .../native-copilot-session-service.ts | 410 +++++++++++++ .../server/native-copilot-sessions/types.ts | 96 +++ packages/coc/src/server/routes/index.ts | 17 + .../routes/native-copilot-session-routes.ts | 150 +++++ .../NativeCopilotSessionsPanel.tsx | 447 ++++++++++++++ .../react/features/repo-detail/RepoDetail.tsx | 27 +- .../useNativeCopilotSessionsEnabled.ts | 13 + .../spa/client/react/types/dashboard.ts | 2 +- .../server/spa/client/react/utils/config.ts | 6 + packages/coc/src/server/types.ts | 2 + .../server/native-copilot-sessions.test.ts | 556 ++++++++++++++++++ .../NativeCopilotSessionsPanel.test.tsx | 234 ++++++++ .../repos/RepoDetail-layout-mode.test.tsx | 2 + 26 files changed, 2130 insertions(+), 6 deletions(-) create mode 100644 packages/coc-client/src/contracts/native-copilot-sessions.ts create mode 100644 packages/coc-client/src/domains/native-copilot-sessions.ts create mode 100644 packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts create mode 100644 packages/coc/src/server/native-copilot-sessions/types.ts create mode 100644 packages/coc/src/server/routes/native-copilot-session-routes.ts create mode 100644 packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx create mode 100644 packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCopilotSessionsEnabled.ts create mode 100644 packages/coc/test/server/native-copilot-sessions.test.ts create mode 100644 packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index f7bdbdb93..926fb99b4 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -23,6 +23,7 @@ spa/client/react/ │ ├── chat/ # Chat UI: ChatDetail, ChatListPane, ConversationArea │ ├── dreams/ # Workspace Dreams review panel with feature/opt-in states, queue-backed run-now task summary, provider-attributed Activity/Admin AI Provider visibility, filters, plain-language card guidance, source evidence links, and card lifecycle actions │ ├── memory/ # Memory V2 route, facts/review/episodes tabs, repo memory settings section +│ ├── native-copilot-sessions/ # Read-only Copilot Sessions tab over the native CLI session store (see Copilot Sessions Tab) │ ├── notes/ # Notes UI: NoteEditor, Mermaid zoom/pan, sidebar, multi-root dropdown with modifier/range root selection and bulk root removal (useNotesRoots) │ ├── pull-requests/ # PR dashboard: attention groups, provider-derived PR helpers, shared provider-id/displayName Team author matching, Team auto-classification triggers, real diff-stat queue badges/risk, deterministic review summary, BatchCommandPanel │ └── terminal/ # Terminal UI: TerminalView, pin/unpin @@ -566,6 +567,10 @@ Ralph activity deep-links mount `RalphWorkflowPane`, which shows a unified task The repo-scoped Dreams tab (`features/dreams/DreamsPanel.tsx`) is a dedicated review surface separate from Work Items. It is included in repo tab strips only when the global `dreams.enabled` feature flag is on, then requires the workspace `preferences.dreams.enabled` opt-in before calling Dreams routes. Once enabled, it lists visible cards by default, supports status filters for hidden lifecycle history, exposes a manual **Run dream now** action, shows run summaries/no-new-dreams states, links source process turn ranges back to the Activity conversation route, and offers card lifecycle actions: approve, dismiss, record conversion, and supersede. Approved cards also expose an explicit **Take next action** dialog: skill/prompt cards can queue an Ask-mode skill-hardening task, user-workflow cards can save to Notes or Memory V2, and product cards can create a new Work Item or append the recommendation to an existing Work Item. Each next action runs only after the dialog submit and then records the resulting artifact as a dream conversion. +## Copilot Sessions Tab + +The repo-scoped `Copilot Sessions` tab (`features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx`) is a read-only view of native GitHub Copilot CLI sessions for the active workspace, gated by `features.nativeCopilotSessions` / `nativeCopilotSessionsEnabled` (disabled by default; `useNativeCopilotSessionsEnabled()` tracks live runtime-config updates). It reads through `coc-client`'s `nativeCopilotSessions` domain, renders a two-pane layout on wide screens (searchable session table left, selected-session detail right) and stacked single-pane navigation on narrow screens, and supports text query (native `search_index` snippets), session-ID, branch, and date-range filters sorted newest `updated_at` first with pagination. Every list row and the detail header carry a `Native Copilot CLI session` label plus a read-only badge whose tooltip/helper explains the data comes from the local native store (`~/.copilot/session-store.db`) and cannot be modified from CoC. The panel renders distinct disabled/unavailable (`db-missing`/`db-invalid`)/loading/empty/error states, shows turns ordered by index with `No assistant response stored` for empty assistant turns and `Indexed (N chars)`/`Not indexed` search-index diagnostics, renders all stored text as plain pre-wrapped text (stored HTML/scripts never execute), and intentionally exposes no CoC chat actions (no follow-up, archive, pin, delete, resume, retry, or turn actions). + ## Memory Route The top-level `#memory` route is embedded in the Admin shell's Knowledge group and renders `MemoryV2Panel` in the right pane. The panel root owns the stable `#view-memory` id. `MemorySubTab` values are `facts`, `review`, `episodes`, and `settings`; hash links such as `#memory/review` and `#memory/settings` select the matching V2 tab. The legacy memory-config panel is not rendered on the Memory route (the tool-call/explore cache has been removed). Repo settings still use `RepoMemorySection` for repo-scoped bounded memory and raw memory inspection. diff --git a/.github/skills/coc-knowledge/references/rest-api.md b/.github/skills/coc-knowledge/references/rest-api.md index 1a9b6943a..0cbe85a07 100644 --- a/.github/skills/coc-knowledge/references/rest-api.md +++ b/.github/skills/coc-knowledge/references/rest-api.md @@ -175,6 +175,15 @@ All Map Reduce routes are workspace-scoped and gated by `mapReduce.enabled` (def | POST | `/api/workspaces/:id/map-reduce-runs/:runId/reduce/retry` | Retry a failed reduce step as a new child chat | | POST | `/api/workspaces/:id/map-reduce-runs/:runId/cancel` | Cancel remaining work, mark pending/running map items skipped, cancel a pending/running/failed reduce step, and cancel active child tasks when available | +## Native Copilot Sessions + +Read-only, workspace-scoped views over the server user's native GitHub Copilot CLI session store (`~/.copilot/session-store.db`). Gated by the disabled-by-default `features.nativeCopilotSessions` flag with a live guard. CoC opens the native SQLite store read-only with short-lived per-request connections, never writes to it, and never imports native sessions into CoC process history. Disabled and unavailable states return HTTP 200 with typed payloads: `{ enabled: false, reason: 'feature-disabled' }` when the flag is off, and `{ enabled: true, available: false, reason: 'db-missing' | 'db-invalid' }` when the store is absent or unreadable. Workspace scoping matches native `sessions.cwd` against the registered workspace root (equal or descendant path) or native `sessions.repository` against the workspace's origin-remote `owner/repo` (case-insensitive). `@plusplusoneplusplus/coc-client` exposes these routes through `client.nativeCopilotSessions`. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/workspaces/:id/native-copilot-sessions` | List workspace-matching native sessions sorted by newest `updated_at`. Query: `q` (text search via the native `search_index` FTS table with match snippets; parameterized literal-quoted terms), `sessionId` (exact or partial), `branch`, `from`/`to` ISO bounds on updated time, `limit` (default 50, max 200), `offset`. Response includes `items` with summary preview and turn counts, `total`, `searchIndexAvailable` (false when the native FTS table is absent — text queries then return no hits non-fatally) | +| GET | `/api/workspaces/:id/native-copilot-sessions/:sessionId` | Read one workspace-matching native session: metadata, full stored summary, and turns ordered by `turn_index` with per-turn char counts and search-index diagnostics (`searchIndexSourceId`/`searchIndexChars`, null when not indexed). Sessions outside the workspace or unknown IDs return 404 | + ## Dreams All Dreams routes are workspace-scoped and gated by `dreams.enabled` (default `false`). Dream generation also requires the target workspace's `preferences.dreams.enabled` opt-in. Cards are review records only: approval records user intent, conversion records an explicit resulting artifact link, and no route mutates skills, prompts, notes, memory, work items, or code directly. diff --git a/.github/skills/coc-knowledge/references/server-architecture.md b/.github/skills/coc-knowledge/references/server-architecture.md index 30f5197c2..c5f39fbf9 100644 --- a/.github/skills/coc-knowledge/references/server-architecture.md +++ b/.github/skills/coc-knowledge/references/server-architecture.md @@ -103,6 +103,7 @@ The `src/server/` tree is grouped by feature domain. Cross-cutting plumbing stay | `ralph/` | Iterative execution sessions and file-backed journal (see [ralph.md](ralph.md)) | | `for-each/` | Dedicated For Each run records, item-plan validation, file-backed repo-scoped draft/approval storage, and sequential child-chat orchestration | | `map-reduce/` | Dedicated Map Reduce plan generation, run records, map-plan validation, reduce-step state, per-run parallelism configuration, file-backed repo-scoped draft/approval/execution storage with parallel map claiming, and child-chat orchestration that auto-chains reduce after successful map completion | +| `native-copilot-sessions/` | Read-only query service over the server user's native GitHub Copilot CLI session store (`~/.copilot/session-store.db`): short-lived read-only SQLite connections, workspace scoping by native `cwd`/`repository`, parameterized FTS text search, typed `db-missing`/`db-invalid` unavailable states. Gated by `features.nativeCopilotSessions` (default `false`); CoC never writes to the native store | | `models/` | Model registry endpoints | | `agent-providers/` | Agent-provider quota cache, provider status routes, SDK install helpers, and the pure Auto provider router that evaluates configured priority, availability, normal quota thresholds, weekly guards, fallback, and selection warnings before callers expand effort tiers. Queue/fresh-terminal defaults, explicit SPA Auto requests (`context.autoProviderRouting.requested`), direct Ralph, For Each, and work-item enqueue surfaces use the shared quota cache and refresh it only when missing or stale. | | `messaging/` | Teams bot integration: manager, command router, per-user state | @@ -196,6 +197,7 @@ claude: features: autoAgentProviderRouting: false # enables Auto for omitted-provider default paths ralphMultiAgentGrill: false # gated multi-agent Ralph grilling setup and agent preflight + nativeCopilotSessions: false # read-only Copilot Sessions dashboard tab over ~/.copilot/session-store.db agentProviderRouting: auto: diff --git a/packages/coc-client/src/client.ts b/packages/coc-client/src/client.ts index 47152b467..d93df70b6 100644 --- a/packages/coc-client/src/client.ts +++ b/packages/coc-client/src/client.ts @@ -1,4 +1,4 @@ -import { AdminClient, AgentProvidersClient, CanvasesClient, DbBrowserClient, DreamsClient, ExplorerClient, ForEachClient, GitClient, HealthClient, LoopsClient, MapReduceClient, MemoryClient, MemoryV2Client, NotesClient, PreferencesClient, ProcessesClient, PromptHistoryClient, PullRequestsClient, QueueClient, SchedulesClient, SeenStateClient, ServersClient, SkillsClient, StatsClient, SuggestionsClient, SyncClient, TaskGroupsClient, TasksClient, TemplatesClient, WikiClient, WorkflowClient, WorkItemsClient, WorkspacesClient } from './domains'; +import { AdminClient, AgentProvidersClient, CanvasesClient, DbBrowserClient, DreamsClient, ExplorerClient, ForEachClient, GitClient, HealthClient, LoopsClient, MapReduceClient, MemoryClient, MemoryV2Client, NativeCopilotSessionsClient, NotesClient, PreferencesClient, ProcessesClient, PromptHistoryClient, PullRequestsClient, QueueClient, SchedulesClient, SeenStateClient, ServersClient, SkillsClient, StatsClient, SuggestionsClient, SyncClient, TaskGroupsClient, TasksClient, TemplatesClient, WikiClient, WorkflowClient, WorkItemsClient, WorkspacesClient } from './domains'; import { HttpTransport, normalizeOptions } from './http'; import { EventsClient } from './realtime'; import type { CocClientOptions, CocRequestOptions, NormalizedCocClientOptions } from './types'; @@ -16,6 +16,7 @@ export class CocClient { readonly health: HealthClient; readonly memory: MemoryClient; readonly memoryV2: MemoryV2Client; + readonly nativeCopilotSessions: NativeCopilotSessionsClient; readonly notes: NotesClient; readonly preferences: PreferencesClient; readonly processes: ProcessesClient; @@ -57,6 +58,7 @@ export class CocClient { this.health = new HealthClient(this.transport); this.memory = new MemoryClient(this.transport); this.memoryV2 = new MemoryV2Client(this.transport); + this.nativeCopilotSessions = new NativeCopilotSessionsClient(this.transport); this.notes = new NotesClient(this.transport); this.preferences = new PreferencesClient(this.transport); this.processes = new ProcessesClient(this.transport, this.options); diff --git a/packages/coc-client/src/contracts/admin.ts b/packages/coc-client/src/contracts/admin.ts index d3c389bcb..2045e6fe9 100644 --- a/packages/coc-client/src/contracts/admin.ts +++ b/packages/coc-client/src/contracts/admin.ts @@ -116,6 +116,7 @@ export interface AdminResolvedConfig { commitChatLensDormantMode?: 'ghost' | 'pill'; autoAgentProviderRouting?: boolean; ralphMultiAgentGrill?: boolean; + nativeCopilotSessions?: boolean; }; workItems?: { hierarchy?: { enabled?: boolean }; sync?: { enabled?: boolean }; aiAuthoring?: { enabled?: boolean }; workflow?: { enabled?: boolean } }; effortLevels?: { enabled?: boolean }; @@ -191,6 +192,7 @@ export interface AdminConfigUpdate { 'features.commitChatLens'?: boolean; 'features.commitChatLensDormantMode'?: 'ghost' | 'pill'; 'features.autoAgentProviderRouting'?: boolean; + 'features.nativeCopilotSessions'?: boolean; 'effortLevels.enabled'?: boolean; [key: string]: unknown; } @@ -239,6 +241,7 @@ export interface RuntimeDashboardConfig { commitChatLensEnabled: boolean; commitChatLensDormantMode: 'ghost' | 'pill'; effortLevelsEnabled: boolean; + nativeCopilotSessionsEnabled: boolean; }; hostname?: string; bindAddress?: string; diff --git a/packages/coc-client/src/contracts/index.ts b/packages/coc-client/src/contracts/index.ts index 4527e6084..71507dc0c 100644 --- a/packages/coc-client/src/contracts/index.ts +++ b/packages/coc-client/src/contracts/index.ts @@ -8,6 +8,7 @@ export * from './for-each'; export * from './git'; export * from './map-reduce'; export * from './memory'; +export * from './native-copilot-sessions'; export * from './notes'; export * from './preferences'; export * from './processes'; diff --git a/packages/coc-client/src/contracts/native-copilot-sessions.ts b/packages/coc-client/src/contracts/native-copilot-sessions.ts new file mode 100644 index 000000000..4a9a66c81 --- /dev/null +++ b/packages/coc-client/src/contracts/native-copilot-sessions.ts @@ -0,0 +1,83 @@ +/** + * Native GitHub Copilot CLI session contracts. + * + * Read-only, workspace-scoped views over the CoC server user's native + * Copilot CLI session store (`~/.copilot/session-store.db`). These sessions + * are external data: CoC never modifies them and never imports them into + * CoC process history. + */ + +/** Reason a native-session response carries no data. */ +export type NativeCopilotSessionsUnavailableReason = 'feature-disabled' | 'db-missing' | 'db-invalid'; + +export interface NativeCopilotSessionListItem { + id: string; + repository: string | null; + cwd: string | null; + hostType: string | null; + branch: string | null; + summaryPreview: string; + createdAt: string | null; + updatedAt: string | null; + turnCount: number; + matchSnippets: string[]; +} + +export interface NativeCopilotSessionTurn { + id: number; + turnIndex: number; + timestamp: string | null; + userMessage: string; + assistantResponse: string; + userChars: number; + assistantChars: number; + searchIndexSourceId: string | null; + searchIndexChars: number | null; +} + +export interface NativeCopilotSessionDetail { + id: string; + repository: string | null; + cwd: string | null; + hostType: string | null; + branch: string | null; + summary: string; + createdAt: string | null; + updatedAt: string | null; + turns: NativeCopilotSessionTurn[]; +} + +export interface ListNativeCopilotSessionsOptions { + /** Free-text query against natively indexed content. */ + q?: string; + /** Exact or partial session ID. */ + sessionId?: string; + /** Exact branch filter. */ + branch?: string; + /** ISO timestamp lower bound on updated time (inclusive). */ + from?: string; + /** ISO timestamp upper bound on updated time (inclusive). */ + to?: string; + limit?: number; + offset?: number; +} + +export interface ListNativeCopilotSessionsResponse { + enabled: boolean; + /** Present when `enabled` is true; false when the native DB is missing/invalid. */ + available?: boolean; + reason?: NativeCopilotSessionsUnavailableReason; + items: NativeCopilotSessionListItem[]; + total: number; + /** False when metadata tables exist but the native search index is absent. */ + searchIndexAvailable?: boolean; + limit: number; + offset: number; +} + +export interface NativeCopilotSessionDetailResponse { + enabled: boolean; + available?: boolean; + reason?: NativeCopilotSessionsUnavailableReason; + session?: NativeCopilotSessionDetail; +} diff --git a/packages/coc-client/src/domains/index.ts b/packages/coc-client/src/domains/index.ts index a1fa2d66e..10f4ff77d 100644 --- a/packages/coc-client/src/domains/index.ts +++ b/packages/coc-client/src/domains/index.ts @@ -9,6 +9,7 @@ export { ForEachClient } from './for-each'; export { GitClient } from './git'; export { HealthClient } from './health'; export { MemoryClient, MemoryV2Client } from './memory'; +export { NativeCopilotSessionsClient } from './native-copilot-sessions'; export { NotesClient } from './notes'; export { PreferencesClient } from './preferences'; export { ProcessesClient } from './processes'; diff --git a/packages/coc-client/src/domains/native-copilot-sessions.ts b/packages/coc-client/src/domains/native-copilot-sessions.ts new file mode 100644 index 000000000..955ad75b9 --- /dev/null +++ b/packages/coc-client/src/domains/native-copilot-sessions.ts @@ -0,0 +1,46 @@ +import type { + ListNativeCopilotSessionsOptions, + ListNativeCopilotSessionsResponse, + NativeCopilotSessionDetailResponse, +} from '../contracts'; +import type { RequestAdapter } from '../types'; +import { encodePathSegment } from '../url'; + +function sessionsPath(workspaceId: string, suffix = ''): string { + return `/workspaces/${encodePathSegment(workspaceId)}/native-copilot-sessions${suffix}`; +} + +function listQuery(options: ListNativeCopilotSessionsOptions | undefined): Record | undefined { + if (!options) return undefined; + return { + q: options.q, + sessionId: options.sessionId, + branch: options.branch, + from: options.from, + to: options.to, + limit: options.limit, + offset: options.offset, + }; +} + +/** + * Read-only client for native GitHub Copilot CLI sessions. The server exposes + * list and detail reads only; there are no mutation endpoints for this domain. + */ +export class NativeCopilotSessionsClient { + constructor(private readonly transport: RequestAdapter) {} + + list(workspaceId: string, options?: ListNativeCopilotSessionsOptions): Promise { + const query = listQuery(options); + return this.transport.request( + sessionsPath(workspaceId), + query ? { query } : undefined, + ); + } + + get(workspaceId: string, sessionId: string): Promise { + return this.transport.request( + sessionsPath(workspaceId, `/${encodePathSegment(sessionId)}`), + ); + } +} diff --git a/packages/coc/AGENTS.md b/packages/coc/AGENTS.md index fe812b8b4..21d41e680 100644 --- a/packages/coc/AGENTS.md +++ b/packages/coc/AGENTS.md @@ -123,6 +123,12 @@ all have their own `references/*.md`. per-trigger cap and low priority instead of adding client-side POST loops. The Team toolbar status UI should read batch status and route manual "Classify now" actions through the same bounded server helper. +- **Native Copilot session reads** (`src/server/native-copilot-sessions/`) + must stay strictly read-only against `~/.copilot/session-store.db`: open with + short-lived `readonly` SQLite connections, keep every user-provided filter + parameterized (FTS terms literal-quoted), and return typed + `db-missing`/`db-invalid` states instead of throwing. Never route native + session IDs into CoC process/chat action handlers. - **Work-item create/update side effects** (hierarchy `parentId` validation, GitHub/Azure Boards provider sync, response-cache invalidation, dashboard broadcasts, auto-execute) live in the shared command service diff --git a/packages/coc/src/config.ts b/packages/coc/src/config.ts index d6b2a2694..d5ea37109 100644 --- a/packages/coc/src/config.ts +++ b/packages/coc/src/config.ts @@ -249,6 +249,8 @@ export interface CLIConfig { autoAgentProviderRouting?: boolean; /** Multi-agent Ralph grilling experience. Disabled by default. */ ralphMultiAgentGrill?: boolean; + /** Read-only native GitHub Copilot CLI sessions tab. Disabled by default. */ + nativeCopilotSessions?: boolean; }; /** Memory promotion configuration */ memoryPromotion?: { @@ -532,6 +534,8 @@ export interface ResolvedCLIConfig { autoAgentProviderRouting: boolean; /** Multi-agent Ralph grilling experience. Disabled by default. */ ralphMultiAgentGrill: boolean; + /** Read-only native GitHub Copilot CLI sessions tab. Disabled by default. */ + nativeCopilotSessions: boolean; }; /** Memory promotion configuration */ memoryPromotion: { @@ -763,6 +767,7 @@ export const DEFAULT_CONFIG: ResolvedCLIConfig = { commitChatLensDormantMode: 'ghost', autoAgentProviderRouting: false, ralphMultiAgentGrill: false, + nativeCopilotSessions: false, }, memoryPromotion: { batchSize: 50, diff --git a/packages/coc/src/config/admin-setting-definitions.ts b/packages/coc/src/config/admin-setting-definitions.ts index cd2a6865f..e02081384 100644 --- a/packages/coc/src/config/admin-setting-definitions.ts +++ b/packages/coc/src/config/admin-setting-definitions.ts @@ -690,6 +690,14 @@ export const ADMIN_SETTING_DEFINITIONS: readonly AdminSettingDefinition[] = [ }, }, bool({ key: 'features.autoAgentProviderRouting', default: false, runtime: 'restartRequired', runtimeFlag: 'autoAgentProviderRoutingEnabled' }), + bool({ + key: 'features.nativeCopilotSessions', default: false, runtime: 'live', runtimeFlag: 'nativeCopilotSessionsEnabled', + ui: { + group: 'dashboard', order: 60, label: 'Native Copilot CLI sessions', badge: 'experimental', + hint: 'Read-only Copilot Sessions tab that lists native GitHub Copilot CLI sessions (~/.copilot/session-store.db) for the active workspace. Disabled by default.', + testId: 'toggle-native-copilot-sessions-enabled', + }, + }), bool({ key: 'features.ralphMultiAgentGrill', default: false, runtime: 'live', runtimeFlag: 'ralphMultiAgentGrillEnabled', ui: { diff --git a/packages/coc/src/server/index.ts b/packages/coc/src/server/index.ts index ead105d3b..d1b1aa7ef 100644 --- a/packages/coc/src/server/index.ts +++ b/packages/coc/src/server/index.ts @@ -532,6 +532,7 @@ export async function createExecutionServer(options: ExecutionServerOptions = {} hostname: os.hostname(), bindAddress: host, syncEngines, + nativeCopilotSessionDbPath: options.nativeCopilotSessionDbPath, }); // Restore auto-commit timers for all workspaces that had it enabled notesGitTimerManager.startAll(store, dataDir).catch(() => { /* best-effort */ }); diff --git a/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts b/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts new file mode 100644 index 000000000..044b04c6f --- /dev/null +++ b/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts @@ -0,0 +1,410 @@ +/** + * Read-only query service for the native GitHub Copilot CLI session store. + * + * The native store (`~/.copilot/session-store.db`) is external data owned by + * the Copilot CLI. This service opens it with short-lived read-only SQLite + * connections per request and never executes a write statement against it. + * Missing or invalid stores produce typed unavailable results instead of + * throwing, so the dashboard can render a non-fatal state. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import DatabaseConstructor from 'better-sqlite3'; +import type { Database } from 'better-sqlite3'; +import type { + NativeCopilotSessionDetail, + NativeCopilotSessionDetailResult, + NativeCopilotSessionListItem, + NativeCopilotSessionListOptions, + NativeCopilotSessionListResult, + NativeCopilotSessionTurn, + NativeSessionWorkspaceScope, +} from './types'; + +export const DEFAULT_NATIVE_SESSION_LIST_LIMIT = 50; +const MAX_NATIVE_SESSION_LIST_LIMIT = 200; +const MAX_MATCH_SNIPPETS = 3; +const SUMMARY_PREVIEW_MAX_CHARS = 200; + +/** Default native Copilot CLI session store location for the server user. */ +export function getDefaultNativeCopilotSessionDbPath(): string { + return path.join(os.homedir(), '.copilot', 'session-store.db'); +} + +interface SessionRow { + id: string; + cwd: string | null; + repository: string | null; + host_type: string | null; + branch: string | null; + summary: string | null; + created_at: string | null; + updated_at: string | null; +} + +interface TurnRow { + id: number; + turn_index: number; + user_message: string | null; + assistant_response: string | null; + timestamp: string | null; +} + +type DbOpenResult = + | { ok: true; db: Database } + | { ok: false; reason: 'db-missing' | 'db-invalid' }; + +/** Normalize a filesystem path for cross-platform equality/prefix matching. */ +function normalizePathForMatch(value: string): string { + let normalized = path.normalize(value.trim()).replace(/\\/g, '/'); + while (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +/** True when a native session belongs to the given CoC workspace. */ +export function sessionMatchesWorkspace( + row: { repository: string | null; cwd: string | null }, + scope: NativeSessionWorkspaceScope, +): boolean { + if (scope.repository && row.repository + && row.repository.trim().toLowerCase() === scope.repository.trim().toLowerCase()) { + return true; + } + if (scope.rootPath && row.cwd) { + const root = normalizePathForMatch(scope.rootPath); + const cwd = normalizePathForMatch(row.cwd); + if (cwd === root || cwd.startsWith(`${root}/`)) { + return true; + } + } + return false; +} + +/** + * Convert free text into a safe FTS5 MATCH expression: each whitespace-separated + * term becomes a quoted string literal so user input can never inject FTS syntax. + * Returns null when the query has no usable terms. + */ +export function buildFtsMatchExpression(query: string): string | null { + const terms = query.split(/\s+/).map(term => term.trim()).filter(Boolean); + if (terms.length === 0) { + return null; + } + return terms.map(term => `"${term.replace(/"/g, '""')}"`).join(' '); +} + +function summaryPreview(summary: string | null): string { + if (!summary) { + return ''; + } + const firstLine = summary.split('\n', 1)[0].trim(); + return firstLine.length > SUMMARY_PREVIEW_MAX_CHARS + ? `${firstLine.slice(0, SUMMARY_PREVIEW_MAX_CHARS)}…` + : firstLine; +} + +function parseTimestamp(value: string | null | undefined): number { + if (!value) { + return Number.NaN; + } + return Date.parse(value); +} + +function clampLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit)) { + return DEFAULT_NATIVE_SESSION_LIST_LIMIT; + } + return Math.min(Math.max(Math.floor(limit), 1), MAX_NATIVE_SESSION_LIST_LIMIT); +} + +function clampOffset(offset: number | undefined): number { + if (offset === undefined || !Number.isFinite(offset)) { + return 0; + } + return Math.max(Math.floor(offset), 0); +} + +export interface NativeCopilotSessionServiceOptions { + /** Override of the native session store path (tests use synthetic fixtures). */ + dbPath?: string; +} + +export class NativeCopilotSessionService { + private readonly dbPath: string; + + constructor(options: NativeCopilotSessionServiceOptions = {}) { + this.dbPath = options.dbPath ?? getDefaultNativeCopilotSessionDbPath(); + } + + /** List native sessions scoped to one workspace, newest `updated_at` first. */ + listSessions( + scope: NativeSessionWorkspaceScope, + options: NativeCopilotSessionListOptions = {}, + ): NativeCopilotSessionListResult & { limit: number; offset: number } { + const limit = clampLimit(options.limit); + const offset = clampOffset(options.offset); + const opened = this.openReadOnly(); + if (!opened.ok) { + return { available: false, reason: opened.reason, limit, offset }; + } + const db = opened.db; + try { + if (!this.hasValidSchema(db)) { + return { available: false, reason: 'db-invalid', limit, offset }; + } + const searchIndexAvailable = this.hasSearchIndex(db); + + // Text query resolves through the native FTS index first. Without + // the index, text search yields no hits but stays non-fatal. + let textHits: Map | null = null; + const matchExpression = options.q ? buildFtsMatchExpression(options.q) : null; + if (matchExpression) { + textHits = searchIndexAvailable ? this.queryTextHits(db, matchExpression) : new Map(); + } + if (textHits && textHits.size === 0) { + return { available: true, items: [], total: 0, searchIndexAvailable, limit, offset }; + } + + const rows = this.querySessionRows(db, options, textHits); + + const fromTs = options.from ? parseTimestamp(options.from) : undefined; + const toTs = options.to ? parseTimestamp(options.to) : undefined; + const scoped = rows.filter(row => { + if (!sessionMatchesWorkspace(row, scope)) { + return false; + } + if (fromTs !== undefined || toTs !== undefined) { + const updated = parseTimestamp(row.updated_at); + if (Number.isNaN(updated)) { + return false; + } + if (fromTs !== undefined && !Number.isNaN(fromTs) && updated < fromTs) { + return false; + } + if (toTs !== undefined && !Number.isNaN(toTs) && updated > toTs) { + return false; + } + } + return true; + }); + + scoped.sort((a, b) => { + const aTs = parseTimestamp(a.updated_at); + const bTs = parseTimestamp(b.updated_at); + return (Number.isNaN(bTs) ? 0 : bTs) - (Number.isNaN(aTs) ? 0 : aTs); + }); + + const total = scoped.length; + const page = scoped.slice(offset, offset + limit); + const turnCounts = this.queryTurnCounts(db, page.map(row => row.id)); + + const items: NativeCopilotSessionListItem[] = page.map(row => ({ + id: row.id, + repository: row.repository, + cwd: row.cwd, + hostType: row.host_type, + branch: row.branch, + summaryPreview: summaryPreview(row.summary), + createdAt: row.created_at, + updatedAt: row.updated_at, + turnCount: turnCounts.get(row.id) ?? 0, + matchSnippets: textHits?.get(row.id)?.slice(0, MAX_MATCH_SNIPPETS) ?? [], + })); + + return { available: true, items, total, searchIndexAvailable, limit, offset }; + } catch { + return { available: false, reason: 'db-invalid', limit, offset }; + } finally { + db.close(); + } + } + + /** Read one native session with ordered turns, scoped to the workspace. */ + getSession( + scope: NativeSessionWorkspaceScope, + sessionId: string, + ): NativeCopilotSessionDetailResult { + const opened = this.openReadOnly(); + if (!opened.ok) { + return { available: false, reason: opened.reason }; + } + const db = opened.db; + try { + if (!this.hasValidSchema(db)) { + return { available: false, reason: 'db-invalid' }; + } + const row = db.prepare( + 'SELECT id, cwd, repository, host_type, branch, summary, created_at, updated_at FROM sessions WHERE id = ?', + ).get(sessionId) as SessionRow | undefined; + if (!row || !sessionMatchesWorkspace(row, scope)) { + return { available: true, session: null }; + } + + const turnRows = db.prepare( + 'SELECT id, turn_index, user_message, assistant_response, timestamp FROM turns WHERE session_id = ? ORDER BY turn_index ASC', + ).all(sessionId) as TurnRow[]; + + const indexDiagnostics = this.querySearchIndexDiagnostics(db, sessionId); + + const turns: NativeCopilotSessionTurn[] = turnRows.map(turn => { + const userMessage = turn.user_message ?? ''; + const assistantResponse = turn.assistant_response ?? ''; + const sourceId = `${sessionId}:turn:${turn.turn_index}`; + const indexedChars = indexDiagnostics.get(sourceId); + return { + id: turn.id, + turnIndex: turn.turn_index, + timestamp: turn.timestamp, + userMessage, + assistantResponse, + userChars: userMessage.length, + assistantChars: assistantResponse.length, + searchIndexSourceId: indexedChars === undefined ? null : sourceId, + searchIndexChars: indexedChars === undefined ? null : indexedChars, + }; + }); + + const session: NativeCopilotSessionDetail = { + id: row.id, + repository: row.repository, + cwd: row.cwd, + hostType: row.host_type, + branch: row.branch, + summary: row.summary ?? '', + createdAt: row.created_at, + updatedAt: row.updated_at, + turns, + }; + return { available: true, session }; + } catch { + return { available: false, reason: 'db-invalid' }; + } finally { + db.close(); + } + } + + private openReadOnly(): DbOpenResult { + if (!fs.existsSync(this.dbPath)) { + return { ok: false, reason: 'db-missing' }; + } + try { + const db = new DatabaseConstructor(this.dbPath, { readonly: true, fileMustExist: true }); + return { ok: true, db }; + } catch { + return { ok: false, reason: 'db-invalid' }; + } + } + + private hasValidSchema(db: Database): boolean { + try { + // prepare() fails when a table or expected column is absent. + db.prepare('SELECT id, cwd, repository, host_type, branch, summary, created_at, updated_at FROM sessions LIMIT 0').all(); + db.prepare('SELECT id, session_id, turn_index, user_message, assistant_response, timestamp FROM turns LIMIT 0').all(); + return true; + } catch { + return false; + } + } + + private hasSearchIndex(db: Database): boolean { + try { + const row = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'search_index'", + ).get(); + return row !== undefined; + } catch { + return false; + } + } + + private queryTextHits(db: Database, matchExpression: string): Map { + const hits = new Map(); + try { + const rows = db.prepare( + "SELECT session_id AS sessionId, snippet(search_index, 0, '', '', '…', 12) AS snip FROM search_index WHERE search_index MATCH ?", + ).all(matchExpression) as { sessionId: string | null; snip: string | null }[]; + for (const row of rows) { + if (!row.sessionId) { + continue; + } + const existing = hits.get(row.sessionId) ?? []; + if (row.snip && existing.length < MAX_MATCH_SNIPPETS) { + existing.push(row.snip); + } + hits.set(row.sessionId, existing); + } + } catch { + // A broken FTS index behaves like an absent one: no text hits. + hits.clear(); + } + return hits; + } + + private querySessionRows( + db: Database, + options: NativeCopilotSessionListOptions, + textHits: Map | null, + ): SessionRow[] { + const where: string[] = []; + const params: unknown[] = []; + + if (options.branch) { + where.push('branch = ?'); + params.push(options.branch); + } + if (options.sessionId) { + const escaped = options.sessionId.replace(/([\\%_])/g, '\\$1'); + where.push("(id = ? OR id LIKE ? ESCAPE '\\')"); + params.push(options.sessionId, `%${escaped}%`); + } + if (textHits) { + const ids = [...textHits.keys()]; + where.push(`id IN (${ids.map(() => '?').join(', ')})`); + params.push(...ids); + } + + const whereClause = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : ''; + return db.prepare( + `SELECT id, cwd, repository, host_type, branch, summary, created_at, updated_at FROM sessions${whereClause}`, + ).all(...params) as SessionRow[]; + } + + private queryTurnCounts(db: Database, sessionIds: string[]): Map { + const counts = new Map(); + if (sessionIds.length === 0) { + return counts; + } + const rows = db.prepare( + `SELECT session_id AS sessionId, COUNT(*) AS turnCount FROM turns WHERE session_id IN (${sessionIds.map(() => '?').join(', ')}) GROUP BY session_id`, + ).all(...sessionIds) as { sessionId: string; turnCount: number }[]; + for (const row of rows) { + counts.set(row.sessionId, row.turnCount); + } + return counts; + } + + private querySearchIndexDiagnostics(db: Database, sessionId: string): Map { + const diagnostics = new Map(); + if (!this.hasSearchIndex(db)) { + return diagnostics; + } + try { + const rows = db.prepare( + 'SELECT source_id AS sourceId, length(content) AS chars FROM search_index WHERE session_id = ?', + ).all(sessionId) as { sourceId: string | null; chars: number | null }[]; + for (const row of rows) { + if (row.sourceId) { + diagnostics.set(row.sourceId, row.chars ?? 0); + } + } + } catch { + // Index diagnostics are optional; a broken index reads as "not indexed". + diagnostics.clear(); + } + return diagnostics; + } +} diff --git a/packages/coc/src/server/native-copilot-sessions/types.ts b/packages/coc/src/server/native-copilot-sessions/types.ts new file mode 100644 index 000000000..1d548d343 --- /dev/null +++ b/packages/coc/src/server/native-copilot-sessions/types.ts @@ -0,0 +1,96 @@ +/** + * Native GitHub Copilot CLI session types. + * + * These records are read from the current user's native Copilot CLI session + * store (`~/.copilot/session-store.db`). The store is external data owned by + * the Copilot CLI — CoC reads it strictly read-only and never imports these + * sessions into CoC process history. + */ + +/** Reason a native-session response carries no data. */ +export type NativeCopilotSessionsUnavailableReason = 'feature-disabled' | 'db-missing' | 'db-invalid'; + +/** One row in the native session list. */ +export interface NativeCopilotSessionListItem { + id: string; + repository: string | null; + cwd: string | null; + hostType: string | null; + branch: string | null; + /** First line of the stored summary, truncated for list display. */ + summaryPreview: string; + createdAt: string | null; + updatedAt: string | null; + turnCount: number; + /** Snippets from indexed content matched by a text query (empty without a text hit). */ + matchSnippets: string[]; +} + +/** One ordered turn of a native session. */ +export interface NativeCopilotSessionTurn { + id: number; + turnIndex: number; + timestamp: string | null; + userMessage: string; + assistantResponse: string; + userChars: number; + assistantChars: number; + /** search_index source id for this turn when an index row exists. */ + searchIndexSourceId: string | null; + /** Indexed content length for this turn when an index row exists. */ + searchIndexChars: number | null; +} + +/** Full native session detail. */ +export interface NativeCopilotSessionDetail { + id: string; + repository: string | null; + cwd: string | null; + hostType: string | null; + branch: string | null; + summary: string; + createdAt: string | null; + updatedAt: string | null; + turns: NativeCopilotSessionTurn[]; +} + +/** Filters accepted by the native session list query. */ +export interface NativeCopilotSessionListOptions { + /** Free-text query against natively indexed content (search_index FTS). */ + q?: string; + /** Exact or partial session ID match. */ + sessionId?: string; + /** Exact branch filter. */ + branch?: string; + /** ISO timestamp lower bound on updated_at (inclusive). */ + from?: string; + /** ISO timestamp upper bound on updated_at (inclusive). */ + to?: string; + limit?: number; + offset?: number; +} + +/** Workspace identity used to scope native sessions to the active CoC workspace. */ +export interface NativeSessionWorkspaceScope { + /** Registered workspace root path; matches native `sessions.cwd` (equal or descendant). */ + rootPath?: string; + /** Workspace `owner/repo` identity; matches native `sessions.repository` case-insensitively. */ + repository?: string; +} + +export type NativeCopilotSessionListResult = + | { + available: true; + items: NativeCopilotSessionListItem[]; + total: number; + /** False when metadata tables exist but the native search_index is absent. */ + searchIndexAvailable: boolean; + } + | { + available: false; + reason: Exclude; + }; + +export type NativeCopilotSessionDetailResult = + | { available: true; session: NativeCopilotSessionDetail | null } + | { available: false; reason: Exclude }; diff --git a/packages/coc/src/server/routes/index.ts b/packages/coc/src/server/routes/index.ts index 9aa82a6fc..993250f8e 100644 --- a/packages/coc/src/server/routes/index.ts +++ b/packages/coc/src/server/routes/index.ts @@ -111,6 +111,8 @@ import { registerMapReduceRoutes } from './map-reduce-routes'; import { FileMapReduceRunStore } from '../map-reduce/map-reduce-run-store'; import { createMapReducePlanGenerator } from '../map-reduce/map-reduce-plan-generator'; import { MapReduceRunExecutor } from '../map-reduce/map-reduce-run-executor'; +import { registerNativeCopilotSessionRoutes } from './native-copilot-session-routes'; +import { NativeCopilotSessionService } from '../native-copilot-sessions/native-copilot-session-service'; import { registerDreamRoutes } from '../dreams/dream-routes'; import { FileDreamStore } from '../dreams/dream-store'; import { DreamRunExecutor, type DreamRunRequestOptions } from '../dreams/dream-runner'; @@ -209,6 +211,8 @@ export interface RegisterRoutesOptions { hostname?: string; bindAddress?: string; syncEngines?: Map; + /** Native Copilot CLI session store path override (for tests). */ + nativeCopilotSessionDbPath?: string; } export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): { wikiManager: WikiManager | undefined; workItemGitHubPullPoller: WorkItemGitHubPullPoller; workItemAzureBoardsPullPoller: WorkItemAzureBoardsPullPoller; agentProvidersQuotaCache?: AgentProvidersQuotaCache; activeWorkspaceBackgroundRefresher: ActiveWorkspaceBackgroundRefresher; dreamIdleScheduler: DreamIdleScheduler } { @@ -711,6 +715,19 @@ export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): resolveDefaultProvider, }); + // Native Copilot CLI session routes: read-only workspace-scoped views over + // the server user's native session store. Live feature guard mirrors + // For Each/Map Reduce so admin toggles take effect without restart. + const getNativeCopilotSessionsEnabled = opts.runtimeConfigService + ? () => opts.runtimeConfigService!.config.features?.nativeCopilotSessions ?? false + : () => opts.resolvedConfig?.features?.nativeCopilotSessions ?? false; + registerNativeCopilotSessionRoutes({ + routes, + store, + getEnabled: getNativeCopilotSessionsEnabled, + service: new NativeCopilotSessionService({ dbPath: opts.nativeCopilotSessionDbPath }), + }); + const workItemStore = new FileWorkItemStore({ dataDir }); // Dreams routes: reviewable, workspace-scoped cards plus manual run trigger. diff --git a/packages/coc/src/server/routes/native-copilot-session-routes.ts b/packages/coc/src/server/routes/native-copilot-session-routes.ts new file mode 100644 index 000000000..92454ce1e --- /dev/null +++ b/packages/coc/src/server/routes/native-copilot-session-routes.ts @@ -0,0 +1,150 @@ +/** + * Native GitHub Copilot CLI session routes. + * + * Read-only, workspace-scoped views over the current server user's native + * Copilot CLI session store. Gated by the disabled-by-default + * `features.nativeCopilotSessions` flag with a live guard so admin toggles + * take effect without restart. Disabled and unavailable states return + * HTTP 200 with typed payloads so the dashboard renders non-fatal states. + */ + +import * as url from 'url'; +import type { Route } from '../types'; +import { sendJSON } from '../core/api-handler'; +import { handleAPIError, notFound } from '../errors'; +import { resolveWorkspaceOrFail } from '../shared/handler-utils'; +import type { ProcessStore, WorkspaceInfo } from '@plusplusoneplusplus/forge'; +import type { NativeCopilotSessionService } from '../native-copilot-sessions/native-copilot-session-service'; +import { DEFAULT_NATIVE_SESSION_LIST_LIMIT } from '../native-copilot-sessions/native-copilot-session-service'; +import type { NativeSessionWorkspaceScope } from '../native-copilot-sessions/types'; +import { parseGitHubRemoteUrl, readGitOriginRemote } from '../work-items/work-item-sync-github-repo'; + +export interface NativeCopilotSessionRouteContext { + routes: Route[]; + store: ProcessStore; + getEnabled: () => boolean; + service: NativeCopilotSessionService; + /** Override of workspace `owner/repo` resolution (tests avoid real git calls). */ + resolveWorkspaceRepository?: (workspace: WorkspaceInfo) => string | undefined; +} + +function defaultResolveWorkspaceRepository(workspace: WorkspaceInfo): string | undefined { + if (!workspace.rootPath) { + return undefined; + } + const remote = readGitOriginRemote(workspace.rootPath); + if (!remote) { + return undefined; + } + const parsed = parseGitHubRemoteUrl(remote); + return parsed ? `${parsed.owner}/${parsed.repo}` : undefined; +} + +function queryString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function queryNumber(value: unknown): number | undefined { + const raw = queryString(value); + if (raw === undefined) { + return undefined; + } + const parsed = Number.parseInt(raw, 10); + return Number.isNaN(parsed) ? undefined : parsed; +} + +export function registerNativeCopilotSessionRoutes(ctx: NativeCopilotSessionRouteContext): void { + const { routes, store, getEnabled, service } = ctx; + const resolveRepository = ctx.resolveWorkspaceRepository ?? defaultResolveWorkspaceRepository; + + const buildScope = (workspace: WorkspaceInfo): NativeSessionWorkspaceScope => ({ + rootPath: workspace.rootPath, + repository: resolveRepository(workspace), + }); + + // GET /api/workspaces/:id/native-copilot-sessions + routes.push({ + method: 'GET', + pattern: /^\/api\/workspaces\/([^/]+)\/native-copilot-sessions$/, + handler: async (req, res, match) => { + const query = url.parse(req.url || '', true).query; + const limit = queryNumber(query.limit) ?? DEFAULT_NATIVE_SESSION_LIST_LIMIT; + const offset = queryNumber(query.offset) ?? 0; + if (!getEnabled()) { + sendJSON(res, 200, { + enabled: false, + reason: 'feature-disabled', + items: [], + total: 0, + limit, + offset, + }); + return; + } + const workspace = await resolveWorkspaceOrFail(store, match!, res); + if (!workspace) { return; } + + const result = service.listSessions(buildScope(workspace), { + q: queryString(query.q), + sessionId: queryString(query.sessionId), + branch: queryString(query.branch), + from: queryString(query.from), + to: queryString(query.to), + limit: queryNumber(query.limit), + offset: queryNumber(query.offset), + }); + + if (!result.available) { + sendJSON(res, 200, { + enabled: true, + available: false, + reason: result.reason, + items: [], + total: 0, + limit: result.limit, + offset: result.offset, + }); + return; + } + sendJSON(res, 200, { + enabled: true, + available: true, + items: result.items, + total: result.total, + searchIndexAvailable: result.searchIndexAvailable, + limit: result.limit, + offset: result.offset, + }); + }, + }); + + // GET /api/workspaces/:id/native-copilot-sessions/:sessionId + routes.push({ + method: 'GET', + pattern: /^\/api\/workspaces\/([^/]+)\/native-copilot-sessions\/([^/]+)$/, + handler: async (_req, res, match) => { + if (!getEnabled()) { + sendJSON(res, 200, { enabled: false, reason: 'feature-disabled' }); + return; + } + const workspace = await resolveWorkspaceOrFail(store, match!, res); + if (!workspace) { return; } + + const sessionId = decodeURIComponent(match![2]); + const result = service.getSession(buildScope(workspace), sessionId); + if (!result.available) { + sendJSON(res, 200, { enabled: true, available: false, reason: result.reason }); + return; + } + if (!result.session) { + handleAPIError(res, notFound('Native Copilot session')); + return; + } + sendJSON(res, 200, { enabled: true, available: true, session: result.session }); + }, + }); +} diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx new file mode 100644 index 000000000..bdfce7c87 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx @@ -0,0 +1,447 @@ +/** + * NativeCopilotSessionsPanel — read-only dashboard view for native GitHub + * Copilot CLI sessions scoped to the active workspace. + * + * Native sessions are external data read from the server user's + * `~/.copilot/session-store.db`. This surface intentionally renders no CoC + * chat actions (no follow-up, archive, pin, delete, resume, retry, or turn + * actions) and labels every session as an external read-only record. All + * stored text renders as plain pre-wrapped text so stored HTML/scripts never + * execute. + */ + +import { useCallback, useEffect, useState } from 'react'; +import type { + ListNativeCopilotSessionsResponse, + NativeCopilotSessionDetail, + NativeCopilotSessionListItem, + NativeCopilotSessionsUnavailableReason, +} from '@plusplusoneplusplus/coc-client'; +import { getSpaCocClient } from '../../api/cocClient'; +import { Button, Spinner, cn } from '../../ui'; +import { useNativeCopilotSessionsEnabled } from '../../hooks/feature-flags/useNativeCopilotSessionsEnabled'; + +const READ_ONLY_TOOLTIP = 'This data is read from the local native Copilot CLI session store (~/.copilot/session-store.db) and cannot be modified from CoC.'; + +interface NativeCopilotSessionsPanelProps { + workspaceId: string; +} + +interface ListFilters { + q: string; + sessionId: string; + branch: string; + from: string; + to: string; +} + +const EMPTY_FILTERS: ListFilters = { q: '', sessionId: '', branch: '', from: '', to: '' }; + +function formatTimestamp(value: string | null): string { + if (!value) return '—'; + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + return new Date(parsed).toLocaleString(); +} + +function unavailableCopy(reason: NativeCopilotSessionsUnavailableReason | undefined): { title: string; body: string } { + if (reason === 'db-missing') { + return { + title: 'Native session store not found', + body: 'No native Copilot CLI session store exists at ~/.copilot/session-store.db on the CoC server. Run the GitHub Copilot CLI at least once to create it.', + }; + } + return { + title: 'Native session store unavailable', + body: 'The native Copilot CLI session store could not be read. It may be corrupt or use an unsupported schema.', + }; +} + +function ReadOnlyBadge() { + return ( + + Read-only + + ); +} + +function ExternalLabel() { + return ( + + Native Copilot CLI session + + ); +} + +export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSessionsPanelProps) { + const enabled = useNativeCopilotSessionsEnabled(); + + const [filterDraft, setFilterDraft] = useState(EMPTY_FILTERS); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [offset, setOffset] = useState(0); + const [listLoading, setListLoading] = useState(false); + const [listError, setListError] = useState(null); + const [listResponse, setListResponse] = useState(null); + + const [selectedSessionId, setSelectedSessionId] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [detail, setDetail] = useState(null); + + const loadList = useCallback(async () => { + if (!enabled) return; + setListLoading(true); + setListError(null); + try { + const response = await getSpaCocClient().nativeCopilotSessions.list(workspaceId, { + q: filters.q || undefined, + sessionId: filters.sessionId || undefined, + branch: filters.branch || undefined, + from: filters.from ? `${filters.from}T00:00:00.000Z` : undefined, + to: filters.to ? `${filters.to}T23:59:59.999Z` : undefined, + offset, + }); + setListResponse(response); + } catch (error) { + setListError(error instanceof Error ? error.message : String(error)); + setListResponse(null); + } finally { + setListLoading(false); + } + }, [enabled, workspaceId, filters, offset]); + + useEffect(() => { void loadList(); }, [loadList]); + + // Reset selection and paging when the workspace changes. + useEffect(() => { + setSelectedSessionId(null); + setDetail(null); + setOffset(0); + setFilterDraft(EMPTY_FILTERS); + setFilters(EMPTY_FILTERS); + }, [workspaceId]); + + useEffect(() => { + if (!enabled || !selectedSessionId) { + setDetail(null); + return; + } + let cancelled = false; + setDetailLoading(true); + setDetailError(null); + getSpaCocClient().nativeCopilotSessions.get(workspaceId, selectedSessionId) + .then(response => { + if (cancelled) return; + if (!response.enabled || response.available === false || !response.session) { + setDetail(null); + setDetailError('This native session is unavailable.'); + return; + } + setDetail(response.session); + }) + .catch(error => { + if (cancelled) return; + setDetail(null); + const message = error instanceof Error ? error.message : String(error); + setDetailError(/not found/i.test(message) + ? 'Session not found in this workspace.' + : message); + }) + .finally(() => { if (!cancelled) setDetailLoading(false); }); + return () => { cancelled = true; }; + }, [enabled, workspaceId, selectedSessionId]); + + if (!enabled) { + return ( +
+
+

Copilot Sessions is disabled

+

+ Enable the features.nativeCopilotSessions flag in Admin to browse native + GitHub Copilot CLI sessions for this workspace in read-only mode. +

+
+
+ ); + } + + const unavailable = listResponse && (listResponse.enabled === false || listResponse.available === false); + const items = listResponse?.items ?? []; + const total = listResponse?.total ?? 0; + const limit = listResponse?.limit ?? 50; + const hasFilters = Boolean(filters.q || filters.sessionId || filters.branch || filters.from || filters.to); + + const applyFilters = (e: React.FormEvent) => { + e.preventDefault(); + setOffset(0); + setFilters(filterDraft); + }; + + const listPane = ( +
+
+
+

Copilot Sessions

+ + + {total} session{total === 1 ? '' : 's'} +
+
+ setFilterDraft(prev => ({ ...prev, q: e.target.value }))} + placeholder="Search indexed content…" + className="h-8 min-w-[160px] flex-1 rounded border border-[#d0d7de] px-2 text-sm" + data-testid="native-sessions-search-input" + /> + setFilterDraft(prev => ({ ...prev, sessionId: e.target.value }))} + placeholder="Session ID" + className="h-8 w-32 rounded border border-[#d0d7de] px-2 text-sm" + data-testid="native-sessions-session-id-input" + /> + setFilterDraft(prev => ({ ...prev, branch: e.target.value }))} + placeholder="Branch" + className="h-8 w-28 rounded border border-[#d0d7de] px-2 text-sm" + data-testid="native-sessions-branch-input" + /> + + + +
+ {listResponse?.available === true && listResponse.searchIndexAvailable === false && filters.q && ( +

+ Text search is unavailable: the native store has no search index. Metadata filters still apply. +

+ )} +
+
+ {listLoading && ( +
+ )} + {!listLoading && listError && ( +
+ Failed to load native sessions: {listError} +
+
+ )} + {!listLoading && !listError && unavailable && ( +
+

{unavailableCopy(listResponse?.reason).title}

+

{unavailableCopy(listResponse?.reason).body}

+
+ )} + {!listLoading && !listError && !unavailable && items.length === 0 && ( +
+ {hasFilters + ? 'No native Copilot CLI sessions match the current filters.' + : 'No native Copilot CLI sessions were found for this workspace.'} +
+ )} + {!listLoading && !listError && !unavailable && items.length > 0 && ( + + + + + + + + + + + {items.map(item => ( + setSelectedSessionId(item.id)} + /> + ))} + +
SessionBranchUpdatedTurns
+ )} +
+ {!unavailable && total > limit && ( +
+ + {offset + 1}–{Math.min(offset + limit, total)} of {total} + +
+ )} +
+ ); + + const detailPane = ( +
+ {!selectedSessionId && ( +
+ Select a native session to view its summary and turns. +
+ )} + {selectedSessionId && detailLoading && ( +
+ )} + {selectedSessionId && !detailLoading && detailError && ( +
+ {detailError} +
+ )} + {selectedSessionId && !detailLoading && !detailError && detail && ( + setSelectedSessionId(null)} /> + )} +
+ ); + + // Wide screens render the searchable table beside the detail; narrow screens + // stack panes and show one at a time based on selection. + return ( +
+
+
+ {listPane} +
+
+ {detailPane} +
+
+
+ ); +} + +function SessionRow({ item, selected, onSelect }: { + item: NativeCopilotSessionListItem; + selected: boolean; + onSelect: () => void; +}) { + return ( + + +
+ {item.id.slice(0, 8)} + +
+
{item.summaryPreview || No summary stored}
+ {item.matchSnippets.length > 0 && ( +
+ {item.matchSnippets.map((snippet, index) => ( +
{snippet}
+ ))} +
+ )} +
{item.repository || item.cwd || ''}
+ + {item.branch || 'Unknown branch'} + {formatTimestamp(item.updatedAt)} + {item.turnCount} + + ); +} + +function SessionDetailView({ detail, onBack }: { detail: NativeCopilotSessionDetail; onBack: () => void }) { + return ( +
+
+
+ + + +
+

{detail.id}

+

+ {READ_ONLY_TOOLTIP} +

+
+
Repository
{detail.repository || '—'}
+
Branch
{detail.branch || 'Unknown branch'}
+
Working dir
{detail.cwd || '—'}
+
Host
{detail.hostType || '—'}
+
Created
{formatTimestamp(detail.createdAt)}
+
Updated
{formatTimestamp(detail.updatedAt)}
+
+ {detail.summary && ( +
+

Stored summary

+
{detail.summary}
+
+ )} +
+ +
+

Turns ({detail.turns.length})

+ {detail.turns.length === 0 && ( +

This native session has no stored turns.

+ )} +
    + {detail.turns.map(turn => ( +
  1. +
    + Turn {turn.turnIndex} + {formatTimestamp(turn.timestamp)} + {turn.userChars} user chars · {turn.assistantChars} assistant chars + + {turn.searchIndexSourceId + ? `Indexed (${turn.searchIndexChars ?? 0} chars)` + : 'Not indexed'} + +
    +
    +

    User

    +
    {turn.userMessage || '—'}
    +
    +
    +

    Assistant

    + {turn.assistantResponse + ?
    {turn.assistantResponse}
    + :

    No assistant response stored

    } +
    +
  2. + ))} +
+
+
+ ); +} diff --git a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx index a044b54e2..2fd55b955 100644 --- a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx +++ b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx @@ -24,6 +24,7 @@ import { WorkflowDetailView } from '../../processes/dag'; import { TerminalView } from '../terminal/TerminalView'; import { NotesView } from '../notes/NotesView'; import { DreamsPanel } from '../dreams/DreamsPanel'; +import { NativeCopilotSessionsPanel } from '../native-copilot-sessions/NativeCopilotSessionsPanel'; import { AddRepoDialog } from '../../repos/AddRepoDialog'; import { ErrorBoundary } from '../../ui/ErrorBoundary'; @@ -38,6 +39,7 @@ import { useNotesEnabled } from '../notes/hooks/useNotesEnabled'; import { useWorkflowsEnabled } from '../../hooks/feature-flags/useWorkflowsEnabled'; import { usePullRequestsEnabled } from '../../hooks/feature-flags/usePullRequestsEnabled'; import { useDreamsEnabled } from '../../hooks/feature-flags/useDreamsEnabled'; +import { useNativeCopilotSessionsEnabled } from '../../hooks/feature-flags/useNativeCopilotSessionsEnabled'; import { MobileTabBar } from '../../layout/MobileTabBar'; import { buildRepoSubTabSuffix } from '../../layout/Router'; import { SHOW_WIKI_TAB } from '../../layout/TopBar'; @@ -63,6 +65,7 @@ export const SUB_TABS: { key: RepoSubTab; label: string; shortcut?: string }[] = { key: 'terminal', label: 'Terminal' }, { key: 'work-items', label: 'Work Items', shortcut: 'Alt+I' }, { key: 'dreams', label: 'Dreams', shortcut: 'Alt+D' }, + { key: 'copilot-sessions', label: 'Copilot Sessions' }, { key: 'pull-requests', label: 'Pull Requests', shortcut: 'Alt+R' }, { key: 'explorer', label: 'Explorer', shortcut: 'Alt+E' }, { key: 'workflows', label: 'Workflows', shortcut: 'Alt+W' }, @@ -85,7 +88,7 @@ export const VISIBLE_SUB_TABS = SHOW_WIKI_TAB */ const TAB_GROUP_INDEX: Record = { 'chats': 1, 'activity': 1, 'git': 1, 'terminal': 1, - 'work-items': 2, 'dreams': 2, 'pull-requests': 2, 'tasks': 2, + 'work-items': 2, 'dreams': 2, 'copilot-sessions': 2, 'pull-requests': 2, 'tasks': 2, 'explorer': 3, 'workflows': 3, 'schedules': 3, 'notes': 4, 'settings': 4, 'wiki': 4, }; @@ -134,6 +137,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { const workflowsEnabled = useWorkflowsEnabled(); const pullRequestsEnabled = usePullRequestsEnabled(); const dreamsEnabled = useDreamsEnabled(); + const nativeCopilotSessionsEnabled = useNativeCopilotSessionsEnabled(); const sessionContextAttachmentsEnabled = isSessionContextAttachmentsEnabled(); const canRetrieveConversations = useConversationRetrievalCapability(ws.id, sessionContextAttachmentsEnabled); const [headerContextDropTarget, setHeaderContextDropTarget] = useState<'task' | 'ask' | null>(null); @@ -162,6 +166,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { const prevWorkflowsEnabled = useRef(workflowsEnabled); const prevPullRequestsEnabled = useRef(pullRequestsEnabled); const prevDreamsEnabled = useRef(dreamsEnabled); + const prevNativeCopilotSessionsEnabled = useRef(nativeCopilotSessionsEnabled); const visibleSubTabs = useMemo(() => { let tabs = VISIBLE_SUB_TABS; @@ -171,6 +176,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { if (!workflowsEnabled) tabs = tabs.filter(t => t.key !== 'workflows'); if (!pullRequestsEnabled) tabs = tabs.filter(t => t.key !== 'pull-requests'); if (!dreamsEnabled) tabs = tabs.filter(t => t.key !== 'dreams'); + if (!nativeCopilotSessionsEnabled) tabs = tabs.filter(t => t.key !== 'copilot-sessions'); // Layout mode filtering if (uiLayoutMode === 'classic') { // Classic: replace Chats with Activity, relabel Tasks as Plans @@ -184,7 +190,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { 'pull-requests': 'Full Requests', }; const devWorkflowOrder: RepoSubTab[] = [ - 'chats', 'work-items', 'dreams', 'schedules', 'explorer', + 'chats', 'work-items', 'dreams', 'copilot-sessions', 'schedules', 'explorer', 'workflows', 'git', 'terminal', 'pull-requests', 'tasks', 'settings', ]; const tabMap = new Map(tabs.map(t => [t.key, t])); @@ -204,7 +210,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { tabs = ordered; } return tabs; - }, [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, uiLayoutMode]); + }, [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, nativeCopilotSessionsEnabled, uiLayoutMode]); // Redirect away from git/pull-requests tab when switching to a non-git repo useEffect(() => { @@ -253,6 +259,14 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { prevDreamsEnabled.current = dreamsEnabled; }, [activeSubTab, dreamsEnabled, dispatch]); + // Redirect away from copilot-sessions tab only when the feature transitions to disabled + useEffect(() => { + if (activeSubTab === 'copilot-sessions' && !nativeCopilotSessionsEnabled && prevNativeCopilotSessionsEnabled.current) { + dispatch({ type: 'SET_REPO_SUB_TAB', tab: 'chats' }); + } + prevNativeCopilotSessionsEnabled.current = nativeCopilotSessionsEnabled; + }, [activeSubTab, nativeCopilotSessionsEnabled, dispatch]); + // Redirect when switching layout modes useEffect(() => { if (uiLayoutMode === 'classic' && activeSubTab === 'chats') { @@ -777,7 +791,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { )} ) : ( -
+
{activeSubTab === 'settings' && } {activeSubTab === 'workflows' && } {/* @@ -834,6 +848,11 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { {wasVisited('dreams') && }
)} + {nativeCopilotSessionsEnabled && ( +
+ {wasVisited('copilot-sessions') && } +
+ )} {activeSubTab === 'workflow' && state.selectedWorkflowProcessId && }
)} diff --git a/packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCopilotSessionsEnabled.ts b/packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCopilotSessionsEnabled.ts new file mode 100644 index 000000000..f4343bc02 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCopilotSessionsEnabled.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react'; +import { DASHBOARD_CONFIG_UPDATED_EVENT, isNativeCopilotSessionsEnabled } from '../../utils/config'; + +/** Live `features.nativeCopilotSessions` flag; tracks runtime config updates. */ +export function useNativeCopilotSessionsEnabled(): boolean { + const [enabled, setEnabled] = useState(isNativeCopilotSessionsEnabled()); + useEffect(() => { + const onConfigUpdated = () => setEnabled(isNativeCopilotSessionsEnabled()); + window.addEventListener(DASHBOARD_CONFIG_UPDATED_EVENT, onConfigUpdated); + return () => window.removeEventListener(DASHBOARD_CONFIG_UPDATED_EVENT, onConfigUpdated); + }, []); + return enabled; +} diff --git a/packages/coc/src/server/spa/client/react/types/dashboard.ts b/packages/coc/src/server/spa/client/react/types/dashboard.ts index 3aae9d437..fe80cd424 100644 --- a/packages/coc/src/server/spa/client/react/types/dashboard.ts +++ b/packages/coc/src/server/spa/client/react/types/dashboard.ts @@ -41,7 +41,7 @@ export type DashboardTab = 'processes' | 'repos' | 'wiki' | 'reports' | 'stats' export const REPO_SUB_TAB_VALUES = [ 'chats', 'work-items', 'settings', 'workflows', 'templates', 'tasks', 'schedules', 'git', 'wiki', 'workflow', 'explorer', 'activity', - 'pull-requests', 'terminal', 'notes', 'dreams', + 'pull-requests', 'terminal', 'notes', 'dreams', 'copilot-sessions', ] as const; export type RepoSubTab = typeof REPO_SUB_TAB_VALUES[number]; diff --git a/packages/coc/src/server/spa/client/react/utils/config.ts b/packages/coc/src/server/spa/client/react/utils/config.ts index 9df801128..51c7c5dbf 100644 --- a/packages/coc/src/server/spa/client/react/utils/config.ts +++ b/packages/coc/src/server/spa/client/react/utils/config.ts @@ -66,6 +66,8 @@ interface DashboardConfig { gitCrossCloneCherryPickEnabled?: boolean; /** Whether the Effort Tiers selector (Low/Medium/High) is enabled in the composer. Disabled by default. */ effortLevelsEnabled?: boolean; + /** Whether the read-only native Copilot CLI sessions tab is enabled (feature flag). */ + nativeCopilotSessionsEnabled?: boolean; } /** Cached runtime config loaded from the API. */ @@ -296,6 +298,10 @@ export function isDreamsEnabled(): boolean { return getConfig().dreamsEnabled === true; } +export function isNativeCopilotSessionsEnabled(): boolean { + return getConfig().nativeCopilotSessionsEnabled === true; +} + export function isExcalidrawEnabled(): boolean { return getConfig().excalidrawEnabled === true; } diff --git a/packages/coc/src/server/types.ts b/packages/coc/src/server/types.ts index be936bbb5..3773f7723 100644 --- a/packages/coc/src/server/types.ts +++ b/packages/coc/src/server/types.ts @@ -86,6 +86,8 @@ export interface ExecutionServerOptions { fileConfig?: CLIConfig; /** Admin token TTL override in ms (for testing). Defaults to TOKEN_EXPIRY_MS (5 min). */ tokenTtlMs?: number; + /** Native Copilot CLI session store path override (for tests). Defaults to `~/.copilot/session-store.db`. */ + nativeCopilotSessionDbPath?: string; /** Queue-specific options. */ queue?: { /** Policy for tasks that were running when the server last stopped (default: 'fail'). */ diff --git a/packages/coc/test/server/native-copilot-sessions.test.ts b/packages/coc/test/server/native-copilot-sessions.test.ts new file mode 100644 index 000000000..84f87cc03 --- /dev/null +++ b/packages/coc/test/server/native-copilot-sessions.test.ts @@ -0,0 +1,556 @@ +/** + * Tests for the read-only native Copilot CLI session routes and query service. + * + * All fixtures are synthetic temporary SQLite databases — these tests never + * read real local user data from ~/.copilot. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as http from 'http'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import DatabaseConstructor from 'better-sqlite3'; +import { createExecutionServer } from '../../src/server/index'; +import { FileProcessStore } from '@plusplusoneplusplus/forge'; +import type { ExecutionServer } from '../../src/server/types'; +import { + NativeCopilotSessionService, + buildFtsMatchExpression, + sessionMatchesWorkspace, +} from '../../src/server/native-copilot-sessions/native-copilot-session-service'; + +// ── Fixture helpers ────────────────────────────────────────────────────────── + +interface FixtureSession { + id: string; + cwd?: string | null; + repository?: string | null; + hostType?: string | null; + branch?: string | null; + summary?: string | null; + createdAt?: string; + updatedAt?: string; +} + +interface FixtureTurn { + sessionId: string; + turnIndex: number; + userMessage?: string | null; + assistantResponse?: string | null; + timestamp?: string; + indexed?: boolean; +} + +function createFixtureDb(dbPath: string, sessions: FixtureSession[], turns: FixtureTurn[] = [], options: { searchIndex?: boolean } = {}): void { + const db = new DatabaseConstructor(dbPath); + try { + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + cwd TEXT, + repository TEXT, + host_type TEXT, + branch TEXT, + summary TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + turn_index INTEGER NOT NULL, + user_message TEXT, + assistant_response TEXT, + timestamp TEXT DEFAULT (datetime('now')), + UNIQUE(session_id, turn_index) + ); + `); + if (options.searchIndex !== false) { + db.exec(` + CREATE VIRTUAL TABLE search_index USING fts5( + content, + session_id UNINDEXED, + source_type UNINDEXED, + source_id UNINDEXED + ); + `); + } + const insertSession = db.prepare( + 'INSERT INTO sessions (id, cwd, repository, host_type, branch, summary, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ); + for (const s of sessions) { + insertSession.run( + s.id, + s.cwd ?? null, + s.repository ?? null, + s.hostType ?? 'github', + s.branch ?? null, + s.summary ?? null, + s.createdAt ?? '2026-06-01T00:00:00.000Z', + s.updatedAt ?? '2026-06-01T00:00:00.000Z', + ); + } + const insertTurn = db.prepare( + 'INSERT INTO turns (session_id, turn_index, user_message, assistant_response, timestamp) VALUES (?, ?, ?, ?, ?)', + ); + const insertIndex = options.searchIndex !== false + ? db.prepare('INSERT INTO search_index (content, session_id, source_type, source_id) VALUES (?, ?, ?, ?)') + : null; + for (const t of turns) { + insertTurn.run(t.sessionId, t.turnIndex, t.userMessage ?? null, t.assistantResponse ?? null, t.timestamp ?? '2026-06-01T00:00:00.000Z'); + if (t.indexed !== false && insertIndex) { + const content = [t.userMessage, t.assistantResponse].filter(Boolean).join('\n'); + if (content) { + insertIndex.run(content, t.sessionId, 'turn', `${t.sessionId}:turn:${t.turnIndex}`); + } + } + } + } finally { + db.close(); + } +} + +function request(url: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = http.request( + { hostname: parsed.hostname, port: parsed.port, path: parsed.pathname + parsed.search, method: 'GET' }, + res => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode || 0, body: Buffer.concat(chunks).toString('utf-8') })); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +function postJSON(url: string, data: unknown): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const body = JSON.stringify(data); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + }, + res => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode || 0, body: Buffer.concat(chunks).toString('utf-8') })); + }, + ); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +// ── Service-level tests ────────────────────────────────────────────────────── + +describe('NativeCopilotSessionService', () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-sessions-svc-')); + dbPath = path.join(tmpDir, 'session-store.db'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns db-missing when the native store does not exist', () => { + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.listSessions({ rootPath: tmpDir }); + expect(result).toMatchObject({ available: false, reason: 'db-missing' }); + }); + + it('returns db-invalid for a non-SQLite file', () => { + fs.writeFileSync(dbPath, 'not a sqlite database at all'); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.listSessions({ rootPath: tmpDir }); + expect(result).toMatchObject({ available: false, reason: 'db-invalid' }); + }); + + it('returns db-invalid when the schema lacks required tables', () => { + const db = new DatabaseConstructor(dbPath); + db.exec('CREATE TABLE unrelated (id INTEGER PRIMARY KEY)'); + db.close(); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.listSessions({ rootPath: tmpDir }); + expect(result).toMatchObject({ available: false, reason: 'db-invalid' }); + const detail = service.getSession({ rootPath: tmpDir }, 'anything'); + expect(detail).toMatchObject({ available: false, reason: 'db-invalid' }); + }); + + it('filters sessions to the workspace by cwd and repository across two synthetic repos', () => { + const wsRoot = path.join(tmpDir, 'repo-a'); + const otherRoot = path.join(tmpDir, 'repo-b'); + createFixtureDb(dbPath, [ + { id: 'in-cwd', cwd: wsRoot, repository: null, updatedAt: '2026-06-03T00:00:00.000Z' }, + { id: 'in-cwd-sub', cwd: path.join(wsRoot, 'packages', 'x'), repository: null, updatedAt: '2026-06-02T00:00:00.000Z' }, + { id: 'in-repo', cwd: otherRoot, repository: 'Owner/Repo-A', updatedAt: '2026-06-01T00:00:00.000Z' }, + { id: 'other-repo', cwd: otherRoot, repository: 'owner/repo-b', updatedAt: '2026-06-04T00:00:00.000Z' }, + { id: 'prefix-trap', cwd: `${wsRoot}-sibling`, repository: null, updatedAt: '2026-06-05T00:00:00.000Z' }, + ]); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.listSessions({ rootPath: wsRoot, repository: 'owner/repo-a' }); + expect(result.available).toBe(true); + if (result.available) { + expect(result.items.map(i => i.id)).toEqual(['in-cwd', 'in-cwd-sub', 'in-repo']); + } + }); + + it('sorts by updated_at descending and reports turn counts', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb( + dbPath, + [ + { id: 'older', cwd: wsRoot, updatedAt: '2026-06-01T00:00:00.000Z' }, + { id: 'newer', cwd: wsRoot, updatedAt: '2026-06-10T00:00:00.000Z' }, + ], + [ + { sessionId: 'older', turnIndex: 0, userMessage: 'hello' }, + { sessionId: 'older', turnIndex: 1, userMessage: 'again' }, + { sessionId: 'newer', turnIndex: 0, userMessage: 'hi' }, + ], + ); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.listSessions({ rootPath: wsRoot }); + expect(result.available).toBe(true); + if (result.available) { + expect(result.items.map(i => i.id)).toEqual(['newer', 'older']); + expect(result.items.map(i => i.turnCount)).toEqual([1, 2]); + } + }); + + it('paginates with limit/offset and reports the full total', () => { + const wsRoot = path.join(tmpDir, 'ws'); + const sessions = Array.from({ length: 5 }, (_, i) => ({ + id: `s-${i}`, + cwd: wsRoot, + updatedAt: `2026-06-0${5 - i}T00:00:00.000Z`, + })); + createFixtureDb(dbPath, sessions); + const service = new NativeCopilotSessionService({ dbPath }); + const page = service.listSessions({ rootPath: wsRoot }, { limit: 2, offset: 2 }); + expect(page.available).toBe(true); + if (page.available) { + expect(page.total).toBe(5); + expect(page.items.map(i => i.id)).toEqual(['s-2', 's-3']); + expect(page.limit).toBe(2); + expect(page.offset).toBe(2); + } + }); + + it('finds text hits through search_index with snippets, and supports combined filters', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb( + dbPath, + [ + { id: 'hit-main', cwd: wsRoot, branch: 'main', updatedAt: '2026-06-02T00:00:00.000Z' }, + { id: 'hit-feature', cwd: wsRoot, branch: 'feature', updatedAt: '2026-06-03T00:00:00.000Z' }, + { id: 'no-hit', cwd: wsRoot, branch: 'main', updatedAt: '2026-06-04T00:00:00.000Z' }, + ], + [ + { sessionId: 'hit-main', turnIndex: 0, userMessage: 'please fix the mermaid build failure' }, + { sessionId: 'hit-feature', turnIndex: 0, userMessage: 'mermaid diagrams render blank' }, + { sessionId: 'no-hit', turnIndex: 0, userMessage: 'unrelated work item planning' }, + ], + ); + const service = new NativeCopilotSessionService({ dbPath }); + + const textOnly = service.listSessions({ rootPath: wsRoot }, { q: 'mermaid' }); + expect(textOnly.available).toBe(true); + if (textOnly.available) { + expect(textOnly.items.map(i => i.id).sort()).toEqual(['hit-feature', 'hit-main']); + expect(textOnly.items[0].matchSnippets.length).toBeGreaterThan(0); + expect(textOnly.searchIndexAvailable).toBe(true); + } + + const combined = service.listSessions({ rootPath: wsRoot }, { q: 'mermaid', branch: 'main' }); + expect(combined.available).toBe(true); + if (combined.available) { + expect(combined.items.map(i => i.id)).toEqual(['hit-main']); + } + + const noResult = service.listSessions({ rootPath: wsRoot }, { q: 'zzz-not-present-anywhere' }); + expect(noResult.available).toBe(true); + if (noResult.available) { + expect(noResult.items).toEqual([]); + expect(noResult.total).toBe(0); + } + }); + + it('filters by partial session id and date range', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb(dbPath, [ + { id: 'abc-123', cwd: wsRoot, updatedAt: '2026-06-02T00:00:00.000Z' }, + { id: 'def-456', cwd: wsRoot, updatedAt: '2026-06-08T00:00:00.000Z' }, + ]); + const service = new NativeCopilotSessionService({ dbPath }); + + const byId = service.listSessions({ rootPath: wsRoot }, { sessionId: 'abc' }); + expect(byId.available && byId.items.map(i => i.id)).toEqual(['abc-123']); + + const byRange = service.listSessions({ rootPath: wsRoot }, { from: '2026-06-05T00:00:00.000Z' }); + expect(byRange.available && byRange.items.map(i => i.id)).toEqual(['def-456']); + + const byUpper = service.listSessions({ rootPath: wsRoot }, { to: '2026-06-05T00:00:00.000Z' }); + expect(byUpper.available && byUpper.items.map(i => i.id)).toEqual(['abc-123']); + }); + + it('treats hostile filter input as data, never as SQL or FTS syntax', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb( + dbPath, + [{ id: 'safe', cwd: wsRoot, branch: 'main', summary: 'safe summary', updatedAt: '2026-06-02T00:00:00.000Z' }], + [{ sessionId: 'safe', turnIndex: 0, userMessage: 'regular indexed content' }], + ); + const service = new NativeCopilotSessionService({ dbPath }); + const hostileInputs = [ + "'; DROP TABLE sessions; --", + '" OR "1"="1', + 'content* OR session_id:x', + '%_\\', + ]; + for (const hostile of hostileInputs) { + const viaQ = service.listSessions({ rootPath: wsRoot }, { q: hostile }); + expect(viaQ.available).toBe(true); + const viaBranch = service.listSessions({ rootPath: wsRoot }, { branch: hostile }); + expect(viaBranch.available && viaBranch.items).toEqual([]); + const viaId = service.listSessions({ rootPath: wsRoot }, { sessionId: hostile }); + expect(viaId.available && viaId.items).toEqual([]); + } + // The sessions table must survive every hostile input above. + const after = service.listSessions({ rootPath: wsRoot }); + expect(after.available && after.items.map(i => i.id)).toEqual(['safe']); + }); + + it('reports text search unavailable when search_index is absent', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb(dbPath, [{ id: 's1', cwd: wsRoot, updatedAt: '2026-06-02T00:00:00.000Z' }], [], { searchIndex: false }); + const service = new NativeCopilotSessionService({ dbPath }); + const noQuery = service.listSessions({ rootPath: wsRoot }); + expect(noQuery.available && noQuery.searchIndexAvailable).toBe(false); + expect(noQuery.available && noQuery.items.map(i => i.id)).toEqual(['s1']); + + const withQuery = service.listSessions({ rootPath: wsRoot }, { q: 'anything' }); + expect(withQuery.available).toBe(true); + if (withQuery.available) { + expect(withQuery.items).toEqual([]); + expect(withQuery.searchIndexAvailable).toBe(false); + } + }); + + it('returns ordered turns, empty assistant responses, and index diagnostics on detail reads', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb( + dbPath, + [{ id: 'detail-1', cwd: wsRoot, branch: null, summary: 'Full stored summary\nsecond line' }], + [ + { sessionId: 'detail-1', turnIndex: 1, userMessage: 'second user', assistantResponse: 'second answer' }, + { sessionId: 'detail-1', turnIndex: 0, userMessage: '', assistantResponse: null, indexed: true }, + { sessionId: 'detail-1', turnIndex: 2, userMessage: 'third — not indexed', assistantResponse: '', indexed: false }, + ], + ); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.getSession({ rootPath: wsRoot }, 'detail-1'); + expect(result.available).toBe(true); + if (!result.available || !result.session) throw new Error('expected session'); + const session = result.session; + expect(session.summary).toBe('Full stored summary\nsecond line'); + expect(session.turns.map(t => t.turnIndex)).toEqual([0, 1, 2]); + // Stored text is returned exactly as stored; rendering safety is a UI concern. + expect(session.turns[0].userMessage).toBe(''); + expect(session.turns[0].assistantResponse).toBe(''); + expect(session.turns[0].assistantChars).toBe(0); + expect(session.turns[0].searchIndexSourceId).toBe('detail-1:turn:0'); + expect(session.turns[0].searchIndexChars).toBeGreaterThan(0); + expect(session.turns[2].searchIndexSourceId).toBeNull(); + expect(session.turns[2].searchIndexChars).toBeNull(); + }); + + it('hides sessions from other workspaces on detail reads', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb(dbPath, [{ id: 'foreign', cwd: path.join(tmpDir, 'elsewhere') }]); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.getSession({ rootPath: wsRoot }, 'foreign'); + expect(result).toMatchObject({ available: true, session: null }); + }); +}); + +describe('native session matching helpers', () => { + it('matches repository case-insensitively and cwd by normalized prefix', () => { + expect(sessionMatchesWorkspace({ repository: 'Owner/Repo', cwd: null }, { repository: 'owner/repo' })).toBe(true); + expect(sessionMatchesWorkspace({ repository: 'owner/other', cwd: null }, { repository: 'owner/repo' })).toBe(false); + expect(sessionMatchesWorkspace({ repository: null, cwd: '/a/b/c' }, { rootPath: '/a/b' })).toBe(true); + expect(sessionMatchesWorkspace({ repository: null, cwd: '/a/bc' }, { rootPath: '/a/b' })).toBe(false); + expect(sessionMatchesWorkspace({ repository: null, cwd: '/a/b/' }, { rootPath: '/a/b' })).toBe(true); + expect(sessionMatchesWorkspace({ repository: null, cwd: null }, { rootPath: '/a/b', repository: 'o/r' })).toBe(false); + expect(sessionMatchesWorkspace({ repository: 'o/r', cwd: '/x' }, {})).toBe(false); + }); + + it('builds literal-quoted FTS match expressions', () => { + expect(buildFtsMatchExpression('hello world')).toBe('"hello" "world"'); + expect(buildFtsMatchExpression('say "hi"')).toBe('"say" """hi"""'); + expect(buildFtsMatchExpression(' ')).toBeNull(); + }); +}); + +// ── Route-level tests ──────────────────────────────────────────────────────── + +describe('Native Copilot session routes', () => { + let server: ExecutionServer | undefined; + let dataDir: string; + let workspaceDir: string; + let fixtureDir: string; + let dbPath: string; + const wsId = 'native-sessions-ws'; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-sessions-data-')); + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-sessions-repo-')); + fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-sessions-db-')); + dbPath = path.join(fixtureDir, 'session-store.db'); + }); + + afterEach(async () => { + if (server) { + await server.close(); + server = undefined; + } + for (const dir of [dataDir, workspaceDir, fixtureDir]) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + async function startServer(options: { enabled?: boolean } = {}): Promise { + const store = new FileProcessStore({ dataDir }); + server = await createExecutionServer({ + port: 0, + host: 'localhost', + store, + dataDir, + fileConfig: { features: { nativeCopilotSessions: options.enabled ?? true } }, + nativeCopilotSessionDbPath: dbPath, + queue: { autoStart: false }, + }); + const res = await postJSON(`${server.url}/api/workspaces`, { + id: wsId, + name: 'Native Sessions Test Workspace', + rootPath: workspaceDir, + }); + expect(res.status).toBe(201); + return server; + } + + function listUrl(query = ''): string { + return `${server!.url}/api/workspaces/${encodeURIComponent(wsId)}/native-copilot-sessions${query}`; + } + + it('returns enabled:false with feature-disabled reason when the flag is off', async () => { + createFixtureDb(dbPath, [{ id: 's1', cwd: workspaceDir }]); + await startServer({ enabled: false }); + const res = await request(listUrl()); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toMatchObject({ enabled: false, reason: 'feature-disabled', items: [], total: 0 }); + + const detail = await request(`${listUrl()}/s1`); + expect(detail.status).toBe(200); + expect(JSON.parse(detail.body)).toMatchObject({ enabled: false, reason: 'feature-disabled' }); + }); + + it('returns a typed unavailable payload when the native DB is missing', async () => { + await startServer(); + const res = await request(listUrl()); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toMatchObject({ enabled: true, available: false, reason: 'db-missing', items: [] }); + }); + + it('returns a typed unavailable payload for an invalid native DB', async () => { + fs.writeFileSync(dbPath, 'garbage, not sqlite'); + await startServer(); + const res = await request(listUrl()); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toMatchObject({ enabled: true, available: false, reason: 'db-invalid' }); + }); + + it('lists only workspace-matching sessions sorted newest first with pagination metadata', async () => { + createFixtureDb( + dbPath, + [ + { id: 'mine-old', cwd: workspaceDir, branch: 'main', summary: 'First summary line\nmore', updatedAt: '2026-06-01T00:00:00.000Z' }, + { id: 'mine-new', cwd: path.join(workspaceDir, 'sub'), branch: 'feature', updatedAt: '2026-06-09T00:00:00.000Z' }, + { id: 'foreign', cwd: '/somewhere/else', repository: 'other/repo', updatedAt: '2026-06-10T00:00:00.000Z' }, + ], + [ + { sessionId: 'mine-old', turnIndex: 0, userMessage: 'searchable mermaid text' }, + { sessionId: 'mine-new', turnIndex: 0, userMessage: 'other text' }, + { sessionId: 'mine-new', turnIndex: 1, userMessage: 'more text' }, + ], + ); + await startServer(); + const res = await request(listUrl()); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.enabled).toBe(true); + expect(body.available).toBe(true); + expect(body.items.map((i: { id: string }) => i.id)).toEqual(['mine-new', 'mine-old']); + expect(body.items[0].turnCount).toBe(2); + expect(body.items[1].summaryPreview).toBe('First summary line'); + expect(body.total).toBe(2); + expect(body.limit).toBe(50); + expect(body.offset).toBe(0); + + const paged = await request(listUrl('?limit=1&offset=1')); + const pagedBody = JSON.parse(paged.body); + expect(pagedBody.items.map((i: { id: string }) => i.id)).toEqual(['mine-old']); + expect(pagedBody.total).toBe(2); + + const searched = await request(listUrl('?q=mermaid')); + const searchedBody = JSON.parse(searched.body); + expect(searchedBody.items.map((i: { id: string }) => i.id)).toEqual(['mine-old']); + expect(searchedBody.items[0].matchSnippets.length).toBeGreaterThan(0); + }); + + it('serves workspace-scoped session detail and 404s for foreign or unknown sessions', async () => { + createFixtureDb( + dbPath, + [ + { id: 'mine', cwd: workspaceDir, summary: 'Stored summary' }, + { id: 'foreign', cwd: '/somewhere/else' }, + ], + [ + { sessionId: 'mine', turnIndex: 0, userMessage: 'user text', assistantResponse: null }, + ], + ); + await startServer(); + + const ok = await request(`${listUrl()}/mine`); + expect(ok.status).toBe(200); + const okBody = JSON.parse(ok.body); + expect(okBody.session.id).toBe('mine'); + expect(okBody.session.turns).toHaveLength(1); + expect(okBody.session.turns[0].assistantResponse).toBe(''); + + const foreign = await request(`${listUrl()}/foreign`); + expect(foreign.status).toBe(404); + + const unknown = await request(`${listUrl()}/does-not-exist`); + expect(unknown.status).toBe(404); + }); + + it('returns 404 for an unknown workspace', async () => { + createFixtureDb(dbPath, [{ id: 's1', cwd: workspaceDir }]); + await startServer(); + const res = await request(`${server!.url}/api/workspaces/nope/native-copilot-sessions`); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx new file mode 100644 index 000000000..a8190c60d --- /dev/null +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx @@ -0,0 +1,234 @@ +/** + * @vitest-environment jsdom + * + * Tests for the read-only NativeCopilotSessionsPanel. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +const mockList = vi.fn(); +const mockGet = vi.fn(); + +vi.mock('../../../../src/server/spa/client/react/api/cocClient', () => ({ + getSpaCocClient: () => ({ + nativeCopilotSessions: { + list: mockList, + get: mockGet, + }, + }), +})); + +vi.mock('../../../../src/server/spa/client/react/ui', () => ({ + Spinner: () => , + Button: ({ children, loading: _loading, variant: _variant, size: _size, ...props }: any) => ( + + ), + cn: (...parts: unknown[]) => parts.filter(Boolean).join(' '), +})); + +import { NativeCopilotSessionsPanel } from '../../../../src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function setFlag(enabled: boolean): void { + (window as any).__DASHBOARD_CONFIG__ = { + apiBasePath: '/api', + wsPath: '/ws', + features: { nativeCopilotSessionsEnabled: enabled }, + }; +} + +function makeListItem(overrides: Partial> = {}) { + return { + id: 'session-aaaa-bbbb', + repository: 'owner/repo', + cwd: '/workspace/path', + hostType: 'github', + branch: 'main', + summaryPreview: 'Stored summary preview', + createdAt: '2026-06-11T17:56:21.130Z', + updatedAt: '2026-06-11T17:56:22.081Z', + turnCount: 3, + matchSnippets: [], + ...overrides, + }; +} + +function makeListResponse(items: unknown[], overrides: Partial> = {}) { + return { + enabled: true, + available: true, + items, + total: items.length, + searchIndexAvailable: true, + limit: 50, + offset: 0, + ...overrides, + }; +} + +function makeDetailResponse(overrides: Partial> = {}) { + return { + enabled: true, + available: true, + session: { + id: 'session-aaaa-bbbb', + repository: 'owner/repo', + cwd: '/workspace/path', + hostType: 'github', + branch: 'main', + summary: 'Full stored summary', + createdAt: '2026-06-11T17:56:21.130Z', + updatedAt: '2026-06-11T17:56:22.081Z', + turns: [ + { + id: 1, + turnIndex: 0, + timestamp: '2026-06-11T17:56:35.601Z', + userMessage: ' stored user text', + assistantResponse: '', + userChars: 40, + assistantChars: 0, + searchIndexSourceId: 'session-aaaa-bbbb:turn:0', + searchIndexChars: 40, + }, + { + id: 2, + turnIndex: 1, + timestamp: '2026-06-11T17:57:35.601Z', + userMessage: 'second question', + assistantResponse: 'second answer', + userChars: 15, + assistantChars: 13, + searchIndexSourceId: null, + searchIndexChars: null, + }, + ], + ...overrides, + }, + }; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('NativeCopilotSessionsPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + setFlag(true); + mockList.mockResolvedValue(makeListResponse([])); + mockGet.mockResolvedValue(makeDetailResponse()); + }); + + afterEach(() => { + cleanup(); + delete (window as any).__DASHBOARD_CONFIG__; + }); + + it('shows the disabled state without calling the API when the flag is off', async () => { + setFlag(false); + render(); + expect(screen.getByTestId('native-sessions-disabled-by-flag')).toBeTruthy(); + expect(mockList).not.toHaveBeenCalled(); + }); + + it('renders the empty state when no sessions match the workspace', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-empty')).toBeTruthy()); + }); + + it('renders a typed unavailable state when the native store is missing', async () => { + mockList.mockResolvedValue({ + enabled: true, available: false, reason: 'db-missing', items: [], total: 0, limit: 50, offset: 0, + }); + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-unavailable')).toBeTruthy()); + expect(screen.getByTestId('native-sessions-unavailable').textContent).toContain('not found'); + }); + + it('renders a request error with retry', async () => { + mockList.mockRejectedValueOnce(new Error('network down')); + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-error')).toBeTruthy()); + expect(screen.getByTestId('native-sessions-error').textContent).toContain('network down'); + }); + + it('renders session rows with external read-only labels', async () => { + mockList.mockResolvedValue(makeListResponse([ + makeListItem(), + makeListItem({ id: 'second-session', branch: null, summaryPreview: '' }), + ])); + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); + + const rows = screen.getAllByTestId('native-session-row'); + expect(rows).toHaveLength(2); + expect(screen.getAllByTestId('native-session-external-label').length).toBeGreaterThan(0); + expect(screen.getAllByTestId('native-session-readonly-badge').length).toBeGreaterThan(0); + // Null branch renders as Unknown branch; null summary renders empty preview copy. + expect(rows[1].textContent).toContain('Unknown branch'); + expect(rows[1].textContent).toContain('No summary stored'); + }); + + it('opens read-only detail with ordered turns, explicit empty assistant copy, and no CoC chat actions', async () => { + mockList.mockResolvedValue(makeListResponse([makeListItem()])); + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); + + fireEvent.click(screen.getAllByTestId('native-session-row')[0]); + await waitFor(() => expect(screen.getByTestId('native-session-detail')).toBeTruthy()); + expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-aaaa-bbbb'); + + const detail = screen.getByTestId('native-session-detail'); + expect(detail.textContent).toContain('Full stored summary'); + + const turns = screen.getAllByTestId('native-session-turn'); + expect(turns).toHaveLength(2); + expect(turns[0].textContent).toContain('Turn 0'); + expect(turns[1].textContent).toContain('Turn 1'); + expect(screen.getByTestId('native-session-turn-no-assistant').textContent).toContain('No assistant response stored'); + + // Stored script text renders as inert text, never as an executable element. + expect(turns[0].textContent).toContain(''); + expect(detail.querySelector('script')).toBeNull(); + + // Index diagnostics: indexed turn shows char count, unindexed shows Not indexed. + const diagnostics = screen.getAllByTestId('native-session-turn-index-diagnostics'); + expect(diagnostics[0].textContent).toContain('Indexed'); + expect(diagnostics[1].textContent).toContain('Not indexed'); + + // Read-only separation: no CoC chat action controls exist anywhere in the panel. + for (const action of ['follow-up', 'follow up', 'archive', 'pin', 'delete', 'resume', 'retry conversation']) { + const pattern = new RegExp(`^${action}$`, 'i'); + expect(screen.queryByRole('button', { name: pattern })).toBeNull(); + } + }); + + it('applies search filters through the typed client and surfaces match snippets', async () => { + mockList.mockResolvedValue(makeListResponse([makeListItem({ matchSnippets: ['matched mermaid snippet'] })])); + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); + + fireEvent.change(screen.getByTestId('native-sessions-search-input'), { target: { value: 'mermaid' } }); + fireEvent.click(screen.getByTestId('native-sessions-apply-filters')); + + await waitFor(() => { + expect(mockList).toHaveBeenLastCalledWith('ws-1', expect.objectContaining({ q: 'mermaid' })); + }); + await waitFor(() => expect(screen.getByTestId('native-session-match-snippets')).toBeTruthy()); + expect(screen.getByTestId('native-session-match-snippets').textContent).toContain('matched mermaid snippet'); + }); + + it('shows the unavailable-search hint when the native index is absent and a query is set', async () => { + mockList.mockResolvedValue(makeListResponse([], { searchIndexAvailable: false })); + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-empty')).toBeTruthy()); + + fireEvent.change(screen.getByTestId('native-sessions-search-input'), { target: { value: 'anything' } }); + fireEvent.click(screen.getByTestId('native-sessions-apply-filters')); + + await waitFor(() => expect(screen.getByTestId('native-sessions-search-unavailable')).toBeTruthy()); + }); +}); diff --git a/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx b/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx index 79e3128ce..e48920e58 100644 --- a/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx +++ b/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx @@ -133,7 +133,9 @@ vi.mock('../../../../../src/server/spa/client/react/utils/config', () => ({ isScratchpadEnabled: () => false, isWorkflowsEnabled: () => false, isPullRequestsEnabled: () => false, + isNativeCopilotSessionsEnabled: () => false, getScratchpadLayout: () => 'horizontal', + DASHBOARD_CONFIG_UPDATED_EVENT: 'coc-dashboard-config-updated', })); // Stub RepoChatTab — render a marker div that captures mode prop From 818ffdafb4c10325f7b4eb48c2091a45226055e2 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 00:18:54 +0000 Subject: [PATCH 02/37] Improve Copilot Sessions panel UI Fix the broken master-detail width (nested flex-1 + max-w-[46%] collapsed the list to ~23% leaving a large empty gap) by giving the list a clamped ~42% column and letting the detail fill the rest. Remove the redundant per-row 'Native Copilot CLI session' badge that caused horizontal column clipping, and replace the cramped 4-column table with a single-column list of session cards (ID chip, timestamp, summary, repo, turn/branch pills, selected-row accent bar). Polish the filter bar (full-width search with icon, focus rings) and the empty detail state (icon + centered copy). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../coc-knowledge/references/dashboard-spa.md | 2 +- .../NativeCopilotSessionsPanel.tsx | 117 +++++++++++------- 2 files changed, 73 insertions(+), 46 deletions(-) diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 926fb99b4..fc1481c05 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -569,7 +569,7 @@ The repo-scoped Dreams tab (`features/dreams/DreamsPanel.tsx`) is a dedicated re ## Copilot Sessions Tab -The repo-scoped `Copilot Sessions` tab (`features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx`) is a read-only view of native GitHub Copilot CLI sessions for the active workspace, gated by `features.nativeCopilotSessions` / `nativeCopilotSessionsEnabled` (disabled by default; `useNativeCopilotSessionsEnabled()` tracks live runtime-config updates). It reads through `coc-client`'s `nativeCopilotSessions` domain, renders a two-pane layout on wide screens (searchable session table left, selected-session detail right) and stacked single-pane navigation on narrow screens, and supports text query (native `search_index` snippets), session-ID, branch, and date-range filters sorted newest `updated_at` first with pagination. Every list row and the detail header carry a `Native Copilot CLI session` label plus a read-only badge whose tooltip/helper explains the data comes from the local native store (`~/.copilot/session-store.db`) and cannot be modified from CoC. The panel renders distinct disabled/unavailable (`db-missing`/`db-invalid`)/loading/empty/error states, shows turns ordered by index with `No assistant response stored` for empty assistant turns and `Indexed (N chars)`/`Not indexed` search-index diagnostics, renders all stored text as plain pre-wrapped text (stored HTML/scripts never execute), and intentionally exposes no CoC chat actions (no follow-up, archive, pin, delete, resume, retry, or turn actions). +The repo-scoped `Copilot Sessions` tab (`features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx`) is a read-only view of native GitHub Copilot CLI sessions for the active workspace, gated by `features.nativeCopilotSessions` / `nativeCopilotSessionsEnabled` (disabled by default; `useNativeCopilotSessionsEnabled()` tracks live runtime-config updates). It reads through `coc-client`'s `nativeCopilotSessions` domain, renders a two-pane layout on wide screens (searchable session list left at a clamped ~42% width, selected-session detail right) and stacked single-pane navigation on narrow screens, and supports text query (native `search_index` snippets), session-ID, branch, and date-range filters sorted newest `updated_at` first with pagination. Each list row shows a short session-ID chip, updated timestamp, two-line summary preview, repository/cwd, and right-aligned turn-count and branch pills; the selected row gets a left accent bar. The list header and the detail header carry a `Native Copilot CLI session` label plus a read-only badge whose tooltip/helper explains the data comes from the local native store (`~/.copilot/session-store.db`) and cannot be modified from CoC. The panel renders distinct disabled/unavailable (`db-missing`/`db-invalid`)/loading/empty/error states, shows turns ordered by index with `No assistant response stored` for empty assistant turns and `Indexed (N chars)`/`Not indexed` search-index diagnostics, renders all stored text as plain pre-wrapped text (stored HTML/scripts never execute), and intentionally exposes no CoC chat actions (no follow-up, archive, pin, delete, resume, retry, or turn actions). ## Memory Route diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx index bdfce7c87..871f4722b 100644 --- a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx +++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx @@ -186,29 +186,36 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession }; const listPane = ( -
-
+
+
-

Copilot Sessions

+

Copilot Sessions

- {total} session{total === 1 ? '' : 's'} + + {total.toLocaleString()} session{total === 1 ? '' : 's'} +
-
- setFilterDraft(prev => ({ ...prev, q: e.target.value }))} - placeholder="Search indexed content…" - className="h-8 min-w-[160px] flex-1 rounded border border-[#d0d7de] px-2 text-sm" - data-testid="native-sessions-search-input" - /> + +
+ + setFilterDraft(prev => ({ ...prev, q: e.target.value }))} + placeholder="Search indexed content…" + className="h-8 w-full rounded-md border border-[#d0d7de] pl-7 pr-2 text-sm focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" + data-testid="native-sessions-search-input" + /> +
setFilterDraft(prev => ({ ...prev, sessionId: e.target.value }))} placeholder="Session ID" - className="h-8 w-32 rounded border border-[#d0d7de] px-2 text-sm" + className="h-8 w-32 rounded-md border border-[#d0d7de] px-2 text-sm focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" data-testid="native-sessions-session-id-input" /> setFilterDraft(prev => ({ ...prev, branch: e.target.value }))} placeholder="Branch" - className="h-8 w-28 rounded border border-[#d0d7de] px-2 text-sm" + className="h-8 w-28 rounded-md border border-[#d0d7de] px-2 text-sm focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" data-testid="native-sessions-branch-input" /> @@ -270,14 +277,6 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession )} {!listLoading && !listError && !unavailable && items.length > 0 && ( - - - - - - - - {items.map(item => ( {!selectedSessionId && ( -
- Select a native session to view its summary and turns. +
+
+ +
+

Select a native session to view its summary and turns.

)} {selectedSessionId && detailLoading && ( @@ -327,7 +331,10 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession return (
-
+
{listPane}
@@ -343,33 +350,53 @@ function SessionRow({ item, selected, onSelect }: { selected: boolean; onSelect: () => void; }) { + const location = item.repository || item.cwd || ''; return (
- - - - ); } From 493acffd806caa871c5c011d25b4083ce5f4f53e Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 00:23:24 +0000 Subject: [PATCH 03/37] Make Copilot Sessions panel more compact Tighten the panel density: reduce header/filter/row/detail padding and gaps, shrink control heights (h-8 -> h-7), drop font sizes to 10-13px, and shrink badges/labels and pre block max-heights so more sessions and turns fit on screen without horizontal or vertical waste. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NativeCopilotSessionsPanel.tsx | 104 +++++++++--------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx index 871f4722b..8388564ab 100644 --- a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx +++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx @@ -62,7 +62,7 @@ function ReadOnlyBadge() { Read-only @@ -74,7 +74,7 @@ function ExternalLabel() { Native Copilot CLI session @@ -187,17 +187,17 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession const listPane = (
-
-
-

Copilot Sessions

+
+
+

Copilot Sessions

- + {total.toLocaleString()} session{total === 1 ? '' : 's'}
- -
+ +
@@ -206,7 +206,7 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession value={filterDraft.q} onChange={e => setFilterDraft(prev => ({ ...prev, q: e.target.value }))} placeholder="Search indexed content…" - className="h-8 w-full rounded-md border border-[#d0d7de] pl-7 pr-2 text-sm focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" + className="h-7 w-full rounded-md border border-[#d0d7de] pl-7 pr-2 text-[13px] focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" data-testid="native-sessions-search-input" />
@@ -215,7 +215,7 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession value={filterDraft.sessionId} onChange={e => setFilterDraft(prev => ({ ...prev, sessionId: e.target.value }))} placeholder="Session ID" - className="h-8 w-32 rounded-md border border-[#d0d7de] px-2 text-sm focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" + className="h-7 w-28 rounded-md border border-[#d0d7de] px-2 text-[13px] focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" data-testid="native-sessions-session-id-input" /> setFilterDraft(prev => ({ ...prev, branch: e.target.value }))} placeholder="Branch" - className="h-8 w-28 rounded-md border border-[#d0d7de] px-2 text-sm focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" + className="h-7 w-24 rounded-md border border-[#d0d7de] px-2 text-[13px] focus:border-[#0969da] focus:outline-none focus:ring-1 focus:ring-[#0969da]" data-testid="native-sessions-branch-input" /> -
@@ -326,7 +356,7 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession )} {selectedSessionId && !detailLoading && !detailError && detail && ( - setSelectedSessionId(null)} /> + selectSession(null)} /> )} ); diff --git a/packages/coc/src/server/spa/client/react/layout/Router.tsx b/packages/coc/src/server/spa/client/react/layout/Router.tsx index d2175af1a..34ed7c424 100644 --- a/packages/coc/src/server/spa/client/react/layout/Router.tsx +++ b/packages/coc/src/server/spa/client/react/layout/Router.tsx @@ -306,6 +306,36 @@ export function buildNoteHash(wsId: string, notePath: string): string { return '#repos/' + encodeURIComponent(wsId) + '/notes/' + encodedPath; } +/** + * Parse a native Copilot Sessions deep-link: + * `#repos/{wsId}/copilot-sessions` + * `#repos/{wsId}/copilot-sessions/{sessionId}` + * + * Returns `{ workspaceId, sessionId }` (sessionId null when only the tab is + * addressed) when the hash targets the Copilot Sessions tab, `null` otherwise. + */ +export function parseNativeCopilotSessionDeepLink( + hash: string, +): { workspaceId: string; sessionId: string | null } | null { + const cleaned = hash.replace(/^#/, ''); + const parts = cleaned.split('/'); + if (parts[0] === 'repos' && parts[1] && parts[2] === 'copilot-sessions') { + return { + workspaceId: decodeURIComponent(parts[1]), + sessionId: parts[3] ? decodeURIComponent(parts[3]) : null, + }; + } + return null; +} + +/** + * Build a Copilot Sessions hash. Omitting `sessionId` addresses the bare tab. + */ +export function buildNativeCopilotSessionHash(wsId: string, sessionId?: string | null): string { + const base = '#repos/' + encodeURIComponent(wsId) + '/copilot-sessions'; + return sessionId ? base + '/' + encodeURIComponent(sessionId) : base; +} + export function buildRepoSubTabSuffix( tab: RepoSubTab, state: AppContextState, diff --git a/packages/coc/test/server/native-copilot-sessions.test.ts b/packages/coc/test/server/native-copilot-sessions.test.ts index 58405eb19..f8c74b50f 100644 --- a/packages/coc/test/server/native-copilot-sessions.test.ts +++ b/packages/coc/test/server/native-copilot-sessions.test.ts @@ -281,6 +281,70 @@ describe('NativeCopilotSessionService', () => { } }); + it('hides background-job sessions (title summarization) and reports backgroundJobCount', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb( + dbPath, + [ + { id: 'real', cwd: wsRoot, updatedAt: '2026-06-03T00:00:00.000Z' }, + { id: 'title-job', cwd: wsRoot, updatedAt: '2026-06-02T00:00:00.000Z' }, + ], + [ + { sessionId: 'real', turnIndex: 0, userMessage: 'can you fix the bug in the parser?' }, + { sessionId: 'title-job', turnIndex: 0, userMessage: 'Summarise the following conversation as a short title (max 8 words)\n\nhello' }, + ], + ); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.listSessions({ rootPath: wsRoot }); + expect(result.available).toBe(true); + if (result.available) { + expect(result.items.map(i => i.id)).toEqual(['real']); + expect(result.total).toBe(1); + expect(result.backgroundJobCount).toBe(1); + } + }); + + it('includes background-job sessions when includeBackgroundJobs is set', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb( + dbPath, + [ + { id: 'real', cwd: wsRoot, updatedAt: '2026-06-03T00:00:00.000Z' }, + { id: 'title-job', cwd: wsRoot, updatedAt: '2026-06-02T00:00:00.000Z' }, + ], + [ + { sessionId: 'real', turnIndex: 0, userMessage: 'normal prompt' }, + { sessionId: 'title-job', turnIndex: 0, userMessage: 'Summarise the following conversation as a short title (max 8 words)' }, + ], + ); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.listSessions({ rootPath: wsRoot }, { includeBackgroundJobs: true }); + expect(result.available).toBe(true); + if (result.available) { + expect(result.items.map(i => i.id)).toEqual(['real', 'title-job']); + expect(result.backgroundJobCount).toBe(0); + } + }); + + it('only treats the first turn as a background-job signal', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb( + dbPath, + [{ id: 'real', cwd: wsRoot }], + [ + { sessionId: 'real', turnIndex: 0, userMessage: 'real first prompt' }, + { sessionId: 'real', turnIndex: 1, userMessage: 'Summarise the following conversation as a short title' }, + ], + ); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.listSessions({ rootPath: wsRoot }); + expect(result.available).toBe(true); + if (result.available) { + expect(result.items.map(i => i.id)).toEqual(['real']); + expect(result.backgroundJobCount).toBe(0); + } + }); + it('finds text hits through search_index with snippets, and supports combined filters', () => { const wsRoot = path.join(tmpDir, 'ws'); createFixtureDb( @@ -585,6 +649,27 @@ describe('Native Copilot session routes', () => { const res = await request(`${server!.url}/api/workspaces/nope/native-copilot-sessions`); expect(res.status).toBe(404); }); + + it('hides background-job sessions and reports backgroundJobCount over HTTP', async () => { + createFixtureDb( + dbPath, + [ + { id: 'real', cwd: workspaceDir, updatedAt: '2026-06-03T00:00:00.000Z' }, + { id: 'title-job', cwd: workspaceDir, updatedAt: '2026-06-02T00:00:00.000Z' }, + ], + [ + { sessionId: 'real', turnIndex: 0, userMessage: 'actual user request' }, + { sessionId: 'title-job', turnIndex: 0, userMessage: 'Summarise the following conversation as a short title (max 8 words)' }, + ], + ); + await startServer(); + const res = await request(listUrl()); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.items.map((i: { id: string }) => i.id)).toEqual(['real']); + expect(body.total).toBe(1); + expect(body.backgroundJobCount).toBe(1); + }); }); describe('Native Copilot session routes — dedup against CoC processes', () => { diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx index d71705e80..f4899496c 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx @@ -118,12 +118,14 @@ describe('NativeCopilotSessionsPanel', () => { beforeEach(() => { vi.clearAllMocks(); setFlag(true); + window.location.hash = ''; mockList.mockResolvedValue(makeListResponse([])); mockGet.mockResolvedValue(makeDetailResponse()); }); afterEach(() => { cleanup(); + window.location.hash = ''; delete (window as any).__DASHBOARD_CONFIG__; }); @@ -245,4 +247,38 @@ describe('NativeCopilotSessionsPanel', () => { await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); expect(screen.queryByTestId('native-sessions-deduplicated')).toBeNull(); }); + + it('shows the background-hidden hint when background jobs are filtered', async () => { + mockList.mockResolvedValue(makeListResponse([makeListItem()], { backgroundJobCount: 5 })); + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-background-hidden')).toBeTruthy()); + expect(screen.getByTestId('native-sessions-background-hidden').textContent).toContain('5 background jobs hidden'); + }); + + it('writes the deep-link hash when a session is selected', async () => { + mockList.mockResolvedValue(makeListResponse([makeListItem()])); + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); + + fireEvent.click(screen.getAllByTestId('native-session-row')[0]); + await waitFor(() => expect(window.location.hash).toBe('#repos/ws-1/copilot-sessions/session-aaaa-bbbb')); + await waitFor(() => expect(screen.getByTestId('native-session-detail')).toBeTruthy()); + }); + + it('restores the selected session from a deep-link hash on mount', async () => { + mockList.mockResolvedValue(makeListResponse([makeListItem()])); + window.location.hash = '#repos/ws-1/copilot-sessions/session-aaaa-bbbb'; + render(); + await waitFor(() => expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-aaaa-bbbb')); + await waitFor(() => expect(screen.getByTestId('native-session-detail')).toBeTruthy()); + }); + + it('ignores a deep-link hash that targets a different workspace', async () => { + mockList.mockResolvedValue(makeListResponse([makeListItem()])); + window.location.hash = '#repos/other-ws/copilot-sessions/session-aaaa-bbbb'; + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); + expect(mockGet).not.toHaveBeenCalled(); + expect(screen.getByTestId('native-session-detail-empty')).toBeTruthy(); + }); }); diff --git a/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts b/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts new file mode 100644 index 000000000..45b6dddf5 --- /dev/null +++ b/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { + parseNativeCopilotSessionDeepLink, + buildNativeCopilotSessionHash, +} from '../../../../src/server/spa/client/react/layout/Router'; + +describe('parseNativeCopilotSessionDeepLink', () => { + it('returns null for hashes outside the copilot-sessions tab', () => { + expect(parseNativeCopilotSessionDeepLink('#repos/ws-1/notes')).toBeNull(); + expect(parseNativeCopilotSessionDeepLink('#repos/ws-1/activity/p-1')).toBeNull(); + expect(parseNativeCopilotSessionDeepLink('#processes/p-1')).toBeNull(); + expect(parseNativeCopilotSessionDeepLink('#repos')).toBeNull(); + }); + + it('parses the bare tab with a null sessionId', () => { + expect(parseNativeCopilotSessionDeepLink('#repos/ws-1/copilot-sessions')).toEqual({ + workspaceId: 'ws-1', + sessionId: null, + }); + }); + + it('parses a session deep-link', () => { + expect(parseNativeCopilotSessionDeepLink('#repos/ws-1/copilot-sessions/sess-abc')).toEqual({ + workspaceId: 'ws-1', + sessionId: 'sess-abc', + }); + }); + + it('decodes URI-encoded workspace and session segments', () => { + expect(parseNativeCopilotSessionDeepLink('#repos/ws%201/copilot-sessions/sess%2Fx')).toEqual({ + workspaceId: 'ws 1', + sessionId: 'sess/x', + }); + }); +}); + +describe('buildNativeCopilotSessionHash', () => { + it('builds the bare tab hash when sessionId is omitted or null', () => { + expect(buildNativeCopilotSessionHash('ws-1')).toBe('#repos/ws-1/copilot-sessions'); + expect(buildNativeCopilotSessionHash('ws-1', null)).toBe('#repos/ws-1/copilot-sessions'); + }); + + it('builds a session hash with encoded segments', () => { + expect(buildNativeCopilotSessionHash('ws 1', 'sess/x')).toBe('#repos/ws%201/copilot-sessions/sess%2Fx'); + }); + + it('round-trips through the parser', () => { + const hash = buildNativeCopilotSessionHash('ws-1', 'sess-abc'); + expect(parseNativeCopilotSessionDeepLink(hash)).toEqual({ workspaceId: 'ws-1', sessionId: 'sess-abc' }); + }); +}); diff --git a/packages/coc/test/spa/react/Router.test.ts b/packages/coc/test/spa/react/Router.test.ts index 807f30f7d..8862d76f1 100644 --- a/packages/coc/test/spa/react/Router.test.ts +++ b/packages/coc/test/spa/react/Router.test.ts @@ -140,8 +140,12 @@ describe('VALID_REPO_SUB_TABS', () => { expect(VALID_REPO_SUB_TABS.has('pull-requests')).toBe(true); }); - it('has exactly 16 entries', () => { - expect(VALID_REPO_SUB_TABS.size).toBe(16); + it('includes "copilot-sessions"', () => { + expect(VALID_REPO_SUB_TABS.has('copilot-sessions')).toBe(true); + }); + + it('has exactly 17 entries', () => { + expect(VALID_REPO_SUB_TABS.size).toBe(17); }); }); From beebde0ecf4879ba9abbf8c25494bf5880f62a86 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 06:55:32 +0000 Subject: [PATCH 06/37] feat(coc): add compact LLM tool parameter summarization util + contract types Foundation for the LLM Tools settings page showing each tool's input parameters compactly (AC-01/AC-02 format). - Add additive `LlmToolParam` type and optional `params?` field to `LlmToolMeta` in both the server registry and the coc-client contract. Existing clients reading only name/label/description/enabledByDefault are unaffected. - Add pure `summarizeToolParameters()` / `compactParamType()` helpers that compress a tool's JSON-schema `parameters` into `{name,type,required}` entries: primitives keep their type, nested objects -> `{...}`, arrays -> `[...]`, typeless enums -> `enum`. Returns `[]` for a no-parameter schema and `undefined` when no JSON schema is available, so callers can render "No parameters" vs "Parameters unavailable". - Unit tests covering type compaction, required/optional flags, ordering, empty-schema, and unavailable-schema cases. No tool behavior, routing, or persisted preference changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../coc-client/src/contracts/preferences.ts | 25 ++++ packages/coc/src/server/llm-tools/index.ts | 2 + .../server/llm-tools/llm-tool-parameters.ts | 88 ++++++++++++++ .../src/server/llm-tools/llm-tool-registry.ts | 26 ++++ .../test/server/llm-tool-parameters.test.ts | 114 ++++++++++++++++++ 5 files changed, 255 insertions(+) create mode 100644 packages/coc/src/server/llm-tools/llm-tool-parameters.ts create mode 100644 packages/coc/test/server/llm-tool-parameters.test.ts diff --git a/packages/coc-client/src/contracts/preferences.ts b/packages/coc-client/src/contracts/preferences.ts index d1905be16..c0f3ee653 100644 --- a/packages/coc-client/src/contracts/preferences.ts +++ b/packages/coc-client/src/contracts/preferences.ts @@ -96,11 +96,36 @@ export interface TaskSettingsUpdate { folderPaths: string[]; } +/** + * Compact, display-only description of a single LLM tool input parameter. + * Derived from a tool's JSON-schema `parameters` for the settings UI; it never + * affects tool execution or persisted preferences. + */ +export interface LlmToolParam { + /** Parameter name as declared in the tool input schema. */ + name: string; + /** + * Compact type label: a JSON-schema primitive (`string`, `number`, + * `boolean`, `integer`), `{...}` for nested objects, `[...]` for arrays, + * `enum` for typeless enums, or `any` when the type cannot be determined. + */ + type: string; + /** Whether the parameter is required by the tool's input schema. */ + required: boolean; +} + export interface LlmToolMeta { name: string; label: string; description: string; enabledByDefault: boolean; + /** + * Optional, additive compact parameter summary derived from the tool's input + * schema. Absent when no JSON-schema is available (render as "parameters + * unavailable"); an empty array means the tool takes no parameters. Existing + * clients that only read name/label/description/enabledByDefault ignore this. + */ + params?: LlmToolParam[]; } export interface LlmToolsConfig { diff --git a/packages/coc/src/server/llm-tools/index.ts b/packages/coc/src/server/llm-tools/index.ts index c3adb3b7a..344d1fef7 100644 --- a/packages/coc/src/server/llm-tools/index.ts +++ b/packages/coc/src/server/llm-tools/index.ts @@ -44,7 +44,9 @@ export { isLlmToolEnabled, filterDisabledLlmTools, type LlmToolMeta, + type LlmToolParam, } from './llm-tool-registry'; +export { summarizeToolParameters, compactParamType } from './llm-tool-parameters'; export { createCreateLoopTool, createCancelLoopTool, diff --git a/packages/coc/src/server/llm-tools/llm-tool-parameters.ts b/packages/coc/src/server/llm-tools/llm-tool-parameters.ts new file mode 100644 index 000000000..6ca919a42 --- /dev/null +++ b/packages/coc/src/server/llm-tools/llm-tool-parameters.ts @@ -0,0 +1,88 @@ +/** + * LLM tool parameter summarization + * + * Pure, display-only helpers that compress a tool's JSON-schema `parameters` + * into a compact, scannable list for the workspace LLM tools settings UI. + * + * This module intentionally has no runtime dependencies and never affects tool + * execution, validation, or persisted preferences — it only derives additive + * display metadata from the schemas tools already declare via `defineTool()`. + */ + +import type { LlmToolParam } from './llm-tool-registry'; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Normalize a JSON-schema `type` field to a single primitive type name. + * Handles union types (`['string', 'null']`) by picking the first non-null + * concrete type. Returns `undefined` when no usable type string is present. + */ +function normalizeType(type: unknown): string | undefined { + if (typeof type === 'string') return type; + if (Array.isArray(type)) { + const first = type.find((t) => typeof t === 'string' && t !== 'null'); + return typeof first === 'string' ? first : undefined; + } + return undefined; +} + +/** + * Compress a single JSON-schema property into a compact type label. + * + * - objects (explicit `type: 'object'` or a `properties` map) → `{...}` + * - arrays (explicit `type: 'array'` or an `items` shape) → `[...]` + * - primitives → their JSON-schema type name (`string`, `number`, …) + * - typeless enums → `enum` + * - anything indeterminate → `any` + */ +export function compactParamType(prop: unknown): string { + if (!isPlainObject(prop)) return 'any'; + const type = normalizeType(prop.type); + if (type === 'object') return '{...}'; + if (type === 'array') return '[...]'; + if (type) return type; + // No explicit type — infer a concise shape from the schema structure. + if (isPlainObject(prop.properties)) return '{...}'; + if (prop.items !== undefined) return '[...]'; + if (Array.isArray(prop.enum)) return 'enum'; + return 'any'; +} + +/** + * Derive a compact, display-only parameter summary from a tool's `parameters` + * JSON schema. + * + * Returns: + * - an array (possibly empty) when `schema` is a usable JSON-schema object; + * an empty array means the tool declares no parameters. + * - `undefined` when no JSON-schema object is available — e.g. a Zod schema, + * a non-object schema, or no schema at all — so callers can distinguish + * "no parameters" (empty array) from "parameters unavailable" (undefined). + * + * Parameters preserve the declaration order of the schema's `properties`. + */ +export function summarizeToolParameters(schema: unknown): LlmToolParam[] | undefined { + if (!isPlainObject(schema)) return undefined; + + const type = normalizeType(schema.type); + const properties = schema.properties; + const isObjectSchema = type === 'object' || isPlainObject(properties); + if (!isObjectSchema) return undefined; + if (!isPlainObject(properties)) return []; + + const requiredList = schema.required; + const required = new Set( + Array.isArray(requiredList) + ? requiredList.filter((n): n is string => typeof n === 'string') + : [], + ); + + return Object.entries(properties).map(([name, prop]) => ({ + name, + type: compactParamType(prop), + required: required.has(name), + })); +} diff --git a/packages/coc/src/server/llm-tools/llm-tool-registry.ts b/packages/coc/src/server/llm-tools/llm-tool-registry.ts index 54165e495..caa5d4f50 100644 --- a/packages/coc/src/server/llm-tools/llm-tool-registry.ts +++ b/packages/coc/src/server/llm-tools/llm-tool-registry.ts @@ -10,6 +10,25 @@ * be toggled by the user. */ +/** + * Compact, display-only description of a single LLM tool input parameter. + * + * Derived from a tool's JSON-schema `parameters` purely for the settings UI; + * it never affects tool execution, validation, or persisted preferences. + */ +export interface LlmToolParam { + /** Parameter name as declared in the tool input schema. */ + name: string; + /** + * Compact type label: a JSON-schema primitive (`string`, `number`, + * `boolean`, `integer`), `{...}` for nested objects, `[...]` for arrays, + * `enum` for typeless enums, or `any` when the type cannot be determined. + */ + type: string; + /** Whether the parameter is required by the tool's input schema. */ + required: boolean; +} + export interface LlmToolMeta { /** Tool name as registered with `defineTool()` (matches the AI-facing name). */ name: string; @@ -19,6 +38,13 @@ export interface LlmToolMeta { description: string; /** Whether this tool is enabled by default when no explicit preference exists. */ enabledByDefault: boolean; + /** + * Optional, additive compact parameter summary derived from the tool's + * input schema for display in the settings UI. Absent when no JSON-schema + * is available (render as "parameters unavailable"); an empty array means + * the tool takes no parameters. Existing clients can ignore this field. + */ + params?: LlmToolParam[]; } /** diff --git a/packages/coc/test/server/llm-tool-parameters.test.ts b/packages/coc/test/server/llm-tool-parameters.test.ts new file mode 100644 index 000000000..ae53b7fc5 --- /dev/null +++ b/packages/coc/test/server/llm-tool-parameters.test.ts @@ -0,0 +1,114 @@ +/** + * Unit tests for LLM tool parameter summarization. + * + * Covers the pure JSON-schema → compact display-metadata derivation used by the + * workspace LLM tools settings surface (AC-01 / AC-02 compact format). + */ + +import { describe, it, expect } from 'vitest'; +import { + summarizeToolParameters, + compactParamType, +} from '../../src/server/llm-tools/llm-tool-parameters'; + +describe('compactParamType', () => { + it('returns the primitive type name for primitives', () => { + expect(compactParamType({ type: 'string' })).toBe('string'); + expect(compactParamType({ type: 'number' })).toBe('number'); + expect(compactParamType({ type: 'boolean' })).toBe('boolean'); + expect(compactParamType({ type: 'integer' })).toBe('integer'); + }); + + it('collapses nested objects to {...}', () => { + expect(compactParamType({ type: 'object', properties: { a: { type: 'string' } } })).toBe('{...}'); + // Inferred object shape with no explicit type. + expect(compactParamType({ properties: { a: { type: 'string' } } })).toBe('{...}'); + }); + + it('collapses arrays to [...]', () => { + expect(compactParamType({ type: 'array', items: { type: 'string' } })).toBe('[...]'); + expect(compactParamType({ type: 'array', items: { type: 'object', properties: {} } })).toBe('[...]'); + // Inferred array shape with no explicit type. + expect(compactParamType({ items: { type: 'string' } })).toBe('[...]'); + }); + + it('picks the first non-null type from a union', () => { + expect(compactParamType({ type: ['string', 'null'] })).toBe('string'); + expect(compactParamType({ type: ['null', 'number'] })).toBe('number'); + }); + + it('labels typeless enums as enum but keeps the explicit type when present', () => { + expect(compactParamType({ enum: ['a', 'b'] })).toBe('enum'); + expect(compactParamType({ type: 'string', enum: ['a', 'b'] })).toBe('string'); + }); + + it('falls back to any for indeterminate shapes', () => { + expect(compactParamType({ description: 'no type here' })).toBe('any'); + expect(compactParamType({})).toBe('any'); + expect(compactParamType('nope')).toBe('any'); + expect(compactParamType(null)).toBe('any'); + }); +}); + +describe('summarizeToolParameters', () => { + it('summarizes a typical tool schema with required and optional params, preserving order', () => { + const schema = { + type: 'object', + properties: { + processId: { type: 'string', description: 'id' }, + maxChars: { type: 'number', description: 'cap' }, + includeToolCalls: { type: 'boolean' }, + fromTurn: { type: 'number' }, + }, + required: ['processId'], + }; + expect(summarizeToolParameters(schema)).toEqual([ + { name: 'processId', type: 'string', required: true }, + { name: 'maxChars', type: 'number', required: false }, + { name: 'includeToolCalls', type: 'boolean', required: false }, + { name: 'fromTurn', type: 'number', required: false }, + ]); + }); + + it('renders nested objects and arrays compactly', () => { + const schema = { + type: 'object', + properties: { + questions: { type: 'array', items: { type: 'object', properties: {} } }, + tags: { type: 'array', items: { type: 'string' } }, + config: { type: 'object', properties: { a: { type: 'string' } } }, + }, + required: ['questions'], + }; + expect(summarizeToolParameters(schema)).toEqual([ + { name: 'questions', type: '[...]', required: true }, + { name: 'tags', type: '[...]', required: false }, + { name: 'config', type: '{...}', required: false }, + ]); + }); + + it('returns an empty array for an object schema with no properties (no parameters)', () => { + expect(summarizeToolParameters({ type: 'object', properties: {} })).toEqual([]); + expect(summarizeToolParameters({ type: 'object' })).toEqual([]); + }); + + it('returns undefined when no JSON-schema object is available (parameters unavailable)', () => { + expect(summarizeToolParameters(undefined)).toBeUndefined(); + expect(summarizeToolParameters(null)).toBeUndefined(); + expect(summarizeToolParameters('string-schema')).toBeUndefined(); + // A Zod-like schema instance exposes neither `type: 'object'` nor a + // plain `properties` map, so it is treated as unavailable. + expect(summarizeToolParameters({ _def: { typeName: 'ZodObject' }, shape: {} })).toBeUndefined(); + }); + + it('ignores non-string entries in the required list', () => { + const schema = { + type: 'object', + properties: { a: { type: 'string' } }, + required: ['a', 123, null], + }; + expect(summarizeToolParameters(schema)).toEqual([ + { name: 'a', type: 'string', required: true }, + ]); + }); +}); From 8187894e5559f771bd2be373cc809c7a1a7a331e Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 07:06:38 +0000 Subject: [PATCH 07/37] feat(coc): wire compact LLM tool param metadata into workspace config API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source each toggleable tool's input-schema structure via a display-only mirror (`LLM_TOOL_PARAMETER_SCHEMAS`) and attach the additive `params` summary to GET/PUT `/api/workspaces/:id/llm-tools-config` responses through `withToolParameterMetadata`. The mirror avoids instantiating tool factories at route time (some build heavyweight deps like FileWorkItemStore) and is display-only — it never affects tool execution, validation, provider routing, or persisted prefs. Tools without a locally-declared schema (e.g. the built-in `memory` tool) omit `params` so clients can render "parameters unavailable". Existing `name`/`label`/`description`/`enabledByDefault` fields are unchanged. A drift-guard test compares the mirror's summary against each live tool schema for every cheaply-constructible factory, plus a completeness check that every registry tool is either mirrored or explicitly excluded. Completes AC-01. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/coc/src/server/llm-tools/index.ts | 1 + .../llm-tools/llm-tool-parameter-schemas.ts | 183 ++++++++++++++++++ .../src/server/routes/api-workspace-routes.ts | 5 +- .../server/llm-tool-parameter-schemas.test.ts | 125 ++++++++++++ .../test/server/llm-tools-config-api.test.ts | 50 +++++ 5 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts create mode 100644 packages/coc/test/server/llm-tool-parameter-schemas.test.ts diff --git a/packages/coc/src/server/llm-tools/index.ts b/packages/coc/src/server/llm-tools/index.ts index 344d1fef7..479a494c1 100644 --- a/packages/coc/src/server/llm-tools/index.ts +++ b/packages/coc/src/server/llm-tools/index.ts @@ -47,6 +47,7 @@ export { type LlmToolParam, } from './llm-tool-registry'; export { summarizeToolParameters, compactParamType } from './llm-tool-parameters'; +export { LLM_TOOL_PARAMETER_SCHEMAS, withToolParameterMetadata } from './llm-tool-parameter-schemas'; export { createCreateLoopTool, createCancelLoopTool, diff --git a/packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts b/packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts new file mode 100644 index 000000000..ee144c0c0 --- /dev/null +++ b/packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts @@ -0,0 +1,183 @@ +/** + * LLM tool parameter schemas (display-only mirror) + * + * A compact, structural mirror of each toggleable LLM tool's input-schema + * `parameters`, used purely to derive the additive `params` display metadata + * surfaced on the workspace LLM Tools settings page. + * + * Why a mirror instead of importing the live schemas? + * - The real schemas are declared inline inside each `defineTool()` call, and + * several factories build heavyweight dependencies at construction time + * (e.g. `create_update_work_item` instantiates a `FileWorkItemStore`). The + * settings route must NOT instantiate tools just to read a schema. + * - This module is display-only: it never affects tool execution, validation, + * provider routing, or persisted preferences (per the feature's scope). + * + * Only the structure the compact summary cares about is mirrored — property + * names, JSON-schema `type`, and the `required` list. Descriptions, defaults, + * enums, and bounds are intentionally omitted because `summarizeToolParameters` + * ignores them. + * + * KEEP IN SYNC: when a tool's `parameters` gains/loses a property or changes a + * property's type or required-ness, update the matching entry here. The + * `llm-tool-parameter-schemas` drift-guard test compares the summaries derived + * here against the live tool schemas for every tool that is cheap to construct. + * + * Tools with no entry here (e.g. the built-in `memory` tool, whose schema is + * not declared locally) intentionally render as "parameters unavailable". + */ + +import type { LlmToolMeta } from './llm-tool-registry'; +import { summarizeToolParameters } from './llm-tool-parameters'; + +/** + * Mirror of each tool's input-schema structure, keyed by the tool name as + * registered in {@link LLM_TOOL_REGISTRY}. + */ +export const LLM_TOOL_PARAMETER_SCHEMAS: Record> = { + suggest_follow_ups: { + type: 'object', + properties: { + suggestions: { type: 'array', items: { type: 'string' } }, + }, + required: ['suggestions'], + }, + search_conversations: { + type: 'object', + properties: { + query: { type: 'string' }, + workspaceId: { type: 'string' }, + since: { type: 'string' }, + until: { type: 'string' }, + limit: { type: 'number' }, + offset: { type: 'number' }, + summarize: { type: 'boolean' }, + }, + required: [], + }, + get_conversation: { + type: 'object', + properties: { + processId: { type: 'string' }, + maxChars: { type: 'number' }, + includeToolCalls: { type: 'boolean' }, + fromTurn: { type: 'number' }, + toTurn: { type: 'number' }, + }, + required: ['processId'], + }, + ask_user: { + type: 'object', + properties: { + questions: { type: 'array', items: { type: 'object' } }, + }, + required: ['questions'], + }, + get_work_item: { + type: 'object', + properties: { + workItemId: { type: 'string' }, + target: { type: 'string' }, + workItemNumber: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + }, + required: [], + }, + create_update_work_item: { + type: 'object', + properties: { + workItemId: { type: 'string' }, + target: { type: 'string' }, + workItemNumber: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + type: { type: 'string' }, + title: { type: 'string' }, + description: { type: 'string' }, + priority: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + plan: { type: 'string' }, + summary: { type: 'string' }, + parentId: { oneOf: [{ type: 'string' }, { type: 'null' }] }, + parentTarget: { type: 'string' }, + parentWorkItemNumber: { oneOf: [{ type: 'number' }, { type: 'string' }] }, + }, + required: [], + }, + save_memory: { + type: 'object', + properties: { + content: { type: 'string' }, + importance: { type: 'number' }, + tags: { type: 'array', items: { type: 'string' } }, + target: { type: 'string' }, + }, + required: ['content'], + }, + recall_memory: { + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'number' }, + }, + required: ['query'], + }, + scheduleWakeup: { + type: 'object', + properties: { + prompt: { type: 'string' }, + delay: { type: ['string', 'number'] }, + model: { type: 'string' }, + }, + required: ['prompt', 'delay'], + }, + create_or_update_excalidraw: { + type: 'object', + properties: { + filename: { type: 'string' }, + content: { type: 'object' }, + }, + required: ['filename', 'content'], + }, + read_excalidraw: { + type: 'object', + properties: { + filename: { type: 'string' }, + }, + required: ['filename'], + }, + tavily_web_search: { + type: 'object', + properties: { + query: { type: 'string' }, + searchDepth: { type: 'string' }, + topic: { type: 'string' }, + maxResults: { type: 'number' }, + includeAnswer: { type: 'boolean' }, + includeRawContent: { type: 'boolean' }, + includeDomains: { type: 'array', items: { type: 'string' } }, + excludeDomains: { type: 'array', items: { type: 'string' } }, + days: { type: 'number' }, + }, + required: ['query'], + }, +}; + +/** + * Return a shallow copy of each tool meta augmented with the additive, + * display-only `params` summary derived from {@link LLM_TOOL_PARAMETER_SCHEMAS}. + * + * - When a schema is available, `params` is attached (an empty array means the + * tool declares no parameters). + * - When no schema is available (no map entry), `params` is left absent so + * clients render "parameters unavailable". + * + * The returned metas are fresh objects; the input registry is not mutated. + */ +export function withToolParameterMetadata(tools: readonly LlmToolMeta[]): LlmToolMeta[] { + return tools.map((tool) => { + const schema = LLM_TOOL_PARAMETER_SCHEMAS[tool.name]; + if (schema === undefined) { + return { ...tool }; + } + const params = summarizeToolParameters(schema); + return params === undefined ? { ...tool } : { ...tool, params }; + }); +} diff --git a/packages/coc/src/server/routes/api-workspace-routes.ts b/packages/coc/src/server/routes/api-workspace-routes.ts index 12bcd93ca..f3d49daaa 100644 --- a/packages/coc/src/server/routes/api-workspace-routes.ts +++ b/packages/coc/src/server/routes/api-workspace-routes.ts @@ -25,6 +25,7 @@ import { validatePerRepoPreferences, } from '../preferences-handler'; import { getEffectiveDefaultDisabledTools, getEffectiveLlmToolRegistry } from '../llm-tools/llm-tool-registry'; +import { withToolParameterMetadata } from '../llm-tools/llm-tool-parameter-schemas'; import { detectEnDevEligibility } from '../endev/endev-detector'; import { skillCache } from '../skills/skill-handler'; import { @@ -735,7 +736,7 @@ export function registerApiWorkspaceRoutes(ctx: ApiRouteContext): void { const ws = await resolveWorkspaceOrFail(store, match!, res); if (!ws) return; const liveFlags = ctx.getLiveFeatureFlags?.() ?? { excalidrawEnabled: false, canvasEnabled: false }; - const effectiveRegistry = getEffectiveLlmToolRegistry({ loopsEnabled: ctx.loopsEnabled, excalidrawEnabled: liveFlags.excalidrawEnabled, canvasEnabled: liveFlags.canvasEnabled }); + const effectiveRegistry = withToolParameterMetadata(getEffectiveLlmToolRegistry({ loopsEnabled: ctx.loopsEnabled, excalidrawEnabled: liveFlags.excalidrawEnabled, canvasEnabled: liveFlags.canvasEnabled })); const conversationRetrievalAvailable = typeof ctx.store.searchConversations === 'function'; if (!ctx.dataDir) { sendJSON(res, 200, { @@ -782,7 +783,7 @@ export function registerApiWorkspaceRoutes(ctx: ApiRouteContext): void { writeRepoPreferences(ctx.dataDir, ws.id, merged); const globalPrefs = readGlobalPreferences(ctx.dataDir); sendJSON(res, 200, { - tools: getEffectiveLlmToolRegistry({ loopsEnabled: ctx.loopsEnabled, excalidrawEnabled: ctx.getLiveFeatureFlags?.()?.excalidrawEnabled ?? false, canvasEnabled: ctx.getLiveFeatureFlags?.()?.canvasEnabled ?? false }), + tools: withToolParameterMetadata(getEffectiveLlmToolRegistry({ loopsEnabled: ctx.loopsEnabled, excalidrawEnabled: ctx.getLiveFeatureFlags?.()?.excalidrawEnabled ?? false, canvasEnabled: ctx.getLiveFeatureFlags?.()?.canvasEnabled ?? false })), disabledLlmTools: merged.disabledLlmTools ?? getEffectiveDefaultDisabledTools(globalPrefs.uiLayoutMode), conversationRetrievalAvailable: typeof ctx.store.searchConversations === 'function', }); diff --git a/packages/coc/test/server/llm-tool-parameter-schemas.test.ts b/packages/coc/test/server/llm-tool-parameter-schemas.test.ts new file mode 100644 index 000000000..537311501 --- /dev/null +++ b/packages/coc/test/server/llm-tool-parameter-schemas.test.ts @@ -0,0 +1,125 @@ +/** + * LLM tool parameter schema map tests + * + * Covers the display-only mirror of tool input schemas + * (`LLM_TOOL_PARAMETER_SCHEMAS`) and the `withToolParameterMetadata` helper + * that attaches the additive `params` summary to registry metadata. + * + * Includes a drift guard: for every tool that is cheap to construct, the + * summary derived from the mirror must equal the summary derived from the + * tool's live `parameters` schema, so the mirror cannot silently drift. + */ + +import { describe, it, expect } from 'vitest'; +import { + LLM_TOOL_PARAMETER_SCHEMAS, + withToolParameterMetadata, +} from '../../src/server/llm-tools/llm-tool-parameter-schemas'; +import { summarizeToolParameters } from '../../src/server/llm-tools/llm-tool-parameters'; +import { LLM_TOOL_REGISTRY, type LlmToolMeta } from '../../src/server/llm-tools/llm-tool-registry'; +import { createSuggestFollowUpsTool } from '../../src/server/llm-tools/suggest-follow-ups-tool'; +import { createSearchConversationsTool } from '../../src/server/llm-tools/search-conversations-tool'; +import { createGetConversationTool } from '../../src/server/llm-tools/get-conversation-tool'; +import { createScheduleWakeupTool } from '../../src/server/llm-tools/loop-tools'; +import { createAskUserTool } from '../../src/server/llm-tools/ask-user-tool'; +import { createMemoryStoreFactTool, createMemoryRecallTool } from '../../src/server/llm-tools/memory-v2-tools'; + +/** + * Registry tools that intentionally have no locally-declared schema and so + * should render "parameters unavailable" rather than appear in the mirror. + */ +const SCHEMA_EXCLUDED_TOOLS = new Set(['memory']); + +describe('LLM_TOOL_PARAMETER_SCHEMAS', () => { + it('covers every registry tool except the documented exclusions', () => { + for (const tool of LLM_TOOL_REGISTRY) { + const inMap = Object.prototype.hasOwnProperty.call(LLM_TOOL_PARAMETER_SCHEMAS, tool.name); + if (SCHEMA_EXCLUDED_TOOLS.has(tool.name)) { + expect(inMap, `${tool.name} should be excluded from the schema mirror`).toBe(false); + } else { + expect(inMap, `${tool.name} is missing from the schema mirror`).toBe(true); + } + } + }); + + it('has no stale entries for tools that left the registry', () => { + const registryNames = new Set(LLM_TOOL_REGISTRY.map(t => t.name)); + for (const name of Object.keys(LLM_TOOL_PARAMETER_SCHEMAS)) { + expect(registryNames.has(name), `${name} in the mirror is not a registered tool`).toBe(true); + } + }); + + it('every mirrored schema summarizes to a usable param list', () => { + for (const [name, schema] of Object.entries(LLM_TOOL_PARAMETER_SCHEMAS)) { + const params = summarizeToolParameters(schema); + expect(params, `${name} should produce a param array`).toBeDefined(); + expect(Array.isArray(params)).toBe(true); + } + }); +}); + +describe('withToolParameterMetadata', () => { + it('attaches a params summary to tools that have a mirrored schema', () => { + const meta: LlmToolMeta = { + name: 'suggest_follow_ups', + label: 'Follow-Up Suggestions', + description: 'desc', + enabledByDefault: true, + }; + const [result] = withToolParameterMetadata([meta]); + expect(result.params).toEqual([{ name: 'suggestions', type: '[...]', required: true }]); + }); + + it('omits params for tools without a mirrored schema', () => { + const meta: LlmToolMeta = { + name: 'memory', + label: 'Memory', + description: 'desc', + enabledByDefault: true, + }; + const [result] = withToolParameterMetadata([meta]); + expect(result.params).toBeUndefined(); + expect('params' in result).toBe(false); + }); + + it('preserves all existing contract fields and does not mutate the input', () => { + const input: LlmToolMeta[] = [ + { name: 'read_excalidraw', label: 'Read Excalidraw', description: 'd', enabledByDefault: false }, + ]; + const frozenSnapshot = JSON.stringify(input); + const [result] = withToolParameterMetadata(input); + expect(result.name).toBe('read_excalidraw'); + expect(result.label).toBe('Read Excalidraw'); + expect(result.description).toBe('d'); + expect(result.enabledByDefault).toBe(false); + // Input is untouched (no params field added in place). + expect(JSON.stringify(input)).toBe(frozenSnapshot); + expect(result).not.toBe(input[0]); + }); + + it('preserves registry order', () => { + const result = withToolParameterMetadata(LLM_TOOL_REGISTRY); + expect(result.map(t => t.name)).toEqual(LLM_TOOL_REGISTRY.map(t => t.name)); + }); +}); + +describe('schema mirror drift guard', () => { + // Tools whose factories are side-effect-free to construct (no fs/store + // instantiation) so we can read their live `parameters` and compare. + const liveSchemas: Array<{ name: string; parameters: unknown }> = [ + { name: 'suggest_follow_ups', parameters: createSuggestFollowUpsTool().parameters }, + { name: 'search_conversations', parameters: createSearchConversationsTool({} as any).tool.parameters }, + { name: 'get_conversation', parameters: createGetConversationTool({} as any).tool.parameters }, + { name: 'scheduleWakeup', parameters: createScheduleWakeupTool({} as any).tool.parameters }, + { name: 'ask_user', parameters: createAskUserTool({} as any).tool.parameters }, + { name: 'save_memory', parameters: createMemoryStoreFactTool({} as any).tool.parameters }, + { name: 'recall_memory', parameters: createMemoryRecallTool({} as any).tool.parameters }, + ]; + + it.each(liveSchemas)('mirror for $name matches the live tool schema summary', ({ name, parameters }) => { + const mirrored = summarizeToolParameters(LLM_TOOL_PARAMETER_SCHEMAS[name]); + const live = summarizeToolParameters(parameters); + expect(live).toBeDefined(); + expect(mirrored).toEqual(live); + }); +}); diff --git a/packages/coc/test/server/llm-tools-config-api.test.ts b/packages/coc/test/server/llm-tools-config-api.test.ts index 5c9cc4779..8acfc325a 100644 --- a/packages/coc/test/server/llm-tools-config-api.test.ts +++ b/packages/coc/test/server/llm-tools-config-api.test.ts @@ -222,6 +222,53 @@ describe('LLM Tools Config API endpoints', () => { } }); + it('attaches compact param metadata derived from tool schemas', async () => { + const res = await request(`${base()}/api/workspaces/${WORKSPACE_ID}/llm-tools-config`); + const data = res.json(); + const byName = (n: string) => data.tools.find((t: any) => t.name === n); + + // Single required array param -> compact `[...]` shape. + expect(byName('suggest_follow_ups').params).toEqual([ + { name: 'suggestions', type: '[...]', required: true }, + ]); + + // Mixed required/optional primitives preserve declaration order. + expect(byName('get_conversation').params).toEqual([ + { name: 'processId', type: 'string', required: true }, + { name: 'maxChars', type: 'number', required: false }, + { name: 'includeToolCalls', type: 'boolean', required: false }, + { name: 'fromTurn', type: 'number', required: false }, + { name: 'toTurn', type: 'number', required: false }, + ]); + + // Nested object param -> compact `{...}` shape; union/oneOf -> `any`. + const createWi = byName('create_update_work_item').params as Array<{ name: string; type: string; required: boolean }>; + expect(createWi.find(p => p.name === 'tags')).toEqual({ name: 'tags', type: '[...]', required: false }); + expect(createWi.find(p => p.name === 'workItemNumber')).toEqual({ name: 'workItemNumber', type: 'any', required: false }); + }); + + it('omits param metadata for tools without an available schema', async () => { + const res = await request(`${base()}/api/workspaces/${WORKSPACE_ID}/llm-tools-config`); + const data = res.json(); + const memory = data.tools.find((t: any) => t.name === 'memory'); + // The built-in memory tool has no locally-declared schema -> field absent. + expect(memory).toBeTruthy(); + expect(memory.params).toBeUndefined(); + }); + + it('preserves the existing tool contract fields unchanged', async () => { + const res = await request(`${base()}/api/workspaces/${WORKSPACE_ID}/llm-tools-config`); + const data = res.json(); + const registryBase = getEffectiveLlmToolRegistry({ loopsEnabled: false }); + for (const expected of registryBase) { + const tool = data.tools.find((t: any) => t.name === expected.name); + expect(tool).toBeTruthy(); + expect(tool.label).toBe(expected.label); + expect(tool.description).toBe(expected.description); + expect(tool.enabledByDefault).toBe(expected.enabledByDefault); + } + }); + it('reports conversation retrieval availability from the process store', async () => { const res = await request(`${base()}/api/workspaces/${WORKSPACE_ID}/llm-tools-config`); expect(res.json().conversationRetrievalAvailable).toBe(true); @@ -284,6 +331,9 @@ describe('LLM Tools Config API endpoints', () => { expect(data.disabledLlmTools).toEqual(['tavily_web_search', 'memory']); expect(data.tools).toHaveLength(getEffectiveLlmToolRegistry({ loopsEnabled: false }).length); expect(data.conversationRetrievalAvailable).toBe(true); + // The PUT response carries the same additive param metadata as GET. + const followUps = data.tools.find((t: any) => t.name === 'suggest_follow_ups'); + expect(followUps.params).toEqual([{ name: 'suggestions', type: '[...]', required: true }]); }); it('persists disabledLlmTools to disk', async () => { From 2b56735a5ed4be0b6dbdbc663faca2f5bb4c0f8e Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 07:12:00 +0000 Subject: [PATCH 08/37] feat(coc): compact expandable param summaries in LLM Tools settings Render each tool's input parameters in the workspace LLM Tools panel as a compact, scannable affordance. Collapsed rows show only a "N parameters" button (keyboard-accessible, aria-expanded/aria-controls); expanding inline shows `name: type*` for required and `name?: type` for optional params, with nested `{...}`/`[...]` shapes left collapsed. Tools with `params: []` show "No parameters" and tools without a schema show "Parameters unavailable". The params button lives outside the toggle
SessionBranchUpdatedTurns
-
- {item.id.slice(0, 8)} - -
-
{item.summaryPreview || No summary stored}
- {item.matchSnippets.length > 0 && ( -
- {item.matchSnippets.map((snippet, index) => ( -
{snippet}
- ))} +
+
+
+
+ {item.id.slice(0, 8)} + {formatTimestamp(item.updatedAt)} +
+
{item.summaryPreview || No summary stored}
+ {item.matchSnippets.length > 0 && ( +
+ {item.matchSnippets.map((snippet, index) => ( +
{snippet}
+ ))} +
+ )} + {location && ( +
+ + {location} +
+ )}
- )} -
{item.repository || item.cwd || ''}
+
+ + {item.turnCount} turn{item.turnCount === 1 ? '' : 's'} + + + + {item.branch || 'Unknown branch'} + +
+
{item.branch || 'Unknown branch'}{formatTimestamp(item.updatedAt)}{item.turnCount}
-
+
+
-
- {item.id.slice(0, 8)} - {formatTimestamp(item.updatedAt)} +
+ {item.id.slice(0, 8)} + {formatTimestamp(item.updatedAt)}
-
{item.summaryPreview || No summary stored}
+
{item.summaryPreview || No summary stored}
{item.matchSnippets.length > 0 && ( -
+
{item.matchSnippets.map((snippet, index) => ( -
{snippet}
+
{snippet}
))}
)} {location && ( -
-
+ {location}
)}
-
- +
+ {item.turnCount} turn{item.turnCount === 1 ? '' : 's'} - - + {item.branch || 'Unknown branch'} @@ -403,13 +403,13 @@ function SessionRow({ item, selected, onSelect }: { function SessionDetailView({ detail, onBack }: { detail: NativeCopilotSessionDetail; onBack: () => void }) { return ( -
-
-
+
+
+
-

{detail.id}

-

+

{detail.id}

+

{READ_ONLY_TOOLTIP}

-
+
Repository
{detail.repository || '—'}
Branch
{detail.branch || 'Unknown branch'}
Working dir
{detail.cwd || '—'}
@@ -430,22 +430,22 @@ function SessionDetailView({ detail, onBack }: { detail: NativeCopilotSessionDet
Updated
{formatTimestamp(detail.updatedAt)}
{detail.summary && ( -
-

Stored summary

-
{detail.summary}
+
+

Stored summary

+
{detail.summary}
)}
-
-

Turns ({detail.turns.length})

+
+

Turns ({detail.turns.length})

{detail.turns.length === 0 && ( -

This native session has no stored turns.

+

This native session has no stored turns.

)} -
    +
      {detail.turns.map(turn => ( -
    1. -
      +
    2. +
      Turn {turn.turnIndex} {formatTimestamp(turn.timestamp)} {turn.userChars} user chars · {turn.assistantChars} assistant chars @@ -455,15 +455,15 @@ function SessionDetailView({ detail, onBack }: { detail: NativeCopilotSessionDet : 'Not indexed'}
      -
      -

      User

      -
      {turn.userMessage || '—'}
      +
      +

      User

      +
      {turn.userMessage || '—'}
      -
      -

      Assistant

      +
      +

      Assistant

      {turn.assistantResponse - ?
      {turn.assistantResponse}
      - :

      No assistant response stored

      } + ?
      {turn.assistantResponse}
      + :

      No assistant response stored

      }
    3. ))} From c282895447547cfdf003667729326cb4ae83b705 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 00:36:16 +0000 Subject: [PATCH 04/37] Dedup native Copilot sessions against CoC process store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Copilot SDK/CLI writes one session id per conversation that equals the native session-store id (~/.copilot/session-store.db sessions.id) and the value CoC persists as processes.sdk_session_id. The read-only Copilot Sessions tab previously listed native sessions independently, so sessions already tracked in the Activity tab appeared in both places. Add ProcessStore.getSdkSessionIds(workspaceId) (SQLite-backed, one indexed query over processes.workspace_id) returning the distinct set of recorded sdk_session_ids. The native session list route passes that set as excludeSessionIds; the service hides matching sessions during its existing in-memory workspace filter (O(1) per row) and returns deduplicatedCount. The panel shows a 'N sessions hidden — already tracked in CoC Activity' hint. Tests: forge getSdkSessionIds unit tests; service-level exclusion and count; route-level dedup via SqliteProcessStore; SPA hint presence/absence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../coc-knowledge/references/dashboard-spa.md | 2 +- .../coc-knowledge/references/rest-api.md | 2 +- .../src/contracts/native-copilot-sessions.ts | 2 + packages/coc/AGENTS.md | 6 +- .../native-copilot-session-service.ts | 11 +- .../server/native-copilot-sessions/types.ts | 9 ++ .../routes/native-copilot-session-routes.ts | 7 ++ .../NativeCopilotSessionsPanel.tsx | 5 + .../server/native-copilot-sessions.test.ts | 113 +++++++++++++++++- .../NativeCopilotSessionsPanel.test.tsx | 14 +++ packages/forge/src/process-store.ts | 9 ++ packages/forge/src/sqlite-process-store.ts | 8 ++ ...lite-process-store-sdk-session-ids.test.ts | 74 ++++++++++++ 13 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 packages/forge/test/sqlite-process-store-sdk-session-ids.test.ts diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index fc1481c05..2c8e7aedb 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -569,7 +569,7 @@ The repo-scoped Dreams tab (`features/dreams/DreamsPanel.tsx`) is a dedicated re ## Copilot Sessions Tab -The repo-scoped `Copilot Sessions` tab (`features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx`) is a read-only view of native GitHub Copilot CLI sessions for the active workspace, gated by `features.nativeCopilotSessions` / `nativeCopilotSessionsEnabled` (disabled by default; `useNativeCopilotSessionsEnabled()` tracks live runtime-config updates). It reads through `coc-client`'s `nativeCopilotSessions` domain, renders a two-pane layout on wide screens (searchable session list left at a clamped ~42% width, selected-session detail right) and stacked single-pane navigation on narrow screens, and supports text query (native `search_index` snippets), session-ID, branch, and date-range filters sorted newest `updated_at` first with pagination. Each list row shows a short session-ID chip, updated timestamp, two-line summary preview, repository/cwd, and right-aligned turn-count and branch pills; the selected row gets a left accent bar. The list header and the detail header carry a `Native Copilot CLI session` label plus a read-only badge whose tooltip/helper explains the data comes from the local native store (`~/.copilot/session-store.db`) and cannot be modified from CoC. The panel renders distinct disabled/unavailable (`db-missing`/`db-invalid`)/loading/empty/error states, shows turns ordered by index with `No assistant response stored` for empty assistant turns and `Indexed (N chars)`/`Not indexed` search-index diagnostics, renders all stored text as plain pre-wrapped text (stored HTML/scripts never execute), and intentionally exposes no CoC chat actions (no follow-up, archive, pin, delete, resume, retry, or turn actions). +The repo-scoped `Copilot Sessions` tab (`features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx`) is a read-only view of native GitHub Copilot CLI sessions for the active workspace, gated by `features.nativeCopilotSessions` / `nativeCopilotSessionsEnabled` (disabled by default; `useNativeCopilotSessionsEnabled()` tracks live runtime-config updates). It reads through `coc-client`'s `nativeCopilotSessions` domain, renders a two-pane layout on wide screens (searchable session list left at a clamped ~42% width, selected-session detail right) and stacked single-pane navigation on narrow screens, and supports text query (native `search_index` snippets), session-ID, branch, and date-range filters sorted newest `updated_at` first with pagination. Each list row shows a short session-ID chip, updated timestamp, two-line summary preview, repository/cwd, and right-aligned turn-count and branch pills; the selected row gets a left accent bar. The list header and the detail header carry a `Native Copilot CLI session` label plus a read-only badge whose tooltip/helper explains the data comes from the local native store (`~/.copilot/session-store.db`) and cannot be modified from CoC. The list route deduplicates against the Activity tab: native sessions whose `sessions.id` matches a CoC process `sdk_session_id` for the workspace (the Copilot SDK/CLI session id equals the native store id, resolved via `ProcessStore.getSdkSessionIds(workspaceId)`) are hidden, and the response `deduplicatedCount` drives a `native-sessions-deduplicated` hint reading `N sessions hidden — already tracked in CoC Activity`. The panel renders distinct disabled/unavailable (`db-missing`/`db-invalid`)/loading/empty/error states, shows turns ordered by index with `No assistant response stored` for empty assistant turns and `Indexed (N chars)`/`Not indexed` search-index diagnostics, renders all stored text as plain pre-wrapped text (stored HTML/scripts never execute), and intentionally exposes no CoC chat actions (no follow-up, archive, pin, delete, resume, retry, or turn actions). ## Memory Route diff --git a/.github/skills/coc-knowledge/references/rest-api.md b/.github/skills/coc-knowledge/references/rest-api.md index 0cbe85a07..09e1c914c 100644 --- a/.github/skills/coc-knowledge/references/rest-api.md +++ b/.github/skills/coc-knowledge/references/rest-api.md @@ -181,7 +181,7 @@ Read-only, workspace-scoped views over the server user's native GitHub Copilot C | Method | Path | Description | |--------|------|-------------| -| GET | `/api/workspaces/:id/native-copilot-sessions` | List workspace-matching native sessions sorted by newest `updated_at`. Query: `q` (text search via the native `search_index` FTS table with match snippets; parameterized literal-quoted terms), `sessionId` (exact or partial), `branch`, `from`/`to` ISO bounds on updated time, `limit` (default 50, max 200), `offset`. Response includes `items` with summary preview and turn counts, `total`, `searchIndexAvailable` (false when the native FTS table is absent — text queries then return no hits non-fatally) | +| GET | `/api/workspaces/:id/native-copilot-sessions` | List workspace-matching native sessions sorted by newest `updated_at`. Query: `q` (text search via the native `search_index` FTS table with match snippets; parameterized literal-quoted terms), `sessionId` (exact or partial), `branch`, `from`/`to` ISO bounds on updated time, `limit` (default 50, max 200), `offset`. Response includes `items` with summary preview and turn counts, `total`, `searchIndexAvailable` (false when the native FTS table is absent — text queries then return no hits non-fatally), and `deduplicatedCount` (native sessions hidden because their `sessions.id` matches a CoC process `sdk_session_id` for the workspace) | | GET | `/api/workspaces/:id/native-copilot-sessions/:sessionId` | Read one workspace-matching native session: metadata, full stored summary, and turns ordered by `turn_index` with per-turn char counts and search-index diagnostics (`searchIndexSourceId`/`searchIndexChars`, null when not indexed). Sessions outside the workspace or unknown IDs return 404 | ## Dreams diff --git a/packages/coc-client/src/contracts/native-copilot-sessions.ts b/packages/coc-client/src/contracts/native-copilot-sessions.ts index 4a9a66c81..73b207fed 100644 --- a/packages/coc-client/src/contracts/native-copilot-sessions.ts +++ b/packages/coc-client/src/contracts/native-copilot-sessions.ts @@ -71,6 +71,8 @@ export interface ListNativeCopilotSessionsResponse { total: number; /** False when metadata tables exist but the native search index is absent. */ searchIndexAvailable?: boolean; + /** Count of native sessions hidden because they are already tracked as CoC processes (Activity tab). */ + deduplicatedCount?: number; limit: number; offset: number; } diff --git a/packages/coc/AGENTS.md b/packages/coc/AGENTS.md index 21d41e680..a6f2b8f61 100644 --- a/packages/coc/AGENTS.md +++ b/packages/coc/AGENTS.md @@ -128,7 +128,11 @@ all have their own `references/*.md`. short-lived `readonly` SQLite connections, keep every user-provided filter parameterized (FTS terms literal-quoted), and return typed `db-missing`/`db-invalid` states instead of throwing. Never route native - session IDs into CoC process/chat action handlers. + session IDs into CoC process/chat action handlers. The list route dedups + against CoC processes by excluding native `sessions.id` values that match a + workspace's `ProcessStore.getSdkSessionIds(workspaceId)` (the Copilot SDK/CLI + session id equals the native store id); the hidden count is returned as + `deduplicatedCount`. - **Work-item create/update side effects** (hierarchy `parentId` validation, GitHub/Azure Boards provider sync, response-cache invalidation, dashboard broadcasts, auto-execute) live in the shared command service diff --git a/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts b/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts index 044b04c6f..be53a2677 100644 --- a/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts +++ b/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts @@ -166,13 +166,15 @@ export class NativeCopilotSessionService { textHits = searchIndexAvailable ? this.queryTextHits(db, matchExpression) : new Map(); } if (textHits && textHits.size === 0) { - return { available: true, items: [], total: 0, searchIndexAvailable, limit, offset }; + return { available: true, items: [], total: 0, searchIndexAvailable, deduplicatedCount: 0, limit, offset }; } const rows = this.querySessionRows(db, options, textHits); const fromTs = options.from ? parseTimestamp(options.from) : undefined; const toTs = options.to ? parseTimestamp(options.to) : undefined; + const excludeSessionIds = options.excludeSessionIds; + let deduplicatedCount = 0; const scoped = rows.filter(row => { if (!sessionMatchesWorkspace(row, scope)) { return false; @@ -189,6 +191,11 @@ export class NativeCopilotSessionService { return false; } } + // Hide native sessions already tracked as CoC processes (dedup). + if (excludeSessionIds && excludeSessionIds.has(row.id)) { + deduplicatedCount += 1; + return false; + } return true; }); @@ -215,7 +222,7 @@ export class NativeCopilotSessionService { matchSnippets: textHits?.get(row.id)?.slice(0, MAX_MATCH_SNIPPETS) ?? [], })); - return { available: true, items, total, searchIndexAvailable, limit, offset }; + return { available: true, items, total, searchIndexAvailable, deduplicatedCount, limit, offset }; } catch { return { available: false, reason: 'db-invalid', limit, offset }; } finally { diff --git a/packages/coc/src/server/native-copilot-sessions/types.ts b/packages/coc/src/server/native-copilot-sessions/types.ts index 1d548d343..d2caf288d 100644 --- a/packages/coc/src/server/native-copilot-sessions/types.ts +++ b/packages/coc/src/server/native-copilot-sessions/types.ts @@ -68,6 +68,13 @@ export interface NativeCopilotSessionListOptions { to?: string; limit?: number; offset?: number; + /** + * Native `sessions.id` values to exclude from results. Used to deduplicate + * against native sessions already tracked as CoC processes (the Copilot + * SDK/CLI session id equals the native store id). Server-internal — not a + * client-supplied query parameter. + */ + excludeSessionIds?: ReadonlySet; } /** Workspace identity used to scope native sessions to the active CoC workspace. */ @@ -85,6 +92,8 @@ export type NativeCopilotSessionListResult = total: number; /** False when metadata tables exist but the native search_index is absent. */ searchIndexAvailable: boolean; + /** Count of workspace-scoped native sessions hidden because they are already tracked as CoC processes. */ + deduplicatedCount: number; } | { available: false; diff --git a/packages/coc/src/server/routes/native-copilot-session-routes.ts b/packages/coc/src/server/routes/native-copilot-session-routes.ts index 92454ce1e..391376f2b 100644 --- a/packages/coc/src/server/routes/native-copilot-session-routes.ts +++ b/packages/coc/src/server/routes/native-copilot-session-routes.ts @@ -88,6 +88,11 @@ export function registerNativeCopilotSessionRoutes(ctx: NativeCopilotSessionRout const workspace = await resolveWorkspaceOrFail(store, match!, res); if (!workspace) { return; } + // Dedup: hide native sessions already tracked as CoC processes for + // this workspace. The Copilot SDK/CLI session id equals the native + // store id, so a single indexed query yields the exclusion set. + const excludeSessionIds = store.getSdkSessionIds?.(workspace.id); + const result = service.listSessions(buildScope(workspace), { q: queryString(query.q), sessionId: queryString(query.sessionId), @@ -96,6 +101,7 @@ export function registerNativeCopilotSessionRoutes(ctx: NativeCopilotSessionRout to: queryString(query.to), limit: queryNumber(query.limit), offset: queryNumber(query.offset), + excludeSessionIds, }); if (!result.available) { @@ -116,6 +122,7 @@ export function registerNativeCopilotSessionRoutes(ctx: NativeCopilotSessionRout items: result.items, total: result.total, searchIndexAvailable: result.searchIndexAvailable, + deduplicatedCount: result.deduplicatedCount, limit: result.limit, offset: result.offset, }); diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx index 8388564ab..6f5a2bc57 100644 --- a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx +++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx @@ -251,6 +251,11 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession Text search is unavailable: the native store has no search index. Metadata filters still apply.

      )} + {listResponse?.available === true && (listResponse.deduplicatedCount ?? 0) > 0 && ( +

      + {listResponse.deduplicatedCount} session{listResponse.deduplicatedCount === 1 ? '' : 's'} hidden — already tracked in CoC Activity. +

      + )}
{listLoading && ( diff --git a/packages/coc/test/server/native-copilot-sessions.test.ts b/packages/coc/test/server/native-copilot-sessions.test.ts index 84f87cc03..58405eb19 100644 --- a/packages/coc/test/server/native-copilot-sessions.test.ts +++ b/packages/coc/test/server/native-copilot-sessions.test.ts @@ -11,7 +11,8 @@ import * as os from 'os'; import * as path from 'path'; import DatabaseConstructor from 'better-sqlite3'; import { createExecutionServer } from '../../src/server/index'; -import { FileProcessStore } from '@plusplusoneplusplus/forge'; +import { FileProcessStore, SqliteProcessStore } from '@plusplusoneplusplus/forge'; +import type { AIProcess, AIProcessStatus } from '@plusplusoneplusplus/forge'; import type { ExecutionServer } from '../../src/server/types'; import { NativeCopilotSessionService, @@ -249,6 +250,37 @@ describe('NativeCopilotSessionService', () => { } }); + it('excludes sessions tracked as CoC processes and reports deduplicatedCount', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb(dbPath, [ + { id: 'native-only-a', cwd: wsRoot, updatedAt: '2026-06-04T00:00:00.000Z' }, + { id: 'tracked-in-coc', cwd: wsRoot, updatedAt: '2026-06-03T00:00:00.000Z' }, + { id: 'native-only-b', cwd: wsRoot, updatedAt: '2026-06-02T00:00:00.000Z' }, + ]); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.listSessions( + { rootPath: wsRoot }, + { excludeSessionIds: new Set(['tracked-in-coc', 'unrelated-id']) }, + ); + expect(result.available).toBe(true); + if (result.available) { + expect(result.items.map(i => i.id)).toEqual(['native-only-a', 'native-only-b']); + expect(result.total).toBe(2); + expect(result.deduplicatedCount).toBe(1); + } + }); + + it('reports deduplicatedCount of zero when no exclusion set is provided', () => { + const wsRoot = path.join(tmpDir, 'ws'); + createFixtureDb(dbPath, [{ id: 'only', cwd: wsRoot }]); + const service = new NativeCopilotSessionService({ dbPath }); + const result = service.listSessions({ rootPath: wsRoot }); + expect(result.available).toBe(true); + if (result.available) { + expect(result.deduplicatedCount).toBe(0); + } + }); + it('finds text hits through search_index with snippets, and supports combined filters', () => { const wsRoot = path.join(tmpDir, 'ws'); createFixtureDb( @@ -554,3 +586,82 @@ describe('Native Copilot session routes', () => { expect(res.status).toBe(404); }); }); + +describe('Native Copilot session routes — dedup against CoC processes', () => { + let server: ExecutionServer | undefined; + let store: SqliteProcessStore | undefined; + let dataDir: string; + let workspaceDir: string; + let fixtureDir: string; + let dbPath: string; + const wsId = 'native-dedup-ws'; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-dedup-data-')); + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-dedup-repo-')); + fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-dedup-db-')); + dbPath = path.join(fixtureDir, 'session-store.db'); + }); + + afterEach(async () => { + if (server) { + await server.close(); + server = undefined; + } + store?.close(); + store = undefined; + for (const dir of [dataDir, workspaceDir, fixtureDir]) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function listUrl(query = ''): string { + return `${server!.url}/api/workspaces/${encodeURIComponent(wsId)}/native-copilot-sessions${query}`; + } + + function makeProcess(id: string, sdkSessionId: string): AIProcess { + return { + id, + type: 'ai', + promptPreview: 'tracked prompt', + fullPrompt: 'tracked full prompt', + status: 'completed' as AIProcessStatus, + startTime: new Date(), + metadata: { type: 'ai', workspaceId: wsId }, + sdkSessionId, + }; + } + + it('hides native sessions already tracked as CoC processes and reports the count', async () => { + createFixtureDb(dbPath, [ + { id: 'native-only', cwd: workspaceDir, updatedAt: '2026-06-05T00:00:00.000Z' }, + { id: 'tracked', cwd: workspaceDir, updatedAt: '2026-06-04T00:00:00.000Z' }, + ]); + store = new SqliteProcessStore({ dbPath: path.join(dataDir, 'processes.db') }); + server = await createExecutionServer({ + port: 0, + host: 'localhost', + store, + dataDir, + fileConfig: { features: { nativeCopilotSessions: true } }, + nativeCopilotSessionDbPath: dbPath, + queue: { autoStart: false }, + }); + const reg = await postJSON(`${server.url}/api/workspaces`, { + id: wsId, + name: 'Native Dedup Workspace', + rootPath: workspaceDir, + }); + expect(reg.status).toBe(201); + // The native session id equals the CoC process sdk_session_id. + await store.addProcess(makeProcess('coc-proc-1', 'tracked')); + + const res = await request(listUrl()); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.available).toBe(true); + expect(body.items.map((i: { id: string }) => i.id)).toEqual(['native-only']); + expect(body.total).toBe(1); + expect(body.deduplicatedCount).toBe(1); + }); +}); diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx index a8190c60d..d71705e80 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx @@ -231,4 +231,18 @@ describe('NativeCopilotSessionsPanel', () => { await waitFor(() => expect(screen.getByTestId('native-sessions-search-unavailable')).toBeTruthy()); }); + + it('shows the dedup hint when sessions are already tracked in CoC Activity', async () => { + mockList.mockResolvedValue(makeListResponse([makeListItem()], { deduplicatedCount: 3 })); + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-deduplicated')).toBeTruthy()); + expect(screen.getByTestId('native-sessions-deduplicated').textContent).toContain('3 sessions hidden'); + }); + + it('omits the dedup hint when no sessions are deduplicated', async () => { + mockList.mockResolvedValue(makeListResponse([makeListItem()])); + render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); + expect(screen.queryByTestId('native-sessions-deduplicated')).toBeNull(); + }); }); diff --git a/packages/forge/src/process-store.ts b/packages/forge/src/process-store.ts index f0f566f7f..e0b454619 100644 --- a/packages/forge/src/process-store.ts +++ b/packages/forge/src/process-store.ts @@ -347,6 +347,15 @@ export interface ProcessStore { */ getProcessIds(filter?: ProcessFilter): Promise; + /** + * Optional: return the distinct set of native SDK session IDs recorded for a + * workspace's processes. The Copilot SDK/CLI shares one session id per + * conversation, so these ids match native `sessions.id` rows and are used to + * deduplicate the read-only native Copilot CLI session view against sessions + * already tracked as CoC processes. Only the SQLite store implements this. + */ + getSdkSessionIds?(workspaceId: string): Set; + /** Return all known workspaces. */ getWorkspaces(): Promise; /** Register (or update) a workspace identity. */ diff --git a/packages/forge/src/sqlite-process-store.ts b/packages/forge/src/sqlite-process-store.ts index 4159be202..868525902 100644 --- a/packages/forge/src/sqlite-process-store.ts +++ b/packages/forge/src/sqlite-process-store.ts @@ -715,6 +715,14 @@ export class SqliteProcessStore implements ProcessStore { return rowToProcess(row, turnRows.map(rowToTurn)); } + getSdkSessionIds(workspaceId: string): Set { + const rows = this.db.prepare( + `SELECT DISTINCT sdk_session_id FROM processes + WHERE workspace_id = ? AND sdk_session_id IS NOT NULL AND sdk_session_id <> ''` + ).all(workspaceId) as Array<{ sdk_session_id: string }>; + return new Set(rows.map(r => r.sdk_session_id)); + } + async forkProcess( sourceId: string, newId: string, diff --git a/packages/forge/test/sqlite-process-store-sdk-session-ids.test.ts b/packages/forge/test/sqlite-process-store-sdk-session-ids.test.ts new file mode 100644 index 000000000..3b0ebd2d7 --- /dev/null +++ b/packages/forge/test/sqlite-process-store-sdk-session-ids.test.ts @@ -0,0 +1,74 @@ +/** + * SqliteProcessStore.getSdkSessionIds Tests + * + * Validates the workspace-scoped distinct native SDK session id accessor used + * to deduplicate the read-only native Copilot CLI session view against + * sessions already tracked as CoC processes. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; + +import { + SqliteProcessStore, + AIProcess, + AIProcessStatus, +} from '../src/index'; + +let tmpDir: string; +let store: SqliteProcessStore; + +function makeProcess(id: string, workspaceId: string, sdkSessionId?: string): AIProcess { + return { + id, + type: 'ai', + promptPreview: 'test prompt', + fullPrompt: 'test full prompt', + status: 'completed' as AIProcessStatus, + startTime: new Date(), + metadata: { type: 'ai', workspaceId }, + sdkSessionId, + }; +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sqlite-store-sdk-ids-')); + store = new SqliteProcessStore({ dbPath: path.join(tmpDir, 'test.db') }); +}); + +afterEach(async () => { + store.close(); + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('SqliteProcessStore.getSdkSessionIds', () => { + it('returns an empty set for a workspace with no processes', () => { + expect(store.getSdkSessionIds('ws-empty')).toEqual(new Set()); + }); + + it('returns distinct sdk session ids scoped to the workspace', async () => { + await store.addProcess(makeProcess('p1', 'ws-a', 'sess-1')); + await store.addProcess(makeProcess('p2', 'ws-a', 'sess-2')); + await store.addProcess(makeProcess('p3', 'ws-b', 'sess-3')); + + expect(store.getSdkSessionIds('ws-a')).toEqual(new Set(['sess-1', 'sess-2'])); + expect(store.getSdkSessionIds('ws-b')).toEqual(new Set(['sess-3'])); + }); + + it('deduplicates repeated sdk session ids (e.g. resumed conversations)', async () => { + await store.addProcess(makeProcess('p1', 'ws-a', 'sess-shared')); + await store.addProcess(makeProcess('p2', 'ws-a', 'sess-shared')); + + expect(store.getSdkSessionIds('ws-a')).toEqual(new Set(['sess-shared'])); + }); + + it('ignores processes with null or empty sdk session ids', async () => { + await store.addProcess(makeProcess('p1', 'ws-a', undefined)); + await store.addProcess(makeProcess('p2', 'ws-a', '')); + await store.addProcess(makeProcess('p3', 'ws-a', 'sess-real')); + + expect(store.getSdkSessionIds('ws-a')).toEqual(new Set(['sess-real'])); + }); +}); From 95f3166f6ddb5b25f12f506e568a1661d6434d88 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 01:56:03 +0000 Subject: [PATCH 05/37] Deep-link Copilot Sessions and hide background-job sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-link support: the selected native session is reflected in the URL hash (#repos/{wsId}/copilot-sessions/{sessionId}) via new Router helpers parseNativeCopilotSessionDeepLink/buildNativeCopilotSessionHash. The panel syncs selection to the hash on select/clear and restores it on mount and hashchange, so selections survive refresh/back-forward and are shareable. Background-job filtering: the native session list now hides automated background sessions whose first turn matches BACKGROUND_JOB_PROMPT_PREFIXES (e.g. 'Summarise the following conversation as a short title') — these are Copilot CLI title-generation jobs, not user conversations (603 of them in a real store). Detection is a chunked indexed query over turns.turn_index=0; the hidden count is returned as backgroundJobCount and surfaced as a panel hint. Opt out via the includeBackgroundJobs service option. Also fix a stale Router test count guard (REPO_SUB_TAB_VALUES is 17 since copilot-sessions was added; the assertion still expected 16). Tests: Router deep-link parse/build, panel select/restore/cross-workspace deep-link sync, panel background-hidden hint, service + route background-job exclusion and includeBackgroundJobs passthrough. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../coc-knowledge/references/dashboard-spa.md | 2 +- .../coc-knowledge/references/rest-api.md | 2 +- .../src/contracts/native-copilot-sessions.ts | 2 + packages/coc/AGENTS.md | 7 +- .../native-copilot-session-service.ts | 68 +++++++++++++-- .../server/native-copilot-sessions/types.ts | 7 ++ .../routes/native-copilot-session-routes.ts | 1 + .../NativeCopilotSessionsPanel.tsx | 38 ++++++++- .../server/spa/client/react/layout/Router.tsx | 30 +++++++ .../server/native-copilot-sessions.test.ts | 85 +++++++++++++++++++ .../NativeCopilotSessionsPanel.test.tsx | 36 ++++++++ .../native-copilot-sessions-deep-link.test.ts | 51 +++++++++++ packages/coc/test/spa/react/Router.test.ts | 8 +- 13 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 2c8e7aedb..80ae1380a 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -569,7 +569,7 @@ The repo-scoped Dreams tab (`features/dreams/DreamsPanel.tsx`) is a dedicated re ## Copilot Sessions Tab -The repo-scoped `Copilot Sessions` tab (`features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx`) is a read-only view of native GitHub Copilot CLI sessions for the active workspace, gated by `features.nativeCopilotSessions` / `nativeCopilotSessionsEnabled` (disabled by default; `useNativeCopilotSessionsEnabled()` tracks live runtime-config updates). It reads through `coc-client`'s `nativeCopilotSessions` domain, renders a two-pane layout on wide screens (searchable session list left at a clamped ~42% width, selected-session detail right) and stacked single-pane navigation on narrow screens, and supports text query (native `search_index` snippets), session-ID, branch, and date-range filters sorted newest `updated_at` first with pagination. Each list row shows a short session-ID chip, updated timestamp, two-line summary preview, repository/cwd, and right-aligned turn-count and branch pills; the selected row gets a left accent bar. The list header and the detail header carry a `Native Copilot CLI session` label plus a read-only badge whose tooltip/helper explains the data comes from the local native store (`~/.copilot/session-store.db`) and cannot be modified from CoC. The list route deduplicates against the Activity tab: native sessions whose `sessions.id` matches a CoC process `sdk_session_id` for the workspace (the Copilot SDK/CLI session id equals the native store id, resolved via `ProcessStore.getSdkSessionIds(workspaceId)`) are hidden, and the response `deduplicatedCount` drives a `native-sessions-deduplicated` hint reading `N sessions hidden — already tracked in CoC Activity`. The panel renders distinct disabled/unavailable (`db-missing`/`db-invalid`)/loading/empty/error states, shows turns ordered by index with `No assistant response stored` for empty assistant turns and `Indexed (N chars)`/`Not indexed` search-index diagnostics, renders all stored text as plain pre-wrapped text (stored HTML/scripts never execute), and intentionally exposes no CoC chat actions (no follow-up, archive, pin, delete, resume, retry, or turn actions). +The repo-scoped `Copilot Sessions` tab (`features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx`) is a read-only view of native GitHub Copilot CLI sessions for the active workspace, gated by `features.nativeCopilotSessions` / `nativeCopilotSessionsEnabled` (disabled by default; `useNativeCopilotSessionsEnabled()` tracks live runtime-config updates). It reads through `coc-client`'s `nativeCopilotSessions` domain, renders a two-pane layout on wide screens (searchable session list left at a clamped ~42% width, selected-session detail right) and stacked single-pane navigation on narrow screens, and supports text query (native `search_index` snippets), session-ID, branch, and date-range filters sorted newest `updated_at` first with pagination. The selected session is deep-linked through the URL hash (`#repos/{wsId}/copilot-sessions/{sessionId}`, parsed/built via `parseNativeCopilotSessionDeepLink`/`buildNativeCopilotSessionHash`) so selections survive refresh/back-forward and are shareable. Each list row shows a short session-ID chip, updated timestamp, two-line summary preview, repository/cwd, and right-aligned turn-count and branch pills; the selected row gets a left accent bar. The list header and the detail header carry a `Native Copilot CLI session` label plus a read-only badge whose tooltip/helper explains the data comes from the local native store (`~/.copilot/session-store.db`) and cannot be modified from CoC. The list route deduplicates against the Activity tab: native sessions whose `sessions.id` matches a CoC process `sdk_session_id` for the workspace (the Copilot SDK/CLI session id equals the native store id, resolved via `ProcessStore.getSdkSessionIds(workspaceId)`) are hidden, and the response `deduplicatedCount` drives a `native-sessions-deduplicated` hint reading `N sessions hidden — already tracked in CoC Activity`. Automated background-job sessions whose first turn (turn_index 0) matches `BACKGROUND_JOB_PROMPT_PREFIXES` in the service (e.g. `Summarise the following conversation as a short title`) are hidden by default and counted in `backgroundJobCount`, which drives a `native-sessions-background-hidden` hint. The panel renders distinct disabled/unavailable (`db-missing`/`db-invalid`)/loading/empty/error states, shows turns ordered by index with `No assistant response stored` for empty assistant turns and `Indexed (N chars)`/`Not indexed` search-index diagnostics, renders all stored text as plain pre-wrapped text (stored HTML/scripts never execute), and intentionally exposes no CoC chat actions (no follow-up, archive, pin, delete, resume, retry, or turn actions). ## Memory Route diff --git a/.github/skills/coc-knowledge/references/rest-api.md b/.github/skills/coc-knowledge/references/rest-api.md index 09e1c914c..27928a7a3 100644 --- a/.github/skills/coc-knowledge/references/rest-api.md +++ b/.github/skills/coc-knowledge/references/rest-api.md @@ -181,7 +181,7 @@ Read-only, workspace-scoped views over the server user's native GitHub Copilot C | Method | Path | Description | |--------|------|-------------| -| GET | `/api/workspaces/:id/native-copilot-sessions` | List workspace-matching native sessions sorted by newest `updated_at`. Query: `q` (text search via the native `search_index` FTS table with match snippets; parameterized literal-quoted terms), `sessionId` (exact or partial), `branch`, `from`/`to` ISO bounds on updated time, `limit` (default 50, max 200), `offset`. Response includes `items` with summary preview and turn counts, `total`, `searchIndexAvailable` (false when the native FTS table is absent — text queries then return no hits non-fatally), and `deduplicatedCount` (native sessions hidden because their `sessions.id` matches a CoC process `sdk_session_id` for the workspace) | +| GET | `/api/workspaces/:id/native-copilot-sessions` | List workspace-matching native sessions sorted by newest `updated_at`. Query: `q` (text search via the native `search_index` FTS table with match snippets; parameterized literal-quoted terms), `sessionId` (exact or partial), `branch`, `from`/`to` ISO bounds on updated time, `limit` (default 50, max 200), `offset`. Response includes `items` with summary preview and turn counts, `total`, `searchIndexAvailable` (false when the native FTS table is absent — text queries then return no hits non-fatally), `deduplicatedCount` (native sessions hidden because their `sessions.id` matches a CoC process `sdk_session_id` for the workspace), and `backgroundJobCount` (automated background-job sessions hidden by first-turn prompt match, e.g. title summarization) | | GET | `/api/workspaces/:id/native-copilot-sessions/:sessionId` | Read one workspace-matching native session: metadata, full stored summary, and turns ordered by `turn_index` with per-turn char counts and search-index diagnostics (`searchIndexSourceId`/`searchIndexChars`, null when not indexed). Sessions outside the workspace or unknown IDs return 404 | ## Dreams diff --git a/packages/coc-client/src/contracts/native-copilot-sessions.ts b/packages/coc-client/src/contracts/native-copilot-sessions.ts index 73b207fed..859b7221f 100644 --- a/packages/coc-client/src/contracts/native-copilot-sessions.ts +++ b/packages/coc-client/src/contracts/native-copilot-sessions.ts @@ -73,6 +73,8 @@ export interface ListNativeCopilotSessionsResponse { searchIndexAvailable?: boolean; /** Count of native sessions hidden because they are already tracked as CoC processes (Activity tab). */ deduplicatedCount?: number; + /** Count of native sessions hidden because they are background jobs (e.g. title summarization). */ + backgroundJobCount?: number; limit: number; offset: number; } diff --git a/packages/coc/AGENTS.md b/packages/coc/AGENTS.md index a6f2b8f61..32921420c 100644 --- a/packages/coc/AGENTS.md +++ b/packages/coc/AGENTS.md @@ -131,8 +131,11 @@ all have their own `references/*.md`. session IDs into CoC process/chat action handlers. The list route dedups against CoC processes by excluding native `sessions.id` values that match a workspace's `ProcessStore.getSdkSessionIds(workspaceId)` (the Copilot SDK/CLI - session id equals the native store id); the hidden count is returned as - `deduplicatedCount`. + session id equals the native store id) and hides automated background-job + sessions whose first turn matches `BACKGROUND_JOB_PROMPT_PREFIXES` (e.g. title + summarization); the hidden counts are returned as `deduplicatedCount` and + `backgroundJobCount`. The panel deep-links the selected session via + `#repos/{wsId}/copilot-sessions/{sessionId}`. - **Work-item create/update side effects** (hierarchy `parentId` validation, GitHub/Azure Boards provider sync, response-cache invalidation, dashboard broadcasts, auto-execute) live in the shared command service diff --git a/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts b/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts index be53a2677..f9e65943b 100644 --- a/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts +++ b/packages/coc/src/server/native-copilot-sessions/native-copilot-session-service.ts @@ -28,6 +28,18 @@ const MAX_NATIVE_SESSION_LIST_LIMIT = 200; const MAX_MATCH_SNIPPETS = 3; const SUMMARY_PREVIEW_MAX_CHARS = 200; +/** + * First-turn user-prompt prefixes that mark a native session as an automated + * background job rather than a user-driven conversation. Matched + * case-insensitively against the trimmed `turns.turn_index = 0` user message. + * The Copilot CLI spawns these (e.g. to generate a conversation title), so they + * are hidden from the read-only session browser by default. + */ +const BACKGROUND_JOB_PROMPT_PREFIXES = [ + 'Summarise the following conversation as a short title', + 'Summarize the following conversation as a short title', +]; + /** Default native Copilot CLI session store location for the server user. */ export function getDefaultNativeCopilotSessionDbPath(): string { return path.join(os.homedir(), '.copilot', 'session-store.db'); @@ -166,7 +178,7 @@ export class NativeCopilotSessionService { textHits = searchIndexAvailable ? this.queryTextHits(db, matchExpression) : new Map(); } if (textHits && textHits.size === 0) { - return { available: true, items: [], total: 0, searchIndexAvailable, deduplicatedCount: 0, limit, offset }; + return { available: true, items: [], total: 0, searchIndexAvailable, deduplicatedCount: 0, backgroundJobCount: 0, limit, offset }; } const rows = this.querySessionRows(db, options, textHits); @@ -199,14 +211,31 @@ export class NativeCopilotSessionService { return true; }); - scoped.sort((a, b) => { + // Hide automated background-job sessions (e.g. title summarization) + // unless explicitly requested. Detected by the first-turn prompt. + let backgroundJobCount = 0; + let visible = scoped; + if (!options.includeBackgroundJobs) { + const backgroundJobIds = this.queryBackgroundJobSessionIds(db, scoped.map(row => row.id)); + if (backgroundJobIds.size > 0) { + visible = scoped.filter(row => { + if (backgroundJobIds.has(row.id)) { + backgroundJobCount += 1; + return false; + } + return true; + }); + } + } + + visible.sort((a, b) => { const aTs = parseTimestamp(a.updated_at); const bTs = parseTimestamp(b.updated_at); return (Number.isNaN(bTs) ? 0 : bTs) - (Number.isNaN(aTs) ? 0 : aTs); }); - const total = scoped.length; - const page = scoped.slice(offset, offset + limit); + const total = visible.length; + const page = visible.slice(offset, offset + limit); const turnCounts = this.queryTurnCounts(db, page.map(row => row.id)); const items: NativeCopilotSessionListItem[] = page.map(row => ({ @@ -222,7 +251,7 @@ export class NativeCopilotSessionService { matchSnippets: textHits?.get(row.id)?.slice(0, MAX_MATCH_SNIPPETS) ?? [], })); - return { available: true, items, total, searchIndexAvailable, deduplicatedCount, limit, offset }; + return { available: true, items, total, searchIndexAvailable, deduplicatedCount, backgroundJobCount, limit, offset }; } catch { return { available: false, reason: 'db-invalid', limit, offset }; } finally { @@ -394,6 +423,35 @@ export class NativeCopilotSessionService { return counts; } + /** + * Return the subset of the given session ids whose first turn (turn_index 0) + * is an automated background-job prompt (e.g. conversation-title + * summarization). Chunked to stay within SQLite's bound-parameter limit. + */ + private queryBackgroundJobSessionIds(db: Database, sessionIds: string[]): Set { + const matches = new Set(); + if (sessionIds.length === 0 || BACKGROUND_JOB_PROMPT_PREFIXES.length === 0) { + return matches; + } + // Escape LIKE metacharacters in prefixes, then append the wildcard. + const likePatterns = BACKGROUND_JOB_PROMPT_PREFIXES.map( + prefix => `${prefix.replace(/([\\%_])/g, '\\$1')}%`, + ); + const promptClause = `(${likePatterns.map(() => "user_message LIKE ? ESCAPE '\\'").join(' OR ')})`; + const CHUNK = 400; + for (let i = 0; i < sessionIds.length; i += CHUNK) { + const chunk = sessionIds.slice(i, i + CHUNK); + const rows = db.prepare( + `SELECT DISTINCT session_id AS sessionId FROM turns + WHERE turn_index = 0 AND session_id IN (${chunk.map(() => '?').join(', ')}) AND ${promptClause}`, + ).all(...chunk, ...likePatterns) as { sessionId: string }[]; + for (const row of rows) { + matches.add(row.sessionId); + } + } + return matches; + } + private querySearchIndexDiagnostics(db: Database, sessionId: string): Map { const diagnostics = new Map(); if (!this.hasSearchIndex(db)) { diff --git a/packages/coc/src/server/native-copilot-sessions/types.ts b/packages/coc/src/server/native-copilot-sessions/types.ts index d2caf288d..c4e205f56 100644 --- a/packages/coc/src/server/native-copilot-sessions/types.ts +++ b/packages/coc/src/server/native-copilot-sessions/types.ts @@ -75,6 +75,11 @@ export interface NativeCopilotSessionListOptions { * client-supplied query parameter. */ excludeSessionIds?: ReadonlySet; + /** + * When true, include background-job sessions (e.g. conversation-title + * summarization) that are otherwise hidden. Defaults to false (hide them). + */ + includeBackgroundJobs?: boolean; } /** Workspace identity used to scope native sessions to the active CoC workspace. */ @@ -94,6 +99,8 @@ export type NativeCopilotSessionListResult = searchIndexAvailable: boolean; /** Count of workspace-scoped native sessions hidden because they are already tracked as CoC processes. */ deduplicatedCount: number; + /** Count of workspace-scoped native sessions hidden because they are background jobs (e.g. title summarization). */ + backgroundJobCount: number; } | { available: false; diff --git a/packages/coc/src/server/routes/native-copilot-session-routes.ts b/packages/coc/src/server/routes/native-copilot-session-routes.ts index 391376f2b..24e69f467 100644 --- a/packages/coc/src/server/routes/native-copilot-session-routes.ts +++ b/packages/coc/src/server/routes/native-copilot-session-routes.ts @@ -123,6 +123,7 @@ export function registerNativeCopilotSessionRoutes(ctx: NativeCopilotSessionRout total: result.total, searchIndexAvailable: result.searchIndexAvailable, deduplicatedCount: result.deduplicatedCount, + backgroundJobCount: result.backgroundJobCount, limit: result.limit, offset: result.offset, }); diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx index 6f5a2bc57..45499cabf 100644 --- a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx +++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx @@ -20,6 +20,7 @@ import type { import { getSpaCocClient } from '../../api/cocClient'; import { Button, Spinner, cn } from '../../ui'; import { useNativeCopilotSessionsEnabled } from '../../hooks/feature-flags/useNativeCopilotSessionsEnabled'; +import { buildNativeCopilotSessionHash, parseNativeCopilotSessionDeepLink } from '../../layout/Router'; const READ_ONLY_TOOLTIP = 'This data is read from the local native Copilot CLI session store (~/.copilot/session-store.db) and cannot be modified from CoC.'; @@ -120,15 +121,39 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession useEffect(() => { void loadList(); }, [loadList]); - // Reset selection and paging when the workspace changes. + // Reset paging/filters when the workspace changes. Selection is driven by + // the URL hash (see the deep-link sync effect below). useEffect(() => { - setSelectedSessionId(null); setDetail(null); setOffset(0); setFilterDraft(EMPTY_FILTERS); setFilters(EMPTY_FILTERS); }, [workspaceId]); + // Deep-link: keep the selected session in sync with the URL hash + // (`#repos/{wsId}/copilot-sessions/{sessionId}`) so selections survive + // refresh/back/forward and can be shared as links. + useEffect(() => { + const apply = () => { + const parsed = parseNativeCopilotSessionDeepLink(window.location.hash); + const next = parsed && parsed.workspaceId === workspaceId ? parsed.sessionId : null; + setSelectedSessionId(prev => (prev === next ? prev : next)); + }; + apply(); + window.addEventListener('hashchange', apply); + return () => window.removeEventListener('hashchange', apply); + }, [workspaceId]); + + // Selecting (or clearing) a session writes the deep-link hash; the + // hashchange listener above then reconciles `selectedSessionId`. + const selectSession = useCallback((sessionId: string | null) => { + setSelectedSessionId(sessionId); + const next = buildNativeCopilotSessionHash(workspaceId, sessionId); + if (window.location.hash !== next) { + window.location.hash = next; + } + }, [workspaceId]); + useEffect(() => { if (!enabled || !selectedSessionId) { setDetail(null); @@ -256,6 +281,11 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession {listResponse.deduplicatedCount} session{listResponse.deduplicatedCount === 1 ? '' : 's'} hidden — already tracked in CoC Activity.

)} + {listResponse?.available === true && (listResponse.backgroundJobCount ?? 0) > 0 && ( +

+ {listResponse.backgroundJobCount} background job{listResponse.backgroundJobCount === 1 ? '' : 's'} hidden (e.g. title generation). +

+ )}
{listLoading && ( @@ -288,7 +318,7 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession key={item.id} item={item} selected={item.id === selectedSessionId} - onSelect={() => setSelectedSessionId(item.id)} + onSelect={() => selectSession(item.id)} /> ))}
- {/* Unavailability warnings */} {defaultProvider === 'codex' && providerAvailability['codex'] && !providerAvailability['codex'].available && (
diff --git a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx index 34aca1ccc..b433dfbef 100644 --- a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx +++ b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx @@ -760,8 +760,16 @@ export function AdminPanel() { return; } void handleRefreshQuota(); + }, [activeTab, handleRefreshQuota]); + + // Dreams provider activity now lives in the admin Dreams tab; auto-load it + // whenever that tab becomes the active dashboard route. + useEffect(() => { + if (activeDashboardTab !== 'dreams-admin' || isContainerMode()) { + return; + } void refreshDreamProviderActivity(); - }, [activeTab, handleRefreshQuota, refreshDreamProviderActivity]); + }, [activeDashboardTab, refreshDreamProviderActivity]); // ── Chat Experience card ── const handleSaveChat = useCallback(async () => { @@ -1288,7 +1296,11 @@ export function AdminPanel() { }} />} {activeToolItem.tab === 'skills' && } - {activeToolItem.tab === 'dreams-admin' && } + {activeToolItem.tab === 'dreams-admin' && } {activeToolItem.tab === 'logs' && } {activeToolItem.tab === 'stats' && } {activeToolItem.tab === 'servers' && } @@ -1988,9 +2000,6 @@ export function AdminPanel() { quotaLoading={quotaLoading} quotaError={quotaError} onRefreshQuota={handleRefreshQuota} - providerActivity={dreamProviderActivity} - providerActivityError={dreamProviderActivityError} - onRefreshProviderActivity={refreshDreamProviderActivity} sources={sources} /> )} diff --git a/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx index d82c9d226..27d2484cd 100644 --- a/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx +++ b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx @@ -9,8 +9,24 @@ // // The per-workspace dream-cards review panel (`DreamsPanel`) is a separate, // untouched surface under each repo's detail view. +// +// State, fetch, and refresh for the provider-activity section are owned by +// `AdminPanel` and passed in as props. + +import type { AgentProviderWorkActivity } from '../../shared/providerActivity'; +import { ProviderActivitySection } from './ProviderActivitySection'; -export function DreamsView() { +export interface DreamsViewProps { + providerActivity?: AgentProviderWorkActivity[]; + providerActivityError?: string | null; + onRefreshProviderActivity?: () => void; +} + +export function DreamsView({ + providerActivity = [], + providerActivityError, + onRefreshProviderActivity, +}: DreamsViewProps = {}) { return (
@@ -23,6 +39,12 @@ export function DreamsView() {
Restart-aware
+ +
); } diff --git a/packages/coc/src/server/spa/client/react/features/dreams/ProviderActivitySection.tsx b/packages/coc/src/server/spa/client/react/features/dreams/ProviderActivitySection.tsx new file mode 100644 index 000000000..5c4f4801b --- /dev/null +++ b/packages/coc/src/server/spa/client/react/features/dreams/ProviderActivitySection.tsx @@ -0,0 +1,76 @@ +/** + * ProviderActivitySection — the "Dreams provider activity" queue + history card. + * + * Renders active and recent Dream runs attributed to the provider, model, and + * timeout selected for each run, with an optional Refresh control. Lives in the + * admin **Dreams** tab (`DreamsView`); the state, fetch, and refresh handler are + * owned by `AdminPanel` and passed in as props. + */ +import { formatProviderActivityTimeout, type AgentProviderWorkActivity } from '../../shared/providerActivity'; +import { PROVIDER_LABELS, ProviderAvatar } from '../../shared/providerVisuals'; + +export interface ProviderActivitySectionProps { + activity: AgentProviderWorkActivity[]; + error?: string | null; + onRefresh?: () => void; +} + +export function ProviderActivitySection({ activity, error, onRefresh }: ProviderActivitySectionProps) { + return ( +
+
+
+
+ Dreams provider activity + queue + history +
+
+ Active and recent Dream jobs are attributed to the provider, model, and timeout selected for each run. +
+
+ {onRefresh && ( + + )} +
+ {error ? ( +
⚠ {error}
+ ) : activity.length === 0 ? ( +
+ No active or recent Dreams work. +
+ ) : ( +
+ {activity.map(item => { + const providerLabel = PROVIDER_LABELS[item.provider] ?? item.provider; + const trigger = item.trigger === 'idle' ? 'Idle' : item.trigger === 'manual' ? 'Manual' : 'Dreams'; + const status = item.status ? item.status.replace(/-/g, ' ') : 'unknown'; + return ( +
+
+ +
+
+ {item.label} + {providerLabel} +
+
+ {trigger} · {status} · {item.model ?? 'provider default'} · {formatProviderActivityTimeout(item.timeoutMs)} +
+ {item.error &&
✕ {item.error}
} +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/packages/coc/src/server/spa/client/react/shared/providerVisuals.tsx b/packages/coc/src/server/spa/client/react/shared/providerVisuals.tsx new file mode 100644 index 000000000..f803dc97e --- /dev/null +++ b/packages/coc/src/server/spa/client/react/shared/providerVisuals.tsx @@ -0,0 +1,53 @@ +/** + * Shared provider brand visuals for the AI-provider admin surfaces. + * + * The `aip-avatar` styled SVG icons, `PROVIDER_LABELS`, and the `ProviderAvatar` + * component are used by both the AI Provider page (`AIProviderPage`) and the + * Dreams provider-activity section (`ProviderActivitySection`). Keeping them in + * one module means the two surfaces share a single source of truth instead of + * duplicating the brand artwork. + */ +import type { AdminConcreteAgentProvider } from '@plusplusoneplusplus/coc-client'; + +export type Provider = AdminConcreteAgentProvider; + +export const PROVIDER_LABELS: Record = { copilot: 'Copilot', codex: 'Codex', claude: 'Claude' }; + +function CopilotIcon() { + return ( + + ); +} + +function OpenAIIcon() { + return ( + + ); +} + +function ClaudeIcon() { + return ( + + ); +} + +export const PROVIDER_ICONS: Record JSX.Element> = { + copilot: CopilotIcon, + codex: OpenAIIcon, + claude: ClaudeIcon, +}; + +export function ProviderAvatar({ provider }: { provider: Provider }) { + const Icon = PROVIDER_ICONS[provider]; + return ( + + ); +} diff --git a/packages/coc/test/spa/react/admin/AIProviderPage.test.tsx b/packages/coc/test/spa/react/admin/AIProviderPage.test.tsx index 963e0a140..8bf22eeac 100644 --- a/packages/coc/test/spa/react/admin/AIProviderPage.test.tsx +++ b/packages/coc/test/spa/react/admin/AIProviderPage.test.tsx @@ -481,31 +481,11 @@ describe('AIProviderPage', () => { expect(within(codexRow).queryByText('Risk')).toBeNull(); }); - it('renders Dreams provider activity with provider, model, and timeout attribution', () => { - const onRefreshProviderActivity = vi.fn(); - renderPage({ - providerActivity: [{ - id: 'dream-task-1', - provider: 'claude', - kind: 'dream-run', - trigger: 'manual', - status: 'running', - label: 'Dream Run: Manual', - model: 'claude-sonnet-4.6', - timeoutMs: 3_600_000, - }], - onRefreshProviderActivity, - }); - - const section = screen.getByTestId('provider-dream-activity'); - expect(within(section).getByText('Dreams provider activity')).toBeDefined(); - const row = within(section).getByTestId('provider-dream-activity-dream-task-1'); - expect(row.textContent).toContain('Dream Run: Manual'); - expect(row.textContent).toContain('Claude'); - expect(row.textContent).toContain('claude-sonnet-4.6'); - expect(row.textContent).toContain('1h timeout'); - fireEvent.click(screen.getByTestId('provider-dream-activity-refresh')); - expect(onRefreshProviderActivity).toHaveBeenCalledOnce(); + it('no longer renders the Dreams provider activity section (relocated to the Dreams tab)', () => { + renderPage(); + // AC-05: the queue + history card moved out of the AI Provider page. + expect(screen.queryByTestId('provider-dream-activity')).toBeNull(); + expect(screen.queryByText('Dreams provider activity')).toBeNull(); }); it('renders simultaneous Claude five-hour and weekly quota windows as separate rows', () => { diff --git a/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx b/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx new file mode 100644 index 000000000..f8a93d1f6 --- /dev/null +++ b/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx @@ -0,0 +1,77 @@ +/** + * Mock-based tests for the admin Dreams tab (`DreamsView`). + * + * AC-05: the "Dreams provider activity" queue + history section was relocated + * here from the AI Provider page. These tests assert the section renders inside + * the Dreams tab, attributes runs to provider/model/timeout, and that the + * Refresh control is preserved. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, within } from '@testing-library/react'; + +// `shared/providerActivity` pulls in the SPA CoC client at module load; stub it +// so importing DreamsView does not require a live client. DreamsView itself only +// renders the data passed in as props, so no client method is exercised here. +vi.mock('../../../../../src/server/spa/client/react/api/cocClient', () => ({ + getSpaCocClient: () => ({}), +})); + +const { DreamsView } = await import('../../../../../src/server/spa/client/react/features/dreams/DreamsView'); +import type { AgentProviderWorkActivity } from '../../../../../src/server/spa/client/react/shared/providerActivity'; + +describe('DreamsView', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('renders the Dreams page shell with title and restart-aware badge', () => { + render(); + expect(screen.getByTestId('dreams-admin-page')).toBeDefined(); + expect(screen.getByText('Dreams')).toBeDefined(); + expect(screen.getByText('Restart-aware')).toBeDefined(); + }); + + it('renders the relocated Dreams provider activity section with provider/model/timeout attribution', () => { + const activity: AgentProviderWorkActivity[] = [{ + id: 'dream-task-1', + provider: 'claude', + kind: 'dream-run', + trigger: 'manual', + status: 'running', + label: 'Dream Run: Manual', + model: 'claude-sonnet-4.6', + timeoutMs: 3_600_000, + }]; + render(); + + const section = screen.getByTestId('provider-dream-activity'); + expect(within(section).getByText('Dreams provider activity')).toBeDefined(); + const row = within(section).getByTestId('provider-dream-activity-dream-task-1'); + expect(row.textContent).toContain('Dream Run: Manual'); + expect(row.textContent).toContain('Claude'); + expect(row.textContent).toContain('claude-sonnet-4.6'); + expect(row.textContent).toContain('1h timeout'); + }); + + it('shows the empty state when there is no Dreams activity', () => { + render(); + expect(screen.getByTestId('provider-dream-activity-empty')).toBeDefined(); + }); + + it('renders the error banner instead of rows when activity fetch failed', () => { + render(); + const banner = screen.getByTestId('provider-dream-activity-error'); + expect(banner.textContent).toContain('boom'); + expect(screen.queryByTestId('provider-dream-activity-empty')).toBeNull(); + }); + + it('preserves the Refresh control and invokes the handler on click', () => { + const onRefreshProviderActivity = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('provider-dream-activity-refresh')); + expect(onRefreshProviderActivity).toHaveBeenCalledOnce(); + }); + + it('omits the Refresh control when no handler is provided', () => { + render(); + expect(screen.queryByTestId('provider-dream-activity-refresh')).toBeNull(); + }); +}); From d72eb71058a78d135ea4e645523ffa1d92ae582a Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 08:29:00 +0000 Subject: [PATCH 14/37] feat(coc): move dreams.enabled toggle into the Dreams tab (AC-03) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the global `dreams.enabled` toggle in the admin Dreams tab and remove it from the general Settings → Features grid. The runtime flag (`dreamsEnabled`) and PUT /api/admin/config validation are unchanged. - admin-setting-definitions: drop the `ui` block from `dreams.enabled` so it no longer renders on the Features card (key/runtime untouched). - Extract the shared admin row primitives (SourceBadge, AdminRow, AdminToggle, AdminSeg, AdminInputSuffix) into admin/adminControls.tsx so the Dreams tab can reuse them instead of duplicating markup. - AdminPanel owns the Dreams config form (loaded with the rest of the admin config) and a Save handler that persists to global config, invalidates display settings, and applies the runtime patch — same flow the Features card used. Passed into DreamsView as props. - DreamsView renders the toggle inside a dirty-tracked SettingsCard. - Tests: DreamsView toggle reflects/drives config + Save/Cancel wiring; contract test guards dreams.enabled stays admin-editable but off the Features card. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/config/admin-setting-definitions.ts | 8 +- .../spa/client/react/admin/AdminPanel.tsx | 142 +++++------------- .../spa/client/react/admin/adminControls.tsx | 110 ++++++++++++++ .../react/features/dreams/DreamsView.tsx | 48 +++++- .../config/admin-setting-definitions.test.ts | 11 ++ .../react/features/dreams/DreamsView.test.tsx | 39 ++++- 6 files changed, 248 insertions(+), 110 deletions(-) create mode 100644 packages/coc/src/server/spa/client/react/admin/adminControls.tsx diff --git a/packages/coc/src/config/admin-setting-definitions.ts b/packages/coc/src/config/admin-setting-definitions.ts index e02081384..28dbdf331 100644 --- a/packages/coc/src/config/admin-setting-definitions.ts +++ b/packages/coc/src/config/admin-setting-definitions.ts @@ -580,13 +580,11 @@ export const ADMIN_SETTING_DEFINITIONS: readonly AdminSettingDefinition[] = [ testId: 'toggle-loops-enabled', }, }), + // `dreams.enabled` is rendered bespoke in the admin Dreams tab + // (Knowledge nav group), not on the general Settings → Features grid, so it + // intentionally omits a `ui` block. Runtime flag + PUT validation are unchanged. bool({ key: 'dreams.enabled', default: false, runtime: 'live', runtimeFlag: 'dreamsEnabled', - ui: { - group: 'aiModes', order: 50, label: 'Dreams', badge: 'experimental', - hint: 'Enables workspace opt-in review cards from idle-time reflection. Disabled by default; workspaces must also opt in individually.', - testId: 'toggle-dreams-enabled', - }, }), bool({ key: 'excalidraw.enabled', default: false, runtime: 'live', runtimeFlag: 'excalidrawEnabled', diff --git a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx index b433dfbef..fa2e8557e 100644 --- a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx +++ b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx @@ -7,7 +7,7 @@ */ import type { AdminAutoProviderRoutingConfig, AdminDefaultProvider, ProviderInstallStatus } from '@plusplusoneplusplus/coc-client'; -import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { getSpaCocClient, getSpaCocClientErrorMessage } from '../api/cocClient'; import { useApp } from '../contexts/AppContext'; import { SHOW_WELCOME_TUTORIAL } from '../featureFlags'; @@ -26,9 +26,11 @@ import { DbBrowserSection } from './DbBrowserSection'; import { PromptsPanel } from './PromptsPanel'; import { ProviderTokensSection } from './ProviderTokensSection'; import { SettingsCard } from './SettingsCard'; +import { AdminInputSuffix, AdminRow, AdminSeg, AdminToggle, SourceBadge } from './adminControls'; import { applyRuntimeConfigPatch, isContainerMode, isServersEnabled } from '../utils/config'; import { AIProviderPage, normalizeAutoProviderRoutingConfig, type NormalizedAutoProviderRoutingConfig } from './AIProviderPage'; +import type { DreamsConfigForm } from '../features/dreams/DreamsView'; import { ADMIN_SETTING_DEFINITIONS, FEATURE_CARD_GROUPS, @@ -411,6 +413,12 @@ export function AdminPanel() { const [dreamProviderActivity, setDreamProviderActivity] = useState([]); const [dreamProviderActivityError, setDreamProviderActivityError] = useState(null); + // Dreams tab config (global). Owned here so it loads with the rest of the + // admin config; edited + saved from the Dreams tab (Knowledge nav group). + const [dreamsForm, setDreamsForm] = useState({ enabled: false }); + const [dreamsSnapshot, setDreamsSnapshot] = useState({ enabled: false }); + const [dreamsSaving, setDreamsSaving] = useState(false); + // Snapshots for per-card dirty tracking (set when config/prefs loads) const [aiExecSnapshot, setAiExecSnapshot] = useState({ model: '', parallel: '1', timeout: '', output: 'table' }); const [defaultProviderSnapshot, setDefaultProviderSnapshot] = useState({ @@ -513,6 +521,9 @@ export function AdminPanel() { const loadedFeatures = readFeatureValues(resolved); setFeatureValues(loadedFeatures); setFeaturesSnapshot(loadedFeatures); + const loadedDreams: DreamsConfigForm = { enabled: resolved.dreams?.enabled ?? false }; + setDreamsForm(loadedDreams); + setDreamsSnapshot(loadedDreams); const aapre = resolved.features?.autoAgentProviderRouting ?? false; setAutoAgentProviderRoutingEnabled(aapre); const cxe = resolved.codex?.enabled ?? false; @@ -624,6 +635,8 @@ export function AdminPanel() { const featuresDirty = FEATURES_CARD_SETTINGS.some(def => featureValues[def.key] !== featuresSnapshot[def.key]); + const dreamsDirty = dreamsForm.enabled !== dreamsSnapshot.enabled; + // ── AI & Execution card ── const handleSaveAiExec = useCallback(async () => { const errors: string[] = []; @@ -886,6 +899,26 @@ export function AdminPanel() { setFeatureValues({ ...featuresSnapshot }); }, [featuresSnapshot]); + // ── Dreams tab config card ── + const handleSaveDreams = useCallback(async () => { + setDreamsSaving(true); + try { + await getSpaCocClient().admin.updateConfig({ 'dreams.enabled': dreamsForm.enabled }); + addToast('Settings saved', 'success'); + invalidateDisplaySettings(); + applyRuntimeConfigPatch({ dreamsEnabled: dreamsForm.enabled }); + setDreamsSnapshot({ ...dreamsForm }); + } catch (err: unknown) { + addToast(getSpaCocClientErrorMessage(err, 'Save failed'), 'error'); + } finally { + setDreamsSaving(false); + } + }, [dreamsForm, addToast]); + + const handleCancelDreams = useCallback(() => { + setDreamsForm({ ...dreamsSnapshot }); + }, [dreamsSnapshot]); + const handleSaveServerName = useCallback(async () => { const trimmed = serverName.trim(); try { @@ -1297,6 +1330,12 @@ export function AdminPanel() { />} {activeToolItem.tab === 'skills' && } {activeToolItem.tab === 'dreams-admin' && setDreamsForm(prev => ({ ...prev, ...patch }))} + configDirty={dreamsDirty} + configSaving={dreamsSaving} + onSaveConfig={handleSaveDreams} + onCancelConfig={handleCancelDreams} providerActivity={dreamProviderActivity} providerActivityError={dreamProviderActivityError} onRefreshProviderActivity={refreshDreamProviderActivity} @@ -2030,104 +2069,3 @@ function resolveNestedValue(obj: Record, key: string): unknown return current; } -function SourceBadge({ source, isDefault }: { source?: string; isDefault?: boolean }) { - const s = source || 'default'; - const variant = - s === 'cli' ? 'ar-src-cli' : - s === 'env' ? 'ar-src-env' : - s === 'file' || s === 'config' ? 'ar-src-config' : - ''; - const modifiedClass = isDefault === false ? ' ar-src-modified' : ''; - const label = isDefault === false ? 'modified' : s; - const title = isDefault === false - ? `Value differs from the built-in default (source: ${s})` - : `Source: ${s}`; - return {label}; -} - -/* ── Row primitives that produce the new visual without changing behaviour ── */ - -interface AdminRowProps { - name: ReactNode; - hint?: ReactNode; - children: ReactNode; - 'data-testid'?: string; -} -function AdminRow({ name, hint, children, 'data-testid': dataTestId }: AdminRowProps) { - return ( -
-
-
{name}
- {hint &&
{hint}
} -
-
{children}
-
- ); -} - -interface AdminToggleProps { - checked: boolean; - onChange: (checked: boolean) => void; - disabled?: boolean; - 'data-testid'?: string; - 'aria-label'?: string; -} -function AdminToggle({ checked, onChange, disabled, 'data-testid': dataTestId, 'aria-label': ariaLabel }: AdminToggleProps) { - return ( - - ); -} - -interface AdminSegOption { - value: T; - label: string; - testId?: string; -} -interface AdminSegProps { - value: T; - onChange: (value: T) => void; - options: ReadonlyArray>; - 'aria-label'?: string; -} -function AdminSeg({ value, onChange, options, 'aria-label': ariaLabel }: AdminSegProps) { - return ( -
- {options.map(opt => ( - - ))} -
- ); -} - -interface AdminInputSuffixProps { - suffix: string; - children: ReactNode; -} -function AdminInputSuffix({ suffix, children }: AdminInputSuffixProps) { - return ( - - {children} - {suffix} - - ); -} diff --git a/packages/coc/src/server/spa/client/react/admin/adminControls.tsx b/packages/coc/src/server/spa/client/react/admin/adminControls.tsx new file mode 100644 index 000000000..3741bae61 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/admin/adminControls.tsx @@ -0,0 +1,110 @@ +/** + * Admin row primitives — shared presentational controls for settings surfaces. + * + * Extracted from `AdminPanel.tsx` so other admin-shell views (e.g. the Dreams + * tab) can render the same Linear-inspired rows/toggles/segments without + * duplicating markup. Visuals come from `admin-redesign.css`; these components + * are pure and carry no behaviour of their own. + */ + +import type { ReactNode } from 'react'; + +export function SourceBadge({ source, isDefault }: { source?: string; isDefault?: boolean }) { + const s = source || 'default'; + const variant = + s === 'cli' ? 'ar-src-cli' : + s === 'env' ? 'ar-src-env' : + s === 'file' || s === 'config' ? 'ar-src-config' : + ''; + const modifiedClass = isDefault === false ? ' ar-src-modified' : ''; + const label = isDefault === false ? 'modified' : s; + const title = isDefault === false + ? `Value differs from the built-in default (source: ${s})` + : `Source: ${s}`; + return {label}; +} + +export interface AdminRowProps { + name: ReactNode; + hint?: ReactNode; + children: ReactNode; + 'data-testid'?: string; +} +export function AdminRow({ name, hint, children, 'data-testid': dataTestId }: AdminRowProps) { + return ( +
+
+
{name}
+ {hint &&
{hint}
} +
+
{children}
+
+ ); +} + +export interface AdminToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + 'data-testid'?: string; + 'aria-label'?: string; +} +export function AdminToggle({ checked, onChange, disabled, 'data-testid': dataTestId, 'aria-label': ariaLabel }: AdminToggleProps) { + return ( + + ); +} + +interface AdminSegOption { + value: T; + label: string; + testId?: string; +} +export interface AdminSegProps { + value: T; + onChange: (value: T) => void; + options: ReadonlyArray>; + 'aria-label'?: string; +} +export function AdminSeg({ value, onChange, options, 'aria-label': ariaLabel }: AdminSegProps) { + return ( +
+ {options.map(opt => ( + + ))} +
+ ); +} + +interface AdminInputSuffixProps { + suffix: string; + children: ReactNode; +} +export function AdminInputSuffix({ suffix, children }: AdminInputSuffixProps) { + return ( + + {children} + {suffix} + + ); +} diff --git a/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx index 27d2484cd..114aa89df 100644 --- a/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx +++ b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx @@ -10,19 +10,42 @@ // The per-workspace dream-cards review panel (`DreamsPanel`) is a separate, // untouched surface under each repo's detail view. // -// State, fetch, and refresh for the provider-activity section are owned by -// `AdminPanel` and passed in as props. +// Config (form + dirty/save) and the provider-activity feed are owned by +// `AdminPanel` and passed in as props, so they load with the rest of the admin +// config and reuse the shared toast + runtime-flag plumbing. +import { SettingsCard } from '../../admin/SettingsCard'; +import { AdminRow, AdminToggle } from '../../admin/adminControls'; import type { AgentProviderWorkActivity } from '../../shared/providerActivity'; import { ProviderActivitySection } from './ProviderActivitySection'; +/** Editable global Dreams settings surfaced on the Dreams tab. */ +export interface DreamsConfigForm { + /** Global `dreams.enabled` flag — gates idle-time reflection everywhere. */ + enabled: boolean; +} + export interface DreamsViewProps { + config?: DreamsConfigForm; + onConfigChange?: (patch: Partial) => void; + configDirty?: boolean; + configSaving?: boolean; + onSaveConfig?: () => void; + onCancelConfig?: () => void; providerActivity?: AgentProviderWorkActivity[]; providerActivityError?: string | null; onRefreshProviderActivity?: () => void; } +const DEFAULT_CONFIG: DreamsConfigForm = { enabled: false }; + export function DreamsView({ + config = DEFAULT_CONFIG, + onConfigChange, + configDirty, + configSaving, + onSaveConfig, + onCancelConfig, providerActivity = [], providerActivityError, onRefreshProviderActivity, @@ -40,6 +63,27 @@ export function DreamsView({ Restart-aware
+ + Enable Dreams Experimental} + hint="Enables workspace opt-in review cards from idle-time reflection. Disabled by default; workspaces must also opt in individually." + > + onConfigChange?.({ enabled })} + data-testid="toggle-dreams-enabled" + /> + + + { } } }); + + // AC-03: `dreams.enabled` is rendered bespoke in the admin Dreams tab, not on + // the general Features grid — it must stay a valid admin-editable definition + // (live runtime flag) while omitting its `ui` block. + it('keeps dreams.enabled admin-editable but off the Features card', () => { + const dreams = ADMIN_SETTING_DEFINITIONS.find(d => d.key === 'dreams.enabled'); + expect(dreams, 'dreams.enabled must remain an admin setting').toBeDefined(); + expect(dreams!.ui, 'dreams.enabled must not be on the Features card').toBeUndefined(); + expect(dreams!.runtimeFlag).toBe('dreamsEnabled'); + expect(dreams!.runtime).toBe('live'); + }); }); diff --git a/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx b/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx index f8a93d1f6..2f2c72eba 100644 --- a/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx +++ b/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx @@ -5,6 +5,10 @@ * here from the AI Provider page. These tests assert the section renders inside * the Dreams tab, attributes runs to provider/model/timeout, and that the * Refresh control is preserved. + * + * AC-03: the global `dreams.enabled` toggle now lives in this tab (removed from + * the general Settings → Features grid). These tests assert the toggle renders, + * reflects the passed config, and drives the change/save/cancel callbacks. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fireEvent, render, screen, within } from '@testing-library/react'; @@ -25,10 +29,43 @@ describe('DreamsView', () => { it('renders the Dreams page shell with title and restart-aware badge', () => { render(); expect(screen.getByTestId('dreams-admin-page')).toBeDefined(); - expect(screen.getByText('Dreams')).toBeDefined(); + expect(screen.getByRole('heading', { level: 2, name: 'Dreams' })).toBeDefined(); expect(screen.getByText('Restart-aware')).toBeDefined(); }); + // ── AC-03: global dreams.enabled toggle lives in the tab ── + it('renders the dreams.enabled toggle reflecting the passed config', () => { + const { rerender } = render(); + const toggle = screen.getByTestId('toggle-dreams-enabled') as HTMLInputElement; + expect(toggle.checked).toBe(false); + rerender(); + expect((screen.getByTestId('toggle-dreams-enabled') as HTMLInputElement).checked).toBe(true); + }); + + it('invokes onConfigChange with the new enabled value when toggled', () => { + const onConfigChange = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('toggle-dreams-enabled')); + expect(onConfigChange).toHaveBeenCalledWith({ enabled: true }); + }); + + it('wires the settings card Save/Cancel footer to the config handlers when dirty', () => { + const onSaveConfig = vi.fn(); + const onCancelConfig = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId('dreams-settings-save')); + expect(onSaveConfig).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByTestId('dreams-settings-cancel')); + expect(onCancelConfig).toHaveBeenCalledOnce(); + }); + it('renders the relocated Dreams provider activity section with provider/model/timeout attribution', () => { const activity: AgentProviderWorkActivity[] = [{ id: 'dream-task-1', From 683a7ace197ef1d820adb5d71e50a56c2aabaddd Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 08:38:09 +0000 Subject: [PATCH 15/37] Add Dreams idle interval admin setting --- .../coc-knowledge/references/admin-config.md | 2 +- .../coc-knowledge/references/dashboard-spa.md | 10 +-- .../src/config/admin-setting-definitions.ts | 6 ++ .../spa/client/react/admin/AdminPanel.tsx | 23 +++++-- .../react/features/dreams/DreamsView.tsx | 24 ++++++- .../config/admin-setting-definitions.test.ts | 10 +++ .../test/server/admin-config-fields.test.ts | 47 ++++++++++---- .../admin/AdminPanel-tools-sidebar.test.tsx | 64 +++++++++++++++++-- .../react/features/dreams/DreamsView.test.tsx | 26 ++++++-- 9 files changed, 177 insertions(+), 35 deletions(-) diff --git a/.github/skills/coc-knowledge/references/admin-config.md b/.github/skills/coc-knowledge/references/admin-config.md index b31deb9ef..5c1c6dd23 100644 --- a/.github/skills/coc-knowledge/references/admin-config.md +++ b/.github/skills/coc-knowledge/references/admin-config.md @@ -27,7 +27,7 @@ Cross-field constraints belong in `CLIConfigSchema`/`validateConfigWithSchema()` The `spaHtml` function in `packages/coc/src/server/index.ts` reads the RuntimeConfigService snapshot on every page request, so feature-flag changes (e.g. `terminal.enabled`) take effect on the next browser reload — no server restart required. -Work Items expose live flags through this path: `workItems.hierarchy.enabled` enables the hierarchy board, `workItems.sync.enabled` enables remote provider integration, `workItems.aiAuthoring.enabled` enables AI-assisted authoring, and `workItems.workflow.enabled` gates the durable Work Items/Goals workflow command center. Sync UI helpers treat provider integration as enabled only when both hierarchy and sync flags are true; provider credentials stay external and are not admin config fields. The durable workflow flag is disabled by default and should gate new Work Items/Goals workflow behavior so existing Chat and Work Items behavior stays unchanged while the flag is off. Pull Requests exposes `pullRequests.enabled`, `pullRequests.suggestions`, and `pullRequests.autoClassifyTeam` through Admin -> Configure -> Features; auto-classifying Team PRs is disabled by default. Dedicated mode flags such as `forEach.enabled`, `mapReduce.enabled`, and `dreams.enabled` live as top-level namespaces and are disabled by default. `dreams.enabled` is the global Dreams gate; each workspace must also opt in through its `PerRepoPreferences.dreams.enabled` preference before dream generation can run. Additional Dreams config-file knobs tune automatic idle scheduling and analysis policy: `dreams.idleCheckIntervalMs`, `dreams.minIdleMs`, `dreams.confidenceThreshold`, `dreams.maxCandidates`, `dreams.conversationLimit`, and `dreams.timeoutMs`. Experimental dashboard/chat flags live under `features.*`; `features.gitCrossCloneCherryPick` enables the cross-clone cherry-pick commit context-menu modal and is enabled by default, `features.sessionContextAttachments` enables drag/drop session-context attachments in chat composers and is disabled by default, `features.ralphMultiAgentGrill` enables the Ralph grilling question-planning setup card, separate grill-agent calls, dedupe/provenance metadata, and grouped consolidated questions, and `features.commitChatLens` enables desktop review-chat lens placement for supported commit and PR chat surfaces. `features.ralphMultiAgentGrill` and `features.commitChatLens` are disabled by default. `features.commitChatLensDormantMode` (`'ghost'` | `'pill'`, default `'ghost'`) controls how the lens recedes when the cursor leaves: ghost fades to near-transparent with scale-down, pill collapses to a compact status pill. `features.autoAgentProviderRouting` is edited from Admin -> AI Provider, enables Auto provider routing, and is disabled by default. +Work Items expose live flags through this path: `workItems.hierarchy.enabled` enables the hierarchy board, `workItems.sync.enabled` enables remote provider integration, `workItems.aiAuthoring.enabled` enables AI-assisted authoring, and `workItems.workflow.enabled` gates the durable Work Items/Goals workflow command center. Sync UI helpers treat provider integration as enabled only when both hierarchy and sync flags are true; provider credentials stay external and are not admin config fields. The durable workflow flag is disabled by default and should gate new Work Items/Goals workflow behavior so existing Chat and Work Items behavior stays unchanged while the flag is off. Pull Requests exposes `pullRequests.enabled`, `pullRequests.suggestions`, and `pullRequests.autoClassifyTeam` through Admin -> Configure -> Features; auto-classifying Team PRs is disabled by default. Dedicated mode flags such as `forEach.enabled`, `mapReduce.enabled`, and `dreams.enabled` live as top-level namespaces and are disabled by default. `dreams.enabled` is the global Dreams gate; each workspace must also opt in through its `PerRepoPreferences.dreams.enabled` preference before dream generation can run. Admin -> Knowledge -> Dreams renders `dreams.enabled` and the restart-required `dreams.idleCheckIntervalMs` editor; the interval is entered in minutes and persisted to the global config as milliseconds. Additional Dreams config-file knobs tune automatic idle scheduling and analysis policy: `dreams.minIdleMs`, `dreams.confidenceThreshold`, `dreams.maxCandidates`, `dreams.conversationLimit`, and `dreams.timeoutMs`. Experimental dashboard/chat flags live under `features.*`; `features.gitCrossCloneCherryPick` enables the cross-clone cherry-pick commit context-menu modal and is enabled by default, `features.sessionContextAttachments` enables drag/drop session-context attachments in chat composers and is disabled by default, `features.ralphMultiAgentGrill` enables the Ralph grilling question-planning setup card, separate grill-agent calls, dedupe/provenance metadata, and grouped consolidated questions, and `features.commitChatLens` enables desktop review-chat lens placement for supported commit and PR chat surfaces. `features.ralphMultiAgentGrill` and `features.commitChatLens` are disabled by default. `features.commitChatLensDormantMode` (`'ghost'` | `'pill'`, default `'ghost'`) controls how the lens recedes when the cursor leaves: ghost fades to near-transparent with scale-down, pill collapses to a compact status pill. `features.autoAgentProviderRouting` is edited from Admin -> AI Provider, enables Auto provider routing, and is disabled by default. The AI provider admin card stores `defaultProvider` as a top-level concrete fallback key. It accepts only `copilot`, `codex`, or `claude` and is used for provider-omitted flows when Auto routing is disabled; individual chat payloads can still set `payload.provider`, and follow-ups continue with the provider recorded on the original process. `features.autoAgentProviderRouting` is the sole user-controlled Auto enablement switch. When it is true, provider-omitted new chats, tasks, and API-created work route through `agentProviderRouting.auto` by default; explicit provider selections still win. Auto routing profile configuration lives under `agentProviderRouting.auto`, with the default ordered profile `claude -> codex -> copilot`, normal thresholds `33/33/10`, matching weekly guard thresholds, and fallback `copilot`. The Admin -> AI Provider -> Provider routing subtab contains the Auto enable toggle, ordered rule editor, fallback selector, weekly-guard help text, and current-selection preview; Admin -> Configure -> Features does not expose a second Auto routing toggle. diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 055089867..53e00c3c6 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -525,10 +525,12 @@ Developer / Internals. Embedded tool rows keep stable ids (`memory-toggle`, `servers-toggle`) and `data-tab` still carries the matching dashboard route; Servers is shown only when `isServersEnabled()` is true. The Knowledge group's **Dreams** row (`dreams-admin-toggle`, route `#dreams-admin`) renders -`features/dreams/DreamsView.tsx` and is the admin home for global Dreams config -and the relocated **Dreams provider activity** queue + history section -(`features/dreams/ProviderActivitySection.tsx`); that section no longer lives on -the AI Provider page. It is distinct from the per-workspace `DreamsPanel`. +`features/dreams/DreamsView.tsx` and is the admin home for global Dreams config: +the live `dreams.enabled` toggle, `dreams.idleCheckIntervalMs` edited in minutes +with a restart hint, and the relocated **Dreams provider activity** queue + +history section (`features/dreams/ProviderActivitySection.tsx`); that section no +longer lives on the AI Provider page. It is distinct from the per-workspace +`DreamsPanel`. Clicking an embedded tool row dispatches `SET_ACTIVE_TAB` and updates `location.hash` to the corresponding top-level route (`#memory`, `#skills`, diff --git a/packages/coc/src/config/admin-setting-definitions.ts b/packages/coc/src/config/admin-setting-definitions.ts index 28dbdf331..0850423e4 100644 --- a/packages/coc/src/config/admin-setting-definitions.ts +++ b/packages/coc/src/config/admin-setting-definitions.ts @@ -586,6 +586,12 @@ export const ADMIN_SETTING_DEFINITIONS: readonly AdminSettingDefinition[] = [ bool({ key: 'dreams.enabled', default: false, runtime: 'live', runtimeFlag: 'dreamsEnabled', }), + { + key: 'dreams.idleCheckIntervalMs', + value: { kind: 'number', integer: true, gt: 0, message: 'dreams.idleCheckIntervalMs must be a positive integer number of milliseconds' }, + default: 5 * 60 * 1000, + runtime: 'restartRequired', + }, bool({ key: 'excalidraw.enabled', default: false, runtime: 'live', runtimeFlag: 'excalidrawEnabled', ui: { diff --git a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx index fa2e8557e..5937da0ec 100644 --- a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx +++ b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx @@ -415,8 +415,8 @@ export function AdminPanel() { // Dreams tab config (global). Owned here so it loads with the rest of the // admin config; edited + saved from the Dreams tab (Knowledge nav group). - const [dreamsForm, setDreamsForm] = useState({ enabled: false }); - const [dreamsSnapshot, setDreamsSnapshot] = useState({ enabled: false }); + const [dreamsForm, setDreamsForm] = useState({ enabled: false, intervalMinutes: '5' }); + const [dreamsSnapshot, setDreamsSnapshot] = useState({ enabled: false, intervalMinutes: '5' }); const [dreamsSaving, setDreamsSaving] = useState(false); // Snapshots for per-card dirty tracking (set when config/prefs loads) @@ -521,7 +521,10 @@ export function AdminPanel() { const loadedFeatures = readFeatureValues(resolved); setFeatureValues(loadedFeatures); setFeaturesSnapshot(loadedFeatures); - const loadedDreams: DreamsConfigForm = { enabled: resolved.dreams?.enabled ?? false }; + const loadedDreams: DreamsConfigForm = { + enabled: resolved.dreams?.enabled ?? false, + intervalMinutes: String(Math.round((resolved.dreams?.idleCheckIntervalMs ?? 5 * 60 * 1000) / 60_000)), + }; setDreamsForm(loadedDreams); setDreamsSnapshot(loadedDreams); const aapre = resolved.features?.autoAgentProviderRouting ?? false; @@ -635,7 +638,8 @@ export function AdminPanel() { const featuresDirty = FEATURES_CARD_SETTINGS.some(def => featureValues[def.key] !== featuresSnapshot[def.key]); - const dreamsDirty = dreamsForm.enabled !== dreamsSnapshot.enabled; + const dreamsDirty = dreamsForm.enabled !== dreamsSnapshot.enabled || + dreamsForm.intervalMinutes !== dreamsSnapshot.intervalMinutes; // ── AI & Execution card ── const handleSaveAiExec = useCallback(async () => { @@ -901,9 +905,17 @@ export function AdminPanel() { // ── Dreams tab config card ── const handleSaveDreams = useCallback(async () => { + const intervalMinutes = Number(dreamsForm.intervalMinutes); + if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1) { + addToast('Dreams idle check interval must be a positive whole number of minutes', 'error'); + return; + } setDreamsSaving(true); try { - await getSpaCocClient().admin.updateConfig({ 'dreams.enabled': dreamsForm.enabled }); + await getSpaCocClient().admin.updateConfig({ + 'dreams.enabled': dreamsForm.enabled, + 'dreams.idleCheckIntervalMs': intervalMinutes * 60_000, + }); addToast('Settings saved', 'success'); invalidateDisplaySettings(); applyRuntimeConfigPatch({ dreamsEnabled: dreamsForm.enabled }); @@ -2068,4 +2080,3 @@ function resolveNestedValue(obj: Record, key: string): unknown } return current; } - diff --git a/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx index 114aa89df..0e102f45b 100644 --- a/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx +++ b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx @@ -15,7 +15,7 @@ // config and reuse the shared toast + runtime-flag plumbing. import { SettingsCard } from '../../admin/SettingsCard'; -import { AdminRow, AdminToggle } from '../../admin/adminControls'; +import { AdminInputSuffix, AdminRow, AdminToggle } from '../../admin/adminControls'; import type { AgentProviderWorkActivity } from '../../shared/providerActivity'; import { ProviderActivitySection } from './ProviderActivitySection'; @@ -23,6 +23,8 @@ import { ProviderActivitySection } from './ProviderActivitySection'; export interface DreamsConfigForm { /** Global `dreams.enabled` flag — gates idle-time reflection everywhere. */ enabled: boolean; + /** Automatic idle-check cadence, edited in minutes and persisted as milliseconds. */ + intervalMinutes: string; } export interface DreamsViewProps { @@ -37,7 +39,7 @@ export interface DreamsViewProps { onRefreshProviderActivity?: () => void; } -const DEFAULT_CONFIG: DreamsConfigForm = { enabled: false }; +const DEFAULT_CONFIG: DreamsConfigForm = { enabled: false, intervalMinutes: '5' }; export function DreamsView({ config = DEFAULT_CONFIG, @@ -82,6 +84,24 @@ export function DreamsView({ data-testid="toggle-dreams-enabled" /> + Idle check interval Restart} + hint="How often the server checks for idle workspaces that are ready for automatic Dream runs. Saved immediately; restart the server for the scheduler cadence to use the new value." + > + + onConfigChange?.({ intervalMinutes: event.target.value })} + data-testid="dreams-idle-check-interval-minutes" + /> + + { expect(dreams!.runtimeFlag).toBe('dreamsEnabled'); expect(dreams!.runtime).toBe('live'); }); + + // AC-02: the Dreams tab renders this bespoke in minutes, but the admin + // registry must keep the persisted millisecond field editable. + it('keeps dreams.idleCheckIntervalMs admin-editable but off the Features card', () => { + const interval = ADMIN_SETTING_DEFINITIONS.find(d => d.key === 'dreams.idleCheckIntervalMs'); + expect(interval, 'dreams.idleCheckIntervalMs must be an admin setting').toBeDefined(); + expect(interval!.ui, 'dreams.idleCheckIntervalMs must not be on the Features card').toBeUndefined(); + expect(interval!.default).toBe(300_000); + expect(interval!.runtime).toBe('restartRequired'); + }); }); diff --git a/packages/coc/test/server/admin-config-fields.test.ts b/packages/coc/test/server/admin-config-fields.test.ts index 071c9653f..857efe9ad 100644 --- a/packages/coc/test/server/admin-config-fields.test.ts +++ b/packages/coc/test/server/admin-config-fields.test.ts @@ -41,6 +41,7 @@ describe('ADMIN_EDITABLE_KEYS', () => { 'scratchpad.enabled', 'scratchpad.layout', 'workflows.enabled', 'pullRequests.enabled', 'pullRequests.autoClassifyTeam', 'servers.enabled', 'ralph.enabled', 'forEach.enabled', 'vimNavigation.enabled', 'loops.enabled', 'dreams.enabled', + 'dreams.idleCheckIntervalMs', 'excalidraw.enabled', 'mcpOauth.enabled', 'mcpOauth.autoRefresh.enabled', 'codex.enabled', 'claude.enabled', @@ -162,10 +163,10 @@ describe('validate()', () => { }); }); - describe('chat.followUpSuggestions.count', () => { - it('accepts 1', () => { - expect(fieldFor('chat.followUpSuggestions.count').validate(1)).toBeUndefined(); - }); + describe('chat.followUpSuggestions.count', () => { + it('accepts 1', () => { + expect(fieldFor('chat.followUpSuggestions.count').validate(1)).toBeUndefined(); + }); it('accepts 5', () => { expect(fieldFor('chat.followUpSuggestions.count').validate(5)).toBeUndefined(); }); @@ -177,8 +178,20 @@ describe('validate()', () => { }); it('rejects float', () => { expect(fieldFor('chat.followUpSuggestions.count').validate(2.5)).toMatch(/1 and 5/); - }); - }); + }); + }); + + describe('dreams.idleCheckIntervalMs', () => { + it('accepts a positive integer millisecond interval', () => { + expect(fieldFor('dreams.idleCheckIntervalMs').validate(300_000)).toBeUndefined(); + }); + it('rejects zero', () => { + expect(fieldFor('dreams.idleCheckIntervalMs').validate(0)).toMatch(/positive integer/); + }); + it('rejects non-integers', () => { + expect(fieldFor('dreams.idleCheckIntervalMs').validate(1.5)).toMatch(/positive integer/); + }); + }); describe('scratchpad.layout', () => { it('accepts "horizontal"', () => { @@ -446,13 +459,21 @@ describe('apply()', () => { }); } - describe('scratchpad.layout', () => { - it('sets layout', () => { - const cfg: CLIConfig = {}; - fieldFor('scratchpad.layout').apply(cfg, 'vertical'); - expect(cfg.scratchpad?.layout).toBe('vertical'); - }); - }); + describe('scratchpad.layout', () => { + it('sets layout', () => { + const cfg: CLIConfig = {}; + fieldFor('scratchpad.layout').apply(cfg, 'vertical'); + expect(cfg.scratchpad?.layout).toBe('vertical'); + }); + }); + + describe('dreams.idleCheckIntervalMs', () => { + it('sets the interval, initializing the dreams namespace', () => { + const cfg: CLIConfig = {}; + fieldFor('dreams.idleCheckIntervalMs').apply(cfg, 600_000); + expect(cfg.dreams?.idleCheckIntervalMs).toBe(600_000); + }); + }); describe('features.commitChatLensDormantMode', () => { it('applies ghost to config', () => { diff --git a/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx b/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx index 377842591..6b8440adb 100644 --- a/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx +++ b/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx @@ -7,6 +7,14 @@ import { AdminPanel } from '../../../../src/server/spa/client/react/admin/AdminP // component reaches its idle state quickly. const mockFetch = vi.fn(); +function jsonResponse(body: unknown) { + return { + ok: true, + json: () => Promise.resolve(body), + headers: new Headers(), + } as Response; +} + // LogsView opens an SSE stream on mount. jsdom does not implement // EventSource — supply a minimal stub so the component does not throw // when it is rendered inside the embedded view tests. @@ -26,11 +34,7 @@ class FakeEventSource { beforeEach(() => { vi.restoreAllMocks(); mockFetch.mockReset(); - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({}), - headers: new Headers(), - }); + mockFetch.mockResolvedValue(jsonResponse({})); global.fetch = mockFetch; (globalThis as any).EventSource = FakeEventSource; if (typeof window !== 'undefined') { @@ -332,4 +336,54 @@ describe('AdminPanel — embedded tools render in the right panel', () => { // Chat tab is now marked active. expect(document.querySelector('[data-testid="settings-subtab-chat"]')!.className).toContain('is-active'); }); + + it('saves the Dreams idle check interval in milliseconds after editing minutes', async () => { + mockFetch.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/admin/config') && init?.method === 'PUT') { + return Promise.resolve(jsonResponse({ success: true })); + } + if (url.includes('/api/admin/config')) { + return Promise.resolve(jsonResponse({ + resolved: { + dreams: { + enabled: false, + idleCheckIntervalMs: 300_000, + }, + }, + })); + } + if (url.includes('/api/admin/dream-provider-activity')) { + return Promise.resolve(jsonResponse({ items: [] })); + } + return Promise.resolve(jsonResponse({})); + }); + + await act(async () => { renderAdmin(); }); + await waitFor(() => expect(document.getElementById('dreams-admin-toggle')).toBeTruthy()); + + await act(async () => { + fireEvent.click(document.getElementById('dreams-admin-toggle')!); + }); + + await waitFor(() => expect(document.querySelector('[data-testid="dreams-admin-page"]')).toBeTruthy()); + const intervalInput = document.querySelector('[data-testid="dreams-idle-check-interval-minutes"]')!; + expect(intervalInput.value).toBe('5'); + + await act(async () => { + fireEvent.change(intervalInput, { target: { value: '12' } }); + }); + await act(async () => { + fireEvent.click(document.querySelector('[data-testid="dreams-settings-save"]')!); + }); + + const saveCall = mockFetch.mock.calls.find(([input, init]: [RequestInfo | URL, RequestInit | undefined]) => + String(input).includes('/api/admin/config') && init?.method === 'PUT' + ); + expect(saveCall).toBeTruthy(); + expect(JSON.parse(String(saveCall![1]!.body))).toMatchObject({ + 'dreams.enabled': false, + 'dreams.idleCheckIntervalMs': 720_000, + }); + }); }); diff --git a/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx b/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx index 2f2c72eba..2733f5554 100644 --- a/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx +++ b/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx @@ -9,6 +9,9 @@ * AC-03: the global `dreams.enabled` toggle now lives in this tab (removed from * the general Settings → Features grid). These tests assert the toggle renders, * reflects the passed config, and drives the change/save/cancel callbacks. + * + * AC-02: `dreams.idleCheckIntervalMs` is edited here in minutes, while the + * owner component persists milliseconds through the global config API. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fireEvent, render, screen, within } from '@testing-library/react'; @@ -35,26 +38,41 @@ describe('DreamsView', () => { // ── AC-03: global dreams.enabled toggle lives in the tab ── it('renders the dreams.enabled toggle reflecting the passed config', () => { - const { rerender } = render(); + const { rerender } = render(); const toggle = screen.getByTestId('toggle-dreams-enabled') as HTMLInputElement; expect(toggle.checked).toBe(false); - rerender(); + rerender(); expect((screen.getByTestId('toggle-dreams-enabled') as HTMLInputElement).checked).toBe(true); }); it('invokes onConfigChange with the new enabled value when toggled', () => { const onConfigChange = vi.fn(); - render(); + render(); fireEvent.click(screen.getByTestId('toggle-dreams-enabled')); expect(onConfigChange).toHaveBeenCalledWith({ enabled: true }); }); + // ── AC-02: idle interval is edited in minutes ── + it('renders the idle check interval in minutes with a restart hint', () => { + render(); + const input = screen.getByTestId('dreams-idle-check-interval-minutes') as HTMLInputElement; + expect(input.value).toBe('7'); + expect(screen.getByText(/restart the server for the scheduler cadence/i)).toBeDefined(); + }); + + it('invokes onConfigChange with the new interval minute string when edited', () => { + const onConfigChange = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('dreams-idle-check-interval-minutes'), { target: { value: '12' } }); + expect(onConfigChange).toHaveBeenCalledWith({ intervalMinutes: '12' }); + }); + it('wires the settings card Save/Cancel footer to the config handlers when dirty', () => { const onSaveConfig = vi.fn(); const onCancelConfig = vi.fn(); render( Date: Sat, 13 Jun 2026 08:52:20 +0000 Subject: [PATCH 16/37] Add Dreams run defaults to admin tab --- .../coc-knowledge/references/admin-config.md | 2 +- .../coc-knowledge/references/dashboard-spa.md | 9 +-- packages/coc-client/src/contracts/admin.ts | 6 ++ packages/coc/src/config.ts | 8 +++ .../src/config/admin-setting-definitions.ts | 25 +++++++- packages/coc/src/config/namespace-registry.ts | 2 - packages/coc/src/config/schema.ts | 4 +- packages/coc/src/server/routes/index.ts | 2 + .../spa/client/react/admin/AdminPanel.tsx | 17 ++++- .../react/features/dreams/DreamsView.tsx | 58 ++++++++++++++++- packages/coc/test/config.test.ts | 4 ++ .../test/server/dream-idle-scheduler.test.ts | 21 ++++++- packages/coc/test/server/routes-index.test.ts | 63 ++++++++++++++++++- .../admin/AdminPanel-tools-sidebar.test.tsx | 12 ++++ .../react/features/dreams/DreamsView.test.tsx | 46 ++++++++++++-- 15 files changed, 257 insertions(+), 22 deletions(-) diff --git a/.github/skills/coc-knowledge/references/admin-config.md b/.github/skills/coc-knowledge/references/admin-config.md index 5c1c6dd23..318aa29b8 100644 --- a/.github/skills/coc-knowledge/references/admin-config.md +++ b/.github/skills/coc-knowledge/references/admin-config.md @@ -27,7 +27,7 @@ Cross-field constraints belong in `CLIConfigSchema`/`validateConfigWithSchema()` The `spaHtml` function in `packages/coc/src/server/index.ts` reads the RuntimeConfigService snapshot on every page request, so feature-flag changes (e.g. `terminal.enabled`) take effect on the next browser reload — no server restart required. -Work Items expose live flags through this path: `workItems.hierarchy.enabled` enables the hierarchy board, `workItems.sync.enabled` enables remote provider integration, `workItems.aiAuthoring.enabled` enables AI-assisted authoring, and `workItems.workflow.enabled` gates the durable Work Items/Goals workflow command center. Sync UI helpers treat provider integration as enabled only when both hierarchy and sync flags are true; provider credentials stay external and are not admin config fields. The durable workflow flag is disabled by default and should gate new Work Items/Goals workflow behavior so existing Chat and Work Items behavior stays unchanged while the flag is off. Pull Requests exposes `pullRequests.enabled`, `pullRequests.suggestions`, and `pullRequests.autoClassifyTeam` through Admin -> Configure -> Features; auto-classifying Team PRs is disabled by default. Dedicated mode flags such as `forEach.enabled`, `mapReduce.enabled`, and `dreams.enabled` live as top-level namespaces and are disabled by default. `dreams.enabled` is the global Dreams gate; each workspace must also opt in through its `PerRepoPreferences.dreams.enabled` preference before dream generation can run. Admin -> Knowledge -> Dreams renders `dreams.enabled` and the restart-required `dreams.idleCheckIntervalMs` editor; the interval is entered in minutes and persisted to the global config as milliseconds. Additional Dreams config-file knobs tune automatic idle scheduling and analysis policy: `dreams.minIdleMs`, `dreams.confidenceThreshold`, `dreams.maxCandidates`, `dreams.conversationLimit`, and `dreams.timeoutMs`. Experimental dashboard/chat flags live under `features.*`; `features.gitCrossCloneCherryPick` enables the cross-clone cherry-pick commit context-menu modal and is enabled by default, `features.sessionContextAttachments` enables drag/drop session-context attachments in chat composers and is disabled by default, `features.ralphMultiAgentGrill` enables the Ralph grilling question-planning setup card, separate grill-agent calls, dedupe/provenance metadata, and grouped consolidated questions, and `features.commitChatLens` enables desktop review-chat lens placement for supported commit and PR chat surfaces. `features.ralphMultiAgentGrill` and `features.commitChatLens` are disabled by default. `features.commitChatLensDormantMode` (`'ghost'` | `'pill'`, default `'ghost'`) controls how the lens recedes when the cursor leaves: ghost fades to near-transparent with scale-down, pill collapses to a compact status pill. `features.autoAgentProviderRouting` is edited from Admin -> AI Provider, enables Auto provider routing, and is disabled by default. +Work Items expose live flags through this path: `workItems.hierarchy.enabled` enables the hierarchy board, `workItems.sync.enabled` enables remote provider integration, `workItems.aiAuthoring.enabled` enables AI-assisted authoring, and `workItems.workflow.enabled` gates the durable Work Items/Goals workflow command center. Sync UI helpers treat provider integration as enabled only when both hierarchy and sync flags are true; provider credentials stay external and are not admin config fields. The durable workflow flag is disabled by default and should gate new Work Items/Goals workflow behavior so existing Chat and Work Items behavior stays unchanged while the flag is off. Pull Requests exposes `pullRequests.enabled`, `pullRequests.suggestions`, and `pullRequests.autoClassifyTeam` through Admin -> Configure -> Features; auto-classifying Team PRs is disabled by default. Dedicated mode flags such as `forEach.enabled`, `mapReduce.enabled`, and `dreams.enabled` live as top-level namespaces and are disabled by default. `dreams.enabled` is the global Dreams gate; each workspace must also opt in through its `PerRepoPreferences.dreams.enabled` preference before dream generation can run. Admin -> Knowledge -> Dreams renders `dreams.enabled`, the restart-required `dreams.idleCheckIntervalMs` editor, and idle-run defaults for `dreams.provider`, `dreams.model`, and `dreams.timeoutMs`; interval and timeout are entered in minutes and persisted to the global config as milliseconds. Additional Dreams config-file knobs tune automatic idle scheduling and analysis policy: `dreams.minIdleMs`, `dreams.confidenceThreshold`, `dreams.maxCandidates`, and `dreams.conversationLimit`. Experimental dashboard/chat flags live under `features.*`; `features.gitCrossCloneCherryPick` enables the cross-clone cherry-pick commit context-menu modal and is enabled by default, `features.sessionContextAttachments` enables drag/drop session-context attachments in chat composers and is disabled by default, `features.ralphMultiAgentGrill` enables the Ralph grilling question-planning setup card, separate grill-agent calls, dedupe/provenance metadata, and grouped consolidated questions, and `features.commitChatLens` enables desktop review-chat lens placement for supported commit and PR chat surfaces. `features.ralphMultiAgentGrill` and `features.commitChatLens` are disabled by default. `features.commitChatLensDormantMode` (`'ghost'` | `'pill'`, default `'ghost'`) controls how the lens recedes when the cursor leaves: ghost fades to near-transparent with scale-down, pill collapses to a compact status pill. `features.autoAgentProviderRouting` is edited from Admin -> AI Provider, enables Auto provider routing, and is disabled by default. The AI provider admin card stores `defaultProvider` as a top-level concrete fallback key. It accepts only `copilot`, `codex`, or `claude` and is used for provider-omitted flows when Auto routing is disabled; individual chat payloads can still set `payload.provider`, and follow-ups continue with the provider recorded on the original process. `features.autoAgentProviderRouting` is the sole user-controlled Auto enablement switch. When it is true, provider-omitted new chats, tasks, and API-created work route through `agentProviderRouting.auto` by default; explicit provider selections still win. Auto routing profile configuration lives under `agentProviderRouting.auto`, with the default ordered profile `claude -> codex -> copilot`, normal thresholds `33/33/10`, matching weekly guard thresholds, and fallback `copilot`. The Admin -> AI Provider -> Provider routing subtab contains the Auto enable toggle, ordered rule editor, fallback selector, weekly-guard help text, and current-selection preview; Admin -> Configure -> Features does not expose a second Auto routing toggle. diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 53e00c3c6..fc51bccda 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -527,10 +527,11 @@ Servers is shown only when `isServersEnabled()` is true. The Knowledge group's **Dreams** row (`dreams-admin-toggle`, route `#dreams-admin`) renders `features/dreams/DreamsView.tsx` and is the admin home for global Dreams config: the live `dreams.enabled` toggle, `dreams.idleCheckIntervalMs` edited in minutes -with a restart hint, and the relocated **Dreams provider activity** queue + -history section (`features/dreams/ProviderActivitySection.tsx`); that section no -longer lives on the AI Provider page. It is distinct from the per-workspace -`DreamsPanel`. +with a restart hint, idle-run defaults for provider, model, and timeout +(`dreams.provider`, `dreams.model`, `dreams.timeoutMs`), and the relocated +**Dreams provider activity** queue + history section +(`features/dreams/ProviderActivitySection.tsx`); that section no longer lives on +the AI Provider page. It is distinct from the per-workspace `DreamsPanel`. Clicking an embedded tool row dispatches `SET_ACTIVE_TAB` and updates `location.hash` to the corresponding top-level route (`#memory`, `#skills`, diff --git a/packages/coc-client/src/contracts/admin.ts b/packages/coc-client/src/contracts/admin.ts index 2045e6fe9..5ad95d4e6 100644 --- a/packages/coc-client/src/contracts/admin.ts +++ b/packages/coc-client/src/contracts/admin.ts @@ -86,6 +86,8 @@ export interface AdminResolvedConfig { mapReduce?: { enabled?: boolean }; dreams?: { enabled?: boolean; + provider?: AdminDefaultProvider; + model?: string; idleCheckIntervalMs?: number; minIdleMs?: number; confidenceThreshold?: number; @@ -175,6 +177,10 @@ export interface AdminConfigUpdate { 'forEach.enabled'?: boolean; 'mapReduce.enabled'?: boolean; 'dreams.enabled'?: boolean; + 'dreams.provider'?: AdminDefaultProvider | null; + 'dreams.model'?: string | null; + 'dreams.idleCheckIntervalMs'?: number; + 'dreams.timeoutMs'?: number; 'excalidraw.enabled'?: boolean; 'mcpOauth.enabled'?: boolean; 'mcpOauth.autoRefresh.enabled'?: boolean; diff --git a/packages/coc/src/config.ts b/packages/coc/src/config.ts index d5ea37109..47810d34a 100644 --- a/packages/coc/src/config.ts +++ b/packages/coc/src/config.ts @@ -176,6 +176,10 @@ export interface CLIConfig { /** Dreams review subsystem configuration. Disabled by default. */ dreams?: { enabled?: boolean; + /** Default provider for idle-triggered Dream runs. Defaults to the global provider when unset. */ + provider?: DefaultAgentProvider; + /** Default model for idle-triggered Dream runs. Defaults to the provider default when unset. */ + model?: string; /** Period between automatic idle dream checks. Default: 5 minutes. */ idleCheckIntervalMs?: number; /** Minimum quiet-window duration before automatic dream analysis may run. Default: 15 minutes. */ @@ -458,6 +462,8 @@ export interface ResolvedCLIConfig { /** Dreams review subsystem configuration. */ dreams: { enabled: boolean; + provider?: DefaultAgentProvider; + model?: string; idleCheckIntervalMs: number; minIdleMs: number; confidenceThreshold: number; @@ -691,6 +697,8 @@ export const DEFAULT_CONFIG: ResolvedCLIConfig = { }, dreams: { enabled: false, + provider: undefined, + model: undefined, idleCheckIntervalMs: 5 * 60 * 1000, minIdleMs: 15 * 60 * 1000, confidenceThreshold: 0.85, diff --git a/packages/coc/src/config/admin-setting-definitions.ts b/packages/coc/src/config/admin-setting-definitions.ts index 0850423e4..0c17b3f93 100644 --- a/packages/coc/src/config/admin-setting-definitions.ts +++ b/packages/coc/src/config/admin-setting-definitions.ts @@ -56,6 +56,8 @@ export type AdminSettingValueSpec = | { kind: 'enum'; values: readonly string[]; + /** Accept null/undefined; applying null clears the stored value. */ + nullable?: boolean; /** Validation error message override. */ message?: string; } @@ -226,8 +228,10 @@ export function validateAdminSettingValue(def: AdminSettingDefinition, value: un return ok ? undefined : numberMessage(def.key, spec); } case 'enum': { + if (spec.nullable && (value === null || value === undefined)) return undefined; const ok = typeof value === 'string' && spec.values.includes(value); - return ok ? undefined : (spec.message ?? `${def.key} must be one of: ${spec.values.join(', ')}`); + const base = spec.message ?? `${def.key} must be one of: ${spec.values.join(', ')}`; + return ok ? undefined : (spec.nullable ? `${base}, or null to clear` : base); } case 'custom': return spec.validate(value); @@ -241,6 +245,7 @@ function clearsStoredValue(def: AdminSettingDefinition, value: unknown): boolean if (spec.kind === 'string' && spec.nullable) { return value === null || (spec.clearOnEmpty === true && value === ''); } + if (spec.kind === 'enum' && spec.nullable) return value === null; return false; } @@ -586,12 +591,30 @@ export const ADMIN_SETTING_DEFINITIONS: readonly AdminSettingDefinition[] = [ bool({ key: 'dreams.enabled', default: false, runtime: 'live', runtimeFlag: 'dreamsEnabled', }), + { + key: 'dreams.provider', + value: { kind: 'enum', values: ['copilot', 'codex', 'claude'], nullable: true, message: 'dreams.provider must be "copilot", "codex", or "claude"' }, + default: undefined, + runtime: 'live', + }, + { + key: 'dreams.model', + value: { kind: 'string', nullable: true, clearOnEmpty: true }, + default: undefined, + runtime: 'live', + }, { key: 'dreams.idleCheckIntervalMs', value: { kind: 'number', integer: true, gt: 0, message: 'dreams.idleCheckIntervalMs must be a positive integer number of milliseconds' }, default: 5 * 60 * 1000, runtime: 'restartRequired', }, + { + key: 'dreams.timeoutMs', + value: { kind: 'number', integer: true, gt: 0, message: 'dreams.timeoutMs must be a positive integer number of milliseconds' }, + default: 3_600_000, + runtime: 'live', + }, bool({ key: 'excalidraw.enabled', default: false, runtime: 'live', runtimeFlag: 'excalidrawEnabled', ui: { diff --git a/packages/coc/src/config/namespace-registry.ts b/packages/coc/src/config/namespace-registry.ts index b6e860f06..a240c366e 100644 --- a/packages/coc/src/config/namespace-registry.ts +++ b/packages/coc/src/config/namespace-registry.ts @@ -77,7 +77,6 @@ const DREAMS_BASE_SOURCE_KEYS = [ 'dreams.confidenceThreshold', 'dreams.maxCandidates', 'dreams.conversationLimit', - 'dreams.timeoutMs', ] as const; const MEMORY_PROMOTION_SOURCE_KEYS = [ @@ -232,7 +231,6 @@ export function createConfigNamespaceRegistry(defaultBundledSkills: readonly str confidenceThreshold: override?.dreams?.confidenceThreshold ?? base.dreams?.confidenceThreshold ?? 0.85, maxCandidates: override?.dreams?.maxCandidates ?? base.dreams?.maxCandidates ?? 8, conversationLimit: override?.dreams?.conversationLimit ?? base.dreams?.conversationLimit ?? 20, - timeoutMs: override?.dreams?.timeoutMs ?? base.dreams?.timeoutMs ?? 3_600_000, } as ResolvedCLIConfig['dreams'], }), }, diff --git a/packages/coc/src/config/schema.ts b/packages/coc/src/config/schema.ts index 23f9a87d7..9b21823c1 100644 --- a/packages/coc/src/config/schema.ts +++ b/packages/coc/src/config/schema.ts @@ -77,7 +77,9 @@ function zodLeafForSpec(key: string, spec: AdminSettingValueSpec): z.ZodTypeAny case 'string': return z.string(); case 'enum': - return z.enum(spec.values as [string, ...string[]]); + return spec.nullable + ? z.enum(spec.values as [string, ...string[]]).nullish() + : z.enum(spec.values as [string, ...string[]]); case 'number': { let leaf = z.number(); if (spec.integer) leaf = leaf.int(); diff --git a/packages/coc/src/server/routes/index.ts b/packages/coc/src/server/routes/index.ts index 4bdaffa65..9bd110d0d 100644 --- a/packages/coc/src/server/routes/index.ts +++ b/packages/coc/src/server/routes/index.ts @@ -792,6 +792,8 @@ export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): const getDefaultDreamRunOptions = (): DreamRunRequestOptions => { const dreams = (opts.runtimeConfigService?.config ?? opts.resolvedConfig)?.dreams; return { + provider: dreams?.provider, + model: dreams?.model, minIdleMs: dreams?.minIdleMs, confidenceThreshold: dreams?.confidenceThreshold, maxCandidates: dreams?.maxCandidates, diff --git a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx index 5937da0ec..2a86780c4 100644 --- a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx +++ b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx @@ -415,8 +415,8 @@ export function AdminPanel() { // Dreams tab config (global). Owned here so it loads with the rest of the // admin config; edited + saved from the Dreams tab (Knowledge nav group). - const [dreamsForm, setDreamsForm] = useState({ enabled: false, intervalMinutes: '5' }); - const [dreamsSnapshot, setDreamsSnapshot] = useState({ enabled: false, intervalMinutes: '5' }); + const [dreamsForm, setDreamsForm] = useState({ enabled: false, provider: '', model: '', timeoutMinutes: '60', intervalMinutes: '5' }); + const [dreamsSnapshot, setDreamsSnapshot] = useState({ enabled: false, provider: '', model: '', timeoutMinutes: '60', intervalMinutes: '5' }); const [dreamsSaving, setDreamsSaving] = useState(false); // Snapshots for per-card dirty tracking (set when config/prefs loads) @@ -523,6 +523,11 @@ export function AdminPanel() { setFeaturesSnapshot(loadedFeatures); const loadedDreams: DreamsConfigForm = { enabled: resolved.dreams?.enabled ?? false, + provider: resolved.dreams?.provider === 'codex' || resolved.dreams?.provider === 'claude' || resolved.dreams?.provider === 'copilot' + ? resolved.dreams.provider + : '', + model: resolved.dreams?.model ?? '', + timeoutMinutes: String(Math.round((resolved.dreams?.timeoutMs ?? 3_600_000) / 60_000)), intervalMinutes: String(Math.round((resolved.dreams?.idleCheckIntervalMs ?? 5 * 60 * 1000) / 60_000)), }; setDreamsForm(loadedDreams); @@ -910,11 +915,19 @@ export function AdminPanel() { addToast('Dreams idle check interval must be a positive whole number of minutes', 'error'); return; } + const timeoutMinutes = Number(dreamsForm.timeoutMinutes); + if (!Number.isInteger(timeoutMinutes) || timeoutMinutes < 1) { + addToast('Dreams run timeout must be a positive whole number of minutes', 'error'); + return; + } setDreamsSaving(true); try { await getSpaCocClient().admin.updateConfig({ 'dreams.enabled': dreamsForm.enabled, + 'dreams.provider': dreamsForm.provider || null, + 'dreams.model': dreamsForm.model.trim() || null, 'dreams.idleCheckIntervalMs': intervalMinutes * 60_000, + 'dreams.timeoutMs': timeoutMinutes * 60_000, }); addToast('Settings saved', 'success'); invalidateDisplaySettings(); diff --git a/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx index 0e102f45b..7fc83a093 100644 --- a/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx +++ b/packages/coc/src/server/spa/client/react/features/dreams/DreamsView.tsx @@ -15,7 +15,7 @@ // config and reuse the shared toast + runtime-flag plumbing. import { SettingsCard } from '../../admin/SettingsCard'; -import { AdminInputSuffix, AdminRow, AdminToggle } from '../../admin/adminControls'; +import { AdminInputSuffix, AdminRow, AdminSeg, AdminToggle } from '../../admin/adminControls'; import type { AgentProviderWorkActivity } from '../../shared/providerActivity'; import { ProviderActivitySection } from './ProviderActivitySection'; @@ -23,6 +23,12 @@ import { ProviderActivitySection } from './ProviderActivitySection'; export interface DreamsConfigForm { /** Global `dreams.enabled` flag — gates idle-time reflection everywhere. */ enabled: boolean; + /** Default provider for idle-triggered Dream runs; blank uses the global default provider. */ + provider: '' | 'copilot' | 'codex' | 'claude'; + /** Optional default model for idle-triggered Dream runs. */ + model: string; + /** Default Dream AI request timeout, edited in minutes and persisted as milliseconds. */ + timeoutMinutes: string; /** Automatic idle-check cadence, edited in minutes and persisted as milliseconds. */ intervalMinutes: string; } @@ -39,7 +45,7 @@ export interface DreamsViewProps { onRefreshProviderActivity?: () => void; } -const DEFAULT_CONFIG: DreamsConfigForm = { enabled: false, intervalMinutes: '5' }; +const DEFAULT_CONFIG: DreamsConfigForm = { enabled: false, provider: '', model: '', timeoutMinutes: '60', intervalMinutes: '5' }; export function DreamsView({ config = DEFAULT_CONFIG, @@ -102,6 +108,54 @@ export function DreamsView({ /> + + onConfigChange?.({ provider })} + aria-label="Dreams default provider" + options={[ + { value: '', label: 'Global', testId: 'dreams-provider-global' }, + { value: 'copilot', label: 'Copilot', testId: 'dreams-provider-copilot' }, + { value: 'codex', label: 'Codex', testId: 'dreams-provider-codex' }, + { value: 'claude', label: 'Claude', testId: 'dreams-provider-claude' }, + ]} + /> + + + onConfigChange?.({ model: event.target.value })} + placeholder="Provider default" + data-testid="dreams-default-model" + /> + + + + onConfigChange?.({ timeoutMinutes: event.target.value })} + data-testid="dreams-timeout-minutes" + /> + + { getDreamsEnabled: () => true, getWorkspaceDreamsEnabled: (workspaceId) => workspaceId === 'ws-two', getRunOptions: () => ({ + provider: 'claude', + model: 'claude-sonnet-4.6', minIdleMs: 123_000, confidenceThreshold: 0.92, maxCandidates: 3, @@ -65,6 +67,8 @@ describe('DreamIdleScheduler', () => { expect(checkIdleReadiness).toHaveBeenCalledTimes(1); expect(checkIdleReadiness).toHaveBeenCalledWith('ws-two', { + provider: 'claude', + model: 'claude-sonnet-4.6', minIdleMs: 123_000, confidenceThreshold: 0.92, maxCandidates: 3, @@ -83,6 +87,11 @@ describe('DreamIdleScheduler', () => { getWorkspaceIds: () => ['ws-one'], getDreamsEnabled: () => true, getWorkspaceDreamsEnabled: () => true, + getRunOptions: () => ({ + provider: 'claude', + model: 'claude-sonnet-4.6', + timeoutMs: 1_800_000, + }), checkIdleReadiness, enqueueIdleRun, onRunResult, @@ -92,8 +101,16 @@ describe('DreamIdleScheduler', () => { scheduler.start(); await flushPromises(); - expect(checkIdleReadiness).toHaveBeenCalledWith('ws-one', {}); - expect(enqueueIdleRun).toHaveBeenCalledWith('ws-one', {}); + expect(checkIdleReadiness).toHaveBeenCalledWith('ws-one', { + provider: 'claude', + model: 'claude-sonnet-4.6', + timeoutMs: 1_800_000, + }); + expect(enqueueIdleRun).toHaveBeenCalledWith('ws-one', { + provider: 'claude', + model: 'claude-sonnet-4.6', + timeoutMs: 1_800_000, + }); expect(onRunResult).toHaveBeenCalledWith('ws-one', 'startup', expect.objectContaining({ started: true, task: expect.objectContaining({ id: 'dream-task-1' }), diff --git a/packages/coc/test/server/routes-index.test.ts b/packages/coc/test/server/routes-index.test.ts index 7a05f4d24..2e1613fa4 100644 --- a/packages/coc/test/server/routes-index.test.ts +++ b/packages/coc/test/server/routes-index.test.ts @@ -47,13 +47,14 @@ function makeStore(): ProcessStore { function makeBridge(): any { return { - enqueue: vi.fn(), + enqueue: vi.fn().mockResolvedValue('task-1'), getRepoExecutor: vi.fn(), createAggregateQueueFacade: vi.fn(), registerRepoId: vi.fn(), dispatchToRepo: vi.fn(), setResolveDefaultProvider: vi.fn(), setDreamRunExecutor: vi.fn(), + findManagerForTask: vi.fn(), registry: { on: vi.fn(), }, @@ -64,6 +65,7 @@ function makeBridge(): any { function makeQueueFacade(): any { return { enqueue: vi.fn(), + getAll: vi.fn().mockReturnValue([]), getQueue: vi.fn(), getHistory: vi.fn(), getQueueStats: vi.fn(), @@ -270,4 +272,63 @@ describe('registerAllRoutes', () => { expect(routes1.length).toBe(routes2.length); expect(routes1).not.toBe(routes2); }); + + it('passes configured Dreams provider, model, and timeout into Dream run tasks', async () => { + await fs.mkdir(path.join(tmpDir, 'repos', 'ws-dream'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'repos', 'ws-dream', 'preferences.json'), + JSON.stringify({ dreams: { enabled: true } }), + 'utf-8', + ); + + const routes: Route[] = []; + const bridge = makeBridge(); + const store = makeStore(); + vi.mocked(store.getWorkspaces).mockResolvedValue([{ id: 'ws-dream', rootPath: '/repo/ws-dream' } as any]); + const result = registerAllRoutes(routes, makeOpts({ + dataDir: tmpDir, + store, + bridge, + runtimeConfigService: { + config: { + dreams: { + enabled: true, + provider: 'claude', + model: 'claude-sonnet-4.6', + timeoutMs: 1_800_000, + minIdleMs: 60_000, + }, + defaultProvider: 'copilot', + codex: { enabled: false }, + claude: { enabled: true }, + }, + } as any, + })); + + expect(result.dreamIdleScheduler).toBeDefined(); + const found = findRoute(routes, 'POST', '/api/workspaces/ws-dream/dreams/run'); + expect(found).toBeDefined(); + const res = fakeRes(); + await found!.route.handler(fakeJsonReq('POST', {}), res, found!.match); + + expect(res.statusCode).toBe(202); + expect(bridge.enqueue).toHaveBeenCalledOnce(); + const input = bridge.enqueue.mock.calls[0][0]; + expect(input).toMatchObject({ + type: 'dream-run', + repoId: 'ws-dream', + payload: { + kind: 'dream-run', + workspaceId: 'ws-dream', + trigger: 'manual', + provider: 'claude', + model: 'claude-sonnet-4.6', + timeoutMs: 1_800_000, + }, + config: { + model: 'claude-sonnet-4.6', + timeoutMs: 1_800_000, + }, + }); + }); }); diff --git a/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx b/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx index 6b8440adb..e7fc0e536 100644 --- a/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx +++ b/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx @@ -348,6 +348,9 @@ describe('AdminPanel — embedded tools render in the right panel', () => { resolved: { dreams: { enabled: false, + provider: 'claude', + model: 'claude-sonnet-4.6', + timeoutMs: 3_600_000, idleCheckIntervalMs: 300_000, }, }, @@ -369,9 +372,15 @@ describe('AdminPanel — embedded tools render in the right panel', () => { await waitFor(() => expect(document.querySelector('[data-testid="dreams-admin-page"]')).toBeTruthy()); const intervalInput = document.querySelector('[data-testid="dreams-idle-check-interval-minutes"]')!; expect(intervalInput.value).toBe('5'); + expect(document.querySelector('[data-testid="dreams-provider-claude"]')?.getAttribute('aria-pressed')).toBe('true'); + expect(document.querySelector('[data-testid="dreams-default-model"]')!.value).toBe('claude-sonnet-4.6'); + expect(document.querySelector('[data-testid="dreams-timeout-minutes"]')!.value).toBe('60'); await act(async () => { fireEvent.change(intervalInput, { target: { value: '12' } }); + fireEvent.click(document.querySelector('[data-testid="dreams-provider-codex"]')!); + fireEvent.change(document.querySelector('[data-testid="dreams-default-model"]')!, { target: { value: 'gpt-5-codex' } }); + fireEvent.change(document.querySelector('[data-testid="dreams-timeout-minutes"]')!, { target: { value: '30' } }); }); await act(async () => { fireEvent.click(document.querySelector('[data-testid="dreams-settings-save"]')!); @@ -383,7 +392,10 @@ describe('AdminPanel — embedded tools render in the right panel', () => { expect(saveCall).toBeTruthy(); expect(JSON.parse(String(saveCall![1]!.body))).toMatchObject({ 'dreams.enabled': false, + 'dreams.provider': 'codex', + 'dreams.model': 'gpt-5-codex', 'dreams.idleCheckIntervalMs': 720_000, + 'dreams.timeoutMs': 1_800_000, }); }); }); diff --git a/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx b/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx index 2733f5554..021770549 100644 --- a/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx +++ b/packages/coc/test/spa/react/features/dreams/DreamsView.test.tsx @@ -12,6 +12,9 @@ * * AC-02: `dreams.idleCheckIntervalMs` is edited here in minutes, while the * owner component persists milliseconds through the global config API. + * + * AC-04: provider, model, and timeout defaults for idle-triggered Dream runs are + * edited from this tab. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fireEvent, render, screen, within } from '@testing-library/react'; @@ -25,6 +28,16 @@ vi.mock('../../../../../src/server/spa/client/react/api/cocClient', () => ({ const { DreamsView } = await import('../../../../../src/server/spa/client/react/features/dreams/DreamsView'); import type { AgentProviderWorkActivity } from '../../../../../src/server/spa/client/react/shared/providerActivity'; +import type { DreamsConfigForm } from '../../../../../src/server/spa/client/react/features/dreams/DreamsView'; + +const dreamsConfig = (overrides: Partial = {}): DreamsConfigForm => ({ + enabled: false, + provider: '', + model: '', + timeoutMinutes: '60', + intervalMinutes: '5', + ...overrides, +}); describe('DreamsView', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -38,23 +51,23 @@ describe('DreamsView', () => { // ── AC-03: global dreams.enabled toggle lives in the tab ── it('renders the dreams.enabled toggle reflecting the passed config', () => { - const { rerender } = render(); + const { rerender } = render(); const toggle = screen.getByTestId('toggle-dreams-enabled') as HTMLInputElement; expect(toggle.checked).toBe(false); - rerender(); + rerender(); expect((screen.getByTestId('toggle-dreams-enabled') as HTMLInputElement).checked).toBe(true); }); it('invokes onConfigChange with the new enabled value when toggled', () => { const onConfigChange = vi.fn(); - render(); + render(); fireEvent.click(screen.getByTestId('toggle-dreams-enabled')); expect(onConfigChange).toHaveBeenCalledWith({ enabled: true }); }); // ── AC-02: idle interval is edited in minutes ── it('renders the idle check interval in minutes with a restart hint', () => { - render(); + render(); const input = screen.getByTestId('dreams-idle-check-interval-minutes') as HTMLInputElement; expect(input.value).toBe('7'); expect(screen.getByText(/restart the server for the scheduler cadence/i)).toBeDefined(); @@ -62,17 +75,38 @@ describe('DreamsView', () => { it('invokes onConfigChange with the new interval minute string when edited', () => { const onConfigChange = vi.fn(); - render(); + render(); fireEvent.change(screen.getByTestId('dreams-idle-check-interval-minutes'), { target: { value: '12' } }); expect(onConfigChange).toHaveBeenCalledWith({ intervalMinutes: '12' }); }); + // ── AC-04: provider/model/timeout defaults live in the Dreams tab ── + it('renders default provider, model, and timeout controls from config', () => { + render(); + expect(screen.getByTestId('dreams-provider-claude').getAttribute('aria-pressed')).toBe('true'); + expect((screen.getByTestId('dreams-default-model') as HTMLInputElement).value).toBe('claude-sonnet-4.6'); + expect((screen.getByTestId('dreams-timeout-minutes') as HTMLInputElement).value).toBe('45'); + }); + + it('invokes onConfigChange when provider, model, and timeout defaults change', () => { + const onConfigChange = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('dreams-provider-codex')); + fireEvent.change(screen.getByTestId('dreams-default-model'), { target: { value: 'gpt-5-codex' } }); + fireEvent.change(screen.getByTestId('dreams-timeout-minutes'), { target: { value: '30' } }); + + expect(onConfigChange).toHaveBeenCalledWith({ provider: 'codex' }); + expect(onConfigChange).toHaveBeenCalledWith({ model: 'gpt-5-codex' }); + expect(onConfigChange).toHaveBeenCalledWith({ timeoutMinutes: '30' }); + }); + it('wires the settings card Save/Cancel footer to the config handlers when dirty', () => { const onSaveConfig = vi.fn(); const onCancelConfig = vi.fn(); render( Date: Sat, 13 Jun 2026 08:56:44 +0000 Subject: [PATCH 17/37] Fix Dreams admin defaults dirty tracking --- .../spa/client/react/admin/AdminPanel.tsx | 3 + .../admin/AdminPanel-tools-sidebar.test.tsx | 127 +++++++++++++----- 2 files changed, 94 insertions(+), 36 deletions(-) diff --git a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx index 2a86780c4..42f217d96 100644 --- a/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx +++ b/packages/coc/src/server/spa/client/react/admin/AdminPanel.tsx @@ -644,6 +644,9 @@ export function AdminPanel() { const featuresDirty = FEATURES_CARD_SETTINGS.some(def => featureValues[def.key] !== featuresSnapshot[def.key]); const dreamsDirty = dreamsForm.enabled !== dreamsSnapshot.enabled || + dreamsForm.provider !== dreamsSnapshot.provider || + dreamsForm.model !== dreamsSnapshot.model || + dreamsForm.timeoutMinutes !== dreamsSnapshot.timeoutMinutes || dreamsForm.intervalMinutes !== dreamsSnapshot.intervalMinutes; // ── AI & Execution card ── diff --git a/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx b/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx index e7fc0e536..415de677d 100644 --- a/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx +++ b/packages/coc/test/spa/react/admin/AdminPanel-tools-sidebar.test.tsx @@ -57,6 +57,55 @@ function renderAdmin() { ); } +function mockDreamsAdminConfig() { + mockFetch.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/admin/config') && init?.method === 'PUT') { + return Promise.resolve(jsonResponse({ success: true })); + } + if (url.includes('/api/admin/config')) { + return Promise.resolve(jsonResponse({ + resolved: { + dreams: { + enabled: false, + provider: 'claude', + model: 'claude-sonnet-4.6', + timeoutMs: 3_600_000, + idleCheckIntervalMs: 300_000, + }, + }, + })); + } + if (url.includes('/api/admin/dream-provider-activity')) { + return Promise.resolve(jsonResponse({ items: [] })); + } + return Promise.resolve(jsonResponse({})); + }); +} + +async function openDreamsAdminSettings() { + await act(async () => { renderAdmin(); }); + await waitFor(() => expect(document.getElementById('dreams-admin-toggle')).toBeTruthy()); + + await act(async () => { + fireEvent.click(document.getElementById('dreams-admin-toggle')!); + }); + + await waitFor(() => expect(document.querySelector('[data-testid="dreams-admin-page"]')).toBeTruthy()); +} + +function getDreamsSaveButton() { + return document.querySelector('[data-testid="dreams-settings-save"]')!; +} + +function getAdminConfigSavePayload() { + const saveCall = mockFetch.mock.calls.find(([input, init]: [RequestInfo | URL, RequestInit | undefined]) => + String(input).includes('/api/admin/config') && init?.method === 'PUT' + ); + expect(saveCall).toBeTruthy(); + return JSON.parse(String(saveCall![1]!.body)) as Record; +} + // ── AdminPanel grouped sidebar navigation ───────────────────── describe('AdminPanel — sidebar layout zones', () => { @@ -338,38 +387,9 @@ describe('AdminPanel — embedded tools render in the right panel', () => { }); it('saves the Dreams idle check interval in milliseconds after editing minutes', async () => { - mockFetch.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - if (url.includes('/api/admin/config') && init?.method === 'PUT') { - return Promise.resolve(jsonResponse({ success: true })); - } - if (url.includes('/api/admin/config')) { - return Promise.resolve(jsonResponse({ - resolved: { - dreams: { - enabled: false, - provider: 'claude', - model: 'claude-sonnet-4.6', - timeoutMs: 3_600_000, - idleCheckIntervalMs: 300_000, - }, - }, - })); - } - if (url.includes('/api/admin/dream-provider-activity')) { - return Promise.resolve(jsonResponse({ items: [] })); - } - return Promise.resolve(jsonResponse({})); - }); - - await act(async () => { renderAdmin(); }); - await waitFor(() => expect(document.getElementById('dreams-admin-toggle')).toBeTruthy()); + mockDreamsAdminConfig(); + await openDreamsAdminSettings(); - await act(async () => { - fireEvent.click(document.getElementById('dreams-admin-toggle')!); - }); - - await waitFor(() => expect(document.querySelector('[data-testid="dreams-admin-page"]')).toBeTruthy()); const intervalInput = document.querySelector('[data-testid="dreams-idle-check-interval-minutes"]')!; expect(intervalInput.value).toBe('5'); expect(document.querySelector('[data-testid="dreams-provider-claude"]')?.getAttribute('aria-pressed')).toBe('true'); @@ -386,11 +406,7 @@ describe('AdminPanel — embedded tools render in the right panel', () => { fireEvent.click(document.querySelector('[data-testid="dreams-settings-save"]')!); }); - const saveCall = mockFetch.mock.calls.find(([input, init]: [RequestInfo | URL, RequestInit | undefined]) => - String(input).includes('/api/admin/config') && init?.method === 'PUT' - ); - expect(saveCall).toBeTruthy(); - expect(JSON.parse(String(saveCall![1]!.body))).toMatchObject({ + expect(getAdminConfigSavePayload()).toMatchObject({ 'dreams.enabled': false, 'dreams.provider': 'codex', 'dreams.model': 'gpt-5-codex', @@ -398,4 +414,43 @@ describe('AdminPanel — embedded tools render in the right panel', () => { 'dreams.timeoutMs': 1_800_000, }); }); + + it.each([ + { + field: 'provider', + edit: () => fireEvent.click(document.querySelector('[data-testid="dreams-provider-codex"]')!), + expected: { 'dreams.provider': 'codex', 'dreams.model': 'claude-sonnet-4.6', 'dreams.timeoutMs': 3_600_000 }, + }, + { + field: 'model', + edit: () => fireEvent.change(document.querySelector('[data-testid="dreams-default-model"]')!, { target: { value: 'claude-opus-4.1' } }), + expected: { 'dreams.provider': 'claude', 'dreams.model': 'claude-opus-4.1', 'dreams.timeoutMs': 3_600_000 }, + }, + { + field: 'timeout', + edit: () => fireEvent.change(document.querySelector('[data-testid="dreams-timeout-minutes"]')!, { target: { value: '45' } }), + expected: { 'dreams.provider': 'claude', 'dreams.model': 'claude-sonnet-4.6', 'dreams.timeoutMs': 2_700_000 }, + }, + ])('enables Save and persists Dreams $field-only edits', async ({ edit, expected }) => { + mockDreamsAdminConfig(); + await openDreamsAdminSettings(); + + expect(getDreamsSaveButton().disabled).toBe(true); + + await act(async () => { + edit(); + }); + + await waitFor(() => expect(getDreamsSaveButton().disabled).toBe(false)); + + await act(async () => { + fireEvent.click(getDreamsSaveButton()); + }); + + expect(getAdminConfigSavePayload()).toMatchObject({ + 'dreams.enabled': false, + 'dreams.idleCheckIntervalMs': 300_000, + ...expected, + }); + }); }); From 607d737054b5679816f8b4df3c3f0a167fb63fca Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 14:23:47 +0000 Subject: [PATCH 18/37] test(coc): cover LLM tools param affordance keyboard a11y + narrow-screen layout Adds the final AC-03 verification tests for the LlmToolsPanel parameter affordance: an explicit keyboard-activation assertion (native button is focusable, Enter expands and Space collapses, with accessible label/state) and a narrow-screen layout assertion (single-column grid by default, wrapping param tokens, fit-width affordance). Verified the production SPA build compiles the Tailwind arbitrary values (pl-[46px] -> padding-left:46px). Co-Authored-By: Claude Opus 4.8 --- .../spa/react/repos/LlmToolsPanel.test.tsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/coc/test/spa/react/repos/LlmToolsPanel.test.tsx b/packages/coc/test/spa/react/repos/LlmToolsPanel.test.tsx index 0589bde80..583f68c7e 100644 --- a/packages/coc/test/spa/react/repos/LlmToolsPanel.test.tsx +++ b/packages/coc/test/spa/react/repos/LlmToolsPanel.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -172,6 +173,55 @@ describe('LlmToolsPanel', () => { expect(mocks.preferences.updateLlmToolsConfig).not.toHaveBeenCalled(); }); + it('exposes the parameter affordance as a focusable native button activated by Enter and Space', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getByTestId('llm-tools-panel')).toBeTruthy()); + + const toggle = screen.getByTestId('llm-tool-params-toggle-create_update_work_item') as HTMLButtonElement; + // Native + ))} +

- Text search is unavailable: the native store has no search index. Metadata filters still apply. + This provider has no native search index. CoC is using on-demand substring search for the current filter.

)} {listResponse?.available === true && (listResponse.deduplicatedCount ?? 0) > 0 && ( @@ -301,15 +363,15 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession )} {!listLoading && !listError && unavailable && (
-

{unavailableCopy(listResponse?.reason).title}

-

{unavailableCopy(listResponse?.reason).body}

+

{unavailableCopy(provider, listResponse?.reason).title}

+

{unavailableCopy(provider, listResponse?.reason).body}

)} {!listLoading && !listError && !unavailable && items.length === 0 && (
{hasFilters - ? 'No native Copilot CLI sessions match the current filters.' - : 'No native Copilot CLI sessions were found for this workspace.'} + ? `No native ${PROVIDER_META[provider].label} CLI sessions match the current filters.` + : `No native ${PROVIDER_META[provider].label} CLI sessions were found for this workspace.`}
)} {!listLoading && !listError && !unavailable && items.length > 0 && ( @@ -366,7 +428,7 @@ export function NativeCopilotSessionsPanel({ workspaceId }: NativeCopilotSession // Wide screens render the searchable table beside the detail; narrow screens // stack panes and show one at a time based on selection. return ( -
+
void; }) { @@ -438,7 +503,7 @@ function SessionRow({ item, selected, onSelect }: { ); } -function SessionDetailView({ detail, workspaceId, onBack }: { detail: NativeCopilotSessionDetail; workspaceId: string; onBack: () => void }) { +function SessionDetailView({ detail, workspaceId, onBack }: { detail: NativeCliSessionDetail; workspaceId: string; onBack: () => void }) { // Reconstructed transcript (rich `events.jsonl` parse, else flat DB // fallback) mapped into the SPA chat shape so we can reuse the existing // read-only chat bubble — no input box, streaming, or resume actions. @@ -455,12 +520,13 @@ function SessionDetailView({ detail, workspaceId, onBack }: { detail: NativeCopi > ← Back - - + + +

{detail.id}

- {READ_ONLY_TOOLTIP} + {readOnlyTooltip(detail.provider, detail.storePath)}

Repository
{detail.repository || '—'}
@@ -481,7 +547,7 @@ function SessionDetailView({ detail, workspaceId, onBack }: { detail: NativeCopi

Conversation ({conversation.length})

- Read-only reconstruction of the native Copilot CLI transcript. No follow-up, streaming, or resume. + Read-only reconstruction of the native {PROVIDER_META[detail.provider].label} CLI transcript. No follow-up, streaming, or resume.

{conversation.length === 0 ? (

This native session has no stored turns.

@@ -493,7 +559,7 @@ function SessionDetailView({ detail, workspaceId, onBack }: { detail: NativeCopi turn={turn} turnIndex={turn.turnIndex ?? index} wsId={workspaceId} - provider="copilot" + provider={detail.provider} /> ))}
diff --git a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx index 2fd55b955..366fe14ed 100644 --- a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx +++ b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx @@ -24,7 +24,7 @@ import { WorkflowDetailView } from '../../processes/dag'; import { TerminalView } from '../terminal/TerminalView'; import { NotesView } from '../notes/NotesView'; import { DreamsPanel } from '../dreams/DreamsPanel'; -import { NativeCopilotSessionsPanel } from '../native-copilot-sessions/NativeCopilotSessionsPanel'; +import { NativeCliSessionsPanel } from '../native-copilot-sessions/NativeCopilotSessionsPanel'; import { AddRepoDialog } from '../../repos/AddRepoDialog'; import { ErrorBoundary } from '../../ui/ErrorBoundary'; @@ -39,7 +39,7 @@ import { useNotesEnabled } from '../notes/hooks/useNotesEnabled'; import { useWorkflowsEnabled } from '../../hooks/feature-flags/useWorkflowsEnabled'; import { usePullRequestsEnabled } from '../../hooks/feature-flags/usePullRequestsEnabled'; import { useDreamsEnabled } from '../../hooks/feature-flags/useDreamsEnabled'; -import { useNativeCopilotSessionsEnabled } from '../../hooks/feature-flags/useNativeCopilotSessionsEnabled'; +import { useNativeCliSessionsEnabled } from '../../hooks/feature-flags/useNativeCliSessionsEnabled'; import { MobileTabBar } from '../../layout/MobileTabBar'; import { buildRepoSubTabSuffix } from '../../layout/Router'; import { SHOW_WIKI_TAB } from '../../layout/TopBar'; @@ -65,7 +65,7 @@ export const SUB_TABS: { key: RepoSubTab; label: string; shortcut?: string }[] = { key: 'terminal', label: 'Terminal' }, { key: 'work-items', label: 'Work Items', shortcut: 'Alt+I' }, { key: 'dreams', label: 'Dreams', shortcut: 'Alt+D' }, - { key: 'copilot-sessions', label: 'Copilot Sessions' }, + { key: 'cli-sessions', label: 'CLI Sessions' }, { key: 'pull-requests', label: 'Pull Requests', shortcut: 'Alt+R' }, { key: 'explorer', label: 'Explorer', shortcut: 'Alt+E' }, { key: 'workflows', label: 'Workflows', shortcut: 'Alt+W' }, @@ -88,7 +88,7 @@ export const VISIBLE_SUB_TABS = SHOW_WIKI_TAB */ const TAB_GROUP_INDEX: Record = { 'chats': 1, 'activity': 1, 'git': 1, 'terminal': 1, - 'work-items': 2, 'dreams': 2, 'copilot-sessions': 2, 'pull-requests': 2, 'tasks': 2, + 'work-items': 2, 'dreams': 2, 'cli-sessions': 2, 'copilot-sessions': 2, 'pull-requests': 2, 'tasks': 2, 'explorer': 3, 'workflows': 3, 'schedules': 3, 'notes': 4, 'settings': 4, 'wiki': 4, }; @@ -137,7 +137,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { const workflowsEnabled = useWorkflowsEnabled(); const pullRequestsEnabled = usePullRequestsEnabled(); const dreamsEnabled = useDreamsEnabled(); - const nativeCopilotSessionsEnabled = useNativeCopilotSessionsEnabled(); + const nativeCliSessionsEnabled = useNativeCliSessionsEnabled(); const sessionContextAttachmentsEnabled = isSessionContextAttachmentsEnabled(); const canRetrieveConversations = useConversationRetrievalCapability(ws.id, sessionContextAttachmentsEnabled); const [headerContextDropTarget, setHeaderContextDropTarget] = useState<'task' | 'ask' | null>(null); @@ -166,7 +166,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { const prevWorkflowsEnabled = useRef(workflowsEnabled); const prevPullRequestsEnabled = useRef(pullRequestsEnabled); const prevDreamsEnabled = useRef(dreamsEnabled); - const prevNativeCopilotSessionsEnabled = useRef(nativeCopilotSessionsEnabled); + const prevNativeCliSessionsEnabled = useRef(nativeCliSessionsEnabled); const visibleSubTabs = useMemo(() => { let tabs = VISIBLE_SUB_TABS; @@ -176,7 +176,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { if (!workflowsEnabled) tabs = tabs.filter(t => t.key !== 'workflows'); if (!pullRequestsEnabled) tabs = tabs.filter(t => t.key !== 'pull-requests'); if (!dreamsEnabled) tabs = tabs.filter(t => t.key !== 'dreams'); - if (!nativeCopilotSessionsEnabled) tabs = tabs.filter(t => t.key !== 'copilot-sessions'); + if (!nativeCliSessionsEnabled) tabs = tabs.filter(t => t.key !== 'cli-sessions' && t.key !== 'copilot-sessions'); // Layout mode filtering if (uiLayoutMode === 'classic') { // Classic: replace Chats with Activity, relabel Tasks as Plans @@ -190,7 +190,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { 'pull-requests': 'Full Requests', }; const devWorkflowOrder: RepoSubTab[] = [ - 'chats', 'work-items', 'dreams', 'copilot-sessions', 'schedules', 'explorer', + 'chats', 'work-items', 'dreams', 'cli-sessions', 'schedules', 'explorer', 'workflows', 'git', 'terminal', 'pull-requests', 'tasks', 'settings', ]; const tabMap = new Map(tabs.map(t => [t.key, t])); @@ -210,7 +210,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { tabs = ordered; } return tabs; - }, [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, nativeCopilotSessionsEnabled, uiLayoutMode]); + }, [isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, nativeCliSessionsEnabled, uiLayoutMode]); // Redirect away from git/pull-requests tab when switching to a non-git repo useEffect(() => { @@ -259,13 +259,13 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { prevDreamsEnabled.current = dreamsEnabled; }, [activeSubTab, dreamsEnabled, dispatch]); - // Redirect away from copilot-sessions tab only when the feature transitions to disabled + // Redirect away from CLI sessions tab only when the feature transitions to disabled useEffect(() => { - if (activeSubTab === 'copilot-sessions' && !nativeCopilotSessionsEnabled && prevNativeCopilotSessionsEnabled.current) { + if ((activeSubTab === 'cli-sessions' || activeSubTab === 'copilot-sessions') && !nativeCliSessionsEnabled && prevNativeCliSessionsEnabled.current) { dispatch({ type: 'SET_REPO_SUB_TAB', tab: 'chats' }); } - prevNativeCopilotSessionsEnabled.current = nativeCopilotSessionsEnabled; - }, [activeSubTab, nativeCopilotSessionsEnabled, dispatch]); + prevNativeCliSessionsEnabled.current = nativeCliSessionsEnabled; + }, [activeSubTab, nativeCliSessionsEnabled, dispatch]); // Redirect when switching layout modes useEffect(() => { @@ -791,7 +791,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { )}
) : ( -
+
{activeSubTab === 'settings' && } {activeSubTab === 'workflows' && } {/* @@ -848,9 +848,9 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { {wasVisited('dreams') && }
)} - {nativeCopilotSessionsEnabled && ( -
- {wasVisited('copilot-sessions') && } + {nativeCliSessionsEnabled && ( +
+ {(wasVisited('cli-sessions') || wasVisited('copilot-sessions')) && }
)} {activeSubTab === 'workflow' && state.selectedWorkflowProcessId && } diff --git a/packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCliSessionsEnabled.ts b/packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCliSessionsEnabled.ts new file mode 100644 index 000000000..422090406 --- /dev/null +++ b/packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCliSessionsEnabled.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react'; +import { DASHBOARD_CONFIG_UPDATED_EVENT, isNativeCliSessionsEnabled } from '../../utils/config'; + +/** Live `features.nativeCliSessions` flag; tracks runtime config updates. */ +export function useNativeCliSessionsEnabled(): boolean { + const [enabled, setEnabled] = useState(isNativeCliSessionsEnabled()); + useEffect(() => { + const onConfigUpdated = () => setEnabled(isNativeCliSessionsEnabled()); + window.addEventListener(DASHBOARD_CONFIG_UPDATED_EVENT, onConfigUpdated); + return () => window.removeEventListener(DASHBOARD_CONFIG_UPDATED_EVENT, onConfigUpdated); + }, []); + return enabled; +} diff --git a/packages/coc/src/server/spa/client/react/layout/Router.tsx b/packages/coc/src/server/spa/client/react/layout/Router.tsx index c71aacfc8..5135a0979 100644 --- a/packages/coc/src/server/spa/client/react/layout/Router.tsx +++ b/packages/coc/src/server/spa/client/react/layout/Router.tsx @@ -16,6 +16,7 @@ import type { UiLayoutMode } from '../types/dashboard'; import { lazy, Suspense } from 'react'; import type { DashboardTab, RepoSubTab, WikiProjectTab, WikiAdminTab, MemorySubTab, SkillsSubTab, AdminSubTab, PrDetailTab, SettingsSection } from '../types/dashboard'; import { SETTINGS_SECTION_VALUES, REPO_SUB_TAB_VALUES, WIKI_PROJECT_TAB_VALUES, WIKI_ADMIN_TAB_VALUES } from '../types/dashboard'; +import type { NativeCliSessionProviderId } from '@plusplusoneplusplus/coc-client'; const AdminPanel = lazy(() => import('../admin/AdminPanel').then(m => ({ default: m.AdminPanel }))); // Memory/Skills/Logs/Usage/Models/Servers no longer mount as standalone @@ -308,21 +309,31 @@ export function buildNoteHash(wsId: string, notePath: string): string { } /** - * Parse a native Copilot Sessions deep-link: - * `#repos/{wsId}/copilot-sessions` - * `#repos/{wsId}/copilot-sessions/{sessionId}` + * Parse a native CLI Sessions deep-link: + * `#repos/{wsId}/cli-sessions/{provider}` + * `#repos/{wsId}/cli-sessions/{provider}/{sessionId}` * - * Returns `{ workspaceId, sessionId }` (sessionId null when only the tab is - * addressed) when the hash targets the Copilot Sessions tab, `null` otherwise. + * Legacy `#repos/{wsId}/copilot-sessions/{sessionId}` links are treated as + * Copilot provider links so shared/bookmarked URLs keep working. */ -export function parseNativeCopilotSessionDeepLink( +export function parseNativeCliSessionDeepLink( hash: string, -): { workspaceId: string; sessionId: string | null } | null { +): { workspaceId: string; provider: NativeCliSessionProviderId; sessionId: string | null } | null { const cleaned = hash.replace(/^#/, ''); const parts = cleaned.split('/'); + if (parts[0] === 'repos' && parts[1] && parts[2] === 'cli-sessions') { + const provider = parts[3] ? decodeURIComponent(parts[3]) : 'codex'; + if (provider !== 'copilot' && provider !== 'codex' && provider !== 'claude') return null; + return { + workspaceId: decodeURIComponent(parts[1]), + provider, + sessionId: parts[4] ? decodeURIComponent(parts[4]) : null, + }; + } if (parts[0] === 'repos' && parts[1] && parts[2] === 'copilot-sessions') { return { workspaceId: decodeURIComponent(parts[1]), + provider: 'copilot', sessionId: parts[3] ? decodeURIComponent(parts[3]) : null, }; } @@ -330,8 +341,21 @@ export function parseNativeCopilotSessionDeepLink( } /** - * Build a Copilot Sessions hash. Omitting `sessionId` addresses the bare tab. + * Build a CLI Sessions hash. Omitting `sessionId` addresses the provider tab. */ +export function buildNativeCliSessionHash(wsId: string, provider: NativeCliSessionProviderId, sessionId?: string | null): string { + const base = '#repos/' + encodeURIComponent(wsId) + '/cli-sessions/' + encodeURIComponent(provider); + return sessionId ? base + '/' + encodeURIComponent(sessionId) : base; +} + +/** @deprecated Use parseNativeCliSessionDeepLink. */ +export function parseNativeCopilotSessionDeepLink(hash: string): { workspaceId: string; sessionId: string | null } | null { + const parsed = parseNativeCliSessionDeepLink(hash); + if (!parsed || parsed.provider !== 'copilot') return null; + return { workspaceId: parsed.workspaceId, sessionId: parsed.sessionId }; +} + +/** @deprecated Use buildNativeCliSessionHash. */ export function buildNativeCopilotSessionHash(wsId: string, sessionId?: string | null): string { const base = '#repos/' + encodeURIComponent(wsId) + '/copilot-sessions'; return sessionId ? base + '/' + encodeURIComponent(sessionId) : base; diff --git a/packages/coc/src/server/spa/client/react/types/dashboard.ts b/packages/coc/src/server/spa/client/react/types/dashboard.ts index f55575380..af3753e52 100644 --- a/packages/coc/src/server/spa/client/react/types/dashboard.ts +++ b/packages/coc/src/server/spa/client/react/types/dashboard.ts @@ -41,7 +41,7 @@ export type DashboardTab = 'processes' | 'repos' | 'wiki' | 'reports' | 'stats' export const REPO_SUB_TAB_VALUES = [ 'chats', 'work-items', 'settings', 'workflows', 'templates', 'tasks', 'schedules', 'git', 'wiki', 'workflow', 'explorer', 'activity', - 'pull-requests', 'terminal', 'notes', 'dreams', 'copilot-sessions', + 'pull-requests', 'terminal', 'notes', 'dreams', 'cli-sessions', 'copilot-sessions', ] as const; export type RepoSubTab = typeof REPO_SUB_TAB_VALUES[number]; diff --git a/packages/coc/src/server/spa/client/react/utils/config.ts b/packages/coc/src/server/spa/client/react/utils/config.ts index 51c7c5dbf..8e3cd94c6 100644 --- a/packages/coc/src/server/spa/client/react/utils/config.ts +++ b/packages/coc/src/server/spa/client/react/utils/config.ts @@ -66,8 +66,10 @@ interface DashboardConfig { gitCrossCloneCherryPickEnabled?: boolean; /** Whether the Effort Tiers selector (Low/Medium/High) is enabled in the composer. Disabled by default. */ effortLevelsEnabled?: boolean; - /** Whether the read-only native Copilot CLI sessions tab is enabled (feature flag). */ + /** Whether the read-only native Copilot CLI sessions tab is enabled (legacy feature flag). */ nativeCopilotSessionsEnabled?: boolean; + /** Whether the read-only native CLI sessions tab is enabled (feature flag). */ + nativeCliSessionsEnabled?: boolean; } /** Cached runtime config loaded from the API. */ @@ -302,6 +304,10 @@ export function isNativeCopilotSessionsEnabled(): boolean { return getConfig().nativeCopilotSessionsEnabled === true; } +export function isNativeCliSessionsEnabled(): boolean { + return getConfig().nativeCliSessionsEnabled === true; +} + export function isExcalidrawEnabled(): boolean { return getConfig().excalidrawEnabled === true; } diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx index ccefd852b..3673bb4a7 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx @@ -29,7 +29,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; // ── Mocks ──────────────────────────────────────────────────────────────────── -// One coc client serves BOTH the panel (nativeCopilotSessions.list/get) and the +// One coc client serves BOTH the panel (nativeCliSessions.list/get) and the // real bubble's image gallery (queue.images). Everything else — the `ui` barrel, // the chat bubble, ToolCallView, the mapper — is the REAL module. @@ -39,6 +39,7 @@ const mockGet = vi.fn(); vi.mock('../../../../src/server/spa/client/react/api/cocClient', () => ({ getSpaCocClient: () => ({ nativeCopilotSessions: { list: mockList, get: mockGet }, + nativeCliSessions: { list: mockList, get: mockGet }, queue: { images: vi.fn() }, }), })); @@ -62,7 +63,7 @@ function setFlag(enabled: boolean): void { (window as any).__DASHBOARD_CONFIG__ = { apiBasePath: '/api', wsPath: '/ws', - features: { nativeCopilotSessionsEnabled: enabled }, + features: { nativeCliSessionsEnabled: enabled }, }; } @@ -78,6 +79,9 @@ function makeListItem() { updatedAt: '2026-06-12T14:53:00.000Z', turnCount: 3, matchSnippets: [], + provider: 'codex', + storePath: '/home/me/.codex/sessions', + searchIndexAvailable: false, }; } @@ -113,6 +117,9 @@ function makeRichDetailResponse() { createdAt: '2026-06-12T14:51:00.000Z', updatedAt: '2026-06-12T14:53:00.000Z', turns: [], + provider: 'codex', + storePath: '/home/me/.codex/sessions', + searchIndexAvailable: false, conversation: [ { role: 'user', @@ -198,7 +205,7 @@ describe('NativeCopilotSessionsPanel — real ConversationTurnBubble integration it('renders the rich transcript through the panel fetch→mapper→real bubble path', async () => { const detail = await openDetail(); - expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-rich-1'); + expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-rich-1', 'codex'); // Metadata header preserved alongside the transcript. expect(detail.textContent).toContain('Inspect the native session store and report its schema.'); diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx index 7d469b80c..2b212cf78 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx @@ -18,6 +18,10 @@ vi.mock('../../../../src/server/spa/client/react/api/cocClient', () => ({ list: mockList, get: mockGet, }, + nativeCliSessions: { + list: mockList, + get: mockGet, + }, }), })); @@ -58,7 +62,7 @@ function setFlag(enabled: boolean): void { (window as any).__DASHBOARD_CONFIG__ = { apiBasePath: '/api', wsPath: '/ws', - features: { nativeCopilotSessionsEnabled: enabled }, + features: { nativeCliSessionsEnabled: enabled }, }; } @@ -74,6 +78,9 @@ function makeListItem(overrides: Partial> = {}) { updatedAt: '2026-06-11T17:56:22.081Z', turnCount: 3, matchSnippets: [], + provider: 'codex', + storePath: '/home/me/.codex/sessions', + searchIndexAvailable: false, ...overrides, }; } @@ -154,6 +161,9 @@ function makeDetailResponse(overrides: Partial> = {}) { ], }, ], + provider: 'codex', + storePath: '/home/me/.codex/sessions', + searchIndexAvailable: false, ...overrides, }, }; @@ -190,7 +200,7 @@ describe('NativeCopilotSessionsPanel', () => { it('renders a typed unavailable state when the native store is missing', async () => { mockList.mockResolvedValue({ - enabled: true, available: false, reason: 'db-missing', items: [], total: 0, limit: 50, offset: 0, + enabled: true, available: false, reason: 'store-missing', provider: 'codex', items: [], total: 0, limit: 50, offset: 0, }); render(); await waitFor(() => expect(screen.getByTestId('native-sessions-unavailable')).toBeTruthy()); @@ -228,7 +238,7 @@ describe('NativeCopilotSessionsPanel', () => { fireEvent.click(screen.getAllByTestId('native-session-row')[0]); await waitFor(() => expect(screen.getByTestId('native-session-detail')).toBeTruthy()); - expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-aaaa-bbbb'); + expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-aaaa-bbbb', 'codex'); const detail = screen.getByTestId('native-session-detail'); // Metadata header preserved. @@ -249,9 +259,9 @@ describe('NativeCopilotSessionsPanel', () => { expect(bubbles[0].getAttribute('data-images')).toBe('1'); expect(bubbles[0].getAttribute('data-ws-id')).toBe('ws-1'); - // Assistant turn carries the Copilot provider, model, tool-call card, and + // Assistant turn carries the selected provider, model, tool-call card, and // reasoning folded into the content/timeline (no component fork). - expect(bubbles[1].getAttribute('data-provider')).toBe('copilot'); + expect(bubbles[1].getAttribute('data-provider')).toBe('codex'); expect(bubbles[1].getAttribute('data-model')).toBe('gpt-5.5'); expect(bubbles[1].getAttribute('data-tool-calls')).toContain('shell'); expect(bubbles[1].getAttribute('data-timeline-types')).toContain('tool-complete'); @@ -287,7 +297,7 @@ describe('NativeCopilotSessionsPanel', () => { fireEvent.click(screen.getByTestId('native-sessions-apply-filters')); await waitFor(() => { - expect(mockList).toHaveBeenLastCalledWith('ws-1', expect.objectContaining({ q: 'mermaid' })); + expect(mockList).toHaveBeenLastCalledWith('ws-1', expect.objectContaining({ provider: 'codex', q: 'mermaid' })); }); await waitFor(() => expect(screen.getByTestId('native-session-match-snippets')).toBeTruthy()); expect(screen.getByTestId('native-session-match-snippets').textContent).toContain('matched mermaid snippet'); @@ -331,21 +341,21 @@ describe('NativeCopilotSessionsPanel', () => { await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); fireEvent.click(screen.getAllByTestId('native-session-row')[0]); - await waitFor(() => expect(window.location.hash).toBe('#repos/ws-1/copilot-sessions/session-aaaa-bbbb')); + await waitFor(() => expect(window.location.hash).toBe('#repos/ws-1/cli-sessions/codex/session-aaaa-bbbb')); await waitFor(() => expect(screen.getByTestId('native-session-detail')).toBeTruthy()); }); it('restores the selected session from a deep-link hash on mount', async () => { mockList.mockResolvedValue(makeListResponse([makeListItem()])); - window.location.hash = '#repos/ws-1/copilot-sessions/session-aaaa-bbbb'; + window.location.hash = '#repos/ws-1/cli-sessions/codex/session-aaaa-bbbb'; render(); - await waitFor(() => expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-aaaa-bbbb')); + await waitFor(() => expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-aaaa-bbbb', 'codex')); await waitFor(() => expect(screen.getByTestId('native-session-detail')).toBeTruthy()); }); it('ignores a deep-link hash that targets a different workspace', async () => { mockList.mockResolvedValue(makeListResponse([makeListItem()])); - window.location.hash = '#repos/other-ws/copilot-sessions/session-aaaa-bbbb'; + window.location.hash = '#repos/other-ws/cli-sessions/codex/session-aaaa-bbbb'; render(); await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); expect(mockGet).not.toHaveBeenCalled(); diff --git a/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts b/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts index 45b6dddf5..2501d6c08 100644 --- a/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts +++ b/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts @@ -1,9 +1,68 @@ import { describe, it, expect } from 'vitest'; import { + parseNativeCliSessionDeepLink, + buildNativeCliSessionHash, parseNativeCopilotSessionDeepLink, buildNativeCopilotSessionHash, } from '../../../../src/server/spa/client/react/layout/Router'; +describe('parseNativeCliSessionDeepLink', () => { + it('returns null for hashes outside the cli-sessions tab', () => { + expect(parseNativeCliSessionDeepLink('#repos/ws-1/notes')).toBeNull(); + expect(parseNativeCliSessionDeepLink('#repos/ws-1/activity/p-1')).toBeNull(); + expect(parseNativeCliSessionDeepLink('#processes/p-1')).toBeNull(); + expect(parseNativeCliSessionDeepLink('#repos')).toBeNull(); + }); + + it('parses the provider tab with a null sessionId', () => { + expect(parseNativeCliSessionDeepLink('#repos/ws-1/cli-sessions/codex')).toEqual({ + workspaceId: 'ws-1', + provider: 'codex', + sessionId: null, + }); + }); + + it('parses a provider session deep-link', () => { + expect(parseNativeCliSessionDeepLink('#repos/ws-1/cli-sessions/claude/sess-abc')).toEqual({ + workspaceId: 'ws-1', + provider: 'claude', + sessionId: 'sess-abc', + }); + }); + + it('decodes URI-encoded workspace and session segments', () => { + expect(parseNativeCliSessionDeepLink('#repos/ws%201/cli-sessions/codex/sess%2Fx')).toEqual({ + workspaceId: 'ws 1', + provider: 'codex', + sessionId: 'sess/x', + }); + }); + + it('treats legacy copilot-sessions links as Copilot provider links', () => { + expect(parseNativeCliSessionDeepLink('#repos/ws-1/copilot-sessions/sess-abc')).toEqual({ + workspaceId: 'ws-1', + provider: 'copilot', + sessionId: 'sess-abc', + }); + }); +}); + +describe('buildNativeCliSessionHash', () => { + it('builds the provider tab hash when sessionId is omitted or null', () => { + expect(buildNativeCliSessionHash('ws-1', 'codex')).toBe('#repos/ws-1/cli-sessions/codex'); + expect(buildNativeCliSessionHash('ws-1', 'claude', null)).toBe('#repos/ws-1/cli-sessions/claude'); + }); + + it('builds a session hash with encoded segments', () => { + expect(buildNativeCliSessionHash('ws 1', 'codex', 'sess/x')).toBe('#repos/ws%201/cli-sessions/codex/sess%2Fx'); + }); + + it('round-trips through the parser', () => { + const hash = buildNativeCliSessionHash('ws-1', 'claude', 'sess-abc'); + expect(parseNativeCliSessionDeepLink(hash)).toEqual({ workspaceId: 'ws-1', provider: 'claude', sessionId: 'sess-abc' }); + }); +}); + describe('parseNativeCopilotSessionDeepLink', () => { it('returns null for hashes outside the copilot-sessions tab', () => { expect(parseNativeCopilotSessionDeepLink('#repos/ws-1/notes')).toBeNull(); diff --git a/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx b/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx index e48920e58..32fd752cd 100644 --- a/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx +++ b/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx @@ -134,6 +134,7 @@ vi.mock('../../../../../src/server/spa/client/react/utils/config', () => ({ isWorkflowsEnabled: () => false, isPullRequestsEnabled: () => false, isNativeCopilotSessionsEnabled: () => false, + isNativeCliSessionsEnabled: () => false, getScratchpadLayout: () => 'horizontal', DASHBOARD_CONFIG_UPDATED_EVENT: 'coc-dashboard-config-updated', })); diff --git a/packages/coc/test/spa/react/RepoDetail.test.ts b/packages/coc/test/spa/react/RepoDetail.test.ts index 1f404c00e..baf788d09 100644 --- a/packages/coc/test/spa/react/RepoDetail.test.ts +++ b/packages/coc/test/spa/react/RepoDetail.test.ts @@ -34,13 +34,13 @@ describe('RepoDetail SUB_TABS', () => { expect(SUB_TABS[1].key).toBe('git'); }); - it('has exactly 13 entries', () => { - expect(SUB_TABS).toHaveLength(13); + it('has exactly 14 entries', () => { + expect(SUB_TABS).toHaveLength(14); }); it('contains all expected sub-tabs in order', () => { const keys = SUB_TABS.map(t => t.key); - expect(keys).toEqual(['chats', 'git', 'terminal', 'work-items', 'dreams', 'pull-requests', 'explorer', 'workflows', 'schedules', 'tasks', 'notes', 'settings', 'wiki']); + expect(keys).toEqual(['chats', 'git', 'terminal', 'work-items', 'dreams', 'cli-sessions', 'pull-requests', 'explorer', 'workflows', 'schedules', 'tasks', 'notes', 'settings', 'wiki']); }); it('includes "wiki" entry without a shortcut', () => { @@ -50,7 +50,7 @@ describe('RepoDetail SUB_TABS', () => { }); it('has explorer as the seventh tab (after pull requests)', () => { - expect(SUB_TABS[6].key).toBe('explorer'); + expect(SUB_TABS[7].key).toBe('explorer'); }); it('chats is the first entry', () => { @@ -69,13 +69,13 @@ describe('RepoDetail VISIBLE_SUB_TABS', () => { expect(VISIBLE_SUB_TABS.find(t => t.key === 'wiki')).toBeUndefined(); }); - it('has 12 entries (all SUB_TABS minus wiki)', () => { - expect(VISIBLE_SUB_TABS).toHaveLength(12); + it('has 13 entries (all SUB_TABS minus wiki)', () => { + expect(VISIBLE_SUB_TABS).toHaveLength(13); }); it('contains all non-wiki tabs in order', () => { const keys = VISIBLE_SUB_TABS.map(t => t.key); - expect(keys).toEqual(['chats', 'git', 'terminal', 'work-items', 'dreams', 'pull-requests', 'explorer', 'workflows', 'schedules', 'tasks', 'notes', 'settings']); + expect(keys).toEqual(['chats', 'git', 'terminal', 'work-items', 'dreams', 'cli-sessions', 'pull-requests', 'explorer', 'workflows', 'schedules', 'tasks', 'notes', 'settings']); }); it('renders visibleSubTabs.map in the tab strip', () => { @@ -101,7 +101,7 @@ describe('RepoDetail Dreams tab feature gating', () => { }); it('visibleSubTabs depends on dreamsEnabled', () => { - expect(REPO_DETAIL_SOURCE).toContain('[isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, uiLayoutMode]'); + expect(REPO_DETAIL_SOURCE).toContain('[isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, nativeCliSessionsEnabled, uiLayoutMode]'); }); it('redirects away from dreams when the feature transitions to disabled', () => { @@ -735,7 +735,7 @@ describe('RepoDetail dev-workflow tab relabeling and reorder', () => { it('dev-workflow branch defines the correct tab order', () => { expect(REPO_DETAIL_SOURCE).toContain( - "'chats', 'work-items', 'dreams', 'schedules', 'explorer',", + "'chats', 'work-items', 'dreams', 'cli-sessions', 'schedules', 'explorer',", ); expect(REPO_DETAIL_SOURCE).toContain( "'workflows', 'git', 'terminal', 'pull-requests', 'tasks', 'settings',", diff --git a/packages/coc/test/spa/react/Router.test.ts b/packages/coc/test/spa/react/Router.test.ts index 44e56f8fa..2343a4bca 100644 --- a/packages/coc/test/spa/react/Router.test.ts +++ b/packages/coc/test/spa/react/Router.test.ts @@ -144,12 +144,13 @@ describe('VALID_REPO_SUB_TABS', () => { expect(VALID_REPO_SUB_TABS.has('pull-requests')).toBe(true); }); - it('includes "copilot-sessions"', () => { + it('includes "cli-sessions"', () => { + expect(VALID_REPO_SUB_TABS.has('cli-sessions')).toBe(true); expect(VALID_REPO_SUB_TABS.has('copilot-sessions')).toBe(true); }); - it('has exactly 17 entries', () => { - expect(VALID_REPO_SUB_TABS.size).toBe(17); + it('has exactly 18 entries', () => { + expect(VALID_REPO_SUB_TABS.size).toBe(18); }); }); diff --git a/packages/coc/test/spa/react/mobile-height-chain.test.ts b/packages/coc/test/spa/react/mobile-height-chain.test.ts index f8183acac..ebdaec1cc 100644 --- a/packages/coc/test/spa/react/mobile-height-chain.test.ts +++ b/packages/coc/test/spa/react/mobile-height-chain.test.ts @@ -31,8 +31,8 @@ describe('mobile repo tab height chain', () => { ...Array.from(repoDetailSource.matchAll(compound), match => match[1]), ]; - expect(paneClasses).toHaveLength(8); - expect(paneClasses).toEqual(Array(8).fill('flex flex-col flex-1 min-h-0 min-w-0 overflow-hidden')); + expect(paneClasses).toHaveLength(9); + expect(paneClasses).toEqual(Array(9).fill('flex flex-col flex-1 min-h-0 min-w-0 overflow-hidden')); }); it('keeps the RepoChatTab mobile root in the flex height chain', () => { From b6056168e9ad6381944add1fc2d3e79eeaaf1a71 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 17:38:24 +0000 Subject: [PATCH 28/37] Harden Codex native session image parsing --- .../coc-knowledge/references/dashboard-spa.md | 2 +- .../cli-session-parsers.ts | 61 +++++++++++++++++++ .../server/native-cli-session-parsers.test.ts | 33 ++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index 1b6c3d35a..da4793d62 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -583,7 +583,7 @@ The list supports text query, session-ID, branch, date-range filters, and pagina The list route deduplicates against the Activity tab: native sessions whose provider session ID matches a CoC process `sdk_session_id` for the workspace (resolved via `ProcessStore.getSdkSessionIds(workspaceId)`) are hidden, and the response `deduplicatedCount` drives a `native-sessions-deduplicated` hint reading `N sessions hidden — already tracked in CoC Activity`. Automated Copilot background-job sessions whose first turn matches `BACKGROUND_JOB_PROMPT_PREFIXES` are hidden by default and counted in `backgroundJobCount`, which drives a `native-sessions-background-hidden` hint. The panel renders distinct disabled/unavailable (`store-missing`/`store-invalid`)/loading/empty/error states per provider. -The detail pane reconstructs the selected session as a rich CoC chat transcript rather than a plain text dump. The unified detail endpoint (`GET /api/workspaces/:id/native-cli-sessions/:sessionId?provider=...`) returns provider-tagged metadata, `storePath`, `searchIndexAvailable`, and an always-present `conversation: ReconstructedConversationTurn[]`. Copilot reconstruction prefers the native `session-state//events.jsonl` log and falls back to flat `session-store.db` turns; Codex and Claude reconstruction comes from defensive JSONL parsers that skip malformed or unknown records and preserve user/assistant messages, tool start/complete/failed timeline items, thinking/reasoning, images, and model metadata when present. The SPA maps each turn to `ClientConversationTurn` via `nativeConversationTurns.ts` (`toClientConversationTurns`, folding assistant `thinking` into a leading markdown blockquote since `ClientConversationTurn` has no reasoning field) and renders one read-only `ConversationTurnBubble` per turn under a `native-session-conversation` card (`Conversation (N)`) with the selected provider passed through for avatar coloring. The whole feature is strictly read-only: no input box, streaming, resume, follow-up, archive, pin, delete, retry, or turn actions are exposed; stored HTML/scripts never execute. +The detail pane reconstructs the selected session as a rich CoC chat transcript rather than a plain text dump. The unified detail endpoint (`GET /api/workspaces/:id/native-cli-sessions/:sessionId?provider=...`) returns provider-tagged metadata, `storePath`, `searchIndexAvailable`, and an always-present `conversation: ReconstructedConversationTurn[]`. Copilot reconstruction prefers the native `session-state//events.jsonl` log and falls back to flat `session-store.db` turns; Codex and Claude reconstruction comes from defensive JSONL parsers that skip malformed or unknown records and preserve user/assistant messages, tool start/complete/failed timeline items, thinking/reasoning, data-URL images, and model metadata when present. Codex `event_msg` user-message image metadata is merged into the matching user turn; `local_images` paths are shown as read-only markdown references because the existing chat image gallery only renders data URLs. The SPA maps each turn to `ClientConversationTurn` via `nativeConversationTurns.ts` (`toClientConversationTurns`, folding assistant `thinking` into a leading markdown blockquote since `ClientConversationTurn` has no reasoning field) and renders one read-only `ConversationTurnBubble` per turn under a `native-session-conversation` card (`Conversation (N)`) with the selected provider passed through for avatar coloring. The whole feature is strictly read-only: no input box, streaming, resume, follow-up, archive, pin, delete, retry, or turn actions are exposed; stored HTML/scripts never execute. ## Memory Route diff --git a/packages/coc/src/server/native-copilot-sessions/cli-session-parsers.ts b/packages/coc/src/server/native-copilot-sessions/cli-session-parsers.ts index c35b14147..8fd07a3a7 100644 --- a/packages/coc/src/server/native-copilot-sessions/cli-session-parsers.ts +++ b/packages/coc/src/server/native-copilot-sessions/cli-session-parsers.ts @@ -95,6 +95,36 @@ function dataUrlFromImageBlock(block: Record): string | undefin return data.startsWith('data:') ? data : `data:${mediaType};base64,${data}`; } +function isDataImageUrl(value: string): boolean { + return /^data:image\/[^;]+;base64,/i.test(value.trim()); +} + +function extractCodexEventImages(payload: Record): { images: string[]; localImages: string[] } { + const images: string[] = []; + const rawImages = payload.images; + if (Array.isArray(rawImages)) { + for (const value of rawImages) { + if (typeof value === 'string') { + if (isDataImageUrl(value)) { + images.push(value); + } + continue; + } + const block = asRecord(value); + const image = block ? dataUrlFromImageBlock(block) : undefined; + if (image) { + images.push(image); + } + } + } + + const localImages = Array.isArray(payload.local_images) + ? payload.local_images.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + : []; + + return { images, localImages }; +} + function extractTextFromBlocks(blocks: unknown): { text: string[]; thinking: string[]; images: string[] } { const text: string[] = []; const thinking: string[] = []; @@ -326,6 +356,37 @@ export function parseCodexRollout(rawJsonl: string): ReconstructedConversationTu currentModel = asString(payload.model) ?? currentModel; continue; } + if (envelopeType === 'event_msg' && asString(payload.type) === 'user_message') { + const message = asString(payload.message); + const { images, localImages } = extractCodexEventImages(payload); + if (!message && images.length === 0 && localImages.length === 0) { + continue; + } + + const previous = turns[turns.length - 1]; + const turn = previous?.role === 'user' && (!message || previous.content === message) + ? previous + : newTurn('user', timestamp); + + if (turn !== previous) { + if (message) { + appendText(turn, message, timestamp); + } + turns.push(turn); + } + if (images.length > 0) { + turn.images = [...(turn.images ?? []), ...images]; + } + if (localImages.length > 0) { + appendText( + turn, + localImages.map(imagePath => `Attached local image: \`${imagePath}\``).join('\n'), + timestamp, + ); + } + currentAssistant = null; + continue; + } if (envelopeType !== 'response_item') { continue; } diff --git a/packages/coc/test/server/native-cli-session-parsers.test.ts b/packages/coc/test/server/native-cli-session-parsers.test.ts index e90172acb..780d9143f 100644 --- a/packages/coc/test/server/native-cli-session-parsers.test.ts +++ b/packages/coc/test/server/native-cli-session-parsers.test.ts @@ -213,6 +213,39 @@ describe('parseCodexRollout', () => { expect(turns![0].images).toEqual(['data:image/jpeg;base64,xyz']); }); + it('captures Codex user_message event image metadata without duplicating the user turn', () => { + const raw = [ + line({ + timestamp: '2026-06-13T11:00:02.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'inspect this screenshot' }], + }, + }), + line({ + timestamp: '2026-06-13T11:00:02.100Z', + type: 'event_msg', + payload: { + type: 'user_message', + message: 'inspect this screenshot', + images: ['data:image/png;base64,abc'], + local_images: ['/tmp/codex-attach/image.png'], + text_elements: [], + }, + }), + ].join('\n'); + + const turns = parseCodexRollout(raw); + + expect(turns).not.toBeNull(); + expect(turns).toHaveLength(1); + expect(turns![0].images).toEqual(['data:image/png;base64,abc']); + expect(turns![0].content).toContain('inspect this screenshot'); + expect(turns![0].content).toContain('Attached local image: `/tmp/codex-attach/image.png`'); + }); + it('marks failed function_call_output records as failed', () => { const raw = [ line({ From 71688e815e975dd142ecc8a32cf44d492d6789b8 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 17:44:16 +0000 Subject: [PATCH 29/37] Deduplicate native CLI session list rows Collapse duplicate filesystem-backed native CLI transcript records with the same provider session ID to the newest metadata record so list rows and detail deep links remain stable. Add regression coverage for duplicate Claude transcript files and keep documentation current. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/server-architecture.md | 2 +- .../native-cli-session-service.ts | 44 ++++++++++++++----- .../server/native-cli-session-service.test.ts | 40 +++++++++++++++++ 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/.github/skills/coc-knowledge/references/server-architecture.md b/.github/skills/coc-knowledge/references/server-architecture.md index 9ca80eeae..06f2af4f5 100644 --- a/.github/skills/coc-knowledge/references/server-architecture.md +++ b/.github/skills/coc-knowledge/references/server-architecture.md @@ -103,7 +103,7 @@ The `src/server/` tree is grouped by feature domain. Cross-cutting plumbing stay | `ralph/` | Iterative execution sessions and file-backed journal (see [ralph.md](ralph.md)) | | `for-each/` | Dedicated For Each run records, item-plan validation, file-backed repo-scoped draft/approval storage, and sequential child-chat orchestration | | `map-reduce/` | Dedicated Map Reduce plan generation, run records, map-plan validation, reduce-step state, per-run parallelism configuration, file-backed repo-scoped draft/approval/execution storage with parallel map claiming, and child-chat orchestration that auto-chains reduce after successful map completion | -| `native-copilot-sessions/` | Read-only native session services. Copilot reads the server user's native GitHub Copilot CLI SQLite store (`~/.copilot/session-store.db`) with short-lived read-only connections, workspace scoping by native `cwd`/`repository`, parameterized FTS text search, and typed `db-missing`/`db-invalid` unavailable states. Codex and Claude filesystem providers scan `~/.codex/sessions` rollout JSONL and `~/.claude/projects/` transcript JSONL with `readFileSync`/`existsSync`, mtime-keyed metadata, workspace scoping by transcript `cwd`, substring text search, and typed `store-missing`/`store-invalid` unavailable states. CoC never writes to native CLI stores | +| `native-copilot-sessions/` | Read-only native session services. Copilot reads the server user's native GitHub Copilot CLI SQLite store (`~/.copilot/session-store.db`) with short-lived read-only connections, workspace scoping by native `cwd`/`repository`, parameterized FTS text search, and typed `db-missing`/`db-invalid` unavailable states. Codex and Claude filesystem providers scan `~/.codex/sessions` rollout JSONL and `~/.claude/projects/` transcript JSONL with `readFileSync`/`existsSync`, mtime-keyed metadata, workspace scoping by transcript `cwd`, duplicate native IDs collapsed to the newest transcript for stable deep links, substring text search, and typed `store-missing`/`store-invalid` unavailable states. CoC never writes to native CLI stores | | `models/` | Model registry endpoints | | `agent-providers/` | Agent-provider quota cache, provider status routes, SDK install helpers, and the pure Auto provider router that evaluates configured priority, availability, normal quota thresholds, weekly guards, fallback, and selection warnings before callers expand effort tiers. Queue/fresh-terminal defaults, explicit SPA Auto requests (`context.autoProviderRouting.requested`), direct Ralph, For Each, and work-item enqueue surfaces use the shared quota cache and refresh it only when missing or stale. | | `messaging/` | Teams bot integration: manager, command router, per-user state | diff --git a/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts b/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts index e4ee7d43e..66d0c0a67 100644 --- a/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts +++ b/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts @@ -128,6 +128,15 @@ function parseTimestamp(value: string | null | undefined): number { return value ? Date.parse(value) : Number.NaN; } +function compareMetadataUpdatedDesc( + a: NativeCliSessionMetadata, + b: NativeCliSessionMetadata, +): number { + const aTs = parseTimestamp(a.updatedAt); + const bTs = parseTimestamp(b.updatedAt); + return (Number.isNaN(bTs) ? 0 : bTs) - (Number.isNaN(aTs) ? 0 : aTs); +} + function summaryPreview(summary: string | null): string { if (!summary) { return ''; @@ -243,7 +252,7 @@ abstract class JsonlFileNativeSessionProvider implements NativeSessionProvider { const fromTs = options.from ? parseTimestamp(options.from) : undefined; const toTs = options.to ? parseTimestamp(options.to) : undefined; const q = options.q?.trim(); - let deduplicatedCount = 0; + const deduplicatedSessionIds = new Set(); const rows: Array<{ metadata: NativeCliSessionMetadata; snippets: string[] }> = []; for (const filePath of files) { @@ -270,7 +279,7 @@ abstract class JsonlFileNativeSessionProvider implements NativeSessionProvider { } } if (options.excludeSessionIds?.has(metadata.id)) { - deduplicatedCount += 1; + deduplicatedSessionIds.add(metadata.id); continue; } const raw = q ? readUtf8(filePath) : null; @@ -281,20 +290,25 @@ abstract class JsonlFileNativeSessionProvider implements NativeSessionProvider { rows.push({ metadata, snippets }); } - rows.sort((a, b) => { - const aTs = parseTimestamp(a.metadata.updatedAt); - const bTs = parseTimestamp(b.metadata.updatedAt); - return (Number.isNaN(bTs) ? 0 : bTs) - (Number.isNaN(aTs) ? 0 : aTs); - }); + const uniqueRowsById = new Map(); + for (const row of rows) { + const existing = uniqueRowsById.get(row.metadata.id); + if (!existing || compareMetadataUpdatedDesc(row.metadata, existing.metadata) < 0) { + uniqueRowsById.set(row.metadata.id, row); + } + } + + const uniqueRows = [...uniqueRowsById.values()]; + uniqueRows.sort((a, b) => compareMetadataUpdatedDesc(a.metadata, b.metadata)); - const total = rows.length; - const page = rows.slice(offset, offset + limit); + const total = uniqueRows.length; + const page = uniqueRows.slice(offset, offset + limit); return { available: true, items: page.map(row => toListItem(row.metadata, row.snippets)), total, searchIndexAvailable: false, - deduplicatedCount, + deduplicatedCount: deduplicatedSessionIds.size, backgroundJobCount: 0, limit, offset, @@ -312,17 +326,23 @@ abstract class JsonlFileNativeSessionProvider implements NativeSessionProvider { } catch { return { available: false, reason: 'store-invalid' }; } + const matches: Array<{ metadata: NativeCliSessionMetadata; filePath: string }> = []; for (const filePath of files) { const metadata = this.getMetadata(filePath); if (!metadata || metadata.id !== id || !this.metadataMatchesWorkspace(metadata, scope)) { continue; } - const raw = readUtf8(filePath); + matches.push({ metadata, filePath }); + } + matches.sort((a, b) => compareMetadataUpdatedDesc(a.metadata, b.metadata)); + const match = matches[0]; + if (match) { + const raw = readUtf8(match.filePath); if (raw === null) { return { available: false, reason: 'store-invalid' }; } const conversation = this.parseConversation(raw) ?? []; - return { available: true, session: toDetail(metadata, conversation) }; + return { available: true, session: toDetail(match.metadata, conversation) }; } return { available: true, session: null }; } diff --git a/packages/coc/test/server/native-cli-session-service.test.ts b/packages/coc/test/server/native-cli-session-service.test.ts index 892e0c83c..5bdc0a8f0 100644 --- a/packages/coc/test/server/native-cli-session-service.test.ts +++ b/packages/coc/test/server/native-cli-session-service.test.ts @@ -159,6 +159,46 @@ describe('ClaudeNativeSessionProvider', () => { expect(result.items[0].matchSnippets[0]).toContain('transcript'); }); + it('collapses duplicate transcript files with the same session id to the newest record', () => { + const workspaceRoot = path.join(tmpDir, 'repo'); + const storePath = path.join(tmpDir, 'claude', 'projects'); + const encodedFolder = path.join(storePath, dashEncode(workspaceRoot)); + writeJsonl(path.join(encodedFolder, 'older.jsonl'), [ + { type: 'user', sessionId: 'claude-dup', cwd: workspaceRoot, gitBranch: 'main', timestamp: '2026-06-13T08:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'Older duplicate transcript' }] } }, + { type: 'assistant', sessionId: 'claude-dup', cwd: workspaceRoot, timestamp: '2026-06-13T08:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'Old answer' }] } }, + ]); + writeJsonl(path.join(encodedFolder, 'newer.jsonl'), [ + { type: 'user', sessionId: 'claude-dup', cwd: workspaceRoot, gitBranch: 'main', timestamp: '2026-06-13T09:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'Newer duplicate transcript' }] } }, + { type: 'assistant', sessionId: 'claude-dup', cwd: workspaceRoot, timestamp: '2026-06-13T09:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'New answer' }] } }, + ]); + + const provider = new ClaudeNativeSessionProvider({ storePath }); + const listed = provider.listSessions({ rootPath: workspaceRoot }); + + expect(listed.available).toBe(true); + if (!listed.available) return; + expect(listed.total).toBe(1); + expect(listed.items).toHaveLength(1); + expect(listed.items[0]).toMatchObject({ + id: 'claude-dup', + summaryPreview: 'Newer duplicate transcript', + updatedAt: '2026-06-13T09:00:01.000Z', + }); + + const detail = provider.getSession({ rootPath: workspaceRoot }, 'claude-dup'); + expect(detail.available).toBe(true); + if (!detail.available) return; + expect(detail.session?.conversation[0].content).toBe('Newer duplicate transcript'); + + const deduped = provider.listSessions({ rootPath: workspaceRoot }, { + excludeSessionIds: new Set(['claude-dup']), + }); + expect(deduped.available).toBe(true); + if (!deduped.available) return; + expect(deduped.total).toBe(0); + expect(deduped.deduplicatedCount).toBe(1); + }); + it('returns reconstructed Claude detail with tool results', () => { const workspaceRoot = path.join(tmpDir, 'repo'); const storePath = path.join(tmpDir, 'claude', 'projects'); From cb2a76e5045f83d01a9a0414c216a0f334194077 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 17:52:32 +0000 Subject: [PATCH 30/37] Consolidate native CLI sessions flag Gate legacy Copilot session compatibility routes with features.nativeCliSessions and remove the old nativeCopilotSessions runtime/admin flag so CLI Sessions has one operational switch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/coc-knowledge/references/rest-api.md | 2 +- .../coc-knowledge/references/server-architecture.md | 1 - packages/coc-client/src/contracts/admin.ts | 3 --- packages/coc/src/config.ts | 5 ----- .../coc/src/config/admin-setting-definitions.ts | 10 +--------- packages/coc/src/server/routes/index.ts | 11 ++++++----- .../server/routes/native-copilot-session-routes.ts | 6 +++--- .../useNativeCopilotSessionsEnabled.ts | 13 ------------- .../coc/src/server/spa/client/react/utils/config.ts | 6 ------ .../test/config/admin-setting-definitions.test.ts | 7 +++++++ .../coc/test/server/native-copilot-sessions.test.ts | 4 ++-- .../NativeCopilotSessionsPanel-real-bubble.test.tsx | 1 - .../spa/client/NativeCopilotSessionsPanel.test.tsx | 4 ---- .../client/repos/RepoDetail-layout-mode.test.tsx | 1 - 14 files changed, 20 insertions(+), 54 deletions(-) delete mode 100644 packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCopilotSessionsEnabled.ts diff --git a/.github/skills/coc-knowledge/references/rest-api.md b/.github/skills/coc-knowledge/references/rest-api.md index fca27236e..ec329b49c 100644 --- a/.github/skills/coc-knowledge/references/rest-api.md +++ b/.github/skills/coc-knowledge/references/rest-api.md @@ -177,7 +177,7 @@ All Map Reduce routes are workspace-scoped and gated by `mapReduce.enabled` (def ## Native Copilot Sessions -Read-only, workspace-scoped views over the server user's native GitHub Copilot CLI session store (`~/.copilot/session-store.db`). Gated by the disabled-by-default `features.nativeCopilotSessions` flag with a live guard. CoC opens the native SQLite store read-only with short-lived per-request connections, never writes to it, and never imports native sessions into CoC process history. Disabled and unavailable states return HTTP 200 with typed payloads: `{ enabled: false, reason: 'feature-disabled' }` when the flag is off, and `{ enabled: true, available: false, reason: 'db-missing' | 'db-invalid' }` when the store is absent or unreadable. Workspace scoping matches native `sessions.cwd` against the registered workspace root (equal or descendant path) or native `sessions.repository` against the workspace's origin-remote `owner/repo` (case-insensitive). `@plusplusoneplusplus/coc-client` exposes these routes through `client.nativeCopilotSessions`. +Read-only compatibility views over the server user's native GitHub Copilot CLI session store (`~/.copilot/session-store.db`). These legacy routes share the disabled-by-default `features.nativeCliSessions` live guard with the unified CLI Sessions API, so there is one operational switch for native Copilot/Codex/Claude session browsing. CoC opens the native SQLite store read-only with short-lived per-request connections, never writes to it, and never imports native sessions into CoC process history. Disabled and unavailable states return HTTP 200 with typed payloads: `{ enabled: false, reason: 'feature-disabled' }` when the flag is off, and `{ enabled: true, available: false, reason: 'db-missing' | 'db-invalid' }` when the store is absent or unreadable. Workspace scoping matches native `sessions.cwd` against the registered workspace root (equal or descendant path) or native `sessions.repository` against the workspace's origin-remote `owner/repo` (case-insensitive). `@plusplusoneplusplus/coc-client` keeps exposing these compatibility routes through `client.nativeCopilotSessions`; new UI code uses `client.nativeCliSessions`. | Method | Path | Description | |--------|------|-------------| diff --git a/.github/skills/coc-knowledge/references/server-architecture.md b/.github/skills/coc-knowledge/references/server-architecture.md index 06f2af4f5..43d35a0e5 100644 --- a/.github/skills/coc-knowledge/references/server-architecture.md +++ b/.github/skills/coc-knowledge/references/server-architecture.md @@ -197,7 +197,6 @@ claude: features: autoAgentProviderRouting: false # enables Auto for omitted-provider default paths ralphMultiAgentGrill: false # gated multi-agent Ralph grilling setup and agent preflight - nativeCopilotSessions: false # read-only Copilot Sessions dashboard tab over ~/.copilot/session-store.db nativeCliSessions: false # read-only CLI Sessions tab over native Copilot, Codex, and Claude stores agentProviderRouting: diff --git a/packages/coc-client/src/contracts/admin.ts b/packages/coc-client/src/contracts/admin.ts index d46d2275e..04361a4bd 100644 --- a/packages/coc-client/src/contracts/admin.ts +++ b/packages/coc-client/src/contracts/admin.ts @@ -118,7 +118,6 @@ export interface AdminResolvedConfig { commitChatLensDormantMode?: 'ghost' | 'pill'; autoAgentProviderRouting?: boolean; ralphMultiAgentGrill?: boolean; - nativeCopilotSessions?: boolean; nativeCliSessions?: boolean; }; workItems?: { hierarchy?: { enabled?: boolean }; sync?: { enabled?: boolean }; aiAuthoring?: { enabled?: boolean }; workflow?: { enabled?: boolean } }; @@ -199,7 +198,6 @@ export interface AdminConfigUpdate { 'features.commitChatLens'?: boolean; 'features.commitChatLensDormantMode'?: 'ghost' | 'pill'; 'features.autoAgentProviderRouting'?: boolean; - 'features.nativeCopilotSessions'?: boolean; 'features.nativeCliSessions'?: boolean; 'effortLevels.enabled'?: boolean; [key: string]: unknown; @@ -249,7 +247,6 @@ export interface RuntimeDashboardConfig { commitChatLensEnabled: boolean; commitChatLensDormantMode: 'ghost' | 'pill'; effortLevelsEnabled: boolean; - nativeCopilotSessionsEnabled: boolean; nativeCliSessionsEnabled: boolean; }; hostname?: string; diff --git a/packages/coc/src/config.ts b/packages/coc/src/config.ts index 2e0508854..e775a0fc1 100644 --- a/packages/coc/src/config.ts +++ b/packages/coc/src/config.ts @@ -253,8 +253,6 @@ export interface CLIConfig { autoAgentProviderRouting?: boolean; /** Multi-agent Ralph grilling experience. Disabled by default. */ ralphMultiAgentGrill?: boolean; - /** Read-only native GitHub Copilot CLI sessions tab. Disabled by default. */ - nativeCopilotSessions?: boolean; /** Read-only native Copilot/Codex/Claude CLI sessions tab. Disabled by default. */ nativeCliSessions?: boolean; }; @@ -542,8 +540,6 @@ export interface ResolvedCLIConfig { autoAgentProviderRouting: boolean; /** Multi-agent Ralph grilling experience. Disabled by default. */ ralphMultiAgentGrill: boolean; - /** Read-only native GitHub Copilot CLI sessions tab. Disabled by default. */ - nativeCopilotSessions: boolean; /** Read-only native Copilot/Codex/Claude CLI sessions tab. Disabled by default. */ nativeCliSessions: boolean; }; @@ -779,7 +775,6 @@ export const DEFAULT_CONFIG: ResolvedCLIConfig = { commitChatLensDormantMode: 'ghost', autoAgentProviderRouting: false, ralphMultiAgentGrill: false, - nativeCopilotSessions: false, nativeCliSessions: false, }, memoryPromotion: { diff --git a/packages/coc/src/config/admin-setting-definitions.ts b/packages/coc/src/config/admin-setting-definitions.ts index 67ff4b79a..81b3c37ae 100644 --- a/packages/coc/src/config/admin-setting-definitions.ts +++ b/packages/coc/src/config/admin-setting-definitions.ts @@ -717,18 +717,10 @@ export const ADMIN_SETTING_DEFINITIONS: readonly AdminSettingDefinition[] = [ }, }, bool({ key: 'features.autoAgentProviderRouting', default: false, runtime: 'restartRequired', runtimeFlag: 'autoAgentProviderRoutingEnabled' }), - bool({ - key: 'features.nativeCopilotSessions', default: false, runtime: 'live', runtimeFlag: 'nativeCopilotSessionsEnabled', - ui: { - group: 'dashboard', order: 60, label: 'Native Copilot CLI sessions', badge: 'experimental', - hint: 'Read-only Copilot Sessions tab that lists native GitHub Copilot CLI sessions (~/.copilot/session-store.db) for the active workspace. Disabled by default.', - testId: 'toggle-native-copilot-sessions-enabled', - }, - }), bool({ key: 'features.nativeCliSessions', default: false, runtime: 'live', runtimeFlag: 'nativeCliSessionsEnabled', ui: { - group: 'dashboard', order: 61, label: 'Native CLI sessions', badge: 'experimental', + group: 'dashboard', order: 60, label: 'Native CLI sessions', badge: 'experimental', hint: 'Read-only CLI Sessions tab that lists native Copilot, Codex, and Claude Code sessions for the active workspace. Disabled by default.', testId: 'toggle-native-cli-sessions-enabled', }, diff --git a/packages/coc/src/server/routes/index.ts b/packages/coc/src/server/routes/index.ts index 061bf5f88..3cdb4c172 100644 --- a/packages/coc/src/server/routes/index.ts +++ b/packages/coc/src/server/routes/index.ts @@ -720,12 +720,13 @@ export function registerAllRoutes(routes: Route[], opts: RegisterRoutesOptions): resolveDefaultProvider, }); - // Native Copilot CLI session routes: read-only workspace-scoped views over - // the server user's native session store. Live feature guard mirrors - // For Each/Map Reduce so admin toggles take effect without restart. + // Legacy Native Copilot CLI session routes: read-only compatibility aliases + // over the server user's native Copilot store. They share the unified + // `features.nativeCliSessions` live guard so there is one operational switch + // for the CLI Sessions surface. const getNativeCopilotSessionsEnabled = opts.runtimeConfigService - ? () => opts.runtimeConfigService!.config.features?.nativeCopilotSessions ?? false - : () => opts.resolvedConfig?.features?.nativeCopilotSessions ?? false; + ? () => opts.runtimeConfigService!.config.features?.nativeCliSessions ?? false + : () => opts.resolvedConfig?.features?.nativeCliSessions ?? false; const nativeCopilotSessionService = new NativeCopilotSessionService({ dbPath: opts.nativeCopilotSessionDbPath, sessionStateDir: opts.nativeCopilotSessionStateDir, diff --git a/packages/coc/src/server/routes/native-copilot-session-routes.ts b/packages/coc/src/server/routes/native-copilot-session-routes.ts index 24e69f467..94c774991 100644 --- a/packages/coc/src/server/routes/native-copilot-session-routes.ts +++ b/packages/coc/src/server/routes/native-copilot-session-routes.ts @@ -2,9 +2,9 @@ * Native GitHub Copilot CLI session routes. * * Read-only, workspace-scoped views over the current server user's native - * Copilot CLI session store. Gated by the disabled-by-default - * `features.nativeCopilotSessions` flag with a live guard so admin toggles - * take effect without restart. Disabled and unavailable states return + * Copilot CLI session store. These compatibility routes are gated by the + * disabled-by-default `features.nativeCliSessions` flag with a live guard so + * admin toggles take effect without restart. Disabled and unavailable states return * HTTP 200 with typed payloads so the dashboard renders non-fatal states. */ diff --git a/packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCopilotSessionsEnabled.ts b/packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCopilotSessionsEnabled.ts deleted file mode 100644 index f4343bc02..000000000 --- a/packages/coc/src/server/spa/client/react/hooks/feature-flags/useNativeCopilotSessionsEnabled.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useEffect, useState } from 'react'; -import { DASHBOARD_CONFIG_UPDATED_EVENT, isNativeCopilotSessionsEnabled } from '../../utils/config'; - -/** Live `features.nativeCopilotSessions` flag; tracks runtime config updates. */ -export function useNativeCopilotSessionsEnabled(): boolean { - const [enabled, setEnabled] = useState(isNativeCopilotSessionsEnabled()); - useEffect(() => { - const onConfigUpdated = () => setEnabled(isNativeCopilotSessionsEnabled()); - window.addEventListener(DASHBOARD_CONFIG_UPDATED_EVENT, onConfigUpdated); - return () => window.removeEventListener(DASHBOARD_CONFIG_UPDATED_EVENT, onConfigUpdated); - }, []); - return enabled; -} diff --git a/packages/coc/src/server/spa/client/react/utils/config.ts b/packages/coc/src/server/spa/client/react/utils/config.ts index 8e3cd94c6..b08ae4359 100644 --- a/packages/coc/src/server/spa/client/react/utils/config.ts +++ b/packages/coc/src/server/spa/client/react/utils/config.ts @@ -66,8 +66,6 @@ interface DashboardConfig { gitCrossCloneCherryPickEnabled?: boolean; /** Whether the Effort Tiers selector (Low/Medium/High) is enabled in the composer. Disabled by default. */ effortLevelsEnabled?: boolean; - /** Whether the read-only native Copilot CLI sessions tab is enabled (legacy feature flag). */ - nativeCopilotSessionsEnabled?: boolean; /** Whether the read-only native CLI sessions tab is enabled (feature flag). */ nativeCliSessionsEnabled?: boolean; } @@ -300,10 +298,6 @@ export function isDreamsEnabled(): boolean { return getConfig().dreamsEnabled === true; } -export function isNativeCopilotSessionsEnabled(): boolean { - return getConfig().nativeCopilotSessionsEnabled === true; -} - export function isNativeCliSessionsEnabled(): boolean { return getConfig().nativeCliSessionsEnabled === true; } diff --git a/packages/coc/test/config/admin-setting-definitions.test.ts b/packages/coc/test/config/admin-setting-definitions.test.ts index c809a9543..4439d1145 100644 --- a/packages/coc/test/config/admin-setting-definitions.test.ts +++ b/packages/coc/test/config/admin-setting-definitions.test.ts @@ -236,6 +236,13 @@ describe('runtime feature flags', () => { expect(flags.gitCommitLookupEnabled).toBe(false); }); + it('exposes only the unified native CLI sessions runtime flag', () => { + const flags = buildRuntimeFeatures(DEFAULT_CONFIG) as Record; + expect(flags.nativeCliSessionsEnabled).toBe(false); + expect(flags.nativeCopilotSessionsEnabled).toBeUndefined(); + expect(ADMIN_SETTING_DEFINITIONS.some(def => def.key === 'features.nativeCopilotSessions')).toBe(false); + }); + it('falls back to absentFallback ?? default for partial configs', () => { const flags = buildRuntimeFeatureFlags({}); for (const def of ADMIN_SETTING_DEFINITIONS) { diff --git a/packages/coc/test/server/native-copilot-sessions.test.ts b/packages/coc/test/server/native-copilot-sessions.test.ts index 05240feb8..8af80a16a 100644 --- a/packages/coc/test/server/native-copilot-sessions.test.ts +++ b/packages/coc/test/server/native-copilot-sessions.test.ts @@ -705,7 +705,7 @@ describe('Native Copilot session routes', () => { host: 'localhost', store, dataDir, - fileConfig: { features: { nativeCopilotSessions: options.enabled ?? true } }, + fileConfig: { features: { nativeCliSessions: options.enabled ?? true } }, nativeCopilotSessionDbPath: dbPath, // Isolated, empty state dir so detail reads exercise the DB fallback // without touching the real ~/.copilot/session-state. @@ -913,7 +913,7 @@ describe('Native Copilot session routes — dedup against CoC processes', () => host: 'localhost', store, dataDir, - fileConfig: { features: { nativeCopilotSessions: true } }, + fileConfig: { features: { nativeCliSessions: true } }, nativeCopilotSessionDbPath: dbPath, queue: { autoStart: false }, }); diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx index 3673bb4a7..730c7f8a5 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx @@ -38,7 +38,6 @@ const mockGet = vi.fn(); vi.mock('../../../../src/server/spa/client/react/api/cocClient', () => ({ getSpaCocClient: () => ({ - nativeCopilotSessions: { list: mockList, get: mockGet }, nativeCliSessions: { list: mockList, get: mockGet }, queue: { images: vi.fn() }, }), diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx index 2b212cf78..e4a02e8bc 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx @@ -14,10 +14,6 @@ const mockGet = vi.fn(); vi.mock('../../../../src/server/spa/client/react/api/cocClient', () => ({ getSpaCocClient: () => ({ - nativeCopilotSessions: { - list: mockList, - get: mockGet, - }, nativeCliSessions: { list: mockList, get: mockGet, diff --git a/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx b/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx index 32fd752cd..b562b348b 100644 --- a/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx +++ b/packages/coc/test/server/spa/client/repos/RepoDetail-layout-mode.test.tsx @@ -133,7 +133,6 @@ vi.mock('../../../../../src/server/spa/client/react/utils/config', () => ({ isScratchpadEnabled: () => false, isWorkflowsEnabled: () => false, isPullRequestsEnabled: () => false, - isNativeCopilotSessionsEnabled: () => false, isNativeCliSessionsEnabled: () => false, getScratchpadLayout: () => 'horizontal', DASHBOARD_CONFIG_UPDATED_EVENT: 'coc-dashboard-config-updated', From 106d692c79a7c47b6372888ad8b3fe2a183ffecc Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 17:56:20 +0000 Subject: [PATCH 31/37] Tighten Claude native session workspace scoping Require Claude native session records with cwd metadata to all remain under the active workspace root before listing or serving detail, preventing mixed-cwd transcripts from leaking across workspaces. Add regression coverage for mixed-cwd Claude transcripts and document the stricter native CLI session scoping rule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../coc-knowledge/references/server-architecture.md | 2 +- .../native-cli-session-service.ts | 9 ++++++--- .../coc/test/server/native-cli-session-service.test.ts | 6 ++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/skills/coc-knowledge/references/server-architecture.md b/.github/skills/coc-knowledge/references/server-architecture.md index 43d35a0e5..a36426b87 100644 --- a/.github/skills/coc-knowledge/references/server-architecture.md +++ b/.github/skills/coc-knowledge/references/server-architecture.md @@ -103,7 +103,7 @@ The `src/server/` tree is grouped by feature domain. Cross-cutting plumbing stay | `ralph/` | Iterative execution sessions and file-backed journal (see [ralph.md](ralph.md)) | | `for-each/` | Dedicated For Each run records, item-plan validation, file-backed repo-scoped draft/approval storage, and sequential child-chat orchestration | | `map-reduce/` | Dedicated Map Reduce plan generation, run records, map-plan validation, reduce-step state, per-run parallelism configuration, file-backed repo-scoped draft/approval/execution storage with parallel map claiming, and child-chat orchestration that auto-chains reduce after successful map completion | -| `native-copilot-sessions/` | Read-only native session services. Copilot reads the server user's native GitHub Copilot CLI SQLite store (`~/.copilot/session-store.db`) with short-lived read-only connections, workspace scoping by native `cwd`/`repository`, parameterized FTS text search, and typed `db-missing`/`db-invalid` unavailable states. Codex and Claude filesystem providers scan `~/.codex/sessions` rollout JSONL and `~/.claude/projects/` transcript JSONL with `readFileSync`/`existsSync`, mtime-keyed metadata, workspace scoping by transcript `cwd`, duplicate native IDs collapsed to the newest transcript for stable deep links, substring text search, and typed `store-missing`/`store-invalid` unavailable states. CoC never writes to native CLI stores | +| `native-copilot-sessions/` | Read-only native session services. Copilot reads the server user's native GitHub Copilot CLI SQLite store (`~/.copilot/session-store.db`) with short-lived read-only connections, workspace scoping by native `cwd`/`repository`, parameterized FTS text search, and typed `db-missing`/`db-invalid` unavailable states. Codex and Claude filesystem providers scan `~/.codex/sessions` rollout JSONL and `~/.claude/projects/` transcript JSONL with `readFileSync`/`existsSync`, mtime-keyed metadata, workspace scoping by transcript `cwd` (Claude requires every recorded transcript `cwd` to stay under the workspace root), duplicate native IDs collapsed to the newest transcript for stable deep links, substring text search, and typed `store-missing`/`store-invalid` unavailable states. CoC never writes to native CLI stores | | `models/` | Model registry endpoints | | `agent-providers/` | Agent-provider quota cache, provider status routes, SDK install helpers, and the pure Auto provider router that evaluates configured priority, availability, normal quota thresholds, weekly guards, fallback, and selection warnings before callers expand effort tiers. Queue/fresh-terminal defaults, explicit SPA Auto requests (`context.autoProviderRouting.requested`), direct Ralph, For Each, and work-item enqueue surfaces use the shared quota cache and refresh it only when missing or stale. | | `messaging/` | Teams bot integration: manager, command router, per-user state | diff --git a/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts b/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts index 66d0c0a67..62ba3e9a4 100644 --- a/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts +++ b/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts @@ -52,6 +52,7 @@ interface NativeCliSessionMetadata { createdAt: string | null; updatedAt: string | null; turnCount: number; + recordedCwds?: string[]; } interface CachedMetadata { @@ -524,15 +525,16 @@ export class ClaudeNativeSessionProvider extends JsonlFileNativeSessionProvider } protected metadataMatchesWorkspace(metadata: NativeCliSessionMetadata, scope: NativeSessionWorkspaceScope): boolean { - return pathMatchesWorkspace(metadata.cwd, scope.rootPath); + const cwdValues = metadata.recordedCwds?.length ? metadata.recordedCwds : (metadata.cwd ? [metadata.cwd] : []); + return cwdValues.length > 0 && cwdValues.every(cwd => pathMatchesWorkspace(cwd, scope.rootPath)); } protected parseMetadata(filePath: string, raw: string, stat: fs.Stats): NativeCliSessionMetadata | null { const records = parseJsonlLines(raw).map(line => line.record); const firstWithSession = records.find(record => asString(record.sessionId)); - const firstWithCwd = records.find(record => asString(record.cwd)); + const recordedCwds = Array.from(new Set(records.map(record => asString(record.cwd)).filter((cwd): cwd is string => Boolean(cwd)))); const id = asString(firstWithSession?.sessionId) ?? path.basename(filePath, '.jsonl'); - const cwd = asString(firstWithCwd?.cwd) ?? null; + const cwd = recordedCwds[0] ?? null; if (!id || !cwd) { return null; } @@ -553,6 +555,7 @@ export class ClaudeNativeSessionProvider extends JsonlFileNativeSessionProvider createdAt: firstTimestamp, updatedAt: lastTimestamp ?? new Date(stat.mtimeMs).toISOString(), turnCount: conversation?.length ?? 0, + recordedCwds, }; } diff --git a/packages/coc/test/server/native-cli-session-service.test.ts b/packages/coc/test/server/native-cli-session-service.test.ts index 5bdc0a8f0..fb105ef4c 100644 --- a/packages/coc/test/server/native-cli-session-service.test.ts +++ b/packages/coc/test/server/native-cli-session-service.test.ts @@ -140,6 +140,10 @@ describe('ClaudeNativeSessionProvider', () => { writeJsonl(path.join(encodedFolder, 'claude-wrong-cwd.jsonl'), [ { type: 'user', sessionId: 'claude-wrong-cwd', cwd: path.join(tmpDir, 'other'), timestamp: '2026-06-13T09:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'wrong cwd' }] } }, ]); + writeJsonl(path.join(encodedFolder, 'claude-mixed-cwd.jsonl'), [ + { type: 'user', sessionId: 'claude-mixed-cwd', cwd: workspaceRoot, gitBranch: 'feature/native-cli', timestamp: '2026-06-13T09:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'mixed cwd transcript' }] } }, + { type: 'assistant', sessionId: 'claude-mixed-cwd', cwd: path.join(tmpDir, 'other'), timestamp: '2026-06-13T09:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'escaped cwd' }] } }, + ]); const provider = new ClaudeNativeSessionProvider({ storePath }); const result = provider.listSessions({ rootPath: workspaceRoot }, { q: 'transcript', branch: 'feature/native-cli' }); @@ -157,6 +161,8 @@ describe('ClaudeNativeSessionProvider', () => { turnCount: 2, }); expect(result.items[0].matchSnippets[0]).toContain('transcript'); + const mixedDetail = provider.getSession({ rootPath: workspaceRoot }, 'claude-mixed-cwd'); + expect(mixedDetail).toEqual({ available: true, session: null }); }); it('collapses duplicate transcript files with the same session id to the newest record', () => { From c03f594f114148b3c7d705111d0afea8780833ae Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 18:02:32 +0000 Subject: [PATCH 32/37] Default CLI sessions to Copilot Keep the unified CLI Sessions tab and bare cli-sessions deep links aligned with the legacy Copilot Sessions behavior and the REST provider default. Add regression coverage for Copilot-default routing plus provider switching. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../coc-knowledge/references/dashboard-spa.md | 2 +- .../NativeCopilotSessionsPanel.tsx | 4 +-- .../server/spa/client/react/layout/Router.tsx | 2 +- ...eCopilotSessionsPanel-real-bubble.test.tsx | 2 ++ .../NativeCopilotSessionsPanel.test.tsx | 36 +++++++++++++------ .../native-copilot-sessions-deep-link.test.ts | 8 +++++ 6 files changed, 39 insertions(+), 15 deletions(-) diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index da4793d62..0d4488eaa 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -577,7 +577,7 @@ The repo-scoped Dreams tab (`features/dreams/DreamsPanel.tsx`) is a dedicated re ## CLI Sessions Tab -The repo-scoped `CLI Sessions` tab (`features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx`, exported as `NativeCliSessionsPanel`) is a read-only provider-switched view of native Copilot, Codex, and Claude Code CLI sessions for the active workspace. It is gated by `features.nativeCliSessions` / `nativeCliSessionsEnabled` (disabled by default; `useNativeCliSessionsEnabled()` tracks live runtime-config updates), reads through `coc-client`'s `nativeCliSessions` domain, and registers as the `cli-sessions` repo sub-tab while accepting the legacy hidden `copilot-sessions` key for old links. The panel renders a two-pane layout on wide screens (searchable session list left at a clamped ~42% width, selected-session detail right) and stacked single-pane navigation on narrow screens. A provider switcher selects Codex, Claude, or Copilot; the header uses the shared `ProviderBadge` palette (Codex indigo, Claude coral, Copilot green), a provider-specific native-session label, and a read-only badge whose tooltip shows the selected provider's local store path. +The repo-scoped `CLI Sessions` tab (`features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx`, exported as `NativeCliSessionsPanel`) is a read-only provider-switched view of native Copilot, Codex, and Claude Code CLI sessions for the active workspace. It is gated by `features.nativeCliSessions` / `nativeCliSessionsEnabled` (disabled by default; `useNativeCliSessionsEnabled()` tracks live runtime-config updates), reads through `coc-client`'s `nativeCliSessions` domain, and registers as the `cli-sessions` repo sub-tab while accepting the legacy hidden `copilot-sessions` key for old links. The panel renders a two-pane layout on wide screens (searchable session list left at a clamped ~42% width, selected-session detail right) and stacked single-pane navigation on narrow screens. A provider switcher defaults to Copilot for legacy compatibility and selects Copilot, Codex, or Claude; the header uses the shared `ProviderBadge` palette (Copilot green, Codex indigo, Claude coral), a provider-specific native-session label, and a read-only badge whose tooltip shows the selected provider's local store path. The list supports text query, session-ID, branch, date-range filters, and pagination. Codex and Claude use on-demand substring search over JSONL transcripts and report `searchIndexAvailable: false`; when a query is active the panel explains that there is no native search index. Copilot delegates to the native SQLite provider and reports its native search-index availability. Each list row shows a short session-ID chip, updated timestamp, two-line summary preview, repository/cwd, optional match snippets, and right-aligned turn-count and branch pills; the selected row gets a left accent bar. The selected session is deep-linked through the URL hash (`#repos/{wsId}/cli-sessions/{provider}/{sessionId}`, parsed/built via `parseNativeCliSessionDeepLink`/`buildNativeCliSessionHash`) so selections survive refresh/back-forward and are shareable; `#repos/{wsId}/copilot-sessions/{sessionId}` is parsed as a legacy Copilot provider link. diff --git a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx index fae560e83..d13cae007 100644 --- a/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx +++ b/packages/coc/src/server/spa/client/react/features/native-copilot-sessions/NativeCopilotSessionsPanel.tsx @@ -26,7 +26,7 @@ import { ConversationTurnBubble } from '../chat/conversation/ConversationTurnBub import { ProviderBadge } from '../chat/ProviderBadge'; import { toClientConversationTurns } from './nativeConversationTurns'; -const PROVIDERS: NativeCliSessionProviderId[] = ['codex', 'claude', 'copilot']; +const PROVIDERS: NativeCliSessionProviderId[] = ['copilot', 'codex', 'claude']; const PROVIDER_META: Record = { codex: { @@ -111,7 +111,7 @@ function ExternalLabel({ provider, storePath }: { provider: NativeCliSessionProv export function NativeCliSessionsPanel({ workspaceId }: NativeCliSessionsPanelProps) { const enabled = useNativeCliSessionsEnabled(); - const [provider, setProvider] = useState('codex'); + const [provider, setProvider] = useState('copilot'); const [filterDraft, setFilterDraft] = useState(EMPTY_FILTERS); const [filters, setFilters] = useState(EMPTY_FILTERS); diff --git a/packages/coc/src/server/spa/client/react/layout/Router.tsx b/packages/coc/src/server/spa/client/react/layout/Router.tsx index 5135a0979..d04ac348e 100644 --- a/packages/coc/src/server/spa/client/react/layout/Router.tsx +++ b/packages/coc/src/server/spa/client/react/layout/Router.tsx @@ -322,7 +322,7 @@ export function parseNativeCliSessionDeepLink( const cleaned = hash.replace(/^#/, ''); const parts = cleaned.split('/'); if (parts[0] === 'repos' && parts[1] && parts[2] === 'cli-sessions') { - const provider = parts[3] ? decodeURIComponent(parts[3]) : 'codex'; + const provider = parts[3] ? decodeURIComponent(parts[3]) : 'copilot'; if (provider !== 'copilot' && provider !== 'codex' && provider !== 'claude') return null; return { workspaceId: decodeURIComponent(parts[1]), diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx index 730c7f8a5..04e24b791 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx @@ -180,7 +180,9 @@ function makeRichDetailResponse() { async function openDetail() { mockList.mockResolvedValue(makeListResponse([makeListItem()])); mockGet.mockResolvedValue(makeRichDetailResponse()); + window.location.hash = '#repos/ws-1/cli-sessions/codex'; render(); + await waitFor(() => expect(screen.getByTestId('native-sessions-provider-codex').getAttribute('aria-selected')).toBe('true')); await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); fireEvent.click(screen.getAllByTestId('native-session-row')[0]); const detail = await screen.findByTestId('native-session-detail'); diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx index e4a02e8bc..99f0235db 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel.test.tsx @@ -74,9 +74,9 @@ function makeListItem(overrides: Partial> = {}) { updatedAt: '2026-06-11T17:56:22.081Z', turnCount: 3, matchSnippets: [], - provider: 'codex', - storePath: '/home/me/.codex/sessions', - searchIndexAvailable: false, + provider: 'copilot', + storePath: '/home/me/.copilot/session-store.db', + searchIndexAvailable: true, ...overrides, }; } @@ -157,9 +157,9 @@ function makeDetailResponse(overrides: Partial> = {}) { ], }, ], - provider: 'codex', - storePath: '/home/me/.codex/sessions', - searchIndexAvailable: false, + provider: 'copilot', + storePath: '/home/me/.copilot/session-store.db', + searchIndexAvailable: true, ...overrides, }, }; @@ -196,7 +196,7 @@ describe('NativeCopilotSessionsPanel', () => { it('renders a typed unavailable state when the native store is missing', async () => { mockList.mockResolvedValue({ - enabled: true, available: false, reason: 'store-missing', provider: 'codex', items: [], total: 0, limit: 50, offset: 0, + enabled: true, available: false, reason: 'store-missing', provider: 'copilot', items: [], total: 0, limit: 50, offset: 0, }); render(); await waitFor(() => expect(screen.getByTestId('native-sessions-unavailable')).toBeTruthy()); @@ -234,7 +234,7 @@ describe('NativeCopilotSessionsPanel', () => { fireEvent.click(screen.getAllByTestId('native-session-row')[0]); await waitFor(() => expect(screen.getByTestId('native-session-detail')).toBeTruthy()); - expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-aaaa-bbbb', 'codex'); + expect(mockGet).toHaveBeenCalledWith('ws-1', 'session-aaaa-bbbb', 'copilot'); const detail = screen.getByTestId('native-session-detail'); // Metadata header preserved. @@ -257,7 +257,7 @@ describe('NativeCopilotSessionsPanel', () => { // Assistant turn carries the selected provider, model, tool-call card, and // reasoning folded into the content/timeline (no component fork). - expect(bubbles[1].getAttribute('data-provider')).toBe('codex'); + expect(bubbles[1].getAttribute('data-provider')).toBe('copilot'); expect(bubbles[1].getAttribute('data-model')).toBe('gpt-5.5'); expect(bubbles[1].getAttribute('data-tool-calls')).toContain('shell'); expect(bubbles[1].getAttribute('data-timeline-types')).toContain('tool-complete'); @@ -293,7 +293,7 @@ describe('NativeCopilotSessionsPanel', () => { fireEvent.click(screen.getByTestId('native-sessions-apply-filters')); await waitFor(() => { - expect(mockList).toHaveBeenLastCalledWith('ws-1', expect.objectContaining({ provider: 'codex', q: 'mermaid' })); + expect(mockList).toHaveBeenLastCalledWith('ws-1', expect.objectContaining({ provider: 'copilot', q: 'mermaid' })); }); await waitFor(() => expect(screen.getByTestId('native-session-match-snippets')).toBeTruthy()); expect(screen.getByTestId('native-session-match-snippets').textContent).toContain('matched mermaid snippet'); @@ -337,7 +337,7 @@ describe('NativeCopilotSessionsPanel', () => { await waitFor(() => expect(screen.getByTestId('native-sessions-table')).toBeTruthy()); fireEvent.click(screen.getAllByTestId('native-session-row')[0]); - await waitFor(() => expect(window.location.hash).toBe('#repos/ws-1/cli-sessions/codex/session-aaaa-bbbb')); + await waitFor(() => expect(window.location.hash).toBe('#repos/ws-1/cli-sessions/copilot/session-aaaa-bbbb')); await waitFor(() => expect(screen.getByTestId('native-session-detail')).toBeTruthy()); }); @@ -349,6 +349,20 @@ describe('NativeCopilotSessionsPanel', () => { await waitFor(() => expect(screen.getByTestId('native-session-detail')).toBeTruthy()); }); + it('defaults to Copilot and switches providers through the tab selector', async () => { + mockList.mockResolvedValue(makeListResponse([makeListItem()])); + render(); + await waitFor(() => expect(mockList).toHaveBeenLastCalledWith('ws-1', expect.objectContaining({ provider: 'copilot' }))); + expect(screen.getByTestId('native-sessions-provider-copilot').getAttribute('aria-selected')).toBe('true'); + + fireEvent.click(screen.getByTestId('native-sessions-provider-claude')); + + await waitFor(() => expect(mockList).toHaveBeenLastCalledWith('ws-1', expect.objectContaining({ provider: 'claude' }))); + expect(window.location.hash).toBe('#repos/ws-1/cli-sessions/claude'); + expect(screen.getByTestId('native-sessions-provider-claude').getAttribute('aria-selected')).toBe('true'); + expect(screen.getByTestId('native-session-external-label').textContent).toContain('Native Claude Code session'); + }); + it('ignores a deep-link hash that targets a different workspace', async () => { mockList.mockResolvedValue(makeListResponse([makeListItem()])); window.location.hash = '#repos/other-ws/cli-sessions/codex/session-aaaa-bbbb'; diff --git a/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts b/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts index 2501d6c08..3320b99c6 100644 --- a/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts +++ b/packages/coc/test/server/spa/client/native-copilot-sessions-deep-link.test.ts @@ -14,6 +14,14 @@ describe('parseNativeCliSessionDeepLink', () => { expect(parseNativeCliSessionDeepLink('#repos')).toBeNull(); }); + it('defaults a bare CLI Sessions tab link to the Copilot provider', () => { + expect(parseNativeCliSessionDeepLink('#repos/ws-1/cli-sessions')).toEqual({ + workspaceId: 'ws-1', + provider: 'copilot', + sessionId: null, + }); + }); + it('parses the provider tab with a null sessionId', () => { expect(parseNativeCliSessionDeepLink('#repos/ws-1/cli-sessions/codex')).toEqual({ workspaceId: 'ws-1', From 5f61f74bee57a0dce546bfb40c82763a37d3dc8b Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 18:06:47 +0000 Subject: [PATCH 33/37] Test native CLI session HTML escaping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NativeCopilotSessionsPanel-real-bubble.test.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx index 04e24b791..46d6caf64 100644 --- a/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx +++ b/packages/coc/test/server/spa/client/NativeCopilotSessionsPanel-real-bubble.test.tsx @@ -130,7 +130,7 @@ function makeRichDetailResponse() { }, { role: 'assistant', - content: '## Database overview\n\nThe `session-store.db` file is a SQLite database.', + content: '## Database overview\n\nThe `session-store.db` file is a SQLite database.\n\n\n', timestamp: '2026-06-12T14:52:10.000Z', turnIndex: 1, model: 'gpt-5.5', @@ -147,7 +147,7 @@ function makeRichDetailResponse() { }, ], timeline: [ - { type: 'content', timestamp: '2026-06-12T14:52:10.000Z', content: '## Database overview\n\nThe `session-store.db` file is a SQLite database.' }, + { type: 'content', timestamp: '2026-06-12T14:52:10.000Z', content: '## Database overview\n\nThe `session-store.db` file is a SQLite database.\n\n\n' }, { type: 'tool-start', timestamp: '2026-06-12T14:52:11.000Z', toolCall: { id: 'tc-bash-1', toolName: 'bash', args: { command: 'sqlite3 session-store.db .tables' }, status: 'running' } }, { type: 'tool-complete', timestamp: '2026-06-12T14:52:12.000Z', toolCall: { id: 'tc-bash-1', toolName: 'bash', args: { command: 'sqlite3 session-store.db .tables' }, result: 'sessions turns forge_trajectory_events checkpoints', status: 'completed' } }, ], @@ -238,6 +238,13 @@ describe('NativeCopilotSessionsPanel — real ConversationTurnBubble integration expect(md).toContain('inspect the schema'); }); + it('escapes stored HTML and script payloads through the real bubble path', async () => { + const detail = await openDetail(); + expect(detail.textContent).toContain(''); + expect(detail.querySelector('script')).toBeNull(); + expect(detail.querySelector('img[onerror*="native"]')).toBeNull(); + }); + it('renders the user image gallery for the attached image', async () => { const detail = await openDetail(); expect(detail.querySelector('[data-testid="image-gallery"]')).toBeTruthy(); From 8d6507d2df263e38f10332b59c73d33a1ef87412 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 21:46:20 +0000 Subject: [PATCH 34/37] Move CLI Sessions sub-tab between Activity and Git Reorder the repo sub-tab strip so CLI Sessions sits immediately after the Activity/Chats tab and before Git in both the classic and dev-workflow layouts. CLI Sessions also moves into the Activity/Git/Terminal divider group so it renders without divider-flanked isolation. Update RepoDetail SUB_TABS unit tests to assert the new ordering and group placement, and add a regression block covering CLI Sessions placement in SUB_TABS, VISIBLE_SUB_TABS, the divider group, and the dev-workflow order. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../react/features/repo-detail/RepoDetail.tsx | 8 +-- .../coc/test/spa/react/RepoDetail.test.ts | 61 ++++++++++++++++--- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx index 366fe14ed..e730fdde0 100644 --- a/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx +++ b/packages/coc/src/server/spa/client/react/features/repo-detail/RepoDetail.tsx @@ -61,11 +61,11 @@ interface RepoDetailProps { export const SUB_TABS: { key: RepoSubTab; label: string; shortcut?: string }[] = [ { key: 'chats', label: 'Chats', shortcut: 'Alt+A' }, + { key: 'cli-sessions', label: 'CLI Sessions' }, { key: 'git', label: 'Git', shortcut: 'Alt+G' }, { key: 'terminal', label: 'Terminal' }, { key: 'work-items', label: 'Work Items', shortcut: 'Alt+I' }, { key: 'dreams', label: 'Dreams', shortcut: 'Alt+D' }, - { key: 'cli-sessions', label: 'CLI Sessions' }, { key: 'pull-requests', label: 'Pull Requests', shortcut: 'Alt+R' }, { key: 'explorer', label: 'Explorer', shortcut: 'Alt+E' }, { key: 'workflows', label: 'Workflows', shortcut: 'Alt+W' }, @@ -87,8 +87,8 @@ export const VISIBLE_SUB_TABS = SHOW_WIKI_TAB * Group identity is purely visual and does not affect functionality. */ const TAB_GROUP_INDEX: Record = { - 'chats': 1, 'activity': 1, 'git': 1, 'terminal': 1, - 'work-items': 2, 'dreams': 2, 'cli-sessions': 2, 'copilot-sessions': 2, 'pull-requests': 2, 'tasks': 2, + 'chats': 1, 'activity': 1, 'cli-sessions': 1, 'copilot-sessions': 1, 'git': 1, 'terminal': 1, + 'work-items': 2, 'dreams': 2, 'pull-requests': 2, 'tasks': 2, 'explorer': 3, 'workflows': 3, 'schedules': 3, 'notes': 4, 'settings': 4, 'wiki': 4, }; @@ -190,7 +190,7 @@ export function RepoDetail({ repo, repos, onRefresh }: RepoDetailProps) { 'pull-requests': 'Full Requests', }; const devWorkflowOrder: RepoSubTab[] = [ - 'chats', 'work-items', 'dreams', 'cli-sessions', 'schedules', 'explorer', + 'chats', 'cli-sessions', 'work-items', 'dreams', 'schedules', 'explorer', 'workflows', 'git', 'terminal', 'pull-requests', 'tasks', 'settings', ]; const tabMap = new Map(tabs.map(t => [t.key, t])); diff --git a/packages/coc/test/spa/react/RepoDetail.test.ts b/packages/coc/test/spa/react/RepoDetail.test.ts index baf788d09..379fed846 100644 --- a/packages/coc/test/spa/react/RepoDetail.test.ts +++ b/packages/coc/test/spa/react/RepoDetail.test.ts @@ -24,14 +24,18 @@ describe('RepoDetail SUB_TABS', () => { expect(SUB_TABS.find(t => t.key === 'queue')).toBeUndefined(); }); - it('"chats" is followed by "git" entry', () => { + it('"chats" is followed by "cli-sessions" entry', () => { const chatsIdx = SUB_TABS.findIndex(t => t.key === 'chats'); - const gitIdx = SUB_TABS.findIndex(t => t.key === 'git'); - expect(gitIdx).toBe(chatsIdx + 1); + const cliSessionsIdx = SUB_TABS.findIndex(t => t.key === 'cli-sessions'); + expect(cliSessionsIdx).toBe(chatsIdx + 1); + }); + + it('"cli-sessions" is the second entry (between chats/activity and git)', () => { + expect(SUB_TABS[1].key).toBe('cli-sessions'); }); - it('"git" is the second entry', () => { - expect(SUB_TABS[1].key).toBe('git'); + it('"git" is the third entry, immediately after cli-sessions', () => { + expect(SUB_TABS[2].key).toBe('git'); }); it('has exactly 14 entries', () => { @@ -40,7 +44,7 @@ describe('RepoDetail SUB_TABS', () => { it('contains all expected sub-tabs in order', () => { const keys = SUB_TABS.map(t => t.key); - expect(keys).toEqual(['chats', 'git', 'terminal', 'work-items', 'dreams', 'cli-sessions', 'pull-requests', 'explorer', 'workflows', 'schedules', 'tasks', 'notes', 'settings', 'wiki']); + expect(keys).toEqual(['chats', 'cli-sessions', 'git', 'terminal', 'work-items', 'dreams', 'pull-requests', 'explorer', 'workflows', 'schedules', 'tasks', 'notes', 'settings', 'wiki']); }); it('includes "wiki" entry without a shortcut', () => { @@ -64,6 +68,47 @@ describe('RepoDetail SUB_TABS', () => { }); }); +describe('RepoDetail CLI Sessions placement (between Activity and Git)', () => { + it('cli-sessions sits immediately after chats and immediately before git in SUB_TABS', () => { + const chatsIdx = SUB_TABS.findIndex(t => t.key === 'chats'); + const cliSessionsIdx = SUB_TABS.findIndex(t => t.key === 'cli-sessions'); + const gitIdx = SUB_TABS.findIndex(t => t.key === 'git'); + expect(cliSessionsIdx).toBe(chatsIdx + 1); + expect(gitIdx).toBe(cliSessionsIdx + 1); + }); + + it('keeps the same placement in VISIBLE_SUB_TABS', () => { + const chatsIdx = VISIBLE_SUB_TABS.findIndex(t => t.key === 'chats'); + const cliSessionsIdx = VISIBLE_SUB_TABS.findIndex(t => t.key === 'cli-sessions'); + const gitIdx = VISIBLE_SUB_TABS.findIndex(t => t.key === 'git'); + expect(cliSessionsIdx).toBe(chatsIdx + 1); + expect(gitIdx).toBe(cliSessionsIdx + 1); + }); + + it('groups cli-sessions with the activity/git/terminal divider group (group 1)', () => { + // TAB_GROUP_INDEX is not exported; assert via source so cli-sessions does + // not render as a divider-flanked island between Activity and Git. + expect(REPO_DETAIL_SOURCE).toContain( + "'chats': 1, 'activity': 1, 'cli-sessions': 1, 'copilot-sessions': 1, 'git': 1, 'terminal': 1,", + ); + // cli-sessions / copilot-sessions must no longer be in the work-items group. + const workItemsGroupLine = REPO_DETAIL_SOURCE + .split('\n') + .find(l => l.includes("'work-items': 2")); + expect(workItemsGroupLine).toBeDefined(); + expect(workItemsGroupLine).not.toContain("'cli-sessions'"); + expect(workItemsGroupLine).not.toContain("'copilot-sessions'"); + }); + + it('dev-workflow order places cli-sessions immediately after chats', () => { + const devOrderMatch = REPO_DETAIL_SOURCE.match(/devWorkflowOrder.*?=\s*\[([\s\S]*?)\]/); + expect(devOrderMatch).toBeTruthy(); + const keys = devOrderMatch![1].match(/'([^']+)'/g)!.map(k => k.replace(/'/g, '')); + expect(keys[0]).toBe('chats'); + expect(keys[1]).toBe('cli-sessions'); + }); +}); + describe('RepoDetail VISIBLE_SUB_TABS', () => { it('excludes wiki when SHOW_WIKI_TAB is false', () => { expect(VISIBLE_SUB_TABS.find(t => t.key === 'wiki')).toBeUndefined(); @@ -75,7 +120,7 @@ describe('RepoDetail VISIBLE_SUB_TABS', () => { it('contains all non-wiki tabs in order', () => { const keys = VISIBLE_SUB_TABS.map(t => t.key); - expect(keys).toEqual(['chats', 'git', 'terminal', 'work-items', 'dreams', 'cli-sessions', 'pull-requests', 'explorer', 'workflows', 'schedules', 'tasks', 'notes', 'settings']); + expect(keys).toEqual(['chats', 'cli-sessions', 'git', 'terminal', 'work-items', 'dreams', 'pull-requests', 'explorer', 'workflows', 'schedules', 'tasks', 'notes', 'settings']); }); it('renders visibleSubTabs.map in the tab strip', () => { @@ -735,7 +780,7 @@ describe('RepoDetail dev-workflow tab relabeling and reorder', () => { it('dev-workflow branch defines the correct tab order', () => { expect(REPO_DETAIL_SOURCE).toContain( - "'chats', 'work-items', 'dreams', 'cli-sessions', 'schedules', 'explorer',", + "'chats', 'cli-sessions', 'work-items', 'dreams', 'schedules', 'explorer',", ); expect(REPO_DETAIL_SOURCE).toContain( "'workflows', 'git', 'terminal', 'pull-requests', 'tasks', 'settings',", From 85b8d0246d9824f076665a4bf2edca469509b4ba Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 23:24:19 +0000 Subject: [PATCH 35/37] Capture Codex sub-agent tool calls --- .../coc-knowledge/references/sdk-wrapper.md | 4 +- .../coc-agent-sdk/src/codex-sdk-service.ts | 374 ++++++++++++++++-- .../test/ai/codex-sdk-collab-tools.test.ts | 241 +++++++++++ 3 files changed, 590 insertions(+), 29 deletions(-) create mode 100644 packages/coc-agent-sdk/test/ai/codex-sdk-collab-tools.test.ts diff --git a/.github/skills/coc-knowledge/references/sdk-wrapper.md b/.github/skills/coc-knowledge/references/sdk-wrapper.md index 5a2ebceb1..b731ac566 100644 --- a/.github/skills/coc-knowledge/references/sdk-wrapper.md +++ b/.github/skills/coc-knowledge/references/sdk-wrapper.md @@ -127,7 +127,7 @@ Codex image attachments are passed at the provider boundary as `@openai/codex-sd Codex token usage is mapped from `turn.completed.usage` into the shared `TokenUsage` result shape when the SDK reports it. The adapter fills per-turn totals only: `inputTokens`, `outputTokens`, `cacheReadTokens` from `cached_input_tokens`, `cacheWriteTokens: 0`, `totalTokens`, and `turnCount`. Codex has no provider-native USD field; Forge estimates USD from the shared Copilot pricing table and populates native-first display cost fields for stats/read models. Session/context fields (`tokenLimit`, `currentTokens`, `systemTokens`, `toolDefinitionsTokens`, `conversationTokens`) remain absent because Codex does not expose an equivalent context-window usage source. -Codex `file_change` stream items are normalized to `apply_patch` tool calls. Because the Codex SDK reports only changed paths and change kinds, `CodexSDKService` snapshots dirty worktree files before the turn starts and enriches completed file-change parameters with a best-effort unified `diff` when a workspace git root is available. Clean files diff against `HEAD`, paths dirty before the turn diff against their pre-turn worktree snapshot, and later changes to the same path diff against the previous captured snapshot. Diff enrichment is display metadata only; failures fall back to the original `{ changes }` file list without failing the Codex turn. +Codex stream items are normalized into the shared `ToolCall` shape before they reach CoC process storage. `command_execution`/`commandExecution` becomes `shell`; `file_change`/`fileChange` becomes `apply_patch`; `mcp_tool_call`/`mcpToolCall` becomes the MCP tool name; `web_search`/`webSearch` becomes `web_search`; `dynamicToolCall` keeps the dynamic tool name unless it is an agent start/wait operation; and `collabAgentToolCall` maps Codex collaboration sub-agent operations onto the dashboard's existing `task` and `read_agent` vocabulary. The adapter handles `item.started`, `item.updated`, and `item.completed`; terminal updates complete a stored tool call even when the final state arrives before a distinct completion event. For file changes, `CodexSDKService` snapshots dirty worktree files before the turn starts and enriches completed file-change parameters with a best-effort unified `diff` when a workspace git root is available. Clean files diff against `HEAD`, paths dirty before the turn diff against their pre-turn worktree snapshot, and later changes to the same path diff against the previous captured snapshot. Diff enrichment is display metadata only; failures fall back to the original `{ changes }` file list without failing the Codex turn. **Thread ↔ session mapping:** Every CoC session ID maps to exactly one Codex thread. The mapping is created on the first `sendMessage()` call for a session and removed on abort or dispose. @@ -143,7 +143,7 @@ sdkServiceRegistry.register(SDK_PROVIDER_CODEX, svc); **Lazy loading:** No SDK module is loaded until the first `isAvailable()` or `sendMessage()` call. -**CoC LLM tools:** when `options.tools` is present, a per-request `Codex` client is built with `config.mcp_servers.coc_llm_tools` pointing at the stdio bridge (see *CoC LLM Tools over MCP*). Captured tool calls from this first-party MCP server store the actual tool input directly in `args` (for example `args.questions` for `ask_user`) so process timelines match the Copilot and Claude display contract; external MCP tool calls retain `{ server, arguments }` metadata. +**CoC LLM tools:** when `options.tools` is present, a per-request `Codex` client is built with `config.mcp_servers.coc_llm_tools` pointing at the stdio bridge (see *CoC LLM Tools over MCP*). Captured tool calls from this first-party MCP server store the actual tool input directly in `args` (for example `args.questions` for `ask_user`) so process timelines match the Copilot and Claude display contract; external MCP tool calls retain `{ server, arguments }` metadata. Codex sub-agent spawn calls store `task` args with `agent_type: "codex"`, `agent_id`/`agent_ids`, prompt/model metadata, and agent state; Codex wait calls store `read_agent` args with `agent_id`, `wait: true`, and the latest agent state so existing dashboard grouping and nesting logic applies. ## ClaudeSDKService Architecture diff --git a/packages/coc-agent-sdk/src/codex-sdk-service.ts b/packages/coc-agent-sdk/src/codex-sdk-service.ts index 0ebae5b65..738ecd0d4 100644 --- a/packages/coc-agent-sdk/src/codex-sdk-service.ts +++ b/packages/coc-agent-sdk/src/codex-sdk-service.ts @@ -160,19 +160,47 @@ interface CodexItemEvent { text?: string; command?: string; aggregated_output?: string; + aggregatedOutput?: string | null; exit_code?: number; + exitCode?: number | null; status?: string; changes?: Array<{ path?: string; kind?: string }>; server?: string; + namespace?: string | null; tool?: string; arguments?: unknown; result?: unknown; - error?: { message?: string }; + error?: { message?: string } | string | null; query?: string; + contentItems?: unknown[] | null; + content_items?: unknown[] | null; + success?: boolean | null; + senderThreadId?: string; + sender_thread_id?: string; + receiverThreadIds?: string[]; + receiver_thread_ids?: string[]; + receiverThreadId?: string; + receiver_thread_id?: string; + newThreadId?: string; + new_thread_id?: string; + prompt?: string | null; + model?: string | null; + reasoningEffort?: string | null; + reasoning_effort?: string | null; + agentsStates?: Record; + agents_states?: Record; }; } type CodexThreadEvent = CodexThreadStartedEvent | CodexTurnCompletedEvent | CodexTurnFailedEvent | CodexErrorEvent | CodexItemEvent; +type CodexToolPhase = 'started' | 'updated' | 'completed'; +interface NormalizedCodexToolItem { + id: string; + toolName: string; + parameters: Record; + result?: string; + error?: string; +} interface CodexFileChange { path?: string; @@ -847,6 +875,7 @@ export class CodexSDKService implements ISDKService { let sessionCreatedNotified = false; const toolCalls = new Map(); const startedToolCalls = new Set(); + const completedToolCalls = new Set(); const fileChangeDiffTracker = new CodexFileChangeDiffTracker(options.workingDirectory); // Releases the per-invocation CoC LLM-tool MCP bridge (no-op when no tools). let mcpCleanup: () => void = () => {}; @@ -899,16 +928,20 @@ export class CodexSDKService implements ISDKService { throw new Error(event.message ?? 'Codex stream error'); } if (event.type === 'item.started') { - await this.handleCodexToolItem(event.item, 'started', options, toolCalls, startedToolCalls, fileChangeDiffTracker); + await this.handleCodexToolItem(event.item, 'started', options, toolCalls, startedToolCalls, completedToolCalls, fileChangeDiffTracker); + continue; + } + if (event.type === 'item.updated') { + await this.handleCodexToolItem(event.item, 'updated', options, toolCalls, startedToolCalls, completedToolCalls, fileChangeDiffTracker); continue; } if (event.type === 'item.completed') { - if (event.item?.type === 'agent_message' && event.item.text) { + if ((event.item?.type === 'agent_message' || event.item?.type === 'agentMessage') && event.item.text) { chunks.push(event.item.text); options.onStreamingChunk?.(event.item.text); continue; } - await this.handleCodexToolItem(event.item, 'completed', options, toolCalls, startedToolCalls, fileChangeDiffTracker); + await this.handleCodexToolItem(event.item, 'completed', options, toolCalls, startedToolCalls, completedToolCalls, fileChangeDiffTracker); continue; } } @@ -938,17 +971,25 @@ export class CodexSDKService implements ISDKService { private async handleCodexToolItem( item: CodexItemEvent['item'] | undefined, - phase: 'started' | 'completed', + phase: CodexToolPhase, options: SendMessageOptions, toolCalls: Map, startedToolCalls: Set, + completedToolCalls: Set, fileChangeDiffTracker: CodexFileChangeDiffTracker, ): Promise { - const normalized = await this.normalizeCodexToolItem(item, phase, fileChangeDiffTracker); + const effectivePhase = phase === 'updated' && this.isTerminalCodexToolItem(item) ? 'completed' : phase; + const normalized = await this.normalizeCodexToolItem(item, effectivePhase, fileChangeDiffTracker); if (!normalized) return; - if (phase === 'started') { - if (startedToolCalls.has(normalized.id)) return; + if (effectivePhase !== 'completed') { + if (startedToolCalls.has(normalized.id)) { + const existing = toolCalls.get(normalized.id); + if (existing) { + existing.args = normalized.parameters; + } + return; + } startedToolCalls.add(normalized.id); const now = new Date(); toolCalls.set(normalized.id, { @@ -967,8 +1008,9 @@ export class CodexSDKService implements ISDKService { return; } + if (completedToolCalls.has(normalized.id)) return; if (!startedToolCalls.has(normalized.id)) { - await this.handleCodexToolItem(item, 'started', options, toolCalls, startedToolCalls, fileChangeDiffTracker); + await this.handleCodexToolItem(item, 'started', options, toolCalls, startedToolCalls, completedToolCalls, fileChangeDiffTracker); } const existing = toolCalls.get(normalized.id); @@ -983,6 +1025,7 @@ export class CodexSDKService implements ISDKService { existing.result = normalized.result; } } + completedToolCalls.add(normalized.id); this.emitToolEvent(options, normalized.error ? { @@ -1011,31 +1054,26 @@ export class CodexSDKService implements ISDKService { private async normalizeCodexToolItem( item: CodexItemEvent['item'] | undefined, - phase: 'started' | 'completed', + phase: CodexToolPhase, fileChangeDiffTracker: CodexFileChangeDiffTracker, - ): Promise<{ - id: string; - toolName: string; - parameters: Record; - result?: string; - error?: string; - } | undefined> { + ): Promise { if (!item?.type || !item.id) return undefined; - switch (item.type) { - case 'command_execution': { + switch (this.normalizeCodexItemType(item.type)) { + case 'commandexecution': { const command = typeof item.command === 'string' ? item.command : ''; - const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : ''; - const failed = item.status === 'failed'; + const output = this.getStringField(item, 'aggregated_output', 'aggregatedOutput') ?? ''; + const exitCode = this.getNumberField(item, 'exit_code', 'exitCode'); + const failed = this.isFailedStatus(item.status); return { id: item.id, toolName: 'shell', parameters: { command }, - ...(failed ? { error: output || `Command failed${typeof item.exit_code === 'number' ? ` with exit code ${item.exit_code}` : ''}` } : { result: output }), + ...(failed ? { error: output || `Command failed${typeof exitCode === 'number' ? ` with exit code ${exitCode}` : ''}` } : { result: output }), }; } - case 'file_change': { + case 'filechange': { const changes = Array.isArray(item.changes) ? item.changes : []; - const failed = item.status === 'failed'; + const failed = this.isFailedStatus(item.status); const parameters = phase === 'completed' && !failed ? await fileChangeDiffTracker.enrichParameters(changes) : { changes }; @@ -1046,10 +1084,10 @@ export class CodexSDKService implements ISDKService { ...(failed ? { error: 'File change failed' } : { result: this.summarizeFileChanges(changes) }), }; } - case 'mcp_tool_call': { + case 'mcptoolcall': { const tool = typeof item.tool === 'string' && item.tool ? item.tool : 'mcp_tool'; const server = typeof item.server === 'string' ? item.server : undefined; - const error = item.error?.message; + const error = this.getCodexErrorMessage(item.error) ?? (this.isFailedStatus(item.status) ? `${tool} failed` : undefined); const toolArguments = (item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments)) ? item.arguments as Record : {}; @@ -1066,7 +1104,7 @@ export class CodexSDKService implements ISDKService { ...(error ? { error } : { result: this.stringifyCodexResult(item.result) }), }; } - case 'web_search': { + case 'websearch': { const query = typeof item.query === 'string' ? item.query : ''; return { id: item.id, @@ -1075,11 +1113,293 @@ export class CodexSDKService implements ISDKService { result: query ? `Searched: ${query}` : 'Search completed', }; } + case 'dynamictoolcall': + return this.normalizeCodexDynamicToolCall(item); + case 'collabagenttoolcall': + case 'collabtoolcall': + return this.normalizeCodexCollabAgentToolCall(item); default: return undefined; } } + private normalizeCodexDynamicToolCall(item: NonNullable): NormalizedCodexToolItem | undefined { + if (!item.id) return undefined; + const rawToolName = this.getStringField(item, 'tool') ?? 'dynamic_tool'; + const toolKey = this.normalizeCodexItemType(rawToolName); + const namespace = this.getStringField(item, 'namespace'); + const toolArguments = this.normalizeCodexArguments(item.arguments); + const failed = this.isFailedStatus(item.status) || item.success === false; + const output = this.stringifyDynamicContentItems(item.contentItems ?? item.content_items) ?? this.stringifyCodexResult(item.result); + const error = this.getCodexErrorMessage(item.error) ?? (failed ? output ?? `${rawToolName} failed` : undefined); + + if (toolKey === 'spawnagent' || toolKey === 'task') { + const parameters = this.normalizeAgentParameters(toolArguments); + return { + id: item.id, + toolName: 'task', + parameters: { + ...parameters, + agent_type: typeof parameters.agent_type === 'string' ? parameters.agent_type : 'codex', + }, + ...(error ? { error } : { result: output ?? this.summarizeDynamicAgentResult('spawnAgent', parameters) }), + }; + } + + if (toolKey === 'waitagent' || toolKey === 'readagent' || toolKey === 'wait') { + const parameters = this.normalizeAgentParameters(toolArguments); + return { + id: item.id, + toolName: 'read_agent', + parameters: { + ...parameters, + wait: parameters.wait ?? true, + }, + ...(error ? { error } : { result: output ?? this.summarizeDynamicAgentResult('wait', parameters) }), + }; + } + + const parameters = namespace + ? { namespace, arguments: toolArguments } + : toolArguments; + return { + id: item.id, + toolName: rawToolName, + parameters, + ...(error ? { error } : { result: output }), + }; + } + + private normalizeCodexCollabAgentToolCall(item: NonNullable): NormalizedCodexToolItem | undefined { + if (!item.id) return undefined; + const tool = this.getStringField(item, 'tool') ?? 'collabAgentToolCall'; + const toolKey = this.normalizeCodexItemType(tool); + const receiverThreadIds = this.getReceiverThreadIds(item); + const agentStates = this.getAgentStates(item); + const prompt = this.getStringField(item, 'prompt'); + const baseParameters = this.buildCollabAgentParameters(item, receiverThreadIds, agentStates); + const failed = this.isFailedStatus(item.status); + const result = this.summarizeCollabAgentResult(tool, receiverThreadIds, agentStates); + const error = this.getCodexErrorMessage(item.error) ?? (failed ? result ?? `${tool} failed` : undefined); + + if (toolKey === 'spawnagent') { + return { + id: item.id, + toolName: 'task', + parameters: { + agent_type: 'codex', + ...(prompt ? { description: prompt, prompt } : {}), + ...baseParameters, + }, + ...(error ? { error } : { result: result ?? this.summarizeCollabAgentSpawn(receiverThreadIds) }), + }; + } + + if (toolKey === 'wait') { + return { + id: item.id, + toolName: 'read_agent', + parameters: { + ...baseParameters, + wait: true, + }, + ...(error ? { error } : { result: result ?? 'Agent wait completed' }), + }; + } + + return { + id: item.id, + toolName: `codex_${this.toSnakeCase(tool)}`, + parameters: { + operation: tool, + ...(prompt ? { prompt } : {}), + ...baseParameters, + }, + ...(error ? { error } : { result: result ?? `${tool} completed` }), + }; + } + + private isTerminalCodexToolItem(item: CodexItemEvent['item'] | undefined): boolean { + if (!item) return false; + if (this.isCompletedStatus(item.status) || this.isFailedStatus(item.status)) return true; + return typeof item.success === 'boolean'; + } + + private normalizeCodexItemType(type: string): string { + return type.replace(/[_-]/g, '').toLowerCase(); + } + + private isCompletedStatus(status: unknown): boolean { + return typeof status === 'string' && status.toLowerCase() === 'completed'; + } + + private isFailedStatus(status: unknown): boolean { + if (typeof status !== 'string') return false; + const normalized = status.toLowerCase(); + return normalized === 'failed' || normalized === 'errored'; + } + + private getStringField(value: unknown, ...keys: string[]): string | undefined { + if (!this.isRecord(value)) return undefined; + for (const key of keys) { + const field = value[key]; + if (typeof field === 'string' && field.length > 0) return field; + } + return undefined; + } + + private getNumberField(value: unknown, ...keys: string[]): number | undefined { + if (!this.isRecord(value)) return undefined; + for (const key of keys) { + const field = value[key]; + if (typeof field === 'number') return field; + } + return undefined; + } + + private normalizeCodexArguments(value: unknown): Record { + if (this.isRecord(value)) return { ...value }; + return value === undefined ? {} : { arguments: value }; + } + + private normalizeAgentParameters(parameters: Record): Record { + const normalized = { ...parameters }; + if (typeof normalized.agent_id !== 'string') { + for (const key of ['agentId', 'threadId', 'thread_id', 'receiverThreadId', 'receiver_thread_id']) { + if (typeof normalized[key] === 'string') { + normalized.agent_id = normalized[key]; + break; + } + } + } + return normalized; + } + + private getCodexErrorMessage(error: unknown): string | undefined { + if (typeof error === 'string' && error.length > 0) return error; + if (this.isRecord(error) && typeof error.message === 'string' && error.message.length > 0) { + return error.message; + } + return undefined; + } + + private getReceiverThreadIds(item: NonNullable): string[] { + const ids = this.getStringArrayField(item, 'receiverThreadIds', 'receiver_thread_ids'); + for (const key of ['receiverThreadId', 'receiver_thread_id', 'newThreadId', 'new_thread_id']) { + const id = this.getStringField(item, key); + if (id && !ids.includes(id)) ids.push(id); + } + return ids; + } + + private getStringArrayField(value: unknown, ...keys: string[]): string[] { + if (!this.isRecord(value)) return []; + for (const key of keys) { + const field = value[key]; + if (Array.isArray(field)) { + return field.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0); + } + } + return []; + } + + private getAgentStates(item: NonNullable): Record { + const raw = this.isRecord(item.agentsStates) ? item.agentsStates : item.agents_states; + if (!this.isRecord(raw)) return {}; + const states: Record = {}; + for (const [id, state] of Object.entries(raw)) { + if (!this.isRecord(state)) continue; + states[id] = { + ...(typeof state.status === 'string' ? { status: state.status } : {}), + ...(typeof state.message === 'string' || state.message === null ? { message: state.message } : {}), + }; + } + return states; + } + + private buildCollabAgentParameters( + item: NonNullable, + receiverThreadIds: string[], + agentStates: Record, + ): Record { + const firstAgentId = receiverThreadIds[0]; + const firstAgentState = firstAgentId ? agentStates[firstAgentId] : undefined; + const senderThreadId = this.getStringField(item, 'senderThreadId', 'sender_thread_id'); + const model = this.getStringField(item, 'model'); + const reasoningEffort = this.getStringField(item, 'reasoningEffort', 'reasoning_effort'); + return { + ...(firstAgentId ? { agent_id: firstAgentId } : {}), + ...(receiverThreadIds.length > 0 ? { agent_ids: receiverThreadIds } : {}), + ...(senderThreadId ? { sender_thread_id: senderThreadId } : {}), + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), + ...(Object.keys(agentStates).length > 0 ? { agents_states: agentStates } : {}), + ...(firstAgentState?.status ? { agent_status: firstAgentState.status } : {}), + ...(firstAgentState?.message ? { agent_message: firstAgentState.message } : {}), + }; + } + + private summarizeCollabAgentSpawn(receiverThreadIds: string[]): string { + return receiverThreadIds.length > 0 + ? `Agent started with agent_id: ${receiverThreadIds.join(', ')}` + : 'Agent started'; + } + + private summarizeCollabAgentResult( + tool: string, + receiverThreadIds: string[], + agentStates: Record, + ): string | undefined { + const stateSummary = this.summarizeAgentStates(receiverThreadIds, agentStates); + if (stateSummary) return stateSummary; + if (this.normalizeCodexItemType(tool) === 'spawnagent') return this.summarizeCollabAgentSpawn(receiverThreadIds); + if (receiverThreadIds.length > 0) return `${tool} completed for ${receiverThreadIds.join(', ')}`; + return undefined; + } + + private summarizeAgentStates( + receiverThreadIds: string[], + agentStates: Record, + ): string | undefined { + const ids = receiverThreadIds.length > 0 ? receiverThreadIds : Object.keys(agentStates); + const lines = ids + .map(id => { + const state = agentStates[id]; + if (!state) return undefined; + const status = state.status ?? 'unknown'; + return `${id} ${status}${state.message ? `: ${state.message}` : ''}`; + }) + .filter((line): line is string => !!line); + return lines.length > 0 ? lines.join('\n') : undefined; + } + + private summarizeDynamicAgentResult(tool: 'spawnAgent' | 'wait', parameters: Record): string { + const agentId = typeof parameters.agent_id === 'string' ? parameters.agent_id : undefined; + if (tool === 'spawnAgent') { + return agentId ? `Agent started with agent_id: ${agentId}` : 'Agent started'; + } + return agentId ? `Agent ${agentId} completed` : 'Agent wait completed'; + } + + private stringifyDynamicContentItems(contentItems: unknown): string | undefined { + if (!Array.isArray(contentItems)) return undefined; + const parts = contentItems + .map(item => this.isRecord(item) && typeof item.text === 'string' ? item.text : undefined) + .filter((text): text is string => !!text); + return parts.length > 0 ? parts.join('\n') : undefined; + } + + private toSnakeCase(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[-\s]+/g, '_') + .toLowerCase(); + } + + private isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); + } + private summarizeFileChanges(changes: Array<{ path?: string; kind?: string }>): string { if (changes.length === 0) return 'File changes applied'; const byKind = new Map(); diff --git a/packages/coc-agent-sdk/test/ai/codex-sdk-collab-tools.test.ts b/packages/coc-agent-sdk/test/ai/codex-sdk-collab-tools.test.ts new file mode 100644 index 000000000..ef644acb1 --- /dev/null +++ b/packages/coc-agent-sdk/test/ai/codex-sdk-collab-tools.test.ts @@ -0,0 +1,241 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CodexSDKService } from '../../src/codex-sdk-service'; + +type CodexItemTestEvent = { + type: 'item.started' | 'item.updated' | 'item.completed'; + item: Record; +}; + +function makeThread(threadId = 'thread-1', itemEvents: CodexItemTestEvent[] = []) { + return { + id: threadId, + runStreamed: vi.fn(async () => ({ + events: (async function* () { + yield { type: 'thread.started' as const, thread_id: threadId }; + for (const event of itemEvents) { + yield event; + } + yield { type: 'item.completed' as const, item: { id: 'msg-1', type: 'agent_message', text: 'ok' } }; + })(), + })), + }; +} + +async function sendWithEvents(itemEvents: CodexItemTestEvent[], onToolEvent?: (event: any) => void) { + const svc = new CodexSDKService(); + const client = { + startThread: vi.fn(() => makeThread('thread-collab', itemEvents)), + resumeThread: vi.fn(), + }; + (svc as unknown as { sdk: unknown }).sdk = client; + (svc as unknown as { availabilityCache: unknown }).availabilityCache = { available: true }; + + try { + return await svc.sendMessage({ prompt: 'run sub agents', onToolEvent }); + } finally { + svc.dispose(); + } +} + +describe('CodexSDKService collaboration tool capture', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('maps Codex collabAgentToolCall spawnAgent and wait into task/read_agent calls', async () => { + const toolEvents: any[] = []; + const result = await sendWithEvents([ + { + type: 'item.started', + item: { + id: 'collab-spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + status: 'inProgress', + senderThreadId: 'parent-thread', + receiverThreadIds: ['agent-0'], + prompt: 'Report the current time', + model: 'gpt-5.4-codex', + reasoningEffort: 'medium', + agentsStates: { 'agent-0': { status: 'pendingInit', message: null } }, + }, + }, + { + type: 'item.completed', + item: { + id: 'collab-spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + status: 'completed', + senderThreadId: 'parent-thread', + receiverThreadIds: ['agent-0'], + prompt: 'Report the current time', + model: 'gpt-5.4-codex', + reasoningEffort: 'medium', + agentsStates: { 'agent-0': { status: 'running', message: 'Checking clock' } }, + }, + }, + { + type: 'item.started', + item: { + id: 'collab-wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + status: 'inProgress', + senderThreadId: 'parent-thread', + receiverThreadIds: ['agent-0'], + agentsStates: { 'agent-0': { status: 'running', message: 'Checking clock' } }, + }, + }, + { + type: 'item.updated', + item: { + id: 'collab-wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + status: 'completed', + senderThreadId: 'parent-thread', + receiverThreadIds: ['agent-0'], + agentsStates: { 'agent-0': { status: 'completed', message: 'It is 23:15 UTC' } }, + }, + }, + ], event => toolEvents.push(event)); + + expect(result.success, JSON.stringify(result)).toBe(true); + expect(result.response).toBe('ok'); + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'collab-spawn-1', + name: 'task', + status: 'completed', + args: { + agent_type: 'codex', + agent_id: 'agent-0', + agent_ids: ['agent-0'], + description: 'Report the current time', + prompt: 'Report the current time', + sender_thread_id: 'parent-thread', + model: 'gpt-5.4-codex', + reasoning_effort: 'medium', + agent_status: 'running', + agent_message: 'Checking clock', + }, + result: 'agent-0 running: Checking clock', + }); + expect(result.toolCalls?.[1]).toMatchObject({ + id: 'collab-wait-1', + name: 'read_agent', + status: 'completed', + args: { + agent_id: 'agent-0', + agent_ids: ['agent-0'], + sender_thread_id: 'parent-thread', + wait: true, + agent_status: 'completed', + agent_message: 'It is 23:15 UTC', + }, + result: 'agent-0 completed: It is 23:15 UTC', + }); + expect(toolEvents.map(event => `${event.type}:${event.toolName}`)).toEqual([ + 'tool-start:task', + 'tool-complete:task', + 'tool-start:read_agent', + 'tool-complete:read_agent', + ]); + }); + + it('maps dynamic spawn_agent and wait_agent host tools into task/read_agent calls', async () => { + const result = await sendWithEvents([ + { + type: 'item.completed', + item: { + id: 'dynamic-spawn-1', + type: 'dynamicToolCall', + tool: 'spawn_agent', + status: 'completed', + arguments: { + agentId: 'agent-d', + prompt: 'Summarize the file', + description: 'Summarize the file', + }, + contentItems: [{ type: 'inputText', text: 'Agent started with agent_id: agent-d' }], + success: true, + }, + }, + { + type: 'item.completed', + item: { + id: 'dynamic-wait-1', + type: 'dynamicToolCall', + tool: 'wait_agent', + status: 'completed', + arguments: { + agentId: 'agent-d', + timeout: 30, + }, + contentItems: [{ type: 'inputText', text: 'agent-d completed: done' }], + success: true, + }, + }, + ]); + + expect(result.success, JSON.stringify(result)).toBe(true); + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'dynamic-spawn-1', + name: 'task', + status: 'completed', + args: { + agent_type: 'codex', + agentId: 'agent-d', + agent_id: 'agent-d', + prompt: 'Summarize the file', + description: 'Summarize the file', + }, + result: 'Agent started with agent_id: agent-d', + }); + expect(result.toolCalls?.[1]).toMatchObject({ + id: 'dynamic-wait-1', + name: 'read_agent', + status: 'completed', + args: { + agentId: 'agent-d', + agent_id: 'agent-d', + timeout: 30, + wait: true, + }, + result: 'agent-d completed: done', + }); + }); + + it('keeps non-agent dynamic tool calls visible with namespace metadata and failures', async () => { + const result = await sendWithEvents([ + { + type: 'item.completed', + item: { + id: 'dynamic-generic-1', + type: 'dynamicToolCall', + namespace: 'host', + tool: 'lookup', + status: 'failed', + arguments: { query: 'open issues' }, + contentItems: [{ type: 'inputText', text: 'lookup failed' }], + success: false, + }, + }, + ]); + + expect(result.success, JSON.stringify(result)).toBe(true); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'dynamic-generic-1', + name: 'lookup', + status: 'failed', + args: { + namespace: 'host', + arguments: { query: 'open issues' }, + }, + error: 'lookup failed', + }); + }); +}); From 81906b089cbab8253d4cc54566cfc43f60ce781b Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sat, 13 Jun 2026 23:42:58 +0000 Subject: [PATCH 36/37] Capture Claude sub-agent tool calls --- .../coc-knowledge/references/sdk-wrapper.md | 2 +- .../coc-agent-sdk/src/claude-sdk-service.ts | 150 ++++++++++- .../test/ai/claude-sdk-service.test.ts | 246 ++++++++++++++++++ 3 files changed, 391 insertions(+), 7 deletions(-) diff --git a/.github/skills/coc-knowledge/references/sdk-wrapper.md b/.github/skills/coc-knowledge/references/sdk-wrapper.md index b731ac566..b53a96aaf 100644 --- a/.github/skills/coc-knowledge/references/sdk-wrapper.md +++ b/.github/skills/coc-knowledge/references/sdk-wrapper.md @@ -192,7 +192,7 @@ Claude image attachments are converted at the provider boundary. When `SendMessa `ClaudeSDKService` wires CoC LLM tools and any caller-provided `mcpServers` into `query({ options: { mcpServers } })`; CoC tools ride a stdio bridge entry (`coc_llm_tools`, `alwaysLoad: true`), are pre-approved via `options.allowedTools` (`mcp__coc_llm_tools__`) so Claude Code never prompts for them, and bridged `tool_use` names are de-namespaced (see *CoC LLM Tools over MCP*). -Claude tool-call capture treats assistant `tool_use` blocks as start events and user `tool_result` / `tool_use_result` payloads as terminal events. Stored tool calls keep the original input parameters in `args` and preserve the actual tool output in `result` or `error`; the adapter does not synthesize completion results from tool input JSON. +Claude tool-call capture treats assistant `tool_use` blocks as start events and user `tool_result` / `tool_use_result` payloads as terminal events. Stored tool calls keep the original input parameters in `args` and preserve the actual tool output in `result` or `error`; the adapter does not synthesize completion results from tool input JSON. Built-in Claude sub-agent starts (`Agent`/`Task`) are normalized to CoC's `task` tool shape, with `subagent_type` copied to `agent_type` and terminal agent metadata (`agentId`, `agentType`, status, output file, prompt/description) merged back into `args` when Claude reports it. Claude background-agent waits (`TaskOutput`) are normalized to `read_agent`, including `agent_id`, `wait`, and timeout metadata. Assistant messages emitted from inside a Claude sub-agent preserve `parent_tool_use_id` as `parentToolCallId`, so nested child tools render under the parent task in process timelines. ## RequestRunner — sendMessage() Flow (Copilot) diff --git a/packages/coc-agent-sdk/src/claude-sdk-service.ts b/packages/coc-agent-sdk/src/claude-sdk-service.ts index 5caa439fa..c5e9a4262 100644 --- a/packages/coc-agent-sdk/src/claude-sdk-service.ts +++ b/packages/coc-agent-sdk/src/claude-sdk-service.ts @@ -91,6 +91,7 @@ interface ClaudeAssistantMessage { message: { content: ClaudeContentBlock[]; }; + parent_tool_use_id?: string | null; session_id?: string; } @@ -161,6 +162,8 @@ interface ClaudeUserMessage { parent_tool_use_id?: string | null; tool_use_result?: unknown; session_id?: string; + subagent_type?: string; + task_description?: string; } interface ClaudeStreamingUserMessage { @@ -693,7 +696,7 @@ export class ClaudeSDKService implements ISDKService { chunks.push(block.text); options.onStreamingChunk?.(block.text); } else if (this.isClaudeToolUseBlock(block)) { - this.handleClaudeToolUse(block, options, toolCalls, startedToolCalls); + this.handleClaudeToolUse(block, options, toolCalls, startedToolCalls, msg.parent_tool_use_id ?? undefined); } } } else if (this.isUserMessage(msg)) { @@ -961,12 +964,11 @@ export class ClaudeSDKService implements ISDKService { options: SendMessageOptions, toolCalls: Map, startedToolCalls: Set, + parentToolCallId?: string, ): void { const id = block.id ?? crypto.randomUUID(); - const toolName = normalizeBridgedToolName(block.name ?? 'unknown_tool'); - const parameters = (typeof block.input === 'object' && block.input !== null) - ? (block.input as Record) - : {}; + const toolName = normalizeClaudeToolName(block.name ?? 'unknown_tool'); + const parameters = this.normalizeClaudeToolInput(toolName, block.input); if (!startedToolCalls.has(id)) { startedToolCalls.add(id); @@ -977,12 +979,14 @@ export class ClaudeSDKService implements ISDKService { status: 'running', startTime: now, args: parameters, + ...(parentToolCallId ? { parentToolCallId } : {}), }); this.emitToolEvent(options, { type: 'tool-start', toolCallId: id, toolName, parameters, + ...(parentToolCallId ? { parentToolCallId } : {}), }); } } @@ -1026,12 +1030,17 @@ export class ClaudeSDKService implements ISDKService { ): void { const existing = toolCalls.get(toolCallId); const toolName = existing?.name ?? 'unknown_tool'; + const parentToolCallId = existing?.parentToolCallId; const result = this.stringifyClaudeToolResult(content); + const resultMetadata = this.extractClaudeToolResultMetadata(toolName, content, result); const now = new Date(); if (existing) { existing.status = isError ? 'failed' : 'completed'; existing.endTime = now; + if (resultMetadata.parameters) { + existing.args = { ...existing.args, ...resultMetadata.parameters }; + } if (isError) { existing.error = result || 'Claude tool failed'; } else { @@ -1044,26 +1053,132 @@ export class ClaudeSDKService implements ISDKService { status: isError ? 'failed' : 'completed', startTime: now, endTime: now, - args: {}, + args: resultMetadata.parameters ?? {}, ...(isError ? { error: result || 'Claude tool failed' } : { result }), }); } + const completedParameters = toolCalls.get(toolCallId)?.args; this.emitToolEvent(options, isError ? { type: 'tool-failed', toolCallId, toolName, + ...(parentToolCallId ? { parentToolCallId } : {}), error: result || 'Claude tool failed', } : { type: 'tool-complete', toolCallId, toolName, + ...(parentToolCallId ? { parentToolCallId } : {}), + ...(completedParameters ? { parameters: completedParameters } : {}), result, }); } + private normalizeClaudeToolInput(toolName: string, input: unknown): Record { + const parameters = (typeof input === 'object' && input !== null && !Array.isArray(input)) + ? { ...(input as Record) } + : {}; + + if (toolName === 'task') { + const agentType = typeof parameters.subagent_type === 'string' + ? parameters.subagent_type + : typeof parameters.agent_type === 'string' + ? parameters.agent_type + : 'claude'; + return { + ...parameters, + agent_type: agentType, + }; + } + + if (toolName === 'read_agent') { + if (typeof parameters.agent_id !== 'string' && typeof parameters.task_id === 'string') { + parameters.agent_id = parameters.task_id; + } + if (typeof parameters.block === 'boolean' && parameters.wait == null) { + parameters.wait = parameters.block; + } + if (typeof parameters.timeout === 'number') { + parameters.timeout_ms = parameters.timeout; + parameters.timeout = Math.ceil(parameters.timeout / 1000); + } + } + + return parameters; + } + + private extractClaudeToolResultMetadata( + toolName: string, + content: unknown, + result: string, + ): { parameters?: Record } { + if (toolName === 'task') { + const parameters: Record = {}; + if (typeof content === 'object' && content !== null && !Array.isArray(content)) { + const record = content as Record; + const agentId = typeof record.agentId === 'string' ? record.agentId : undefined; + const agentType = typeof record.agentType === 'string' ? record.agentType : undefined; + if (agentId) { + parameters.agent_id = agentId; + parameters.agent_ids = [agentId]; + } + if (agentType) { + parameters.agent_type = agentType; + } + if (typeof record.status === 'string') { + parameters.agent_status = record.status; + } + if (typeof record.outputFile === 'string') { + parameters.output_file = record.outputFile; + } + if (typeof record.description === 'string') { + parameters.description = record.description; + } + if (typeof record.prompt === 'string') { + parameters.prompt = record.prompt; + } + } + + const textAgentId = this.extractClaudeAgentId(result); + if (textAgentId && typeof parameters.agent_id !== 'string') { + parameters.agent_id = textAgentId; + parameters.agent_ids = [textAgentId]; + } + + return Object.keys(parameters).length > 0 ? { parameters } : {}; + } + + if (toolName === 'read_agent') { + const parameters: Record = {}; + if (typeof content === 'object' && content !== null && !Array.isArray(content)) { + const record = content as Record; + const agentId = typeof record.agentId === 'string' + ? record.agentId + : typeof record.task_id === 'string' + ? record.task_id + : undefined; + if (agentId) parameters.agent_id = agentId; + if (typeof record.status === 'string') parameters.agent_status = record.status; + } + const textAgentId = this.extractClaudeAgentId(result); + if (textAgentId && typeof parameters.agent_id !== 'string') { + parameters.agent_id = textAgentId; + } + return Object.keys(parameters).length > 0 ? { parameters } : {}; + } + + return {}; + } + + private extractClaudeAgentId(text: string): string | undefined { + const match = /\bagentId:\s*([A-Za-z0-9._:-]+)/.exec(text) + ?? /\bagent_id:\s*([A-Za-z0-9._:-]+)/.exec(text); + return match?.[1]; + } + private stringifyClaudeToolResult(content: unknown): string { if (content == null) return ''; if (typeof content === 'string') return content; @@ -1076,6 +1191,16 @@ export class ClaudeSDKService implements ISDKService { if (typeof content === 'object') { const record = content as Record; if (record.type === 'text' && typeof record.text === 'string') return record.text; + if (Array.isArray(record.content)) { + const parts = record.content + .map(item => this.stringifyClaudeToolResult(item)) + .filter(text => text.length > 0); + if (parts.length > 0) return parts.join('\n'); + } + if (record.status === 'async_launched' && typeof record.agentId === 'string') { + const description = typeof record.description === 'string' ? `: ${record.description}` : ''; + return `Agent started with agent_id: ${record.agentId}${description}`; + } const stdout = typeof record.stdout === 'string' ? record.stdout : ''; const stderr = typeof record.stderr === 'string' ? record.stderr : ''; if (stdout || stderr) return [stdout, stderr].filter(Boolean).join('\n'); @@ -1306,6 +1431,19 @@ function normalizeBridgedToolName(name: string): string { return name.startsWith(prefix) ? name.slice(prefix.length) : name; } +function normalizeClaudeToolName(name: string): string { + const normalized = normalizeBridgedToolName(name); + switch (normalized) { + case 'Agent': + case 'Task': + return 'task'; + case 'TaskOutput': + return 'read_agent'; + default: + return normalized; + } +} + /** * Normalize a forge `MCPServerConfig` into the Claude Code `mcpServers` shape. * Returns `undefined` for configs missing the fields Claude requires. diff --git a/packages/coc-agent-sdk/test/ai/claude-sdk-service.test.ts b/packages/coc-agent-sdk/test/ai/claude-sdk-service.test.ts index 6d1e8827f..9d76b61dc 100644 --- a/packages/coc-agent-sdk/test/ai/claude-sdk-service.test.ts +++ b/packages/coc-agent-sdk/test/ai/claude-sdk-service.test.ts @@ -999,6 +999,252 @@ describe('ClaudeSDKService.sendMessage', () => { }); }); + it('normalizes Claude Agent tool calls to task and preserves subagent child nesting', async () => { + queryFn.mockReturnValueOnce(makeMessages([ + { + type: 'assistant', + parent_tool_use_id: null, + message: { + content: [ + { + type: 'tool_use', + id: 'agent-tool', + name: 'Agent', + input: { + description: 'Get time', + prompt: 'Run date -u', + subagent_type: 'general-purpose', + }, + }, + ], + }, + }, + { + type: 'assistant', + parent_tool_use_id: 'agent-tool', + message: { + content: [ + { + type: 'tool_use', + id: 'bash-child', + name: 'Bash', + input: { command: 'date -u' }, + }, + ], + }, + }, + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'bash-child', + content: 'Sat Jun 13 23:35:39 UTC 2026', + }, + ], + }, + }, + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'agent-tool', + content: 'done\nagentId: af43d1cb10a1f5b7d', + }, + ], + }, + }, + { type: 'result', subtype: 'success' }, + ])); + + const toolEvents: object[] = []; + const result = await svc.sendMessage({ + prompt: 'ask a subagent', + onToolEvent: (e) => toolEvents.push(e), + }); + + expect(result.toolCalls).toHaveLength(2); + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'agent-tool', + name: 'task', + status: 'completed', + args: { + description: 'Get time', + prompt: 'Run date -u', + subagent_type: 'general-purpose', + agent_type: 'general-purpose', + agent_id: 'af43d1cb10a1f5b7d', + agent_ids: ['af43d1cb10a1f5b7d'], + }, + result: 'done\nagentId: af43d1cb10a1f5b7d', + }); + expect(result.toolCalls?.[1]).toMatchObject({ + id: 'bash-child', + name: 'Bash', + status: 'completed', + parentToolCallId: 'agent-tool', + args: { command: 'date -u' }, + result: 'Sat Jun 13 23:35:39 UTC 2026', + }); + expect(toolEvents).toEqual([ + expect.objectContaining({ + type: 'tool-start', + toolCallId: 'agent-tool', + toolName: 'task', + parameters: expect.objectContaining({ + agent_type: 'general-purpose', + description: 'Get time', + }), + }), + expect.objectContaining({ + type: 'tool-start', + toolCallId: 'bash-child', + toolName: 'Bash', + parentToolCallId: 'agent-tool', + }), + expect.objectContaining({ + type: 'tool-complete', + toolCallId: 'bash-child', + parentToolCallId: 'agent-tool', + result: 'Sat Jun 13 23:35:39 UTC 2026', + }), + expect.objectContaining({ + type: 'tool-complete', + toolCallId: 'agent-tool', + toolName: 'task', + parameters: expect.objectContaining({ + agent_id: 'af43d1cb10a1f5b7d', + agent_ids: ['af43d1cb10a1f5b7d'], + }), + result: 'done\nagentId: af43d1cb10a1f5b7d', + }), + ]); + }); + + it('captures structured Claude Agent output metadata on task tool calls', async () => { + queryFn.mockReturnValueOnce(makeMessages([ + { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'agent-structured', + name: 'Agent', + input: { + description: 'Review patch', + prompt: 'Review the diff', + }, + }, + ], + }, + }, + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'agent-structured', + content: { + status: 'completed', + agentId: 'agent-42', + agentType: 'reviewer', + content: [{ type: 'text', text: 'all done' }], + totalToolUseCount: 1, + totalDurationMs: 12, + totalTokens: 34, + usage: { + input_tokens: 10, + output_tokens: 24, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + server_tool_use: null, + service_tier: null, + cache_creation: null, + }, + prompt: 'Review the diff', + }, + }, + ], + }, + }, + { type: 'result', subtype: 'success' }, + ])); + + const result = await svc.sendMessage({ prompt: 'review with subagent' }); + + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'agent-structured', + name: 'task', + status: 'completed', + args: { + description: 'Review patch', + prompt: 'Review the diff', + agent_type: 'reviewer', + agent_id: 'agent-42', + agent_ids: ['agent-42'], + agent_status: 'completed', + }, + result: 'all done', + }); + }); + + it('normalizes Claude TaskOutput calls to read_agent with wait metadata', async () => { + queryFn.mockReturnValueOnce(makeMessages([ + { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'task-output', + name: 'TaskOutput', + input: { + task_id: 'agent-bg', + block: true, + timeout: 120000, + }, + }, + ], + }, + }, + { + type: 'user', + message: { + content: [ + { + type: 'tool_result', + tool_use_id: 'task-output', + content: 'background done', + }, + ], + }, + }, + { type: 'result', subtype: 'success' }, + ])); + + const result = await svc.sendMessage({ prompt: 'wait for background agent' }); + + expect(result.toolCalls?.[0]).toMatchObject({ + id: 'task-output', + name: 'read_agent', + status: 'completed', + args: { + task_id: 'agent-bg', + agent_id: 'agent-bg', + block: true, + wait: true, + timeout: 120, + timeout_ms: 120000, + }, + result: 'background done', + }); + }); + it('marks Claude tool_result blocks with is_error as failed', async () => { queryFn.mockReturnValueOnce(makeMessages([ { From 8252410fa57e8b6ea777a24f4e19bf516f371625 Mon Sep 17 00:00:00 2001 From: Yiheng Tao Date: Sun, 14 Jun 2026 06:06:51 +0000 Subject: [PATCH 37/37] Fix CI failures from new feature flags and Windows path encoding The merged native-CLI-sessions, Dreams admin, and LLM-tool-param features left several deterministic test failures uncovered by the sharded suite: - llm-tool-parameter-schemas: mirror the three canvas tools (write_canvas, read_canvas, extension_canvas) so the registry-completeness guard passes. - shared barrel: re-export providerVisuals so barrel-completeness passes. - config "all fields overridden": set the new dreams.provider, dreams.model, and features.nativeCliSessions keys in the fixture; refresh the inline resolved-config/source snapshot for features.nativeCliSessions. - terminal-tab-integration: update the visibleSubTabs dependency-array assertion for the added nativeCliSessionsEnabled dep. - admin e2e: expect Dreams in the Knowledge nav group. Also fix a Windows-only regression: dashEncodeWorkspaceRoot kept the drive-letter colon (C:\... -> C:-...), an invalid Windows path segment that broke ClaudeNativeSessionProvider directory reads. Encode colons to dashes (C--...), export the helper, and add cross-platform regression tests. Co-Authored-By: Claude Opus 4.8 --- .../llm-tools/llm-tool-parameter-schemas.ts | 56 +++++++++++++++++++ .../native-cli-session-service.ts | 11 +++- .../server/spa/client/react/shared/index.ts | 2 + packages/coc/test/config.test.ts | 5 ++ packages/coc/test/e2e/admin.spec.ts | 2 +- .../server/native-cli-session-service.test.ts | 22 +++++++- .../react/terminal-tab-integration.test.ts | 2 +- 7 files changed, 95 insertions(+), 5 deletions(-) diff --git a/packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts b/packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts index ee144c0c0..a1b41f7b7 100644 --- a/packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts +++ b/packages/coc/src/server/llm-tools/llm-tool-parameter-schemas.ts @@ -143,6 +143,62 @@ export const LLM_TOOL_PARAMETER_SCHEMAS: Record> }, required: ['filename'], }, + write_canvas: { + type: 'object', + properties: { + canvasId: { type: 'string' }, + title: { type: 'string' }, + content: { type: 'string' }, + edits: { + type: 'array', + items: { + type: 'object', + properties: { + oldText: { type: 'string' }, + newText: { type: 'string' }, + }, + required: ['oldText', 'newText'], + }, + }, + type: { type: 'string' }, + language: { type: 'string' }, + expectedRevision: { type: 'number' }, + }, + required: [], + }, + read_canvas: { + type: 'object', + properties: { + canvasId: { type: 'string' }, + }, + required: ['canvasId'], + }, + extension_canvas: { + type: 'object', + properties: { + canvasId: { type: 'string' }, + capability: { type: 'string' }, + params: { type: 'object' }, + title: { type: 'string' }, + description: { type: 'string' }, + capabilities: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + paramsDescription: { type: 'string' }, + }, + required: ['name', 'description'], + }, + }, + capabilitiesJs: { type: 'string' }, + uiHtml: { type: 'string' }, + initialState: { type: 'object' }, + }, + required: [], + }, tavily_web_search: { type: 'object', properties: { diff --git a/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts b/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts index 62ba3e9a4..96e2a2dd6 100644 --- a/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts +++ b/packages/coc/src/server/native-copilot-sessions/native-cli-session-service.ts @@ -506,11 +506,18 @@ export class CopilotNativeSessionProvider implements NativeSessionProvider { } } -function dashEncodeWorkspaceRoot(rootPath: string | undefined): string | undefined { +/** + * Encode an absolute workspace root into Claude Code's `~/.claude/projects/` + * directory name. Claude replaces path separators AND the Windows drive-letter + * colon with dashes (a literal `:` is not a valid path segment on Windows), so + * `C:\Users\me\repo` becomes `C--Users-me-repo`. Exported for cross-platform + * regression coverage of the colon encoding. + */ +export function dashEncodeWorkspaceRoot(rootPath: string | undefined): string | undefined { if (!rootPath) { return undefined; } - return path.resolve(rootPath).replace(/\\/g, '/').replace(/\//g, '-'); + return path.resolve(rootPath).replace(/\\/g, '/').replace(/[/:]/g, '-'); } export class ClaudeNativeSessionProvider extends JsonlFileNativeSessionProvider { diff --git a/packages/coc/src/server/spa/client/react/shared/index.ts b/packages/coc/src/server/spa/client/react/shared/index.ts index 91becb900..5836e43fe 100644 --- a/packages/coc/src/server/spa/client/react/shared/index.ts +++ b/packages/coc/src/server/spa/client/react/shared/index.ts @@ -35,6 +35,8 @@ export { NotificationBell } from './NotificationBell'; export { agentProviderQuotaIndicator } from './AgentProviderQuotaIndicator'; export { collectDreamProviderActivity, getTaskProvider, loadDreamProviderActivity } from './providerActivity'; export type { AgentProviderWorkActivity } from './providerActivity'; +export { PROVIDER_LABELS, PROVIDER_ICONS, ProviderAvatar } from './providerVisuals'; +export type { Provider } from './providerVisuals'; export { DASHBOARD_AI_COMMANDS } from './ai-commands'; export type { DashboardAICommand } from './ai-commands'; export { shortenFilePath, linkifyFilePaths, FILE_PATH_RE } from './file-path-utils'; diff --git a/packages/coc/test/config.test.ts b/packages/coc/test/config.test.ts index e9f7d8c44..367e0f7c3 100644 --- a/packages/coc/test/config.test.ts +++ b/packages/coc/test/config.test.ts @@ -964,6 +964,8 @@ timeout: 300 ' enabled: true', 'dreams:', ' enabled: true', + ' provider: codex', + ' model: gpt-test', ' idleCheckIntervalMs: 120000', ' minIdleMs: 60000', ' confidenceThreshold: 0.9', @@ -1016,6 +1018,7 @@ timeout: 300 ' commitChatLensDormantMode: pill', ' autoAgentProviderRouting: true', ' ralphMultiAgentGrill: true', + ' nativeCliSessions: true', 'memoryPromotion:', ' batchSize: 25', ' timeoutMs: 80000', @@ -1228,6 +1231,7 @@ timeout: 300 "focusedDiff": true, "gitCommitLookup": false, "gitCrossCloneCherryPick": true, + "nativeCliSessions": false, "ralphMultiAgentGrill": false, "sessionContextAttachments": false, }, @@ -1392,6 +1396,7 @@ timeout: 300 "features.commitChatLensDormantMode": "default", "features.focusedDiff": "file", "features.gitCrossCloneCherryPick": "default", + "features.nativeCliSessions": "default", "features.ralphMultiAgentGrill": "default", "features.sessionContextAttachments": "default", "forEach.enabled": "file", diff --git a/packages/coc/test/e2e/admin.spec.ts b/packages/coc/test/e2e/admin.spec.ts index 623abcaf8..871e514cc 100644 --- a/packages/coc/test/e2e/admin.spec.ts +++ b/packages/coc/test/e2e/admin.spec.ts @@ -73,7 +73,7 @@ test.describe('Admin Panel (008)', () => { 'AI Provider', 'Servers', ]); - expect(byLabel.Knowledge).toEqual(['Memory', 'Skills']); + expect(byLabel.Knowledge).toEqual(['Memory', 'Skills', 'Dreams']); expect(byLabel.Operations).toEqual(['Usage & Costs', 'Logs', 'Server', 'Backup & Reset']); expect(byLabel['Developer / Internals']).toEqual(['System Prompts', 'Database Browser', 'Advanced']); diff --git a/packages/coc/test/server/native-cli-session-service.test.ts b/packages/coc/test/server/native-cli-session-service.test.ts index fb105ef4c..04b59f939 100644 --- a/packages/coc/test/server/native-cli-session-service.test.ts +++ b/packages/coc/test/server/native-cli-session-service.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ClaudeNativeSessionProvider, CodexNativeSessionProvider, + dashEncodeWorkspaceRoot, } from '../../src/server/native-copilot-sessions/native-cli-session-service'; let tmpDir: string; @@ -19,7 +20,7 @@ function writeJsonl(filePath: string, records: unknown[]): void { } function dashEncode(rootPath: string): string { - return path.resolve(rootPath).replace(/\\/g, '/').replace(/\//g, '-'); + return path.resolve(rootPath).replace(/\\/g, '/').replace(/[/:]/g, '-'); } beforeEach(() => { @@ -30,6 +31,25 @@ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); +describe('dashEncodeWorkspaceRoot', () => { + it('returns undefined when no root is provided', () => { + expect(dashEncodeWorkspaceRoot(undefined)).toBeUndefined(); + expect(dashEncodeWorkspaceRoot('')).toBeUndefined(); + }); + + it('encodes to a single path segment with no separators or colons', () => { + // Regression: Windows drive-letter roots (C:\...) previously kept their + // colon, yielding an invalid path segment that broke directory reads. + const encoded = dashEncodeWorkspaceRoot(path.join(tmpDir, 'repo')); + expect(encoded).toBeDefined(); + expect(encoded!).not.toMatch(/[:/\\]/); + }); + + it('strips the drive-letter colon from the encoded folder name', () => { + expect(dashEncodeWorkspaceRoot('/home/runner/C:fakepath')).not.toContain(':'); + }); +}); + describe('CodexNativeSessionProvider', () => { it('lists workspace-scoped rollout sessions with filters, pagination, text snippets, and dedup', () => { const workspaceRoot = path.join(tmpDir, 'repo'); diff --git a/packages/coc/test/spa/react/terminal-tab-integration.test.ts b/packages/coc/test/spa/react/terminal-tab-integration.test.ts index 5b4b8dfd9..854b9d5aa 100644 --- a/packages/coc/test/spa/react/terminal-tab-integration.test.ts +++ b/packages/coc/test/spa/react/terminal-tab-integration.test.ts @@ -88,7 +88,7 @@ describe('RepoDetail terminal visibility gating', () => { }); it('visibleSubTabs depends on terminalEnabled', () => { - expect(REPO_DETAIL_SOURCE).toContain('[isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, uiLayoutMode]'); + expect(REPO_DETAIL_SOURCE).toContain('[isGitRepo, terminalEnabled, notesEnabled, workflowsEnabled, pullRequestsEnabled, dreamsEnabled, nativeCliSessionsEnabled, uiLayoutMode]'); }); });