Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/components/audit/audit-cells.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,9 @@ const DECISIONS: Record<string, string> = {
"computer.browser_cleared": "A person cleared the browser",
"computer.updated": "The computer was updated",
"computer.stopped": "A person pressed stop",
// Not "Allowed": nobody asked. The computer was idle, and its files and sign-ins are kept.
"computer.suspended": "Turned off while idle",
"computer.resumed": "Started again",

"component.granted": "Granted to this Bot",
"component.revoked": "Taken away from this Bot",
Expand Down
41 changes: 39 additions & 2 deletions app/src/components/computer/computer-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import {
readPageFrame,
readScreenshot,
startComputer,
type Screenshot,
} from "@/lib/computers/screen";
import { useComputerControl } from "@/lib/computers/use-control";
Expand Down Expand Up @@ -576,8 +577,44 @@ export function ComputerView({
/** The fresher of the two answers: the poll that just ran, else the status read. */
const computerState: ComputerState | undefined =
polledState ?? status.data?.state;
/** In a turn, an absent computer is one the Bot's action is about to start, not one left off. */
const asleep = computerState === "absent" && !toolCallId;
/*
* OPENING THE SCREEN STARTS A COMPUTER THAT IS OFF. Once, for the state the screen was opened on.
*
* Nothing this view reads starts a computer, so one that was off stayed off however long a person
* looked at it, and the only way to see the Bot's browser was to ask the Bot to do something. A
* person opening the screen is asking to see the computer, so the server is told, and starts it.
*
* Only the screen beside a conversation, which a person opens: a card in a turn is drawn by
* scrolling past it, and its computer is started by the Bot's action. And only for what the screen
* found when it opened. A computer that goes off later, in front of a person who left the screen
* open, stays off: nobody opened anything, and starting it again would keep every computer with a
* screen left open running for ever.
*/
const openedOn = useRef<string | null>(null);
const [startAsked, setStartAsked] = useState(false);
// A picture is a computer that is up, whatever the state reads have or have not said yet.
const hasFrame = shot !== null;
useEffect(() => {
if (toolCallId || settled || !visualVisible) return;
if (!computerState && !hasFrame) return;
if (openedOn.current === computerId) return;
openedOn.current = computerId;
if (computerState !== "absent") return;
setStartAsked(true);
void startComputer(computerId).then((state) => {
// Not started (an answer of off, or none): the screen says it is off, which it is.
if (state === null || state === "absent") setStartAsked(false);
});
}, [computerId, computerState, hasFrame, settled, toolCallId, visualVisible]);
useEffect(() => {
// Up, or on its way: what the server says is the news again, and a later "off" is one.
if (computerState && computerState !== "absent") setStartAsked(false);
}, [computerState]);
/**
* Off, and staying off until the Bot uses it. In a turn, an absent computer is one the Bot's action
* is about to start, and one this screen has just asked for is on its way.
*/
const asleep = computerState === "absent" && !toolCallId && !startAsked;
/** Waiting for a first frame, for a reason that can end. A computer that is off waits for the Bot. */
const waitingForFrame = !settled && visualVisible && shot === null && !asleep;

Expand Down
31 changes: 31 additions & 0 deletions app/src/lib/computers/screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,37 @@ export async function readScreenshot(
}
}

/**
* Say the Bot's screen was opened, which starts a computer that is off.
*
* The reads above never start one: a conversation makes them too, and a computer that started for
* every look would never stay off. Opening the screen is a person asking to see the computer, so
* that is said on its own. The server answers at once with the computer's state (`starting` for one
* it has just started) and does not wait for it to come up.
*
* Fails closed: nothing when the server could not be asked or has no such route, and the screen then
* says the computer is off, which it is.
*/
export async function startComputer(
computerId: string,
): Promise<ComputerState | null> {
try {
const response = await tryClient(
`/api/computers/${encodeURIComponent(computerId)}/wake`,
{ method: "POST" },
);
if (!response.ok) return null;
const body = (await response.json().catch(() => null)) as {
state?: unknown;
} | null;
return COMPUTER_STATES.includes(body?.state as ComputerState)
? (body?.state as ComputerState)
: null;
} catch {
return null;
}
}

/** The server's "not ready yet" answer, or nothing when the body is something else. */
function notReadyOf(body: unknown): ScreenshotRead["notReady"] {
if (!body || typeof body !== "object" || Array.isArray(body)) return;
Expand Down
76 changes: 74 additions & 2 deletions app/tests/computer-calm-start.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,66 @@ test("a computer that is off, watched from the side panel, says it starts when t
expect(view.queryByText(/can't show the screen/i)).toBeNull();
});

/** A server that keeps a computer off until it is asked to start it, and counts the asking. */
function serveComputerThatStartsWhenAsked() {
const asked = { times: 0 };
const state = () => (asked.times > 0 ? "starting" : "absent");
globalThis.fetch = (async (input, init) => {
const path = String(input);
if (path.endsWith("/wake") && init?.method === "POST") {
asked.times += 1;
return Response.json({ botId: "sales", state: "starting" });
}
if (path.endsWith("/status"))
return Response.json({ botId: "sales", state: state() });
if (path.endsWith("/screenshot"))
return Response.json({ ready: false, state: state() });
if (path.endsWith("/control")) return Response.json(control);
return new Response("not found", { status: 404 });
}) as typeof fetch;
return asked;
}

test("opening the screen beside a conversation starts a computer that is off, once", async () => {
const asked = serveComputerThatStartsWhenAsked();

const view = renderWithQueries(
<ComputerView active computerId="sales" intervalMs={10} name="Sales" />,
);

expect(
await view.findByText(
"Starting Sales's computer. The first start takes about a minute.",
),
).toBeDefined();
// Many more polls: it was asked for once, and is never called off while it comes up.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 120));
});
expect(asked.times).toBe(1);
expect(view.queryByText(/is off/)).toBeNull();
});

test("a card in a turn does not start a computer that is off: the Bot's action does", async () => {
const asked = serveComputerThatStartsWhenAsked();

const view = renderWithQueries(
<ComputerView
active
computerId="sales"
intervalMs={10}
name="Sales"
toolCallId="call-1"
/>,
);

await view.findByText(/^Starting Sales's computer/);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 80));
});
expect(asked.times).toBe(0);
});

test("a live stream that drops shows a quiet badge, not the Retry panel", async () => {
HTMLCanvasElement.prototype.getContext = (() => ({
drawImage: () => undefined,
Expand Down Expand Up @@ -309,10 +369,17 @@ test("a computer that was off and then fails is not called off for ever", async
expect(view.queryByText(/is off/)).toBeNull();
});

test("a computer that goes to sleep stops showing its last frame", async () => {
test("a computer that goes to sleep stops showing its last frame, and is not started again", async () => {
let shots = 0;
let asked = 0;
serve({
"/status": () => Response.json({ botId: "sales", state: "absent" }),
"/wake": () => {
asked += 1;
return Response.json({ botId: "sales", state: "starting" });
},
// Up while its first frame is taken, as a computer that can be photographed is.
"/status": () =>
Response.json({ botId: "sales", state: shots < 2 ? "ready" : "absent" }),
"/screenshot": () =>
shots++ === 0
? frame()
Expand All @@ -332,4 +399,9 @@ test("a computer that goes to sleep stops showing its last frame", async () => {
).toBeDefined();
expect(view.queryByAltText("What the Bot is looking at")).toBeNull();
});
// Nobody opened anything: the screen was already open when the computer went off.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 80));
});
expect(asked).toBe(0);
});
2 changes: 2 additions & 0 deletions shared/audit-words.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export const AUDIT_EVENT_WORDS: Readonly<Record<string, string>> = {
"computer.reset": "Reset the computer",
"computer.browser_cleared": "Clear the browser",
"computer.updated": "Update the computer",
"computer.suspended": "Turn off an idle computer",
"computer.resumed": "Start a computer again",
"computer.policy_loaded": "Start the computer",
"computer.policy_changed": "Change the boundaries",
"computer.policy_translated": "Write a boundary from a description",
Expand Down
Loading