diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 0ce038341826..fa31b3c086d7 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -7985,6 +7985,7 @@ function AcpmuxPane() { const queuedMessageActionsRef = useRef(undefined); // The chat's Codex skills, for its sent prompts' `/$name` (conversation/skillWords.ts). const skillWords = useSkillWords(snapshot.commands); + const queueRunsNext = snapshot.queue.some((entry) => !entry.heldByStop && !entry.held && !entry.awaitingOwner); // The waiting tagged-ask card's first answer, which the composer's ⌘↩ on an empty prompt sends // (NextStepCard.tsx, TurnActions `nextStepKey`). const [nextStepFire, setNextStepFire] = useState<{ fire: () => void }>(); @@ -8060,14 +8061,17 @@ function AcpmuxPane() { // reader's prompt and starts the next turn, or queues behind one already sent. Like Restart // (restart.ts), never to a View Only chat, a subagent's read-only pane or where the composer // cannot send, and only to the chat this pane still shows, never the next during a switch's hold. - ...(restartChat && { - resume: ( - (owner: string) => () => - restartReaches(owner, sessionIdRef.current, directClient.current?.selectedSession) - ? resumeThrough(callNative)() - : Promise.resolve() - )(restartChat), - }), + // A prompt that runs next anyway (a queued one no Stop or owner holds, such as a message acpmux + // queued again after the Stop) starts the next turn: no Continue beside it. + ...(restartChat && + !queueRunsNext && { + resume: ( + (owner: string) => () => + restartReaches(owner, sessionIdRef.current, directClient.current?.selectedSession) + ? resumeThrough(callNative)() + : Promise.resolve() + )(restartChat), + }), // A tagged ask's answer (NextStepCard.tsx) sends as Continue does, with the same reach. ...(restartChat && { nextStepKey, @@ -8216,6 +8220,7 @@ function AcpmuxPane() { catalog, restartChat, snapshot.commands, + queueRunsNext, nextStepKey, ], ); diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index baea2558e889..63fd63458121 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -1,6 +1,8 @@ { "sourceLanguage": "en", "strings": { + "turn.stoppedMark": {"comment":"Agent pane: one muted word in a stopped turn's footer (the person's Stop, or one cmux had to force); short","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"needs_review","value":"متوقف"}},"bs":{"stringUnit":{"state":"needs_review","value":"Zaustavljeno"}},"da":{"stringUnit":{"state":"needs_review","value":"Stoppet"}},"de":{"stringUnit":{"state":"needs_review","value":"Gestoppt"}},"en":{"stringUnit":{"state":"translated","value":"Stopped"}},"es":{"stringUnit":{"state":"needs_review","value":"Detenido"}},"fr":{"stringUnit":{"state":"needs_review","value":"Arrêté"}},"it":{"stringUnit":{"state":"needs_review","value":"Interrotto"}},"ja":{"stringUnit":{"state":"needs_review","value":"停止済み"}},"km":{"stringUnit":{"state":"needs_review","value":"បានឈប់"}},"ko":{"stringUnit":{"state":"needs_review","value":"중지됨"}},"nb":{"stringUnit":{"state":"needs_review","value":"Stoppet"}},"pl":{"stringUnit":{"state":"needs_review","value":"Zatrzymano"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Interrompido"}},"ru":{"stringUnit":{"state":"needs_review","value":"Остановлено"}},"th":{"stringUnit":{"state":"needs_review","value":"หยุดแล้ว"}},"tr":{"stringUnit":{"state":"needs_review","value":"Durduruldu"}},"uk":{"stringUnit":{"state":"needs_review","value":"Зупинено"}},"vi":{"stringUnit":{"state":"needs_review","value":"Đã dừng"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"已停止"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"已停止"}}}}, + "turn.continueHelp": {"comment":"Agent pane: tooltip of the Continue button on a stopped turn's footer; it sends the prompt “Continue” to the agent","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"needs_review","value":"يرسل «متابعة» ليكمل الوكيل من حيث توقف"}},"bs":{"stringUnit":{"state":"needs_review","value":"Šalje „Nastavi“ da agent nastavi tamo gdje je stao"}},"da":{"stringUnit":{"state":"needs_review","value":"Sender “Fortsæt”, så agenten fortsætter, hvor den stoppede"}},"de":{"stringUnit":{"state":"needs_review","value":"Sendet „Fortfahren“, damit der Agent dort weitermacht, wo er aufgehört hat"}},"en":{"stringUnit":{"state":"translated","value":"Sends “Continue” so the agent picks up where it stopped"}},"es":{"stringUnit":{"state":"needs_review","value":"Envía «Continuar» para que el agente siga donde se detuvo"}},"fr":{"stringUnit":{"state":"needs_review","value":"Envoie « Continuer » pour que l’agent reprenne là où il s’est arrêté"}},"it":{"stringUnit":{"state":"needs_review","value":"Invia «Continua» perché l’agente riprenda da dove si è fermato"}},"ja":{"stringUnit":{"state":"needs_review","value":"「続ける」を送信し、エージェントが停止したところから再開します"}},"km":{"stringUnit":{"state":"needs_review","value":"ផ្ញើ “បន្ត” ដើម្បីឱ្យភ្នាក់ងារបន្តពីកន្លែងដែលវាបានឈប់"}},"ko":{"stringUnit":{"state":"needs_review","value":"“계속”을 보내 에이전트가 멈춘 곳부터 이어서 하게 합니다"}},"nb":{"stringUnit":{"state":"needs_review","value":"Sender «Fortsett» slik at agenten fortsetter der den stoppet"}},"pl":{"stringUnit":{"state":"needs_review","value":"Wysyła „Kontynuuj”, aby agent wznowił pracę tam, gdzie przerwał"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Envia “Continuar” para o agente retomar de onde parou"}},"ru":{"stringUnit":{"state":"needs_review","value":"Отправляет «Продолжить», чтобы агент продолжил с места остановки"}},"th":{"stringUnit":{"state":"needs_review","value":"ส่ง “ดำเนินการต่อ” เพื่อให้เอเจนต์ทำต่อจากจุดที่หยุด"}},"tr":{"stringUnit":{"state":"needs_review","value":"Ajanın kaldığı yerden devam etmesi için “Devam et” gönderir"}},"uk":{"stringUnit":{"state":"needs_review","value":"Надсилає «Продовжити», щоб агент продовжив з місця зупинки"}},"vi":{"stringUnit":{"state":"needs_review","value":"Gửi “Tiếp tục” để tác tử làm tiếp từ chỗ đã dừng"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"发送“继续”,让智能体从停下的地方接着做"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"傳送「繼續」,讓代理程式從停下的地方接著做"}}}}, "composer.heldForWake": {"comment":"Agent pane: heads the prompts sent in a Cloud chat whose machine was paused; the machine wakes and they go once the chat is back","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"needs_review","value":"جارٍ تشغيل الجهاز…"}},"bs":{"stringUnit":{"state":"needs_review","value":"Mašina se budi…"}},"da":{"stringUnit":{"state":"needs_review","value":"Vækker maskinen…"}},"de":{"stringUnit":{"state":"needs_review","value":"Maschine wird geweckt…"}},"en":{"stringUnit":{"state":"translated","value":"Waking the machine…"}},"es":{"stringUnit":{"state":"needs_review","value":"Activando la máquina…"}},"fr":{"stringUnit":{"state":"needs_review","value":"Réveil de la machine…"}},"it":{"stringUnit":{"state":"needs_review","value":"Riattivazione della macchina…"}},"ja":{"stringUnit":{"state":"needs_review","value":"マシンを起動しています…"}},"km":{"stringUnit":{"state":"needs_review","value":"កំពុងដាស់ម៉ាស៊ីន…"}},"ko":{"stringUnit":{"state":"needs_review","value":"머신을 깨우는 중…"}},"nb":{"stringUnit":{"state":"needs_review","value":"Vekker maskinen…"}},"pl":{"stringUnit":{"state":"needs_review","value":"Wybudzanie maszyny…"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Ativando a máquina…"}},"ru":{"stringUnit":{"state":"needs_review","value":"Пробуждение машины…"}},"th":{"stringUnit":{"state":"needs_review","value":"กำลังปลุกเครื่อง…"}},"tr":{"stringUnit":{"state":"needs_review","value":"Makine uyandırılıyor…"}},"uk":{"stringUnit":{"state":"needs_review","value":"Пробудження машини…"}},"vi":{"stringUnit":{"state":"needs_review","value":"Đang đánh thức máy…"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"正在唤醒机器…"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"正在喚醒機器…"}}}}, "tools.approved": {"comment":"Agent pane: at the end of a tool row whose permission ask the person approved (Allow once or Always allow); the approval reads on its row, never as a line of its own","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"تمت الموافقة"}},"bs":{"stringUnit":{"state":"translated","value":"Odobreno"}},"da":{"stringUnit":{"state":"translated","value":"Godkendt"}},"de":{"stringUnit":{"state":"translated","value":"Genehmigt"}},"en":{"stringUnit":{"state":"translated","value":"Approved"}},"es":{"stringUnit":{"state":"translated","value":"Aprobado"}},"fr":{"stringUnit":{"state":"translated","value":"Approuvée"}},"it":{"stringUnit":{"state":"translated","value":"Approvata"}},"ja":{"stringUnit":{"state":"translated","value":"承認済み"}},"km":{"stringUnit":{"state":"translated","value":"បានយល់ព្រម"}},"ko":{"stringUnit":{"state":"translated","value":"승인됨"}},"nb":{"stringUnit":{"state":"translated","value":"Godkjent"}},"pl":{"stringUnit":{"state":"translated","value":"Zatwierdzono"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Aprovado"}},"ru":{"stringUnit":{"state":"translated","value":"Одобрено"}},"th":{"stringUnit":{"state":"translated","value":"อนุมัติแล้ว"}},"tr":{"stringUnit":{"state":"translated","value":"Onaylandı"}},"uk":{"stringUnit":{"state":"translated","value":"Схвалено"}},"vi":{"stringUnit":{"state":"translated","value":"Đã duyệt"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"已批准"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"已核准"}}}}, "subagents.state.idle": {"comment":"Agent pane: a running subagent's state word when it has done nothing for minutes with no call open (it waits on background work of its own)","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"needs_review","value":"خامل"}},"bs":{"stringUnit":{"state":"needs_review","value":"Neaktivan"}},"da":{"stringUnit":{"state":"needs_review","value":"Inaktiv"}},"de":{"stringUnit":{"state":"needs_review","value":"Untätig"}},"en":{"stringUnit":{"state":"translated","value":"Idle"}},"es":{"stringUnit":{"state":"needs_review","value":"Inactivo"}},"fr":{"stringUnit":{"state":"needs_review","value":"Inactif"}},"it":{"stringUnit":{"state":"needs_review","value":"Inattivo"}},"ja":{"stringUnit":{"state":"needs_review","value":"待機中"}},"km":{"stringUnit":{"state":"needs_review","value":"ទំនេរ"}},"ko":{"stringUnit":{"state":"needs_review","value":"유휴"}},"nb":{"stringUnit":{"state":"needs_review","value":"Inaktiv"}},"pl":{"stringUnit":{"state":"needs_review","value":"Bezczynny"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Ocioso"}},"ru":{"stringUnit":{"state":"needs_review","value":"Простаивает"}},"th":{"stringUnit":{"state":"needs_review","value":"ว่าง"}},"tr":{"stringUnit":{"state":"needs_review","value":"Boşta"}},"uk":{"stringUnit":{"state":"needs_review","value":"Простоює"}},"vi":{"stringUnit":{"state":"needs_review","value":"Đang rảnh"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"空闲"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"閒置"}}}}, @@ -8051,7 +8053,7 @@ } }, "stop.forced": { - "comment": "Agent pane: transcript note when the agent never answered Stop and cmux ended its turn itself", + "comment": "Agent pane: in the tooltips of a stopped turn's footer (never the transcript text) when the agent never answered Stop and cmux ended its turn itself", "extractionState": "manual", "localizations": { "ar": { diff --git a/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx b/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx index 6d3aaea886e5..faa5eec4e8b0 100644 --- a/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx +++ b/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx @@ -19,7 +19,8 @@ import { groupId, runStretches, toolGroups, type ToolGroup } from "./toolGroups" import type { RetryNote } from "../subagents/retryState"; import { TurnActionsContext } from "./turnActions"; import { errorRecovery, signInRetry, type ErrorRecovery } from "./errorRecovery"; -import { footerLabel, isEmptyFooter, workedLabel } from "./turns"; +import { footerLabel, isEmptyFooter, isStopped, workedLabel } from "./turns"; +import { stoppedTurnLook } from "./stoppedTurn.experiment"; import { turnCopyText } from "./turnCopy"; import { hasTokens, TimedLabel, tokensText, TurnTokens } from "./TurnTokens"; import { AlertCircle, ArrowRight, ChevronRight, Retry } from "./icons"; @@ -301,9 +302,20 @@ export function TurnFooter({ row }: { row: AcpmuxRow }) { const authenticationFailed = (kind === "subscription-login" || kind === "auth") && !teamMachine; const label = footerLabel(t, row); // The turn's time and tokens, for the time's tooltip: "Worked for 13s · ↓ 359 out ↑ 328.8K in". + // A Stop acpmux had to force (the agent never answered it) says so here only, never in the + // transcript's text (Leo, 2026-11-03: "idk if it should be showing things like that"). + const forced = row.stopForced ? t("stop.forced") : undefined; const detail = - [label, hasTokens(row.tokens) ? tokensText(t, language, row.tokens) : undefined].filter(Boolean).join(" · ") || - undefined; + [label, hasTokens(row.tokens) ? tokensText(t, language, row.tokens) : undefined, forced] + .filter(Boolean) + .join(" · ") || undefined; + // A stopped turn says so in one muted word, whoever ended it (stoppedTurn.experiment.ts). + const stoppedLook = isStopped(row) ? experimentArm(stoppedTurnLook) : "none"; + const stoppedWord = stoppedLook !== "none" && ( + + {t("turn.stoppedMark")} + + ); // The last turn, stopped and not yet followed by a prompt, offers Continue (Codex's): it sends the // continue prompt as the composer would, and the button goes once that prompt shows (turns.ts). const resumable = row.resumable === true && resume !== undefined; @@ -444,10 +456,12 @@ export function TurnFooter({ row }: { row: AcpmuxRow }) { )} + {stoppedLook === "word" && stoppedWord} {resumable && ( )} + {stoppedLook === "trailing" && stoppedWord} {!messages && } diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index 15ea833abad8..c2a57a1bd080 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -4200,6 +4200,17 @@ a.cv-sha:focus-visible { margin-inline-start: -2px; white-space: nowrap; } +/* A stopped turn's one muted word (TurnRows.tsx `stoppedWord`), in the time's caption size and the + footer's resting ink: what ended the turn is its tooltip, never the transcript's text. */ +.cv-turn-actions .cv-turn-stopped { + display: inline-flex; + align-items: center; + height: var(--cv-footer-row); + color: var(--cv-footer-ink); + font-size: var(--text-caption); + letter-spacing: var(--text-caption--letter-spacing); + white-space: nowrap; +} .cv-iconbtn.cv-turn-continue:disabled { opacity: 0.5; pointer-events: none; diff --git a/webviews/src/agent-session/acpmux/conversation/stoppedTurn.experiment.ts b/webviews/src/agent-session/acpmux/conversation/stoppedTurn.experiment.ts new file mode 100644 index 000000000000..bcf53b7b822c --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/stoppedTurn.experiment.ts @@ -0,0 +1,27 @@ +// l10n-allow-file: experiment descriptions are gallery (developer tool) text, never shown in the pane. +import { defineExperiment } from "../../../experiments/experiment"; + +/// How a stopped turn says it stopped (Leo, 2026-11-03, on "The agent didn't answer Stop, so cmux +/// ended its turn." under a Continue button: "this is an "interesting" recovery flow... idk if it +/// should be showing things like that"). A Stop acpmux had to force reads as any other Stop; only the +/// footer's tooltips say cmux ended it. Compare the arms in the gallery's `agent-pane.stopped-turn`. +export const stoppedTurnLook = defineExperiment({ + id: "stopped-turn-look", + title: "Stopped turn", + description: "How a stopped turn's footer says it stopped, whoever ended it.", + arms: { + word: { + label: "Stopped, then Continue", + description: "A muted Stopped after the footer's buttons, then Continue on the last turn.", + }, + trailing: { + label: "Continue, then Stopped", + description: "Continue first, the muted Stopped after it, before the time.", + }, + none: { + label: "Continue only", + description: "No word: the footer's buttons and Continue, as a stop drew before; Stopped is in the tooltip.", + }, + }, + defaultArm: "word", +}); diff --git a/webviews/src/agent-session/acpmux/conversation/stoppedTurn.gallery.ts b/webviews/src/agent-session/acpmux/conversation/stoppedTurn.gallery.ts new file mode 100644 index 000000000000..405e856821ec --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/stoppedTurn.gallery.ts @@ -0,0 +1,78 @@ +// l10n-allow-file: gallery fixtures (sample prompts and agent messages), not shipped UI. +// A stopped turn (TurnRows.tsx TurnFooter, direct.ts `deferLateSteers`). Leo, 2026-11-03, on a +// coordinator's message drawn above "Continue" and "The agent didn't answer Stop, so cmux ended its +// turn.": "this is an "interesting" recovery flow... idk if it should be showing things like that". +// A Stop acpmux had to force reads as any Stop: a muted "Stopped" in the footer, the reason only in +// its tooltips. A message steered in after the Stop never reached the agent, so it draws below the +// stop, in the order it came; one queued to run next takes Continue away. Compare arms shows the +// `stopped-turn-look` experiment's three footers. +import { agentPaneEntry } from "../../../gallery/format"; +import { assistant, chat, row, summary, user } from "../../../gallery/fixtures/acpmux"; +import { stoppedTurnLook } from "./stoppedTurn.experiment"; + +const prompt = "Migrate the parser to the new token stream"; + +const coordinator = (minutes: number, fields: Parameters[2] = {}) => + user("Status check: are you still on the parser migration? Reply with where you are.", minutes, { + incoming: { + kind: "message", + message: { + name: "coordinator", + sessionId: "coordinator", + text: "Status check: are you still on the parser migration? Reply with where you are.", + raw: "[coordinator] Status check: are you still on the parser migration? Reply with where you are.", + }, + }, + untyped: true, + ...fields, + }); + +const stoppedTurn = (fields: Parameters[1] = {}) => [ + user(prompt, 6), + assistant("The lexer already emits the new tokens. I'm moving the parser's lookahead over next.", 5.5), + summary(5.3, { status: "cancelled", durationMs: 41_000, ...fields }), +]; + +export default agentPaneEntry({ + id: "agent-pane.stopped-turn", + title: "Stopped turn", + area: "Agent pane", + height: 280, + covers: ["agent-session/acpmux/conversation/TurnRows.tsx#TurnFooter"], + keywords: ["stop", "stopped", "cancel", "continue", "forced", "steer"], + variants: { + "forced-stop": { + note: "acpmux ended the turn because the agent never answered Stop: it reads as any stop; the reason is in the tooltips of Stopped and the time.", + snapshot: chat(stoppedTurn({ stopForced: true })), + }, + "person-stop": { + note: "The person's Stop the agent answered: the same footer.", + snapshot: chat(stoppedTurn()), + }, + "forced-stop-message-after": { + note: "Another agent's message steered in after the Stop never reached the agent: it draws below the stop, and a prompt after the stop takes Continue away.", + snapshot: chat([...stoppedTurn({ stopForced: true }), coordinator(5.2, { afterStop: true })]), + }, + "forced-stop-queued-next": { + note: "acpmux queued that message to run next: its waiting card, and no Continue beside it.", + snapshot: chat(stoppedTurn({ stopForced: true }), { + queue: [{ id: "p-late", prompt: "Status check: are you still on the parser migration?", next: true }], + }), + }, + before: { + note: "Before (for comparison): the old notice line under Continue. Not drawn any more.", + snapshot: chat([ + user(prompt, 6), + assistant("The lexer already emits the new tokens. I'm moving the parser's lookahead over next.", 5.5), + coordinator(5.4), + summary(5.3, { status: "cancelled", durationMs: 41_000 }), + row("notice", 5.3, { text: "The agent didn't answer Stop, so cmux ended its turn." }), + ]), + }, + }, + experiment: { + definition: stoppedTurnLook, + // The footer at rest: the last stopped turn's row shows without a pointer. + script: [{ name: "at rest", run: async () => {} }], + }, +}); diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index e1badca976c1..a74d359b1f43 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -1513,6 +1513,19 @@ export class OrderedRows extends Map { return (!before || this.order.get(before.id) !== order) && (!after || this.order.get(after.id) !== order); } + /// Moves `id` after every row the event being reduced made, and before the next event's: a steer + /// its stopped turn never read goes below that turn's end (`deferLateSteers`). + placeAfterCurrent(id: string): void { + if (!this.has(id) || this.current === undefined) return; + this.order.set(id, this.current + 0.25); + this.local.delete(id); + this.pullRequestActivityVersion = Symbol(); + this.pullRequestCommandVersion = Symbol(); + this.transcriptSummaryVersion = {}; + this.ordered = undefined; + this.snapshot = undefined; + } + /// The place `id` sorts at: its event's sequence number, or just after the events seen before it. orderOf(id: string): number { return this.order.get(id) ?? Infinity; @@ -1822,6 +1835,12 @@ export class AcpmuxDirectClient { /// Each steer's row by its promptId. A steer that reaches the agent as its turn ends runs as the /// next prompt with the same promptId, recorded again: that prompt's row replaces the steer's. private steerRows = new Map(); + /// The running turn acpmux logged as stopping (its `stopping` record, hub/cancel.rs), until it ends. + private stoppingTurn?: { turnId?: string }; + /// Steers logged after their turn's `stopping` record. The agent never reads one: if the turn ends + /// stopped, each goes below the stop (`deferLateSteers`), where it belongs in time, and acpmux sends + /// it again as the next prompt with the same promptId, whose row replaces it (`steerRows`). + private lateSteers: string[] = []; /// The promptIds drawn as prompts. An older page folds in after the rows below it, so the steer /// a held prompt re-ran comes after that prompt: it draws nothing. Kept across that fold. private promptsShown = new Set(); @@ -2805,6 +2824,8 @@ export class AcpmuxDirectClient { this.compactionFenceSeq = undefined; this.continuations.clear(); this.steerRows.clear(); + this.stoppingTurn = undefined; + this.lateSteers = []; this.promptsShown.clear(); this.retryAttachments.clear(); this.supersededMessageIds.clear(); @@ -4946,6 +4967,8 @@ export class AcpmuxDirectClient { this.endedTurns.clear(); this.supersededTurns.clear(); this.steerRows.clear(); + this.stoppingTurn = undefined; + this.lateSteers = []; this.promptsShown.clear(); this.firstSeq = undefined; // Found again as the held events are reduced (`reduceEvent`). @@ -5113,6 +5136,7 @@ export class AcpmuxDirectClient { // A task's result waiting behind the turn draws its received line now (offer/SuggestedTasks.tsx). if (this.queuedResult(msg, event.at)) return; if (id) { + this.dropDeferredSteer(id, String(msg.text ?? "")); this.queueAuthors.set(id, promptAuthor(msg) ?? null); // acpmux names the images it kept; a record that names none (an older acpmux) keeps those // this pane sent. @@ -5910,6 +5934,14 @@ export class AcpmuxDirectClient { // A new list, never one changed in place: the snapshot names its rows by the list's identity. if (steer === "waiting") this.waitingSteers = [...this.waitingSteers, { rowId: durableRowId, promptId }]; if (steer === "waiting" && promptId) this.steerRows.set(promptId, durableRowId); + if ( + steer === "waiting" && + this.stoppingTurn && + (typeof msg.turnId !== "string" || + this.stoppingTurn.turnId === undefined || + msg.turnId === this.stoppingTurn.turnId) + ) + this.lateSteers = [...this.lateSteers, durableRowId]; const inlineParts = recordedPromptParts(msg, knownParts) ?? knownParts; const optimisticRow = optimisticRowId ? this.rows.get(optimisticRowId) : undefined; this.rows.set(durableRowId, { @@ -5954,6 +5986,9 @@ export class AcpmuxDirectClient { // A prompt opens its turn; a steer goes into the turn already open. if (promptId && msg.steer !== true) this.turnPrompt = promptRoot(promptId); this.turnOpen = true; + } else if (event.kind === "stopping") { + // acpmux took the person's Stop for the running turn (hub/cancel.rs `mark_stopping`). + if (this.turnOpen) this.stoppingTurn = { turnId: typeof msg.turnId === "string" ? msg.turnId : undefined }; } else if (event.kind === "turn_silent" || event.kind === "turn_heard") { // acpmux's diagnostics of a quiet model (hub/silent_turn.rs): the turn still reads Thinking. } else if (event.kind === "turn_started") { @@ -5964,6 +5999,10 @@ export class AcpmuxDirectClient { const agentStarted = !this.turnOpen && startedTurnId === undefined; this.stoppingOpens(event); this.stopSeesTurnOpen(event, startedTurnId, agentStarted); + if (this.stoppingTurn && startedTurnId !== undefined && startedTurnId !== this.stoppingTurn.turnId) { + this.stoppingTurn = undefined; + this.lateSteers = []; + } // A turn that does not name its prompt is no known one's: never the turn before's. this.turnPrompt = typeof msg.promptId === "string" && msg.promptId ? promptRoot(msg.promptId) : undefined; this.turnOpen = true; @@ -6063,18 +6102,12 @@ export class AcpmuxDirectClient { error: msg.errorText ?? (status === "failed" && typeof msg.error === "string" ? msg.error : undefined), ...(typeof msg.errorCode === "string" && msg.errorCode ? { errorCode: msg.errorCode } : {}), ...(checkpoint ? { checkpoint } : {}), + // acpmux ended a turn whose agent did not answer Stop (`hub/cancel.rs`). The turn reads + // as any stop does; only the footer's tooltip says cmux ended it (Leo, 2026-11-03: + // "idk if it should be showing things like that"). + ...(msg.cancelForced === true ? { stopForced: true as const } : {}), }); - // acpmux ended a turn whose agent did not answer Stop (`hub/cancel.rs`). After the summary, so its fold never hides it. - if (event.kind === "turn_result" && msg.cancelForced === true) { - const noticeId = `notice-stop-${turnId ?? event.seq}`; - this.rows.set(noticeId, { - id: noticeId, - version: 1, - at: event.at, - kind: "notice", - text: translate("stop.forced"), - }); - } + if (event.kind === "turn_result") this.deferLateSteers(turnId, status); this.streamingAssistant = undefined; this.streamingAssistantMessageId = undefined; this.streamingActivity = undefined; @@ -6437,6 +6470,43 @@ export class AcpmuxDirectClient { return undefined; } + /// A stopped turn's end: each steer logged after its `stopping` record goes below the stop line, as a + /// plain message, since the agent never read it. Any other end lets them stand as they landed. + private deferLateSteers(turnId: string | undefined, status: string): void { + const stopping = this.stoppingTurn; + if (!stopping) return; + if (turnId !== undefined && stopping.turnId !== undefined && turnId !== stopping.turnId) return; + const late = this.lateSteers; + this.stoppingTurn = undefined; + this.lateSteers = []; + if (status !== "cancelled" && status !== "interrupted") return; + for (const rowId of late) { + const row = this.rows.get(rowId); + if (row?.kind !== "user") continue; + // Queued again already: its queued card draws it at the end of the chat. + if (row.promptId !== undefined && this.queue.some((entry) => entry.id === row.promptId)) { + this.rows.delete(rowId); + continue; + } + const { steer: _steer, steerPromptId: _steerPromptId, ...rest } = row; + this.rows.set(rowId, { ...rest, version: row.version + 1, afterStop: true }); + this.rows.placeAfterCurrent(rowId); + } + } + + /// A prompt acpmux queued again: a steer drawn below a stop gives way to its queued card. + private dropDeferredSteer(promptId: string, text: string): void { + for (const row of this.rows.values()) + if ( + row.kind === "user" && + row.afterStop === true && + // Its promptId when it has one; its text only when it has none, so two messages with the same + // words but different prompts never both go. + (row.promptId !== undefined ? row.promptId === promptId : (row.resend ?? row.text) === text) + ) + this.rows.delete(row.id); + } + /// The waiting steers (or the one `promptId` names) were read: their rows say so. private landSteer(promptId?: string): void { const read = this.waitingSteers.filter((waiting) => promptId === undefined || waiting.promptId === promptId); diff --git a/webviews/src/agent-session/acpmux/mockFixture.ts b/webviews/src/agent-session/acpmux/mockFixture.ts index 1c286dfad21a..fa454d1ba81e 100644 --- a/webviews/src/agent-session/acpmux/mockFixture.ts +++ b/webviews/src/agent-session/acpmux/mockFixture.ts @@ -581,6 +581,62 @@ export const mockSessions: MockSession[] = [ ]; }, }, + { + // A Stop the agent never answered, with another agent's message steered in a second after it + // (Leo, 2026-11-03: "this is an "interesting" recovery flow... idk if it should be showing things + // like that"): acpmux forced the turn's end 10 s later and ran the message as the next turn's + // prompt, same promptId. The stopped turn reads "Stopped", the message comes after it. + sessionId: "mock-forced-stop", + title: "Migrate the parser to the new token stream", + harness: "claude", + model: "claude-opus-5-5", + status: "idle", + cwd: CMUX, + ago: 9400, + host: LOCAL_HOST, + hostKind: "local", + reply: "Still on the parser: the lexer is done, the lookahead is next.", + history: (at) => [ + { + ago: at + 120_000, + mux: "user_message", + msg: { text: "Migrate the parser to the new token stream", promptId: "mock-fs-1", turnId: "mock-fs-turn-1" }, + }, + { ago: at + 120_000, mux: "turn_started", msg: { turnId: "mock-fs-turn-1", promptId: "mock-fs-1" } }, + { ago: at + 80_000, update: text("The lexer already emits the new tokens. I'm moving the lookahead over next.") }, + { ago: at + 60_000, mux: "stopping", msg: { turnId: "mock-fs-turn-1" } }, + { + ago: at + 59_000, + mux: "user_message", + msg: { + text: "Status check: are you still on the parser migration?", + promptId: "mock-fs-late", + turnId: "mock-fs-turn-1", + steer: true, + _meta: { provenance: { kind: "session_message", from: "mock-session", fromShortName: "coordinator" } }, + }, + }, + { ago: at + 50_000, mux: "turn_end", msg: { stopReason: "cancelled", turnId: "mock-fs-turn-1" } }, + { + ago: at + 50_000, + mux: "turn_result", + msg: { status: "cancelled", stopReason: "cancelled", turnId: "mock-fs-turn-1", cancelForced: true }, + }, + { + ago: at + 40_000, + mux: "user_message", + msg: { + text: "Status check: are you still on the parser migration?", + promptId: "mock-fs-late", + turnId: "mock-fs-turn-2", + _meta: { provenance: { kind: "session_message", from: "mock-session", fromShortName: "coordinator" } }, + }, + }, + { ago: at + 40_000, mux: "turn_started", msg: { turnId: "mock-fs-turn-2", promptId: "mock-fs-late" } }, + { ago: at + 10_000, update: text("Still on the parser: the lexer is done, the lookahead is next.") }, + { ago: at, mux: "turn_result", msg: { status: "completed", turnId: "mock-fs-turn-2" } }, + ], + }, { sessionId: "mock-session-links", title: "Which lanes own Session Info", diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index b032a6608c0d..e0d831d4d52e 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -511,6 +511,12 @@ export type AcpmuxRow = { /// The summary of a turn a prompt sent now ended, not Stop or Esc (direct.ts): the turn goes on /// under that prompt, so it draws no "You stopped after" (conversation/turns.ts). continued?: boolean; + /// A stopped turn's summary that acpmux ended itself because the agent never answered Stop + /// (`cancelForced`, hub/cancel.rs). It draws as any stop; only the footer's tooltip says so. + stopForced?: true; + /// A steer logged after its turn's Stop and never read: drawn as a plain message below the stop + /// (direct.ts `deferLateSteers`), until acpmux runs it again as the next prompt. + afterStop?: true; /// A waiting steer's promptId, which its Send now names (conversation/PendingSteer.tsx). steerPromptId?: string; /// A prompt's promptId as acpmux recorded it (its `user_message` record's): the thread rail keys diff --git a/webviews/src/experiments/registry.ts b/webviews/src/experiments/registry.ts index 5c1bfc5922cd..0ad7b090622e 100644 --- a/webviews/src/experiments/registry.ts +++ b/webviews/src/experiments/registry.ts @@ -24,6 +24,7 @@ import { finishedTaskLook } from "../agent-session/acpmux/conversation/taskLine. import { turnErrorCard } from "../agent-session/acpmux/conversation/turnErrorCard.experiment"; import { pausedMachineResume } from "../agent-session/acpmux/pausedMachine.experiment"; import { codeRouterOffer } from "../agent-session/acpmux/conversation/codeRouterOffer.experiment"; +import { stoppedTurnLook } from "../agent-session/acpmux/conversation/stoppedTurn.experiment"; import { chromiumGlassUnderlay } from "../ui/glass/chromiumUnderlay.experiment"; import { questionPreviewLayout } from "../agent-session/acpmux/question/previewLayout.experiment"; import { trayPanelLook, trayRowLook, trayStopLook } from "../agent-session/acpmux/subagents/trayPanel.experiment"; @@ -60,6 +61,7 @@ export const EXPERIMENTS: readonly Experiment[] = [ turnErrorCard, pausedMachineResume, codeRouterOffer, + stoppedTurnLook, chromiumGlassUnderlay, questionPreviewLayout, toolErrorLook, diff --git a/webviews/test/agent-pane-compaction-rows.test.ts b/webviews/test/agent-pane-compaction-rows.test.ts index 31a84dc04791..60089ddfc8dd 100644 --- a/webviews/test/agent-pane-compaction-rows.test.ts +++ b/webviews/test/agent-pane-compaction-rows.test.ts @@ -10,7 +10,6 @@ import { turnView } from "../src/agent-session/acpmux/conversation/turns"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { CompactionNote, compactTokens } from "../src/agent-session/acpmux/conversation/CompactionNote"; -import { translate } from "../src/agent-session/acpmux/i18n"; const CHAT = "0190a000-0000-7000-8000-0000000000c7"; const START = Date.UTC(2026, 9, 18, 9, 0, 0); @@ -273,7 +272,7 @@ test("a compaction that ends after a forced Stop ended its turn stays one row", expect(compactions(reloaded.rows())).toEqual([[3, "completed"]]); }); -test("a compacting forced Stop is one notice and exposes Continue after the matching result", async () => { +test("a compacting forced Stop is one forced summary and exposes Continue after the matching result", async () => { const forced = { status: "cancelled", stopReason: "cancelled", @@ -295,7 +294,7 @@ test("a compacting forced Stop is one notice and exposes Continue after the matc expect(daemon.sent.filter((raw) => JSON.parse(raw).method === "session/cancel")).toHaveLength(1); // A compacting Stop settles only when acpmux publishes its terminal result; the GUI must not // manufacture a forced-end notice or Continue while that result is still in flight. - expect(rows().filter((row) => row.kind === "notice" && row.text === translate("stop.forced"))).toHaveLength(0); + expect(rows().filter((row) => row.kind === "turnSummary" && row.stopForced === true)).toHaveLength(0); expect(turnView(rows(), new Set(), { now: START + 30_000 }).some((row) => row.resumable)).toBe(false); daemon.push( record( @@ -307,7 +306,7 @@ test("a compacting forced Stop is one notice and exposes Continue after the matc 4, ), ); - const notices = () => rows().filter((row) => row.kind === "notice" && row.text === translate("stop.forced")); + const notices = () => rows().filter((row) => row.kind === "turnSummary" && row.stopForced === true); expect(notices()).toHaveLength(1); const summaries = turnView(rows(), new Set(), { now: START + 30_000 }).filter((row) => row.kind === "turnSummary"); expect(summaries).toHaveLength(1); @@ -669,7 +668,7 @@ for (const mode of ["loadOlder", "lag"]) const { client, rows, working } = await connect(daemon, (connection) => { if (connection === "resynced") resynced?.(); }); - const notices = () => rows().filter((row) => row.id === "notice-stop-turn-current"); + const notices = () => rows().filter((row) => row.id === "summary-turn-current" && row.stopForced === true); jest.useFakeTimers(); try { await client.cancel(); @@ -750,7 +749,7 @@ for (const oldTurnId of ["turn-old", undefined]) }); for (const turnId of ["turn-grace", undefined]) - test(`only the authoritative Stop result draws one notice (${turnId ?? "unknown identity"})`, async () => { + test(`only the authoritative Stop result marks one summary forced (${turnId ?? "unknown identity"})`, async () => { const daemon = fakeDaemon( record([ ["mux", "user_message", { text: "work", turnId }], @@ -758,7 +757,7 @@ for (const turnId of ["turn-grace", undefined]) ]), ); const { client, rows } = await connect(daemon); - const notices = () => rows().filter((row) => row.kind === "notice" && row.text === translate("stop.forced")); + const notices = () => rows().filter((row) => row.kind === "turnSummary" && row.stopForced === true); jest.useFakeTimers(); try { await client.cancel(); @@ -768,7 +767,9 @@ for (const turnId of ["turn-grace", undefined]) daemon.push(record([["mux", "turn_result", result]], 3)); daemon.push(record([["mux", "turn_result", result]], 4)); expect(notices()).toHaveLength(1); - expect(notices()[0]?.id).toBe("notice-stop-turn-grace"); + expect(notices()[0]?.id).toBe("summary-turn-grace"); + // No transcript line says how the Stop ended (Leo, 2026-11-03): no notice row at all. + expect(rows().some((row) => row.kind === "notice")).toBe(false); } finally { client.close(); jest.useRealTimers(); @@ -1721,3 +1722,109 @@ test("a compaction whose end was never heard says it ended, not that it failed", expect(reported).toContain('data-state="failed"'); expect(render({ state: "failed" })).toContain("Compaction failed"); }); + +// Leo, 2026-11-03, on a coordinator's message drawn above "Continue" and "The agent didn't answer +// Stop, so cmux ended its turn.": "this is an "interesting" recovery flow... idk if it should be +// showing things like that". A message steered in after acpmux took the Stop never reached the +// agent: it draws below the stop, in the order it came, and acpmux's run of it as the next prompt +// (same promptId) takes its place. The forced end says nothing in the transcript. +const lateSteerHistory = (): Step[] => [ + ["mux", "user_message", { text: "migrate the parser", promptId: "p-1", turnId: "turn-1" }], + ["mux", "turn_started", { turnId: "turn-1", promptId: "p-1" }], + ["mux", "stopping", { turnId: "turn-1" }], + [ + "mux", + "user_message", + { text: "status check from the coordinator", promptId: "p-late", turnId: "turn-1", steer: true }, + ], + ["mux", "turn_end", { stopReason: "cancelled", turnId: "turn-1" }], + ["mux", "turn_result", { status: "cancelled", stopReason: "cancelled", turnId: "turn-1", cancelForced: true }], +]; +const drawnKinds = (rows: AcpmuxRow[]) => + rows + .filter((row) => row.kind === "user" || row.kind === "turnSummary" || row.kind === "notice") + .map((row) => (row.kind === "user" ? row.text : row.kind)); + +for (const live of [true, false]) + test(`a steer logged after the Stop draws below the stopped turn (${live ? "live" : "reload"})`, async () => { + const steps = lateSteerHistory(); + const daemon = fakeDaemon(record(live ? steps.slice(0, 2) : steps)); + const { client, rows } = await connect(daemon); + try { + if (live) daemon.push(record(steps.slice(2), 3)); + expect(drawnKinds(rows())).toEqual(["migrate the parser", "turnSummary", "status check from the coordinator"]); + const late = rows().find((row) => row.promptId === "p-late")!; + expect(late.steer).toBeUndefined(); + expect(late.afterStop).toBe(true); + expect(rows().find((row) => row.kind === "turnSummary")?.stopForced).toBe(true); + // acpmux runs it as the next prompt: one row, still below the stop. + daemon.push( + record( + [ + [ + "mux", + "user_message", + { text: "status check from the coordinator", promptId: "p-late", turnId: "turn-2" }, + ], + ["mux", "turn_started", { turnId: "turn-2", promptId: "p-late" }], + ], + 7, + ), + ); + expect(drawnKinds(rows())).toEqual(["migrate the parser", "turnSummary", "status check from the coordinator"]); + expect(rows().filter((row) => row.promptId === "p-late")).toHaveLength(1); + } finally { + client.close(); + } + }); + +test("a late steer acpmux queues again gives way to its queued card", async () => { + const daemon = fakeDaemon(record(lateSteerHistory())); + const { client, rows } = await connect(daemon); + try { + daemon.push(record([["mux", "queued", { text: "status check from the coordinator", promptId: "p-late" }]], 7)); + expect(rows().some((row) => row.kind === "user" && row.promptId === "p-late")).toBe(false); + } finally { + client.close(); + } +}); + +test("a re-queue drops only the late steer with its promptId, not another with the same words", async () => { + const steps = lateSteerHistory(); + steps.splice(4, 0, [ + "mux", + "user_message", + { text: "status check from the coordinator", promptId: "p-late-2", turnId: "turn-1", steer: true }, + ]); + const daemon = fakeDaemon(record(steps)); + const { client, rows } = await connect(daemon); + try { + daemon.push(record([["mux", "queued", { text: "status check from the coordinator", promptId: "p-late" }]], 8)); + expect( + rows() + .filter((row) => row.kind === "user" && row.afterStop) + .map((row) => row.promptId), + ).toEqual(["p-late-2"]); + } finally { + client.close(); + } +}); + +test("a steer before the Stop stays in its turn, and an answered Stop marks nothing forced", async () => { + const daemon = fakeDaemon( + record([ + ["mux", "user_message", { text: "migrate the parser", promptId: "p-1", turnId: "turn-1" }], + ["mux", "turn_started", { turnId: "turn-1", promptId: "p-1" }], + ["mux", "user_message", { text: "also the lexer", promptId: "p-early", turnId: "turn-1", steer: true }], + ["mux", "stopping", { turnId: "turn-1" }], + ["mux", "turn_result", { status: "cancelled", turnId: "turn-1" }], + ]), + ); + const { client, rows } = await connect(daemon); + try { + expect(drawnKinds(rows())).toEqual(["migrate the parser", "also the lexer", "turnSummary"]); + expect(rows().find((row) => row.kind === "turnSummary")?.stopForced).toBeUndefined(); + } finally { + client.close(); + } +}); diff --git a/webviews/test/agent-pane-steer.test.ts b/webviews/test/agent-pane-steer.test.ts index c1b0697843b9..d96cd9144fdf 100644 --- a/webviews/test/agent-pane-steer.test.ts +++ b/webviews/test/agent-pane-steer.test.ts @@ -36,6 +36,8 @@ await requireBrowserLane("agent-pane-steer.test.ts", async () => { const webviews = path.resolve(import.meta.dir, ".."); const SESSION = "steer-session"; + // A forced Stop's muted Stopped, which names cmux's end only in its tooltip. + const FORCED_STOP = `.cv-turn-stopped[title="The agent didn't answer Stop, so cmux ended its turn."]`; const turn = { turnId: "turn-1", promptId: "p-turn", @@ -434,7 +436,10 @@ await requireBrowserLane("agent-pane-steer.test.ts", async () => { } }); - test("acpmux's end of a turn whose agent ignored Stop says so", async () => { + // Leo, 2026-11-03: "this is an "interesting" recovery flow... idk if it should be showing things + // like that". A Stop acpmux had to force reads as any Stop: a muted Stopped, with the reason only + // in its tooltip, never a line of transcript text. + test("acpmux's end of a turn whose agent ignored Stop reads as a plain stop", async () => { const page = await openChat(); recordInbound("compaction", { method: "session/update", @@ -450,7 +455,11 @@ await requireBrowserLane("agent-pane-steer.test.ts", async () => { record("turn_end", { stopReason: "cancelled", turnId: "turn-1" }); record("turn_result", { status: "cancelled", stopReason: "cancelled", turnId: "turn-1", cancelForced: true }); record("status", { status: "idle" }); - await page.getByText("The agent didn't answer Stop, so cmux ended its turn.").waitFor({ timeout: 5_000 }); + await page.locator(".cv-turn-stopped", { hasText: "Stopped" }).waitFor({ timeout: 5_000 }); + expect(await page.locator(FORCED_STOP).count()).toBe(1); + expect( + await page.getByText("The agent didn't answer Stop, so cmux ended its turn.", { exact: true }).count(), + ).toBe(0); expect(await page.getByRole("button", { name: "Continue", exact: true }).count()).toBe(1); // The cancelled compaction can report its boundary after a new explicit prompt. It must not // revive the old row; a fresh running update still gets its own row. @@ -503,7 +512,7 @@ await requireBrowserLane("agent-pane-steer.test.ts", async () => { await page.getByRole("button", { name: "Stopping…", exact: true }).waitFor(); await page.clock.runFor(6_000); await drawn(page); - expect(await page.getByText("The agent didn't answer Stop, so cmux ended its turn.").count()).toBe(0); + expect(await page.locator(FORCED_STOP).count()).toBe(0); expect(await page.getByRole("button", { name: "Stopping…", exact: true }).count()).toBe(1); record("turn_end", { stopReason: "cancelled", turnId: "turn-1" }); await drawn(page); @@ -543,7 +552,7 @@ await requireBrowserLane("agent-pane-steer.test.ts", async () => { // Past the old five-second client deadline, then draw another record. await page.clock.runFor(6_000); await drawn(page); - expect(await page.getByText("The agent didn't answer Stop, so cmux ended its turn.").count()).toBe(0); + expect(await page.locator(FORCED_STOP).count()).toBe(0); expect(await page.getByRole("button", { name: "Stop", exact: true }).count()).toBe(1); await page.close(); }); @@ -559,7 +568,7 @@ await requireBrowserLane("agent-pane-steer.test.ts", async () => { // Past the old five-second client deadline, then draw another record. await page.clock.runFor(6_000); await drawn(page); - expect(await page.getByText("The agent didn't answer Stop, so cmux ended its turn.").count()).toBe(0); + expect(await page.locator(FORCED_STOP).count()).toBe(0); expect(await page.getByRole("button", { name: "Stop", exact: true }).count()).toBe(1); await page.close(); }); @@ -573,7 +582,7 @@ await requireBrowserLane("agent-pane-steer.test.ts", async () => { await page.clock.runFor(6_000); await drawn(page); expect(await page.getByRole("button", { name: "Stopping…", exact: true }).count()).toBe(1); - expect(await page.getByText("The agent didn't answer Stop, so cmux ended its turn.").count()).toBe(0); + expect(await page.locator(FORCED_STOP).count()).toBe(0); await page.close(); }); @@ -586,7 +595,7 @@ await requireBrowserLane("agent-pane-steer.test.ts", async () => { summary.status = "idle"; socket?.send(JSON.stringify({ jsonrpc: "2.0", method: "_acpmux/session_changed", params: { session: summary } })); try { - await page.getByText("The agent didn't answer Stop, so cmux ended its turn.").waitFor(); + await page.locator(FORCED_STOP).waitFor(); await lag(page); expect(await page.getByRole("button", { name: "Stop", exact: true }).count()).toBe(0); expect(await page.getByRole("button", { name: "Stopping…", exact: true }).count()).toBe(0);