Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ function QueuedMessage({sessionId, turn, index, targetTurnId, onChange, onInputC
return <li onKeyDown={event => {
if (composing.current || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
if (event.key === "Escape" && !busy && !unresolved) {
event.preventDefault(); event.stopPropagation(); discard(); return;
event.preventDefault(); event.stopPropagation();
if (menu) {setMenu(false); menuButton.current?.focus();}
else discard();
return;
}
if (editing) {
if (composerKeyAction({...event, isComposing: event.nativeEvent.isComposing, keyCode: event.nativeEvent.keyCode}, false) === "steer") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,19 @@ export const attachedHostFollowUpScenario = {
const retainedDraft = "保留这份尚未保存的更正。\n原消息启动后也不能把它重新发送或丢失。";
await queuedFollowUp.getByRole("button", {name: "更多操作:待处理消息 1", exact: true}).click();
await queuedFollowUp.getByRole("button", {name: "编辑", exact: true}).click();
await page.getByRole("textbox", {name: "编辑待处理消息 1", exact: true}).fill(retainedDraft);
const editor = page.getByRole("textbox", {name: "编辑待处理消息 1", exact: true});
await editor.fill(retainedDraft);
const queueMenu = queuedFollowUp.getByRole("button", {name: "更多操作:待处理消息 1", exact: true});
await queueMenu.click();
await page.keyboard.press("Escape");
await page.screenshot({path: resolve(outputDir, "queue-editor-menu-escape.png"), fullPage: false});
if (!await editor.isVisible() || await editor.inputValue() !== retainedDraft) {
throw new Error("Closing the queue action menu discarded the independent edit draft");
}
if (await queueMenu.getAttribute("aria-expanded") !== "false"
|| !await queueMenu.evaluate(element => element === document.activeElement)) {
throw new Error("Escape must close the queue action menu and restore its trigger focus");
}
await composer.fill("主输入框的另一份草稿");
page.__loopxRuntime.queuedTurns.set(attachedSessionId,
page.__loopxRuntime.queuedTurns.get(attachedSessionId).filter(turn => turn.turn_id !== "turn-queued-1"));
Expand Down
84 changes: 84 additions & 0 deletions examples/personal-workspace-browser/managed-agent-sessions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,88 @@ import {openWorkspacePage} from "./scenario-context.mjs";
import {resolveTestPython} from "../../scripts/test-python.mjs";
import {withManagedFixture} from "./managed-fixture-process.mjs";

async function verifyQueuedEditor({page, origin, sessionId, turnId, composer, control}) {
const nativeStarts = (await control("inspect")).calls_after_setup.filter(call => call.method === "turn/start").length;
const source = "# Source note\nPreserve the original attachment.\n";
const attachment = {id: "queue-source", name: "source.md", mime_type: "text/markdown",
size: Buffer.byteLength(source), data_url: `data:text/markdown;base64,${Buffer.from(source).toString("base64")}`};
const path = `/api/chat/sessions/${sessionId}`;
const submitted = await page.request.post(`${origin}${path}/turns`, {
data: {message: "Read the source before the next handoff.", attachments: [attachment],
client_turn_id: "managed-queue-editor", delivery: "queue"},
});
assert.equal(submitted.status(), 202);
const queuedId = (await submitted.json()).turn_id;
const inputPath = `${path}/queue/${queuedId}/input`;
const posts = [];
const observe = request => {if (request.method() === "POST" && new URL(request.url()).pathname.startsWith(path)) posts.push(request.url());};
page.on("request", observe);
try {
const row = page.locator(".personal-conversation-queue li");
const menu = row.getByRole("button", {name: "更多操作:待处理消息 1", exact: true});
await menu.click();
await row.getByRole("button", {name: "编辑", exact: true}).click();
const editor = row.getByRole("textbox", {name: "编辑待处理消息 1", exact: true});
const draft = "先核对原始材料,再继续下一步。\n保留这份独立的队列修改草稿和原附件。";
await editor.fill(draft);
await composer.fill("另一份尚未发送的主输入框草稿");
await menu.click();
await menu.dispatchEvent("keydown", {key: "Escape", isComposing: true});
assert.equal(await menu.getAttribute("aria-expanded"), "true", "IME Escape does not close the menu or discard the edit");
await page.keyboard.press("Escape");
assert.equal(await menu.getAttribute("aria-expanded"), "false");
assert.equal(await editor.inputValue(), draft);
assert.equal(await menu.evaluate(node => node === document.activeElement), true);
// Dismissing a menu is independent of storage availability. It must never
// call the editor's destructive discard path, even if that path would fail.
await page.evaluate(() => {window.queueRemoveItem = Storage.prototype.removeItem;
Storage.prototype.removeItem = function() {throw new Error("isolated storage failure");};});
try {
await menu.click(); await page.keyboard.press("Escape");
assert.equal(await menu.getAttribute("aria-expanded"), "false");
assert.equal(await editor.inputValue(), draft);
assert.equal(await row.getByRole("alert").count(), 0);
} finally {await page.evaluate(() => {Storage.prototype.removeItem = window.queueRemoveItem; delete window.queueRemoveItem;});}
assert.deepEqual(posts, [], "Menu dismissal neither edits, cancels, steers nor sends a message");
await page.screenshot({path: resolve(outputDir, "queue-editor-native-desktop.png")});
await page.setViewportSize({width: 390, height: 844});
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await page.screenshot({path: resolve(outputDir, "queue-editor-native-narrow.png")});
await page.reload({waitUntil: "domcontentloaded"});
await editor.waitFor();
assert.equal(await editor.inputValue(), draft, "Reload retains the edit under its original queued Turn");
assert.equal(await composer.inputValue(), "另一份尚未发送的主输入框草稿");
const before = await (await page.request.get(`${origin}${path}/queue`)).json();
assert.equal(before.active_turn_id, turnId);
assert.deepEqual(before.turns.map(turn => [turn.turn_id, turn.input_revision, turn.attachment_count]), [[queuedId, 0, 1]]);
const saved = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === inputPath);
await editor.press("Meta+Enter");
assert.equal((await (await saved).json()).input_revision, 1);
await editor.waitFor({state: "hidden"});
const history = await (await page.request.get(`${origin}${path}?turn_id=${queuedId}&message_limit=200`)).json();
const input = history.messages.find(message => message.role === "user" && message.turn_id === queuedId);
assert.equal(input.text, draft);
assert.deepEqual(input.attachments, [attachment]);
await menu.click();
await row.getByRole("button", {name: "编辑", exact: true}).click();
await editor.fill("明确放弃的修改");
await editor.press("Escape");
await editor.waitFor({state: "hidden"});
assert.equal(posts.length, 1, "Escape with no open menu still discards only the local edit");
await menu.click();
const cancelled = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === inputPath);
await row.getByRole("button", {name: "取消排队", exact: true}).click();
assert.equal((await (await cancelled).json()).input_revision, 2);
await row.waitFor({state: "hidden"});
const after = await (await page.request.get(`${origin}${path}/queue`)).json();
assert.equal(after.active_turn_id, turnId);
assert.deepEqual(after.turns, []);
assert.equal(posts.length, 2, "Only explicit save and cancel mutate the original queued input");
assert.equal((await control("inspect")).calls_after_setup.filter(call => call.method === "turn/start").length, nativeStarts,
"Queue editing adds no native execution");
} finally {page.off("request", observe);}
}

async function verifyManagedTurnObservers({page, origin, sessions, panel, chooser, composer, control}) {
const {session_id: sessionId} = await control("observer-session");
const before = (await control("inspect")).calls_after_setup.filter(row => row.method === "turn/start").length;
Expand Down Expand Up @@ -91,6 +173,8 @@ async function verifyManagedTurnObservers({page, origin, sessions, panel, choose
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await page.screenshot({path: resolve(outputDir, "managed-observer-narrow.png")});
await assertSingleObserver(ownTurn, 1);
await page.setViewportSize({width: 1440, height: 900});
await verifyQueuedEditor({page, origin, sessionId, turnId: ownTurn, composer, control});
await interrupt(ownTurn);

// Start through the same real ingress while another Session is selected.
Expand Down
Loading