You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/10/19 16:24:48 UTC
(maka) branch fix/assistant-host-project created (now 7388f72397)
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a change to branch fix/assistant-host-project
in repository https://gitbox.apache.org/repos/asf/maka.git
at 7388f72397 fix(desktop): resolve assistant projects on Host-owned workspaces
This branch includes the following new commits:
new 7388f72397 fix(desktop): resolve assistant projects on Host-owned workspaces
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/01: fix(desktop): resolve assistant projects on Host-owned workspaces
Posted by zh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch fix/assistant-host-project
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 7388f7239721e73f9824c831b7edf719f7d33db7
Author: me2seeks <me...@gmail.com>
AuthorDate: Tue Oct 20 00:24:23 2026 +0800
fix(desktop): resolve assistant projects on Host-owned workspaces
---
.../__tests__/personal-assistant-project.test.ts | 158 +++++++++++++++++++++
.../runtime-host-new-task-preload.test.ts | 40 +++++-
apps/desktop/src/preload/bridge-contract.d.ts | 2 +-
apps/desktop/src/preload/preload.ts | 4 +-
.../features/personal-assistant/navigation.ts | 4 +-
.../features/personal-assistant/services.ts | 2 +-
.../platform/desktop/personal-assistant.ts | 26 +++-
7 files changed, 222 insertions(+), 14 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/personal-assistant-project.test.ts b/apps/desktop/src/main/__tests__/personal-assistant-project.test.ts
new file mode 100644
index 0000000000..e0a67cb885
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/personal-assistant-project.test.ts
@@ -0,0 +1,158 @@
+/*
+ * 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 assert from 'node:assert/strict';
+import test, { type TestContext } from 'node:test';
+import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs';
+import type { ProjectRecord } from '@maka/core/project';
+import type { DesktopNewTaskCatalog, DesktopNewTaskTarget, DesktopRuntimeHostRef } from '../../preload/bridge-contract.js';
+import { createDesktopPersonalAssistantPort } from '../../renderer/platform/desktop/personal-assistant.js';
+import { createAssistantNavigation } from '../../renderer/features/personal-assistant/testing.js';
+import { resolveDesktopSessionWorkspace } from '../new-session-project.js';
+
+const host = { hostId: 'host-a', profileId: 'profile-a' };
+const project = (id: string, extra: Partial<ProjectRecord> = {}): ProjectRecord => ({
+ id, name: id, locations: [], available: true, ...extra,
+});
+function catalog(
+ kind: 'local' | 'environment' | 'remote',
+ projects: ProjectRecord[],
+ preferences: { defaultProjectId?: string; selectedProjectId?: string | null } = {},
+): DesktopNewTaskCatalog {
+ return {
+ defaultProfileId: host.profileId,
+ hosts: [{
+ profile: { id: host.profileId, kind } as never,
+ hostId: host.hostId, readiness: 'ready', state: 'available', projects,
+ selectedProjectId: undefined, ...preferences,
+ capabilities: {
+ chooseClientDirectory: kind === 'local', chooseHostDirectory: kind !== 'local',
+ selectNoProject: kind === 'local', setLocalDefault: kind === 'local',
+ viewClientPath: kind === 'local',
+ },
+ chatDefaults: {} as never,
+ }],
+ };
+}
+function setup(t: TestContext, read: () => Promise<DesktopNewTaskCatalog>) {
+ const calls: DesktopNewTaskTarget[] = [];
+ let workspace: unknown;
+ const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
+ const create = async (input: CreateSessionRequestInput, ownerHost: DesktopRuntimeHostRef = host) => {
+ const target = { ...ownerHost, projectId: input.projectId as string | null };
+ calls.push(target);
+ assert.equal(input.name, '个人助手');
+ assert.deepEqual(input.labels, ['personal-assistant']);
+ const current = await read();
+ const owner = current.hosts.find(h => h.profile.id === target.profileId);
+ workspace = await resolveDesktopSessionWorkspace(
+ { projectId: target.projectId },
+ { current: async () => ({ projectId: undefined, path: '/client/fallback' }), select: unexpected },
+ { register: unexpected },
+ {
+ allowHostPath: owner?.profile.kind === 'local',
+ allocateDedicatedDirectory: async () => '/tasks/assistant',
+ },
+ );
+ return { id: JSON.stringify([target.hostId, 'assistant']) };
+ };
+ Object.defineProperty(globalThis, 'window', { configurable: true, value: { maka: {
+ newTasks: { getCatalog: read, create: unexpected }, sessions: { create },
+ } } });
+ t.after(() => {
+ if (previous) Object.defineProperty(globalThis, 'window', previous);
+ else Reflect.deleteProperty(globalThis, 'window');
+ });
+ return { port: createDesktopPersonalAssistantPort(), calls, workspace: () => workspace };
+}
+
+for (const kind of ['environment', 'remote'] as const) {
+ test(`assistant opens on ${kind} with available projects and no persisted selection`, async t => {
+ const { port, calls } = setup(t, async () => catalog(kind, [
+ project('offline', { available: false }), project('archived', { archivedAt: 1 }),
+ project('usable'), project('other'),
+ ]));
+ let binding: { sessionId: string } | null = null;
+ const navigation = createAssistantNavigation({
+ host: async () => host,
+ binding: async () => ({ state: binding }),
+ create: port.createSession,
+ bind: async (target, sessionId) => {
+ assert.deepEqual(target, host);
+ return binding = { sessionId };
+ },
+ list: async () => [{ id: JSON.stringify([host.hostId, 'assistant']) }],
+ });
+ assert.equal(await navigation.open(), JSON.stringify([host.hostId, 'assistant']));
+ assert.equal(await navigation.open(), JSON.stringify([host.hostId, 'assistant']));
+ assert.deepEqual(calls, [{ ...host, projectId: 'usable' }]);
+ });
+}
+
+test('assistant preserves usable default/current preferences and resolves project aliases', async t => {
+ let preferences: { defaultProjectId?: string; selectedProjectId?: string | null } = {
+ defaultProjectId: 'old-default', selectedProjectId: 'current',
+ };
+ const { port, calls } = setup(t, async () => catalog('remote', [
+ project('first'), project('default', { aliases: ['old-default'] }), project('current'),
+ project('archived', { archivedAt: 1 }),
+ ], preferences));
+ await port.createSession(host);
+ preferences = { defaultProjectId: 'archived', selectedProjectId: 'current' };
+ await port.createSession(host);
+ preferences = { selectedProjectId: 'missing' };
+ await port.createSession(host);
+ assert.deepEqual(calls.map(c => c.projectId), ['default', 'current', 'first']);
+});
+
+test('local assistant keeps its dedicated no-project directory even with available projects', async t => {
+ const fixture = setup(t, async () => catalog('local', [project('first')], {
+ selectedProjectId: null,
+ }));
+ await fixture.port.createSession(host);
+ assert.deepEqual(fixture.workspace(), { kind: 'host_path', path: '/tasks/assistant' });
+ assert.deepEqual(fixture.calls, [{ ...host, projectId: null }]);
+});
+
+test('Host without usable projects reports an actionable error without creating a session', async t => {
+ const { port, calls } = setup(t, async () => catalog('remote', [
+ project('offline', { available: false }), project('archived', { archivedAt: 0 }),
+ ]));
+ await assert.rejects(port.createSession(host), /添加可用项目/);
+ assert.deepEqual(calls, []);
+});
+
+test('default Host changes during project lookup do not redirect assistant creation', async t => {
+ const { port, calls } = setup(t, async () => ({
+ ...catalog('environment', [project('original')]), defaultProfileId: 'other-profile',
+ }));
+ await port.createSession(host);
+ assert.deepEqual(calls, [{ ...host, projectId: 'original' }]);
+});
+
+test('a replaced Host identity never borrows the replacement project', async t => {
+ const value = catalog('remote', [project('replacement')]);
+ const { port, calls } = setup(t, async () => ({
+ ...value, hosts: value.hosts.map(entry => ({ ...entry, hostId: 'replacement-host' })),
+ }));
+ await assert.rejects(port.createSession(host), /Host/);
+ assert.deepEqual(calls, []);
+});
+
+async function unexpected(): Promise<never> { throw Error('Unexpected call'); }
diff --git a/apps/desktop/src/main/__tests__/runtime-host-new-task-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-new-task-preload.test.ts
index af0bdb8364..2024b2d195 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-new-task-preload.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-new-task-preload.test.ts
@@ -26,15 +26,16 @@ import test from 'node:test';
import { build } from 'esbuild';
import type { MakaBridge } from '../../preload/bridge-contract.js';
-async function loadBridge(changeDuringRead: 'guest' | 'owner') {
+async function loadBridge(changeDuringRead: 'guest' | 'owner' | 'creation') {
const owner = {
hostId: 'local-host', targetEpoch: 'local-epoch', profileId: 'local',
profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready',
};
const guest = {
hostId: 'shared-host', targetEpoch: 'shared-epoch', profileId: 'shared',
- profileName: 'Shared', profileKind: 'remote', profileAccess: 'session_guest',
- readiness: 'reconnecting',
+ profileName: 'Shared', profileKind: 'remote',
+ profileAccess: changeDuringRead === 'creation' ? 'owner' : 'session_guest',
+ readiness: changeDuringRead === 'creation' ? 'ready' : 'reconnecting',
};
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
const emit = (channel: string, value: unknown) => {
@@ -43,6 +44,7 @@ async function loadBridge(changeDuringRead: 'guest' | 'owner') {
let catalogReads = 0;
let projectReads = 0;
const scopedCalls: string[] = [];
+ const creations: { scope: unknown; input: unknown }[] = [];
const ipcRenderer = {
on(channel: string, listener: (...args: unknown[]) => void) {
const handlers = listeners.get(channel) ?? new Set();
@@ -53,12 +55,16 @@ async function loadBridge(changeDuringRead: 'guest' | 'owner') {
listeners.get(channel)?.delete(listener);
},
send() {},
- async invoke(channel: string, scope?: { hostId?: string }) {
+ async invoke(channel: string, scope?: { hostId?: string }, input?: unknown) {
if (scope?.hostId) {
scopedCalls.push(scope.hostId);
- assert.equal(scope.hostId, owner.hostId, 'Guest reconnects must not redirect Owner reads');
+ if (changeDuringRead !== 'creation')
+ assert.equal(scope.hostId, owner.hostId, 'Guest reconnects must not redirect Owner reads');
}
switch (channel) {
+ case 'sessions:create':
+ creations.push({ scope, input });
+ return { id: 'created', name: 'Assistant', labels: [], activityAt: 1 };
case 'runtime-host:identities': return [
{ ...owner, epoch: owner.targetEpoch, isDefault: true },
{ ...guest, epoch: guest.targetEpoch, isDefault: false },
@@ -71,8 +77,8 @@ async function loadBridge(changeDuringRead: 'guest' | 'owner') {
entries: [
{ profile: { id: 'local', name: 'Local', kind: 'local' },
hostId: owner.hostId, enabled: true, readiness: 'ready' },
- { profile: { id: 'shared', name: 'Shared', kind: 'remote', access: 'session_guest' },
- hostId: guest.hostId, enabled: true, readiness: 'reconnecting' },
+ { profile: { id: 'shared', name: 'Shared', kind: 'remote', access: guest.profileAccess },
+ hostId: guest.hostId, enabled: true, readiness: guest.readiness },
],
};
case 'projects:getSnapshot':
@@ -127,6 +133,7 @@ async function loadBridge(changeDuringRead: 'guest' | 'owner') {
bridge,
catalogReads: () => catalogReads,
scopedCalls,
+ creations,
emitOwnerCatalogChanged: () => emit('external-agents:catalog-changed', owner),
};
}
@@ -171,3 +178,22 @@ test('an Owner replacement still invalidates the new-task catalog', async () =>
assert.equal(catalogReads(), 2);
assert.equal(catalog.hosts[0]?.readiness, 'ready');
});
+
+test('Host-scoped session creation reaches the Host immediately instead of staging a local task', async () => {
+ const { bridge, creations } = await loadBridge('creation');
+ const input = { name: '个人助手', projectId: 'remote-project', labels: ['personal-assistant'] };
+ const created = await bridge.sessions.create(input, { profileId: 'shared', hostId: 'shared-host' });
+ assert.equal(created.id, JSON.stringify(['shared-host', 'created']));
+ assert.equal(creations.length, 1);
+ assert.deepEqual(JSON.parse(JSON.stringify(creations[0])), {
+ scope: { hostId: 'shared-host', targetEpoch: 'shared-epoch' }, input,
+ });
+ await assert.rejects(
+ bridge.sessions.create(input, { profileId: 'shared', hostId: 'replaced-host' }),
+ /no longer available/,
+ );
+ assert.equal(creations.length, 1);
+ const local = await bridge.sessions.create();
+ assert.equal(local.id, JSON.stringify(['local-host', 'created']));
+ assert.equal(creations.length, 2);
+});
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 578dde8aa0..459e0781bc 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -1180,7 +1180,7 @@ export interface MakaBridge {
sessions: DesktopSessionSummary[];
completeHostIds: string[];
}>;
- create(input?: CreateSessionRequestInput): Promise<DesktopSessionSummary>;
+ create(input?: CreateSessionRequestInput, host?: DesktopRuntimeHostRef): Promise<DesktopSessionSummary>;
send(
sessionId: string,
command: {
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index a46dbc0a50..b1c160170d 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -2333,8 +2333,8 @@ const makaBridge = {
* labels it implies (`create-session-input.ts`); the renderer cannot
* reach a boundary like `explore` by asking for it directly.
*/
- async create(input?: CreateSessionRequestInput): Promise<DesktopSessionSummary> {
- const scope = await activeRuntimeHostRef();
+ async create(input?: CreateSessionRequestInput, host?: DesktopRuntimeHostRef): Promise<DesktopSessionSummary> {
+ const scope = await selectedRuntimeHostScope(host);
return createDesktopSessionOnScope(scope, input);
},
async send(sessionId, command) {
diff --git a/apps/desktop/src/renderer/features/personal-assistant/navigation.ts b/apps/desktop/src/renderer/features/personal-assistant/navigation.ts
index 16696d7167..9d86bd5070 100644
--- a/apps/desktop/src/renderer/features/personal-assistant/navigation.ts
+++ b/apps/desktop/src/renderer/features/personal-assistant/navigation.ts
@@ -25,7 +25,7 @@ export interface AssistantNavigationPorts {
host(): Promise<AssistantHost>;
binding(host: AssistantHost): Promise<{ state: null | { sessionId: string } }>;
bind(host: AssistantHost, id: string): Promise<{ sessionId: string }>;
- create(): Promise<{ id: string }>;
+ create(host: AssistantHost): Promise<{ id: string }>;
list(): Promise<{ id: string }[]>;
}
export const identity = (id: string): [string | null, string] => {
@@ -50,7 +50,7 @@ export function createAssistantNavigation(ports: AssistantNavigationPorts) {
if (!state) {
let candidate = candidates.get(key);
if (!candidate) {
- const session = await ports.create();
+ const session = await ports.create(host);
candidate = session.id;
if (identity(candidate)[0] && identity(candidate)[0] !== host.hostId)
throw Error('会话所在 Host 已变化,请重新打开个人助手。');
diff --git a/apps/desktop/src/renderer/features/personal-assistant/services.ts b/apps/desktop/src/renderer/features/personal-assistant/services.ts
index f38a265835..fa21e46aa8 100644
--- a/apps/desktop/src/renderer/features/personal-assistant/services.ts
+++ b/apps/desktop/src/renderer/features/personal-assistant/services.ts
@@ -25,7 +25,7 @@ export interface PersonalAssistantPort {
call(method: PersonalAssistantRequest['method'], input?: unknown, host?: AssistantHost): Promise<any>;
defaultHost(): Promise<AssistantHost>;
hostSnapshot(): Promise<{ entries: ReadonlyArray<{ profile: { id: string }; hostId?: string; enabled: boolean }> }>;
- createSession(): Promise<{ id: string }>;
+ createSession(host: AssistantHost): Promise<{ id: string }>;
listSessions(): Promise<Array<{ id: string }>>;
}
export function createPersonalAssistantServices(port: PersonalAssistantPort) {
diff --git a/apps/desktop/src/renderer/platform/desktop/personal-assistant.ts b/apps/desktop/src/renderer/platform/desktop/personal-assistant.ts
index b8c3c36a10..9c033925b2 100644
--- a/apps/desktop/src/renderer/platform/desktop/personal-assistant.ts
+++ b/apps/desktop/src/renderer/platform/desktop/personal-assistant.ts
@@ -17,6 +17,7 @@
* under the License.
*/
+import { findProjectByIdentity } from '@maka/core/project';
import type { PersonalAssistantPort } from '../../features/personal-assistant/index.js';
export function createDesktopPersonalAssistantPort(): PersonalAssistantPort {
@@ -27,7 +28,30 @@ export function createDesktopPersonalAssistantPort(): PersonalAssistantPort {
},
defaultHost: () => window.maka.runtimeHostProfiles.getDefaultHost(),
hostSnapshot: () => window.maka.runtimeHostProfiles.getSnapshot(),
- createSession: () => window.maka.sessions.create({ name: '个人助手', labels: ['personal-assistant'] }),
+ createSession: async (host) => {
+ const catalog = await window.maka.newTasks.getCatalog();
+ const target = catalog.hosts.find(
+ (entry) => entry.profile.id === host.profileId &&
+ entry.readiness === 'ready' && entry.hostId === host.hostId,
+ );
+ if (!target || target.readiness !== 'ready') throw Error('请先连接 Host。');
+ if (target.state === 'error') throw Error(target.message);
+ const usable = target.projects.filter(
+ (project) => project.available && project.archivedAt === undefined,
+ );
+ const preferred = [target.defaultProjectId, target.selectedProjectId]
+ .map((id) => typeof id === 'string' ? findProjectByIdentity(usable, id) : undefined)
+ .find((project) => project !== undefined);
+ const projectId = preferred?.id ??
+ (target.capabilities.selectNoProject ? null : usable[0]?.id);
+ if (projectId === undefined) {
+ throw Error('请先在设置中为此 Host 添加可用项目,再打开个人助手。');
+ }
+ return window.maka.sessions.create(
+ { name: '个人助手', labels: ['personal-assistant'], projectId },
+ host,
+ );
+ },
listSessions: () => window.maka.sessions.list(),
};
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org