@syengup/friday-channel-next 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,8 @@
11
11
  - File upload/download (`POST /friday-next/files`, `GET /friday-next/files/:id`)
12
12
  - Cancel (`POST /friday-next/cancel`) and status with `activeRuns` (`GET /friday-next/status`)
13
13
  - **Agent config editing** (mirrors ControlUI, no core changes): model / core `.md` files / tool permissions / skills, per agent — via `agents.list[]` (`mutateConfigFile`) + workspace fs
14
+ - **Codex backend support**: asserts `model_reasoning_summary` in each agent's `codex-home/config.toml` so ChatGPT/OAuth (Codex app-server) models stream reasoning text
15
+ - **Exec / plugin approvals**: forwards approval requests as `event: approval` and accepts decisions at `POST /friday-next/approvals/{approvalId}`
14
16
  - History sync, link-preview cards, and in-app plugin self-upgrade
15
17
 
16
18
  ## Endpoints
@@ -22,6 +24,7 @@
22
24
  - `GET /friday-next/models` · `GET /friday-next/agents` · `GET|PUT /friday-next/sessions/settings`
23
25
  - `GET|PUT /friday-next/agents/{id}/config` · `GET|PUT /friday-next/agents/{id}/files[/{name}]` · `GET /friday-next/agents/{id}/tools/catalog`
24
26
  - `GET /friday-next/history/sessions` · `GET /friday-next/history/messages` · `GET /friday-next/link-preview`
27
+ - `POST /friday-next/device-approve` · `POST /friday-next/nodes-approve` · `POST /friday-next/approvals/{approvalId}`
25
28
  - `GET /friday-next/plugin/info` · `POST /friday-next/plugin/upgrade`
26
29
 
27
30
  See **`API.md`** (English) and **`API.zh-CN.md`** (Chinese) for payloads, event shapes, offline queue paths, and breaking changes.
@@ -1,12 +1,25 @@
1
1
  export type AbortRunResult = {
2
2
  aborted: boolean;
3
- drained: boolean;
3
+ };
4
+ export type AbortRunDeps = {
5
+ resolveActiveEmbeddedRunSessionId: (sessionKey: string) => string | undefined;
6
+ abortAgentHarnessRun: (sessionId: string) => boolean;
4
7
  };
5
8
  /**
6
- * Abort the active run for a channel `sessionKey`.
9
+ * Abort the active run for a channel `sessionKey` — the CANONICAL OpenClaw stop.
10
+ *
11
+ * Mirrors how ControlUI (`chat.abort` → `abortChatRunById`) and the voice/`/compact`/
12
+ * steer paths stop a run: resolve sessionKey → internal sessionId (active runs are keyed
13
+ * by sessionId, not the channel runId), then fire a PLAIN abort (`abortAgentHarnessRun`
14
+ * = `abortEmbeddedAgentRun` → `handle.abort()`). That sets the run's `externalAbort` flag
15
+ * so it unwinds to a clean `"aborted_by_user"` terminal — a SILENT reply plus a
16
+ * `status:"cancelled"` lifecycle — with NO error event.
7
17
  *
8
- * A session has at most one active run at a time, and the SDK keys active runs by
9
- * their internal `sessionId` (not the channel runId). So resolve sessionKey → sessionId
10
- * first, then abort-and-drain so the caller learns whether the run actually settled.
18
+ * Deliberately NOT abort-and-drain with `forceClear`: `forceClear` force-fails the reply
19
+ * operation (`operation.fail("run_failed", new Error("Embedded run force-cleared by …"))`)
20
+ * and never fires the abort signal, so (a) the failed operation surfaces as a spurious
21
+ * `dispatch_error`, and (b) without `externalAbort` the interrupted LLM call is classified
22
+ * as a real failure → `"LLM request failed."`. Both show up as an error toast in the app.
23
+ * `forceClear` is OpenClaw's stuck/cron-timeout recovery hammer, not a user-initiated stop.
11
24
  */
