Skip to content
Open
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
77 changes: 77 additions & 0 deletions apps/desktop/e2e/composer-directory-reference.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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 { COMPOSER_INPUT, expect, test } from './fixtures';

test('a folder reference is removable, survives send/reload, and leaves project selection unchanged', async ({
directoryReferenceWindow: { page, folder },
}, testInfo) => {
const composer = page.locator(COMPOSER_INPUT);
const project = page.locator('button.maka-workspace-picker');
// The composer can mount before TaskEntry loads the initial project selection.
// Compare the settled selection, not the generic label shown during loading.
const originalProject = '选择项目:无项目';
await expect(project).toHaveAttribute('aria-label', originalProject);
const pick = async (keyboard = false) => {
const trigger = page.locator('.maka-composer-plus-menu button').first();
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
if (keyboard) {
// Exercise keyboard reopening as well. Astryx intentionally ignores pointer
// reopening within 50ms of dismiss; the native chooser mock returns instantly.
await trigger.press('ArrowDown');
} else {
await trigger.click();
}
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
await page.getByRole('menuitem', { name: '引用文件夹', exact: true }).click();
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
};

await pick();
const chip = page.locator('.maka-composer-context-drawer .maka-composer-attachment-token');
await expect(chip).toContainText('referenced-source');
await chip.getByRole('button').click();
await expect(chip).toHaveCount(0);
await pick(true);
await expect(chip).toContainText('referenced-source');
await expect(project).toHaveAttribute('aria-label', originalProject);
await composer.fill('请检查引用目录');
await page.screenshot({ path: testInfo.outputPath('directory-reference-staged.png') });
await composer.press('Enter');

const user = page.getByLabel('你发送的消息').first();
await expect(user).toContainText('请检查引用目录');
await expect(user).toContainText('referenced-source');
await expect(user).not.toContainText('README.md');
const transcript = page.getByRole('log');
await expect(transcript).toContainText('README.md');
await expect(transcript).toContainText('"status":"listed"');
await expect(transcript).not.toContainText('DO_NOT_READ_FILE_CONTENTS');
await expect(transcript).not.toContainText('deep.txt');
await expect(chip).toHaveCount(0);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });

