From c3b642befad74128983321ab27a114d673cf2162 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 3 Oct 2026 20:03:37 +0800 Subject: [PATCH 1/2] fix(app): keep composer waits and receipts in their conversation Signed-off-by: huangruiteng --- .../personal-workspace-page.tsx | 23 ++++----- .../use-conversation-input-state.ts | 50 +++++++++++++++++++ .../app-conversation-and-async-inbox-v0.md | 9 ++++ .../composer-session-admission.mjs | 46 ++++++++++++++++- 4 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index af0f6245d2..93017de219 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1,5 +1,6 @@ import { goalCreateRequest } from "./goal-create-request"; import { readSteeringRequest, retainSteeringRequest, retireSteeringRequest } from "./steering-recovery"; +import { useConversationInputState } from "./use-conversation-input-state"; import type { ConversationHistoryStatus } from "../../data/use-conversation-history"; import { GoalDraftCard } from "./goal-draft-card"; import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js"; @@ -843,15 +844,9 @@ export function PersonalWorkspacePage({ return {}; } }); - const [sending, setSending] = useState(false); - const [steering, setSteering] = useState(false); const [actionDraft, setActionDraft] = useState(null); const [loopxMode, setLoopxMode] = useState(null); const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue"); - const [loopxMessageReceipt, setLoopxMessageReceipt] = useState(""); - const [imageAttachments, setImageAttachments] = useState([]); - const [imageAttachmentError, setImageAttachmentError] = useState(null); - const [actionFeedback, setActionFeedback] = useState(null); const [lifecycleBusyGoalIds, setLifecycleBusyGoalIds] = useState>(() => new Set()); const [quickCompletingTodoIds, setQuickCompletingTodoIds] = useState>(() => new Set()); const [refreshState, setRefreshState] = useState<"idle" | "loading" | "done" | "error">("idle"); @@ -882,10 +877,10 @@ export function PersonalWorkspacePage({ const selectedAgentId = controlledAgentId ?? localAgentId; const composerDraftKey = `${selectedGoalId ?? "manager"}:${selectedAgentId}`; const composer = drafts[composerDraftKey] ?? ""; - useEffect(() => { - setImageAttachments([]); - setImageAttachmentError(null); - }, [composerDraftKey]); + const { sending, setSending, steering, setSteering, actionFeedback, setActionFeedback, + imageAttachments, setImageAttachments, imageAttachmentError, setImageAttachmentError, + loopxMessageReceipt, setLoopxMessageReceipt, isCurrentConversation, + } = useConversationInputState(composerDraftKey); useEffect(() => { const input = composerRef.current; if (!input) return; @@ -894,7 +889,7 @@ export function PersonalWorkspacePage({ }, [composer, selectedGoalId, managerChatOpen]); function setComposerDraft(key: string, value: string, expectedValue?: string) { setDrafts((current) => { - if (expectedValue !== undefined && current[key] !== expectedValue) return current; + if (expectedValue !== undefined && (current[key] ?? "") !== expectedValue) return current; const next = { ...current }; if (value) { next[key] = value; @@ -1274,7 +1269,7 @@ export function PersonalWorkspacePage({ } rememberSessionProposal(local.previewId, selectedGoalId); setProposals((current) => ({ ...current, [local.previewId]: local })); - if (options.select !== false) setSelection({ item: local, kind: "proposal" }); + if (options.select !== false && isCurrentConversation()) setSelection({ item: local, kind: "proposal" }); return local; } @@ -1733,7 +1728,7 @@ export function PersonalWorkspacePage({ setSending(true); try { const receipt = await sendLoopXMessage(conversationSessionId, message, loopxDelivery); - if (!messageOverride) setComposer(""); + if (!messageOverride) setComposerDraft(composerDraftKey, "", composer); setLoopxMessageReceipt(locale === "zh-CN" ? `${loopxDelivery === "queue" ? "已排队,等待后续回合" : loopxDelivery === "inbox" ? "已进入收件箱" : "已提交纠偏"} · ${receipt.status}` : `${loopxDelivery}: ${receipt.status}`); } catch (error) {setImageAttachmentError(error instanceof Error ? error.message : String(error));} finally {setSending(false);} @@ -1758,7 +1753,7 @@ export function PersonalWorkspacePage({ if (previews?.decision) await createPreview(previews.decision); } catch (error) { if (!messageOverride) { - setComposer(message); + setComposerDraft(composerDraftKey, message, ""); setImageAttachments(pendingImages); } const errorMessage = error instanceof Error ? error.message : t("feedback.sendGenericError"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts b/apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts new file mode 100644 index 0000000000..197feb9a88 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts @@ -0,0 +1,50 @@ +import { useRef, useState, type SetStateAction } from "react"; +import type { WorkspaceImageAttachment } from "./personal-workspace-model"; + +interface ConversationInputState { + sending: boolean; + steering: boolean; + actionFeedback: string | null; + imageAttachments: WorkspaceImageAttachment[]; + imageAttachmentError: string | null; + loopxMessageReceipt: string; +} + +const emptyInput: ConversationInputState = { + sending: false, + steering: false, + actionFeedback: null, + imageAttachments: [], + imageAttachmentError: null, + loopxMessageReceipt: "", +}; + +// An async callback keeps the key from its originating render. Leaving a +// conversation does not cancel delivery or let its late receipt edit a peer. +// This state is presentation only; Session/Turn ingress still owns effects. +export function useConversationInputState(key: string) { + const [inputs, setInputs] = useState>({}); + const visibleKey = useRef(key); + visibleKey.current = key; + + function setter(field: K) { + return (value: SetStateAction) => setInputs((current) => { + const input = current[key] ?? emptyInput; + const next = typeof value === "function" + ? (value as (previous: ConversationInputState[K]) => ConversationInputState[K])(input[field]) + : value; + return { ...current, [key]: { ...input, [field]: next } }; + }); + } + + return { + ...(inputs[key] ?? emptyInput), + setSending: setter("sending"), + setSteering: setter("steering"), + setActionFeedback: setter("actionFeedback"), + setImageAttachments: setter("imageAttachments"), + setImageAttachmentError: setter("imageAttachmentError"), + setLoopxMessageReceipt: setter("loopxMessageReceipt"), + isCurrentConversation: () => visibleKey.current === key, + }; +} diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index 21f493da7b..bdea753b17 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -167,6 +167,15 @@ receipt → observed work or actionable failure → readable answer in the same promise reload recovery. This tab-local cache is not delivery authority. Acceptance means the executor received the instructions, not that it adopted them or that delegated/team work stopped. Live adoption stays a release gate. +- The shared App composer owns pending submission, instruction receipts, feedback + and unsent images by its existing Goal/Agent conversation key. Waiting in one + conversation cannot lock Send in another. Returning retains that conversation's + actual wait or receipt; a late completion cannot clear a peer's pending request, + replace a newer draft or open an old proposal over the current conversation. + Navigation now retains unsent images in their original composer, rather than + discarding them; nothing is automatically resent. Packaged browser regressions + inject delayed receipts and a rejected independent request. This qualifies App + state ownership, not live model adoption, host steering or a new effect grant. - The compact receipt and full conversation offer the same controls. Failure ends the live indicator, preserves the request/partial answer and names the next supported action. A completed delegation still shows receiver adoption diff --git a/examples/personal-workspace-browser/composer-session-admission.mjs b/examples/personal-workspace-browser/composer-session-admission.mjs index 446fb81436..06138e4b7e 100644 --- a/examples/personal-workspace-browser/composer-session-admission.mjs +++ b/examples/personal-workspace-browser/composer-session-admission.mjs @@ -117,9 +117,53 @@ export const composerSessionAdmissionScenario = { for (let attempt = 0; attempt < 100 && !delayedReceipt; attempt += 1) await page.waitForTimeout(50); if (!delayedReceipt) throw new Error("Retry did not reach the original Turn"); await composerInput.fill("先写下另一条指令"); + await page.locator(".personal-manager-link").click(); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话", exact: true }).click(); + const managerDraft = "另一件事:给我列三条发布前检查。"; + await composerInput.fill(managerDraft); + if (await sendButton.isDisabled()) throw new Error("Another conversation's pending instruction blocks this composer's Send"); + let managerSubmission; + const managerRoute = "**/api/chat/sessions/*/turns"; + await page.route(managerRoute, (route) => { + if (route.request().method() === "POST" + && route.request().postDataJSON().message === managerDraft) managerSubmission = route; + else return route.fallback(); + }); + await sendButton.click(); + for (let attempt = 0; attempt < 100 && !managerSubmission; attempt += 1) await page.waitForTimeout(50); + if (!managerSubmission) throw new Error("The independent conversation did not submit its own request"); + const nextManagerDraft = "再列一下回滚步骤。"; + await composerInput.fill(nextManagerDraft); await delayedReceipt(); + await page.waitForTimeout(100); + if (!await sendButton.isDisabled() || await composerInput.inputValue() !== nextManagerDraft + || await page.getByRole("status").filter({ hasText: "执行器已接收本轮追加指令" }).count()) { + throw new Error("A late instruction receipt changed the other conversation's pending state, draft or feedback"); + } + await openGoalChat(); await page.getByRole("status").filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); if (await composerInput.inputValue() !== "先写下另一条指令") throw new Error("Delivery erased the draft typed while it was sending"); + await managerSubmission.fulfill({ status: 400, json: { ok: false, error: "独立请求未被接收", delivery_state: "not_delivered", turn_replay_safe: true } }); + await page.waitForTimeout(100); + if (await page.getByRole("status").filter({ hasText: "独立请求未被接收" }).count()) throw new Error("The other conversation's rejection leaked into this view"); + await page.locator(".personal-manager-link").click(); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话", exact: true }).click(); + await page.locator(".personal-action-feedback", { hasText: "独立请求未被接收" }).waitFor(); + if (await composerInput.inputValue() !== nextManagerDraft || await sendButton.isDisabled()) { + throw new Error("A rejected original request replaced the later draft or left its composer blocked"); + } + await page.unroute(managerRoute); + const image = { name: "release-check.png", mimeType: "image/png", + buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jN3sAAAAASUVORK5CYII=", "base64") }; + await page.locator('input[type="file"]').setInputFiles(image); + await page.locator(".personal-composer-images img").waitFor(); + await openGoalChat(); + if (await page.locator(".personal-composer-images img").count()) throw new Error("Unsent images moved to another conversation"); + await page.locator(".personal-manager-link").click(); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "对话", exact: true }).click(); + await page.locator(".personal-composer-images img").waitFor(); + if (await composerInput.inputValue() !== nextManagerDraft) throw new Error("Returning lost the unsent request's text"); + await openGoalChat(); if (new Set(adjustments.slice(0, 3).map(row => row.client_ingress_id)).size !== 1) throw new Error("Uncertain retries minted new ingress identities"); await composerInput.fill(instruction); await sendButton.click(); @@ -186,7 +230,7 @@ export const composerSessionAdmissionScenario = { throw new Error("Delivered replay did not read back one stored instruction on the current page"); } await managed.release(); - notes.push("managed Codex: ordinary composer steers its exact Turn while the original send waits, retaining drafts and retry identity, including after completion and reload; restored requests never dispatch automatically"); + notes.push("managed Codex: ordinary composer steers its exact Turn while the original send waits, retaining drafts and retry identity, including after completion and reload; concurrent conversations retain their own pending state, receipts, newer drafts and unsent images; restored requests never dispatch automatically"); const unsupported = await reloadWithRunningTurn("managed_runtime", `turn-unsupported-${Date.now()}`, "external"); await turnRunningHint.waitFor(); From b09f61477456e72ddba35eba84cb9bf36e320be0 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 3 Oct 2026 20:44:23 +0800 Subject: [PATCH 2/2] fix(app): restore failed input only before a newer draft exists Signed-off-by: huangruiteng --- .../personal-workspace-page.tsx | 42 +++---------------- .../use-conversation-input-state.ts | 37 +++++++++++++--- .../app-conversation-and-async-inbox-v0.md | 2 + .../composer-session-admission.mjs | 33 +++++++++++++-- 4 files changed, 69 insertions(+), 45 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 93017de219..7cd0d09edc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -833,17 +833,6 @@ export function PersonalWorkspacePage({ } const [managerConversationReceiptVisible, setManagerConversationReceiptVisible] = useState(false); const [goalConversationReceiptVisible, setGoalConversationReceiptVisible] = useState(false); - const [drafts, setDrafts] = useState>(() => { - try { - const raw = window.sessionStorage.getItem("loopx-pw-composer-drafts"); - const parsed = raw ? JSON.parse(raw) : {}; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? parsed as Record - : {}; - } catch { - return {}; - } - }); const [actionDraft, setActionDraft] = useState(null); const [loopxMode, setLoopxMode] = useState(null); const [loopxDelivery, setLoopxDelivery] = useState<"queue" | "inbox" | "steer">("queue"); @@ -876,8 +865,8 @@ export function PersonalWorkspacePage({ const actionReadback = useTypedActionReadback(readOnly, selectedGoalId); const selectedAgentId = controlledAgentId ?? localAgentId; const composerDraftKey = `${selectedGoalId ?? "manager"}:${selectedAgentId}`; - const composer = drafts[composerDraftKey] ?? ""; - const { sending, setSending, steering, setSteering, actionFeedback, setActionFeedback, + const { composer, setComposer, restoreFailedSubmission, + sending, setSending, steering, setSteering, actionFeedback, setActionFeedback, imageAttachments, setImageAttachments, imageAttachmentError, setImageAttachmentError, loopxMessageReceipt, setLoopxMessageReceipt, isCurrentConversation, } = useConversationInputState(composerDraftKey); @@ -887,26 +876,6 @@ export function PersonalWorkspacePage({ input.style.height = "auto"; input.style.height = `${Math.min(input.scrollHeight, 120)}px`; }, [composer, selectedGoalId, managerChatOpen]); - function setComposerDraft(key: string, value: string, expectedValue?: string) { - setDrafts((current) => { - if (expectedValue !== undefined && (current[key] ?? "") !== expectedValue) return current; - const next = { ...current }; - if (value) { - next[key] = value; - } else { - delete next[key]; - } - try { - window.sessionStorage.setItem("loopx-pw-composer-drafts", JSON.stringify(next)); - } catch { - // Storage may be unavailable (private mode); drafts simply stay in memory. - } - return next; - }); - } - function setComposer(value: string) { - setComposerDraft(composerDraftKey, value); - } async function reviewGoalDraft(draft: GoalDraft, edit = false, draftId = "") { // Source message + reviewed contents survive retry without merging distinct requests. if (!edit && !draft.question && draft.completion_criteria.trim()) { @@ -1705,7 +1674,7 @@ export function PersonalWorkspacePage({ try { await callbacks.onSteerConversationTurn(selectedGoalId ?? "manager", request.turnId, message, request.id); retireSteeringRequest(composerDraftKey, request.id); - if (!messageOverride) setComposerDraft(composerDraftKey, "", composer); + if (!messageOverride) setComposer("", composer); setActionFeedback(locale === "zh-CN" ? "执行器已接收本轮追加指令。" : "The executor accepted instructions for this turn."); } catch (error) { // Unknown delivery retries the original Turn even after it completes. @@ -1728,7 +1697,7 @@ export function PersonalWorkspacePage({ setSending(true); try { const receipt = await sendLoopXMessage(conversationSessionId, message, loopxDelivery); - if (!messageOverride) setComposerDraft(composerDraftKey, "", composer); + if (!messageOverride) setComposer("", composer); setLoopxMessageReceipt(locale === "zh-CN" ? `${loopxDelivery === "queue" ? "已排队,等待后续回合" : loopxDelivery === "inbox" ? "已进入收件箱" : "已提交纠偏"} · ${receipt.status}` : `${loopxDelivery}: ${receipt.status}`); } catch (error) {setImageAttachmentError(error instanceof Error ? error.message : String(error));} finally {setSending(false);} @@ -1753,8 +1722,7 @@ export function PersonalWorkspacePage({ if (previews?.decision) await createPreview(previews.decision); } catch (error) { if (!messageOverride) { - setComposerDraft(composerDraftKey, message, ""); - setImageAttachments(pendingImages); + restoreFailedSubmission(message, pendingImages); } const errorMessage = error instanceof Error ? error.message : t("feedback.sendGenericError"); setActionFeedback(t("feedback.sendFailed", { error: errorMessage })); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts b/apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts index 197feb9a88..a2d7327528 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/use-conversation-input-state.ts @@ -1,7 +1,8 @@ -import { useRef, useState, type SetStateAction } from "react"; +import { useEffect, useRef, useState, type SetStateAction } from "react"; import type { WorkspaceImageAttachment } from "./personal-workspace-model"; interface ConversationInputState { + composer: string; sending: boolean; steering: boolean; actionFeedback: string | null; @@ -11,6 +12,7 @@ interface ConversationInputState { } const emptyInput: ConversationInputState = { + composer: "", sending: false, steering: false, actionFeedback: null, @@ -23,22 +25,47 @@ const emptyInput: ConversationInputState = { // conversation does not cancel delivery or let its late receipt edit a peer. // This state is presentation only; Session/Turn ingress still owns effects. export function useConversationInputState(key: string) { - const [inputs, setInputs] = useState>({}); + const [inputs, setInputs] = useState>(() => { + try { + const parsed = JSON.parse(window.sessionStorage.getItem("loopx-pw-composer-drafts") ?? "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? Object.fromEntries(Object.entries(parsed).filter(([, text]) => typeof text === "string") + .map(([draftKey, text]) => [draftKey, { ...emptyInput, composer: text as string }])) + : {}; + } catch { return {}; } + }); + const savedDrafts = JSON.stringify(Object.fromEntries(Object.entries(inputs) + .filter(([, input]) => input.composer).map(([draftKey, input]) => [draftKey, input.composer]))); + useEffect(() => { + try { window.sessionStorage.setItem("loopx-pw-composer-drafts", savedDrafts); } + catch { /* Storage may be unavailable; input remains in memory. */ } + }, [savedDrafts]); const visibleKey = useRef(key); visibleKey.current = key; - function setter(field: K) { - return (value: SetStateAction) => setInputs((current) => { + function updateInput(update: (input: ConversationInputState) => ConversationInputState) { + setInputs((current) => { const input = current[key] ?? emptyInput; + const next = update(input); + return next === input ? current : { ...current, [key]: next }; + }); + } + + function setter(field: K) { + return (value: SetStateAction) => updateInput((input) => { const next = typeof value === "function" ? (value as (previous: ConversationInputState[K]) => ConversationInputState[K])(input[field]) : value; - return { ...current, [key]: { ...input, [field]: next } }; + return { ...input, [field]: next }; }); } return { ...(inputs[key] ?? emptyInput), + setComposer: (value: string, expectedValue?: string) => updateInput((input) => + expectedValue !== undefined && input.composer !== expectedValue ? input : { ...input, composer: value }), + restoreFailedSubmission: (text: string, images: WorkspaceImageAttachment[]) => updateInput((input) => + input.composer || input.imageAttachments.length ? input : { ...input, composer: text, imageAttachments: images }), setSending: setter("sending"), setSteering: setter("steering"), setActionFeedback: setter("actionFeedback"), diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index bdea753b17..4686a4414b 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -172,6 +172,8 @@ receipt → observed work or actionable failure → readable answer in the same conversation cannot lock Send in another. Returning retains that conversation's actual wait or receipt; a late completion cannot clear a peer's pending request, replace a newer draft or open an old proposal over the current conversation. + Failed delivery restores the submitted text and images together only while + that composer's draft is still empty; later text or pasted images take precedence. Navigation now retains unsent images in their original composer, rather than discarding them; nothing is automatically resent. Packaged browser regressions inject delayed receipts and a rejected independent request. This qualifies App diff --git a/examples/personal-workspace-browser/composer-session-admission.mjs b/examples/personal-workspace-browser/composer-session-admission.mjs index 06138e4b7e..06a1872faf 100644 --- a/examples/personal-workspace-browser/composer-session-admission.mjs +++ b/examples/personal-workspace-browser/composer-session-admission.mjs @@ -19,6 +19,15 @@ export const composerSessionAdmissionScenario = { const composerInput = page.getByLabel("向 LoopX 发送消息"); const sendButton = page.getByRole("button", { name: "发送", exact: true }); const turnRunningHint = page.locator(".personal-composer-status", { hasText: "本轮回答进行中" }); + const pasteDraftImage = async (name) => { + await composerInput.evaluate((target, filename) => { + const png = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jN3sAAAAASUVORK5CYII="), (char) => char.charCodeAt(0)); + const transfer = new DataTransfer(); + transfer.items.add(new File([png], filename, { type: "image/png" })); + target.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer })); + }, name); + await page.locator(".personal-composer-images").getByRole("img", { name, exact: true }).waitFor(); + }; const openGoalChat = async () => { await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); await page.locator(".personal-goal-link").first().click(); @@ -134,6 +143,8 @@ export const composerSessionAdmissionScenario = { if (!managerSubmission) throw new Error("The independent conversation did not submit its own request"); const nextManagerDraft = "再列一下回滚步骤。"; await composerInput.fill(nextManagerDraft); + await pasteDraftImage("pasted-during-send.png"); + const newerImage = page.locator(".personal-composer-images").getByRole("img", { name: "pasted-during-send.png", exact: true }); await delayedReceipt(); await page.waitForTimeout(100); if (!await sendButton.isDisabled() || await composerInput.inputValue() !== nextManagerDraft @@ -141,6 +152,7 @@ export const composerSessionAdmissionScenario = { throw new Error("A late instruction receipt changed the other conversation's pending state, draft or feedback"); } await openGoalChat(); + if (await newerImage.count()) throw new Error("A newer image moved to another conversation"); await page.getByRole("status").filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); if (await composerInput.inputValue() !== "先写下另一条指令") throw new Error("Delivery erased the draft typed while it was sending"); await managerSubmission.fulfill({ status: 400, json: { ok: false, error: "独立请求未被接收", delivery_state: "not_delivered", turn_replay_safe: true } }); @@ -152,10 +164,25 @@ export const composerSessionAdmissionScenario = { if (await composerInput.inputValue() !== nextManagerDraft || await sendButton.isDisabled()) { throw new Error("A rejected original request replaced the later draft or left its composer blocked"); } + if (!await newerImage.count()) throw new Error("Failed delivery discarded a newer image pasted while the request was pending"); + await page.unroute(managerRoute); + let imageOnlySubmission; + await page.route(managerRoute, (route) => { + if (route.request().method() === "POST" && route.request().postDataJSON().message === nextManagerDraft) imageOnlySubmission = route; + else return route.fallback(); + }); + await sendButton.click(); + for (let attempt = 0; attempt < 100 && !imageOnlySubmission; attempt += 1) await page.waitForTimeout(50); + if (!imageOnlySubmission) throw new Error("The image-bearing request did not reach admission"); + await pasteDraftImage("later-image-only.png"); + await imageOnlySubmission.fulfill({ status: 400, json: { ok: false, error: "图片请求未被接收", delivery_state: "not_delivered", turn_replay_safe: true } }); + await page.locator(".personal-action-feedback", { hasText: "图片请求未被接收" }).waitFor(); + if (await composerInput.inputValue() || await newerImage.count() + || !await page.locator(".personal-composer-images").getByRole("img", { name: "later-image-only.png", exact: true }).count()) { + throw new Error("Failure mixed the original text/images into a newer image-only draft"); + } await page.unroute(managerRoute); - const image = { name: "release-check.png", mimeType: "image/png", - buffer: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jN3sAAAAASUVORK5CYII=", "base64") }; - await page.locator('input[type="file"]').setInputFiles(image); + await composerInput.fill(nextManagerDraft); await page.locator(".personal-composer-images img").waitFor(); await openGoalChat(); if (await page.locator(".personal-composer-images img").count()) throw new Error("Unsent images moved to another conversation");