@syengup/friday-channel-next 1.0.3 → 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.
package/dist/index.js CHANGED
@@ -16,8 +16,6 @@ import { ensureCodexReasoningSummary } from "./src/codex-reasoning-config.js";
16
16
  const hookLogger = createFridayNextLogger("hook");
17
17
  export { fridayNextChannelPlugin } from "./src/channel.js";
18
18
  export { setFridayNextRuntime } from "./src/runtime.js";
19
- /** `api.on` returns void — register tool hooks at most once per process. */
20
- let fridayNextToolHooksRegistered = false;
21
19
  let disposeAgentEventListener = null;
22
20
  /**
23
21
  * Track the last `api` instance on which HTTP routes were registered.
@@ -142,10 +140,17 @@ export default defineChannelPluginEntry({
142
140
  total: event.usage?.total,
143
141
  }, event.model, event.provider);
144
142
  });
145
- if (fridayNextToolHooksRegistered) {
143
+ // Tool hooks (subagent_delivery_target / before_tool_call / after_tool_call) must follow the
144
+ // SAME re-registration discipline as the HTTP routes and onAgentEvent listener above. When the
145
+ // health-monitor restarts the plugin, `registerFull` receives a fresh `api` and the host
146
+ // dispatches hooks on THAT api; hooks left bound to the first api go silent for the rest of the
147
+ // process. The old one-time boolean guard bound them to whichever api arrived first — which is
148
+ // exactly why Codex `command_output` (the A3 after_tool_call stdout synthesis) fired on some
149
+ // gateway processes and not others. Re-register on every genuinely-new api; skip only a repeat
150
+ // call with the same api (matching the `!sameApi` gate the routes use).
151
+ if (sameApi) {
146
152
  return;
147
153
  }
148
- fridayNextToolHooksRegistered = true;
149
154
  // Make Codex (ChatGPT/OAuth) models emit reasoning summary text so the app can stream
150
155
  // "thinking". OpenClaw never sets this; we assert it on the plugin side. Best-effort.
151
156
  ensureCodexReasoningSummary((msg) => hookLogger.info(msg));
@@ -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
+ }
@@ -346,12 +346,18 @@ export function forwardAgentEventRaw(evt) {
346
346
  // Codex app-server projects every tool/command call onto BOTH the standard `tool` stream
347
347
  // (carrying args + the real result) AND a redundant `item` event (kind:"tool"/"command"),
348
348
  // and core flags that item `suppressChannelProgress: true` ("do not surface in channel
349
- // progress"). Forwarding the suppressed item anyway double-renders every non-exec tool in
350
- // the app — the `tool`-stream row plus a second `item kind:tool` row, with the result landing
351
- // only on the first. Honor the flag and drop suppressed items; the `tool` stream (and, for
352
- // exec, the synthesized `command_output`) already carries everything the app renders. Codex
353
- // reasoning items (preamble/analysis) are NOT suppressed, so this never touches thinking.
354
- if (evt.stream === "item" && evt.data.suppressChannelProgress === true) {
349
+ // progress"). Forwarding the suppressed *tool* item double-renders every non-exec tool in the
350
+ // app — the `tool`-stream row plus a second `item kind:tool` row, with the result landing only
351
+ // on the first. So drop ONLY suppressed `kind:"tool"` items.
352
+ //
353
+ // BUT keep suppressed `kind:"command"`/`"process"` items: the `tool` stream's exec result is
354
+ // just {exitCode,duration} (no stdout), and the app bootstraps its command-terminal row from
355
+ // the `item kind:command` event — that row is what the synthesized `command_output` end event
356
+ // then attaches the real stdout to. Dropping it left exec tools with a command line and no
357
+ // output in the trace. (Reasoning items preamble/analysis are never suppressed.)
358
+ if (evt.stream === "item" &&
359
+ evt.data.suppressChannelProgress === true &&
360
+ evt.data.kind === "tool") {
355
361
  return;
356
362
  }
357
363
  // Register sessionKey → runId so we can resolve parentRunId
@@ -91,6 +91,21 @@ function parseContent(content) {
91
91
  }
92
92
  break;
93
93
  }
94
+ case "toolResult":
95
+ case "tool_result": {
96
+ // Codex (app-server backend) persists an exec/bash tool's STDOUT in a content
97
+ // block whose `type` is "toolResult" (not "text") — the output lives in the
98
+ // block's `text`/`content`. The native/Anthropic path uses plain "text" blocks,
99
+ // so this case was never hit and the command output was silently dropped from
100
+ // history (`parsed.text` stayed ""), leaving the app's trace tool row with no
101
+ // result. ControlUI shows it because its projection reads these blocks.
102
+ const t = readString(block.text) || readString(block.content);
103
+ if (t) {
104
+ textParts.push(t);
105
+ out.images.push(...extractMediaMarkers(t));
106
+ }
107
+ break;
108
+ }
94
109
  case "thinking": {
95
110
  const t = readString(block.thinking);
96
111
  if (t)
@@ -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/index.ts CHANGED
@@ -30,8 +30,6 @@ const hookLogger = createFridayNextLogger("hook");
30
30
  export { fridayNextChannelPlugin } from "./src/channel.js";
31
31
  export { setFridayNextRuntime } from "./src/runtime.js";
32
32
 
33
- /** `api.on` returns void — register tool hooks at most once per process. */
34
- let fridayNextToolHooksRegistered = false;
35
33
  let disposeAgentEventListener: (() => void) | null = null;
