diff --git a/ui/src/pages/custodian/custodian-page.structured-wizard.test.ts b/ui/src/pages/custodian/custodian-page.structured-wizard.test.ts index 2ffe6151dd09..b7ee6dc88120 100644 --- a/ui/src/pages/custodian/custodian-page.structured-wizard.test.ts +++ b/ui/src/pages/custodian/custodian-page.structured-wizard.test.ts @@ -19,7 +19,10 @@ describe("custodian structured wizard", () => { vi.restoreAllMocks(); }); - it("keeps a rejected typed answer active without showing a submitted receipt", async () => { + it.each([ + { accepted: false, result: "rejected" }, + { accepted: undefined, result: "ambiguous" }, + ])("keeps a $result typed answer active as a plain user turn", async ({ accepted }) => { const step = { id: "port", type: "text" as const, @@ -38,7 +41,7 @@ describe("custodian structured wizard", () => { sessionId: "validation-session", reply: "Enter port 18789.", action: "none", - wizardActionAccepted: false, + ...(accepted === undefined ? {} : { wizardActionAccepted: accepted }), wizardInputPending: true, step, }); @@ -63,7 +66,64 @@ describe("custodian structured wizard", () => { }); expect(page.querySelector(".custodian__structured-response")).toBeNull(); expect(page.querySelector(".custodian__wizard-step")).not.toBeNull(); - expect(page.querySelector(".chat-group.user")).toBeNull(); + expect(page.querySelector(".chat-group.user")?.textContent).toContain("banana"); + const groups = [...page.querySelectorAll(".chat-group")]; + const rejectedTurnIndex = groups.findIndex( + (group) => group.classList.contains("user") && group.textContent?.includes("banana"), + ); + const guidanceIndex = groups.findIndex( + (group) => + group.classList.contains("assistant") && group.textContent?.includes("Enter port 18789."), + ); + expect(rejectedTurnIndex).toBeGreaterThanOrEqual(0); + expect(guidanceIndex).toBeGreaterThanOrEqual(0); + expect(rejectedTurnIndex).toBeLessThan(guidanceIndex); + }); + + it("keeps an ambiguously delivered sensitive answer masked as a plain user turn", async () => { + const step = { + id: "port", + type: "text" as const, + message: "Gateway port", + sensitive: true, + }; + const request = vi + .fn() + .mockResolvedValueOnce({ + sessionId: "uncertain-session", + reply: "Enter a port.", + action: "none", + sensitive: true, + wizardInputPending: true, + step, + }) + .mockImplementationOnce( + async (_method: string, _params: unknown, options: { onSent?: () => void }) => { + options.onSent?.(); + throw new Error("connection closed after send"); + }, + ); + const { context } = createContext(request); + const { page } = await mountPage(context); + + const input = await waitForFast(() => { + const element = page.querySelector( + '.custodian__wizard-step input[name="wizard-text"][type="password"]', + ); + expect(element).not.toBeNull(); + return element!; + }); + input.value = "banana"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await page.updateComplete; + page.querySelector(".custodian__wizard-step .btn.primary")!.click(); + + await waitForFast(() => + expect(page.querySelector(".chat-group.user")?.textContent).toContain("Sensitive reply sent"), + ); + expect(page.querySelector(".custodian__structured-response")).toBeNull(); + expect(page.querySelector(".custodian__wizard-step")).not.toBeNull(); + expect(page.innerHTML).not.toContain("banana"); }); it("does not confirm an answer after its Gateway client is replaced", async () => { @@ -131,6 +191,7 @@ describe("custodian structured wizard", () => { await waitForFast(() => expect(page.textContent).toContain("Gateway connection changed")); expect(page.textContent).not.toContain("Answer submitted"); expect(page.querySelector(".custodian__structured-response")).toBeNull(); + expect(page.querySelector(".chat-group.user")?.textContent).toContain("18789"); }); it("keeps older-Gateway wizard answers as plain user turns", async () => { diff --git a/ui/src/pages/custodian/custodian-session-store.ts b/ui/src/pages/custodian/custodian-session-store.ts index 76d5c5384de4..01edfb10ddf9 100644 --- a/ui/src/pages/custodian/custodian-session-store.ts +++ b/ui/src/pages/custodian/custodian-session-store.ts @@ -244,7 +244,7 @@ export class CustodianSessionStore extends CustodianTranscriptState { params: SystemAgentChatParams, displayText: string, questionReply: boolean, - appendUserMessage = true, + userTurnProjection: "always" | "unless-accepted" = "always", ): Promise { const questionState = [this.answeredQuestions, this.questionReplyUncertain] as const; if (questionReply) { @@ -252,25 +252,32 @@ export class CustodianSessionStore extends CustodianTranscriptState { } this.abandonedTurnOutcomeUnknown = false; this.answeredQuestions = retireCustodianQuestions(this.messages, this.answeredQuestions); - if (appendUserMessage) { - this.messages = [ - ...this.messages, - { - id: this.nextMessageId++, - role: "user", - text: displayText, - at: Date.now(), - question: null, - step: null, - structuredResponse: null, - }, - ]; + const precedingMessage = this.messages.at(-1) ?? null; + const userMessage: CustodianMessage = { + id: this.nextMessageId++, + role: "user", + text: displayText, + at: Date.now(), + question: null, + step: null, + structuredResponse: null, + }; + if (userTurnProjection === "always") { + this.messages = [...this.messages, userMessage]; } this.input = ""; this.emit(); const reply = this.requestReply(client, params); const replyEpoch = this.requestEpoch; const outcome = await reply; + if (userTurnProjection === "unless-accepted" && outcome !== "accepted") { + // Owner-confirmed acceptance replaces the attempted turn with a receipt. Every other + // outcome keeps the masked display in its original transcript position. + const precedingIndex = precedingMessage ? this.messages.indexOf(precedingMessage) : -1; + if (precedingIndex >= 0) { + this.messages = this.messages.toSpliced(precedingIndex + 1, 0, userMessage); + } + } if (questionReply && this.requestEpoch === replyEpoch) { this.questionReplyUncertain = eventNudgeState.questionUncertainty(questionState[1], outcome); if (outcome === "rejected") { diff --git a/ui/src/pages/custodian/custodian-structured-interaction.ts b/ui/src/pages/custodian/custodian-structured-interaction.ts index 992afd7ab076..8ee871834068 100644 --- a/ui/src/pages/custodian/custodian-structured-interaction.ts +++ b/ui/src/pages/custodian/custodian-structured-interaction.ts @@ -25,7 +25,7 @@ type StructuredInteractionHost = { client: GatewayBrowserClient, params: SystemAgentChatParams, display: string, - appendUserMessage: boolean, + userTurnProjection: "always" | "unless-accepted", ) => Promise; }; @@ -76,7 +76,7 @@ export function createCustodianStructuredInteraction(host: StructuredInteraction params.client, params.request, params.display, - !state.wizardActionReceiptsAvailable, + state.wizardActionReceiptsAvailable ? "unless-accepted" : "always", ); if (!state.wizardActionReceiptsAvailable) { return outcome;