const sessions = await page.evaluate(() => window.maka.sessions.list());
expect(sessions).toHaveLength(1);
expect(sessions[0]!.cwd).not.toBe(folder);
await page.reload();
await expect(page.getByLabel('你发送的消息').first()).toContainText('referenced-source');
await expect(page.getByRole('log')).toContainText('README.md');
await page.screenshot({ path: testInfo.outputPath('directory-reference-sent.png') });
});
22 changes: 20 additions & 2 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ async function withE2eWindow(
parentRemovalSessions?: boolean;
newTaskProject?: boolean;
},
use: (page: Page, context: { userDataDir: string }) => Promise<void>,
use: (page: Page, context: { userDataDir: string; app: ElectronApplication }) => Promise<void>,
): Promise<void> {
const userDataDir = await mkdtemp(path.join(tmpdir(), 'maka-e2e-'));
// Lives inside the throwaway userData dir so the existing teardown removes
Expand Down Expand Up @@ -437,7 +437,7 @@ async function withE2eWindow(
const rendererDetail = rendererLogs.length > 0 ? `\nRenderer console:\n${rendererLogs.join('\n')}` : '';
throw new Error(`${detail}${mainDetail}${rendererDetail}`, { cause: error });
}
await use(page, { userDataDir });
await use(page, { userDataDir, app });
} finally {
try {
if (app) await closeElectronApplication(app, 5_000);
Expand All @@ -459,7 +459,25 @@ export const test = base.extend<{
promptRailMotionWindow: Page;
requestHeaderRowWindow: Page;
newTaskTargetWindow: Page;
directoryReferenceWindow: { page: Page; folder: string };
}>({
directoryReferenceWindow: async ({}, use) => {
await withE2eWindow(
{ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh', showWindow: true },
async (page, { userDataDir, app }) => {
const folder = path.join(userDataDir, 'referenced-source');
await mkdir(path.join(folder, 'nested'), { recursive: true });
await writeFile(path.join(folder, 'README.md'), 'DO_NOT_READ_FILE_CONTENTS');
await writeFile(path.join(folder, 'nested', 'deep.txt'), 'DO_NOT_DESCEND');
// Replace only the OS chooser. IPC, Host admission, filesystem reads,
// event persistence and rendering still run through the real stack.
await app.evaluate(({ dialog }, selectedPath) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selectedPath] });
}, folder);
await use({ page, folder });
},
);
},
// Seeded: a pre-staged connection clears onboarding so the composer is ready.
window: async ({}, use) => {
await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh' }, use);
Expand Down
133 changes: 133 additions & 0 deletions apps/desktop/src/main/__tests__/composer-directories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* 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 { afterEach, test } from 'node:test';
import { act, createElement } from 'react';
import { LocaleProvider } from '@maka/ui';
import { normalizeSessionSendCommand } from '../permission-response-guard.js';
import { useComposerDirectories } from '../../renderer/use-composer-directories.js';
import { cleanupFakeDom, installReactRenderer } from './fake-dom.js';

afterEach(cleanupFakeDom);

type Options = Parameters<typeof useComposerDirectories>[0];
type State = ReturnType<typeof useComposerDirectories>;
const reference = { hostId: 'host-a', path: '/workspace/source' };

async function mount(initial: Partial<Options> = {}) {
const { root } = installReactRenderer();
let state!: State;
const errors: string[] = [];
let options: Options = {
draftKey: 'draft-a', hostId: 'host-a',
pick: async () => ({ ok: true, reference }),
toastApi: { error: (title, description) => errors.push(description ?? title) },
...initial,
};
function Probe() {
state = useComposerDirectories(options);
return null;
}
const render = async (patch: Partial<Options> = {}) => {
options = { ...options, ...patch };
await act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: createElement(Probe) })));
};
await render();
return { state: () => state, render, errors };
}

test('directory picker cancellation, duplicates and removal leave the draft consistent', async () => {
const probe = await mount({ pick: async () => ({ ok: false, reason: 'cancelled' }) });
await act(() => probe.state().pickDirectory!());
assert.deepEqual(probe.state().pendingDirectories, []);
await probe.render({ pick: async () => ({ ok: true, reference }) });
await act(() => probe.state().pickDirectory!());
await act(() => probe.state().pickDirectory!());
assert.deepEqual(probe.state().pendingDirectories, [reference]);
await act(() => probe.state().removeDirectory(0));
assert.deepEqual(probe.state().pendingDirectories, []);
assert.deepEqual(probe.errors, []);
});

test('discards a picker reply after its draft or Host changes', async () => {
for (const patch of [{ draftKey: 'draft-b' }, { hostId: 'host-b' }]) {
let resolve!: (result: Awaited<ReturnType<Options['pick']>>) => void;
const pending = new Promise<Awaited<ReturnType<Options['pick']>>>((settle) => { resolve = settle; });
const probe = await mount({ pick: () => pending });
let picked!: Promise<void>;
await act(() => { picked = probe.state().pickDirectory!(); });
await probe.render(patch);
await act(async () => { resolve({ ok: true, reference }); await picked; });
assert.deepEqual(probe.state().pendingDirectories, []);
}
});

test('rejects a foreign Host picker result and does not pick without a local Host', async () => {
let picks = 0;
const probe = await mount({ hostId: undefined, pick: async () => {
picks += 1;
return { ok: true, reference: { ...reference, hostId: 'host-b' } };
} });
await act(() => probe.state().pickDirectory!());
assert.equal(picks, 0);
await probe.render({ hostId: 'host-a' });
await act(() => probe.state().pickDirectory!());
assert.equal(probe.errors.length, 1);
assert.deepEqual(probe.state().pendingDirectories, []);
});

