diff --git a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts index e9fd46f15a..780eaae806 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts @@ -36,7 +36,7 @@ import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.j type BotClient = RuntimeHostBotSessionAdapterDeps['client']; -test('creates an explore Session through the Host-owned default model route', async () => { +test('creates a bot-mode Session through the Host-owned default model route', async () => { const creates: unknown[] = []; const changes: unknown[] = []; const client = botClient({ @@ -68,7 +68,7 @@ test('creates an explore Session through the Host-owned default model route', as name: 'Telegram conversation', labels: ['bot', 'telegram'], modelTarget: { kind: 'default' }, - permissionMode: 'explore', + mode: 'bot', }, ]); assert.deepEqual(changes, [ diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts index a173957d97..26ae63728f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-ipc-main.test.ts @@ -19,8 +19,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { SessionCatalogProjection } from '@maka/runtime-host/protocol'; -import { toDesktopHostSessionSummary } from '../runtime-host-session-catalog-ipc-main.js'; +import type { IpcMain } from 'electron'; +import type { SessionCatalogProjection, SessionCreateInput } from '@maka/runtime-host/protocol'; +import { + registerRuntimeHostSessionCatalogIpc, + toDesktopHostSessionSummary, + type RuntimeHostSessionCatalogIpcDeps, +} from '../runtime-host-session-catalog-ipc-main.js'; test('maps Runtime Host live run state without collapsing unknown and known-empty', () => { const unknown = toDesktopHostSessionSummary(projection()); @@ -36,6 +41,58 @@ test('maps Runtime Host live run state without collapsing unknown and known-empt assert.deepEqual(running.runningTurnIds, ['turn-live']); }); +test('session creation forwards the caller name for a mode that carries none', async () => { + const creates: SessionCreateInput[] = []; + const ipc = ipcHarness(); + registerRuntimeHostSessionCatalogIpc(createDeps(creates), ipc as unknown as IpcMain); + + await ipc.invoke('sessions:create', { mode: 'bot', name: '飞书 任务' }); + await ipc.invoke('sessions:create', { mode: 'deep_research', name: '飞书 任务' }); + + assert.deepEqual( + creates.map((input) => [input.mode, input.name]), + [ + ['bot', '飞书 任务'], + ['deep_research', '飞书 任务'], + ], + ); +}); + +type IpcHandler = Parameters['handle']>[1]; + +function ipcHarness() { + const handlers = new Map(); + return { + handle(channel: string, handler: IpcHandler) { + handlers.set(channel, handler); + }, + async invoke(channel: string, ...args: unknown[]): Promise { + const handler = handlers.get(channel); + assert.ok(handler, `missing handler: ${channel}`); + return handler({} as never, ...args); + }, + }; +} + +function createDeps(creates: SessionCreateInput[]): RuntimeHostSessionCatalogIpcDeps { + return { + client: { + createSession: async (input: SessionCreateInput) => { + creates.push(input); + return projection({ id: input.sessionId }); + }, + } as unknown as RuntimeHostSessionCatalogIpcDeps['client'], + runningTurnIds: () => [], + resolveCreateProject: async () => ({ kind: 'host_path', path: '/workspace' }), + emitSessionsChanged: () => {}, + releaseSessionResources: () => {}, + sessionCopyCleanup: { + ownCreation: async (_creation: unknown, operation: () => Promise) => operation(), + recover: async () => ({ removed: [], failed: [] }), + } as unknown as RuntimeHostSessionCatalogIpcDeps['sessionCopyCleanup'], + }; +} + function projection(overrides: Partial = {}): SessionCatalogProjection { return { id: 'session-1', diff --git a/apps/desktop/src/main/create-session-input.ts b/apps/desktop/src/main/create-session-input.ts index b61ea0b931..d098cf9614 100644 --- a/apps/desktop/src/main/create-session-input.ts +++ b/apps/desktop/src/main/create-session-input.ts @@ -43,7 +43,7 @@ import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; -import type { SessionStartMode } from '@maka/core/deep-research'; +import type { SessionStartMode } from '@maka/core/session-start-mode'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; import { isChatDefaultPermissionMode } from '@maka/core/settings'; @@ -52,7 +52,7 @@ import { isCollaborationMode } from '@maka/core/collaboration'; import { isOrchestrationMode } from '@maka/core/orchestration'; -import { isSessionStartMode } from '@maka/core/deep-research'; +import { isSessionStartMode } from '@maka/core/session-start-mode'; /** * `unknown`, because this is an IPC boundary and the renderer's type is a diff --git a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts index e0d0f31a90..9f7fdfc275 100644 --- a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts +++ b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts @@ -76,7 +76,7 @@ export function createRuntimeHostBotSessionAdapter( name: input.name, labels: [...input.labels], modelTarget: { kind: 'default' }, - permissionMode: 'explore', + mode: 'bot', }); } catch (error) { if ( diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index 1c9325ec0d..94afaa8730 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -125,7 +125,8 @@ export function registerRuntimeHostSessionCatalogIpc( sessionId: newId(), workspace, ...(request.mode === undefined ? {} : { mode: request.mode }), - ...(request.mode === undefined ? { name: request.name } : {}), + // A nameless mode (`bot`) keeps the caller's name, so always forward it. + name: request.name, ...(request.labels === undefined ? {} : { labels: request.labels }), modelTarget: normalizeModelTarget(input), ...normalizeCreateThinkingLevel(input?.thinkingLevel), diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 0cfcb58143..90ac526ac6 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -20,7 +20,7 @@ import { useMemo, useRef } from "react"; import type { LlmConnection } from '@maka/core/llm-connections'; import type { PermissionMode } from '@maka/core/permission'; -import type { SessionStartMode } from '@maka/core/deep-research'; +import type { SessionStartMode } from '@maka/core/session-start-mode'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; import type { SettingsSection, ThemePreference } from '@maka/core/settings'; import type { UiLocale } from '@maka/core/ui-locale'; diff --git a/apps/desktop/src/renderer/app-shell-session-start-actions.ts b/apps/desktop/src/renderer/app-shell-session-start-actions.ts index 344ebbd502..14fd92a0d2 100644 --- a/apps/desktop/src/renderer/app-shell-session-start-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-start-actions.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { SessionStartMode } from '@maka/core/deep-research'; +import type { SessionStartMode } from '@maka/core/session-start-mode'; import type { UiLocale } from '@maka/core/ui-locale'; import type { NavSelection } from '@maka/ui'; import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; diff --git a/packages/core/package.json b/packages/core/package.json index bab15ee8f4..c935846b49 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -75,6 +75,7 @@ "./daily-review": "./dist/daily-review.js", "./work-board": "./dist/work-board.js", "./deep-research": "./dist/deep-research.js", + "./session-start-mode": "./dist/session-start-mode.js", "./long-term-memory": "./dist/long-term-memory.js", "./local-memory": "./dist/local-memory.js", "./web-search": "./dist/web-search.js", diff --git a/packages/core/src/deep-research.ts b/packages/core/src/deep-research.ts index 9f480ed920..3088a90fee 100644 --- a/packages/core/src/deep-research.ts +++ b/packages/core/src/deep-research.ts @@ -20,47 +20,7 @@ /** Read-only Deep Research session semantics and prompt contract. */ import type { DeepResearchRun } from './deep-research-run.js'; -import type { PermissionMode } from './permission.js'; - -/** - * A product intent a caller can open a new session at, distinct from the - * ordinary chat that needs no intent at all. Absence is spelled `undefined`, - * not a `'chat'` member: a second spelling of "no mode" is a second thing to - * keep in agreement. - */ -export interface SessionStartModeSpec { - readonly name: string; - readonly labels: readonly string[]; - readonly permissionMode: PermissionMode; -} - -export const SESSION_START_MODE_SPECS = { - deep_research: { - name: 'Deep Research', - labels: ['mode:deep_research'], - permissionMode: 'explore', - }, -} as const satisfies Record; - -export type SessionStartMode = keyof typeof SESSION_START_MODE_SPECS; -export const SESSION_START_MODES: readonly SessionStartMode[] = Object.keys( - SESSION_START_MODE_SPECS, -) as SessionStartMode[]; -export const SESSION_START_MODE_LABELS: readonly string[] = [ - ...new Set(Object.values(SESSION_START_MODE_SPECS).flatMap((spec) => spec.labels)), -]; - -export function isSessionStartMode(value: unknown): value is SessionStartMode { - return typeof value === 'string' && Object.hasOwn(SESSION_START_MODE_SPECS, value); -} - -export function sessionStartModeSpec(mode: SessionStartMode): SessionStartModeSpec { - return SESSION_START_MODE_SPECS[mode]; -} - -export function isSessionStartModeLabel(value: unknown): value is string { - return typeof value === 'string' && SESSION_START_MODE_LABELS.includes(value); -} +import { SESSION_START_MODE_SPECS } from './session-start-mode.js'; export const DEEP_RESEARCH_SESSION_NAME = SESSION_START_MODE_SPECS.deep_research.name; export const DEEP_RESEARCH_SESSION_LABEL = SESSION_START_MODE_SPECS.deep_research.labels[0]; diff --git a/packages/core/src/runtime-inputs.ts b/packages/core/src/runtime-inputs.ts index a3ac133ed2..b104f44346 100644 --- a/packages/core/src/runtime-inputs.ts +++ b/packages/core/src/runtime-inputs.ts @@ -34,7 +34,7 @@ import type { PermissionMode } from './permission.js'; import type { ThinkingLevel } from './model-thinking.js'; import type { CollaborationMode } from './collaboration.js'; import type { OrchestrationMode, TurnOrchestration } from './orchestration.js'; -import type { SessionStartMode } from './deep-research.js'; +import type { SessionStartMode } from './session-start-mode.js'; import type { SubagentWorkspaceBinding } from './subagent-workspace.js'; import type { ToolMode } from './tool-mode.js'; import type { TurnOrigin } from './turn-origin.js'; diff --git a/packages/core/src/session-start-mode.ts b/packages/core/src/session-start-mode.ts new file mode 100644 index 0000000000..b90dc13859 --- /dev/null +++ b/packages/core/src/session-start-mode.ts @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { PermissionMode } from './permission.js'; + +/** + * A product intent a caller can open a new session at, distinct from the + * ordinary chat that needs no intent at all. Absence is spelled `undefined`, + * not a `'chat'` member: a second spelling of "no mode" is a second thing to + * keep in agreement. + */ +export interface SessionStartModeSpec { + /** Omitted where the mode keeps the caller's name, as `bot` does. */ + readonly name?: string; + readonly labels: readonly string[]; + readonly permissionMode: PermissionMode; +} + +export const SESSION_START_MODE_SPECS = { + deep_research: { + name: 'Deep Research', + labels: ['mode:deep_research'], + permissionMode: 'explore', + }, + bot: { + labels: ['mode:bot'], + permissionMode: 'explore', + }, +} as const satisfies Record; + +export type SessionStartMode = keyof typeof SESSION_START_MODE_SPECS; +export const SESSION_START_MODES: readonly SessionStartMode[] = Object.keys( + SESSION_START_MODE_SPECS, +) as SessionStartMode[]; +export const SESSION_START_MODE_LABELS: readonly string[] = [ + ...new Set(Object.values(SESSION_START_MODE_SPECS).flatMap((spec) => spec.labels)), +]; + +export function isSessionStartMode(value: unknown): value is SessionStartMode { + return typeof value === 'string' && Object.hasOwn(SESSION_START_MODE_SPECS, value); +} + +export function sessionStartModeSpec(mode: SessionStartMode): SessionStartModeSpec { + return SESSION_START_MODE_SPECS[mode]; +} + +export function isSessionStartModeLabel(value: unknown): value is string { + return typeof value === 'string' && SESSION_START_MODE_LABELS.includes(value); +} diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index c02dde9c3c..614998f366 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -921,6 +921,40 @@ test('creation materializes Deep Research semantics inside the Host transaction' assert.equal(fixture.drainRequests(), 0); }); +test('bot mode grants explore while keeping the Bot-supplied Session name', async () => { + let created: Parameters[0] | undefined; + const fixture = createFixture({ + stores: { + createStableSession: async (request) => { + created = request; + return { + kind: 'existing', + record: headerSnapshot(sessionHeader(request.sessionId, request.input.labels ?? []), 3), + }; + }, + }, + }); + + const outcome = await fixture.coordinator.handlers['session.create']( + { + sessionId: fixture.sessionId, + workspace: { kind: 'host_path', path: process.cwd() }, + mode: 'bot', + name: '飞书 任务', + labels: ['bot', 'feishu'], + modelTarget: { kind: 'default' }, + }, + context, + ); + + assert.equal(outcome.ok, true); + assert.ok(created); + assert.equal(created.input.name, '飞书 任务'); + assert.deepEqual(created.input.labels, ['bot', 'feishu', 'mode:bot']); + assert.equal(created.input.permissionMode, 'explore'); + assert.equal(fixture.drainRequests(), 0); +}); + test('configuration update admits Plan mode through Runtime authority', async () => { const fixture = createFixture(); const input = configurationInput(fixture.sessionId, fixture.revision()); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 5c208bf3d8..c52c7b5e5a 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -94,7 +94,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 68 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 69 as const; // 68: Connection onboarding replaces nullable canonical-slug targeting with // explicit create/existing identity and returns the committed Connection. // Older peers reject the closed target and saved-result shapes. diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts index 62c5309a48..dec7244ff6 100644 --- a/packages/runtime-host/src/protocol/session-catalog.ts +++ b/packages/runtime-host/src/protocol/session-catalog.ts @@ -20,7 +20,7 @@ import { isCollaborationMode, type CollaborationMode } from '@maka/core/collaboration'; import { isOrchestrationMode, type OrchestrationMode } from '@maka/core/orchestration'; import { isPermissionMode, type PermissionMode } from '@maka/core/permission'; -import { isSessionStartMode, type SessionStartMode } from '@maka/core/deep-research'; +import { isSessionStartMode, type SessionStartMode } from '@maka/core/session-start-mode'; import { isSessionBlockedReason, isSessionToolProfile, diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 10dd15752b..937a64ce44 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -32,7 +32,7 @@ import { DEFAULT_SESSION_NAME, normalizeUserSessionName } from '@maka/core/sessi import { isSessionStartModeLabel as isExecutionSemanticLabel, sessionStartModeSpec, -} from '@maka/core/deep-research'; +} from '@maka/core/session-start-mode'; import { isWorkHubCoordinationSessionId, isWorkHubCoordinationSessionTarget,