diff --git a/.changeset/feat-hide-acp-workspaces.md b/.changeset/feat-hide-acp-workspaces.md new file mode 100644 index 0000000000..7504bffe95 --- /dev/null +++ b/.changeset/feat-hide-acp-workspaces.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/acp-adapter": patch +"@moonshot-ai/kimi-web": patch +--- + +Tag ACP-created sessions with `source: 'acp'` (plus the client's `clientInfo.name`) in their persisted custom metadata, and add a kimi-web setting (on by default) that hides ACP-created sessions and ACP-only workspaces from the sidebar and archived lists. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 5708bcc9bf..57e3bafeef 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -998,6 +998,7 @@ function openPr(url: string): void { :notify-permission="client.notifyPermission.value" :sound="client.soundOnComplete.value" :conversation-toc="client.conversationToc.value" + :hide-acp-sessions="client.hideAcpSessions.value" :config="client.config.value" :models="client.models.value" :config-saving="configSaving" @@ -1011,6 +1012,7 @@ function openPr(url: string): void { @set-notify-approval="client.setNotifyOnApproval($event)" @set-sound="client.setSoundOnComplete($event)" @set-conversation-toc="client.setConversationToc($event)" + @set-hide-acp-sessions="client.setHideAcpSessions($event)" @update-config="handleUpdateConfig($event)" @login="() => { showSettings = false; openLogin(); }" @logout="client.logout" diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index 5eeda29e08..fbcd4388ba 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -109,6 +109,7 @@ export function toAppSession(wire: WireSession): AppSession { typeof wire.metadata['parent_session_id'] === 'string' ? wire.metadata['parent_session_id'] : undefined, + source: typeof wire.metadata['source'] === 'string' ? wire.metadata['source'] : undefined, }; } diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index 3f82db6a4b..f90ba663fb 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -90,6 +90,12 @@ export interface AppSession { * from. Used to keep child sessions out of the main session list. */ parentSessionId?: string; + /** + * Creation source of the session (e.g. 'acp' for sessions created through + * the ACP adapter), forwarded from the persisted session `custom` map. + * Used to hide ACP-created sessions/workspaces from the sidebar. + */ + source?: string; } /** diff --git a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue index 814ea8c398..f0bc74e752 100644 --- a/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue +++ b/apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue @@ -198,6 +198,7 @@ function backToMain(): void { const filteredArchived = computed(() => { const q = archiveQuery.value.trim().toLowerCase(); let rows = archivedItems.value.filter((s) => s.archived === true); + if (client.hideAcpSessions.value) rows = rows.filter((s) => s.source !== 'acp'); if (q) rows = rows.filter((s) => s.title.toLowerCase().includes(q)); rows = rows.slice(); if (archiveSort.value === 'archived-desc') { diff --git a/apps/kimi-web/src/components/settings/SettingsDialog.vue b/apps/kimi-web/src/components/settings/SettingsDialog.vue index 181e4197eb..76680e51f8 100644 --- a/apps/kimi-web/src/components/settings/SettingsDialog.vue +++ b/apps/kimi-web/src/components/settings/SettingsDialog.vue @@ -40,6 +40,8 @@ const props = defineProps<{ sound: boolean; /** Conversation outline (proportional bubbles, viewport indicator, hover tooltip). */ conversationToc?: boolean; + /** Hide ACP-created sessions/workspaces from the sidebar preference. */ + hideAcpSessions?: boolean; /** Global daemon config from GET /api/v1/config. Secrets are redacted server-side. */ config?: AppConfig | null; /** Models from the daemon catalog, used to label default-model choices. */ @@ -61,6 +63,7 @@ const emit = defineEmits<{ setNotifyApproval: [on: boolean]; setSound: [on: boolean]; setConversationToc: [on: boolean]; + setHideAcpSessions: [on: boolean]; login: []; logout: []; openOnboarding: []; @@ -280,6 +283,7 @@ const filteredArchived = computed(() => { // even if an older server ignores `archived_only` and falls back to the // default (unarchived) list. Filter again on the client. let rows = archivedItems.value.filter((s) => s.archived === true); + if (client.hideAcpSessions.value) rows = rows.filter((s) => s.source !== 'acp'); if (archiveWsFilter.value !== 'all') { rows = rows.filter((s) => s.cwd === archiveWsFilter.value); } @@ -397,6 +401,17 @@ function archiveTime(iso: string): string { @update:model-value="emit('setConversationToc', $event)" /> +
+ + {{ t('settings.hideAcpSessions') }} + {{ t('settings.hideAcpSessionsHint') }} + + +
diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 5f5cdbfa5b..0607b3ee59 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -245,6 +245,13 @@ export interface UseWorkspaceStateDeps { mergedWorkspaces: ComputedRef; /** Sidebar-facing workspaces in the user's (dragged) display order. */ workspacesView: ComputedRef; + /** + * Sessions hidden from the sidebar list by preference (e.g. ACP-created + * sessions when `hideAcpSessions` is on). Fresh-load auto-selection skips + * these so the user never lands on a session the sidebar does not render. + * Defaults to "nothing hidden" when the facade does not wire a preference. + */ + isSessionHiddenFromList?: (session: AppSession) => boolean; status: ComputedRef; workspaceIdForSession: (s: { workspaceId?: string; cwd: string }) => string; savePermissionToStorage: (mode: PermissionMode) => void; @@ -295,6 +302,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta persistSessionProfile, mergedWorkspaces, workspacesView, + isSessionHiddenFromList = () => false, status, workspaceIdForSession, savePermissionToStorage, @@ -878,7 +886,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // First load: pick the workspace of the most-recent session, unless the // user already has a persisted active workspace that still exists. - const mostRecent = sessions[0]; + // Sessions hidden from the sidebar (e.g. ACP-created) are skipped — the + // user should not land on a session the sidebar does not render. + const mostRecent = sessions.find((s) => !isSessionHiddenFromList(s)); const persisted = rawState.activeWorkspaceId; const persistedStillExists = persisted !== null && mergedWorkspaces.value.some((w) => w.id === persisted); @@ -903,8 +913,13 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // Auto-select first session if none selected (also the fallback for a dead // deep link — 'replace' rewrites the URL to the session actually shown). + // Skip sessions hidden from the sidebar; when everything is hidden there + // is simply nothing to auto-select. if (!rawState.activeSessionId && sessions.length > 0) { - await selectSession(sessions[0]!.id, { urlMode: 'replace' }); + const firstVisible = sessions.find((s) => !isSessionHiddenFromList(s)); + if (firstVisible) { + await selectSession(firstVisible.id, { urlMode: 'replace' }); + } } } catch (err) { traceStatus = 'failed'; @@ -965,10 +980,15 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta selectWorkspace(id); const sessionsInWs = rawState.sessions.filter((s) => workspaceIdForSession(s) === id); if (sessionsInWs.length > 0) { - const mostRecent = sessionsInWs[0]; + // Skip sessions hidden from the sidebar (e.g. ACP-created) — clicking a + // workspace must not land on a session the sidebar does not render. + const mostRecent = sessionsInWs.find((s) => !isSessionHiddenFromList(s)); if (mostRecent && mostRecent.id !== rawState.activeSessionId) { // One user action (clicking the workspace) = one history entry. void selectSession(mostRecent.id); + } else if (!mostRecent) { + setActiveSessionId(undefined); + writeSessionUrl(undefined, 'push'); } } else { setActiveSessionId(undefined); @@ -1351,13 +1371,14 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta } // A history entry can point at a session that has since been deleted (or one // outside the loaded page): try to fetch it; on failure fall back to the most - // recent session and FIX the URL so the bad entry doesn't stick around. + // recent sidebar-visible session (skipping hidden ones, e.g. ACP-created) + // and FIX the URL so the bad entry doesn't stick around. void (async () => { if (await fetchSessionIntoList(id)) { await selectSession(id, { urlMode: 'none' }); return; } - const next = rawState.sessions[0]; + const next = rawState.sessions.find((s) => !isSessionHiddenFromList(s)); if (next) { await selectSession(next.id, { urlMode: 'replace' }); } else { @@ -2370,8 +2391,9 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta // If archived session was active, pick another. 'replace' so the address // bar doesn't keep pointing at (and back doesn't return to) a dead session. + // Skip sessions hidden from the sidebar (e.g. ACP-created). if (rawState.activeSessionId === id) { - const next = rawState.sessions[0]; + const next = rawState.sessions.find((s) => !isSessionHiddenFromList(s)); if (next) { await selectSession(next.id, { urlMode: 'replace' }); } else { diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index db5281071a..f1f1528877 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -14,6 +14,7 @@ import { type WorkspaceSortMode, } from '../lib/workspaceOrder'; import { mergeWorkspaces } from '../lib/mergeWorkspaces'; +import { acpOnlyWorkspaceRoots, isAcpSession } from '../lib/acpSessions'; import { workspaceRootKey } from '../lib/rootKey'; import { mergeSnapshotMessages } from '../lib/snapshotMessages'; import { mergeSnapshotSubagents } from '../lib/taskMerge'; @@ -1386,7 +1387,9 @@ async function handleSessionNotFound(sessionId: string): Promise { if (rawState.activeSessionId !== sessionId) return; - const next = rawState.sessions[0]; + // Skip sessions hidden from the sidebar (e.g. ACP-created), matching the + // other pick-next paths in useWorkspaceState. + const next = rawState.sessions.find((s) => !(hideAcpSessions.value && isAcpSession(s))); if (next) { await workspaceState.selectSession(next.id, { urlMode: 'replace' }); } else { @@ -2350,6 +2353,36 @@ function workspaceIdForSession(s: { workspaceId?: string; cwd: string }): string ); } +/** + * Hide ACP-created sessions/workspaces from the sidebar. Default ON: sessions + * created via the ACP adapter (e.g. bot clients driving kimi-code headlessly) + * carry `source: 'acp'` in their persisted custom metadata, and their + * workspaces are usually machine-owned directories the user never edits in. + * Persisted as '1'/'0' like the notification prefs. + */ +function loadHideAcpSessions(): boolean { + const v = safeGetString(STORAGE_KEYS.hideAcpSessions); + return v === null ? true : v === '1'; +} + +const hideAcpSessions = ref(loadHideAcpSessions()); + +function setHideAcpSessions(on: boolean): void { + hideAcpSessions.value = on; + safeSetString(STORAGE_KEYS.hideAcpSessions, on ? '1' : '0'); +} + +/** + * Roots whose loaded sessions are ALL ACP-created (see `acpOnlyWorkspaceRoots` + * for the semantics and the pagination caveat). Deliberately NOT written into + * `rawState.hiddenWorkspaceRoots`: that set is user-owned, gets persisted, + * and is auto-cleared by workspace WS broadcasts. These roots only exist at + * the `mergeWorkspaces` call site below. + */ +const acpHiddenWorkspaceRoots = computed(() => + hideAcpSessions.value ? acpOnlyWorkspaceRoots(rawState.sessions) : [], +); + /** * Merge real (daemon) workspaces with workspaces DERIVED from the current * sessions' cwds. Each distinct cwd with no matching real workspace becomes one @@ -2359,8 +2392,10 @@ function workspaceIdForSession(s: { workspaceId?: string; cwd: string }): string const mergedWorkspaces = computed(() => mergeWorkspaces({ workspaces: rawState.workspaces, - sessions: rawState.sessions, - hiddenWorkspaceRoots: rawState.hiddenWorkspaceRoots, + sessions: hideAcpSessions.value + ? rawState.sessions.filter((s) => !isAcpSession(s)) + : rawState.sessions, + hiddenWorkspaceRoots: [...rawState.hiddenWorkspaceRoots, ...acpHiddenWorkspaceRoots.value], sessionsHasMoreByWorkspace: rawState.sessionsHasMoreByWorkspace, }), ); @@ -2483,7 +2518,12 @@ const sessionsForView = computed(() => { // excluded too, so this flat list matches what the grouped sidebar renders // and sidebar search can't resurrect sessions from a removed workspace. return rawState.sessions - .filter((s) => !s.parentSessionId && visibleWorkspaceIds.has(workspaceIdForSession(s))) + .filter( + (s) => + !s.parentSessionId && + visibleWorkspaceIds.has(workspaceIdForSession(s)) && + !(hideAcpSessions.value && isAcpSession(s)), + ) .map((s) => { const workspaceId = workspaceIdForSession(s); return { @@ -2508,6 +2548,7 @@ const workspaceGroups = computed(() => { (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), )) { if (s.parentSessionId) continue; // child sessions stay out of the list + if (hideAcpSessions.value && isAcpSession(s)) continue; // ACP sessions hidden by preference const wid = workspaceIdForSession(s); const view: Session = { id: s.id, @@ -2654,6 +2695,7 @@ const workspaceState = useWorkspaceState(rawState, { persistSessionProfile, mergedWorkspaces, workspacesView, + isSessionHiddenFromList: (s: AppSession) => hideAcpSessions.value && isAcpSession(s), status, workspaceIdForSession, savePermissionToStorage, @@ -2889,6 +2931,10 @@ export function useKimiWebClient() { conversationToc, setConversationToc, + // Hide ACP-created sessions/workspaces from the sidebar + hideAcpSessions, + setHideAcpSessions, + // Color scheme colorScheme: appearance.colorScheme, setColorScheme: appearance.setColorScheme, diff --git a/apps/kimi-web/src/i18n/locales/en/settings.ts b/apps/kimi-web/src/i18n/locales/en/settings.ts index 733cb83e07..45ac799e0f 100644 --- a/apps/kimi-web/src/i18n/locales/en/settings.ts +++ b/apps/kimi-web/src/i18n/locales/en/settings.ts @@ -54,6 +54,8 @@ export default { exportLogBtn: 'Export log', conversationToc: 'Show conversation outline', conversationTocHint: 'Show a clickable outline in the right margin to jump between messages', + hideAcpSessions: 'Hide ACP sessions', + hideAcpSessionsHint: 'Hide workspaces and sessions created by ACP clients (e.g. automation bots) from the sidebar', archivedTitle: 'Archived sessions', archivedDesc: 'Browse archived sessions, see their workspace path, name, and archive time, and restore them to the session list.', archivedSearch: 'Search archived sessions', diff --git a/apps/kimi-web/src/i18n/locales/zh/settings.ts b/apps/kimi-web/src/i18n/locales/zh/settings.ts index 3ebdbbe65f..567c2b8e0c 100644 --- a/apps/kimi-web/src/i18n/locales/zh/settings.ts +++ b/apps/kimi-web/src/i18n/locales/zh/settings.ts @@ -54,6 +54,8 @@ export default { exportLogBtn: '导出日志', conversationToc: '显示对话目录', conversationTocHint: '在右侧显示可点击跳转的对话目录', + hideAcpSessions: '隐藏 ACP 会话', + hideAcpSessionsHint: '不在会话列表显示由 ACP 客户端(如自动化 bot)创建的工作区和会话', archivedTitle: '已归档会话', archivedDesc: '查看已归档会话,确认其所属工作区路径、会话名称和归档时间,并可恢复到会话列表。', archivedSearch: '搜索已归档会话', diff --git a/apps/kimi-web/src/lib/acpSessions.test.ts b/apps/kimi-web/src/lib/acpSessions.test.ts new file mode 100644 index 0000000000..5f947152d8 --- /dev/null +++ b/apps/kimi-web/src/lib/acpSessions.test.ts @@ -0,0 +1,69 @@ +// apps/kimi-web/src/lib/acpSessions.test.ts +import { describe, expect, it } from 'vitest'; + +import type { AppSession } from '../api/types'; +import { acpOnlyWorkspaceRoots, isAcpSession } from './acpSessions'; +import { workspaceRootKey } from './rootKey'; + +function makeSession(overrides: Partial): AppSession { + return { + id: 'session_1', + title: 't', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + busy: false, + archived: false, + cwd: '/repo', + model: 'kimi', + usage: { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + contextTokens: 0, + contextLimit: 0, + turnCount: 0, + }, + messageCount: 0, + lastSeq: 0, + ...overrides, + }; +} + +describe('isAcpSession', () => { + it('matches exactly the acp source tag', () => { + expect(isAcpSession(makeSession({ source: 'acp' }))).toBe(true); + expect(isAcpSession(makeSession({ source: 'vscode' }))).toBe(false); + expect(isAcpSession(makeSession({}))).toBe(false); + }); +}); + +describe('acpOnlyWorkspaceRoots', () => { + it('reports roots whose sessions are all ACP-created', () => { + const roots = acpOnlyWorkspaceRoots([ + makeSession({ id: 'a', cwd: '/agent/s_1', source: 'acp' }), + makeSession({ id: 'b', cwd: '/agent/s_1', source: 'acp' }), + makeSession({ id: 'c', cwd: '/repo', source: undefined }), + ]); + expect(roots).toEqual([workspaceRootKey('/agent/s_1')]); + }); + + it('hides nothing when a workspace has any non-ACP session', () => { + const roots = acpOnlyWorkspaceRoots([ + makeSession({ id: 'a', cwd: '/mixed', source: 'acp' }), + makeSession({ id: 'b', cwd: '/mixed' }), + ]); + expect(roots).toEqual([]); + }); + + it('never reports a root with no ACP session (no vacuous truth on empty/ACP-free sets)', () => { + expect(acpOnlyWorkspaceRoots([])).toEqual([]); + expect(acpOnlyWorkspaceRoots([makeSession({ id: 'a', cwd: '/repo' })])).toEqual([]); + }); + + it('skips sessions without a cwd', () => { + const roots = acpOnlyWorkspaceRoots([makeSession({ id: 'a', cwd: '', source: 'acp' })]); + expect(roots).toEqual([]); + }); +}); diff --git a/apps/kimi-web/src/lib/acpSessions.ts b/apps/kimi-web/src/lib/acpSessions.ts new file mode 100644 index 0000000000..cdcb499681 --- /dev/null +++ b/apps/kimi-web/src/lib/acpSessions.ts @@ -0,0 +1,39 @@ +// apps/kimi-web/src/lib/acpSessions.ts +// ACP-session filtering helpers. Sessions created via the ACP adapter (bot +// clients driving kimi-code headlessly) carry `source: 'acp'` in their +// persisted custom metadata, forwarded onto the wire session's `metadata`. +// When the user preference is on, these sessions (and workspaces that contain +// nothing else) are hidden from the sidebar. + +import type { AppSession } from '../api/types'; +import { workspaceRootKey } from './rootKey'; + +export function isAcpSession(session: AppSession): boolean { + return session.source === 'acp'; +} + +/** + * Folded root keys whose loaded sessions are ALL ACP-created (with at least + * one such session). Keyed via `workspaceRootKey`, matching how + * `mergeWorkspaces` applies `hiddenWorkspaceRoots`. + * + * Known limitation: sessions load paginated, so a mixed workspace whose + * non-ACP sessions are all beyond the loaded pages is reported here until + * those sessions load. Accepted — the preference toggle restores everything. + */ +export function acpOnlyWorkspaceRoots(sessions: AppSession[]): string[] { + const stats = new Map(); + for (const s of sessions) { + if (!s.cwd) continue; + const key = workspaceRootKey(s.cwd); + const entry = stats.get(key) ?? { acp: 0, other: 0 }; + if (isAcpSession(s)) entry.acp += 1; + else entry.other += 1; + stats.set(key, entry); + } + const roots: string[] = []; + for (const [key, entry] of stats) { + if (entry.acp > 0 && entry.other === 0) roots.push(key); + } + return roots; +} diff --git a/apps/kimi-web/src/lib/storage.ts b/apps/kimi-web/src/lib/storage.ts index 5cd60760c6..12d5c23f34 100644 --- a/apps/kimi-web/src/lib/storage.ts +++ b/apps/kimi-web/src/lib/storage.ts @@ -25,6 +25,7 @@ export const STORAGE_KEYS = { workspaceOrder: 'kimi-web.workspace-order', workspaceNameOverrides: 'kimi-web.workspace-name-overrides', workspaceSort: 'kimi-web.workspace-sort', + hideAcpSessions: 'kimi-web.hide-acp-sessions', // Conversation outline (TOC). The value keeps the legacy `beta-toc` name so // users who explicitly turned it off while it was experimental keep their // preference after it became on-by-default. diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 5ea1a8615c..9235ac7376 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -1258,6 +1258,103 @@ describe('useWorkspaceState — session list loading', () => { expect(deps.pushOperationFailure).toHaveBeenCalledWith('load', error); }); + function createAutoSelectRig(isSessionHiddenFromList?: (s: AppSession) => boolean) { + const state = createState(); + state.sessions = []; + state.activeSessionId = null; + const deps = { + ...createDeps(), + isSessionHiddenFromList, + modelProvider: { loadModels: vi.fn().mockResolvedValue(undefined) }, + initialized: ref(false), + connectIssue: ref(null), + setActiveSessionId: vi.fn((id: string | undefined) => { + state.activeSessionId = id ?? null; + }), + setSessions: vi.fn((next: AppSession[]) => { + state.sessions = next; + }), + workspaceIdForSession: vi.fn( + (session: { workspaceId?: string; cwd: string }) => + state.workspaces.find((item) => item.root === session.cwd)?.id ?? + session.workspaceId ?? + session.cwd, + ), + } as unknown as UseWorkspaceStateDeps; + return { state, workspaceState: useWorkspaceState(state, deps) }; + } + + it('fresh-load auto-select skips sessions hidden from the sidebar list', async () => { + const acp = { + ...createSession(), + id: 'sess_acp', + source: 'acp', + updatedAt: '2026-01-03T00:00:00.000Z', + }; + const human = { + ...createSession(), + id: 'sess_human', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + apiMock.listWorkspaces.mockResolvedValue([workspace('wd_ws', '/workspace', 'Workspace')]); + apiMock.listSessions.mockResolvedValue({ items: [acp, human], hasMore: false }); + const { state, workspaceState } = createAutoSelectRig((s) => s.source === 'acp'); + + await workspaceState.load(); + + // The ACP session is the most recent, but hidden — the user must land on + // the session the sidebar actually renders. + expect(state.activeSessionId).toBe('sess_human'); + }); + + it('fresh-load auto-selects nothing when every loaded session is hidden', async () => { + const acp = { ...createSession(), id: 'sess_acp', source: 'acp' }; + apiMock.listWorkspaces.mockResolvedValue([workspace('wd_ws', '/workspace', 'Workspace')]); + apiMock.listSessions.mockResolvedValue({ items: [acp], hasMore: false }); + const { state, workspaceState } = createAutoSelectRig((s) => s.source === 'acp'); + + await workspaceState.load(); + + expect(state.activeSessionId).toBeNull(); + }); + + it('fresh-load auto-select keeps picking the most recent session when nothing is hidden', async () => { + const first = { ...createSession(), id: 'sess_new', updatedAt: '2026-01-03T00:00:00.000Z' }; + const second = { ...createSession(), id: 'sess_old', updatedAt: '2026-01-02T00:00:00.000Z' }; + apiMock.listWorkspaces.mockResolvedValue([workspace('wd_ws', '/workspace', 'Workspace')]); + apiMock.listSessions.mockResolvedValue({ items: [first, second], hasMore: false }); + const { state, workspaceState } = createAutoSelectRig(); + + await workspaceState.load(); + + expect(state.activeSessionId).toBe('sess_new'); + }); + + it('openWorkspace activates the most recent sidebar-visible session in a mixed workspace', () => { + const acp = { + ...createSession(), + id: 'sess_acp', + source: 'acp', + workspaceId: 'wd_ws', + updatedAt: '2026-01-03T00:00:00.000Z', + }; + const human = { + ...createSession(), + id: 'sess_human', + workspaceId: 'wd_ws', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + const { state, workspaceState } = createAutoSelectRig((s) => s.source === 'acp'); + state.sessions = [acp, human]; + + workspaceState.openWorkspace('wd_ws'); + + // The ACP session is more recent but hidden; selectSession runs its + // synchronous activation before its first await, so the human session + // must already be active here. + expect(state.activeSessionId).toBe('sess_human'); + }); + it('keeps failed workspace sessions while replacing a successful shared-root workspace', async () => { const error = new Error('legacy workspace unavailable'); const cached = { diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index d0286d18db..90c378a8b2 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -136,6 +136,7 @@ function thinkingEnabledFromEffort(effort: unknown): boolean | undefined { export class AcpServer implements Agent { private negotiated: AcpVersionSpec | undefined; private clientCapabilities: ClientCapabilities | undefined; + private clientInfo: Implementation | undefined; private readonly sessions = new Map(); private readonly agentInfo: Implementation | undefined; private readonly terminalAuthEnv: Readonly> | undefined; @@ -220,6 +221,7 @@ export class AcpServer implements Agent { async initialize(params: InitializeRequest): Promise { this.negotiated = negotiateVersion(params.protocolVersion); this.clientCapabilities = params.clientCapabilities; + this.clientInfo = params.clientInfo ?? undefined; const agentCapabilities: AgentCapabilities = { loadSession: true, @@ -295,6 +297,17 @@ export class AcpServer implements Agent { kaos: acpKaos, persistenceKaos, sessionStartedProperties: { mode: 'new' }, + // Tag the session as ACP-created so surfaces like kimi-web can + // filter them out of the workspace sidebar. `metadata` lands in the + // persisted session `custom` map (agent-core `CreateSessionPayload`) + // and is forwarded verbatim onto the wire session's `metadata` by + // kap-server. Forked sessions inherit `custom` (minus `goal`), so + // the tag propagates to forks — it marks "created via ACP", not + // "currently driven by an ACP client" (`session/resume` never tags). + metadata: { + source: 'acp', + ...(this.clientInfo ? { acp_client: this.clientInfo.name } : {}), + }, // @ts-expect-error — `mcpServers` is a kernel-side extension // (agent-core `CreateSessionPayload`) the SDK transparently // forwards via spread. See block comment above. diff --git a/packages/acp-adapter/test/session-new.test.ts b/packages/acp-adapter/test/session-new.test.ts index 61220b462b..56d2636ff4 100644 --- a/packages/acp-adapter/test/session-new.test.ts +++ b/packages/acp-adapter/test/session-new.test.ts @@ -46,7 +46,12 @@ function makeInMemoryStreamPair(): { } interface CapturedCall { - options: { id?: string; workDir: string; mcpServers?: Record }; + options: { + id?: string; + workDir: string; + mcpServers?: Record; + metadata?: Record; + }; } function makeHarness( @@ -157,6 +162,42 @@ describe('AcpServer session/new', () => { expect(captured[1]?.options.id).toBe(second.sessionId); }); + it('tags created sessions as ACP-sourced via metadata, carrying clientInfo name when advertised', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-tagged', captured); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await client.initialize({ + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, + clientInfo: { name: 'kitty', version: '1.2.3' }, + }); + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + // The kernel persists `metadata` into the session's `custom` map; + // kap-server forwards it onto the wire session so surfaces like + // kimi-web can hide ACP-created workspaces. + expect(captured).toHaveLength(1); + expect(captured[0]?.options.metadata).toEqual({ source: 'acp', acp_client: 'kitty' }); + }); + + it('tags metadata with source only when the client sent no clientInfo', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-tagged-no-info', captured); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.options.metadata).toEqual({ source: 'acp' }); + }); + it('advertises configOptions (PLAN D11 + Phase 15 thinking toggle) — model + thinking + mode under the unified SessionConfigOption surface', async () => { const captured: CapturedCall[] = []; const { harness } = makeHarness('sess-modes', captured);