test('caps concurrent picker results and clearing a submitted draft keeps newer references', async () => {
let sequence = 0;
const probe = await mount({ pick: async () => ({
ok: true, reference: { ...reference, path: '/workspace/' + ++sequence },
}) });
const pick = probe.state().pickDirectory!;
await act(() => Promise.all(Array.from({ length: 6 }, pick)).then(() => undefined));
assert.equal(probe.state().pendingDirectories.length, 4);
assert.equal(probe.state().pickDirectory, undefined);
const submitted = probe.state().pendingDirectories;
const clearSubmitted = probe.state().clearSubmittedDirectories;
await act(() => probe.state().removeDirectory(0));
await act(() => probe.state().pickDirectory!());
await probe.render({ draftKey: 'draft-b' });
await act(() => probe.state().pickDirectory!());
await act(() => clearSubmitted(submitted));
assert.equal(probe.state().pendingDirectories.length, 1, 'must not clear a different draft');
await probe.render({ draftKey: 'draft-a' });
assert.equal(probe.state().pendingDirectories.length, 1, 'must not clear a reference added after send');
});

test('IPC validates directory references without turning them into attachments or permissions', () => {
const normalized = normalizeSessionSendCommand({
type: 'send', text: 'inspect', directoryReferences: [reference],
});
assert.deepEqual(normalized?.directoryReferences, [reference]);
assert.equal(normalized?.attachmentItems, undefined);
assert.notEqual(normalized?.directoryReferences?.[0], reference);
for (const references of [
[{ ...reference, path: '../outside' }],
[{ ...reference, grant: 'read' }],
Array.from({ length: 5 }, () => reference),
]) {
assert.throws(() => normalizeSessionSendCommand({
type: 'send', text: 'inspect', directoryReferences: references,
}), /Invalid directory references/);
}
});
23 changes: 22 additions & 1 deletion apps/desktop/src/main/permission-response-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ import type {
ReviseBeforeTurnInput,
TurnOrchestration,
} from '@maka/core/runtime-inputs';
import type { QuoteRef } from '@maka/core/events';
import {
isDirectoryReference,
DIRECTORY_REFERENCE_MAX_COUNT,
type DirectoryReference,
type QuoteRef,
} from '@maka/core/events';
import type { UserQuestionResponse } from '@maka/core/user-question';
import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments';
Expand Down Expand Up @@ -60,6 +65,7 @@ interface NormalizedSendSessionCommand {
attachmentItems?: unknown;
retainedAttachments?: AttachmentRef[];
turnOrchestration?: TurnOrchestration;
directoryReferences?: DirectoryReference[];
quotes?: QuoteRef[];
workspaceFileReferences?: WorkspaceFileReferencePosition[];
}
Expand Down Expand Up @@ -189,6 +195,7 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi
...(value.turnOrchestration !== undefined
? { turnOrchestration: normalizeTurnOrchestration(value.turnOrchestration) }
: {}),
...normalizeOptionalDirectoryReferences(value.directoryReferences),
...normalizeOptionalQuotes(value.quotes),
...normalizeOptionalWorkspaceFileReferences(
value.workspaceFileReferences,
Expand Down Expand Up @@ -387,3 +394,17 @@ function normalizeOptionalSendTurnId(input: unknown): { turnId?: string } {
turnId: normalizeRequiredString(input, 'Invalid send turnId', MAX_TURN_ID_LENGTH),
};
}

