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
115 changes: 115 additions & 0 deletions apps/desktop/e2e/session-mailbox.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* 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 { Page } from '@playwright/test';
import { expect, test, COMPOSER_INPUT } from './fixtures';

async function establishSourceAndTarget(
page: Page,
targetName: string,
): Promise<string> {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('建立发送方任务');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: 建立发送方任务')).toBeVisible();
return page.evaluate(async (name) => {
const source = (await window.maka.sessions.list())[0];
if (!source) throw new Error('source Session was not created');
const target = await window.maka.sessions.create({
name,
cwd: source.cwd,
projectId: source.projectId,
});
return target.id;
}, targetName);
}

async function selectMailboxTarget(
page: Page,
query: string,
targetName: string,
): Promise<void> {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('/send');
await composer.press('Enter');
const search = page.getByPlaceholder('搜索任务名称…');
await expect(search).toBeVisible();
await search.fill(query);
const target = page.getByRole('option', { name: new RegExp(targetName) });
await expect(target).toBeVisible();
await target.click();
}

test('selects a searchable /send target and settles delivery into the transcript', async ({
window: page,
}, testInfo) => {
const composer = page.locator(COMPOSER_INPUT);
await establishSourceAndTarget(page, '支付回调恢复检查');
await selectMailboxTarget(page, '恢复检查', '支付回调恢复检查');

await expect(page.getByText('发送给“支付回调恢复检查”')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('session-mailbox-before-send.png') });
await composer.fill('请检查回执恢复链路');
await composer.press('Enter');

const card = page.locator('.maka-session-mailbox-bubble', {
hasText: '请检查回执恢复链路',
});
await expect(card).toContainText('支付回调恢复检查');
await expect(card).toContainText(/已送达|已排队/);
await expect(page.getByText('发送给“支付回调恢复检查”')).toHaveCount(0);
const dismissToast = page.getByRole('button', { name: '关闭通知' });
if (await dismissToast.isVisible()) await dismissToast.click();
await page.screenshot({ path: testInfo.outputPath('session-mailbox-card.png') });
});

test('cancels a selected /send target without consuming the next message', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await establishSourceAndTarget(page, '可以取消的任务');
await selectMailboxTarget(page, '可以取消', '可以取消的任务');

const notice = page.locator('[data-send-target-notice="true"]');
await expect(notice).toContainText('发送给“可以取消的任务”');
await notice.getByRole('button', { name: '取消' }).click();
await expect(notice).toHaveCount(0);

await composer.fill('取消后仍是普通消息');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: 取消后仍是普通消息')).toBeVisible();
await expect(page.locator('.maka-session-mailbox-bubble')).toHaveCount(0);
});

test('keeps failed /send delivery actionable after the target becomes unavailable', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const targetId = await establishSourceAndTarget(page, '即将归档的任务');
await selectMailboxTarget(page, '即将归档', '即将归档的任务');
await page.evaluate((sessionId) => window.maka.sessions.archive(sessionId), targetId);

await composer.fill('这条发送应当失败');
await composer.press('Enter');
const notice = page.locator('[data-send-target-notice="true"]');
await expect(notice).toHaveAttribute('data-delivery-status', 'failed');
await expect(notice).toContainText('发送给“即将归档的任务”失败');
await expect(composer).toHaveText('这条发送应当失败');
await expect(page.locator('.maka-session-mailbox-bubble')).toHaveCount(0);
});
35 changes: 35 additions & 0 deletions apps/desktop/src/main/__tests__/workhub-session-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,41 @@ test('desktop adapter projects Session catalog facts without owning copies', asy
]);
});

test('desktop adapter invalidates and re-reads a renamed Session even when activity time is unchanged', async () => {
let sessionName = 'Original name';
let onChanged: (() => void) | undefined;
const adapter = createDesktopWorkHubSessionPort({
transcripts: unusedTranscripts,
sessions: {
list: async () => [desktopSession('ordinary', {
name: sessionName,
lastMessageAt: 30,
})],
listTurns: async () => [],
create: async () => { throw new Error('not used'); },
send: async () => { throw new Error('not used'); },
stop: async () => {},
subscribeChanges: (handler) => {
onChanged = handler;
return () => {};
},
},
projectName: () => 'Maka',
newTurnId: () => 'unused',
});

assert.equal((await adapter.list())[0]?.sessionName, 'Original name');
let invalidations = 0;
adapter.subscribe(() => {
invalidations += 1;
});
sessionName = 'Renamed without a new message';
onChanged?.();

assert.equal(invalidations, 1);
assert.equal((await adapter.list())[0]?.sessionName, 'Renamed without a new message');
});