36
34
  /**
37
35
  * Track the last `api` instance on which HTTP routes were registered.
@@ -163,10 +161,17 @@ export default defineChannelPluginEntry({
163
161
  );
164
162
  });
165
163
 
166
- if (fridayNextToolHooksRegistered) {
164
+ // Tool hooks (subagent_delivery_target / before_tool_call / after_tool_call) must follow the
165
+ // SAME re-registration discipline as the HTTP routes and onAgentEvent listener above. When the
166
+ // health-monitor restarts the plugin, `registerFull` receives a fresh `api` and the host
167
+ // dispatches hooks on THAT api; hooks left bound to the first api go silent for the rest of the
168
+ // process. The old one-time boolean guard bound them to whichever api arrived first — which is
169
+ // exactly why Codex `command_output` (the A3 after_tool_call stdout synthesis) fired on some
170
+ // gateway processes and not others. Re-register on every genuinely-new api; skip only a repeat
171
+ // call with the same api (matching the `!sameApi` gate the routes use).
172
+ if (sameApi) {
167
173
  return;
168
174
  }
169
- fridayNextToolHooksRegistered = true;
170
175
 
171
176
  // Make Codex (ChatGPT/OAuth) models emit reasoning summary text so the app can stream
172
177
  // "thinking". OpenClaw never sets this; we assert it on the plugin side. Best-effort.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syengup/friday-channel-next",
3
- "version": "1.0.3",
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
+ }
@@ -195,6 +195,27 @@ describe("forwardAgentEventRaw (thinking delta rewrite)", () => {
195
195
  expect(sseEmitter.broadcastToRun).not.toHaveBeenCalled();
196
196
  });
197
197
 
198
+ it("KEEPS suppressed item kind:command (exec row the command_output stdout attaches to)", () => {
199
+ // The `tool`-stream exec result is just {exitCode,duration}; the real stdout arrives on the
200
+ // synthesized `command_output` end event, which the app attaches to the command-terminal row
201
+ // bootstrapped from THIS `item kind:command`. Dropping it (as the old broad filter did) left
202
+ // exec tools with a command line and no output in the trace.
203
+ forwardAgentEventRaw({
204
+ runId,
205
+ seq: 1,
206
+ stream: "item",
207
+ sessionKey,
208
+ data: {
209
+ itemId: "call_exec1",
210
+ kind: "command",
211
+ phase: "start",
212
+ name: "bash",
213
+ suppressChannelProgress: true,
214
+ },
215
+ });
216
+ expect(sseEmitter.broadcastToRun).toHaveBeenCalledTimes(1);
217
+ });
218
+
198
219
  it("forwards item events that are not suppressed (e.g. reasoning analysis markers)", () => {
199
220
  forwardAgentEventRaw({
200
221
  runId,
@@ -401,12 +401,20 @@ export function forwardAgentEventRaw(evt: ForwardAgentEventArgs): void {
401
401
  // Codex app-server projects every tool/command call onto BOTH the standard `tool` stream
402
402
  // (carrying args + the real result) AND a redundant `item` event (kind:"tool"/"command"),
403
403
  // and core flags that item `suppressChannelProgress: true` ("do not surface in channel
404
- // progress"). Forwarding the suppressed item anyway double-renders every non-exec tool in
405
- // the app — the `tool`-stream row plus a second `item kind:tool` row, with the result landing
406
- // only on the first. Honor the flag and drop suppressed items; the `tool` stream (and, for
407
- // exec, the synthesized `command_output`) already carries everything the app renders. Codex
408
- // reasoning items (preamble/analysis) are NOT suppressed, so this never touches thinking.
409
- if (evt.stream === "item" && evt.data.suppressChannelProgress === true) {
404
+ // progress"). Forwarding the suppressed *tool* item double-renders every non-exec tool in the
405
+ // app — the `tool`-stream row plus a second `item kind:tool` row, with the result landing only
406
+ // on the first. So drop ONLY suppressed `kind:"tool"` items.
407
+ //
408
+ // BUT keep suppressed `kind:"command"`/`"process"` items: the `tool` stream's exec result is
409
+ // just {exitCode,duration} (no stdout), and the app bootstraps its command-terminal row from
410
+ // the `item kind:command` event — that row is what the synthesized `command_output` end event
411
+ // then attaches the real stdout to. Dropping it left exec tools with a command line and no
412
+ // output in the trace. (Reasoning items preamble/analysis are never suppressed.)
413
+ if (
414
+ evt.stream === "item" &&
415
+ evt.data.suppressChannelProgress === true &&
416
+ evt.data.kind === "tool"
417
+ ) {
410
418
  return;
411
419
  }
412
420
 
@@ -185,6 +185,33 @@ describe("normalizeHistoryMessage", () => {
185
185
  expect(plain?.mediaPaths).toBeUndefined();
186
186
  });
187
187
 
188
+ it("extracts Codex exec stdout from a `toolResult`-typed content block", () => {
189
+ // Codex (app-server) persists bash/exec stdout in a content block whose `type` is
190
+ // "toolResult" (not "text"); the native path only ever emits "text" blocks. Before the
191
+ // fix this fell through `default` and the command output was dropped (text="").
192
+ const out = normalizeHistoryMessage(
193
+ {
194
+ role: "toolResult",
195
+ toolCallId: "call_GSu3",
196
+ toolName: "bash",
197
+ content: [
198
+ {
199
+ type: "toolResult",
200
+ toolCallId: "call_GSu3",
201
+ name: "bash",
202
+ content: "Applications\nDesktop\nDocuments",
203
+ text: "Applications\nDesktop\nDocuments",
204
+ },
205
+ ],
206
+ ...meta("tr-1", 3),
207
+ },
208
+ 0,
209
+ );
210
+ expect(out?.role).toBe("toolResult");
211
+ expect(out?.toolResult?.text).toBe("Applications\nDesktop\nDocuments");
212
+ expect(out?.toolResult?.toolCallId).toBe("call_GSu3");
213
+ });
214
+
188
215
  it("flags compaction records via __openclaw.kind", () => {
189
216
  const out = normalizeHistoryMessage(
190
217
  {
@@ -158,6 +158,21 @@ function parseContent(content: unknown): ParsedContent {
158
158
  }
159
159
  break;
160
160
  }
161
+ case "toolResult":
162
+ case "tool_result": {
163
+ // Codex (app-server backend) persists an exec/bash tool's STDOUT in a content
164
+ // block whose `type` is "toolResult" (not "text") — the output lives in the
165
+ // block's `text`/`content`. The native/Anthropic path uses plain "text" blocks,
166
+ // so this case was never hit and the command output was silently dropped from
167
+ // history (`parsed.text` stayed ""), leaving the app's trace tool row with no
168
+ // result. ControlUI shows it because its projection reads these blocks.
169
+ const t = readString(block.text) || readString(block.content);
170
+ if (t) {
171
+ textParts.push(t);
172
+ out.images.push(...extractMediaMarkers(t));
173
+ }
174
+ break;
175
+ }
161
176
  case "thinking": {
162
177
  const t = readString(block.thinking);
163
178
  if (t) thinkingParts.push(t);
@@ -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,