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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
};
};

Comment thread
github-actions[bot] marked this conversation as resolved.
export type ListedControlSession = {
sessionId: string;
title: string;
workspace: string;
updatedAt: number;
pinned: boolean;
};

export type ListControlSessionsState = {
workspaces: ControlSessionWorkspace[];
sessionsByWorkspaceId: Record<string, ControlSessionLike[]>;
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<string, unknown> {
return args && typeof args === "object" ? args as Record<string, unknown> : {};
}

/**
* `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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,10 +33,6 @@ type UseSessionControlActionsInput = {
archiveSession: (sessionId: string, archived: boolean) => Promise<boolean>;
};

function workspaceLabel(workspace: SessionControlWorkspace) {
return workspace.displayName?.trim() || workspace.name?.trim() || workspace.path?.trim() || "workspace";
}

function findSessionWorkspace(
workspaces: SessionControlWorkspace[],
sessionsByWorkspaceId: Record<string, SessionLike[]>,
Expand Down Expand Up @@ -108,25 +95,15 @@ export function useSessionControlActions(input: UseSessionControlActionsInput) {
const listSessionsControlAction = useMemo<OpenworkControlAction>(() => ({
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);

Expand Down
2 changes: 1 addition & 1 deletion docs/mcp-ui-control-profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
108 changes: 108 additions & 0 deletions evals/specs/session-list-sessions-inventory.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ 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.

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?`

Expand Down
Loading