12
- export declare function abortRunForSessionKey(sessionKey: string): Promise<AbortRunResult>;
25
+ export declare function abortRunForSessionKey(sessionKey: string, deps?: AbortRunDeps): Promise<AbortRunResult>;
@@ -1,26 +1,49 @@
1
+ const NO_OP = { aborted: false };
2
+ async function loadAbortRunDeps() {
3
+ // The SDK is optional at runtime and unavailable/unmockable under Vitest; tests
4
+ // inject `deps` directly to exercise the real abort path.
5
+ if (process.env.VITEST === "true")
6
+ return null;
7
+ try {
8
+ return await import("openclaw/plugin-sdk/agent-harness");
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
1
14
  /**
2
- * Abort the active run for a channel `sessionKey`.
15
+ * Abort the active run for a channel `sessionKey` — the CANONICAL OpenClaw stop.
3
16
  *
4
- * A session has at most one active run at a time, and the SDK keys active runs by
5
- * their internal `sessionId` (not the channel runId). So resolve sessionKey → sessionId
6
- * first, then abort-and-drain so the caller learns whether the run actually settled.
17
+ * Mirrors how ControlUI (`chat.abort` `abortChatRunById`) and the voice/`/compact`/
18
+ * steer paths stop a run: resolve sessionKey → internal sessionId (active runs are keyed
19
+ * by sessionId, not the channel runId), then fire a PLAIN abort (`abortAgentHarnessRun`
20
+ * = `abortEmbeddedAgentRun` → `handle.abort()`). That sets the run's `externalAbort` flag
21
+ * so it unwinds to a clean `"aborted_by_user"` terminal — a SILENT reply plus a
22
+ * `status:"cancelled"` lifecycle — with NO error event.
23
+ *
24
+ * Deliberately NOT abort-and-drain with `forceClear`: `forceClear` force-fails the reply
25
+ * operation (`operation.fail("run_failed", new Error("Embedded run force-cleared by …"))`)
26
+ * and never fires the abort signal, so (a) the failed operation surfaces as a spurious
27
+ * `dispatch_error`, and (b) without `externalAbort` the interrupted LLM call is classified
28
+ * as a real failure → `"LLM request failed."`. Both show up as an error toast in the app.
29
+ * `forceClear` is OpenClaw's stuck/cron-timeout recovery hammer, not a user-initiated stop.
7
30
  */
8
- export async function abortRunForSessionKey(sessionKey) {
9
- if (process.env.VITEST === "true")
10
- return { aborted: false, drained: false };
31
+ export async function abortRunForSessionKey(sessionKey, deps) {
11
32
  const key = sessionKey.trim();
12
33
  if (!key)
13
- return { aborted: false, drained: false };
34
+ return NO_OP;
35
+ const resolved = deps ?? (await loadAbortRunDeps());
36
+ if (!resolved)
37
+ return NO_OP;
14
38
  try {
15
- const { resolveActiveEmbeddedRunSessionId, abortAndDrainAgentHarnessRun } = await import("openclaw/plugin-sdk/agent-harness");
16
- const sessionId = resolveActiveEmbeddedRunSessionId(key);
39
+ const sessionId = resolved.resolveActiveEmbeddedRunSessionId(key);
17
40
  if (!sessionId)
18
- return { aborted: false, drained: false };
19
- const result = await abortAndDrainAgentHarnessRun({ sessionId, sessionKey: key });
20
- return { aborted: result.aborted, drained: result.drained };
41
+ return NO_OP;
42
+ const aborted = resolved.abortAgentHarnessRun(sessionId);
43
+ return { aborted };
21
44
  }
22
45
  catch {
23
46
  // optional at runtime
24
- return { aborted: false, drained: false };
47
+ return NO_OP;
25
48
  }
26
49
  }
@@ -0,0 +1,6 @@
1
+ /** How long after a user stop to treat surfaced errors as abort-noise. */
2
+ export declare const RECENT_ABORT_SUPPRESSION_MS = 20000;
3
+ export declare function markUserAbort(sessionKey: string, nowMs?: number): void;
4
+ export declare function wasRecentlyUserAborted(sessionKey: string, nowMs?: number): boolean;
5
+ /** Test/maintenance hook. */
6
+ export declare function clearRecentAborts(): void;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Tracks recent user-initiated aborts per channel sessionKey.
3
+ *
4
+ * When the user stops a run, OpenClaw's embedded runner / Codex backend can still surface an
5
+ * error as a consequence of the interruption — the aborted run's own failover ("LLM request
6
+ * failed.") or a generic failure on the very next turn ("Something went wrong …"). Those are
7
+ * abort-noise, not real failures the user should see as an error toast. The channel records the
8
+ * abort here so the deliver/error forwarders can drop `isError` payloads that land within a short
9
+ * window of a stop the user explicitly requested.
10
+ */
11
+ const recentAbortAtMs = new Map();
12
+ /** How long after a user stop to treat surfaced errors as abort-noise. */
13
+ export const RECENT_ABORT_SUPPRESSION_MS = 20_000;
14
+ export function markUserAbort(sessionKey, nowMs = Date.now()) {
15
+ const key = sessionKey.trim();
16
+ if (key)
17
+ recentAbortAtMs.set(key, nowMs);
18
+ }
19
+ export function wasRecentlyUserAborted(sessionKey, nowMs = Date.now()) {
20
+ const key = sessionKey.trim();
21
+ if (!key)
22
+ return false;
23
+ const at = recentAbortAtMs.get(key);
24
+ if (at === undefined)
25
+ return false;
26
+ if (nowMs - at > RECENT_ABORT_SUPPRESSION_MS) {
27
+ recentAbortAtMs.delete(key);
28
+ return false;
29
+ }
30
+ return true;
31
+ }
32
+ /** Test/maintenance hook. */
33
+ export function clearRecentAborts() {
34
+ recentAbortAtMs.clear();
35
+ }
@@ -1,4 +1,5 @@
1
1
  import { abortRunForSessionKey } from "../../agent/abort-run.js";
2
+ import { markUserAbort } from "../../agent/recent-aborts.js";
2
3
  import { getRunRoute } from "../../run-metadata.js";
3
4
  import { sseEmitter } from "../../sse/emitter.js";
4
5
  import { readJsonBody } from "../middleware/body.js";
@@ -31,7 +32,11 @@ export async function handleCancel(req, res) {
31
32
  }
32
33
  const result = sessionKey
33
34
  ? await abortRunForSessionKey(sessionKey)
34
- : { aborted: false, drained: false };
35
+ : { aborted: false };
36
+ // Record the user stop so abort-induced error deliveries are suppressed for a short window
37
+ // (the aborted run's own failover, or a generic failure on the immediate next turn).
38
+ if (sessionKey)
39
+ markUserAbort(sessionKey);
35
40
  if (runId)
36
41
  sseEmitter.untrackRun(runId);
37
42
  res.statusCode = 200;
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import crypto from "node:crypto";
13
13
  import { resolveFridayNextConfig } from "../../config.js";
14
+ import { wasRecentlyUserAborted } from "../../agent/recent-aborts.js";
14
15
  import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
15
16
  import { getFridayNextRuntime } from "../../runtime.js";
16
17
  import { resolveAgentDefaults, setSessionSettings, splitModelRef, toSessionStoreKey, } from "../../session/session-manager.js";
@@ -429,6 +430,13 @@ export async function handleMessages(req, res) {
429
430
  }
430
431
  }
431
432
  const payload = translateDeliverPayload(pl, info.kind, meta);
433
+ const deliverIsError = payload?.isError || pl?.isError;
434
+ // Drop error deliveries that are a direct consequence of a user stop (the aborted
435
+ // run's own failover, e.g. "LLM request failed."). The user already chose to stop;
436
+ // surfacing it as an error toast is abort-noise, not a real failure.
437
+ if (deliverIsError && wasRecentlyUserAborted(baseSessionKey)) {
438
+ return;
439
+ }
432
440
  log("EVENT_SENT", normalizedDeviceId, runId, `deliver kind=${info.kind}`);
433
441
  sseEmitter.broadcastToRun(runId, {
434
442
  type: "deliver",
@@ -449,6 +457,9 @@ export async function handleMessages(req, res) {
449
457
  }
450
458
  },
451
459
  onError: (err) => {
460
+ if (wasRecentlyUserAborted(baseSessionKey)) {
461
+ return;
462
+ }
452
463
  log("RUN_ERROR", normalizedDeviceId, runId, String(err), "error");
453
464
  sseEmitter.broadcastToRun(runId, {
454
465
  type: "outbound",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "OpenClaw Friday Next Apple channel plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { abortRunForSessionKey } from "./abort-run.js";
3
+
4
+ describe("abortRunForSessionKey", () => {
5
+ it("resolves sessionKey → sessionId then fires a PLAIN abort (canonical clean stop)", async () => {
6
+ const abortAgentHarnessRun = vi.fn().mockReturnValue(true);
7
+ const resolveActiveEmbeddedRunSessionId = vi.fn().mockReturnValue("sid-9");
8
+
9
+ const result = await abortRunForSessionKey("sk-9", {
10
+ resolveActiveEmbeddedRunSessionId,
11
+ abortAgentHarnessRun,
12
+ });
13
+
14
+ expect(resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith("sk-9");
15
+ // Plain abort by internal sessionId — fires handle.abort(), no drain/forceClear.
16
+ expect(abortAgentHarnessRun).toHaveBeenCalledWith("sid-9");
17
+ expect(result).toEqual({ aborted: true });
18
+ });
19
+
20
+ it("returns a no-op without aborting when there is no active run for the sessionKey", async () => {
21
+ const abortAgentHarnessRun = vi.fn();
22
+ const result = await abortRunForSessionKey("sk-x", {
23
+ resolveActiveEmbeddedRunSessionId: () => undefined,
24
+ abortAgentHarnessRun,
25
+ });
26
+ expect(abortAgentHarnessRun).not.toHaveBeenCalled();
27
+ expect(result).toEqual({ aborted: false });
28
+ });
29
+
30
+ it("returns a no-op for an empty sessionKey", async () => {
31
+ const abortAgentHarnessRun = vi.fn();
32
+ const result = await abortRunForSessionKey(" ", {
33
+ resolveActiveEmbeddedRunSessionId: () => "sid",
34
+ abortAgentHarnessRun,
35
+ });
36
+ expect(abortAgentHarnessRun).not.toHaveBeenCalled();
37
+ expect(result).toEqual({ aborted: false });
38
+ });
39
+ });
@@ -1,25 +1,55 @@
1
- export type AbortRunResult = { aborted: boolean; drained: boolean };
1
+ export type AbortRunResult = { aborted: boolean };
2
+
3
+ export type AbortRunDeps = {
4
+ resolveActiveEmbeddedRunSessionId: (sessionKey: string) => string | undefined;
5
+ abortAgentHarnessRun: (sessionId: string) => boolean;
6
+ };
7
+
8
+ const NO_OP: AbortRunResult = { aborted: false };
9
+
10
+ async function loadAbortRunDeps(): Promise<AbortRunDeps | null> {
11
+ // The SDK is optional at runtime and unavailable/unmockable under Vitest; tests
12
+ // inject `deps` directly to exercise the real abort path.
13
+ if (process.env.VITEST === "true") return null;
14
+ try {
15
+ return await import("openclaw/plugin-sdk/agent-harness");
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
2
20
 
3
21
  /**
4
- * Abort the active run for a channel `sessionKey`.
22
+ * Abort the active run for a channel `sessionKey` — the CANONICAL OpenClaw stop.
23
+ *
24
+ * Mirrors how ControlUI (`chat.abort` → `abortChatRunById`) and the voice/`/compact`/
25
+ * steer paths stop a run: resolve sessionKey → internal sessionId (active runs are keyed
26
+ * by sessionId, not the channel runId), then fire a PLAIN abort (`abortAgentHarnessRun`
27
+ * = `abortEmbeddedAgentRun` → `handle.abort()`). That sets the run's `externalAbort` flag
28
+ * so it unwinds to a clean `"aborted_by_user"` terminal — a SILENT reply plus a
29
+ * `status:"cancelled"` lifecycle — with NO error event.
5
30
  *
6
- * A session has at most one active run at a time, and the SDK keys active runs by
7
- * their internal `sessionId` (not the channel runId). So resolve sessionKey → sessionId
8
- * first, then abort-and-drain so the caller learns whether the run actually settled.
31
+ * Deliberately NOT abort-and-drain with `forceClear`: `forceClear` force-fails the reply
32
+ * operation (`operation.fail("run_failed", new Error("Embedded run force-cleared by …"))`)
33
+ * and never fires the abort signal, so (a) the failed operation surfaces as a spurious
34
+ * `dispatch_error`, and (b) without `externalAbort` the interrupted LLM call is classified
35
+ * as a real failure → `"LLM request failed."`. Both show up as an error toast in the app.
36
+ * `forceClear` is OpenClaw's stuck/cron-timeout recovery hammer, not a user-initiated stop.
9
37
  */
10
- export async function abortRunForSessionKey(sessionKey: string): Promise<AbortRunResult> {
11
- if (process.env.VITEST === "true") return { aborted: false, drained: false };
38
+ export async function abortRunForSessionKey(
39
+ sessionKey: string,
40
+ deps?: AbortRunDeps,
41
+ ): Promise<AbortRunResult> {
12
42
  const key = sessionKey.trim();
13
- if (!key) return { aborted: false, drained: false };
43
+ if (!key) return NO_OP;
44
+ const resolved = deps ?? (await loadAbortRunDeps());
45
+ if (!resolved) return NO_OP;
14
46
  try {
15
- const { resolveActiveEmbeddedRunSessionId, abortAndDrainAgentHarnessRun } =
16
- await import("openclaw/plugin-sdk/agent-harness");
17
- const sessionId = resolveActiveEmbeddedRunSessionId(key);
18
- if (!sessionId) return { aborted: false, drained: false };
19
- const result = await abortAndDrainAgentHarnessRun({ sessionId, sessionKey: key });
20
- return { aborted: result.aborted, drained: result.drained };
47
+ const sessionId = resolved.resolveActiveEmbeddedRunSessionId(key);
48
+ if (!sessionId) return NO_OP;
49
+ const aborted = resolved.abortAgentHarnessRun(sessionId);
50
+ return { aborted };
21
51
  } catch {
22
52
  // optional at runtime
23
- return { aborted: false, drained: false };
53
+ return NO_OP;
24
54
  }
25
55
  }
@@ -0,0 +1,29 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import {
3
+ clearRecentAborts,
4
+ markUserAbort,
5
+ RECENT_ABORT_SUPPRESSION_MS,
6
+ wasRecentlyUserAborted,
7
+ } from "./recent-aborts.js";
8
+
9
+ afterEach(() => clearRecentAborts());
10
+
11
+ describe("recent-aborts", () => {
12
+ it("reports a sessionKey as recently aborted within the window", () => {
13
+ markUserAbort("sk-1", 1_000);
14
+ expect(wasRecentlyUserAborted("sk-1", 1_000)).toBe(true);
15
+ expect(wasRecentlyUserAborted("sk-1", 1_000 + RECENT_ABORT_SUPPRESSION_MS)).toBe(true);
16
+ });
17
+
18
+ it("expires after the window", () => {
19
+ markUserAbort("sk-2", 1_000);
20
+ expect(wasRecentlyUserAborted("sk-2", 1_000 + RECENT_ABORT_SUPPRESSION_MS + 1)).toBe(false);
21
+ });
22
+
23
+ it("is false for an unknown or empty sessionKey", () => {
24
+ expect(wasRecentlyUserAborted("never", 1_000)).toBe(false);
25
+ expect(wasRecentlyUserAborted(" ", 1_000)).toBe(false);
26
+ markUserAbort(" ", 1_000);
27
+ expect(wasRecentlyUserAborted(" ", 1_000)).toBe(false);
28
+ });
29
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Tracks recent user-initiated aborts per channel sessionKey.
3
+ *
4
+ * When the user stops a run, OpenClaw's embedded runner / Codex backend can still surface an
5
+ * error as a consequence of the interruption — the aborted run's own failover ("LLM request
6
+ * failed.") or a generic failure on the very next turn ("Something went wrong …"). Those are
7
+ * abort-noise, not real failures the user should see as an error toast. The channel records the
8
+ * abort here so the deliver/error forwarders can drop `isError` payloads that land within a short
9
+ * window of a stop the user explicitly requested.
10
+ */
11
+ const recentAbortAtMs = new Map<string, number>();
12
+
13
+ /** How long after a user stop to treat surfaced errors as abort-noise. */
14
+ export const RECENT_ABORT_SUPPRESSION_MS = 20_000;
15
+
16
+ export function markUserAbort(sessionKey: string, nowMs: number = Date.now()): void {
17
+ const key = sessionKey.trim();
18
+ if (key) recentAbortAtMs.set(key, nowMs);
19
+ }
20
+
21
+ export function wasRecentlyUserAborted(sessionKey: string, nowMs: number = Date.now()): boolean {
22
+ const key = sessionKey.trim();
23
+ if (!key) return false;
24
+ const at = recentAbortAtMs.get(key);
25
+ if (at === undefined) return false;
26
+ if (nowMs - at > RECENT_ABORT_SUPPRESSION_MS) {
27
+ recentAbortAtMs.delete(key);
28
+ return false;
29
+ }
30
+ return true;
31
+ }
32
+
33
+ /** Test/maintenance hook. */
34
+ export function clearRecentAborts(): void {
35
+ recentAbortAtMs.clear();
36
+ }
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { abortRunForSessionKey } from "../../agent/abort-run.js";
3
+ import { markUserAbort } from "../../agent/recent-aborts.js";
3
4
  import { getRunRoute } from "../../run-metadata.js";
4
5
  import { sseEmitter } from "../../sse/emitter.js";
5
6
  import { readJsonBody } from "../middleware/body.js";
@@ -34,7 +35,10 @@ export async function handleCancel(req: IncomingMessage, res: ServerResponse): P
34
35
  }
35
36
  const result = sessionKey
36
37
  ? await abortRunForSessionKey(sessionKey)
37
- : { aborted: false, drained: false };
38
+ : { aborted: false };
39
+ // Record the user stop so abort-induced error deliveries are suppressed for a short window
40
+ // (the aborted run's own failover, or a generic failure on the immediate next turn).
41
+ if (sessionKey) markUserAbort(sessionKey);
38
42
  if (runId) sseEmitter.untrackRun(runId);
39
43
  res.statusCode = 200;
40
44
  res.setHeader("Content-Type", "application/json");
@@ -25,6 +25,7 @@ export type FridayReplyPayload = {
25
25
  channelData?: unknown;
26
26
  };
27
27
  import { resolveFridayNextConfig } from "../../config.js";
28
+ import { wasRecentlyUserAborted } from "../../agent/recent-aborts.js";
28
29
  import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
29
30
  import { getFridayNextRuntime } from "../../runtime.js";
30
31
  import {
@@ -589,6 +590,14 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
589
590
  }
590
591
  }
591
592
  const payload = translateDeliverPayload(pl, info.kind, meta);
593
+ const deliverIsError =
594
+ (payload as { isError?: boolean })?.isError || (pl as { isError?: boolean })?.isError;
595
+ // Drop error deliveries that are a direct consequence of a user stop (the aborted
596
+ // run's own failover, e.g. "LLM request failed."). The user already chose to stop;
597
+ // surfacing it as an error toast is abort-noise, not a real failure.
598
+ if (deliverIsError && wasRecentlyUserAborted(baseSessionKey)) {
599
+ return;
600
+ }
592
601
  log("EVENT_SENT", normalizedDeviceId, runId, `deliver kind=${info.kind}`);
593
602
  sseEmitter.broadcastToRun(
594
603
  runId,
@@ -613,6 +622,9 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
613
622
  }
614
623
  },
615
624
  onError: (err: unknown) => {
625
+ if (wasRecentlyUserAborted(baseSessionKey)) {
626
+ return;
627
+ }
616
628
  log("RUN_ERROR", normalizedDeviceId, runId, String(err), "error");
617
629
  sseEmitter.broadcastToRun(
618
630
  runId,