mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(web-shell): 增加 managed-host 构建入口与托管会话页 为产品侧集成提供独立的 managed-host 产物(--mode managed-host)与托管会话页: 不挂 daemon workspace/session context,托管会话由 provider 直接渲染列表、会话与输入框。 包含:vite lib 构建模式、managed-host 渲染入口及测试、ManagedSessionsPage 托管分支与 测试、provider 类型调整、README 与中英文设计说明。本次不涉及宿主注入能力。 * feat(web-shell): 托管宿主可注入侧栏导航 props ManagedAgentWebShell 增加可选导航 props 并原样透传给 ManagedSessionsPage: navigationTarget / archivedNavigationTarget / onArchivedSessionsStatusChange / navigationActive / navigationSearch。 宿主据此决定是否把官方会话列表 portal 到自己的侧栏(navigationTarget 为 null 时 仍走 home 布局但不 portal),并同步搜索词与归档计数。类型复用 ManagedSessionsPage 现有 props,不新增重复定义;新增 2 个用例覆盖原样透传与未传时保持 undefined。 * feat(web-shell): add full-page app-host entry and align managed empty-state layout - Add app-host mount entry (renderWebShellApp) so a non-React host can render the complete WebShell (sidebar + welcome + composer) by injecting baseUrl/provider; no shell is hand-drawn and no backend address is hardcoded. - Center the managed empty state like the standalone chat (content shrinks so the chatPane centers it) instead of pinning the welcome to the top and composer to the bottom; gate the welcome header off when a failed session is selected. - Surface session failure and unconfirmed-submission notices together above the composer, dedupe the same message across channels, and style them as callouts. - A pending prompt owned by another session now offers a redirect to the owning conversation instead of retry/discard from a foreign session; reconcile tests. - Build app-host with global (unscoped) CSS so root-level state classes match. * test(web-shell): align managed status smoke assertions Co-authored-by: Qwen-Coder <[email protected]> * test(web-shell): reach the owning conversation before retrying a foreign pending A pending prompt owned by another conversation no longer offers retry in the foreign session: it renders "Open the original conversation" instead. The spec still clicked "Retry the same request" straight from the foreign session, so the two foreign variants timed out waiting for a button that is deliberately absent from this head while the owner variant passed. Follow the redirect first, then retry. Owner variant unchanged. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmvwo8rnm58 * docs(web-shell): document the app-host entry and drop ignored e2e links The review on head932a61d8dcread `app-host` as undocumented: no README section, no mention in either design doc. Document it the same way `managed-host` is documented -- entry API, build mode, served directory -- and record the unscoped-CSS behaviour as a deliberate decision narrowed to that bundle, naming the entries that keep scoped CSS. Also state that neither host mode is part of `npm run build`, which is what the wiring actually does. Both design docs linked `.qwen/e2e-tests/managed-host-webshell.md`, which `.gitignore` excludes (`.qwen/*`), so the links are dropped and the statement they supported stays. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmvxdyp0v69 * fix(web-shell): revert breaking changes to existing consumers - Revert `isChatEmptyState` to `!managedMode &&` (was: `!connection.sessionId && !(managedAgentProvider !== undefined && managedSessionId)`), restoring the original behavior where managed mode is never empty state. The head condition broke existing managed consumers by making them hit empty-state layout when no session was selected. - Revert `sidebarRailEnabled` to drop `managedAgentProvider === undefined &&`, restoring rail for existing managed consumers. The condition was app-host-specific and should not affect hosts that already pass a provider. - Revert `ManagedSessionsPage` inline `flex: 0 0 auto` (was unconditional in empty state), restoring `flex: 1 1 auto` for the managed entry. The shrink was only correct when paired with `.appChatEmpty .chatPane { justify-content: center }`, which only exists in the full-page `renderWebShellApp` entry. In `renderManagedAgentWebShell` the class is absent, so the shrink alone produced a 136px content block pinned to the top with 550px dead space below. - Remove `skipScope` from `vite.lib.config.ts`: the justification ("scoped selectors use [data-web-shell-root] as an ancestor anchor") is factually incorrect. `COMPONENT_SCOPE` includes `:where([data-web-shell-root][data-web-shell-shadcn], ...)` which matches the element itself, so scoped bundles already apply state classes on the root element. Keep scoping for all modes. - Update README and both design docs to reflect the corrected scoping behavior. Co-authored-by: Qwen-Coder <[email protected]> * chore(web-shell): remove app-host entry The app-host entry (renderWebShellApp) had no build wiring (absent from build script, exports, files, and no CI job invoked --mode app-host), no test coverage, and hardcoded a specific host's sidebar/footer configuration. Remove it from this PR; it can be reintroduced in a follow-up with proper packaging and tests once a named consumer exists. Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): drop unused mode parameter from injectCssModules The mode parameter became unused after the skipScope removal. Remove it to fix the ESLint no-unused-vars error. Co-authored-by: Qwen-Coder <[email protected]> * docs(web-shell): scope the managed-host entry and correct stale layout notes - README: say plainly that `managed-host` is source-only (no `build`, `exports` or `files` wiring, so it is not in the npm package), and record that one shell per shadow root is the contract. - package.json: keep the orphan `dist/types/managed-host.d.ts` out of the published tree so the package does not ship a type with no implementation. - ManagedSessionsPage: point the `summary.failure` dedupe comment at the current failure block above the composer, and record why a session switch resets failure and discard notices. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmvy1jgjo76 * docs(web-shell): state the real consequence of the session-switch reset The comment claimed the list error is re-derived on a session switch. It is not: the list effect's deps exclude sessionId, so a catalog-load error is hidden until provider/listRevision changes. Say what actually happens. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmvy1jgjo76 * fix(web-shell): keep managed sessions visible when workspaces collapse Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): address doudouOUC review feedback - Remove shadow-DOM style adoption from renderManagedAgentWebShell: the shared CSSStyleSheet cache caused cross-contamination when two shells shared one shadow root. Hosts using shadow DOM should adopt styles themselves or use a plain container. - Scope the error/discard reset effect to hostSessionOnly only: the unconditional clear on [provider, sessionId] hid the catalog-load error for existing managed consumers on every session switch without retrying. - Update the managed-host test to match the removed shadow-DOM behavior. - Drop the stale shadow-root note from README. Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): preserve styles for managed hosts in shared shadow roots Restore the documented managed-host ShadowRoot style lifecycle and keep the shared package sheet until its last owner unmounts. Cover both fallback and constructable sheet behavior. Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): center the managed host empty conversation as one group The host mount entry has no .appChatEmpty ancestor, so the managed empty conversation kept its full-height content box and pinned the composer to the bottom. Give host-mounted empty conversations the same shrink-to-content centering, mount the entry through the managedHome fill wrapper, and drop the empty trailing status row below the composer. Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): let a host-mode session clear through without an echo channel hostSessionOnly alone marks the shell controlled, so the page's clear signal after deleting or archiving the current session was dropped when the host passed no onSessionChange (the README-documented mount). The deleted conversation stayed on screen and sendable. Allow the internal write for a clear (undefined) when no echo channel exists; new selections stay host-owned, preserving the existing 'unrequested selection is suppressed' contract. The bot's suggested controlled-expression rewrite would have broken that contract, so the gate lives in the handler instead. Witness: the new no-echo test fails at5683eed0without this change (sessionId stays 's1') and passes with it; the echo-channel test pins that the fix does not leak internal writes into echo-owned hosts. --------- Co-authored-by: Shaojin Wen <[email protected]> Co-authored-by: Qwen-Coder <[email protected]>
685 lines
23 KiB
TypeScript
685 lines
23 KiB
TypeScript
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
|
import type {
|
|
JavaAgentEvent,
|
|
JavaAgentSession,
|
|
JavaAgentTranscript,
|
|
} from '../components/managed/java-managed-agent-client';
|
|
import type { components } from '../components/managed/generated/managed-agent-api';
|
|
import {
|
|
createWebShellDaemonScenario,
|
|
installMockDaemon,
|
|
} from './utils/mockDaemon';
|
|
|
|
const SESSION_ID = 'managed-progress-session';
|
|
const LONG_ANSWER = Array.from(
|
|
{ length: 70 },
|
|
(_, index) => `Paragraph ${index}: a previous workspace inspection.`,
|
|
).join('\n\n');
|
|
|
|
async function installManagedScenario(page: Page, testInfo: TestInfo) {
|
|
const baseURL = String(testInfo.project.use.baseURL);
|
|
const scenario = createWebShellDaemonScenario({ sessions: [] });
|
|
const daemon = await installMockDaemon(page, scenario, { baseURL });
|
|
const events: JavaAgentEvent[] = [];
|
|
const prompts: Array<{ prompt: unknown; key?: string }> = [];
|
|
const cancellations: unknown[] = [];
|
|
const errors: string[] = [];
|
|
const streamCursors: number[] = [];
|
|
const detailRequests: Array<{ sessionId: string; itemId: string }> = [];
|
|
const toolDetails = new Map<
|
|
string,
|
|
components['schemas']['WebShellToolDetails']
|
|
>();
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
let summary: JavaAgentSession = {
|
|
sessionId: SESSION_ID,
|
|
title: 'Managed progress regression',
|
|
agentId: 'qwen-code',
|
|
status: 'active',
|
|
createdAt: Date.now() - 10_000,
|
|
updatedAt: Date.now(),
|
|
activeTurn: {
|
|
turnId: 'p1',
|
|
sessionId: SESSION_ID,
|
|
status: 'completed',
|
|
submittedAt: Date.now() - 10_000,
|
|
},
|
|
environment: { state: 'ready' },
|
|
lastSequence: 0,
|
|
capabilities: {
|
|
tasks: true,
|
|
artifacts: false,
|
|
actions: false,
|
|
workspaceTurns: false,
|
|
},
|
|
};
|
|
function append(type: string, data: Record<string, unknown> = {}) {
|
|
const sequence = events.length + 1;
|
|
events.push({
|
|
sequence,
|
|
eventId: `event-${sequence}`,
|
|
createdAt: Date.now(),
|
|
sessionId: SESSION_ID,
|
|
turnId: summary.activeTurn!.turnId,
|
|
type,
|
|
...(type.startsWith('item.tool_')
|
|
? { itemId: String(data.itemId ?? data.toolCallId) }
|
|
: {}),
|
|
data,
|
|
terminal: ['turn.completed', 'turn.cancelled'].includes(type),
|
|
});
|
|
summary.lastSequence = sequence;
|
|
}
|
|
append('turn.accepted', {
|
|
input: [{ type: 'text', text: 'Previous inspection' }],
|
|
});
|
|
append('item.output_text.delta', { text: LONG_ANSWER });
|
|
append('turn.completed');
|
|
|
|
await page.route('**/api/agent/web-shell/v1/**', async (route) => {
|
|
const request = route.request();
|
|
expect(request.method()).toBe('POST');
|
|
expect(request.headers()['x-qwen-tenant-id']).toBe('local-java-demo');
|
|
const path = new URL(request.url()).pathname.replace(
|
|
'/api/agent/web-shell/v1',
|
|
'',
|
|
);
|
|
const body = request.postDataJSON();
|
|
if (path.endsWith('/user-messages/query'))
|
|
return route.fulfill({ json: { paused: false, messages: [] } });
|
|
const respond = (json: unknown, status = 200) =>
|
|
route.fulfill({ status, json });
|
|
if (path === '/sessions/query')
|
|
return respond({ data: [summary], hasMore: false });
|
|
if (path === '/sessions/get') return respond(summary);
|
|
if (path === '/transcript/query')
|
|
return respond({
|
|
items: [],
|
|
events,
|
|
coveredSequence: summary.lastSequence,
|
|
lastSequence: summary.lastSequence,
|
|
hasMore: false,
|
|
} satisfies JavaAgentTranscript);
|
|
if (path === '/tool-details/get') {
|
|
detailRequests.push({ sessionId: body.sessionId, itemId: body.itemId });
|
|
const details = toolDetails.get(body.itemId);
|
|
if (details) return respond(details);
|
|
return respond(
|
|
{ error: { code: 'tool_details_not_found', message: 'Unavailable' } },
|
|
404,
|
|
);
|
|
}
|
|
if (path === '/events/stream') {
|
|
streamCursors.push(body.afterSequence ?? 0);
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
body: events
|
|
.filter((event) => event.sequence > (body.afterSequence ?? 0))
|
|
.map(
|
|
(event) =>
|
|
`id: ${event.sequence}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`,
|
|
)
|
|
.join(''),
|
|
});
|
|
}
|
|
if (path === '/turns/submit') {
|
|
prompts.push({ prompt: body.input, key: body.idempotencyKey });
|
|
summary = {
|
|
...summary,
|
|
updatedAt: Date.now(),
|
|
activeTurn: {
|
|
sessionId: SESSION_ID,
|
|
turnId: `p${prompts.length + 1}`,
|
|
status: 'running',
|
|
submittedAt: Date.now(),
|
|
},
|
|
};
|
|
append('turn.accepted', {
|
|
input: body.input.map((block: { text: string }) => ({
|
|
type: 'text',
|
|
text: block.text,
|
|
})),
|
|
});
|
|
append('turn.started');
|
|
return respond(
|
|
{
|
|
sessionId: SESSION_ID,
|
|
turnId: summary.activeTurn!.turnId,
|
|
status: 'accepted',
|
|
replayed: false,
|
|
operationId: `operation-${summary.activeTurn!.turnId}`,
|
|
admissionStage: 'java_durable',
|
|
deliveryState: 'pending',
|
|
},
|
|
202,
|
|
);
|
|
}
|
|
if (path === '/turns/cancel') {
|
|
cancellations.push({ turnId: body.turnId });
|
|
summary.activeTurn!.status = 'cancelling';
|
|
append('turn.cancel.requested');
|
|
return respond(
|
|
{
|
|
sessionId: SESSION_ID,
|
|
turnId: body.turnId,
|
|
status: 'accepted',
|
|
replayed: false,
|
|
operationId: `operation-${summary.activeTurn!.turnId}`,
|
|
admissionStage: 'java_durable',
|
|
deliveryState: 'pending',
|
|
},
|
|
202,
|
|
);
|
|
}
|
|
if (path === '/operations/query') {
|
|
return respond({
|
|
sessionId: SESSION_ID,
|
|
operationId: body.operationId,
|
|
type: 'cancel',
|
|
status:
|
|
summary.activeTurn?.status === 'cancelled' ? 'completed' : 'running',
|
|
replayed: false,
|
|
});
|
|
}
|
|
return respond({ error: `Unexpected Managed request: ${path}` }, 500);
|
|
});
|
|
|
|
async function emit(type: string, data?: Record<string, unknown>) {
|
|
if (type === 'turn.completed' || type === 'turn.cancelled') {
|
|
summary.activeTurn!.status = type.slice('turn.'.length);
|
|
}
|
|
append(type, data);
|
|
}
|
|
async function waitForCurrentStream() {
|
|
await expect
|
|
.poll(() => streamCursors.includes(summary.lastSequence))
|
|
.toBe(true);
|
|
}
|
|
await page.goto(
|
|
`/?managed=1&managedProvider=java&managedSession=${SESSION_ID}`,
|
|
);
|
|
await expect(
|
|
page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.locator('[data-web-shell-composer-editor] .cm-content'),
|
|
).toHaveAttribute('contenteditable', 'true');
|
|
await waitForCurrentStream();
|
|
return {
|
|
daemon,
|
|
prompts,
|
|
cancellations,
|
|
errors,
|
|
emit,
|
|
waitForCurrentStream,
|
|
detailRequests,
|
|
toolDetails,
|
|
};
|
|
}
|
|
|
|
async function expectScrollableTranscript(page: Page) {
|
|
const list = page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.locator('[data-web-shell-message-list]');
|
|
await expect
|
|
.poll(() =>
|
|
list.evaluate((element) => {
|
|
const parent = element.parentElement!;
|
|
return {
|
|
bounded: element.clientHeight <= parent.clientHeight + 1,
|
|
contained: parent.scrollHeight <= parent.clientHeight + 1,
|
|
overflowing: element.scrollHeight > element.clientHeight + 100,
|
|
atBottom:
|
|
element.scrollTop > 0 &&
|
|
element.scrollHeight - element.clientHeight - element.scrollTop < 5,
|
|
};
|
|
}),
|
|
)
|
|
.toEqual({
|
|
bounded: true,
|
|
contained: true,
|
|
overflowing: true,
|
|
atBottom: true,
|
|
});
|
|
}
|
|
|
|
test('Managed progress stays visible through a long transcript and live thought/tool events @smoke', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const fixture = await installManagedScenario(page, testInfo);
|
|
await expectScrollableTranscript(page);
|
|
const composer = page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.locator('[data-web-shell-composer-editor] .cm-content');
|
|
const send = page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.getByRole('button', { name: 'Send message', exact: true });
|
|
await composer.fill('Inspect marker.txt');
|
|
await send.click();
|
|
await fixture.waitForCurrentStream();
|
|
await expect(page.locator('[data-managed-progress]')).toContainText(
|
|
'Thinking / responding',
|
|
);
|
|
await expect(page.locator('[data-managed-progress]')).toBeInViewport();
|
|
await expect(composer).toHaveAttribute('contenteditable', 'true');
|
|
await expect(
|
|
page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.locator('[data-web-shell-composer-submit]'),
|
|
).toHaveAccessibleName('esc to cancel');
|
|
await expect(
|
|
page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.getByRole('button', { name: 'esc to cancel', exact: true }),
|
|
).toBeEnabled();
|
|
await expect(page.locator('[data-managed-progress]')).toContainText(
|
|
/[1-9]\d*s elapsed/,
|
|
);
|
|
|
|
await fixture.emit('item.reasoning.delta', {
|
|
text: 'Checking the requested file.',
|
|
});
|
|
const list = page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.locator('[data-web-shell-message-list]');
|
|
await expect(
|
|
list.getByRole('button', { name: /^Thinking\b/ }),
|
|
).toBeInViewport();
|
|
await fixture.emit('item.tool_call.updated', {
|
|
toolCallId: 'read-marker',
|
|
status: 'in_progress',
|
|
toolName: 'read_file',
|
|
input: { file_path: 'marker.txt' },
|
|
});
|
|
await expect(list.getByText('marker.txt', { exact: true })).toBeInViewport();
|
|
await fixture.emit('item.tool_call.updated', {
|
|
toolCallId: 'read-marker',
|
|
toolName: 'read_file',
|
|
status: 'completed',
|
|
failed: false,
|
|
output: 'marker contents',
|
|
});
|
|
await fixture.emit('item.output_text.delta', { text: `${LONG_ANSWER}\n\n` });
|
|
await fixture.emit('item.output_text.delta', {
|
|
text: 'Latest answer marker',
|
|
});
|
|
await expect(
|
|
list.getByText('Latest answer marker', { exact: true }),
|
|
).toBeInViewport();
|
|
await expectScrollableTranscript(page);
|
|
await fixture.emit('turn.completed');
|
|
await expect(page.locator('[data-managed-progress]')).toHaveCount(0);
|
|
await expect(composer).toHaveAttribute('contenteditable', 'true');
|
|
expect(fixture.prompts).toHaveLength(1);
|
|
expect(fixture.prompts[0].key).toBeTruthy();
|
|
expect(fixture.daemon.promptRequests()).toHaveLength(0);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test('Managed tool details load only after expansion and remain readable after refresh @smoke', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const fixture = await installManagedScenario(page, testInfo);
|
|
fixture.toolDetails.set('item-saved-read', {
|
|
input: { file_path: '/tmp/example.diff' },
|
|
output:
|
|
'Hosted file tools require file_path relative to the saved Session working directory.',
|
|
truncated: false,
|
|
});
|
|
fixture.toolDetails.set('item-saved-shell', {
|
|
input: { command: "printf 'saved shell output'", directory: 'src' },
|
|
output: 'saved shell output\nExit Code: 0',
|
|
truncated: false,
|
|
});
|
|
await fixture.emit('item.tool_call.updated', {
|
|
itemId: 'item-saved-read',
|
|
toolCallId: 'saved-read',
|
|
name: 'read_file',
|
|
status: 'failed',
|
|
});
|
|
await fixture.emit('item.tool_call.updated', {
|
|
itemId: 'item-saved-shell',
|
|
toolCallId: 'saved-shell',
|
|
name: 'run_shell_command',
|
|
status: 'completed',
|
|
});
|
|
await fixture.emit('item.tool_call.updated', {
|
|
itemId: 'item-old-read',
|
|
toolCallId: 'old-read',
|
|
name: 'read_file',
|
|
status: 'failed',
|
|
});
|
|
await fixture.emit('turn.completed');
|
|
await page.reload();
|
|
const list = page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.locator('[data-web-shell-message-list]');
|
|
const group = list.getByRole('button', {
|
|
name: 'Ran 1 command Read files 2 times',
|
|
});
|
|
await expect(group).toBeVisible();
|
|
expect(fixture.detailRequests).toEqual([]);
|
|
await expect(list).not.toContainText('saved shell output');
|
|
await group.click();
|
|
expect(fixture.detailRequests).toEqual([]);
|
|
const read = list.locator(
|
|
'[data-transcript-tool-call-id="p1:item-saved-read"]',
|
|
);
|
|
const shell = list.locator(
|
|
'[data-transcript-tool-call-id="p1:item-saved-shell"]',
|
|
);
|
|
const old = list.locator('[data-transcript-tool-call-id="p1:item-old-read"]');
|
|
await read.getByRole('button').first().click();
|
|
await expect(read).toContainText(
|
|
'Hosted file tools require file_path relative',
|
|
);
|
|
expect(fixture.detailRequests).toEqual([
|
|
{ sessionId: SESSION_ID, itemId: 'item-saved-read' },
|
|
]);
|
|
await shell.getByRole('button').first().click();
|
|
await expect(shell).toContainText("printf 'saved shell output'");
|
|
await expect(shell).toContainText('saved shell output\nExit Code: 0');
|
|
expect(fixture.detailRequests).toEqual([
|
|
{ sessionId: SESSION_ID, itemId: 'item-saved-read' },
|
|
{ sessionId: SESSION_ID, itemId: 'item-saved-shell' },
|
|
]);
|
|
await old.getByRole('button').first().click();
|
|
await expect(old).toContainText('Output is unavailable for this tool call.');
|
|
await expect.poll(() => fixture.detailRequests.length).toBe(3);
|
|
await testInfo.attach('managed-tool-details', {
|
|
body: await page.screenshot({ fullPage: true }),
|
|
contentType: 'image/png',
|
|
});
|
|
expect(fixture.prompts).toHaveLength(0);
|
|
expect(fixture.daemon.promptRequests()).toHaveLength(0);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
for (const scenario of [
|
|
{ outcome: 'completed', exitCode: 0, signal: null, status: 'Succeeded' },
|
|
{
|
|
outcome: 'failed',
|
|
exitCode: 7,
|
|
signal: null,
|
|
status: 'Exited with code 7',
|
|
},
|
|
{ outcome: 'timed_out', exitCode: null, signal: 15, status: 'Timed out' },
|
|
{ outcome: 'cancelled', exitCode: null, signal: 15, status: 'Cancelled' },
|
|
] as const) {
|
|
test(`Managed saved structured Shell ${scenario.outcome} uses the shared result card @smoke`, async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const fixture = await installManagedScenario(page, testInfo);
|
|
fixture.toolDetails.set('item-structured-shell', {
|
|
input: { command: 'synthetic command' },
|
|
output: 'legacy display text',
|
|
truncated: false,
|
|
shellResult: {
|
|
type: 'shell_result',
|
|
version: 1,
|
|
text: 'saved display text',
|
|
output: 'structured stdout',
|
|
directory: '/workspace/project',
|
|
exitCode: scenario.exitCode,
|
|
signal: scenario.signal,
|
|
pid: 123,
|
|
error: scenario.outcome === 'completed' ? null : 'saved failure',
|
|
outcome: scenario.outcome,
|
|
notices: ['saved notice'],
|
|
truncated: true,
|
|
outputFiles: ['/tmp/saved.log'],
|
|
},
|
|
});
|
|
await fixture.emit('item.tool_call.updated', {
|
|
itemId: 'item-structured-shell',
|
|
toolCallId: 'structured-shell',
|
|
name: 'run_shell_command',
|
|
status: scenario.outcome === 'completed' ? 'completed' : 'failed',
|
|
});
|
|
await fixture.emit('turn.completed');
|
|
await page.reload();
|
|
expect(fixture.detailRequests).toEqual([]);
|
|
await page.getByRole('button', { name: 'Shell', exact: true }).click();
|
|
const shell = page.locator(
|
|
'[data-transcript-tool-call-id="p1:item-structured-shell"]',
|
|
);
|
|
await expect(shell).toContainText(scenario.status);
|
|
await expect(
|
|
shell.getByText('structured stdout', { exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
shell.getByText('saved notice', { exact: true }),
|
|
).toBeVisible();
|
|
await expect(shell).toContainText('Output preview truncated');
|
|
await expect(shell).not.toContainText('legacy display text');
|
|
await shell.getByText('Execution details', { exact: true }).click();
|
|
await expect(
|
|
shell.getByText('/workspace/project', { exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
shell.getByText('/tmp/saved.log', { exact: true }),
|
|
).toBeVisible();
|
|
if (scenario.exitCode !== null) {
|
|
await expect(
|
|
shell
|
|
.locator('dl')
|
|
.getByText(String(scenario.exitCode), { exact: true }),
|
|
).toBeVisible();
|
|
} else {
|
|
await expect(shell).toContainText('Signal: 15');
|
|
}
|
|
if (scenario.outcome !== 'completed') {
|
|
await expect(
|
|
shell.getByText('saved failure', { exact: true }),
|
|
).toBeVisible();
|
|
}
|
|
expect(fixture.detailRequests).toEqual([
|
|
{ sessionId: SESSION_ID, itemId: 'item-structured-shell' },
|
|
]);
|
|
expect(fixture.prompts).toHaveLength(0);
|
|
expect(fixture.daemon.promptRequests()).toHaveLength(0);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
}
|
|
|
|
test('Managed details distinguish saved empty output from unavailable output @smoke', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const fixture = await installManagedScenario(page, testInfo);
|
|
fixture.toolDetails.set('item-empty-shell', {
|
|
input: { command: 'true' },
|
|
output: '',
|
|
truncated: false,
|
|
});
|
|
await fixture.emit('item.tool_call.updated', {
|
|
itemId: 'item-empty-shell',
|
|
toolCallId: 'empty-shell',
|
|
name: 'run_shell_command',
|
|
status: 'completed',
|
|
});
|
|
await fixture.emit('turn.completed');
|
|
await page.reload();
|
|
await page.getByRole('button', { name: 'Shell', exact: true }).click();
|
|
const shell = page.locator(
|
|
'[data-transcript-tool-call-id="p1:item-empty-shell"]',
|
|
);
|
|
await expect(shell).toContainText('No output');
|
|
await expect(shell).toContainText('true');
|
|
await expect(shell).not.toContainText('Output is unavailable');
|
|
await expect(shell.locator('dl:empty')).toHaveCount(0);
|
|
expect(fixture.detailRequests).toEqual([
|
|
{ sessionId: SESSION_ID, itemId: 'item-empty-shell' },
|
|
]);
|
|
expect(fixture.prompts).toHaveLength(0);
|
|
expect(fixture.daemon.promptRequests()).toHaveLength(0);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test('collapsing a Managed card cancels its pending detail read @smoke', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const fixture = await installManagedScenario(page, testInfo);
|
|
await fixture.emit('item.tool_call.updated', {
|
|
itemId: 'item-delayed-read',
|
|
toolCallId: 'delayed-read',
|
|
name: 'read_file',
|
|
status: 'failed',
|
|
});
|
|
await fixture.emit('turn.completed');
|
|
await page.reload();
|
|
const failedReads: string[] = [];
|
|
page.on('requestfailed', (request) => {
|
|
if (new URL(request.url()).pathname.endsWith('/tool-details/get')) {
|
|
failedReads.push(request.failure()?.errorText ?? 'request failed');
|
|
}
|
|
});
|
|
let reads = 0;
|
|
let release!: () => void;
|
|
const pending = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.route(
|
|
'**/api/agent/web-shell/v1/tool-details/get',
|
|
async (route) => {
|
|
reads += 1;
|
|
const response = {
|
|
status: 200,
|
|
json: {
|
|
input: { file_path: 'saved.txt' },
|
|
output: 'saved detail after reopening',
|
|
truncated: false,
|
|
},
|
|
};
|
|
if (reads === 1) {
|
|
await pending;
|
|
await route.fulfill(response).catch(() => undefined);
|
|
return;
|
|
}
|
|
await route.fulfill(response);
|
|
},
|
|
);
|
|
const read = page.locator(
|
|
'[data-transcript-tool-call-id="p1:item-delayed-read"]',
|
|
);
|
|
const header = page.getByRole('button', { name: 'ReadFile', exact: true });
|
|
try {
|
|
await header.click();
|
|
await expect.poll(() => reads).toBe(1);
|
|
await header.click();
|
|
await expect(header).toHaveAttribute('aria-expanded', 'false');
|
|
await expect.poll(() => failedReads.length).toBe(1);
|
|
} finally {
|
|
release();
|
|
}
|
|
await expect(read).toHaveCount(0);
|
|
await header.click();
|
|
await expect(read).toContainText('saved detail after reopening');
|
|
expect(reads).toBe(2);
|
|
expect(fixture.prompts).toHaveLength(0);
|
|
expect(fixture.daemon.promptRequests()).toHaveLength(0);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test('Managed cancellation waits for settlement before continuing the same session @smoke', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const fixture = await installManagedScenario(page, testInfo);
|
|
const composer = page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.locator('[data-web-shell-composer-editor] .cm-content');
|
|
const send = page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.getByRole('button', { name: 'Send message', exact: true });
|
|
await composer.fill('Turn to cancel');
|
|
await send.click();
|
|
await fixture.waitForCurrentStream();
|
|
await page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.getByRole('button', { name: 'esc to cancel', exact: true })
|
|
.click();
|
|
await fixture.waitForCurrentStream();
|
|
// waitForCurrentStream can pass on the still-running turn's stale cursor
|
|
// while the cancel POST is still on the wire: poll the actual list.
|
|
await expect.poll(() => fixture.cancellations).toEqual([{ turnId: 'p2' }]);
|
|
await expect(page.locator('[data-managed-progress]')).toContainText(
|
|
'Cancelling',
|
|
);
|
|
await expect(composer).toHaveAttribute('contenteditable', 'true');
|
|
await expect(
|
|
page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.locator('[data-web-shell-composer-submit]'),
|
|
).toBeDisabled();
|
|
await fixture.emit('turn.cancelled');
|
|
await expect(page.locator('[data-managed-progress]')).toHaveCount(0);
|
|
await expect(composer).toHaveAttribute('contenteditable', 'true');
|
|
await composer.fill('Continue after cancellation');
|
|
await send.click();
|
|
await fixture.waitForCurrentStream();
|
|
await fixture.emit('item.output_text.delta', {
|
|
text: 'Continuation succeeded',
|
|
});
|
|
await fixture.emit('turn.completed');
|
|
await expect(
|
|
page.getByText('Continuation succeeded', { exact: true }),
|
|
).toBeInViewport();
|
|
await expect(composer).toHaveAttribute('contenteditable', 'true');
|
|
await expect(page).toHaveURL(new RegExp(`managedSession=${SESSION_ID}`));
|
|
expect(fixture.prompts.map((request) => request.prompt)).toEqual([
|
|
[{ type: 'input_text', text: 'Turn to cancel' }],
|
|
[{ type: 'input_text', text: 'Continue after cancellation' }],
|
|
]);
|
|
expect(new Set(fixture.prompts.map((request) => request.key)).size).toBe(2);
|
|
expect(fixture.daemon.promptRequests()).toHaveLength(0);
|
|
expect(fixture.errors).toEqual([]);
|
|
});
|
|
|
|
test('a permanently failing pending create exposes a discard path @smoke', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await installManagedScenario(page, testInfo);
|
|
// Registered after the scenario's catch-all, so it wins for this path:
|
|
// the create fails permanently and the pending prompt would otherwise
|
|
// wedge the whole composer on an indefinite retry.
|
|
const attempts: string[] = [];
|
|
await page.route(
|
|
'**/api/agent/web-shell/v1/sessions/create',
|
|
async (route) => {
|
|
attempts.push(String(route.request().postDataJSON()?.idempotencyKey));
|
|
await route.fulfill({
|
|
status: 500,
|
|
json: { error: { code: 'create_down', message: 'permanent failure' } },
|
|
});
|
|
},
|
|
);
|
|
|
|
await page.getByRole('button', { name: 'New session', exact: true }).click();
|
|
const composer = page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.locator('[data-web-shell-composer-editor] .cm-content');
|
|
await composer.fill('Fragile draft');
|
|
await page
|
|
.getByRole('region', { name: 'Managed conversation' })
|
|
.getByRole('button', { name: 'Send message', exact: true })
|
|
.click();
|
|
|
|
await expect(
|
|
page.getByText('The previous message was not confirmed as submitted', {
|
|
exact: false,
|
|
}),
|
|
).toBeVisible();
|
|
await expect(composer).toHaveAttribute('contenteditable', 'false');
|
|
|
|
await page.getByRole('button', { name: 'Discard this request' }).click();
|
|
await expect(
|
|
page.getByText('The previous message was not confirmed as submitted', {
|
|
exact: false,
|
|
}),
|
|
).toBeHidden();
|
|
await expect(composer).toHaveAttribute('contenteditable', 'true');
|
|
await expect(composer).toHaveText('Fragile draft');
|
|
|
|
// Discard is quiet: no resubmission, no new idempotency key for the draft.
|
|
expect(attempts).toHaveLength(1);
|
|
});
|