@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 +3 -0
- package/dist/index.js +9 -4
- package/dist/src/agent/abort-run.d.ts +19 -6
- package/dist/src/agent/abort-run.js +37 -14
- package/dist/src/agent/recent-aborts.d.ts +6 -0
- package/dist/src/agent/recent-aborts.js +35 -0
- package/dist/src/friday-session.js +12 -6
- package/dist/src/history/normalize-message.js +15 -0
- package/dist/src/http/handlers/cancel.js +6 -1
- package/dist/src/http/handlers/messages.js +11 -0
- package/index.ts +9 -4
- package/package.json +1 -1
- package/src/agent/abort-run.test.ts +39 -0
- package/src/agent/abort-run.ts +45 -15
- package/src/agent/recent-aborts.test.ts +29 -0
- package/src/agent/recent-aborts.ts +36 -0
- package/src/friday-session.forward-agent.test.ts +21 -0
- package/src/friday-session.ts +14 -6
- package/src/history/normalize-message.test.ts +27 -0
- package/src/history/normalize-message.ts +15 -0
- package/src/http/handlers/cancel.ts +5 -1
- package/src/http/handlers/messages.ts +12 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
34
|
+
return NO_OP;
|
|
35
|
+
const resolved = deps ?? (await loadAbortRunDeps());
|
|
36
|
+
if (!resolved)
|
|
37
|
+
return NO_OP;
|
|
14
38
|
try {
|
|
15
|
-
const
|
|
16
|
-
const sessionId = resolveActiveEmbeddedRunSessionId(key);
|
|
39
|
+
const sessionId = resolved.resolveActiveEmbeddedRunSessionId(key);
|
|
17
40
|
if (!sessionId)
|
|
18
|
-
return
|
|
19
|
-
const
|
|
20
|
-
return { aborted
|
|
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
|
|
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
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
+
});
|
package/src/agent/abort-run.ts
CHANGED
|
@@ -1,25 +1,55 @@
|
|
|
1
|
-
export type AbortRunResult = { aborted: 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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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(
|
|
11
|
-
|
|
38
|
+
export async function abortRunForSessionKey(
|
|
39
|
+
sessionKey: string,
|
|
40
|
+
deps?: AbortRunDeps,
|
|
41
|
+
): Promise<AbortRunResult> {
|
|
12
42
|
const key = sessionKey.trim();
|
|
13
|
-
if (!key) return
|
|
43
|
+
if (!key) return NO_OP;
|
|
44
|
+
const resolved = deps ?? (await loadAbortRunDeps());
|
|
45
|
+
if (!resolved) return NO_OP;
|
|
14
46
|
try {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
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
|
|
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,
|
package/src/friday-session.ts
CHANGED
|
@@ -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
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
|
|
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
|
|
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,
|