Keep main green: racing E2E tests, the git-clone push, and runs that never fail (#224)

* Stop two web E2E tests from racing on main

Replies read themselves aloud by default since the voice update, so in the
group chat test the last bot's automatic read-aloud could beat the manual
Speak click. The test turns automatic reading off for its two bots.

Opening the browser panel on a running computer now only fetches the
screen, as it did before the panel change, instead of also reloading the
whole thread on every computer state change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Wait for the speaking switch to settle in the voice test

The app turns the local voice on by itself on first start and keeps the
switch disabled meanwhile. The helper read "off" during that window and
clicked a disabled switch; it now retries until the switch is on, clicking
only when it is enabled and off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Fix main after the git-clone push: lint, i18n and abort tests

- Lint: formatting in two files and no assignment inside an expression
  in the steering poller.
- i18n: German for the new "Resume" and "Stopped" labels on web, and
  de/ru/zh on mobile; catalogs re-extracted.
- Tests follow the intended change that a caller abort ends a turn as
  "stopped" instead of failing it as a provider error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Record runtime failures again instead of leaving the run running

A runtime error closes the stream through withRuntimeCleanup, which aborts the
run's own controller. The catch treated that as an outside stop and returned
without finalizing, so a failed run stayed running forever and its delegated
task never reported back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-23 03:33:07 +02:00 committed by GitHub
parent 5168518759
commit 467d9b0dc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 166 additions and 56 deletions

View File

@ -261,6 +261,8 @@ export const DE_MESSAGES: Record<string, string> = {
Eyeshadow: "Lidschatten",
Failed: "Fehlgeschlagen",
"Failed to refresh": "Aktualisieren fehlgeschlagen",
Resume: "Fortsetzen",
Stopped: "Gestoppt",
"Failed to remove message": "Nachricht konnte nicht entfernt werden",
"Failed to send message": "Nachricht konnte nicht gesendet werden",
"Failed to stop work": "Arbeit konnte nicht gestoppt werden",

View File

@ -274,6 +274,8 @@ export const RU_MESSAGES: Record<string, string> = {
"Executor token": "Токен Executor",
Failed: "Ошибка",
"Failed to refresh": "Не удалось обновить",
Resume: "Продолжить",
Stopped: "Остановлено",
"Failed to remove message": "Не удалось удалить сообщение",
"Failed to send message": "Не удалось отправить сообщение",
"Failed to stop work": "Не удалось остановить работу",

View File

@ -259,6 +259,8 @@ export const ZH_MESSAGES: Record<string, string> = {
"Executor token": "Executor 令牌",
Failed: "失败",
"Failed to refresh": "刷新失败",
Resume: "继续",
Stopped: "已停止",
"Failed to remove message": "删除消息失败",
"Failed to send message": "发送消息失败",
"Failed to stop work": "停止工作失败",

View File

@ -63,6 +63,10 @@ test("create group from + and see two bots in one transcript", async ({ page },
const researcherId = await createBot(page, "Researcher");
const writerId = await createBot(page, "Research Writer");
// Replies read themselves aloud by default; keep that off so only the Speak click below talks.
for (const botId of [researcherId, writerId]) {
await rpc(page, "bots/update", { botId, autoSpeak: false });
}
await openNewGroup(page);
await page.locator("label:has-text('Name') input").fill("Draft team");

View File

@ -7,17 +7,15 @@ async function turnOnSpeaking(page: Page) {
await expect(voice).toBeVisible();
await expect(voice.getByRole("button", { name: "Other voice providers" })).toHaveCount(0);
await expect(voice.getByLabel("API key", { exact: true })).toHaveCount(0);
// The app turns the local voice on by itself the first time it starts.
// The app turns the local voice on by itself the first time it starts, and the switch stays
// disabled while that runs; only click once it settled off.
const speaking = voice.getByRole("switch", { name: "Speaking on or off" });
// Clicking before the automatic start has settled would turn the voice off again.
const startedByItself = await expect(speaking)
.toHaveAttribute("aria-checked", "true", { timeout: 5_000 })
.then(
() => true,
() => false,
);
if (!startedByItself) await speaking.click();
await expect(speaking).toHaveAttribute("aria-checked", "true");
await expect(async () => {
if ((await speaking.getAttribute("aria-checked")) !== "true" && (await speaking.isEnabled())) {
await speaking.click();
}
await expect(speaking).toHaveAttribute("aria-checked", "true", { timeout: 3_000 });
}).toPass({ timeout: 30_000 });
await expect(voice.getByTestId("voice-carousel")).toBeVisible();
return voice;
}

View File

@ -18,6 +18,7 @@ export function InAppBrowser({
sandboxProvider,
booting,
onRefreshScreen,
onConnectScreen,
onOpenComputer,
hideCloseButton = false,
}: {
@ -29,6 +30,7 @@ export function InAppBrowser({
sandboxProvider: string | undefined;
booting: boolean;
onRefreshScreen: () => Promise<unknown>;
onConnectScreen: () => Promise<unknown>;
onOpenComputer?: () => void;
hideCloseButton?: boolean;
}) {
@ -92,6 +94,7 @@ export function InAppBrowser({
sandboxProvider={sandboxProvider}
booting={booting}
onRefreshScreen={onRefreshScreen}
onConnectScreen={onConnectScreen}
onOpenComputer={onOpenComputer}
/>
) : (

View File

@ -35,6 +35,7 @@ export function OoComputerBrowser({
sandboxProvider,
booting,
onRefreshScreen,
onConnectScreen,
onOpenComputer,
}: {
active: Bot;
@ -44,6 +45,8 @@ export function OoComputerBrowser({
sandboxProvider: string | undefined;
booting: boolean;
onRefreshScreen: () => Promise<unknown>;
/** Fetches the screen of an already running computer, without touching the thread. */
onConnectScreen: () => Promise<unknown>;
/** Opens the full computer window, where the user can take control or teach. */
onOpenComputer?: () => void;
}) {
@ -88,12 +91,12 @@ export function OoComputerBrowser({
};
}, [active.id, computer?.state, refreshChrome]);
const refreshScreen = useRef(onRefreshScreen);
refreshScreen.current = onRefreshScreen;
const connectScreen = useRef(onConnectScreen);
connectScreen.current = onConnectScreen;
// Showing the browser never starts a stopped computer; it only reconnects a running screen.
useEffect(() => {
if (computer?.state !== "running" || embeddedScreenUrl || computer?.kind === "desktop") return;
void refreshScreen.current().catch(() => undefined);
void connectScreen.current().catch(() => undefined);
}, [active.id, computer?.kind, computer?.state, embeddedScreenUrl]);
const runNav = useCallback(

View File

@ -2354,6 +2354,7 @@ msgstr "Verbindung wird getrennt…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr "Ausblenden"
@ -4720,6 +4721,11 @@ msgstr "Stellt den zuletzt gespeicherten Arbeitsbereich wieder her. Nicht gespei
msgid "Restoring your workspace"
msgstr "Dein Arbeitsbereich wird wiederhergestellt"
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr "Fortsetzen"
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr "Wiederholen"
@ -5362,6 +5368,10 @@ msgstr "Anlernen beenden"
msgid "Stop timer"
msgstr "Timer stoppen"
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr "Gestoppt"
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -2354,6 +2354,7 @@ msgstr "Disconnecting…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr "Dismiss"
@ -4720,6 +4721,11 @@ msgstr "Restore the last saved workspace. Unsaved work on the computer is lost."
msgid "Restoring your workspace"
msgstr "Restoring your workspace"
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr "Resume"
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr "Retry"
@ -5362,6 +5368,10 @@ msgstr "Stop teaching"
msgid "Stop timer"
msgstr "Stop timer"
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr "Stopped"
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -2354,6 +2354,7 @@ msgstr "Desconectando…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr "Descartar"
@ -4720,6 +4721,11 @@ msgstr "Restaura el último espacio de trabajo guardado. Se pierde el trabajo si
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -5362,6 +5368,10 @@ msgstr "Detener enseñanza"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -2354,6 +2354,7 @@ msgstr "डिस्कनेक्ट हो रहा है…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr ""
@ -4720,6 +4721,11 @@ msgstr "अंतिम सहेजे गए वर्कस्पेस क
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -5362,6 +5368,10 @@ msgstr "सिखाना रोकें"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -2354,6 +2354,7 @@ msgstr "연결 해제 중…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr "닫기"
@ -4720,6 +4721,11 @@ msgstr "마지막으로 저장된 작업 공간을 복원합니다. 컴퓨터에
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -5362,6 +5368,10 @@ msgstr "가르치기 종료"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -2354,6 +2354,7 @@ msgstr "Desconectando…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr ""
@ -4720,6 +4721,11 @@ msgstr "Restaure o último espaço de trabalho salvo. O trabalho não salvo no c
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -5362,6 +5368,10 @@ msgstr "Parar de ensinar"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -2354,6 +2354,7 @@ msgstr "Отключение…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr "Закрыть"
@ -4720,6 +4721,11 @@ msgstr "Восстановите последнее сохраненное ра
msgid "Restoring your workspace"
msgstr "Восстановление вашего рабочего пространства"
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -5362,6 +5368,10 @@ msgstr "Остановить обучение"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -2354,6 +2354,7 @@ msgstr "Bağlantı kesiliyor…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr ""
@ -4720,6 +4721,11 @@ msgstr ""
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -5362,6 +5368,10 @@ msgstr "Öğretmeyi durdur"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -2354,6 +2354,7 @@ msgstr "正在断开…"
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/ThreadCard.tsx
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Dismiss"
msgstr ""
@ -4720,6 +4721,11 @@ msgstr "还原上次保存的工作区。电脑上未保存的工作会丢失。
msgid "Restoring your workspace"
msgstr ""
#: src/pages/shell/composer.tsx
#: src/pages/shell/message-cards.tsx
msgid "Resume"
msgstr ""
#: src/components/FolderPickerDialog.tsx
msgid "Retry"
msgstr ""
@ -5362,6 +5368,10 @@ msgstr "停止示范"
msgid "Stop timer"
msgstr ""
#: src/pages/shell/composer.tsx
msgid "Stopped"
msgstr ""
#. js-lingui-explicit-id
#: src/lib/browser-notifications.ts
msgid "Stopped."

View File

@ -4687,6 +4687,10 @@ export function ShellPage() {
await refreshComputerScreen(target.id);
await refreshThread(target.id).catch(() => undefined);
}}
onConnectScreen={async () => {
const target = active ?? coordinatorBot;
if (target) await refreshComputerScreen(target.id);
}}
onOpenComputer={() => void openComputer()}
/>
</div>

View File

@ -166,7 +166,11 @@ describe("github broker remotes", () => {
assertRemoteAllowed(linked, ["git", "fetch", "origin"], repo),
).resolves.toBeUndefined();
await expect(
assertRemoteAllowed(linked, ["git", "ls-remote", "https://github.com/cynar/umbra.git"], repo),
assertRemoteAllowed(
linked,
["git", "ls-remote", "https://github.com/cynar/umbra.git"],
repo,
),
).resolves.toBeUndefined();
} finally {
rmSync(repo, { recursive: true, force: true });

View File

@ -1233,7 +1233,9 @@ export function createRunExecutor(deps: ExecutorDeps) {
hasModelOverride && bot.modelProvider
? findModelCredential(deps.prisma, run, bot.modelProvider, bot.modelId)
: Promise.resolve(null),
bot.modelTier ? findSpaceModelTier(deps.prisma, run, bot.modelTier) : Promise.resolve(null),
bot.modelTier
? findSpaceModelTier(deps.prisma, run, bot.modelTier)
: Promise.resolve(null),
]);
const brokerWorkspacePath =
projectWorkspacePath ??
@ -5296,12 +5298,9 @@ export function createRunExecutor(deps: ExecutorDeps) {
getLogger().error("history.compact enqueue failed", error);
}
} catch (error) {
if (
approvalPausePending ||
!leaseValid ||
deps.shutdownSignal?.aborted ||
runAbortController?.signal.aborted
) {
// A runtime error also aborts runAbortController through withRuntimeCleanup,
// so only an abort from outside the run may skip recording the failure.
if (approvalPausePending || !leaseValid || deps.shutdownSignal?.aborted) {
return;
}
if (run.trigger === "routine") {

View File

@ -1167,30 +1167,31 @@ describe("Pi connector tool dispatch", () => {
return { ok: true };
});
await expect(async () => {
for await (const _event of runtime.run(
{
botId: "b",
threadId: "t",
runId: "fuse-resume",
prompt: "keep going",
instructions: "Use shell.",
history: [],
tools: [shellTool],
model: { provider: "test", id: "dispatch-test-model" },
executeTool,
},
{
operationId: "2d",
traceId: "2d",
spaceId: "w",
userId: "u",
signal: abort.signal,
},
)) {
// Exhaust until abort fails the turn.
}
}).rejects.toThrow();
// A caller abort ends the turn as stopped rather than as a provider error.
const stopped: unknown[] = [];
for await (const event of runtime.run(
{
botId: "b",
threadId: "t",
runId: "fuse-resume",
prompt: "keep going",
instructions: "Use shell.",
history: [],
tools: [shellTool],
model: { provider: "test", id: "dispatch-test-model" },
executeTool,
},
{
operationId: "2d",
traceId: "2d",
spaceId: "w",
userId: "u",
signal: abort.signal,
},
)) {
stopped.push(event);
}
expect(stopped).toContainEqual({ type: "done", text: "stopped" });
executeTool.mockImplementation(async () => ({ ok: true }));
const events: unknown[] = [];

View File

@ -558,7 +558,8 @@ function ensureHostSteeringPoller(host: ToolHost): void {
// Nested subagents run inside the parent's active `run_subagent` tool call, whose
// root host poller already claims steering and cascades abort to nested tools.
if (host.depth > 0 || !host.request.claimSteering || host.signal.aborted) return;
const poller = (host.steeringPoller ??= { inFlight: false });
host.steeringPoller ??= { inFlight: false };
const poller = host.steeringPoller;
if (poller.timer) return;
poller.timer = setInterval(async () => {
if (host.signal.aborted || host.activeToolControllers.size === 0) {

View File

@ -209,9 +209,8 @@ describe("sanitized real-model computer recordings", () => {
fixture.computer,
interruptedContext,
);
await expect(
executeContactsJourney(emulator.model, recorder, interruptedContext),
).rejects.toThrow(/aborted/i);
// A caller abort ends the turn as stopped, not as a provider error.
await executeContactsJourney(emulator.model, recorder, interruptedContext);
emulator.assertComplete();
expect(held).toBe(true);
expect(controller.signal.aborted).toBe(true);

View File

@ -236,17 +236,15 @@ describe("real Pi against an offline model HTTP endpoint", () => {
new PiAgentRuntime().run(runRequest(server.model), { signal: controller.signal }),
events,
);
// Attach the rejection handler before aborting to avoid an unhandled rejection.
const outcome = work.then(
() => undefined,
(error: unknown) => error,
);
await opened.promise;
controller.abort();
expect(await outcome).toBeInstanceOf(Error);
// A caller abort ends the turn as stopped, not as a provider error.
await work;
await closed.promise;
server.assertComplete();
expect(events.some((event) => event.type === "done")).toBe(false);
expect(events.filter((event) => event.type === "done")).toEqual([
{ type: "done", text: "stopped" },
]);
});
it("keeps two model connections and their responses isolated", async () => {