function normalizeOptionalDirectoryReferences(
input: unknown,
): { directoryReferences?: DirectoryReference[] } {
if (input === undefined) return {};
if (
!Array.isArray(input) ||
input.length > DIRECTORY_REFERENCE_MAX_COUNT ||
!input.every(isDirectoryReference)
) {
throw new Error('Invalid directory references');
}
return input.length ? { directoryReferences: input.map((ref) => ({ ...ref })) } : {};
}
13 changes: 13 additions & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1497,6 +1497,19 @@ function registerPersistentClientIpc(): void {
}),
);
registerDesktopDiagnosticsIpc({ ipcMain, ...desktopDiagnostics });
ipcMain.handle('directories:pick', async () => {
const local = runtimeHostManager?.entries().find(
(state) => state.target.profile.kind === 'local',
);
if (!local || local.readiness !== 'ready') throw new Error('Local Runtime Host is unavailable');
const hostId = local.candidate.client.hostId;
const result = await mainWindowController.showOpenDialog({
title: 'Reference folder',
properties: ['openDirectory'],
});
if (result.canceled || !result.filePaths[0]) return { ok: false, reason: 'cancelled' };
return { ok: true, reference: { hostId, path: result.filePaths[0] } };
});
ipcMain.handle("attachments:pickFiles", async (event) => {
const result = await mainWindowController.showOpenDialog({
title: "Add attachments",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export function registerRuntimeHostSessionExecutionIpc(
? { displayText: command.displayText }
: {}),
...(attachments.length > 0 ? { attachments } : {}),
...(command.directoryReferences ? { directoryReferences: command.directoryReferences } : {}),
...(command.quotes ? { quotes: command.quotes } : {}),
inlineReferences,
},
Expand Down Expand Up @@ -461,6 +462,7 @@ export function registerRuntimeHostSessionExecutionIpc(
? { displayText: command.displayText }
: {}),
...(attachments.length > 0 ? { attachments } : {}),
...(command.directoryReferences ? { directoryReferences: command.directoryReferences } : {}),
...(command.quotes ? { quotes: command.quotes } : {}),
inlineReferences,
},
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,7 @@ export interface MakaBridge {
attachmentItems?: RendererIngestInput[];
retainedAttachments?: import('@maka/core/events').AttachmentRef[];
turnOrchestration?: TurnOrchestration;
directoryReferences?: import('@maka/core/events').DirectoryReference[];
quotes?: import('@maka/core/events').QuoteRef[];
workspaceFileReferences?: Array<
Pick<import('@maka/core/events').InlineReference, 'value' | 'start'>
Expand Down Expand Up @@ -965,6 +966,7 @@ export interface MakaBridge {
turnOrchestration?: TurnOrchestration;
attachmentItems?: RendererIngestInput[];
retainedAttachments?: import('@maka/core/events').AttachmentRef[];
directoryReferences?: import('@maka/core/events').DirectoryReference[];
quotes?: import('@maka/core/events').QuoteRef[];
workspaceFileReferences?: Array<
Pick<import('@maka/core/events').InlineReference, 'value' | 'start'>
Expand Down Expand Up @@ -1307,6 +1309,7 @@ export interface MakaBridge {
openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }>;
};
attachments: {
pickDirectory(): Promise<{ ok: true; reference: import('@maka/core/events').DirectoryReference } | { ok: false; reason: 'cancelled' }>;
pickFiles(): Promise<
| {
ok: true;
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1710,6 +1710,9 @@ const makaBridge = {
},
async send(sessionId, command) {
const session = await runtimeHostSessionRef(sessionId);
if (command.directoryReferences?.some((ref) => ref.hostId !== session.scope.hostId)) {
throw new Error('Directory references belong to a different Runtime Host. Select the folder on the target Host.');
}
const encoded =
'attachmentItems' in command && command.attachmentItems
? { ...command, attachmentItems: await encodeIngestItems(command.attachmentItems) }
Expand Down Expand Up @@ -1745,6 +1748,9 @@ const makaBridge = {
},
async submitMessage(sessionId, placement, command) {
const session = await runtimeHostSessionRef(sessionId);
if (command.directoryReferences?.some((ref) => ref.hostId !== session.scope.hostId)) {
throw new Error('Directory references belong to a different Runtime Host. Select the folder on the target Host.');
}
const attachmentItems = command.attachmentItems
? await encodeIngestItems(command.attachmentItems)
: undefined;
Expand Down Expand Up @@ -2553,6 +2559,7 @@ const makaBridge = {
},
},
attachments: {
pickDirectory: () => ipcRenderer.invoke('directories:pick'),
pickFiles(): Promise<
| {
ok: true;
Expand Down
Loading
Loading