diff --git a/apps/app/src/react-app/domains/session/control/list-control-sessions.ts b/apps/app/src/react-app/domains/session/control/list-control-sessions.ts new file mode 100644 index 00000000000..12783845fd7 --- /dev/null +++ b/apps/app/src/react-app/domains/session/control/list-control-sessions.ts @@ -0,0 +1,74 @@ +import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; + +export type ControlSessionWorkspace = { + id: string; + name?: string | null; + path?: string | null; + displayName?: string | null; +}; + +export type ControlSessionLike = { + id?: string; + title?: string; + time?: { + updated?: number; + created?: number; + }; +}; + +export type ListedControlSession = { + sessionId: string; + title: string; + workspace: string; + updatedAt: number; + pinned: boolean; +}; + +export type ListControlSessionsState = { + workspaces: ControlSessionWorkspace[]; + sessionsByWorkspaceId: Record; + pinnedIds: readonly string[]; +}; + +export function controlWorkspaceLabel(workspace: ControlSessionWorkspace) { + return workspace.displayName?.trim() || workspace.name?.trim() || workspace.path?.trim() || "workspace"; +} + +function matchesWorkspace(workspace: ControlSessionWorkspace, query: string) { + const lower = query.toLowerCase(); + return workspace.id.toLowerCase() === lower || controlWorkspaceLabel(workspace).toLowerCase() === lower; +} + +function argsRecord(args: unknown): Record { + return args && typeof args === "object" ? args as Record : {}; +} + +/** + * `session.list_sessions`: sessions the app already holds in memory, pinned + * first then newest first. `args` is the raw control-action payload; the list + * is only truncated when it carries a positive integer `limit`, and an + * unknown `workspaceId` yields no sessions rather than another workspace's. + * A silent cap here made large workspaces impossible to inventory. + */ +export function listControlSessions(args: unknown, state: ListControlSessionsState): ListedControlSession[] { + const record = argsRecord(args); + const workspaceQuery = typeof record.workspaceId === "string" ? record.workspaceId.trim() : ""; + const limit = record.limit; + const out: ListedControlSession[] = []; + for (const workspace of state.workspaces) { + if (workspaceQuery && !matchesWorkspace(workspace, workspaceQuery)) continue; + for (const session of state.sessionsByWorkspaceId[workspace.id] ?? []) { + const sessionId = session.id?.trim() ?? ""; + if (!sessionId) continue; + out.push({ + sessionId, + title: getDisplaySessionTitle(session.title ?? ""), + workspace: controlWorkspaceLabel(workspace), + updatedAt: session.time?.updated ?? session.time?.created ?? 0, + pinned: state.pinnedIds.includes(sessionId), + }); + } + } + out.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt); + return typeof limit === "number" && Number.isInteger(limit) && limit > 0 ? out.slice(0, limit) : out; +} diff --git a/apps/app/src/react-app/domains/session/control/session-control-actions.ts b/apps/app/src/react-app/domains/session/control/session-control-actions.ts index b6461176552..bb73ce2e903 100644 --- a/apps/app/src/react-app/domains/session/control/session-control-actions.ts +++ b/apps/app/src/react-app/domains/session/control/session-control-actions.ts @@ -5,19 +5,10 @@ import type { createClient } from "../../../../app/lib/opencode"; import type { OpenworkServerClient, OpenworkWorkspaceInfo } from "../../../../app/lib/openwork-server"; import { deleteRouteSession } from "../../../shell/route-workspaces"; import type { ResolvedWorkspaceEndpoint } from "../../../../app/lib/workspace-endpoint"; -import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; import { useControlAction, type OpenworkControlAction } from "../../../shell/control/control-provider"; import { useSessionManagementStore } from "../sidebar/session-management-store"; import { isSameWorkbenchSession, useWorkbenchStore } from "../chat/workbench-store"; - -type SessionLike = { - id?: string; - title?: string; - time?: { - updated?: number; - created?: number; - }; -}; +import { controlWorkspaceLabel as workspaceLabel, listControlSessions, type ControlSessionLike as SessionLike } from "./list-control-sessions"; type SessionControlWorkspace = OpenworkWorkspaceInfo & { displayNameResolved: string; @@ -42,10 +33,6 @@ type UseSessionControlActionsInput = { archiveSession: (sessionId: string, archived: boolean) => Promise; }; -function workspaceLabel(workspace: SessionControlWorkspace) { - return workspace.displayName?.trim() || workspace.name?.trim() || workspace.path?.trim() || "workspace"; -} - function findSessionWorkspace( workspaces: SessionControlWorkspace[], sessionsByWorkspaceId: Record, @@ -108,25 +95,15 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) { const listSessionsControlAction = useMemo(() => ({ id: "session.list_sessions", label: "List available sessions", - description: "Return sessions across workspaces. Entries include `pinned`, and pinned sessions come first.", + description: "Return every loaded session across workspaces (pinned first, then newest). Entries include `pinned`. Pass `limit` to cap the count or `workspaceId` to narrow to one workspace.", kind: "query", effects: { data: "read", ui: "none", external: false }, sideEffect: "none", - execute: () => { - const out: { sessionId: string; title: string; workspace: string; updatedAt: number; pinned: boolean }[] = []; - for (const workspace of workspaces) { - const list = sessionsByWorkspaceId[workspace.id] ?? []; - for (const session of list) { - const sessionId = session.id?.trim() ?? ""; - if (!sessionId) continue; - const title = getDisplaySessionTitle(session.title ?? ""); - const updatedAt = session.time?.updated ?? session.time?.created ?? 0; - out.push({ sessionId, title, workspace: workspaceLabel(workspace), updatedAt, pinned: pinnedIds.includes(sessionId) }); - } - } - out.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.updatedAt - a.updatedAt); - return out.slice(0, 30); - }, + args: [ + { name: "limit", type: "number", required: false, description: "Maximum sessions to return. Omit to return all loaded sessions." }, + { name: "workspaceId", type: "string", required: false, description: "Workspace ID or display name. Omit to include every workspace." }, + ], + execute: (args) => listControlSessions(args, { workspaces, sessionsByWorkspaceId, pinnedIds }), }), [pinnedIds, sessionsByWorkspaceId, workspaces]); useControlAction(listSessionsControlAction); diff --git a/docs/mcp-ui-control-profile.md b/docs/mcp-ui-control-profile.md index 4eb8a9afd81..dbddb6e3d10 100644 --- a/docs/mcp-ui-control-profile.md +++ b/docs/mcp-ui-control-profile.md @@ -213,7 +213,7 @@ The exact list depends on the current OpenWork route and state. Common actions i | Action | Description | |--------|-------------| | `session.create_task` | Create a new session in the selected workspace | -| `session.list_sessions` | List sessions across workspaces | +| `session.list_sessions` | List all loaded sessions across workspaces (optional `limit`, `workspaceId`) | | `session.open` | Navigate to a session by ID | | `session.rename` | Rename a session | | `session.delete` | Delete a session (requires confirmation) | diff --git a/evals/specs/session-list-sessions-inventory.test.ts b/evals/specs/session-list-sessions-inventory.test.ts new file mode 100644 index 00000000000..a78942b0581 --- /dev/null +++ b/evals/specs/session-list-sessions-inventory.test.ts @@ -0,0 +1,108 @@ +import { expect } from "vitest"; +import { test } from "@openwork/testkit"; + +import { listControlSessions } from "../../apps/app/src/react-app/domains/session/control/list-control-sessions"; + +// The hook's execute is `(args) => listControlSessions(args, state)`, so every +// call below passes the raw control-action payload exactly as the control +// bridge delivers it (null / {} / a JSON object). + +const workspaces = [ + { id: "ws_alpha", displayName: "Alpha" }, + { id: "ws_beta", name: "beta-repo" }, +]; + +function sessions(prefix: string, count: number, startAt: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `${prefix}_${index}`, + title: `${prefix} task ${index}`, + time: { updated: startAt + index }, + })); +} + +// ~100 sessions in one workspace mirrors the reported inventory that the old +// hard-coded 30-row cap silently dropped. +const state = { + workspaces, + sessionsByWorkspaceId: { + ws_alpha: sessions("alpha", 100, 1_000), + ws_beta: sessions("beta", 20, 5_000), + }, + pinnedIds: [], +}; + +test("session.list_sessions returns every loaded session instead of a silent 30-row cap", async ({ evidence }) => { + const withNull = listControlSessions(null, state); + const withEmpty = listControlSessions({}, state); + + expect(withNull).toHaveLength(120); + expect(withEmpty).toEqual(withNull); + expect(new Set(withNull.map((session) => session.sessionId)).size).toBe(120); + expect(withNull.slice(0, 20).every((session) => session.workspace === "beta-repo")).toBe(true); + expect(withNull[20]?.sessionId).toBe("alpha_99"); + expect(withNull.at(-1)?.sessionId).toBe("alpha_0"); + evidence.recordAssertionEvidence( + "Large session inventories are fully visible to the control surface", + `120 loaded sessions across two workspaces were all returned for both null and {} args, newest first, with no truncation.`, + withNull.length === 120 && withEmpty.length === 120, + ); +}); + +test("session.list_sessions caps output only when args carry a positive integer limit", async ({ evidence }) => { + const capped = listControlSessions({ limit: 30 }, state); + const zero = listControlSessions({ limit: 0 }, state); + const fractional = listControlSessions({ limit: 2.5 }, state); + const stringy = listControlSessions({ limit: "30" }, state); + + expect(capped).toHaveLength(30); + expect(capped.map((session) => session.sessionId)).toEqual([ + ...Array.from({ length: 20 }, (_, index) => `beta_${19 - index}`), + ...Array.from({ length: 10 }, (_, index) => `alpha_${99 - index}`), + ]); + expect(zero).toHaveLength(120); + expect(fractional).toHaveLength(120); + expect(stringy).toHaveLength(120); + evidence.recordAssertionEvidence( + "Truncation is opt-in and only honours a positive integer limit", + `limit: 30 returned the 30 newest sessions; 0, 2.5 and "30" were ignored and returned all 120.`, + capped.length === 30 && zero.length === 120 && fractional.length === 120 && stringy.length === 120, + ); +}); + +test("session.list_sessions narrows to one workspace by id or display name without leaking others", async ({ evidence }) => { + const byId = listControlSessions({ workspaceId: "ws_alpha" }, state); + const byName = listControlSessions({ workspaceId: " alpha " }, state); + const unknown = listControlSessions({ workspaceId: "ws_missing" }, state); + const combined = listControlSessions({ workspaceId: "ws_alpha", limit: 5 }, state); + + expect(byId).toHaveLength(100); + expect(byId.every((session) => session.workspace === "Alpha")).toBe(true); + expect(byName.map((session) => session.sessionId)).toEqual(byId.map((session) => session.sessionId)); + expect(unknown).toEqual([]); + expect(combined.map((session) => session.sessionId)).toEqual(["alpha_99", "alpha_98", "alpha_97", "alpha_96", "alpha_95"]); + evidence.recordAssertionEvidence( + "Workspace filter is exact, composes with limit, and never falls back to another workspace", + `ws_alpha and " alpha " both returned the same 100 sessions; an unknown workspace returned none; limit 5 kept the 5 newest alpha sessions.`, + byId.length === 100 && unknown.length === 0 && combined.length === 5, + ); +}); + +test("session.list_sessions keeps pinned sessions first and skips entries without ids", async ({ evidence }) => { + const listed = listControlSessions(null, { + workspaces, + sessionsByWorkspaceId: { + ws_alpha: [...sessions("alpha", 3, 1_000), { title: "no id" }, { id: " " }], + ws_beta: sessions("beta", 2, 5_000), + }, + pinnedIds: ["alpha_0"], + }); + + expect(listed.map((session) => session.sessionId)).toEqual(["alpha_0", "beta_1", "beta_0", "alpha_2", "alpha_1"]); + expect(listed[0]?.pinned).toBe(true); + expect(listed.slice(1).every((session) => !session.pinned)).toBe(true); + evidence.recordAssertionEvidence( + "Pinned-first ordering and id hygiene survive the refactor", + `alpha_0 (pinned, oldest) led the list; two id-less entries were dropped.`, + listed[0]?.sessionId === "alpha_0" && listed.length === 5, + ); +}); diff --git a/packages/docs/start-here/do-work-with-it/cross-chat-memory.mdx b/packages/docs/start-here/do-work-with-it/cross-chat-memory.mdx index efaa9a5d873..d3462559eda 100644 --- a/packages/docs/start-here/do-work-with-it/cross-chat-memory.mdx +++ b/packages/docs/start-here/do-work-with-it/cross-chat-memory.mdx @@ -7,7 +7,7 @@ OpenWork's cross-chat memory currently comes from saved session history. It is n When an agent is running inside the OpenWork app, it can use OpenWork's semantic UI actions to look up past chats: -- `session.list_sessions` lists recent sessions across the workspaces the app knows about. +- `session.list_sessions` lists every loaded session across the workspaces the app knows about (pinned first, then newest). Pass `limit` to cap the count or `workspaceId` to narrow to one workspace. - `session.open` opens a matching session by ID. - `session.read_transcript` reads recent messages from the currently open session. - `session.latest_message` reads the newest message in the currently open session. @@ -15,7 +15,7 @@ When an agent is running inside the OpenWork app, it can use OpenWork's semantic That means you can ask things like: - `What did I say in the customer migration session?` -- `Find the session about Ramki and remind me what we decided.` +- `Find the session about the pricing page redesign and remind me what we decided.` - `Open the onboarding bug chat and summarize the next steps.` - `What did I ask you to do in session ses_abc123?`