test('desktop adapter preserves per-Host catalog coverage for ownership reconciliation', async () => {
const localSessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'local' });
const adapter = createDesktopWorkHubSessionPort({
Expand Down
37 changes: 36 additions & 1 deletion apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
RuntimeHostOperationError,
RuntimeHostRequestInterruptedError,
} from '@maka/runtime-host/client';
import { HOST_OPERATION_SPECS } from '@maka/runtime-host/protocol';
import { SKILL_INVOCATION_TOKEN_SOURCE } from '@maka/core/skill-invocation-token';
import { isSideConversationSession } from '@maka/core/side-conversation';
import {
Expand Down Expand Up @@ -109,7 +110,9 @@ type RuntimeHostSessionExecutionClient = Pick<
| "submitMessage"
| "updateSessionMetadata"
| "updateSessionConfiguration"
>;
> & {
request?: DesktopRuntimeHostClient['request'];
};

async function submitMessageWithReconnect(
client: Pick<RuntimeHostSessionExecutionClient, 'getSession' | 'submitMessage'>,
Expand Down Expand Up @@ -254,6 +257,38 @@ export function registerRuntimeHostSessionExecutionIpc(
],
);

handleReconnectableRead(
ipcMain,
'sessions:mailboxTargets',
async (_event, sourceSessionId: unknown) => {
if (!deps.client.request) {
throw new Error('Session messaging is unavailable on this Runtime Host client');
}
return deps.client.request('session.mailbox.targets', {
sourceSessionId: requiredId(sourceSessionId, 'Source Session'),
});
},
);

ipcMain.handle(
'sessions:mailboxSend',
async (_event, sourceSessionId: unknown, targetSessionId: unknown, text: unknown) => {
if (!deps.client.request) {
throw new Error('Session messaging is unavailable on this Runtime Host client');
}
return deps.client.request(
'session.mailbox.send',
HOST_OPERATION_SPECS['session.mailbox.send'].decodeInput({
sourceSessionId: requiredId(sourceSessionId, 'Source Session'),
targetSessionId: requiredId(targetSessionId, 'Target Session'),
messageId: newId(),
kind: 'request',
text,
}),
);
},
);

ipcMain.handle(
"sessions:send",
async (event, sessionId: string, input: unknown) => {
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,14 @@ export interface MakaBridge {
completeHostIds: string[];
}>;
create(input?: CreateSessionRequestInput): Promise<DesktopSessionSummary>;
listMailboxTargets(
sourceSessionId: string,
): Promise<readonly import('@maka/runtime-host/protocol').SessionMailboxTarget[]>;
sendMailboxMessage(
sourceSessionId: string,
targetSessionId: string,
text: string,
): Promise<import('@maka/runtime-host/protocol').SessionMailboxSendResult>;
send(
sessionId: string,
command:
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1585,6 +1585,37 @@ const makaBridge = {
const scope = await activeRuntimeHostRef();
return createDesktopSessionOnScope(scope, input);
},
async listMailboxTargets(sourceSessionId: string) {
const source = await runtimeHostSessionRef(sourceSessionId);
const result = await ipcRenderer.invoke(
'sessions:mailboxTargets',
source.scope,
source.sessionId,
) as OperationOutput<'session.mailbox.targets'>;
return result.targets.map((target) => ({
...target,
sessionId: desktopSessionKey({
hostId: source.scope.hostId,
sessionId: target.sessionId,
}),
}));
},
async sendMailboxMessage(sourceSessionId: string, targetSessionId: string, text: string) {
const [source, target] = await Promise.all([
runtimeHostSessionRef(sourceSessionId),
runtimeHostSessionRef(targetSessionId),
]);
if (source.scope.hostId !== target.scope.hostId) {
throw new Error('Session messages require both tasks to use the same Runtime Host');
}
return ipcRenderer.invoke(
'sessions:mailboxSend',
source.scope,
source.sessionId,
target.sessionId,
text,
);
},
async send(
sessionId: string,
command:
Expand Down
Loading