From 9cb559f860eebb8463c239841a953de802e9d329 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Thu, 12 Nov 2026 16:34:36 -0800
Subject: [PATCH 1/3] Fix five bugs the computer and approvals journeys found
- Activity names a declined or handed-over step's control from the
snapshot its ref came from, not the ref ("Declined Place order").
- Every Activity row has its time: a step's answer says when it
finished (stamped by the server for the steps it runs after an
approval, and by the browser for its own), and a step the person
answered is timed by their answer.
- The Bot's screen fills its frame after a tab closes and after Hand
back: the window is fitted to the page the live screen starts showing,
so Chromium's --no-sandbox warning bar no longer cuts 56 px off the
tab left in front.
- The Reset and Update computer dialogs keep their row while they
close, so the title no longer falls back to "Reset the computer?".
- The masked secret box asks for the field by its ref: the instructions
no longer tell the Bot to click the field first, which handed the step
to the person.
Each fix has a journey assertion that fails on main, plus unit tests
where the rule is cheap to pin. New computer-secret journey, recorded
against the real model because the Bot's instructions changed.
---
agent-computer/src/bot-browser-host.ts | 2 +
agent-computer/src/index.ts | 12 +-
agent-computer/src/profiles.ts | 9 +
agent-computer/src/recorder-in-browser.ts | 47 +--
.../components/approvals/inline-approval.tsx | 4 +-
app/src/components/computer/activity-log.tsx | 3 +-
app/src/components/manage/computers-page.tsx | 72 +++--
app/src/lib/computers/activity.ts | 151 ++++++++--
app/src/lib/copilot/approval-tools.tsx | 18 +-
app/src/lib/copilot/computer-tools.tsx | 5 +-
.../computer-activity-names-and-times.test.ts | 274 ++++++++++++++++++
app/tests/personal-settings.test.tsx | 24 +-
e2e/README.md | 7 +-
...nai-2026-11-13T00-04-55-037Z-51ce2bd9.json | 31 ++
...nai-2026-11-13T00-04-57-413Z-a8059788.json | 76 +++++
...nai-2026-11-13T00-05-03-056Z-b4352ecf.json | 39 +++
...nai-2026-11-13T00-05-07-952Z-dc12df55.json | 59 ++++
...nai-2026-11-13T00-05-15-061Z-7bd22944.json | 54 ++++
...nai-2026-11-13T00-05-16-803Z-50396085.json | 34 +++
...nai-2026-11-13T00-05-25-959Z-12d29145.json | 47 +++
...nai-2026-11-13T00-05-38-758Z-ccc2f2d7.json | 37 +++
...nai-2026-11-13T00-05-48-192Z-c2cbbfd5.json | 42 +++
e2e/journeys/approvals-allow-once.journey.ts | 20 ++
e2e/journeys/computer-secret.journey.ts | 94 ++++++
e2e/journeys/computer-sign-in.journey.ts | 9 +
e2e/journeys/computer-steps.ts | 58 ++++
e2e/journeys/computer-take-control.journey.ts | 23 ++
server/src/computer/headless-tools.ts | 9 +-
.../computer-secret-without-a-click.test.ts | 73 +++++
server/tests/headless-tools.test.ts | 28 ++
shared/bot-prompt.ts | 5 +-
shared/computer-control.ts | 33 +++
32 files changed, 1315 insertions(+), 84 deletions(-)
create mode 100644 app/tests/computer-activity-names-and-times.test.ts
create mode 100644 e2e/fixtures/computer-secret/openai-2026-11-13T00-04-55-037Z-51ce2bd9.json
create mode 100644 e2e/fixtures/computer-secret/openai-2026-11-13T00-04-57-413Z-a8059788.json
create mode 100644 e2e/fixtures/computer-secret/openai-2026-11-13T00-05-03-056Z-b4352ecf.json
create mode 100644 e2e/fixtures/computer-secret/openai-2026-11-13T00-05-07-952Z-dc12df55.json
create mode 100644 e2e/fixtures/computer-secret/openai-2026-11-13T00-05-15-061Z-7bd22944.json
create mode 100644 e2e/fixtures/computer-secret/openai-2026-11-13T00-05-16-803Z-50396085.json
create mode 100644 e2e/fixtures/computer-secret/openai-2026-11-13T00-05-25-959Z-12d29145.json
create mode 100644 e2e/fixtures/computer-secret/openai-2026-11-13T00-05-38-758Z-ccc2f2d7.json
create mode 100644 e2e/fixtures/computer-secret/openai-2026-11-13T00-05-48-192Z-c2cbbfd5.json
create mode 100644 e2e/journeys/computer-secret.journey.ts
create mode 100644 server/tests/computer-secret-without-a-click.test.ts
diff --git a/agent-computer/src/bot-browser-host.ts b/agent-computer/src/bot-browser-host.ts
index 7917020b9c..3aad2c842e 100644
--- a/agent-computer/src/bot-browser-host.ts
+++ b/agent-computer/src/bot-browser-host.ts
@@ -458,6 +458,8 @@ export function createBotBrowserHost(options: BotBrowserHostOptions) {
? await bar.navigate(browser, navigate.value)
: ({ ok: true } as const);
if (!done.ok) return json({ error: done.error }, done.status);
+ // The tab now in front, fitted at once, so the screen never shows it short (see the live screen's `attach`).
+ if (tabs?.ok) await view.fitWindow(view.active());
const after = profiles.bot(botId);
return json(after ? await stateOf(botId, after) : closedState);
}
diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts
index bc1b8321f6..c8e95d3450 100644
--- a/agent-computer/src/index.ts
+++ b/agent-computer/src/index.ts
@@ -1024,6 +1024,15 @@ serve({
const attach = async () => {
const target = await currentPage(ws.data.botId);
if (target === casting) return;
+ /*
+ * THE WINDOW FITTED TO THE PAGE THE SCREEN IS ABOUT TO SHOW, every time it starts showing one. A tab laid out
+ * again (in front again after a person closed the one before it) takes what its window has then, and a window
+ * fitted for another tab can have less: Chromium's warning bar for `--no-sandbox` sits in a Bot's first tab and
+ * is only made room for when that tab is laid out again, so it came back 56 pixels short and the screen showed
+ * a black band under the page, after Hand back too. Fitted here, from the tab as it is now laid out, it holds
+ * the whole page. A Bot's browser only: a person's is fitted as its tabs open, and local Chrome is theirs.
+ */
+ await profiles.bot(ws.data.botId)?.fitWindow(target);
const cast = await startScreencast(target, send);
// The claim stops a cast it refuses, so a launch that lost the screen leaks nothing.
if (!(await claim.install(cast))) return;
@@ -1307,7 +1316,8 @@ serve({
return json(state);
}
- // The Bot asking for one value it must not be told. It has already focused the field.
+ // The Bot asking for one value it must not be told, naming the field by its ref. It does not click it first:
+ // the value is put into that field below, when the person sends it.
if (url.pathname === "/control/secret" && request.method === "POST") {
const body = (await request.json().catch(() => null)) as {
label?: unknown;
diff --git a/agent-computer/src/profiles.ts b/agent-computer/src/profiles.ts
index 35c38c2e6b..7ca15a06c9 100644
--- a/agent-computer/src/profiles.ts
+++ b/agent-computer/src/profiles.ts
@@ -83,6 +83,7 @@ import { seatIdsIn } from "./profile-listing";
import { findRecorder, isRecorderPage, type Recorder } from "./recorder";
import {
fitWindowsToViewport,
+ fitWindowToViewport,
holdFilterCredentials,
keepRecorderCurrent,
reachRecorder,
@@ -489,6 +490,8 @@ export type OpenBotBrowser = {
* `fitWindowsToViewport`). Not at launch: a Bot's browser nobody works stays exactly as it launches.
*/
fitWindows(): Promise;
+ /** Fit the window to this tab now, as it is laid out (see `fitWindowToViewport`). */
+ fitWindow(page: Page): Promise;
};
/** A person's open browser, for the browser routes. */
@@ -556,6 +559,7 @@ export function createProfiles(
pin(page: Page): void;
session(): Promise;
fitWindows(): Promise;
+ fitWindow(page: Page): Promise;
};
};
@@ -1251,6 +1255,10 @@ export function createProfiles(
fitted ??= fitWindowsToViewport(context, VIEWPORT, botId);
return fitted;
},
+ fitWindow: (shown: Page) =>
+ local
+ ? Promise.resolve()
+ : fitWindowToViewport(shown, VIEWPORT, botId),
},
};
touchTab(page);
@@ -1532,6 +1540,7 @@ export function createProfiles(
.pages()
.filter((page) => !page.isClosed() && bot.own(page)),
fitWindows: bot.fitWindows,
+ fitWindow: bot.fitWindow,
};
},
diff --git a/agent-computer/src/recorder-in-browser.ts b/agent-computer/src/recorder-in-browser.ts
index 30743a7559..ae1ea134f4 100644
--- a/agent-computer/src/recorder-in-browser.ts
+++ b/agent-computer/src/recorder-in-browser.ts
@@ -121,30 +121,41 @@ export async function fitWindowsToViewport(
viewport: { width: number; height: number },
botId: string,
): Promise {
- const fit = async (page: Page) => {
- const session = await context.newCDPSession(page);
+ context.on("page", (page) => void fitWindowToViewport(page, viewport, botId));
+ await Promise.all(
+ context.pages().map((page) => fitWindowToViewport(page, viewport, botId)),
+ );
+}
+
+/**
+ * Make the window hold exactly this tab's page, now. Fitting is measured from the tab as it is laid
+ * out at that moment, so fitting again corrects a window fitted for a tab laid out differently.
+ */
+export async function fitWindowToViewport(
+ page: Page,
+ viewport: { width: number; height: number },
+ botId: string,
+): Promise {
+ try {
+ const session = await page.context().newCDPSession(page);
try {
const { windowId } = await session.send("Browser.getWindowForTarget");
await session.send("Browser.setContentsSize", { windowId, ...viewport });
} finally {
await session.detach().catch(() => undefined);
}
- };
- const fitting = (page: Page) =>
- fit(page).catch((error: unknown) => {
- // A tab that closed while it was being fitted needs nothing; anything else is said, because
- // the live screen then shows less than the whole page.
- if (page.isClosed()) return;
- console.info(
- JSON.stringify({
- type: "computer-window-not-fitted",
- botId,
- error: error instanceof Error ? error.message : String(error),
- }),
- );
- });
- context.on("page", (page) => void fitting(page));
- await Promise.all(context.pages().map(fitting));
+ } catch (error) {
+ // A tab that closed while it was being fitted needs nothing; anything else is said, because
+ // the live screen then shows less than the whole page.
+ if (page.isClosed()) return;
+ console.info(
+ JSON.stringify({
+ type: "computer-window-not-fitted",
+ botId,
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ );
+ }
}
/**
diff --git a/app/src/components/approvals/inline-approval.tsx b/app/src/components/approvals/inline-approval.tsx
index 09aa5fd215..aa52504aec 100644
--- a/app/src/components/approvals/inline-approval.tsx
+++ b/app/src/components/approvals/inline-approval.tsx
@@ -10,7 +10,7 @@ import {
approvalInboxOptions,
decideApproval,
} from "@/lib/approvals";
-import { noteWatched } from "@/lib/computers/activity";
+import { noteAnswered } from "@/lib/computers/activity";
import { EASE_OUT } from "@/lib/motion";
/**
@@ -80,7 +80,7 @@ export function InlineApproval({
onSuccess: (_, choice) => {
setAnswered(choice);
// Answered here, so the step's entry in Activity says when, even on a page opened after it was asked.
- if (toolCallId) noteWatched(toolCallId);
+ if (toolCallId) noteAnswered(toolCallId);
return cache.invalidateQueries({ queryKey: ["approvals"] });
},
});
diff --git a/app/src/components/computer/activity-log.tsx b/app/src/components/computer/activity-log.tsx
index fcfeecbca5..45ddb0388a 100644
--- a/app/src/components/computer/activity-log.tsx
+++ b/app/src/components/computer/activity-log.tsx
@@ -9,8 +9,7 @@
*
* Read from the conversation, so it is the same whichever way a call ran: in the browser, at the
* server's gate, or after a person allowed it. A conversation opened again, after a reload too,
- * shows what was already done in it. What this page watched happen says when; what was done before
- * the page opened it does not, because when it ran is not something the conversation says. The
+ * shows what was already done in it, and when: each step's answer says when it finished. The
* record is still the audit trail on the server; this is the window beside the screen.
*/
import { ownToolLabel } from "@openbot/shared/tool-name";
diff --git a/app/src/components/manage/computers-page.tsx b/app/src/components/manage/computers-page.tsx
index 9b0015074f..828dbc8f77 100644
--- a/app/src/components/manage/computers-page.tsx
+++ b/app/src/components/manage/computers-page.tsx
@@ -68,6 +68,29 @@ function isOwnBrowser(computer: ComputerProfile): boolean {
return computer.botId !== undefined && isPersonBrowserId(computer.botId);
}
+/**
+ * What a confirmation is about, kept while its dialog closes.
+ *
+ * The dialog fades out after Cancel or Reset it, and its words are drawn on every frame of that.
+ * Cleared on close, the row went with it, and the title fell back to its words for no row: "Reset the
+ * computer?" flashed up as the dialog left after "Reset Fernhill Guide's browser?" was answered. So
+ * closing only closes, and what the dialog names is replaced the next time it asks.
+ */
+function useConfirmation() {
+ const [asking, setAsking] = useState<{
+ about: ComputerProfile;
+ open: boolean;
+ } | null>(null);
+ return {
+ /** The row asked about, still named while the dialog closes. */
+ about: asking?.about ?? null,
+ open: asking?.open === true,
+ ask: (about: ComputerProfile) => setAsking({ about, open: true }),
+ close: () =>
+ setAsking((current) => (current ? { ...current, open: false } : null)),
+ };
+}
+
/**
* `children` are sections only the team edition's Admin adds below the page's own (the admin's
* controls for these). The personal edition's Settings renders this page with none.
@@ -97,10 +120,11 @@ export function ComputersPage({
/** The row currently running a stop/reset request. */
const [busy, setBusy] = useState(null);
/** Reset deletes the browser profiles, so it requires confirmation. */
- const [confirming, setConfirming] = useState(null);
+ const reset = useConfirmation();
+ const confirming = reset.about;
/** Updating restarts a running computer mid-task, so it is confirmed too. */
- const [confirmingUpdate, setConfirmingUpdate] =
- useState(null);
+ const update = useConfirmation();
+ const confirmingUpdate = update.about;
const queryClient = useQueryClient();
const nameFor = useBotNames();
/*
@@ -156,8 +180,8 @@ export function ComputersPage({
action: "stop" | "reset" | "update",
) => {
setBusy(rowId(computer));
- setConfirming(null);
- setConfirmingUpdate(null);
+ reset.close();
+ update.close();
setState.mutate(
{
action,
@@ -347,7 +371,7 @@ export function ComputersPage({
{computer.updateAvailable ? (
) : null}
+
);
}
@@ -221,6 +241,7 @@ function Entry({ entry }: { entry: ComputerActivity }) {
/>
)}
+
);
}
diff --git a/app/src/lib/approvals.ts b/app/src/lib/approvals.ts
index 095200bd08..7750d1a5d1 100644
--- a/app/src/lib/approvals.ts
+++ b/app/src/lib/approvals.ts
@@ -65,6 +65,8 @@ export type ApprovalInboxData = {
createdAt: string;
/** When the person (or a rule) decided it; null while it waits. */
decidedAt?: string | null;
+ /** What the person answered; null while it waits. */
+ decision?: ApprovalDecision | null;
action: {
botId: string;
toolRef: string;
diff --git a/app/src/lib/computers/activity.ts b/app/src/lib/computers/activity.ts
index 57634422cf..2477feb40d 100644
--- a/app/src/lib/computers/activity.ts
+++ b/app/src/lib/computers/activity.ts
@@ -7,7 +7,13 @@ import {
outcomeForPerson,
} from "@openbot/shared/approval-outcomes";
import type { DecisionToolName } from "@openbot/shared/decision-tools";
+import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
+import {
+ type ApprovalDecision,
+ type ApprovalInboxData,
+ approvalInboxOptions,
+} from "@/lib/approvals";
import type { ToolOutcome } from "./call";
import { followView, latestView } from "./panel-view";
@@ -74,6 +80,8 @@ export type ComputerActivity = {
refused?: boolean;
/** Refused because the person declined it, so the line says declined rather than what it would have done. */
declined?: boolean;
+ /** The person allowed it on its card, and how: the line says so, as it says a step was declined. */
+ allowed?: AllowedDecision;
/**
* It was handed to the person and they did it themselves. Done, so not drawn as refused, and
* nothing ran on the computer for it, so not drawn as a command that printed nothing either.
@@ -84,6 +92,12 @@ export type ComputerActivity = {
timedOut?: boolean;
};
+/** How a person allowed a step on its card. */
+export type AllowedDecision = Extract<
+ ApprovalDecision,
+ "allow_once" | "allow_conversation" | "allow_always"
+>;
+
/** What a call did, without the bookkeeping the pane adds to it. */
export type ActivityDetails = Omit;
@@ -95,8 +109,49 @@ export type ConversationCall = {
id: string;
did: ActivityDetails | null;
finishedAt?: number;
+ /** When the person answered it on its card, for a step whose answer is a sentence that says no time. */
+ decidedAt?: number;
};
+/** What the person answered on a step's card, and when, by the step's call. */
+export type StepDecision = { decision: ApprovalDecision; at?: number };
+export type StepDecisions = ReadonlyMap;
+const NO_DECISIONS: StepDecisions = new Map();
+
+/**
+ * The person's answers on the cards of this owner's recent steps, by call, from the approvals inbox
+ * the conversation already reads. The card says what was answered while it shows; once answered it
+ * folds away, and the step's own answer is the computer's, or a sentence for the Bot, neither of
+ * which says how the person answered or, for a sentence, when. So "Allow for this conversation" left
+ * no line in Activity, and a step declined there had no time once the conversation was opened again.
+ */
+export function decisionsIn(
+ inbox: ApprovalInboxData | undefined,
+): StepDecisions {
+ const decisions = new Map();
+ for (const request of inbox?.requests ?? []) {
+ const id = request.action.toolCallId;
+ if (!id || !request.decision) continue;
+ const at = request.decidedAt ? Date.parse(request.decidedAt) : Number.NaN;
+ decisions.set(id, {
+ decision: request.decision,
+ ...(Number.isFinite(at) ? { at } : {}),
+ });
+ }
+ return decisions;
+}
+
+function allowedBy(
+ decision: StepDecision | undefined,
+): AllowedDecision | undefined {
+ const answered = decision?.decision;
+ return answered === "allow_once" ||
+ answered === "allow_conversation" ||
+ answered === "allow_always"
+ ? answered
+ : undefined;
+}
+
/**
* The most recent entries per computer.
*
@@ -175,6 +230,7 @@ function sameDetails(entry: ComputerActivity, details: ActivityDetails) {
entry.exitCode === details.exitCode &&
entry.refused === details.refused &&
entry.declined === details.declined &&
+ entry.allowed === details.allowed &&
entry.doneByPerson === details.doneByPerson &&
entry.truncated === details.truncated &&
entry.timedOut === details.timedOut
@@ -319,7 +375,7 @@ export function recordActivity(
const existing = present.get(call.id);
if (existing) {
// The server's answer can arrive after the browser's placeholder, with when it finished.
- const at = existing.at ?? call.finishedAt;
+ const at = existing.at ?? call.finishedAt ?? call.decidedAt;
if (!sameDetails(existing, call.did) || at !== existing.at) {
next ??= [...current];
const position = next.findIndex((entry) => entry.id === call.id);
@@ -342,7 +398,10 @@ export function recordActivity(
answeredHere.delete(call.id);
// When the step says it finished, when the person answered it here, or when this page saw its answer arrive.
const at =
- call.finishedAt ?? answeredAt ?? (watched ? Date.now() : undefined);
+ call.finishedAt ??
+ call.decidedAt ??
+ answeredAt ??
+ (watched ? Date.now() : undefined);
const entry: ComputerActivity = {
...call.did,
id: call.id,
@@ -867,6 +926,7 @@ function declinedCommand(command: string): ActivityDetails {
*/
export function conversationActivity(
messages: readonly Message[],
+ decisions: StepDecisions = NO_DECISIONS,
): ConversationCall[] {
const computerCalls = new Set();
const browserCalls = new Set();
@@ -922,25 +982,24 @@ export function conversationActivity(
continue;
}
const name = call.function.name;
- if (isBrowserStep(name)) {
- const answer = answered.get(call.id);
- if (answer && awaitingFreshLook(answer)) continue;
- const finishedAt = finishedAtOf(answer);
- calls.push({
- id: call.id,
- did: answer ? browserDidOf(name, call, answer, labels) : null,
- ...(finishedAt === undefined ? {} : { finishedAt }),
- });
- continue;
- }
+ const browser = isBrowserStep(name);
const kind = KINDS[name];
- if (!kind) continue;
+ if (!browser && !kind) continue;
const answer = answered.get(call.id);
+ if (browser && answer && awaitingFreshLook(answer)) continue;
+ const did = !answer
+ ? null
+ : browser
+ ? browserDidOf(name, call, answer, labels)
+ : didOf(kind as ComputerActivity["kind"], call, answer);
+ const decision = decisions.get(call.id);
+ const allowed = allowedBy(decision);
const finishedAt = finishedAtOf(answer);
calls.push({
id: call.id,
- did: answer ? didOf(kind, call, answer) : null,
+ did: did && allowed ? { ...did, allowed } : did,
...(finishedAt === undefined ? {} : { finishedAt }),
+ ...(decision?.at === undefined ? {} : { decidedAt: decision.at }),
});
}
}
@@ -967,6 +1026,11 @@ export function useConversationActivity(
messages: readonly Message[],
): void {
const { copilotkit } = useCopilotKit();
+ // The person's answers on the steps' cards: the same inbox the conversation's cards read.
+ const decisions = useQuery({
+ ...approvalInboxOptions(),
+ select: decisionsIn,
+ }).data;
useEffect(
() =>
copilotkit.subscribe({
@@ -983,7 +1047,10 @@ export function useConversationActivity(
[copilotkit],
);
useEffect(() => {
- recordActivity(computerId, conversationActivity(messages));
+ recordActivity(
+ computerId,
+ conversationActivity(messages, decisions ?? NO_DECISIONS),
+ );
// And which of the panel's views the Bot is working in: see panel-view.ts.
followView(computerId, latestView(messages));
});
diff --git a/app/tests/activity-allowed-line.test.tsx b/app/tests/activity-allowed-line.test.tsx
new file mode 100644
index 0000000000..ded3cb6290
--- /dev/null
+++ b/app/tests/activity-allowed-line.test.tsx
@@ -0,0 +1,50 @@
+import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
+import { GlobalRegistrator } from "@happy-dom/global-registrator";
+import { cleanup, render } from "@testing-library/react";
+import { ActivityLog } from "@/components/computer/activity-log";
+import { recordActivity } from "@/lib/computers/activity";
+import { settleReactWork } from "./settle-react-work";
+
+/**
+ * A step the person allowed on its card says how in Activity, as one they declined says "You
+ * declined it.": after "Allow for this conversation" no row said so, though the banner above the
+ * composer did.
+ */
+
+beforeAll(() => GlobalRegistrator.register());
+afterEach(cleanup);
+afterAll(async () => {
+ await settleReactWork();
+ GlobalRegistrator.unregister();
+});
+
+test("a step allowed on its card has a line that says how", () => {
+ recordActivity("allowed-line", [
+ {
+ id: "sunflower",
+ did: {
+ kind: "browser",
+ step: "computer_click",
+ subject: "Place order",
+ output: "",
+ allowed: "allow_conversation",
+ },
+ finishedAt: Date.parse("2026-11-12T22:05:33.000Z"),
+ },
+ {
+ id: "chamomile",
+ did: {
+ kind: "browser",
+ step: "computer_click",
+ subject: "Place order",
+ output: "",
+ allowed: "allow_once",
+ },
+ finishedAt: Date.parse("2026-11-12T22:06:10.000Z"),
+ },
+ ]);
+ const view = render();
+ const rows = view.getAllByRole("listitem");
+ expect(rows[0]?.textContent).toContain("Allowed once.");
+ expect(rows[1]?.textContent).toContain("Allowed for this conversation.");
+});
diff --git a/app/tests/computer-activity-names-and-times.test.ts b/app/tests/computer-activity-names-and-times.test.ts
index 6be9963233..1a8d3acff9 100644
--- a/app/tests/computer-activity-names-and-times.test.ts
+++ b/app/tests/computer-activity-names-and-times.test.ts
@@ -5,9 +5,11 @@ import {
HANDLED_BY_PERSON,
} from "@openbot/shared/approval-outcomes";
import { withFinishedAt } from "@openbot/shared/computer-control";
+import type { ApprovalInboxData } from "../src/lib/approvals";
import {
activityFor,
conversationActivity,
+ decisionsIn,
noteAnswered,
recordActivity,
} from "../src/lib/computers/activity";
@@ -272,3 +274,92 @@ test("a step's answer is stamped with when it finished, once, and a step waiting
// A sentence (declined, done by the person) is not an object to stamp.
expect(withFinishedAt(DECLINED_BY_PERSON)).toBe(DECLINED_BY_PERSON);
});
+
+/** The approvals inbox as the server answers it, with one decided request for this call. */
+function inboxWith(
+ toolCallId: string,
+ decision: "allow_conversation" | "deny",
+ decidedAt: string,
+): ApprovalInboxData {
+ return {
+ enabled: true,
+ rules: [],
+ teamRules: [],
+ questions: [],
+ requests: [
+ {
+ id: `request-${toolCallId}`,
+ status: "completed",
+ createdAt: "2026-11-12T22:05:00.000Z",
+ decidedAt,
+ decision,
+ action: {
+ botId: "fernhill-shopper",
+ toolRef: "computer_click",
+ effect: "write",
+ scope: "host.docker.internal",
+ threadId: "thread-1",
+ toolCallId,
+ args: { ref: "f2e13", snapshotId: 4 },
+ target: {},
+ },
+ },
+ ],
+ };
+}
+
+test("a step allowed for the conversation on its card says so", () => {
+ const decisions = decisionsIn(
+ inboxWith("sunflower", "allow_conversation", "2026-11-12T22:05:31.000Z"),
+ );
+ const did = conversationActivity(
+ [
+ ...checkout,
+ ...step(
+ "sunflower",
+ "computer_click",
+ { ref: "f2e13", snapshotId: 4 },
+ {
+ ok: true,
+ action: "click",
+ element: { role: "button", name: "Place order" },
+ url: "http://host.docker.internal:46600/shop/sunflower",
+ elapsedMs: 40,
+ finishedAt: "2026-11-12T22:05:33.000Z",
+ },
+ ),
+ ],
+ decisions,
+ ).find((call) => call.id === "sunflower")?.did;
+ expect(did).toMatchObject({
+ subject: "Place order",
+ allowed: "allow_conversation",
+ });
+});
+
+test("a step declined on its card keeps the time it was declined when the conversation is opened again", () => {
+ const id = computer();
+ const decidedAt = "2026-11-12T22:05:42.000Z";
+ // Opened again: nothing here watched it, and its answer is a sentence that says no time.
+ recordActivity(
+ id,
+ conversationActivity(
+ [
+ ...checkout,
+ ...step(
+ "basil",
+ "computer_click",
+ { ref: "f2e13", snapshotId: 4 },
+ DECLINED_BY_PERSON,
+ ),
+ ],
+ decisionsIn(inboxWith("basil", "deny", decidedAt)),
+ ),
+ );
+ const declined = activityFor(id).find((entry) => entry.id === "basil");
+ expect(declined).toMatchObject({
+ declined: true,
+ subject: "Place order",
+ at: Date.parse(decidedAt),
+ });
+});
diff --git a/e2e/README.md b/e2e/README.md
index c547dbe88d..b313c36a53 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -166,14 +166,14 @@ The `computer-*` and `approvals-*` journeys:
| Journey | What a person does |
| --- | --- |
-| `computer-browse` | Asks a Bot to read a page. The Computer opens on its live screen; the answer comes from the page; Activity lists the page with its time, and still does after a reload; the conversation folds the browsing into one line that opens to its steps. |
+| `computer-browse` | Asks a Bot to read a page. The Computer opens on its live screen; the answer comes from the page; Activity lists the page with its time, and still does after a reload, every row with its time; the conversation folds the browsing into one line that opens to its steps. |
| `computer-take-control` | Opens the Bot's screen full size, takes control, goes somewhere from the address bar, back, forward, reload, opens a tab and closes it (the screen still fills its frame, with no band under the page, then and after hand back), is asked before clearing the browser, and hands it back. Stops and resets the Bot's browser in Settings → Computer, and the Reset dialog names the Bot's browser until it has gone. |
| `computer-terminal` | Asks a Bot to run a command: Activity shows it with what it printed. Takes control of the Terminal, types a command, hands it back. |
| `computer-form` | Asks a Bot to fill in and send a form from one message, its fields and checkbox in one step: every action runs, the form arrives filled in, and Activity lists each with its time. |
| `computer-sign-in` | A Bot reaches a password field and hands it over (no standing answer offered). The person takes control, types the password into the Bot's screen, hands back and answers "I did it myself"; the Bot carries on from the signed-in page. Activity names the field handed over, and every row has its time. |
| `computer-secret` | A Bot asks for a password with the masked box, naming the field by its ref, without clicking it into a hand-over. The person types it into the box beside the Bot's screen; the Bot signs in and answers, and no step was handed over. |
| `approvals-allow-once` | A Bot paying asks first. Allow once places that order and the reply arrives whole, and every Activity row has its time, the steps the server took after the approval too; the next order asks again, and waits on the Bots page under Needs you too; Deny there places nothing, the Bot says so, and Activity names the button declined. |
-| `approvals-this-conversation` | Allow for this conversation: the next order there goes through with no card, the banner says what is allowed, Turn off makes the next order ask again. |
+| `approvals-this-conversation` | Allow for this conversation: Activity says the order was allowed for this conversation, the next order there goes through with no card, the banner says what is allowed, Turn off makes the next order ask again. After a reload Activity still says how each order was allowed, every row with its time. |
| `approvals-always` | Always allow here: the Bot pays without asking in its next conversation; the rule is on the Bot's Approvals page, where Remove takes it away. |
| `approvals-settings` | Settings → Approvals: Extra vigilant, Auto-review and commands on this computer, kept across a reload; a rule for every Bot added, changed, removed, and cancelled. |
diff --git a/e2e/journeys/approvals-allow-once.journey.ts b/e2e/journeys/approvals-allow-once.journey.ts
index ed7a9daad1..b253d91519 100644
--- a/e2e/journeys/approvals-allow-once.journey.ts
+++ b/e2e/journeys/approvals-allow-once.journey.ts
@@ -120,6 +120,8 @@ test("Allow once places one order and the next one asks again; Deny buys nothing
await expect(declined).toHaveCount(1);
await expect(declined).toContainText("Place order");
await expect(declined).toContainText("You declined it.");
+ // This page was opened again after the Deny, and every row kept its time, the declined one too.
+ await expectEveryRowTimed(page);
await shot("denied");
});
});
diff --git a/e2e/journeys/approvals-this-conversation.journey.ts b/e2e/journeys/approvals-this-conversation.journey.ts
index 76ffba3de5..deb1954782 100644
--- a/e2e/journeys/approvals-this-conversation.journey.ts
+++ b/e2e/journeys/approvals-this-conversation.journey.ts
@@ -6,10 +6,13 @@ import {
expectAsksToPay,
} from "./approval-steps";
import {
+ activityRows,
expect,
+ expectEveryRowTimed,
expectWholeReply,
named,
newConversation,
+ openComputer,
replyAfter,
runsSoFar,
send,
@@ -54,6 +57,14 @@ test("Allow for this conversation lets the next order through, until it is turne
await expectWholeReply(page, reply);
await named(page, channelId);
expect(ordered()).toEqual(["sunflower"]);
+ // Activity says how the order was allowed, as it says when one is declined.
+ await openComputer(page);
+ await expect(
+ activityRows(page)
+ .filter({ hasText: "Place order" })
+ .filter({ hasText: "Allowed for this conversation." }),
+ ).toHaveCount(1);
+ await expectEveryRowTimed(page);
});
const allowed = allowances(page);
@@ -102,4 +113,17 @@ test("Allow for this conversation lets the next order through, until it is turne
await expect(allowed).toHaveCount(0);
await shot("allowed-once-after-turn-off");
});
+
+ await test.step("after a reload, Activity still says how each order was allowed, and when", async () => {
+ await page.reload();
+ await openComputer(page);
+ const orders = activityRows(page).filter({ hasText: "Place order" });
+ await expect(orders).toHaveCount(3);
+ await expect(
+ orders.filter({ hasText: "Allowed for this conversation." }),
+ ).toHaveCount(1);
+ await expect(orders.filter({ hasText: "Allowed once." })).toHaveCount(1);
+ await expectEveryRowTimed(page);
+ await shot("activity-after-reload");
+ });
});
diff --git a/e2e/journeys/computer-browse.journey.ts b/e2e/journeys/computer-browse.journey.ts
index 4d6e7ddd69..e4bc25adfb 100644
--- a/e2e/journeys/computer-browse.journey.ts
+++ b/e2e/journeys/computer-browse.journey.ts
@@ -4,6 +4,7 @@ import {
CLOCK,
computerPanel,
expect,
+ expectEveryRowTimed,
expectWholeReply,
named,
newConversation,
@@ -98,6 +99,8 @@ test("a Bot opens a page, reads it and answers, live on its computer", async ({
await expect(opened).toHaveCount(1);
await expect(opened).toContainText(`${SITE}/`);
await expect(panel.getByText(/^Nothing yet/)).toHaveCount(0);
+ // Every step keeps its time across the reload: its answer says when it finished.
+ await expectEveryRowTimed(page);
await shot("activity-after-reload");
});
});
From c7b41bcc0f52bed2130d1daf3fdd3fe63253c6d6 Mon Sep 17 00:00:00 2001
From: David McKay
Date: Thu, 12 Nov 2026 18:05:01 -0800
Subject: [PATCH 3/3] Expect the finish time on the answers the approval
wrapper gives the Bot
The browser now stamps each computer step's answer with when it finished,
so the wrapper's tests expect it beside the result they pin.
---
app/tests/approval-tools.test.tsx | 23 ++++++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/app/tests/approval-tools.test.tsx b/app/tests/approval-tools.test.tsx
index e68614862d..77dd9ecc29 100644
--- a/app/tests/approval-tools.test.tsx
+++ b/app/tests/approval-tools.test.tsx
@@ -254,6 +254,8 @@ test("a change the computer could not make comes back to the Bot as a result, no
reason:
"Element e7 is not on the page any more. Take a fresh snapshot and use the refs from it.",
staleRefs: true,
+ // Every answer says when it finished, for the time beside it in Activity (`withFinishedAt`).
+ finishedAt: expect.any(String),
});
});
@@ -269,6 +271,7 @@ test("a refused change comes back as a refusal the Bot can explain", async () =>
reason: "Your team does not allow this.",
refused: true,
rule: "team-rule-1",
+ finishedAt: expect.any(String),
});
});
@@ -280,10 +283,15 @@ test("a gate that cannot be reached, or answers with no result, is still a resul
).toEqual({
ok: false,
reason: "The assistant's computer could not be reached.",
+ finishedAt: expect.any(String),
});
expect(
await clickAgainstGate(async () => Response.json({}, { status: 200 })),
- ).toEqual({ ok: false, reason: "The computer action returned no result." });
+ ).toEqual({
+ ok: false,
+ reason: "The computer action returned no result.",
+ finishedAt: expect.any(String),
+ });
});
/*
@@ -436,7 +444,11 @@ test("a look nothing asks about goes straight to the computer and nowhere else",
expect(looked).toHaveLength(1);
// Asked once about the conversation, and the answer kept: it never changes.
expect(paths).toEqual(["/api/approvals/threads/thread"]);
- expect(result).toEqual({ ok: true, title: "Example Domain" });
+ expect(result).toEqual({
+ ok: true,
+ title: "Example Domain",
+ finishedAt: expect.any(String),
+ });
});
test("in a task's own conversation, opened with no conversation list loaded, a look goes to the gate first", async () => {
@@ -474,7 +486,11 @@ test("a look whose conversation couldn't be checked goes through the gate, where
"/api/approvals/threads/thread",
"/api/approvals/computer/default",
]);
- expect(result).toEqual({ ok: true, title: "Example Domain" });
+ expect(result).toEqual({
+ ok: true,
+ title: "Example Domain",
+ finishedAt: expect.any(String),
+ });
});
test("in a task's own conversation, a look with no conversation to ask in is not taken at all", async () => {
@@ -492,6 +508,7 @@ test("in a task's own conversation, a look with no conversation to ask in is not
ok: false,
reason:
"Not done: this conversation asks before every step, and this step couldn't be asked about here.",
+ finishedAt: expect.any(String),
});
});