feat(web-shell): review MCP App messages before submission (PR3a) (#13958)

* feat(web-shell): review MCP App messages before session submission

* fix(web-shell): preserve MCP App state and admission outcomes

---------

Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
This commit is contained in:
qqqys 2026-10-14 06:34:37 +00:00 • committed by GitHub
parent f76ff1d147
commit 97fb4a547d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 3264 additions and 38 deletions

View file

@ -50,7 +50,9 @@ Import and show are model-visible only. Query is also App-visible and does not c
The card starts with failed assertions and file errors. Switch collections, submit a case-sensitive literal search over the whole snapshot, and page through the matches. Open an entry to read diagnostics, the full title or the full path in separate chunks. Reported suite counts and computed file/assertion counts remain distinct; the source path, hash, import time and report-only provenance identify the evidence.
Select up to 20 failed entries across pages to prepare a short analysis request. Manually copy the text into the conversation; the card does not send messages, execute tests or apply fixes. Report strings are rendered as plain text without automatic links.
Select up to 20 failed entries across pages to prepare a short analysis request. Hosts with MCP App text messaging offer **Review selected failures**. In Qwen Code, this opens a host dialog showing the full request, its server/resource and its original session; only explicit confirmation submits it. Qwen Code preserves the composer draft and reports acceptance only after the daemon returns a prompt receipt; acceptance does not mean analysis has completed. Other messaging hosts apply their own confirmation and acceptance policy. Hosts without messaging retain the selectable text for manual copy into the conversation.
Qwen Code refuses known-busy sessions without queueing this request. Before dispatch, cancellation or a changed session/client invalidates the review; request a new review after recovery. If acceptance is unknown, check the host review and original conversation before sending again. Requests are never resent automatically, and closing the card does not cancel an accepted turn. The generated request asks for read-only analysis; the card itself does not execute tests or apply fixes, and downstream tools retain the host's normal permission checks. It sends one user text block through standard `ui/message`; Qwen Code accepts at most 16 KiB of UTF-8 text. Report strings are rendered as plain text without automatic links.
The self-contained HTML resource is `ui://test-failure-explorer/v1/index.html`. It contains no report data and requests no external CSP domains. Data arrives through the show result and standard App tool calls; there are no external assets or separate network data requests. Refresh resets filters, pages and selection to the initial card result. If a host offers no tool callback, the saved summary and first page remain readable with query controls disabled. Query failures retain that saved evidence and display an error. If historical HTML has been evicted, use the text fallback or call show again with the saved reportId.

View file

@ -17,6 +17,7 @@ import type {
ShowData,
} from '../app-contract.js';
import { ReportApp } from './App.js';
import type { SendAnalysis } from './analysis-request.js';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
@ -138,21 +139,30 @@ function detail(overrides: Partial<DetailData> = {}): DetailData {
};
}
function render(query?: Query, data = report()) {
function render(query?: Query, data = report(), sendAnalysis?: SendAnalysis) {
const container = globalThis.document.createElement('div');
globalThis.document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<ReportApp report={data} query={query} />));
act(() =>
root.render(
<ReportApp report={data} query={query} sendAnalysis={sendAnalysis} />,
),
);
mounted.push({ root, container });
return {
container,
rerender: (nextQuery?: Query, connectionNotice?: string) =>
rerender: (
nextQuery?: Query,
connectionNotice?: string,
nextSendAnalysis: SendAnalysis | null | undefined = sendAnalysis,
) =>
act(() =>
root.render(
<ReportApp
report={data}
query={nextQuery}
connectionNotice={connectionNotice}
sendAnalysis={nextSendAnalysis ?? undefined}
/>,
),
),
@ -195,10 +205,10 @@ function inputText(container: HTMLElement, value: string) {
});
}
function deferred() {
let resolve!: (data: QueryData) => void;
function deferred<T = QueryData>() {
let resolve!: (data: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<QueryData>((resolvePromise, rejectPromise) => {
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
@ -637,4 +647,144 @@ describe('ReportApp', () => {
});
expect(container.textContent).toBe('');
});
it('keeps manual copying available without message capability', async () => {
const { container } = render();
await act(async () =>
container
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect(container.textContent).not.toContain('Review selected failures');
const request = container.querySelector('textarea')!.value;
expect(request).toContain('Read-only analysis');
expect(request).toContain('do not modify files or rerun tests');
expect(request).toContain(reportId);
expect(request).toContain('f0:a0');
expect(request).not.toContain('example.test.ts');
await click(container, 'Select request text');
expect(globalThis.document.activeElement).toBe(
container.querySelector('textarea'),
);
});
it('requests review only on click, synchronously prevents duplicates, and reports acceptance without claiming completion', async () => {
const pending = deferred<{ isError?: boolean }>();
const sendAnalysis = vi.fn<SendAnalysis>().mockReturnValue(pending.promise);
const query = vi.fn<Query>().mockResolvedValue(list());
const { container, rerender } = render(query, report(), sendAnalysis);
await act(async () =>
container
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
expect(sendAnalysis).not.toHaveBeenCalled();
const review = button(container, 'Review selected failures');
await act(async () => {
review.click();
review.click();
});
expect(sendAnalysis).toHaveBeenCalledTimes(1);
expect(sendAnalysis.mock.calls[0][0]).toEqual(['f0:a0']);
expect(sendAnalysis.mock.calls[0][1].aborted).toBe(false);
expect(review.disabled).toBe(true);
expect(button(container, 'Search').disabled).toBe(false);
expect(button(container, 'Clear selection').disabled).toBe(true);
expect(container.textContent).toContain('Review this request in the host');
await act(async () => pending.resolve({}));
expect(container.textContent).toContain('daemon accepted the last request');
expect(container.textContent).toContain('does not confirm completion');
expect(container.textContent).not.toContain('Cancel request');
rerender(undefined, 'Connection closed.', null);
expect(container.textContent).toContain('daemon accepted the last request');
expect(sendAnalysis).toHaveBeenCalledTimes(1);
});
it.each(['host rejection', 'RPC timeout'] as const)(
'keeps %s unconfirmed and does not retry on its own',
async (outcome) => {
const sendAnalysis = vi.fn<SendAnalysis>();
if (outcome === 'host rejection')
sendAnalysis.mockResolvedValue({ isError: true });
else sendAnalysis.mockRejectedValue(new Error('Request timed out'));
const query = vi.fn<Query>().mockResolvedValue(list());
const { container, rerender } = render(query, report(), sendAnalysis);
await act(async () =>
container
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
await click(container, 'Review selected failures');
expect(container.querySelector('[role="status"]')?.textContent).toBe(
outcome === 'host rejection'
? 'The host did not confirm acceptance. Check the host review and original conversation before sending again.'
: 'Acceptance is unknown. The request may have timed out or lost its connection; check the host review and original conversation before sending again.',
);
expect(container.textContent).not.toContain('daemon accepted');
expect(container.querySelector('textarea')!.value).toContain(reportId);
await click(container, 'Search');
rerender(query);
expect(sendAnalysis).toHaveBeenCalledTimes(1);
},
);
it('cancels the pending RPC and ignores a later success without automatically resending', async () => {
const pending = deferred<{ isError?: boolean }>();
const sendAnalysis = vi.fn<SendAnalysis>().mockReturnValue(pending.promise);
const { container } = render(undefined, report(), sendAnalysis);
await act(async () =>
container
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
await click(container, 'Review selected failures');
await click(container, 'Cancel request');
expect(sendAnalysis.mock.calls[0][1].aborted).toBe(true);
expect(container.textContent).toContain(
'Cancellation requested. Acceptance is unknown',
);
await act(async () => pending.resolve({}));
expect(container.textContent).not.toContain('daemon accepted');
expect(container.textContent).toContain('Acceptance is unknown');
expect(sendAnalysis).toHaveBeenCalledTimes(1);
});
it('aborts review when the connection disappears and does not replay it on reconnection', async () => {
const pending = deferred<{ isError?: boolean }>();
const sendAnalysis = vi.fn<SendAnalysis>().mockReturnValue(pending.promise);
const { container, rerender } = render(undefined, report(), sendAnalysis);
await act(async () =>
container
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
await click(container, 'Review selected failures');
rerender(undefined, 'Connection closed.', null);
expect(sendAnalysis.mock.calls[0][1].aborted).toBe(true);
expect(container.textContent).toContain(
'Connection lost. Acceptance is unknown',
);
expect(container.querySelector('textarea')!.value).toContain('f0:a0');
await act(async () => pending.reject(new Error('Connection closed')));
rerender(undefined, '', sendAnalysis);
expect(sendAnalysis).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain('daemon accepted');
});
it('aborts pending analysis on teardown and ignores its later rejection', async () => {
const pending = deferred<{ isError?: boolean }>();
const sendAnalysis = vi.fn<SendAnalysis>().mockReturnValue(pending.promise);
const { container, unmount } = render(undefined, report(), sendAnalysis);
await act(async () =>
container
.querySelector<HTMLInputElement>('input[type="checkbox"]')!
.click(),
);
await click(container, 'Review selected failures');
unmount();
expect(sendAnalysis.mock.calls[0][1].aborted).toBe(true);
await act(async () => pending.reject(new Error('View removed')));
expect(container.textContent).toBe('');
expect(sendAnalysis).toHaveBeenCalledTimes(1);
});
});

View file

@ -13,6 +13,7 @@ import type {
ReportQuery,
ShowData,
} from '../app-contract.js';
import { analysisRequestText, type SendAnalysis } from './analysis-request.js';
type Query = (query: ReportQuery, signal: AbortSignal) => Promise<QueryData>;
type Collection = ListData['collection'];
@ -44,10 +45,12 @@ export function ReportApp({
report,
query,
connectionNotice,
sendAnalysis,
}: {
report: ShowData;
query?: Query;
connectionNotice?: string;
sendAnalysis?: SendAnalysis;
}) {
const { summary } = report;
const [filters, setFilters] = useState<Filters>({
@ -74,6 +77,14 @@ export function ReportApp({
});
const [detailOpen, setDetailOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [analysisStatus, setAnalysisStatus] = useState<{
state: 'idle' | 'pending' | 'accepted' | 'unconfirmed';
text: string;
}>({ state: 'idle', text: '' });
const analysisRequest = useRef<{
generation: number;
controller?: AbortController;
}>({ generation: 0 });
const listRequest = useRef<{
generation: number;
controller?: AbortController;
@ -90,11 +101,14 @@ export function ReportApp({
useEffect(() => {
const list = listRequest.current;
const detail = detailRequest.current;
const analysis = analysisRequest.current;
return () => {
list.generation += 1;
detail.generation += 1;
list.controller?.abort();
detail.controller?.abort();
analysis.generation += 1;
analysis.controller?.abort();
};
}, []);
@ -116,6 +130,19 @@ export function ReportApp({
}
}, [query, report.firstPage]);
useEffect(() => {
const current = analysisRequest.current;
if (!sendAnalysis && current.controller) {
current.generation += 1;
current.controller.abort();
current.controller = undefined;
setAnalysisStatus({
state: 'unconfirmed',
text: 'Connection lost. Acceptance is unknown; check the host review and original conversation before sending again.',
});
}
}, [sendAnalysis]);
useEffect(() => {
if (detailOpen) detailHeading.current?.focus();
else if (wasDetailOpen.current) listHeading.current?.focus();
@ -221,6 +248,55 @@ export function ReportApp({
void loadList({ collection, text: filters.text }, 0, []);
}
async function requestAnalysis() {
const current = analysisRequest.current;
if (!sendAnalysis || current.controller || selectedIds.length === 0) return;
const controller = new AbortController();
const generation = ++current.generation;
current.controller = controller;
setAnalysisStatus({
state: 'pending',
text: 'Review this request in the host. Analysis starts only after host approval and acceptance.',
});
try {
const result = await sendAnalysis([...selectedIds], controller.signal);
if (generation !== current.generation || controller.signal.aborted)
return;
setAnalysisStatus(
result.isError
? {
state: 'unconfirmed',
text: 'The host did not confirm acceptance. Check the host review and original conversation before sending again.',
}
: {
state: 'accepted',
text: 'The daemon accepted the last request. Follow the original conversation for analysis; this does not confirm completion.',
},
);
} catch {
if (generation !== current.generation || controller.signal.aborted)
return;
setAnalysisStatus({
state: 'unconfirmed',
text: 'Acceptance is unknown. The request may have timed out or lost its connection; check the host review and original conversation before sending again.',
});
} finally {
if (generation === current.generation) current.controller = undefined;
}
}
function cancelAnalysis() {
const current = analysisRequest.current;
if (!current.controller) return;
current.generation += 1;
current.controller.abort();
current.controller = undefined;
setAnalysisStatus({
state: 'unconfirmed',
text: 'Cancellation requested. Acceptance is unknown; check the host review and original conversation before sending again.',
});
}
const total =
filters.collection === 'failures'
? summary.computed.failures
@ -244,11 +320,10 @@ export function ReportApp({
: filters.collection === 'failures'
? 'No failed entries were recorded in this report.'
: `No ${filters.collection} entries were recorded in this report.`;
const selectionText = `Analyze these failures using test_report_query:\n${JSON.stringify(
{ reportId: report.reportId, itemIds: selectedIds },
null,
2,
)}`;
const selectionText =
selectedIds.length > 0
? analysisRequestText(report.reportId, selectedIds)
: '';
return (
<main className="explorer" aria-label="Test failure explorer">
@ -467,6 +542,7 @@ export function ReportApp({
aria-label={`Select ${entry.itemId}`}
checked={selectedIds.includes(entry.itemId)}
disabled={
analysisStatus.state === 'pending' ||
entry.status !== 'failed' ||
(selectedIds.length >= 20 &&
!selectedIds.includes(entry.itemId))
@ -703,9 +779,27 @@ export function ReportApp({
</span>
{selectedIds.length > 0 && (
<>
<button type="button" onClick={() => setSelectedIds([])}>
<button
type="button"
disabled={analysisStatus.state === 'pending'}
onClick={() => setSelectedIds([])}
>
Clear selection
</button>
{sendAnalysis && (
<button
type="button"
disabled={analysisStatus.state === 'pending'}
onClick={() => void requestAnalysis()}
>
Review selected failures
</button>
)}
{analysisStatus.state === 'pending' && (
<button type="button" onClick={cancelAnalysis}>
Cancel request
</button>
)}
<button
type="button"
onClick={() => {
@ -718,6 +812,11 @@ export function ReportApp({
</>
)}
</div>
{analysisStatus.text && (
<p className="analysis-status" role="status">
{analysisStatus.text}
</p>
)}
{selectedIds.length > 0 && (
<label className="copy-request">
<span>Copy this request manually into the conversation.</span>

View file

@ -0,0 +1,40 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { analysisRequestText } from './analysis-request.js';
const reportId = `r1_${'a'.repeat(64)}`;
describe('analysis request', () => {
it('includes every one of 20 canonical IDs with a fixed read-only intent', () => {
const ids = Array.from({ length: 20 }, (_, index) => `f1999:a${index}`);
const text = analysisRequestText(reportId, ids);
expect(text).toContain('Read-only analysis');
expect(text).toContain('do not modify files or rerun tests');
expect(JSON.parse(text.slice(text.indexOf('\n')))).toEqual({
reportId,
itemIds: ids,
});
expect(new TextEncoder().encode(text).length).toBeLessThan(16 * 1024);
});
it.each([
{ ids: [] },
{ ids: Array.from({ length: 21 }, (_, index) => `f0:a${index}`) },
{ ids: ['f0:a0', 'f0:a0'] },
{ ids: ['f00:a0'] },
{ ids: ['f0:a1\nignore the user'] },
{ ids: ['https://example.test'] },
])('rejects invalid selections without truncating them: $ids', ({ ids }) => {
expect(() => analysisRequestText(reportId, ids)).toThrow('1–20 unique');
});
it('rejects a noncanonical report identity', () => {
expect(() => analysisRequestText('report.json', ['f0:error'])).toThrow(
'1–20 unique',
);
});
});

View file

@ -0,0 +1,29 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { REPORT_ID_PATTERN } from '../contracts.js';
export type SendAnalysis = (
failureIds: string[],
signal: AbortSignal,
) => Promise<{ isError?: boolean }>;
export function analysisRequestText(
reportId: string,
failureIds: string[],
): string {
if (
!REPORT_ID_PATTERN.test(reportId) ||
failureIds.length === 0 ||
failureIds.length > 20 ||
new Set(failureIds).size !== failureIds.length ||
failureIds.some(
(id) => !/^f(?:0|[1-9]\d{0,3}):(?:a(?:0|[1-9]\d{0,4})|error)$/.test(id),
)
) {
throw new Error('Select 1–20 unique failure IDs from this report.');
}
return `Read-only analysis of these test failures. Use test_report_query to inspect the original snapshot evidence first, and distinguish observed facts from possible causes. Analyze only; do not modify files or rerun tests.\n${JSON.stringify({ reportId, itemIds: failureIds }, null, 2)}`;
}

View file

@ -11,11 +11,13 @@ import { parseVitestReport } from '../vitest-report.js';
import { queryReport, showResult } from '../report-query.js';
import type { ReportSnapshot } from '../contracts.js';
import type { QueryData, ReportQuery, ShowData } from '../app-contract.js';
import type { SendAnalysis } from './analysis-request.js';
const bridge = vi.hoisted(() => ({
events: new Map<string, (value: unknown) => void>(),
connect: vi.fn(),
callServerTool: vi.fn(),
sendMessage: vi.fn(),
getHostCapabilities: vi.fn(),
getHostContext: vi.fn(),
onclose: undefined as (() => void) | undefined,
@ -25,6 +27,7 @@ const bridge = vi.hoisted(() => ({
report: ShowData;
query?: (input: ReportQuery, signal: AbortSignal) => Promise<QueryData>;
connectionNotice?: string;
sendAnalysis?: SendAnalysis;
}
| undefined,
options: undefined as { autoResize?: boolean; strict?: boolean } | undefined,
@ -75,6 +78,7 @@ beforeEach(() => {
vi.clearAllMocks();
bridge.events.clear();
bridge.props = undefined;
bridge.sendMessage.mockReset().mockResolvedValue({});
bridge.getHostCapabilities.mockReturnValue({ serverTools: {} });
bridge.getHostContext.mockReturnValue({ theme: 'dark' });
bridge.connect.mockImplementation(async () => {
@ -155,4 +159,216 @@ describe('MCP App bridge', () => {
'Unsupported or invalid saved report',
);
});
it.each([{}, { message: {} }, { message: { image: {} } }])(
'keeps copying as the only handoff without explicit text message capability: %j',
async (capabilities) => {
bridge.getHostCapabilities.mockReturnValue(capabilities);
await boot();
expect(bridge.props?.sendAnalysis).toBeUndefined();
expect(bridge.sendMessage).not.toHaveBeenCalled();
},
);
it('calls standard ui/message with one user text block, a 120-second timeout and a cancellable signal', async () => {
bridge.getHostCapabilities.mockReturnValue({ message: { text: {} } });
await boot();
expect(bridge.props?.query).toBeUndefined();
expect(bridge.sendMessage).not.toHaveBeenCalled();
const controller = new AbortController();
await expect(
bridge.props!.sendAnalysis!(['f0:a1'], controller.signal),
).resolves.toEqual({});
const [message, options] = bridge.sendMessage.mock.calls[0] as [
{ role: string; content: Array<{ type: string; text: string }> },
{ timeout: number; signal: AbortSignal },
];
expect(message.role).toBe('user');
expect(message.content).toHaveLength(1);
expect(message.content[0].type).toBe('text');
expect(message.content[0].text).toContain(
'do not modify files or rerun tests',
);
expect(message.content[0].text).not.toContain(
'preserves the failing assertion',
);
expect(message.content[0].text).not.toContain('/fixture-workspace');
expect(
JSON.parse(
message.content[0].text.slice(message.content[0].text.indexOf('\n')),
),
).toEqual({ reportId: snapshot.reportId, itemIds: ['f0:a1'] });
expect(options.timeout).toBe(120_000);
expect(options.signal.aborted).toBe(false);
controller.abort();
expect(options.signal.aborted).toBe(true);
});
it('accepts 20 observed failures across pages and rejects 21 without silently dropping IDs', async () => {
const failure = snapshot.files
.flatMap((file) => file.items)
.find((item) => item.status === 'failed')!;
const many: ReportSnapshot = {
...snapshot,
computed: {
...snapshot.computed,
fileEntries: 1,
passedFiles: 0,
failedFiles: 1,
assertions: 21,
failures: 21,
statuses: { ...snapshot.computed.statuses, passed: 0, failed: 21 },
},
reported: {
...snapshot.reported,
numTotalTestSuites: 1,
numPassedTestSuites: 0,
numFailedTestSuites: 1,
numTotalTests: 21,
numPassedTests: 0,
numFailedTests: 21,
},
files: [
{
...snapshot.files[0],
items: Array.from({ length: 21 }, (_, index) => ({
...failure,
itemId: `f0:a${index}`,
assertionIndex: index,
path: 'fixture.ts',
fullName: `failure ${index}`,
title: `failure ${index}`,
})),
},
],
};
bridge.getHostCapabilities.mockReturnValue({
serverTools: {},
message: { text: {} },
});
bridge.connect.mockImplementation(async () =>
bridge.events.get('toolresult')!(showResult(many)),
);
await boot();
const query = { kind: 'list', collection: 'failures', limit: 50 } as const;
bridge.callServerTool.mockResolvedValue(queryReport(many, query));
await bridge.props!.query!(query, new AbortController().signal);
const twenty = Array.from({ length: 20 }, (_, index) => `f0:a${index}`);
await expect(
bridge.props!.sendAnalysis!(twenty, new AbortController().signal),
).resolves.toEqual({});
const text = (
bridge.sendMessage.mock.calls[0][0] as {
content: Array<{ text: string }>;
}
).content[0].text;
expect(JSON.parse(text.slice(text.indexOf('\n'))).itemIds).toEqual(twenty);
await expect(
bridge.props!.sendAnalysis!(
[...twenty, 'f0:a20'],
new AbortController().signal,
),
).rejects.toThrow('1–20 unique');
expect(bridge.sendMessage).toHaveBeenCalledTimes(1);
});
it.each([
{ ids: ['f0:a0'] },
{ ids: ['f9:error'] },
{ ids: ['f0:a1', 'f0:a1'] },
])(
'rejects passed, unknown or duplicated failure references: $ids',
async ({ ids }) => {
bridge.getHostCapabilities.mockReturnValue({ message: { text: {} } });
await boot();
await expect(
bridge.props!.sendAnalysis!(ids, new AbortController().signal),
).rejects.toThrow();
expect(bridge.sendMessage).not.toHaveBeenCalled();
},
);
it('does not rebind an old sender to a new report', async () => {
bridge.getHostCapabilities.mockReturnValue({ message: { text: {} } });
await boot();
const oldSender = bridge.props!.sendAnalysis!;
await act(async () =>
bridge.events.get('toolresult')!(
showResult({ ...snapshot, reportId: `r1_${'d'.repeat(64)}` }),
),
);
await expect(
oldSender(['f0:a1'], new AbortController().signal),
).rejects.toThrow('unavailable');
expect(bridge.sendMessage).not.toHaveBeenCalled();
});
it.each(['close', 'teardown'] as const)(
'aborts message requests on %s and rejects stale send callbacks',
async (event) => {
bridge.getHostCapabilities.mockReturnValue({ message: { text: {} } });
bridge.sendMessage.mockImplementation(
(_message: unknown, options: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener(
'abort',
() => reject(new Error('Request aborted')),
{ once: true },
);
}),
);
await boot();
const sender = bridge.props!.sendAnalysis!;
const pending = sender(['f0:a1'], new AbortController().signal);
const outcome = pending.then(
() => ({ status: 'resolved' }),
(error: unknown) => ({ status: 'rejected', error }),
);
await act(async () => {
if (event === 'close') bridge.onclose!();
else await bridge.onteardown!();
});
expect(await outcome).toEqual({
status: 'rejected',
error: new Error('Request aborted'),
});
expect(bridge.sendMessage.mock.calls[0][1].signal.aborted).toBe(true);
if (event === 'close') expect(bridge.props?.sendAnalysis).toBeUndefined();
await expect(
sender(['f0:a1'], new AbortController().signal),
).rejects.toThrow('unavailable');
expect(bridge.sendMessage).toHaveBeenCalledTimes(1);
},
);
it('passes rejection and timeout outcomes through without repeating message requests', async () => {
bridge.getHostCapabilities.mockReturnValue({ message: { text: {} } });
await boot();
bridge.sendMessage.mockResolvedValueOnce({ isError: true });
await expect(
bridge.props!.sendAnalysis!(['f0:a1'], new AbortController().signal),
).resolves.toEqual({ isError: true });
bridge.sendMessage.mockRejectedValueOnce(new Error('Request timed out'));
await expect(
bridge.props!.sendAnalysis!(['f0:a1'], new AbortController().signal),
).rejects.toThrow('Request timed out');
expect(bridge.sendMessage).toHaveBeenCalledTimes(2);
});
it('does not restore message capability when connect settles after transport closure', async () => {
let finishConnect!: () => void;
bridge.getHostCapabilities.mockReturnValue({ message: { text: {} } });
bridge.connect.mockImplementation(() => {
bridge.events.get('toolresult')!(showResult(snapshot));
return new Promise<void>((resolve) => {
finishConnect = resolve;
});
});
await boot();
await act(async () => bridge.onclose!());
await act(async () => finishConnect());
expect(bridge.props?.sendAnalysis).toBeUndefined();
expect(bridge.props?.connectionNotice).toContain('Connection closed');
expect(bridge.sendMessage).not.toHaveBeenCalled();
});
});

View file

@ -14,6 +14,7 @@ import {
type QueryData,
} from '../app-contract.js';
import { ReportApp } from './App.js';
import { analysisRequestText, type SendAnalysis } from './analysis-request.js';
import './styles.css';
const app = new App(
@ -29,6 +30,9 @@ let query:
let connected = false;
let stopped = false;
let notice = 'Connecting to the report host…';
let sendAnalysis: SendAnalysis | undefined;
let knownFailureIds = new Set<string>();
const lifetime = new AbortController();
function render() {
if (stopped) return;
@ -42,6 +46,11 @@ function render() {
? query
: undefined
}
sendAnalysis={
connected && app.getHostCapabilities()?.message?.text
? sendAnalysis
: undefined
}
connectionNotice={notice}
/>
) : (
@ -62,10 +71,15 @@ app.addEventListener('toolresult', (result) => {
throw new Error(
'Unsupported or invalid saved report. Show this report again from the conversation.',
);
if (report?.reportId !== parsed.data.reportId) knownFailureIds = new Set();
report = parsed.data;
const reportId = report.reportId;
query = async (input, signal) =>
readQueryData(
const failures = knownFailureIds;
for (const item of report.firstPage?.items ?? []) {
if (item.status === 'failed') failures.add(item.itemId);
}
query = async (input, signal) => {
const result = readQueryData(
readToolData(
await app.callServerTool(
{
@ -78,6 +92,39 @@ app.addEventListener('toolresult', (result) => {
reportId,
input,
);
if (result.kind === 'list') {
for (const item of result.items) {
if (item.status === 'failed') failures.add(item.itemId);
}
}
return result;
};
sendAnalysis = async (failureIds, signal) => {
if (
stopped ||
!connected ||
report?.reportId !== reportId ||
!app.getHostCapabilities()?.message?.text ||
signal.aborted ||
failureIds.some((id) => !failures.has(id))
) {
throw new Error(
'This report request is unavailable. Check the host and original conversation.',
);
}
return app.sendMessage(
{
role: 'user',
content: [
{ type: 'text', text: analysisRequestText(reportId, failureIds) },
],
},
{
signal: AbortSignal.any([signal, lifetime.signal]),
timeout: 120_000,
},
);
};
notice = '';
} catch (error) {
notice =
@ -90,12 +137,15 @@ app.addEventListener('toolresult', (result) => {
app.addEventListener('hostcontextchanged', theme);
app.onclose = () => {
connected = false;
lifetime.abort();
notice =
'Connection closed. Saved report evidence is still shown; reopen this card to reconnect.';
render();
};
app.onteardown = async () => {
stopped = true;
connected = false;
lifetime.abort();
root.unmount();
return {};
};
@ -103,6 +153,7 @@ render();
app
.connect()
.then(() => {
if (stopped || lifetime.signal.aborted) return;
connected = true;
if (notice === 'Connecting to the report host…') notice = '';
theme();

View file

@ -399,10 +399,16 @@ summary {
background: var(--subtle);
}
.selection-heading {
flex-wrap: wrap;
color: var(--muted);
font-size: 10px;
gap: 6px;
}
.analysis-status {
margin-top: 5px;
font-size: 10px;
color: var(--muted);
}
.selection-heading > span {
margin-right: auto;
}

View file

@ -704,15 +704,24 @@ export class DaemonSessionClient {
* register the prompt in `_pendingPrompts`, or wait for the matching
* `turn_complete` / `turn_error` SSE event. Callers that need final turn
* results should use `prompt()` or manage SSE terminal events themselves.
* `beforeDispatch` can reject a stale caller before each HTTP attempt,
* including the retry after client registration recovery.
*/
async submitPrompt(
req: PromptRequest,
signal?: AbortSignal,
beforeDispatch?: (clientId: string | undefined) => void,
): Promise<NonBlockingPromptAccepted> {
signal?.throwIfAborted();
const accepted = await this.withClientIdSelfHeal(() =>
this.client.promptNonBlocking(this.sessionId, req, signal, this.clientId),
);
const accepted = await this.withClientIdSelfHeal(() => {
signal?.throwIfAborted();
beforeDispatch?.(this.clientId);
return this.client.promptNonBlocking(
this.sessionId,
req,
signal,
this.clientId,
);
});
if (!isNonBlockingAccepted(accepted)) {
throw new Error('Expected non-blocking prompt acceptance');
}

View file

@ -3621,6 +3621,92 @@ describe('DaemonSessionClient clientId self-heal', () => {
});
}
it('checks the caller before the first admission request', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(202, { promptId: 'p-1', lastEventId: 0 }),
);
const session = newSession(
new DaemonClient({ baseUrl: 'http://daemon', fetch }),
);
const beforeDispatch = vi.fn(() => {
throw new DOMException('Caller expired', 'AbortError');
});
await expect(
session.submitPrompt(
{ prompt: [{ type: 'text', text: 'hi' }] },
undefined,
beforeDispatch,
),
).rejects.toMatchObject({ name: 'AbortError' });
expect(beforeDispatch).toHaveBeenCalledWith('client-1');
expect(calls).toHaveLength(0);
});
it.each(['cancel', 'owner', 'client', 'unchanged', 'no-guard'] as const)(
'revalidates admission after client recovery: %s',
async (change) => {
let releaseResume!: (response: Response) => void;
const resume = new Promise<Response>((resolve) => {
releaseResume = resolve;
});
let promptCalls = 0;
const { fetch, calls } = recordingFetch((req) => {
if (req.url.endsWith('/resume')) return resume;
if (req.url.endsWith('/prompt')) {
promptCalls++;
return promptCalls === 1
? invalidClientIdResponse()
: jsonResponse(202, { promptId: 'p-1', lastEventId: 0 });
}
return jsonResponse(500, { error: `unexpected ${req.url}` });
});
const session = newSession(
new DaemonClient({ baseUrl: 'http://daemon', fetch }),
);
const caller = new AbortController();
let ownerCurrent = true;
const beforeDispatch = vi.fn((clientId: string | undefined) => {
caller.signal.throwIfAborted();
if (!ownerCurrent || (change === 'client' && clientId !== 'client-1')) {
throw new DOMException('Caller expired', 'AbortError');
}
});
const pending = session.submitPrompt(
{ prompt: [{ type: 'text', text: 'hi' }] },
undefined,
change === 'no-guard' ? undefined : beforeDispatch,
);
const result = pending.catch((error: unknown) => error);
await vi.waitFor(() =>
expect(calls.some((call) => call.url.endsWith('/resume'))).toBe(true),
);
if (change === 'cancel') caller.abort();
if (change === 'owner') ownerCurrent = false;
releaseResume(
jsonResponse(200, {
sessionId: 's-1',
workspaceCwd: '/work/a',
attached: true,
clientId: 'client-2',
state: {},
}),
);
if (change === 'unchanged' || change === 'no-guard') {
expect(await result).toEqual({ promptId: 'p-1', lastEventId: 0 });
expect(promptCalls).toBe(2);
expect(calls.at(-1)?.headers['x-qwen-client-id']).toBe('client-2');
} else {
expect(await result).toMatchObject({ name: 'AbortError' });
expect(promptCalls).toBe(1);
}
if (change !== 'no-guard') {
expect(beforeDispatch.mock.calls).toEqual([['client-1'], ['client-2']]);
}
},
);
it('retries a worktree prompt only after matching durable reattachment', async () => {
let promptCalls = 0;
const { fetch } = recordingFetch((req) => {

View file

@ -1635,6 +1635,7 @@ describe('EmbeddedApp message edit rewind', () => {
sessionId?: string;
prompt: string;
inputAnnotations: unknown[];
source?: 'mcp-app';
}
function snapshot(turnIndex: number): RewindSnapshotStub {
@ -1692,6 +1693,68 @@ describe('EmbeddedApp message edit rewind', () => {
// The daemon-backed edit/rewind shipped with the cutover but nothing ever
// exercised it: getRewindSnapshots and rewindSession appeared in this file
// only as mock stubs (#9911).
it('keeps a pending composer edit intact when preparing an MCP App message', async () => {
const props = await renderApp();
await act(async () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
type: 'activeEditorChanged',
data: {
fileName: 'editor.ts',
filePath: '/workspace/editor.ts',
selection: { startLine: 3, endLine: 5 },
},
},
}),
);
});
sdkMocks.getRewindSnapshots.mockResolvedValue({ snapshots: [snapshot(3)] });
const prepareSubmit = await startEditing(props, 3);
await act(async () => {
await expect(
prepareSubmit({
sessionId: 'session-1',
prompt: 'Analyze selected failures',
inputAnnotations: [],
source: 'mcp-app',
}),
).resolves.toBeUndefined();
});
expect(sdkMocks.getRewindSnapshots).not.toHaveBeenCalled();
expect(sdkMocks.rewindSession).not.toHaveBeenCalled();
const prepareComposer = callback<
(submission: PrepareSubmission) => Promise<unknown>
>(mocks.embeddedProps.current as CapturedProps, 'prepareSubmit');
await act(async () => {
await expect(
prepareComposer({
sessionId: 'session-1',
prompt: 'Edited composer message',
inputAnnotations: [],
}),
).resolves.toEqual({
prompt: '@editor.ts (selected lines 3-5) Edited composer message',
inputAnnotations: [
expect.objectContaining({
type: 'reference',
reference: expect.objectContaining({
id: 'vscode-active-file:/workspace/editor.ts',
value: '/workspace/editor.ts',
}),
}),
],
});
});
expect(sdkMocks.rewindSession).toHaveBeenCalledWith(
'session-1',
'prompt-3',
{ rewindFiles: false },
);
});
it('rewinds to the snapshot for the edited turn, not the newest one', async () => {
const props = await renderApp();
sdkMocks.getRewindSnapshots.mockResolvedValue({

View file

@ -1612,6 +1612,7 @@ export function EmbeddedApp() {
}}
composerRef={composerRef}
prepareSubmit={async (submission) => {
if (submission.source === 'mcp-app') return undefined;
if (editingMessage) {
const sessionId = submission.sessionId ?? runtime.sessionId;
if (!daemonClient || !sessionId) {

View file

@ -42,6 +42,8 @@ import {
type SessionSourcesResult,
} from '@qwen-code/sdk/daemon';
import type { WebShellApi } from './App';
import { PromptDispatchCancelledError } from './utils/promptAdmission';
import type { McpAppPromptSubmission } from './hooks/useMcpAppMessages';
import type { ArtifactPanel } from './components/artifacts/ArtifactPanel';
import type { WebShellSettingsOptions } from './settings';
import type { WebShellModelManagementOptions } from './modelManagement';
@ -62,6 +64,19 @@ import { serializeStatusMessage } from './components/messages/StatusMessage';
import { loadSplitSessions, saveSplitSessions } from './utils/splitUrl';
import { StandaloneContext } from './config/standalone';
type McpAppMessageHost = Parameters<
typeof import('./hooks/useMcpAppMessages').useMcpAppMessages
>[0];
const mcpAppMessageHost = vi.hoisted(() => ({
current: undefined as McpAppMessageHost | undefined,
}));
vi.mock('./hooks/useMcpAppMessages', () => ({
useMcpAppMessages: (host: McpAppMessageHost) => {
mcpAppMessageHost.current = host;
return { context: undefined, review: null };
},
}));
type StreamingState = 'idle' | 'responding';
type MockConnection = {
@ -11743,6 +11758,7 @@ function makePlanPermissionBlock() {
}
beforeEach(() => {
mcpAppMessageHost.current = undefined;
testState.turnChoices = [];
testState.turnLocations.clear();
testState.provisionalTurns = [];
@ -11776,6 +11792,7 @@ beforeEach(() => {
})),
});
mockConnection.sessionId = 'session-1';
mockConnection.clientId = 'client-1';
mockConnection.sessionContext = undefined;
mockConnection.context = undefined;
mockConnection.workspaceCwd = '/tmp/project';
@ -25769,6 +25786,501 @@ describe('App session callbacks', () => {
});
});
describe('MCP App prompt admission', () => {
function submission(controller = new AbortController()) {
const owner = mcpAppMessageHost.current!.captureOwner();
return {
signal: controller.signal,
isCurrent: () => owner.isCurrent(),
onDispatched: vi.fn(),
onAdmitted: vi.fn(),
} satisfies McpAppPromptSubmission;
}
it('runs host preparation and passes the real receipt without waiting for the turn or touching the draft', async () => {
const turn = deferred<void>();
const prepareSubmit = vi.fn(async () => ({
prompt: 'prepared analysis',
}));
const onSubmitBefore = vi.fn().mockResolvedValue(undefined);
mockSessionActions.sendPrompt.mockImplementationOnce((_text, options) => {
options.onAdmissionStarted();
options.beforeDispatch?.('client-1');
options.onAdmitted({ promptId: 'accepted-app-prompt' });
return turn.promise;
});
renderApp({ prepareSubmit, onSubmitBefore });
await flush();
const controller = new AbortController();
const request = submission(controller);
let result!: Promise<unknown>;
act(() => {
result = mcpAppMessageHost.current!.submit(
'Analyze selected failures',
request,
);
});
await flush();
expect(prepareSubmit).toHaveBeenCalledWith({
sessionId: 'session-1',
prompt: 'Analyze selected failures',
inputAnnotations: [],
source: 'mcp-app',
});
expect(onSubmitBefore).toHaveBeenCalledWith({
sessionId: 'session-1',
prompt: 'prepared analysis',
source: 'mcp-app',
});
expect(request.onDispatched).toHaveBeenCalledOnce();
expect(request.onAdmitted).toHaveBeenCalledWith({
promptId: 'accepted-app-prompt',
});
expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith(
'prepared analysis',
expect.objectContaining({
optimisticUserMessage: undefined,
source: 'mcp-app',
}),
);
expect(testState.prompt).toBe('hello');
expect(editorClear).not.toHaveBeenCalled();
expect(editorCommit).not.toHaveBeenCalled();
expect(mockFollowup.clear).not.toHaveBeenCalled();
controller.abort();
expect(mockSessionActions.cancel).not.toHaveBeenCalled();
await act(async () => {
turn.resolve();
await result;
});
});
it.each([
'abort',
'owner',
'recovery',
'client',
'session',
'write-block',
] as const)(
'does not dispatch after %s invalidates an asynchronous preparation',
async (change) => {
const prepared = deferred<void>();
const prepareSubmit = vi.fn(() => prepared.promise);
const onSubmitBefore = vi.fn().mockResolvedValue(undefined);
const { rerender } = renderApp({ prepareSubmit, onSubmitBefore });
await flush();
const controller = new AbortController();
const request = submission(controller);
let result!: Promise<unknown>;
act(() => {
result = mcpAppMessageHost.current!.submit(
'Analyze failures',
request,
);
});
expect(prepareSubmit).toHaveBeenCalledOnce();
act(() => {
if (change === 'abort') controller.abort();
if (change === 'owner') testState.ownerVersion += 1;
if (change === 'recovery') testState.recoveryVersion += 1;
if (change === 'client') mockConnection.clientId = 'client-2';
if (change === 'session') mockConnection.sessionId = 'session-2';
if (change === 'write-block') mockConnection.loadingTranscript = true;
rerender({ prepareSubmit, onSubmitBefore });
});
if (change === 'write-block') {
act(() => {
mockConnection.loadingTranscript = false;
rerender({ prepareSubmit, onSubmitBefore });
});
}
await act(async () => {
prepared.resolve();
await result;
});
expect(onSubmitBefore).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(request.onDispatched).not.toHaveBeenCalled();
expect(request.onAdmitted).not.toHaveBeenCalled();
expect(editorClear).not.toHaveBeenCalled();
expect(editorCommit).not.toHaveBeenCalled();
expect(mockFollowup.clear).not.toHaveBeenCalled();
},
);
it.each(['abort', 'recovery', 'client'] as const)(
'checks %s again at the final transport boundary',
async (change) => {
const ready = deferred<void>();
const daemonDispatch = vi.fn();
mockSessionActions.sendPrompt.mockImplementationOnce(
async (_text, options) => {
await ready.promise;
options.onAdmissionStarted();
options.beforeDispatch?.('client-1');
daemonDispatch();
},
);
const { rerender } = renderApp();
await flush();
const controller = new AbortController();
const request = submission(controller);
let result!: Promise<unknown>;
act(() => {
result = mcpAppMessageHost.current!.submit(
'Analyze failures',
request,
);
});
await flush();
expect(mockSessionActions.sendPrompt).toHaveBeenCalledOnce();
act(() => {
if (change === 'abort') controller.abort();
if (change === 'recovery') testState.recoveryVersion += 1;
if (change === 'client') mockConnection.clientId = 'client-2';
rerender();
});
await act(async () => {
ready.resolve();
await expect(result).rejects.toMatchObject({ name: 'AbortError' });
});
expect(daemonDispatch).not.toHaveBeenCalled();
expect(request.onDispatched).not.toHaveBeenCalled();
expect(
sessionCatalogController.promptAdmissionUncertain,
).not.toHaveBeenCalled();
},
);
it.each(['prepareSubmit', 'onSubmitBefore'] as const)(
'propagates %s rejection without dispatching or restoring App text into the draft',
async (hook) => {
const rejected = vi.fn().mockRejectedValue(new Error('Host refused'));
renderApp({ [hook]: rejected, onToast: vi.fn() });
await flush();
const request = submission();
await act(async () => {
await expect(
mcpAppMessageHost.current!.submit('Analyze failures', request),
).rejects.toThrow('Host refused');
});
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(request.onDispatched).not.toHaveBeenCalled();
expect(request.onAdmitted).not.toHaveBeenCalled();
expect(testState.prompt).toBe('hello');
expect(editorClear).not.toHaveBeenCalled();
expect(editorCommit).not.toHaveBeenCalled();
expect(mockFollowup.clear).not.toHaveBeenCalled();
},
);
describe.each(['initial', 'retry'] as const)(
'%s SDK dispatch',
(attempt) => {
it.each(['sdk-client', 'rpc-cancel', 'owner-recovery'] as const)(
'refuses transport after %s changes beyond the admission-start notification',
async (change) => {
const ready = deferred<void>();
const daemonDispatch = vi.fn();
let dispatchClientId: string | undefined = 'client-1';
mockSessionActions.sendPrompt.mockImplementationOnce(
async (_text, options) => {
options.onAdmissionStarted();
if (attempt === 'retry') {
options.beforeDispatch(dispatchClientId);
daemonDispatch();
}
await ready.promise;
options.beforeDispatch(dispatchClientId);
daemonDispatch();
options.onAdmitted({ promptId: 'must-not-be-admitted' });
},
);
const { rerender } = renderApp();
await flush();
const controller = new AbortController();
const request = submission(controller);
let result!: Promise<unknown>;
act(() => {
result = mcpAppMessageHost.current!.submit(
'Analyze failures',
request,
);
});
await flush();
const sentAttempts = attempt === 'retry' ? 1 : 0;
expect(request.onDispatched).toHaveBeenCalledTimes(sentAttempts);
expect(daemonDispatch).toHaveBeenCalledTimes(sentAttempts);
act(() => {
if (change === 'sdk-client')
dispatchClientId = 'client-recovered';
if (change === 'rpc-cancel') controller.abort();
if (change === 'owner-recovery') testState.recoveryVersion += 1;
rerender();
});
expect(mockConnection.clientId).toBe('client-1');
await act(async () => {
ready.resolve();
await expect(result).rejects.toMatchObject({
name: 'AbortError',
});
});
expect(daemonDispatch).toHaveBeenCalledTimes(sentAttempts);
expect(request.onAdmitted).not.toHaveBeenCalled();
expect(
sessionCatalogController.promptAdmissionUncertain,
).not.toHaveBeenCalled();
expect(testState.prompt).toBe('hello');
},
);
},
);
it('permits each SDK dispatch with the same live client and notifies each actual attempt', async () => {
const daemonDispatch = vi.fn();
mockSessionActions.sendPrompt.mockImplementationOnce(
async (_text, options) => {
options.onAdmissionStarted();
options.beforeDispatch('client-1');
daemonDispatch();
await Promise.resolve();
options.beforeDispatch('client-1');
daemonDispatch();
options.onAdmitted({ promptId: 'accepted-retry' });
},
);
renderApp();
await flush();
const request = submission();
await act(async () => {
await mcpAppMessageHost.current!.submit('Analyze failures', request);
});
expect(daemonDispatch).toHaveBeenCalledTimes(2);
expect(request.onDispatched).toHaveBeenCalledTimes(2);
expect(request.onAdmitted).toHaveBeenCalledWith({
promptId: 'accepted-retry',
});
});
it.each(['streaming', 'active', 'goal'] as const)(
'refuses a known %s session without enqueuing the App text',
async (busy) => {
if (busy === 'streaming') testState.streamingState = 'responding';
if (busy === 'active') testState.sessionHasActivePrompt = true;
if (busy === 'goal') mockConnection.goalState = activeGoalSnapshot();
const prepareSubmit = vi.fn();
renderApp({ prepareSubmit });
await flush();
await expect(
mcpAppMessageHost.current!.submit('Analyze failures', submission()),
).rejects.toThrow();
expect(prepareSubmit).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
expect(rawEnqueuePrompt).not.toHaveBeenCalled();
expect(testState.prompt).toBe('hello');
},
);
it('leaves dispatch without a receipt distinguishable from admission', async () => {
mockSessionActions.sendPrompt.mockImplementationOnce(
async (_text, options) => {
options.onAdmissionStarted();
options.beforeDispatch?.('client-1');
throw new TypeError('Lost response');
},
);
renderApp();
await flush();
const request = submission();
await act(async () => {
await expect(
mcpAppMessageHost.current!.submit('Analyze failures', request),
).rejects.toThrow('Lost response');
});
expect(request.onDispatched).toHaveBeenCalledOnce();
expect(request.onAdmitted).not.toHaveBeenCalled();
expect(
sessionCatalogController.promptAdmissionUncertain,
).toHaveBeenCalledWith('/tmp/project');
expect(testState.prompt).toBe('hello');
expect(mockSessionActions.sendPrompt).toHaveBeenCalledOnce();
});
it('does not take an accepted App prompt back into an empty composer on cancellation', async () => {
const turn = deferred<void>();
mockSessionActions.sendPrompt.mockImplementationOnce((text, options) => {
testState.blocks = [{ id: 'app-user', kind: 'user', text }];
options.onAdmissionStarted();
options.beforeDispatch?.('client-1');
options.onAdmitted({ promptId: 'app-prompt' });
return turn.promise;
});
mockSessionActions.cancel.mockImplementationOnce(async () =>
turn.resolve(),
);
testState.prompt = '';
renderApp();
await flush();
let result!: Promise<unknown>;
act(() => {
result = mcpAppMessageHost.current!.submit(
'Analyze failures',
submission(),
);
});
await flush();
await act(async () => {
testState.latestChatEditorProps!.onCancel!();
await result;
for (const listener of testState.promptSettledListeners) {
listener({
sessionId: 'session-1',
promptId: 'app-prompt',
outcome: 'cancelled',
originatorClientId: 'client-1',
});
}
});
await flush();
expect(testState.prompt).toBe('');
expect(mockSessionActions.getRewindSnapshots).not.toHaveBeenCalled();
expect(mockSessionActions.rewindSession).not.toHaveBeenCalled();
});
it.each(['working_directory_missing', 'working_directory_compromised'])(
'refuses App messages in a session with %s',
async (errorCode) => {
const prepareSubmit = vi.fn();
mockConnection.standaloneSession = { errorCode };
renderApp({ prepareSubmit });
await flush();
await expect(
mcpAppMessageHost.current!.submit('Analyze failures', submission()),
).rejects.toMatchObject({ name: 'AbortError' });
expect(prepareSubmit).not.toHaveBeenCalled();
expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled();
},
);
it.each(['veto', 'rejected'])(
'preserves the earlier composer retry after an App request is %s',
async (reason) => {
mockSessionActions.sendPrompt.mockImplementationOnce(
async (text, options) => {
testState.blocks = [{ id: 'composer-user', kind: 'user', text }];
options.onAdmissionStarted();
options.onAdmitted({ promptId: 'composer-prompt' });
},
);
const { rerender } = renderApp();
await flush();
const files = [
{
name: 'failure.log',
media_type: 'text/plain',
text: 'original evidence',
},
];
await act(async () => {
testState.latestChatEditorProps!.onSubmit(
'original composer text',
undefined,
files,
);
});
await flush();
act(() => {
testState.blocks = [
...testState.blocks,
{
id: 'composer-error',
kind: 'error',
source: 'turn_error',
message: 'Network error',
},
];
rerender();
});
await flush();
mockSessionActions.sendPrompt.mockImplementationOnce(
async (_text, options) => {
options.onAdmissionStarted();
if (reason === 'veto')
throw new PromptDispatchCancelledError('Source changed');
options.beforeDispatch('client-1');
throw new DaemonHttpError(403, {}, 'Denied');
},
);
await act(async () => {
await expect(
mcpAppMessageHost.current!.submit('App analysis', submission()),
).rejects.toThrow();
});
expect(
sessionCatalogController.promptAdmissionUncertain,
).not.toHaveBeenCalled();
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'y', ctrlKey: true }),
);
});
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(3);
expect(mockSessionActions.sendPrompt).toHaveBeenLastCalledWith(
'original composer text',
expect.objectContaining({ files, retry: true }),
);
},
);
it('does not retry an earlier composer prompt for a later App turn error', async () => {
mockSessionActions.sendPrompt.mockImplementation(
async (text, options) => {
testState.blocks = [
...testState.blocks,
{ id: `user-${text}`, kind: 'user', text },
];
options.onAdmissionStarted();
options.beforeDispatch?.('client-1');
options.onAdmitted({ promptId: `prompt-${text}` });
},
);
const { container, rerender } = renderApp();
await flush();
await clickSubmit(container);
await flush();
testState.prompt = 'unfinished draft';
await act(async () => {
await mcpAppMessageHost.current!.submit(
'Analyze failures',
submission(),
);
});
act(() => {
testState.blocks = [
...testState.blocks,
{
kind: 'error',
source: 'turn_error',
id: 'app-error',
message: 'Network error',
},
];
rerender();
});
await flush();
await act(async () => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'y', ctrlKey: true }),
);
});
expect(mockSessionActions.sendPrompt).toHaveBeenCalledTimes(2);
expect(testState.prompt).toBe('unfinished draft');
});
});
it('gates direct submissions and dispatches compatible submit events', async () => {
const onSubmitBefore = vi.fn().mockResolvedValue(undefined);
const onSessionChange = vi.fn();
@ -25787,6 +26299,12 @@ describe('App session callbacks', () => {
'hello',
expect.objectContaining({ retry: undefined }),
);
expect(
mockSessionActions.sendPrompt.mock.calls.at(-1)?.[1],
).not.toHaveProperty('beforeDispatch');
expect(
mockSessionActions.sendPrompt.mock.calls.at(-1)?.[1],
).not.toHaveProperty('source');
expect(editorCommit).toHaveBeenCalledTimes(1);
expect(editorClear).not.toHaveBeenCalled();
expect(onSessionChange).toHaveBeenCalledWith({

View file

@ -306,7 +306,11 @@ import { ExtensionsManagerPage } from './components/extensions/ExtensionsManager
import { PluginManagerPage } from './components/plugins/PluginManagerPage';
import { ChannelsManagerPage } from './components/channels/ChannelsManagerPage';
import { ShadowDomBoundary } from './components/ShadowDomBoundary';
import { McpAppHostContext } from './mcpAppHostContext';
import { McpAppHostContext, McpAppMessagesContext } from './mcpAppHostContext';
import {
useMcpAppMessages,
type McpAppPromptSubmission,
} from './hooks/useMcpAppMessages';
import {
isItemVisible,
isSettingVisible,
@ -463,7 +467,11 @@ import {
shouldDisableComposerInput,
type ComposerPlaceholderState,
} from './utils/composerInputState';
import { isDefinitelyRejectedPromptAdmission } from './utils/promptAdmission';
import {
isDefinitelyRejectedPromptAdmission,
isMcpAppPromptAdmissionRejected,
PromptDispatchCancelledError,
} from './utils/promptAdmission';
import { base64ToBlob } from './utils/base64';
import type {
ACPToolCall,
@ -755,11 +763,13 @@ function resolvePreparedSubmit(
interface SendPromptOptionsWithRetry {
submittedPrompt?: string;
source?: 'mcp-app';
optimisticUserMessage?: boolean;
images?: PromptImage[];
files?: PromptFile[];
inputAnnotations?: DaemonInputAnnotation[];
retry?: boolean;
beforeDispatch?: (clientId: string | undefined) => void;
onAdmissionStarted?: () => void;
clearComposerOnPromptStart?: boolean;
commitComposerAccepted?: ComposerSubmitCommit;
@ -1667,6 +1677,7 @@ export interface WebShellProps {
onSubmitBefore?: (params: {
sessionId: string | undefined;
prompt: string;
source?: 'mcp-app';
}) => Promise<void>;
}
@ -11147,8 +11158,11 @@ export function App({
inputAnnotations?: DaemonInputAnnotation[];
clearComposerOnPromptStart?: boolean;
commitComposerAccepted?: ComposerSubmitCommit;
source?: 'mcp-app';
isCurrent?: () => boolean;
beforeDispatch?: (clientId: string | undefined) => void;
onAdmissionStarted?: (sessionId: string | undefined) => void;
onAdmitted?: () => void;
onAdmitted?: (admission: { promptId: string }) => void;
onCancelledBeforeAdmission?: () => void;
onOptimisticUserMessage?: (message: OptimisticUserMessage) => void;
onPreparedSubmit?: (prepared: WebShellPreparedSubmit) => void;
@ -11163,6 +11177,7 @@ export function App({
'InvalidStateError',
);
}
const preserveComposer = opts?.source === 'mcp-app';
let preparedPrompt = text;
let preparedInputAnnotations = opts?.inputAnnotations
? [...opts.inputAnnotations]
@ -11220,6 +11235,7 @@ export function App({
};
const admissionSourceIsCurrent = (allocatedSessionId?: string) =>
admissionOwnerIsCurrent(allocatedSessionId) &&
(opts?.isCurrent?.() ?? true) &&
!sessionWriteBlockedRef.current &&
sessionWriteBlockGenerationRef.current ===
admissionSource.writeBlockGeneration;
@ -11231,6 +11247,7 @@ export function App({
sessionId: admissionSource.sessionId,
prompt: preparedPrompt,
inputAnnotations: preparedInputAnnotations,
...(opts?.source ? { source: opts.source } : {}),
});
const resolved = resolvePreparedSubmit(
preparedPrompt,
@ -11248,6 +11265,7 @@ export function App({
await submitBefore({
sessionId: admissionSource.sessionId,
prompt: preparedPrompt,
...(opts?.source ? { source: opts.source } : {}),
});
}
} catch (err) {
@ -11266,6 +11284,7 @@ export function App({
// Restore retry-critical refs so Ctrl+Y doesn't resend the
// cancelled prompt.
restoreCancelledSubmitState();
if (preserveComposer && !isAbortError(err)) throw err;
return;
}
if (!appMountedRef.current) return;
@ -11331,6 +11350,7 @@ export function App({
: existingSessionWorkspaceCwd;
const isSlashPreparedSubmit = preparedPrompt.trimStart().startsWith('/');
if (
!preserveComposer &&
!opts?.retry &&
opts?.optimisticUserMessage !== false &&
!isSlashPreparedSubmit
@ -11359,6 +11379,7 @@ export function App({
snapshot: sessionOwnerGuard.capture(),
};
} else if (
!preserveComposer &&
!opts?.retry &&
opts?.optimisticUserMessage !== false &&
isSlashPreparedSubmit
@ -11368,14 +11389,16 @@ export function App({
// resend the already-succeeded earlier prompt.
disarmSubmittedPromptRetry();
}
setShowRetryHint(false);
if (!preserveComposer) setShowRetryHint(false);
finishPreparing();
if (opts?.ownerRef) opts.ownerRef.current = sessionOwnerGuard.capture();
clearFollowup();
if (opts?.commitComposerAccepted) {
opts.commitComposerAccepted();
} else if (opts?.clearComposerOnPromptStart) {
editorRef.current?.clear();
if (!preserveComposer) {
clearFollowup();
if (opts?.commitComposerAccepted) {
opts.commitComposerAccepted();
} else if (opts?.clearComposerOnPromptStart) {
editorRef.current?.clear();
}
}
let admissionStarted = false;
let admitted = false;
@ -11387,6 +11410,7 @@ export function App({
// attachments only once it is admitted, so both points try.
const trackInFlightPrompt = () => {
if (
preserveComposer ||
inFlightPrompt ||
opts?.retry ||
opts?.optimisticUserMessage === false ||
@ -11409,6 +11433,7 @@ export function App({
...(opts?.submittedPrompt !== undefined
? { submittedPrompt: opts.submittedPrompt }
: {}),
...(opts?.source ? { source: opts.source } : {}),
images,
files,
inputAnnotations:
@ -11417,14 +11442,21 @@ export function App({
: undefined,
optimisticUserMessage: opts?.optimisticUserMessage,
retry: opts?.retry,
...(opts?.beforeDispatch
? { beforeDispatch: opts.beforeDispatch }
: {}),
onAdmissionStarted: () => {
admissionStarted = true;
opts?.onAdmissionStarted?.(
connectionRef.current.sessionId ?? allocatedSessionId,
);
admissionStarted = true;
},
onAdmitted: (admission) => {
admitted = true;
if (preserveComposer && admissionOwnerIsCurrent()) {
disarmSubmittedPromptRetry();
setShowRetryHint(false);
}
if (sessionIdAfterEnsure && promptWorkspaceCwd) {
sessionCatalogController.promptAdmitted(
promptWorkspaceCwd,
@ -11435,7 +11467,7 @@ export function App({
if (inFlightPrompt && admission?.promptId) {
inFlightPrompt.promptId = admission.promptId;
}
opts?.onAdmitted?.();
opts?.onAdmitted?.(admission);
},
};
if (
@ -11482,7 +11514,9 @@ export function App({
if (
admissionStarted &&
!admitted &&
!isDefinitelyRejectedPromptAdmission(error) &&
!(preserveComposer
? isMcpAppPromptAdmissionRejected(error)
: isDefinitelyRejectedPromptAdmission(error)) &&
promptWorkspaceCwd
) {
sessionCatalogController.promptAdmissionUncertain(promptWorkspaceCwd);
@ -12004,6 +12038,96 @@ export function App({
t,
});
const mcpAppQueueBusyRef = useRef(queuedPrompts.length > 0);
mcpAppQueueBusyRef.current = queuedPrompts.length > 0;
const submitMcpAppMessage = useCallback(
async (text: string, submission: McpAppPromptSubmission) => {
const sessionId = connectionRef.current.sessionId;
const clientId = connectionRef.current.clientId;
const workspaceCwd = getComposerWorkspaceCwd();
const sourceVersion = composerSourceVersionRef.current;
const writeBlockGeneration = sessionWriteBlockGenerationRef.current;
const isCurrent = () => {
const current = connectionRef.current;
return (
appMountedRef.current &&
!submission.signal.aborted &&
submission.isCurrent() &&
current.status === 'connected' &&
!current.runtimeStopped &&
Boolean(sessionId && clientId) &&
current.sessionId === sessionId &&
current.clientId === clientId &&
getComposerWorkspaceCwd() === workspaceCwd &&
composerSourceVersionRef.current === sourceVersion &&
!sessionWriteBlockedRef.current &&
sessionWriteBlockGenerationRef.current === writeBlockGeneration &&
!current.standaloneSession?.creationRecovery &&
!isSessionWriterBlockedCode(current.standaloneSession?.errorCode) &&
current.standaloneSession?.errorCode !==
'working_directory_missing' &&
current.standaloneSession?.errorCode !==
'working_directory_compromised' &&
!(
unknownPromptAdmissionRef.current?.payloadAvailable &&
unknownPromptAdmissionRef.current.sessionId === sessionId
)
);
};
const assertCurrent = () => {
if (!isCurrent()) throw new DOMException('Cancelled', 'AbortError');
};
const assertIdle = () => {
if (
streamingStateRef.current !== 'idle' ||
sessionHasActivePromptRef.current ||
isGoalGateBlocked() ||
mcpAppQueueBusyRef.current ||
queuedShellCommandsRef.current.length > 0 ||
shellSubmitInFlightRef.current
) {
throw new Error(t('mcpApp.message.busy'));
}
};
assertCurrent();
assertIdle();
if (promptPreparationOwnerRef.current) {
throw new Error(t('mcpApp.message.busy'));
}
return sendPrompt(text, undefined, undefined, {
source: 'mcp-app',
isCurrent,
beforeAdmission: async () => {
assertCurrent();
assertIdle();
},
beforeDispatch: (dispatchClientId) => {
try {
assertCurrent();
if (dispatchClientId !== clientId) {
throw new DOMException('Session client changed', 'AbortError');
}
submission.onDispatched();
} catch (error) {
throw new PromptDispatchCancelledError('Cancelled before sending', {
cause: error,
});
}
},
onAdmissionStarted: assertCurrent,
onAdmitted: submission.onAdmitted,
});
},
[getComposerWorkspaceCwd, isGoalGateBlocked, sendPrompt, t],
);
const mcpAppMessages = useMcpAppMessages({
enabled: connection.status === 'connected' && !connection.runtimeStopped,
sessionId: connection.sessionId,
clientId: connection.clientId,
captureOwner: () => sessionOwnerGuard.capture({ includeRecovery: true }),
submit: submitMcpAppMessage,
});
const enqueuePrompt = useCallback(
(
text: string,
@ -20282,6 +20406,7 @@ export function App({
{/* prettier-ignore */}
<BrandProvider value={resolvedBrand}>
<I18nProvider language={selectedLanguage}>
<McpAppMessagesContext.Provider value={mcpAppMessages.context}>
<McpAppHostContext.Provider value={workspace.baseUrl}>
{/* prettier-ignore */}
<WebShellPortalRootContext.Provider value={portalRoot}>
@ -20297,6 +20422,7 @@ export function App({
data-compact-sidebar={compactShell ? '' : undefined}
lang={selectedLanguage}
>
{mcpAppMessages.review}
{capacityRecovery && <CapacityRecoveryDialog intent={capacityRecovery} onClose={dismissCapacityRecovery} />}
{!onToast && (
<ToastHost
@ -23375,6 +23501,7 @@ export function App({
</CompactModeContext.Provider>
</WebShellPortalRootContext.Provider>
</McpAppHostContext.Provider>
</McpAppMessagesContext.Provider>
</I18nProvider>
</BrandProvider>
</ThemeProvider>

View file

@ -606,6 +606,18 @@ describe('build artifact — transcript entry (#11031)', () => {
expect(js).not.toMatch(/["'`]settings\./);
});
it('keeps MCP App message review strings in the interactive entry only', () => {
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,
'',
);
expect(js).not.toMatch(/["'`]mcpApp\.message\./);
const interactive = readPackageJavascript();
expect(interactive).toContain('mcpApp.message.title');
expect(interactive).toContain('Review App message');
expect(interactive).toContain('审阅 App 消息');
});
it('keeps trajectory filter strings in the interactive entry only', () => {
const js = readTranscriptBundle().replace(
/^const __qwenWebShellCss=[^\n]*\n/,

View file

@ -4,9 +4,11 @@ import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import {
McpAppHostContext,
McpAppMessagesContext,
McpAppSessionContext,
McpAppToolsContext,
} from '../../mcpAppHostContext';
import { useMcpAppMessages } from '../../hooks/useMcpAppMessages';
import { ThemeProvider, WebShellThemeId } from '../../themeContext';
import type { McpAppDisplay } from './McpApp';
@ -15,6 +17,10 @@ const appBridgeMocks = vi.hoisted(() => ({
last: null as {
onsandboxready?: () => void;
oninitialized?: () => void;
onmessage?: (
params: { role: string; content: Array<Record<string, unknown>> },
extra: { signal: AbortSignal },
) => Promise<unknown>;
oncalltool?: (
params: {
name: string;
@ -122,6 +128,251 @@ function renderApp(
}
describe('McpApp host lifetime', () => {
it('advertises messages only for the bound transcript session', () => {
const requestMessage = vi.fn().mockResolvedValue({});
const { rerender } = renderApp(appDisplay());
const wrap = (sessionId: string) => (
<McpAppMessagesContext.Provider
value={{ sessionId: 'live', requestMessage }}
>
<McpAppSessionContext.Provider value={sessionId}>
<McpApp display={appDisplay()} />
</McpAppSessionContext.Provider>
</McpAppMessagesContext.Provider>
);
rerender(wrap('live'));
expect(appBridgeMocks.lastCapabilities).toMatchObject({
message: { text: {} },
});
expect(appBridgeMocks.last?.onmessage).toBeTypeOf('function');
rerender(wrap('historical'));
expect(appBridgeMocks.lastCapabilities).not.toHaveProperty('message');
expect(appBridgeMocks.last?.onmessage).toBeUndefined();
});
it('validates the role, single text block and UTF-8 byte limit before review', async () => {
const requestMessage = vi.fn().mockResolvedValue({});
const { rerender } = renderApp(appDisplay());
rerender(
<McpAppMessagesContext.Provider
value={{ sessionId: 's', requestMessage }}
>
<McpAppSessionContext.Provider value="s">
<McpApp display={appDisplay()} />
</McpAppSessionContext.Provider>
</McpAppMessagesContext.Provider>,
);
const invoke = appBridgeMocks.last!.onmessage!;
for (const params of [
{ role: 'assistant', content: [{ type: 'text', text: 'no' }] },
{ role: 'system', content: [{ type: 'text', text: 'no' }] },
{ role: 'user', content: [] },
{ role: 'user', content: [{ type: 'text', text: ' ' }] },
{
role: 'user',
content: [
{ type: 'text', text: 'a' },
{ type: 'text', text: 'b' },
],
},
{
role: 'user',
content: [{ type: 'image', data: 'x', mimeType: 'image/png' }],
},
{
role: 'user',
content: [{ type: 'resource', resource: { uri: 'file:///a' } }],
},
{ role: 'user', content: [{ type: 'text', text: '界'.repeat(5462) }] },
]) {
expect(
await invoke(params, { signal: new AbortController().signal }),
).toEqual({ isError: true });
}
expect(requestMessage).not.toHaveBeenCalled();
const text = '界'.repeat(5461) + 'a';
expect(
await invoke(
{ role: 'user', content: [{ type: 'text', text }] },
{ signal: new AbortController().signal },
),
).toEqual({});
expect(requestMessage).toHaveBeenCalledWith(
expect.objectContaining({
serverName: 'demo',
resourceUri: 'ui://demo/app',
text,
}),
);
});
it('releases settled message requests and aborts pending ones on resource replacement', async () => {
let settle!: (result: object) => void;
const requestMessage = vi.fn(
(_request: { signal: AbortSignal }) =>
new Promise<object>((resolve) => {
settle = resolve;
}),
);
const { rerender } = renderApp(appDisplay());
const wrap = (resourceUri: string) => (
<McpAppMessagesContext.Provider
value={{ sessionId: 's', requestMessage }}
>
<McpAppSessionContext.Provider value="s">
<McpApp display={appDisplay({ resourceUri })} />
</McpAppSessionContext.Provider>
</McpAppMessagesContext.Provider>
);
rerender(wrap('ui://demo/app'));
const oldBridge = appBridgeMocks.last!;
const invoke = oldBridge.onmessage!;
const params = {
role: 'user',
content: [{ type: 'text', text: 'review me' }],
};
const first = invoke(params, { signal: new AbortController().signal });
expect(
await invoke(params, { signal: new AbortController().signal }),
).toEqual({ isError: true });
expect(requestMessage).toHaveBeenCalledTimes(1);
rerender(wrap('ui://demo/replacement'));
expect(requestMessage.mock.calls[0][0].signal.aborted).toBe(true);
expect(oldBridge.onmessage).toBeUndefined();
expect(
await invoke(params, { signal: new AbortController().signal }),
).toEqual({ isError: true });
settle({ isError: true });
await first;
const second = appBridgeMocks.last!.onmessage!(params, {
signal: new AbortController().signal,
});
settle({});
expect(await second).toEqual({});
const third = appBridgeMocks.last!.onmessage!(params, {
signal: new AbortController().signal,
});
expect(requestMessage).toHaveBeenCalledTimes(3);
settle({ isError: true });
await third;
});
it.each(['client recovery', 'transport reconnect'])(
'preserves the iframe and tool calls across %s while invalidating old reviews',
async (change) => {
let finishTool!: (result: { content: [] }) => void;
const callTool = vi.fn(
(_request: unknown, _signal: AbortSignal) =>
new Promise<{ content: [] }>((resolve) => {
finishTool = resolve;
}),
);
const submit = vi.fn().mockResolvedValue(undefined);
const captureOwner = () => ({ isCurrent: () => true });
function BoundApp({ clientId = 'client-a', enabled = true }) {
const { context } = useMcpAppMessages({
sessionId: 's',
clientId,
enabled,
captureOwner,
submit,
});
return (
<McpAppMessagesContext.Provider value={context}>
<McpAppToolsContext.Provider value={{ sessionId: 's', callTool }}>
<McpAppSessionContext.Provider value="s">
<McpApp display={appDisplay()} />
</McpAppSessionContext.Provider>
</McpAppToolsContext.Provider>
</McpAppMessagesContext.Provider>
);
}
const { container, rerender } = renderApp(appDisplay());
await act(async () => rerender(<BoundApp />));
const bridge = appBridgeMocks.last!;
const iframe = container.querySelector('iframe');
const src = iframe!.src;
const constructed = appBridgeMocks.constructed;
const params = {
role: 'user',
content: [{ type: 'text', text: 'review' }],
};
let pendingMessage!: Promise<unknown>;
let pendingTool!: Promise<unknown>;
await act(async () => {
pendingMessage = bridge.onmessage!(params, {
signal: new AbortController().signal,
});
pendingTool = bridge.oncalltool!(
{ name: 'query' },
{ signal: new AbortController().signal },
);
});
await act(async () =>
rerender(
<BoundApp
clientId={change === 'client recovery' ? 'client-b' : 'client-a'}
enabled={change !== 'transport reconnect'}
/>,
),
);
expect(await pendingMessage).toEqual({ isError: true });
expect(appBridgeMocks.last).toBe(bridge);
expect(appBridgeMocks.constructed).toBe(constructed);
expect(container.querySelector('iframe')).toBe(iframe);
expect(iframe!.src).toBe(src);
expect(callTool.mock.calls[0][1].aborted).toBe(false);
await act(async () => rerender(<BoundApp clientId="client-b" />));
expect(appBridgeMocks.last).toBe(bridge);
let fresh!: Promise<unknown>;
await act(async () => {
fresh = bridge.onmessage!(params, {
signal: new AbortController().signal,
});
});
await act(async () =>
rerender(<BoundApp clientId="client-b" enabled={false} />),
);
expect(await fresh).toEqual({ isError: true });
expect(submit).not.toHaveBeenCalled();
finishTool({ content: [] });
await pendingTool;
},
);
it('propagates RPC cancellation and revokes messages on failed initialization', async () => {
appBridgeMocks.sendToolInput.mockRejectedValueOnce(
new Error('handoff failed'),
);
let signal: AbortSignal | undefined;
const requestMessage = vi.fn((request: { signal: AbortSignal }) => {
signal = request.signal;
return Promise.resolve({ isError: true });
});
const { rerender } = renderApp(appDisplay());
rerender(
<McpAppMessagesContext.Provider
value={{ sessionId: 's', requestMessage }}
>
<McpAppSessionContext.Provider value="s">
<McpApp display={appDisplay()} />
</McpAppSessionContext.Provider>
</McpAppMessagesContext.Provider>,
);
const bridge = appBridgeMocks.last!;
const caller = new AbortController();
await bridge.onmessage!(
{ role: 'user', content: [{ type: 'text', text: 'review' }] },
{ signal: caller.signal },
);
caller.abort();
expect(signal?.aborted).toBe(true);
await act(async () => {
bridge.oninitialized?.();
});
expect(bridge.onmessage).toBeUndefined();
});
it.each([
'sendSandboxResourceReady',
'sendToolInput',

View file

@ -5,6 +5,7 @@ import {
} from '@modelcontextprotocol/ext-apps/app-bridge';
import {
McpAppHostContext,
McpAppMessagesContext,
McpAppSessionContext,
McpAppToolsContext,
} from '../../mcpAppHostContext';
@ -162,6 +163,13 @@ export function McpApp({ display }: { display: McpAppDisplay }) {
const daemonBaseUrl = useContext(McpAppHostContext);
const sessionId = useContext(McpAppSessionContext);
const tools = useContext(McpAppToolsContext);
const messages = useContext(McpAppMessagesContext);
const messageHost =
sessionId && messages?.sessionId === sessionId ? messages : undefined;
const requestMessage =
import.meta.env.MODE === 'transcript'
? undefined
: messageHost?.requestMessage;
const callTool =
sessionId && tools?.sessionId === sessionId ? tools.callTool : undefined;
const theme = useTheme();
@ -203,6 +211,7 @@ export function McpApp({ display }: { display: McpAppDisplay }) {
{ name: 'qwen-code-web-shell', version: '0.0.1' },
{
...(callTool ? { serverTools: {} } : {}),
...(requestMessage ? { message: { text: {} } } : {}),
sandbox: {
...(current.csp ? { csp: current.csp } : {}),
},
@ -211,6 +220,38 @@ export function McpApp({ display }: { display: McpAppDisplay }) {
);
bridgeRef.current = bridge;
const appAbort = new AbortController();
let messagePending = false;
if (requestMessage) {
bridge.onmessage = async (params, extra) => {
const signal = AbortSignal.any([extra.signal, appAbort.signal]);
const content = params.content;
if (
signal.aborted ||
messagePending ||
params.role !== 'user' ||
content.length !== 1 ||
content[0]?.type !== 'text' ||
content[0].text.length > 16 * 1024 ||
!content[0].text.trim() ||
new TextEncoder().encode(content[0].text).byteLength > 16 * 1024
) {
return { isError: true };
}
messagePending = true;
try {
return await requestMessage({
serverName: current.serverName,
resourceUri: current.resourceUri,
text: content[0].text,
signal,
});
} catch {
return { isError: true };
} finally {
messagePending = false;
}
};
}
if (callTool) {
bridge.oncalltool = async (params, extra) => {
const signal = AbortSignal.any([extra.signal, appAbort.signal]);
@ -262,6 +303,7 @@ export function McpApp({ display }: { display: McpAppDisplay }) {
active = false;
clearTimeout(readyTimeout);
bridge.oncalltool = undefined;
bridge.onmessage = undefined;
appAbort.abort();
iframe.removeAttribute('src');
bridgeRef.current = null;
@ -326,6 +368,7 @@ export function McpApp({ display }: { display: McpAppDisplay }) {
active = false;
clearTimeout(readyTimeout);
bridge.oncalltool = undefined;
bridge.onmessage = undefined;
appAbort.abort();
bridgeRef.current = null;
const unload = () => {
@ -350,6 +393,8 @@ export function McpApp({ display }: { display: McpAppDisplay }) {
sandboxUrl,
dataSandbox,
callTool,
requestMessage,
sessionId,
display.serverName,
display.resourceUri,
display.html,

View file

@ -285,6 +285,7 @@ export interface WebShellSubmitSnapshot {
sessionId?: string;
prompt: string;
inputAnnotations: readonly DaemonInputAnnotation[];
source?: 'mcp-app';
}
export type UserMessageContentRenderer = (

View file

@ -181,7 +181,470 @@ describe('context usage counter reconciliation', () => {
);
});
describe('sendPrompt admission notices', () => {
it.each([
{
name: 'MCP App response loss',
source: 'mcp-app' as const,
error: new TypeError('Failed to fetch'),
noticeCount: 0,
},
{
name: 'ordinary composer response loss',
source: undefined,
error: new TypeError('Failed to fetch'),
noticeCount: 1,
},
{
name: 'MCP App definite rejection',
source: 'mcp-app' as const,
error: new DaemonHttpError(
413,
{ code: 'prompt_too_large' },
'Prompt too large',
),
noticeCount: 0,
},
])(
'preserves the original error for $name',
async ({ source, error, noticeCount }) => {
const session = createMockSession('session-a');
session.submitPrompt.mockRejectedValueOnce(error);
const addNotice = vi.fn();
const onAdmitted = vi.fn();
const { actions } = createActionsHarness({ session, addNotice });
await expect(
actions.sendPrompt('Analyze failures', {
...(source ? { source } : {}),
onAdmitted,
}),
).rejects.toBe(error);
expect(session.submitPrompt).toHaveBeenCalledOnce();
expect(onAdmitted).not.toHaveBeenCalled();
expect(addNotice).toHaveBeenCalledTimes(noticeCount);
if (noticeCount > 0) {
expect(addNotice).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'send_prompt',
message: 'Prompt failed: Failed to fetch',
}),
);
}
},
);
it('keeps notices for MCP App errors before admission starts', async () => {
const session = createMockSession('session-a');
const error = new Error('Admission preparation failed');
const addNotice = vi.fn();
const { actions } = createActionsHarness({ session, addNotice });
await expect(
actions.sendPrompt('Analyze failures', {
source: 'mcp-app',
onAdmissionStarted: () => {
throw error;
},
}),
).rejects.toBe(error);
expect(session.submitPrompt).not.toHaveBeenCalled();
expect(addNotice).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
operation: 'send_prompt',
message: 'Prompt failed: Admission preparation failed',
}),
);
});
it('keeps notices for MCP App errors after a receipt arrives', async () => {
const session = createMockSession('session-a');
const error = new Error('Turn connection lost');
const addNotice = vi.fn();
const onAdmitted = vi.fn();
const { actions, activePromptsRef } = createActionsHarness({
session,
addNotice,
});
const pending = actions
.sendPrompt('Analyze failures', {
source: 'mcp-app',
onAdmitted,
})
.catch((error: unknown) => error);
await vi.waitFor(() =>
expect(onAdmitted).toHaveBeenCalledExactlyOnceWith({
promptId: 'prompt-1',
}),
);
const active = activePromptsRef.current.get('session-a');
expect(active?.reject).toEqual(expect.any(Function));
active?.reject?.(error);
expect(await pending).toBe(error);
expect(addNotice).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
operation: 'send_prompt',
message: 'Prompt failed: Turn connection lost',
}),
);
});
});
describe('sendPrompt client registration recovery', () => {
it('keeps a cancelled recovery request rejected and admits a fresh guarded request', async () => {
const resume = createDeferred<Response>();
const promptClientIds: Array<string | null> = [];
const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
const url = String(input);
if (url.endsWith('/resume')) return resume.promise;
if (url.endsWith('/prompt')) {
promptClientIds.push(
new Headers(init?.headers).get('X-Qwen-Client-Id'),
);
return new Response(
JSON.stringify(
promptClientIds.length === 1
? { code: 'invalid_client_id', error: 'unknown client' }
: { promptId: 'fresh-prompt', lastEventId: 0 },
),
{ status: promptClientIds.length === 1 ? 400 : 202 },
);
}
throw new Error(`Unexpected request: ${url}`);
});
const session = new DaemonSessionClient({
client: new DaemonClient({ baseUrl: 'http://daemon', fetch }),
session: {
sessionId: 'session-a',
clientId: 'client-a',
attached: true,
workspaceCwd: '/workspace',
},
});
const connection: DaemonConnectionState = {
status: 'connected',
sessionId: 'session-a',
clientId: 'client-a',
workspaceCwd: '/workspace',
currentModel: 'model-a',
};
const h = createActionsHarness({ connection });
h.sessionRef.current = session;
const rpc = new AbortController();
const onAdmitted = vi.fn();
const beforeDispatch = vi.fn((clientId: string | undefined) => {
if (rpc.signal.aborted || clientId !== connection.clientId) {
throw new DOMException('Source expired', 'AbortError');
}
});
const cancelled = h.actions.sendPrompt('Analyze failures', {
beforeDispatch,
onAdmitted,
});
await vi.waitFor(() =>
expect(
fetch.mock.calls.some(([url]) => String(url).endsWith('/resume')),
).toBe(true),
);
rpc.abort();
resume.resolve(
new Response(
JSON.stringify({
sessionId: 'session-a',
clientId: 'client-recovered',
attached: true,
workspaceCwd: '/workspace',
state: {},
}),
{ status: 200 },
),
);
expect(await cancelled).toEqual({ stopReason: 'cancelled' });
expect(promptClientIds).toEqual(['client-a']);
expect(beforeDispatch.mock.calls).toEqual([
['client-a'],
['client-recovered'],
]);
expect(onAdmitted).not.toHaveBeenCalled();
expect(h.getConnection()).toEqual({
...connection,
clientId: 'client-recovered',
});
const reviewedClientId = h.getConnection().clientId;
const freshGuard = vi.fn((clientId: string | undefined) => {
if (clientId !== reviewedClientId) {
throw new DOMException('Source expired', 'AbortError');
}
});
const fresh = h.actions.sendPrompt('Analyze failures again', {
beforeDispatch: freshGuard,
onAdmitted,
});
await vi.waitFor(() =>
expect(onAdmitted).toHaveBeenCalledExactlyOnceWith({
promptId: 'fresh-prompt',
}),
);
expect(freshGuard).toHaveBeenCalledExactlyOnceWith('client-recovered');
expect(promptClientIds).toEqual(['client-a', 'client-recovered']);
const active = h.activePromptsRef.current.get('session-a');
expect(active?.resolve).toEqual(expect.any(Function));
active?.resolve?.({ stopReason: 'end_turn' });
expect(await fresh).toEqual({ stopReason: 'end_turn' });
});
it.each(['session-b', 'session-a'])(
'does not publish a recovered client to a replacement SDK instance for %s',
async (replacementSessionId) => {
const resume = createDeferred<Response>();
let promptCount = 0;
const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/resume')) return resume.promise;
if (url.endsWith('/prompt')) {
promptCount++;
return new Response(
JSON.stringify({
code: 'invalid_client_id',
error: 'unknown client',
}),
{ status: 400 },
);
}
throw new Error(`Unexpected request: ${url}`);
});
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
const session = new DaemonSessionClient({
client,
session: {
sessionId: 'session-a',
clientId: 'client-a',
attached: true,
workspaceCwd: '/workspace',
},
});
const h = createActionsHarness({
connection: {
status: 'connected',
sessionId: 'session-a',
clientId: 'client-a',
},
});
h.sessionRef.current = session;
const onAdmitted = vi.fn();
const pending = h.actions.sendPrompt('Analyze failures', {
onAdmitted,
beforeDispatch: (clientId) => {
if (h.sessionRef.current !== session || clientId !== 'client-a') {
throw new DOMException('Source expired', 'AbortError');
}
},
});
await vi.waitFor(() =>
expect(
fetch.mock.calls.some(([url]) => String(url).endsWith('/resume')),
).toBe(true),
);
const replacement = new DaemonSessionClient({
client,
session: {
sessionId: replacementSessionId,
clientId: 'client-replacement',
attached: true,
workspaceCwd: '/replacement',
},
});
h.sessionRef.current = replacement;
const replacementConnection: DaemonConnectionState = {
status: 'connected',
sessionId: replacementSessionId,
clientId: 'client-replacement',
workspaceCwd: '/replacement',
currentModel: 'model-replacement',
};
h.replaceConnection(replacementConnection);
resume.resolve(
new Response(
JSON.stringify({
sessionId: 'session-a',
clientId: 'client-recovered',
attached: true,
workspaceCwd: '/workspace',
state: {},
}),
{ status: 200 },
),
);
expect(await pending).toEqual({ stopReason: 'cancelled' });
expect(session.clientId).toBe('client-recovered');
expect(promptCount).toBe(1);
expect(onAdmitted).not.toHaveBeenCalled();
expect(h.getConnection()).toBe(replacementConnection);
expect(h.sessionRef.current).toBe(replacement);
},
);
it('preserves automatic recovery and admission without a dispatch guard', async () => {
const promptClientIds: Array<string | null> = [];
const fetch = vi.fn<typeof globalThis.fetch>(async (input, init) => {
const url = String(input);
if (url.endsWith('/resume')) {
return new Response(
JSON.stringify({
sessionId: 'session-a',
clientId: 'client-recovered',
attached: true,
workspaceCwd: '/workspace',
state: {},
}),
{ status: 200 },
);
}
if (url.endsWith('/prompt')) {
promptClientIds.push(
new Headers(init?.headers).get('X-Qwen-Client-Id'),
);
return new Response(
JSON.stringify(
promptClientIds.length === 1
? { code: 'invalid_client_id', error: 'unknown client' }
: { promptId: 'recovered-prompt', lastEventId: 0 },
),
{ status: promptClientIds.length === 1 ? 400 : 202 },
);
}
throw new Error(`Unexpected request: ${url}`);
});
const session = new DaemonSessionClient({
client: new DaemonClient({ baseUrl: 'http://daemon', fetch }),
session: {
sessionId: 'session-a',
clientId: 'client-a',
attached: true,
workspaceCwd: '/workspace',
},
});
const connection: DaemonConnectionState = {
status: 'connected',
sessionId: 'session-a',
clientId: 'client-a',
workspaceCwd: '/workspace',
currentModel: 'model-a',
};
const h = createActionsHarness({ connection });
h.sessionRef.current = session;
const onAdmitted = vi.fn();
const pending = h.actions.sendPrompt('Normal composer prompt', {
onAdmitted,
});
await vi.waitFor(() =>
expect(onAdmitted).toHaveBeenCalledExactlyOnceWith({
promptId: 'recovered-prompt',
}),
);
expect(promptClientIds).toEqual(['client-a', 'client-recovered']);
const active = h.activePromptsRef.current.get('session-a');
expect(active?.resolve).toEqual(expect.any(Function));
active?.resolve?.({ stopReason: 'end_turn' });
expect(await pending).toEqual({ stopReason: 'end_turn' });
expect(h.getConnection()).toEqual({
...connection,
clientId: 'client-recovered',
});
});
});
describe('getConnectionAfterSessionClear', () => {
it('rechecks the MCP App source before an SDK recovery retry', async () => {
const resume = createDeferred<Response>();
let promptCount = 0;
const fetch = vi.fn<typeof globalThis.fetch>(async (input) => {
const url = String(input);
if (url.endsWith('/resume')) return resume.promise;
if (url.endsWith('/prompt')) {
promptCount++;
return new Response(
JSON.stringify(
promptCount === 1
? { code: 'invalid_client_id', error: 'unknown client' }
: { promptId: 'must-not-be-admitted', lastEventId: 0 },
),
{ status: promptCount === 1 ? 400 : 202 },
);
}
throw new Error(`Unexpected request: ${url}`);
});
const session = new DaemonSessionClient({
client: new DaemonClient({ baseUrl: 'http://daemon', fetch }),
session: {
sessionId: 'session-a',
clientId: 'client-a',
attached: true,
workspaceCwd: '/workspace',
},
});
const addNotice = vi.fn();
const h = createActionsHarness({ addNotice });
h.sessionRef.current = session;
let sourceCurrent = true;
const onAdmissionStarted = vi.fn();
const onAdmitted = vi.fn(() => {
h.activePromptsRef.current
.get('session-a')
?.resolve?.({ stopReason: 'end_turn' });
});
const beforeDispatch = vi.fn((clientId: string | undefined) => {
if (!sourceCurrent || clientId !== 'client-a') {
throw new DOMException('Source expired', 'AbortError');
}
});
const result = h.actions
.sendPrompt('Analyze failures', {
source: 'mcp-app',
onAdmissionStarted,
onAdmitted,
beforeDispatch,
})
.catch((error: unknown) => error);
await vi.waitFor(() =>
expect(
fetch.mock.calls.some(([url]) => String(url).endsWith('/resume')),
).toBe(true),
);
sourceCurrent = false;
resume.resolve(
new Response(
JSON.stringify({
sessionId: 'session-a',
clientId: 'client-b',
attached: true,
workspaceCwd: '/workspace',
state: {},
}),
{ status: 200 },
),
);
expect(await result).toMatchObject({ name: 'AbortError' });
expect(promptCount).toBe(1);
expect(beforeDispatch.mock.calls).toEqual([['client-a'], ['client-b']]);
expect(onAdmissionStarted).toHaveBeenCalledOnce();
expect(onAdmitted).not.toHaveBeenCalled();
expect(addNotice).not.toHaveBeenCalled();
expect(h.store.dispatch).toHaveBeenCalledWith({
type: 'assistant.done',
reason: 'cancelled',
});
expect(h.activePromptsRef.current.has('session-a')).toBe(false);
expect(h.setPromptStatus).toHaveBeenLastCalledWith('idle');
});
it('uses the current provider mode for mid-turn admission', async () => {
const session = {
...createMockSession('session-a'),

View file

@ -1219,6 +1219,7 @@ export function createDaemonSessionActions({
controller: ctrl,
replayedTurnEvents: new Map(),
});
let admissionError = false;
try {
// Normalize images once and pass the same array to both calls
const normalizedImages: Array<{ data: string; mimeType: string }> = (
@ -1322,8 +1323,10 @@ export function createDaemonSessionActions({
accepted = await session.submitPrompt(
promptRequest as Parameters<typeof session.submitPrompt>[0],
ctrl.signal,
...(options?.beforeDispatch ? [options.beforeDispatch] : []),
);
} catch (error) {
admissionError = true;
publishStandaloneWorkingDirectoryError(sessionId, error);
const definiteRejection = isDefinitePromptAdmissionRejection(error);
if (definiteRejection) {
@ -1342,6 +1345,19 @@ export function createDaemonSessionActions({
);
}
throw error;
} finally {
setConnection((current) => {
const clientId = session.clientId;
if (
sessionRef.current !== session ||
current.sessionId !== session.sessionId ||
!clientId ||
current.clientId === clientId
) {
return current;
}
return { ...current, clientId };
});
}
if (shouldAppendOptimisticMessage && !optimisticMessageAppended) {
store.appendLocalUserMessage(
@ -1385,6 +1401,7 @@ export function createDaemonSessionActions({
if (sessionRef.current?.sessionId === sessionId) {
store.dispatch({ type: 'assistant.done', reason: 'cancelled' });
}
if (options?.source === 'mcp-app' && admissionError) throw error;
return { stopReason: 'cancelled' };
}
if (isDaemonTurnError(error)) {
@ -1393,6 +1410,9 @@ export function createDaemonSessionActions({
if (sessionRef.current?.sessionId === sessionId) {
store.dispatch({ type: 'assistant.done', reason: 'error' });
}
if (options?.source === 'mcp-app' && admissionError) {
throw error;
}
throw dispatchActionError(
addNotice,
'Prompt failed',
@ -1648,9 +1668,10 @@ export function createDaemonSessionActions({
options?.onAdmissionStarted?.();
let accepted: Awaited<ReturnType<typeof session.submitPrompt>>;
try {
accepted = await session.submitPrompt(
promptRequest as Parameters<typeof session.submitPrompt>[0],
);
const request = promptRequest as Parameters<
typeof session.submitPrompt
>[0];
accepted = await session.submitPrompt(request);
} catch (error) {
publishStandaloneWorkingDirectoryError(session.sessionId, error);
const definiteRejection = isDefinitePromptAdmissionRejection(error);

View file

@ -408,6 +408,7 @@ export interface DaemonCommandInfo {
export interface SendPromptOptions {
/** Original text declared at the user submission boundary, before host preparation. */
submittedPrompt?: string;
source?: 'mcp-app';
optimisticUserMessage?: boolean;
images?: DaemonPromptImage[];
files?: DaemonPromptFile[];
@ -420,6 +421,8 @@ export interface SendPromptOptions {
retry?: boolean;
/** Fired after local validation, immediately before dispatch to the daemon. */
onAdmissionStarted?: () => void;
/** Revalidates the caller before every HTTP attempt, including client recovery. */
beforeDispatch?: (clientId: string | undefined) => void;
/**
* Fired once the daemon has ACCEPTED the prompt (admission), before the turn
* runs to completion. Lets a caller act on "the prompt reached the session"
@ -431,7 +434,8 @@ export interface SendPromptOptions {
onAdmitted?: (admission: { promptId: string }) => void;
}
export interface SubmitPromptOptions extends SendPromptOptions {
export interface SubmitPromptOptions
extends Omit<SendPromptOptions, 'beforeDispatch' | 'source'> {
sessionId?: string;
signal?: AbortSignal;
}

View file

@ -0,0 +1,534 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DaemonHttpError } from '@qwen-code/sdk/daemon';
import { I18nProvider, type WebShellLanguage } from '../i18n';
import { WebShellPortalRootContext } from '../portalRoot';
import type { McpAppMessages } from '../mcpAppHostContext';
import { PromptDispatchCancelledError } from '../utils/promptAdmission';
import {
useMcpAppMessages,
type McpAppPromptSubmission,
} from './useMcpAppMessages';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
vi.useRealTimers();
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function setup() {
const submit = vi
.fn<(text: string, options: McpAppPromptSubmission) => Promise<unknown>>()
.mockResolvedValue(undefined);
let owner = {};
let context: McpAppMessages | undefined;
const captureOwner = () => {
const captured = owner;
return { isCurrent: () => captured === owner };
};
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
function Harness({
sessionId = 'session-a',
clientId = 'client-a',
enabled = true,
language = 'en' as WebShellLanguage,
}) {
const message = useMcpAppMessages({
sessionId,
clientId,
enabled,
captureOwner,
submit,
});
context = message.context;
return (
<I18nProvider language={language}>
<WebShellPortalRootContext.Provider value={container}>
{message.review}
</WebShellPortalRootContext.Provider>
</I18nProvider>
);
}
const render = (props = {}) => act(() => root.render(<Harness {...props} />));
render();
mounted.push({ root, container });
const request = (signal = new AbortController().signal) => {
let result!: ReturnType<McpAppMessages['requestMessage']>;
act(() => {
result = context!.requestMessage({
serverName: 'trusted-host-server',
resourceUri: 'ui://host/resource',
text: '<script>untrusted text</script>\nFull message',
signal,
});
});
return result;
};
const button = (text: string) =>
Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === text,
)!;
return {
submit,
container,
render,
request,
button,
context: () => context,
recover: () => {
owner = {};
render();
},
};
}
describe('MCP App host message review', () => {
it('uses the host language inside the portal', async () => {
const h = setup();
h.render({ language: 'zh-CN' });
const result = h.request();
expect(h.container.textContent).toContain('审阅 App 消息');
expect(h.container.textContent).toContain('目标会话');
await act(async () => h.button('取消').click());
expect(await result).toEqual({ isError: true });
});
it('waits for host confirmation and resolves at the real receipt, before turn completion', async () => {
const modelTurn = deferred<unknown>();
const h = setup();
h.submit.mockReturnValue(modelTurn.promise);
const result = h.request();
let settled = false;
void result.then(() => {
settled = true;
});
expect(h.submit).not.toHaveBeenCalled();
expect(h.container.textContent).toContain('trusted-host-server');
expect(h.container.textContent).toContain('ui://host/resource');
expect(h.container.textContent).toContain('session-a');
expect(h.container.querySelector('pre')?.textContent).toBe(
'<script>untrusted text</script>\nFull message',
);
expect(h.container.querySelector('script')).toBeNull();
await act(async () => {
const send = h.button('Send to session');
send.click();
send.click();
});
expect(h.submit).toHaveBeenCalledTimes(1);
expect(settled).toBe(false);
const submission = h.submit.mock.calls[0][1];
act(() => submission.onDispatched());
expect(settled).toBe(false);
await act(async () =>
submission.onAdmitted({ promptId: 'real-daemon-receipt' }),
);
expect(await result).toEqual({});
expect(h.container.textContent).toContain('real-daemon-receipt');
expect(h.container.textContent).toContain('Analysis may still be running');
await act(async () => modelTurn.reject(new Error('later model failure')));
expect(h.container.textContent).toContain('The session accepted');
});
it('rejects duplicate reviews and cancels with zero submissions', async () => {
const h = setup();
const first = h.request();
expect(await h.request()).toEqual({ isError: true });
await act(async () => h.button('cancel').click());
expect(await first).toEqual({ isError: true });
expect(h.submit).not.toHaveBeenCalled();
expect(h.container.querySelector('[role="dialog"]')).toBeNull();
});
it.each(['cancel', 'rpc', 'recovery', 'session', 'client', 'disconnected'])(
'invalidates async preparation on %s without a late write',
async (reason) => {
const h = setup();
const gate = deferred<void>();
const writes = vi.fn();
h.submit.mockImplementation(async (_text, submission) => {
await gate.promise;
if (!submission.isCurrent() || submission.signal.aborted) return;
submission.onDispatched();
writes();
submission.onAdmitted({ promptId: 'unexpected' });
});
const rpc = new AbortController();
const result = h.request(rpc.signal);
await act(async () => h.button('Send to session').click());
await act(async () => {
if (reason === 'cancel') h.button('cancel').click();
if (reason === 'rpc') rpc.abort();
if (reason === 'recovery') h.recover();
if (reason === 'session') {
h.render({ sessionId: 'session-b' });
}
if (reason === 'client') h.render({ clientId: 'client-b' });
if (reason === 'disconnected') h.render({ enabled: false });
});
if (reason === 'session') h.render({ sessionId: 'session-a' });
expect(await result).toEqual({ isError: true });
await act(async () => gate.resolve());
expect(writes).not.toHaveBeenCalled();
expect(h.submit).toHaveBeenCalledTimes(1);
},
);
it('keeps an unknown receipt visible after card cancellation and never resubmits', async () => {
const h = setup();
const turn = deferred<void>();
h.submit.mockImplementation(async (_text, submission) => {
submission.onDispatched();
await turn.promise;
});
const rpc = new AbortController();
const result = h.request(rpc.signal);
const settled = vi.fn();
void result.then(settled);
await act(async () => h.button('Send to session').click());
await act(async () => rpc.abort());
expect(await result).toEqual({ isError: true });
expect(settled).toHaveBeenCalledOnce();
expect(h.container.textContent).toContain(
'Acceptance could not be confirmed',
);
expect(h.container.textContent).toContain('session-a');
expect(await h.request()).toEqual({ isError: true });
expect(h.submit).toHaveBeenCalledTimes(1);
await act(async () =>
h.submit.mock.calls[0][1].onAdmitted({ promptId: 'late-real-receipt' }),
);
expect(h.container.textContent).toContain('The session accepted');
expect(h.container.textContent).toContain('late-real-receipt');
expect(await result).toEqual({ isError: true });
await act(async () => turn.resolve());
});
it.each(['timeout', 'stop waiting'])(
'releases an unknown request after %s when the App RPC ends',
async (reason) => {
vi.useFakeTimers();
const h = setup();
const turn = deferred<void>();
h.submit.mockImplementation(async (_text, submission) => {
submission.onDispatched();
await turn.promise;
});
const rpc = new AbortController();
const settled = vi.fn();
const result = h.request(rpc.signal);
void result.then(settled);
await act(async () => h.button('Send to session').click());
await act(async () => {
if (reason === 'timeout') vi.advanceTimersByTime(90_000);
else h.button('Stop waiting for receipt').click();
});
expect(settled).not.toHaveBeenCalled();
await act(async () => rpc.abort());
expect(settled).toHaveBeenCalledWith({ isError: true });
expect(h.container.textContent).toContain(
'Acceptance could not be confirmed',
);
await act(async () => h.button('close').click());
const next = h.request();
expect(h.container.textContent).toContain('Review the full message');
await act(async () =>
h.submit.mock.calls[0][1].onAdmitted({ promptId: 'late-old-receipt' }),
);
expect(h.container.textContent).not.toContain('late-old-receipt');
expect(h.container.textContent).toContain('Review the full message');
expect(h.submit).toHaveBeenCalledOnce();
await act(async () => h.button('cancel').click());
expect(await next).toEqual({ isError: true });
await act(async () => turn.resolve());
},
);
it.each([false, true])(
'classifies dispatch failure using the real rejection boundary (definite=%s)',
async (definite) => {
const h = setup();
h.submit.mockImplementation(async (_text, submission) => {
submission.onDispatched();
throw definite
? new DaemonHttpError(413, undefined, 'too large')
: new Error('response lost');
});
const result = h.request();
await act(async () => h.button('Send to session').click());
expect(await result).toEqual({ isError: true });
expect(h.container.textContent).toContain(
definite
? 'The message was not accepted'
: 'Acceptance could not be confirmed',
);
expect(h.container.querySelector('pre')).not.toBeNull();
if (definite) expect(h.container.textContent).toContain('too large');
expect(h.submit).toHaveBeenCalledTimes(1);
},
);
it('does not treat a resolved send without an admission callback as success', async () => {
const h = setup();
const result = h.request();
await act(async () => h.button('Send to session').click());
expect(await result).toEqual({ isError: true });
expect(h.container.textContent).toContain('Cancelled before sending');
});
it('settles a finished submission without a receipt as unknown after dispatch', async () => {
const h = setup();
h.submit.mockImplementation(async (_text, submission) => {
submission.onDispatched();
});
const result = h.request();
await act(async () => h.button('Send to session').click());
expect(await result).toEqual({ isError: true });
expect(h.container.textContent).toContain(
'Acceptance could not be confirmed',
);
expect(await h.request()).toEqual({ isError: true });
});
it('shows a host refusal before dispatch as a failed review with its reason', async () => {
const h = setup();
h.submit.mockRejectedValue(new Error('The session is busy'));
const result = h.request();
await act(async () => h.button('Send to session').click());
expect(await result).toEqual({ isError: true });
expect(h.container.textContent).toContain('The message was not accepted');
expect(h.container.textContent).toContain('The session is busy');
expect(h.container.textContent).not.toContain(
'Acceptance could not be confirmed',
);
});
it('keeps the original session and text when the user stops waiting for a dispatched request', async () => {
const h = setup();
const pending = deferred<void>();
h.submit.mockImplementation(async (_text, submission) => {
submission.onDispatched();
await pending.promise;
});
const result = h.request();
const settled = vi.fn();
void result.then(settled);
await act(async () => h.button('Send to session').click());
await act(async () => h.button('Stop waiting for receipt').click());
expect(settled).not.toHaveBeenCalled();
expect(h.container.textContent).toContain(
'Acceptance could not be confirmed',
);
expect(h.container.textContent).toContain('session-a');
expect(h.container.querySelector('pre')?.textContent).toBe(
'<script>untrusted text</script>\nFull message',
);
expect(await h.request()).toEqual({ isError: true });
await act(async () => pending.reject(new Error('lost response')));
expect(await result).toEqual({ isError: true });
expect(h.submit).toHaveBeenCalledTimes(1);
});
it('supports keyboard cancellation in the host portal', async () => {
const h = setup();
const result = h.request();
expect(h.container.querySelector('[role="dialog"]')).not.toBeNull();
await act(async () => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
});
expect(await result).toEqual({ isError: true });
expect(h.submit).not.toHaveBeenCalled();
});
it('expires review at 90 seconds without sending', async () => {
vi.useFakeTimers();
const h = setup();
const result = h.request();
await act(async () => vi.advanceTimersByTime(90_000));
expect(await result).toEqual({ isError: true });
expect(h.container.textContent).toContain('Review expired before sending');
expect(h.submit).not.toHaveBeenCalled();
});
it('does not cancel accepted work when the iframe or review closes', async () => {
const h = setup();
const turn = deferred<void>();
h.submit.mockImplementation(async (_text, submission) => {
submission.onDispatched();
submission.onAdmitted({ promptId: 'p' });
await turn.promise;
});
const rpc = new AbortController();
const result = h.request(rpc.signal);
await act(async () => h.button('Send to session').click());
expect(await result).toEqual({});
await act(async () => {
rpc.abort();
h.button('close').click();
});
expect(h.submit).toHaveBeenCalledTimes(1);
await act(async () => turn.resolve());
});
it('keeps capability stable while disconnected but refuses new requests', async () => {
const h = setup();
expect(h.context()).toMatchObject({
sessionId: 'session-a',
});
const requestMessage = h.context()!.requestMessage;
h.render({ enabled: false });
expect(h.context()!.requestMessage).toBe(requestMessage);
expect(await h.request()).toEqual({ isError: true });
expect(h.submit).not.toHaveBeenCalled();
h.render({ clientId: '' });
expect(h.context()).toBeUndefined();
});
it.each(['stop waiting', 'timeout', 'disconnect'])(
'waits for a late receipt after %s and settles before the turn completes',
async (reason) => {
vi.useFakeTimers();
const h = setup();
const turn = deferred<void>();
h.submit.mockImplementation(async (_text, submission) => {
submission.onDispatched();
await turn.promise;
});
const result = h.request();
const settled = vi.fn();
void result.then(settled);
await act(async () => h.button('Send to session').click());
await act(async () => {
if (reason === 'timeout') vi.advanceTimersByTime(90_000);
else if (reason === 'disconnect') h.render({ enabled: false });
else h.button('Stop waiting for receipt').click();
});
expect(h.container.textContent).toContain(
'Acceptance could not be confirmed',
);
expect(settled).not.toHaveBeenCalled();
expect(await h.request()).toEqual({ isError: true });
await act(async () =>
h.submit.mock.calls[0][1].onAdmitted({ promptId: 'late-receipt' }),
);
expect(await result).toEqual({});
expect(h.container.textContent).toContain('late-receipt');
expect(h.submit).toHaveBeenCalledOnce();
await act(async () => turn.resolve());
},
);
it('does not reopen a dismissed unknown review when its receipt arrives', async () => {
const h = setup();
const turn = deferred<void>();
h.submit.mockImplementation(async (_text, submission) => {
submission.onDispatched();
await turn.promise;
});
const result = h.request();
await act(async () => h.button('Send to session').click());
await act(async () => h.button('Stop waiting for receipt').click());
await act(async () => h.button('close').click());
await act(async () =>
h.submit.mock.calls[0][1].onAdmitted({ promptId: 'late' }),
);
expect(await result).toEqual({});
expect(h.container.querySelector('[role="dialog"]')).toBeNull();
const next = h.request();
expect(h.container.textContent).toContain('Review the full message');
await act(async () => h.button('cancel').click());
expect(await next).toEqual({ isError: true });
await act(async () => turn.resolve());
});
it('starts a fresh bounded admission wait after reviewing near expiry', async () => {
vi.useFakeTimers();
const h = setup();
const turn = deferred<void>();
h.submit.mockImplementation(async (_text, submission) => {
submission.onDispatched();
await turn.promise;
});
const result = h.request();
await act(async () => vi.advanceTimersByTime(89_000));
await act(async () => h.button('Send to session').click());
await act(async () => vi.advanceTimersByTime(2_000));
expect(h.container.textContent).toContain('Waiting for the session');
await act(async () =>
h.submit.mock.calls[0][1].onAdmitted({ promptId: 'p' }),
);
expect(await result).toEqual({});
await act(async () => turn.resolve());
});
it.each([false, true])(
'shows a definite veto as cancelled after previous attempt=%s',
async (attempted) => {
const h = setup();
h.submit.mockImplementation(async (_text, submission) => {
if (attempted) submission.onDispatched();
throw new PromptDispatchCancelledError('Session client changed');
});
const result = h.request();
await act(async () => h.button('Send to session').click());
expect(await result).toEqual({ isError: true });
expect(h.container.textContent).toContain('Cancelled before sending');
expect(h.container.textContent).not.toContain(
'Acceptance could not be confirmed',
);
expect(h.container.textContent).not.toContain('Session client changed');
const next = h.request();
await act(async () => h.button('cancel').click());
expect(await next).toEqual({ isError: true });
},
);
it.each([403, 404, 409, 500, 503])(
'reports HTTP %s admission accurately',
async (status) => {
const h = setup();
h.submit.mockImplementation(async (_text, submission) => {
submission.onDispatched();
throw new DaemonHttpError(
status,
undefined,
'daemon rejected this request',
);
});
const result = h.request();
await act(async () => h.button('Send to session').click());
expect(await result).toEqual({ isError: true });
expect(h.container.textContent).toContain(
status < 500
? 'The message was not accepted'
: 'Acceptance could not be confirmed',
);
if (status < 500)
expect(h.container.textContent).toContain(
'daemon rejected this request',
);
},
);
});

View file

@ -0,0 +1,342 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
McpAppMessageRequest,
McpAppMessages,
} from '../mcpAppHostContext';
import type { DaemonSessionOwnerSnapshot } from '../daemon/session/types';
import {
isMcpAppPromptAdmissionRejected,
PromptDispatchCancelledError,
} from '../utils/promptAdmission';
import { useI18n } from '../i18n';
import { Button } from '../components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../components/ui/dialog';
export interface McpAppPromptSubmission {
signal: AbortSignal;
isCurrent(): boolean;
onDispatched(): void;
onAdmitted(admission: { promptId: string }): void;
}
interface MessageHost {
enabled: boolean;
sessionId?: string;
clientId?: string;
captureOwner(): DaemonSessionOwnerSnapshot;
submit(text: string, options: McpAppPromptSubmission): Promise<unknown>;
}
type Phase =
| 'reviewing'
| 'preparing'
| 'dispatched'
| 'accepted'
| 'cancelled'
| 'expired'
| 'failed'
| 'unknown';
interface Review extends McpAppMessageRequest {
sessionId: string;
clientId: string;
phase: Phase;
promptId?: string;
error?: string;
}
interface Pending extends Review {
controller: AbortController;
owner: DaemonSessionOwnerSnapshot;
finish(phase: Phase, error?: string): void;
settle(): void;
startTimeout(): void;
cleanup(): void;
}
function isPending(phase: Phase): boolean {
return ['reviewing', 'preparing', 'dispatched'].includes(phase);
}
export function useMcpAppMessages(host: MessageHost) {
const hostRef = useRef(host);
hostRef.current = host;
const pendingRef = useRef<Pending | undefined>(undefined);
const mountedRef = useRef(true);
const [review, setReview] = useState<Review>();
const isOwnerCurrent = useCallback((request: Pending) => {
const current = hostRef.current;
return (
mountedRef.current &&
current.enabled &&
current.sessionId === request.sessionId &&
current.clientId === request.clientId &&
request.owner.isCurrent()
);
}, []);
const requestMessage = useCallback(
(
input: McpAppMessageRequest,
): ReturnType<McpAppMessages['requestMessage']> => {
const current = hostRef.current;
const previous = pendingRef.current;
if (
!mountedRef.current ||
!current.enabled ||
!current.sessionId ||
!current.clientId ||
input.signal.aborted ||
(previous &&
(isPending(previous.phase) || previous.phase === 'unknown'))
) {
return Promise.resolve({ isError: true });
}
return new Promise((resolve) => {
const controller = new AbortController();
const signal = AbortSignal.any([input.signal, controller.signal]);
const abort = () => {
request.finish(
request.phase === 'dispatched' || request.phase === 'unknown'
? 'unknown'
: 'cancelled',
);
request.settle();
};
let timeout: ReturnType<typeof setTimeout>;
const request: Pending = {
...input,
signal,
controller,
sessionId: current.sessionId!,
clientId: current.clientId!,
owner: current.captureOwner(),
phase: 'reviewing',
startTimeout: () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
request.finish(
request.phase === 'dispatched' ? 'unknown' : 'expired',
);
controller.abort();
}, 90_000);
},
cleanup: () => {
clearTimeout(timeout);
input.signal.removeEventListener('abort', abort);
},
settle: () => {
request.cleanup();
resolve(request.phase === 'accepted' ? {} : { isError: true });
},
finish: (phase, error) => {
if (!isPending(request.phase) && request.phase !== 'unknown')
return;
clearTimeout(timeout);
request.phase = phase;
request.error = error;
if (mountedRef.current && pendingRef.current === request) {
setReview({ ...request });
}
if (phase !== 'unknown') request.settle();
},
};
pendingRef.current = request;
request.startTimeout();
input.signal.addEventListener('abort', abort, { once: true });
if (!isOwnerCurrent(request)) {
request.finish('cancelled');
controller.abort();
return;
}
setReview({ ...request });
});
},
[isOwnerCurrent],
);
useEffect(() => {
const request = pendingRef.current;
if (request && isPending(request.phase) && !isOwnerCurrent(request)) {
request.finish(request.phase === 'dispatched' ? 'unknown' : 'cancelled');
request.controller.abort();
}
});
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
const request = pendingRef.current;
if (request) {
request.finish(
request.phase === 'dispatched' || request.phase === 'unknown'
? 'unknown'
: 'cancelled',
);
request.settle();
request.controller.abort();
}
};
}, []);
const send = useCallback(() => {
const request = pendingRef.current;
if (!request || request.phase !== 'reviewing') return;
const isCurrent = () =>
pendingRef.current === request &&
!request.signal.aborted &&
isOwnerCurrent(request);
if (!isCurrent()) {
request.finish('cancelled');
request.controller.abort();
return;
}
request.phase = 'preparing';
request.startTimeout();
setReview({ ...request });
void Promise.resolve()
.then(() => {
if (!isCurrent()) throw new DOMException('Cancelled', 'AbortError');
return hostRef.current.submit(request.text, {
signal: request.signal,
isCurrent,
onDispatched: () => {
if (!isCurrent()) throw new DOMException('Cancelled', 'AbortError');
request.phase = 'dispatched';
if (mountedRef.current) setReview({ ...request });
},
onAdmitted: ({ promptId }) => {
if (!promptId) return;
request.promptId = promptId;
request.finish('accepted');
},
});
})
.then(() => {
if (isPending(request.phase)) {
request.finish(
request.phase === 'dispatched' ? 'unknown' : 'cancelled',
);
}
request.settle();
})
.catch((error: unknown) => {
request.finish(
error instanceof PromptDispatchCancelledError ||
(request.phase === 'preparing' &&
error instanceof Error &&
error.name === 'AbortError')
? 'cancelled'
: (request.phase === 'dispatched' || request.phase === 'unknown') &&
!isMcpAppPromptAdmissionRejected(error)
? 'unknown'
: 'failed',
error instanceof Error ? error.message : undefined,
);
request.settle();
});
}, [isOwnerCurrent]);
const close = useCallback(() => {
const request = pendingRef.current;
if (request?.phase === 'dispatched') {
request.finish('unknown');
request.controller.abort();
return;
}
if (request && isPending(request.phase)) {
request.finish('cancelled');
request.controller.abort();
}
pendingRef.current = undefined;
setReview(undefined);
}, []);
const context = useMemo<McpAppMessages | undefined>(
() =>
host.sessionId && host.clientId
? { sessionId: host.sessionId, requestMessage }
: undefined,
[host.sessionId, host.clientId, requestMessage],
);
return {
context,
review: review ? (
<McpAppMessageReview review={review} onSend={send} onClose={close} />
) : null,
};
}
function McpAppMessageReview({
review,
onSend,
onClose,
}: {
review: Review;
onSend(): void;
onClose(): void;
}) {
const { t } = useI18n();
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-xl" showCloseButton={false}>
<DialogHeader>
<DialogTitle>{t('mcpApp.message.title')}</DialogTitle>
<DialogDescription>
{t('mcpApp.message.description')}
</DialogDescription>
</DialogHeader>
<dl className="grid gap-1 break-all text-xs text-muted-foreground">
<div>
{t('mcpApp.message.server')}: {review.serverName}
</div>
<div>
{t('mcpApp.message.resource')}: {review.resourceUri}
</div>
<div>
{t('mcpApp.message.session')}: {review.sessionId}
</div>
</dl>
<pre
className="max-h-[40vh] overflow-auto whitespace-pre-wrap break-words rounded-md border bg-muted p-3 text-sm"
tabIndex={0}
>
{review.text}
</pre>
<p role="status">{t(`mcpApp.message.${review.phase}`)}</p>
{review.promptId && (
<p className="break-all text-xs text-muted-foreground">
{t('mcpApp.message.receipt')}: {review.promptId}
</p>
)}
{review.error && review.phase === 'failed' && (
<p className="break-words text-sm">{review.error}</p>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t(
review.phase === 'dispatched'
? 'mcpApp.message.stopWaiting'
: isPending(review.phase)
? 'common.cancel'
: 'common.close',
)}
</Button>
{review.phase === 'reviewing' && (
<Button onClick={onSend}>{t('mcpApp.message.send')}</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -21,6 +21,10 @@ import {
import { VOICE_MESSAGES_EN, VOICE_MESSAGES_ZH } from './voice/messages.js';
import { LIVE_MESSAGES_EN, LIVE_MESSAGES_ZH } from './live/messages.js';
import {
MCP_APP_MESSAGES_EN,
MCP_APP_MESSAGES_ZH,
} from './mcp-app/messages.js';
import {
COLLAB_MESSAGES_EN,
COLLAB_MESSAGES_ZH,
@ -4381,6 +4385,7 @@ const EN: Messages = {
'welcome.prompt': 'What would you like to do?',
'welcome.titlePrefix': 'Welcome to',
'welcome.tipLabel': 'Tips:',
...MCP_APP_MESSAGES_EN,
...VOICE_MESSAGES_EN,
...LIVE_MESSAGES_EN,
...COLLAB_MESSAGES_EN,
@ -8459,6 +8464,7 @@ const ZH: Messages = {
'welcome.prompt': '你想构建什么?',
'welcome.titlePrefix': '欢迎使用',
'welcome.tipLabel': '提示:',
...MCP_APP_MESSAGES_ZH,
...VOICE_MESSAGES_ZH,
...LIVE_MESSAGES_ZH,
...COLLAB_MESSAGES_ZH,

View file

@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const MCP_APP_MESSAGES_EN = {};
export const MCP_APP_MESSAGES_ZH = {};

View file

@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const MCP_APP_MESSAGES_EN: Record<string, string> = {
'mcpApp.message.title': 'Review App message',
'mcpApp.message.description':
'This text comes from an MCP App. Sending starts an ordinary request in the session below; normal tool approvals still apply.',
'mcpApp.message.server': 'Server',
'mcpApp.message.resource': 'Resource',
'mcpApp.message.session': 'Target session',
'mcpApp.message.receipt': 'Prompt receipt',
'mcpApp.message.send': 'Send to session',
'mcpApp.message.stopWaiting': 'Stop waiting for receipt',
'mcpApp.message.busy':
'The session is busy. Wait for it to finish, then send the message again.',
'mcpApp.message.reviewing': 'Review the full message before sending.',
'mcpApp.message.preparing': 'Preparing the message. You can still cancel.',
'mcpApp.message.dispatched':
'Sending. Waiting for the session to confirm acceptance.',
'mcpApp.message.accepted':
'The session accepted this request. Analysis may still be running.',
'mcpApp.message.cancelled': 'Cancelled before sending.',
'mcpApp.message.expired':
'Review expired before sending. Request a new review from the App.',
'mcpApp.message.failed':
'The message was not accepted. The text is kept here.',
'mcpApp.message.unknown':
'Acceptance could not be confirmed. Check the original session before sending again. This request will not be retried automatically.',
};
export const MCP_APP_MESSAGES_ZH: Record<string, string> = {
'mcpApp.message.title': '审阅 App 消息',
'mcpApp.message.description':
'以下文字来自 MCP App。发送后将作为普通请求进入下方会话,工具仍需按正常规则审批。',
'mcpApp.message.server': '服务',
'mcpApp.message.resource': '资源',
'mcpApp.message.session': '目标会话',
'mcpApp.message.receipt': '请求回执',
'mcpApp.message.send': '发送到会话',
'mcpApp.message.stopWaiting': '停止等待回执',
'mcpApp.message.busy': '会话正忙,请等待结束后重新发送消息。',
'mcpApp.message.reviewing': '请审阅完整消息后再发送。',
'mcpApp.message.preparing': '正在准备消息,仍可取消。',
'mcpApp.message.dispatched': '正在发送,等待会话确认接收。',
'mcpApp.message.accepted': '会话已接收请求,分析可能仍在进行。',
'mcpApp.message.cancelled': '已在发送前取消。',
'mcpApp.message.expired': '审阅已过期,消息未发送。请从 App 重新发起审阅。',
'mcpApp.message.failed': '消息未被接收,原文保留在此。',
'mcpApp.message.unknown':
'无法确认是否已接收。再次发送前请检查原会话,本请求不会自动重试。',
};

View file

@ -12,6 +12,22 @@ export const McpAppSessionContext = createContext<string | undefined>(
undefined,
);
export interface McpAppMessageRequest {
serverName: string;
resourceUri: string;
text: string;
signal: AbortSignal;
}
export interface McpAppMessages {
sessionId: string;
requestMessage(request: McpAppMessageRequest): Promise<{ isError?: boolean }>;
}
export const McpAppMessagesContext = createContext<McpAppMessages | undefined>(
undefined,
);
export const McpAppToolsContext = createContext<
| {
sessionId: string;

View file

@ -1,6 +1,58 @@
import { describe, expect, it } from 'vitest';
import { DaemonHttpError } from '@qwen-code/sdk/daemon';
import { isDefinitelyRejectedPromptAdmission } from './promptAdmission';
import {
DaemonHttpError,
DaemonPendingPromptLimitError,
} from '@qwen-code/sdk/daemon';
import {
isDefinitelyRejectedPromptAdmission,
isMcpAppPromptAdmissionRejected,
PromptDispatchCancelledError,
} from './promptAdmission';
describe('MCP App admission rejection', () => {
it.each([400, 401, 403, 404, 409, 413, 429, 501])(
'recognizes HTTP %s rejection',
(status) => {
expect(
isMcpAppPromptAdmissionRejected(
new DaemonHttpError(status, undefined, 'refused'),
),
).toBe(true);
},
);
it.each([408, 499, 500, 502, 503])(
'leaves HTTP %s acceptance unknown',
(status) => {
expect(
isMcpAppPromptAdmissionRejected(
new DaemonHttpError(status, undefined, 'uncertain'),
),
).toBe(false);
},
);
it('distinguishes a pre-dispatch veto from an aborted or lost HTTP response', () => {
expect(
isMcpAppPromptAdmissionRejected(
new PromptDispatchCancelledError('source changed'),
),
).toBe(true);
expect(
isMcpAppPromptAdmissionRejected(
new DaemonPendingPromptLimitError('session-a', 10, 10),
),
).toBe(true);
expect(
isMcpAppPromptAdmissionRejected(
new DOMException('Aborted', 'AbortError'),
),
).toBe(false);
expect(
isMcpAppPromptAdmissionRejected(new TypeError('Failed to fetch')),
).toBe(false);
});
});
describe('isDefinitelyRejectedPromptAdmission', () => {
it.each([413, 501])('classifies HTTP %s as definitely rejected', (status) => {

View file

@ -1,4 +1,20 @@
import { DaemonHttpError } from '@qwen-code/sdk/daemon';
import {
DaemonHttpError,
DaemonPendingPromptLimitError,
} from '@qwen-code/sdk/daemon';
export class PromptDispatchCancelledError extends Error {
override name = 'AbortError';
}
export function isMcpAppPromptAdmissionRejected(error: unknown): boolean {
return (
error instanceof PromptDispatchCancelledError ||
error instanceof DaemonPendingPromptLimitError ||
(error instanceof DaemonHttpError &&
[400, 401, 403, 404, 409, 413, 429, 501].includes(error.status))
);
}
export function isDefinitelyRejectedPromptAdmission(error: unknown): boolean {
return (

View file

@ -163,6 +163,10 @@ function injectCssModules(): Plugin {
const TRANSCRIPT_STUBS = new Map(
[
['./client/live/messages.ts', './client/live/messages.transcript-stub.ts'],
[
'./client/mcp-app/messages.ts',
'./client/mcp-app/messages.transcript-stub.ts',
],
[
'./client/components/artifacts/files-messages.ts',
'./client/components/artifacts/files-messages.transcript-stub.ts',