fix(ui): retain rejected setup turns

This commit is contained in:
jesse-merhi
2026-08-13 12:05:01 +10:00
parent 60e85f3c51
commit ebdc94df20
3 changed files with 87 additions and 19 deletions
@@ -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<HTMLInputElement>(
'.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<HTMLButtonElement>(".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 () => {
@@ -244,7 +244,7 @@ export class CustodianSessionStore extends CustodianTranscriptState {
params: SystemAgentChatParams,
displayText: string,
questionReply: boolean,
appendUserMessage = true,
userTurnProjection: "always" | "unless-accepted" = "always",
): Promise<eventNudgeState.CustodianSendOutcome> {
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") {
@@ -25,7 +25,7 @@ type StructuredInteractionHost = {
client: GatewayBrowserClient,
params: SystemAgentChatParams,
display: string,
appendUserMessage: boolean,
userTurnProjection: "always" | "unless-accepted",
) => Promise<eventNudgeState.CustodianSendOutcome>;
};
@@ -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;