SaylorTwift HF Staff commited on
Commit
4440aec
·
verified ·
1 Parent(s): 78d0c37

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/auto-reply/reply/abort-cutoff.runtime.test.ts +53 -0
  2. src/auto-reply/reply/abort-cutoff.runtime.ts +36 -0
  3. src/auto-reply/reply/abort-cutoff.ts +107 -0
  4. src/auto-reply/reply/abort-operation.ts +389 -0
  5. src/auto-reply/reply/abort-primitives.ts +128 -0
  6. src/auto-reply/reply/abort.acp-boundary.test.ts +352 -0
  7. src/auto-reply/reply/abort.lazy.test.ts +19 -0
  8. src/auto-reply/reply/abort.runtime-types.ts +26 -0
  9. src/auto-reply/reply/abort.runtime.ts +2 -0
  10. src/auto-reply/reply/abort.target-owner.test.ts +383 -0
  11. src/auto-reply/reply/abort.test.ts +1559 -0
  12. src/auto-reply/reply/abort.ts +86 -0
  13. src/auto-reply/reply/acp-elicitation-handler-lazy.ts +15 -0
  14. src/auto-reply/reply/acp-elicitation-handler.test.ts +240 -0
  15. src/auto-reply/reply/acp-elicitation-handler.ts +110 -0
  16. src/auto-reply/reply/acp-elicitation.ts +136 -0
  17. src/auto-reply/reply/acp-projector.test.ts +688 -0
  18. src/auto-reply/reply/acp-projector.ts +510 -0
  19. src/auto-reply/reply/acp-reset-target.ts +163 -0
  20. src/auto-reply/reply/acp-stream-settings.test.ts +112 -0
  21. src/auto-reply/reply/acp-stream-settings.ts +134 -0
  22. src/auto-reply/reply/agent-event-bridge.test.ts +136 -0
  23. src/auto-reply/reply/agent-event-bridge.ts +79 -0
  24. src/auto-reply/reply/agent-lifecycle-terminal.test.ts +120 -0
  25. src/auto-reply/reply/agent-lifecycle-terminal.ts +192 -0
  26. src/auto-reply/reply/agent-runner-auth-profile.ts +76 -0
  27. src/auto-reply/reply/agent-runner-auto-fallback.test.ts +427 -0
  28. src/auto-reply/reply/agent-runner-auto-fallback.ts +203 -0
  29. src/auto-reply/reply/agent-runner-cli-candidate.ts +514 -0
  30. src/auto-reply/reply/agent-runner-cli-dispatch.test.ts +1051 -0
  31. src/auto-reply/reply/agent-runner-cli-dispatch.ts +705 -0
  32. src/auto-reply/reply/agent-runner-command-output.test.ts +109 -0
  33. src/auto-reply/reply/agent-runner-command-output.ts +104 -0
  34. src/auto-reply/reply/agent-runner-compaction-accounting.ts +58 -0
  35. src/auto-reply/reply/agent-runner-context-recovery.test.ts +142 -0
  36. src/auto-reply/reply/agent-runner-context-recovery.ts +183 -0
  37. src/auto-reply/reply/agent-runner-core.test.ts +231 -0
  38. src/auto-reply/reply/agent-runner-core.ts +510 -0
  39. src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +716 -0
  40. src/auto-reply/reply/agent-runner-embedded-candidate.ts +380 -0
  41. src/auto-reply/reply/agent-runner-error-handler.ts +294 -0
  42. src/auto-reply/reply/agent-runner-event-handler.ts +296 -0
  43. src/auto-reply/reply/agent-runner-execute.ts +559 -0
  44. src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts +546 -0
  45. src/auto-reply/reply/agent-runner-execution-cli-admission.test.ts +397 -0
  46. src/auto-reply/reply/agent-runner-execution-cli-auth.test.ts +172 -0
  47. src/auto-reply/reply/agent-runner-execution-cli-commentary.test.ts +304 -0
  48. src/auto-reply/reply/agent-runner-execution-cli-delegation.test.ts +79 -0
  49. src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts +897 -0
  50. src/auto-reply/reply/agent-runner-execution-cli-sessions.test.ts +947 -0
src/auto-reply/reply/abort-cutoff.runtime.test.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Covers abort-cutoff clearing against missing persisted rows.
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { describe, expect, it } from "vitest";
6
+ import { loadSessionEntry } from "../../config/sessions/session-accessor.js";
7
+ import type { SessionEntry } from "../../config/sessions/types.js";
8
+ import { clearAbortCutoffInSessionRuntime } from "./abort-cutoff.runtime.js";
9
+
10
+ async function withTempStore<T>(run: (storePath: string) => Promise<T>): Promise<T> {
11
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-cutoff-"));
12
+ try {
13
+ return await run(path.join(dir, "sessions.json"));
14
+ } finally {
15
+ await fs.rm(dir, { recursive: true, force: true });
16
+ }
17
+ }
18
+
19
+ describe("clearAbortCutoffInSessionRuntime", () => {
20
+ it("recreates a complete persisted row when clearing abort cutoff state", async () => {
21
+ await withTempStore(async (storePath) => {
22
+ const sessionKey = "agent:main:explicit:cutoff-missing-row";
23
+ const entry: SessionEntry = {
24
+ sessionId: "cutoff-session",
25
+ updatedAt: 1,
26
+ modelProvider: "anthropic",
27
+ model: "claude-opus-4-6",
28
+ abortCutoffMessageSid: "msg-42",
29
+ abortCutoffTimestamp: 123,
30
+ };
31
+ const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
32
+
33
+ const cleared = await clearAbortCutoffInSessionRuntime({
34
+ sessionEntry: entry,
35
+ sessionStore,
36
+ sessionKey,
37
+ storePath,
38
+ });
39
+
40
+ const persisted = loadSessionEntry({ storePath, sessionKey });
41
+ expect(cleared).toBe(true);
42
+ expect(sessionStore[sessionKey]?.sessionId).toBe("cutoff-session");
43
+ expect(sessionStore[sessionKey]?.modelProvider).toBe("anthropic");
44
+ expect(sessionStore[sessionKey]?.abortCutoffMessageSid).toBeUndefined();
45
+ expect(sessionStore[sessionKey]?.abortCutoffTimestamp).toBeUndefined();
46
+ expect(persisted?.sessionId).toBe("cutoff-session");
47
+ expect(persisted?.modelProvider).toBe("anthropic");
48
+ expect(persisted?.model).toBe("claude-opus-4-6");
49
+ expect(persisted?.abortCutoffMessageSid).toBeUndefined();
50
+ expect(persisted?.abortCutoffTimestamp).toBeUndefined();
51
+ });
52
+ });
53
+ });
src/auto-reply/reply/abort-cutoff.runtime.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Runtime persistence helper for clearing abort-cutoff state from sessions. */
2
+ import { patchSessionEntryCore } from "../../config/sessions/session-accessor.js";
3
+ import type { SessionEntry } from "../../config/sessions/types.js";
4
+ import { applyAbortCutoffToSessionEntry, hasAbortCutoff } from "./abort-cutoff.js";
5
+
6
+ /** Clears abort cutoff state in memory and persisted session storage. */
7
+ export async function clearAbortCutoffInSessionRuntime(params: {
8
+ sessionEntry?: SessionEntry;
9
+ sessionStore?: Record<string, SessionEntry>;
10
+ sessionKey?: string;
11
+ storePath?: string;
12
+ }): Promise<boolean> {
13
+ const { sessionEntry, sessionStore, sessionKey, storePath } = params;
14
+ if (!sessionEntry || !sessionStore || !sessionKey || !hasAbortCutoff(sessionEntry)) {
15
+ return false;
16
+ }
17
+
18
+ applyAbortCutoffToSessionEntry(sessionEntry, undefined);
19
+ const updatedAt = Date.now();
20
+ sessionEntry.updatedAt = updatedAt;
21
+ sessionStore[sessionKey] = sessionEntry;
22
+
23
+ if (storePath) {
24
+ await patchSessionEntryCore(
25
+ { storePath, sessionKey },
26
+ () => ({
27
+ abortCutoffMessageSid: undefined,
28
+ abortCutoffTimestamp: undefined,
29
+ updatedAt,
30
+ }),
31
+ { fallbackEntry: sessionEntry },
32
+ );
33
+ }
34
+
35
+ return true;
36
+ }
src/auto-reply/reply/abort-cutoff.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Resolves abort cutoff markers used to stop stale reply streams.
2
+ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
3
+ import type { SessionEntry } from "../../config/sessions/types.js";
4
+ import type { MsgContext } from "../templating.js";
5
+
6
+ export type AbortCutoff = {
7
+ messageSid?: string;
8
+ timestamp?: number;
9
+ };
10
+
11
+ type SessionAbortCutoffEntry = Pick<SessionEntry, "abortCutoffMessageSid" | "abortCutoffTimestamp">;
12
+
13
+ export function resolveAbortCutoffFromContext(ctx: MsgContext): AbortCutoff | undefined {
14
+ const messageSid =
15
+ normalizeOptionalString(ctx.MessageSidFull) ?? normalizeOptionalString(ctx.MessageSid);
16
+ const timestamp =
17
+ typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp) ? ctx.Timestamp : undefined;
18
+ if (!messageSid && timestamp === undefined) {
19
+ return undefined;
20
+ }
21
+ return { messageSid, timestamp };
22
+ }
23
+
24
+ export function readAbortCutoffFromSessionEntry(
25
+ entry: SessionAbortCutoffEntry | undefined,
26
+ ): AbortCutoff | undefined {
27
+ if (!entry) {
28
+ return undefined;
29
+ }
30
+ const messageSid = normalizeOptionalString(entry.abortCutoffMessageSid);
31
+ const timestamp =
32
+ typeof entry.abortCutoffTimestamp === "number" && Number.isFinite(entry.abortCutoffTimestamp)
33
+ ? entry.abortCutoffTimestamp
34
+ : undefined;
35
+ if (!messageSid && timestamp === undefined) {
36
+ return undefined;
37
+ }
38
+ return { messageSid, timestamp };
39
+ }
40
+
41
+ export function hasAbortCutoff(entry: SessionAbortCutoffEntry | undefined): boolean {
42
+ return readAbortCutoffFromSessionEntry(entry) !== undefined;
43
+ }
44
+
45
+ export function applyAbortCutoffToSessionEntry(
46
+ entry: SessionAbortCutoffEntry,
47
+ cutoff: AbortCutoff | undefined,
48
+ ): void {
49
+ entry.abortCutoffMessageSid = cutoff?.messageSid;
50
+ entry.abortCutoffTimestamp = cutoff?.timestamp;
51
+ }
52
+
53
+ function toNumericMessageSid(value: string | undefined): bigint | undefined {
54
+ const trimmed = normalizeOptionalString(value);
55
+ if (!trimmed || !/^\d+$/.test(trimmed)) {
56
+ return undefined;
57
+ }
58
+ try {
59
+ return BigInt(trimmed);
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ export function shouldSkipMessageByAbortCutoff(params: {
66
+ cutoffMessageSid?: string;
67
+ cutoffTimestamp?: number;
68
+ messageSid?: string;
69
+ timestamp?: number;
70
+ }): boolean {
71
+ const cutoffSid = normalizeOptionalString(params.cutoffMessageSid);
72
+ const currentSid = normalizeOptionalString(params.messageSid);
73
+ if (cutoffSid && currentSid) {
74
+ const cutoffNumeric = toNumericMessageSid(cutoffSid);
75
+ const currentNumeric = toNumericMessageSid(currentSid);
76
+ if (cutoffNumeric !== undefined && currentNumeric !== undefined) {
77
+ return currentNumeric <= cutoffNumeric;
78
+ }
79
+ if (currentSid === cutoffSid) {
80
+ return true;
81
+ }
82
+ }
83
+ if (
84
+ typeof params.cutoffTimestamp === "number" &&
85
+ Number.isFinite(params.cutoffTimestamp) &&
86
+ typeof params.timestamp === "number" &&
87
+ Number.isFinite(params.timestamp)
88
+ ) {
89
+ return params.timestamp <= params.cutoffTimestamp;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ export function shouldPersistAbortCutoff(params: {
95
+ commandSessionKey?: string;
96
+ targetSessionKey?: string;
97
+ }): boolean {
98
+ const commandSessionKey = normalizeOptionalString(params.commandSessionKey);
99
+ const targetSessionKey = normalizeOptionalString(params.targetSessionKey);
100
+ if (!commandSessionKey || !targetSessionKey) {
101
+ return true;
102
+ }
103
+ // Native targeted /stop can run from a slash/session-control key while the
104
+ // actual target session uses different message id/timestamp spaces.
105
+ // Persist cutoff only when command source and target are the same session.
106
+ return commandSessionKey === targetSessionKey;
107
+ }
src/auto-reply/reply/abort-operation.ts ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Handles abort requests and active reply run cancellation.
2
+ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
3
+ import { getAcpSessionManager } from "../../acp/control-plane/manager.js";
4
+ import { retireSessionMcpRuntime } from "../../agents/agent-bundle-mcp-manager-api.js";
5
+ import { resolveSessionAgentId } from "../../agents/agent-scope.js";
6
+ import { resolveActiveEmbeddedRunSessionId } from "../../agents/embedded-agent-runner/active-run-projections.js";
7
+ import { abortEmbeddedAgentRun } from "../../agents/embedded-agent-runner/runs.js";
8
+ import { killAllControlledSubagentRuns } from "../../agents/subagents/registry/subagent-control.js";
9
+ import { listSubagentRunsForController } from "../../agents/subagents/registry/subagent-registry-read.js";
10
+ import {
11
+ resolveInternalSessionKey,
12
+ resolveMainSessionAlias,
13
+ } from "../../agents/tools/sessions-helpers.js";
14
+ import { resolveSessionStorePathCore } from "../../config/sessions.js";
15
+ import {
16
+ loadSessionEntry,
17
+ markSessionAbortTarget,
18
+ resolveSessionAbortTarget,
19
+ type SessionAbortTargetContext,
20
+ type SessionAbortTargetIdentity,
21
+ type SessionAbortTargetResult,
22
+ } from "../../config/sessions/session-accessor.js";
23
+ import type { OpenClawConfig } from "../../config/types.openclaw.js";
24
+ import { logVerbose } from "../../globals.js";
25
+ import { formatErrorMessage } from "../../infra/errors.js";
26
+ import { isAcpSessionKey, isSubagentSessionKey } from "../../routing/session-key.js";
27
+ import { resolveCommandAuthorization } from "../command-auth.js";
28
+ import type { FinalizedRuntimeMsgContext } from "../templating.js";
29
+ import {
30
+ type AbortCutoff,
31
+ resolveAbortCutoffFromContext,
32
+ shouldPersistAbortCutoff,
33
+ } from "./abort-cutoff.js";
34
+ import { setAbortMemory } from "./abort-primitives.js";
35
+ import type { FastAbortRequestParams, FastAbortResult, PreparedFastAbortRequest } from "./abort.js";
36
+ import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js";
37
+ import { resolveConversationBindingContextFromMessage } from "./conversation-binding-input.js";
38
+ import { clearSessionQueues } from "./queue.js";
39
+ import { replyRunRegistry } from "./reply-run-registry.js";
40
+
41
+ export function abortSessionRunTargetWithOutcome(params: { key?: string; sessionId?: string }): {
42
+ active: boolean;
43
+ aborted: boolean;
44
+ retirement?: Promise<void>;
45
+ } {
46
+ const sessionIds = new Set<string>();
47
+ const key = normalizeOptionalString(params.key);
48
+ let active = key ? replyRunRegistry.isActive(key) : false;
49
+ if (key) {
50
+ const activeSessionId = resolveActiveEmbeddedRunSessionId(key);
51
+ if (activeSessionId) {
52
+ active = true;
53
+ sessionIds.add(activeSessionId);
54
+ }
55
+ }
56
+ const explicitSessionId = normalizeOptionalString(params.sessionId);
57
+ if (explicitSessionId) {
58
+ sessionIds.add(explicitSessionId);
59
+ }
60
+
61
+ let aborted = key ? replyRunRegistry.abort(key) : false;
62
+ for (const sessionId of sessionIds) {
63
+ aborted = abortEmbeddedAgentRun(sessionId) || aborted;
64
+ }
65
+ // Stop owns these captured IDs; a later turn may rebind the session key.
66
+ const retirement =
67
+ !active || aborted
68
+ ? Promise.all(
69
+ [...sessionIds].map((sessionId) =>
70
+ retireSessionMcpRuntime({
71
+ sessionId,
72
+ reason: "session-stop",
73
+ }),
74
+ ),
75
+ ).then(() => undefined)
76
+ : undefined;
77
+ return { active, aborted, retirement };
78
+ }
79
+
80
+ function resolveStoredSessionId(params: {
81
+ cfg: OpenClawConfig;
82
+ sessionKey: string;
83
+ }): string | undefined {
84
+ const agentId = resolveSessionAgentId({
85
+ sessionKey: params.sessionKey,
86
+ config: params.cfg,
87
+ });
88
+ const storePath = resolveSessionStorePathCore(params.cfg.session?.store, { agentId });
89
+ try {
90
+ return loadSessionEntry({
91
+ agentId,
92
+ clone: false,
93
+ sessionKey: params.sessionKey,
94
+ storePath,
95
+ })?.sessionId;
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ function resolveBoundAcpAbortTargetSessionKey(params: {
102
+ ctx: FinalizedRuntimeMsgContext;
103
+ cfg: OpenClawConfig;
104
+ activeSessionKey: string;
105
+ }): string | undefined {
106
+ const bindingContext = resolveConversationBindingContextFromMessage({
107
+ cfg: params.cfg,
108
+ ctx: params.ctx,
109
+ });
110
+ if (!bindingContext) {
111
+ return undefined;
112
+ }
113
+ return resolveEffectiveResetTargetSessionKey({
114
+ cfg: params.cfg,
115
+ channel: bindingContext.channel,
116
+ accountId: bindingContext.accountId,
117
+ conversationId: bindingContext.conversationId,
118
+ parentConversationId: bindingContext.parentConversationId,
119
+ activeSessionKey: params.activeSessionKey,
120
+ skipConfiguredFallbackWhenActiveSessionNonAcp: false,
121
+ fallbackToActiveAcpWhenUnbound: false,
122
+ });
123
+ }
124
+
125
+ function normalizeRequesterSessionKey(
126
+ cfg: OpenClawConfig,
127
+ key: string | undefined,
128
+ ): string | undefined {
129
+ const cleaned = normalizeOptionalString(key);
130
+ if (!cleaned) {
131
+ return undefined;
132
+ }
133
+ const { mainKey, alias } = resolveMainSessionAlias(cfg);
134
+ return resolveInternalSessionKey({ key: cleaned, alias, mainKey });
135
+ }
136
+
137
+ export async function stopSubagentsForRequester(params: {
138
+ cfg: OpenClawConfig;
139
+ requesterSessionKey?: string;
140
+ requesterAgentId?: string;
141
+ beforeKill?: Parameters<typeof killAllControlledSubagentRuns>[0]["beforeKill"];
142
+ }): Promise<{ stopped: number; failed: number }> {
143
+ const requesterKey = normalizeRequesterSessionKey(params.cfg, params.requesterSessionKey);
144
+ if (!requesterKey) {
145
+ await params.beforeKill?.();
146
+ return { stopped: 0, failed: 0 };
147
+ }
148
+ const controllerAgentId = resolveSessionAgentId({
149
+ config: params.cfg,
150
+ sessionKey: requesterKey,
151
+ fallbackAgentId: params.requesterAgentId,
152
+ });
153
+ const result = await killAllControlledSubagentRuns({
154
+ cfg: params.cfg,
155
+ controller: {
156
+ controllerSessionKey: requesterKey,
157
+ controllerAgentId,
158
+ callerSessionKey: requesterKey,
159
+ callerIsSubagent: isSubagentSessionKey(requesterKey),
160
+ controlScope: "children",
161
+ },
162
+ runs: listSubagentRunsForController(requesterKey),
163
+ suppressTaskDelivery: true,
164
+ beforeKill: params.beforeKill,
165
+ });
166
+ if (result.status === "error") {
167
+ logVerbose(`abort: failed to stop subagents for ${requesterKey}: ${result.error}`);
168
+ }
169
+ if (result.killed > 0) {
170
+ logVerbose(`abort: stopped ${result.killed} subagent run(s) for ${requesterKey}`);
171
+ }
172
+ return { stopped: result.killed, failed: result.status === "error" ? result.failed : 0 };
173
+ }
174
+
175
+ export async function executeFastAbortRequest(
176
+ params: FastAbortRequestParams,
177
+ request: PreparedFastAbortRequest,
178
+ ): Promise<FastAbortResult> {
179
+ const { ctx, cfg } = params;
180
+ const { commandSessionKey, targetKey, resolveTargetAgentId } = request;
181
+
182
+ const commandAuthorized = ctx.CommandAuthorized;
183
+ const auth = resolveCommandAuthorization({
184
+ ctx,
185
+ cfg,
186
+ commandAuthorized,
187
+ });
188
+ if (!auth.isAuthorizedSender) {
189
+ return { handled: false, aborted: false };
190
+ }
191
+
192
+ const agentId = resolveTargetAgentId();
193
+ const abortKey = targetKey ?? auth.from ?? auth.to;
194
+ const requesterSessionKey = targetKey ?? ctx.SessionKey ?? abortKey;
195
+
196
+ if (targetKey) {
197
+ const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId });
198
+ const abortCutoffForTarget = (target: SessionAbortTargetContext): AbortCutoff | undefined =>
199
+ shouldPersistAbortCutoff({
200
+ commandSessionKey,
201
+ targetSessionKey: target.sessionKey,
202
+ })
203
+ ? resolveAbortCutoffFromContext(ctx)
204
+ : undefined;
205
+ let resolvedAbortTarget: SessionAbortTargetIdentity | null = null;
206
+ try {
207
+ resolvedAbortTarget = resolveSessionAbortTarget({
208
+ agentId,
209
+ sessionKey: targetKey,
210
+ storePath,
211
+ });
212
+ } catch (error) {
213
+ logVerbose(
214
+ `abort: failed to resolve abort metadata for ${targetKey}: ${formatErrorMessage(error)}`,
215
+ );
216
+ }
217
+ const resolvedTargetKey = resolvedAbortTarget?.sessionKey ?? targetKey;
218
+ const conversationBoundAcpTargetKey = commandSessionKey
219
+ ? resolveBoundAcpAbortTargetSessionKey({
220
+ ctx,
221
+ cfg,
222
+ activeSessionKey: commandSessionKey,
223
+ })
224
+ : undefined;
225
+ const boundAcpTargetKey = !isAcpSessionKey(resolvedTargetKey)
226
+ ? conversationBoundAcpTargetKey
227
+ : undefined;
228
+ const abortTargetKeys = [resolvedTargetKey];
229
+ if (boundAcpTargetKey && boundAcpTargetKey !== resolvedTargetKey) {
230
+ abortTargetKeys.push(boundAcpTargetKey);
231
+ }
232
+ let aborted = false;
233
+ let activeAbortRejected = false;
234
+ const acpCancellations: Promise<void>[] = [];
235
+ try {
236
+ const { stopped, failed } = await stopSubagentsForRequester({
237
+ cfg,
238
+ requesterSessionKey,
239
+ requesterAgentId: agentId,
240
+ beforeKill: () => {
241
+ if (params.isCommandTargetCurrent?.() === false) {
242
+ throw new Error("The selected session changed before it could be stopped.");
243
+ }
244
+ try {
245
+ const sourceAbortKey =
246
+ commandSessionKey &&
247
+ !abortTargetKeys.includes(commandSessionKey) &&
248
+ conversationBoundAcpTargetKey &&
249
+ abortTargetKeys.includes(conversationBoundAcpTargetKey)
250
+ ? commandSessionKey
251
+ : undefined;
252
+ const sessionIdsByKey = new Map<string, string | undefined>(
253
+ abortTargetKeys.map((abortTargetKey) => [
254
+ abortTargetKey,
255
+ replyRunRegistry.resolveSessionId(abortTargetKey) ??
256
+ (abortTargetKey === resolvedTargetKey
257
+ ? resolvedAbortTarget?.sessionId
258
+ : resolveStoredSessionId({ cfg, sessionKey: abortTargetKey })),
259
+ ]),
260
+ );
261
+ for (const abortTargetKey of abortTargetKeys) {
262
+ const outcome = abortSessionRunTargetWithOutcome({
263
+ key: abortTargetKey,
264
+ sessionId: sessionIdsByKey.get(abortTargetKey),
265
+ });
266
+ if (outcome.retirement) {
267
+ acpCancellations.push(outcome.retirement);
268
+ }
269
+ activeAbortRejected ||= outcome.active && !outcome.aborted;
270
+ aborted = outcome.aborted || aborted;
271
+ }
272
+ const sourceSessionId = sourceAbortKey
273
+ ? (replyRunRegistry.resolveSessionId(sourceAbortKey) ??
274
+ resolveStoredSessionId({ cfg, sessionKey: sourceAbortKey }))
275
+ : undefined;
276
+ if (sourceAbortKey) {
277
+ const outcome = abortSessionRunTargetWithOutcome({
278
+ key: sourceAbortKey,
279
+ sessionId: sourceSessionId,
280
+ });
281
+ if (outcome.retirement) {
282
+ acpCancellations.push(outcome.retirement);
283
+ }
284
+ activeAbortRejected ||= outcome.active && !outcome.aborted;
285
+ aborted = outcome.aborted || aborted;
286
+ }
287
+ const cleared = clearSessionQueues(
288
+ abortTargetKeys
289
+ .flatMap((abortTargetKey) => [abortTargetKey, sessionIdsByKey.get(abortTargetKey)])
290
+ .concat(sourceAbortKey, sourceSessionId),
291
+ );
292
+ if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
293
+ logVerbose(
294
+ `abort: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
295
+ );
296
+ }
297
+ } finally {
298
+ // The tree already holds queued reservations. Initiate ACP without awaiting
299
+ // either backend so native cleanup cannot delay signal-less ACP steer turns.
300
+ const acpManager = getAcpSessionManager();
301
+ for (const acpTargetKey of abortTargetKeys) {
302
+ const resolution = acpManager.resolveSession({
303
+ cfg,
304
+ sessionKey: acpTargetKey,
305
+ agentId: acpTargetKey === resolvedTargetKey ? agentId : undefined,
306
+ });
307
+ if (resolution.kind === "none") {
308
+ continue;
309
+ }
310
+ acpCancellations.push(
311
+ acpManager
312
+ .cancelSession({
313
+ cfg,
314
+ sessionKey: resolution.sessionKey,
315
+ agentId: resolution.agentId,
316
+ reason: "fast-abort",
317
+ })
318
+ .catch((error: unknown) => {
319
+ logVerbose(
320
+ `abort: ACP cancel failed for ${acpTargetKey}: ${formatErrorMessage(error)}`,
321
+ );
322
+ }),
323
+ );
324
+ }
325
+ }
326
+ return true;
327
+ },
328
+ });
329
+ const rejectionReason = activeAbortRejected && !aborted ? "finalizing" : undefined;
330
+ if (!rejectionReason) {
331
+ let persistedAbortTarget: SessionAbortTargetResult | null = null;
332
+ try {
333
+ persistedAbortTarget = await markSessionAbortTarget({
334
+ isCurrent: params.isCommandTargetCurrent,
335
+ scope: {
336
+ agentId,
337
+ sessionKey: targetKey,
338
+ storePath,
339
+ },
340
+ resolveAbortCutoff: abortCutoffForTarget,
341
+ });
342
+ } catch (error) {
343
+ logVerbose(
344
+ `abort: failed to persist abort metadata for ${targetKey}: ${formatErrorMessage(error)}`,
345
+ );
346
+ }
347
+ if (persistedAbortTarget?.persisted === false) {
348
+ logVerbose(
349
+ `abort: failed to persist abort metadata for ${targetKey}: ${persistedAbortTarget.persistenceError ?? "unknown error"}`,
350
+ );
351
+ }
352
+ const abortMemoryKey =
353
+ persistedAbortTarget?.sessionKey ?? resolvedAbortTarget?.sessionKey ?? abortKey;
354
+ const hasAbortTargetEntry = Boolean(
355
+ persistedAbortTarget?.entry ?? resolvedAbortTarget?.entry,
356
+ );
357
+ if (
358
+ persistedAbortTarget?.persisted !== true &&
359
+ abortMemoryKey &&
360
+ !hasAbortTargetEntry &&
361
+ params.isCommandTargetCurrent?.() !== false
362
+ ) {
363
+ setAbortMemory(abortMemoryKey, true);
364
+ }
365
+ }
366
+ return {
367
+ handled: true,
368
+ aborted,
369
+ ...(rejectionReason ? { rejectionReason } : {}),
370
+ stoppedSubagents: stopped,
371
+ failedSubagents: failed,
372
+ };
373
+ } finally {
374
+ // Join even when native signaling or metadata exits exceptionally.
375
+ await Promise.all(acpCancellations);
376
+ }
377
+ }
378
+
379
+ if (abortKey) {
380
+ setAbortMemory(abortKey, true);
381
+ }
382
+ const { stopped, failed } = await stopSubagentsForRequester({ cfg, requesterSessionKey });
383
+ return {
384
+ handled: true,
385
+ aborted: false,
386
+ stoppedSubagents: stopped,
387
+ failedSubagents: failed,
388
+ };
389
+ }
src/auto-reply/reply/abort-primitives.ts ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Normalizes abort command primitives before runtime cancellation.
2
+ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
3
+ import { resolveGlobalMap } from "../../shared/global-singleton.js";
4
+ import { normalizeCommandBody } from "../commands-registry-normalize.js";
5
+ import type { CommandNormalizeOptions } from "../commands-registry.types.js";
6
+
7
+ const ABORT_TRIGGERS = new Set([
8
+ "stop",
9
+ "esc",
10
+ "abort",
11
+ "exit",
12
+ "interrupt",
13
+ "detente",
14
+ "deten",
15
+ "detén",
16
+ "arrete",
17
+ "arrête",
18
+ "停止",
19
+ "停下来",
20
+ "暂停",
21
+ "やめて",
22
+ "止めて",
23
+ "रुको",
24
+ "توقف",
25
+ "стоп",
26
+ "остановись",
27
+ "останови",
28
+ "остановить",
29
+ "прекрати",
30
+ "halt",
31
+ "anhalten",
32
+ "aufhören",
33
+ "hoer auf",
34
+ "stopp",
35
+ "pare",
36
+ "stop openclaw",
37
+ "openclaw stop",
38
+ "stop action",
39
+ "stop current action",
40
+ "stop run",
41
+ "stop current run",
42
+ "stop agent",
43
+ "stop the agent",
44
+ "stop don't do anything",
45
+ "stop dont do anything",
46
+ "stop do not do anything",
47
+ "stop doing anything",
48
+ "do not do that",
49
+ "please stop",
50
+ "stop please",
51
+ ]);
52
+ const ABORT_MEMORY = resolveGlobalMap<string, boolean>(
53
+ Symbol.for("openclaw.abortMemory"),
54
+ "close-and-restart",
55
+ );
56
+ const ABORT_MEMORY_MAX = 2000;
57
+ const TRAILING_ABORT_PUNCTUATION_RE = /[.!?!?…,,。;;::'"’”)\]}]+$/u;
58
+
59
+ function normalizeAbortTriggerText(text: string): string {
60
+ return normalizeLowercaseStringOrEmpty(text)
61
+ .replace(/[’`]/g, "'")
62
+ .replace(/\s+/g, " ")
63
+ .replace(TRAILING_ABORT_PUNCTUATION_RE, "")
64
+ .trim();
65
+ }
66
+
67
+ export function isAbortTrigger(text?: string): boolean {
68
+ if (!text) {
69
+ return false;
70
+ }
71
+ const normalized = normalizeAbortTriggerText(text);
72
+ return ABORT_TRIGGERS.has(normalized);
73
+ }
74
+
75
+ export function isAbortRequestText(text?: string, options?: CommandNormalizeOptions): boolean {
76
+ if (!text) {
77
+ return false;
78
+ }
79
+ const normalized = normalizeCommandBody(text, options).trim();
80
+ if (!normalized) {
81
+ return false;
82
+ }
83
+ const normalizedLower = normalizeLowercaseStringOrEmpty(normalized);
84
+ return (
85
+ normalizedLower === "/stop" ||
86
+ normalizeAbortTriggerText(normalizedLower) === "/stop" ||
87
+ isAbortTrigger(normalizedLower)
88
+ );
89
+ }
90
+
91
+ export function getAbortMemory(key: string): boolean | undefined {
92
+ const normalized = key.trim();
93
+ if (!normalized) {
94
+ return undefined;
95
+ }
96
+ return ABORT_MEMORY.get(normalized);
97
+ }
98
+
99
+ function pruneAbortMemory(): void {
100
+ if (ABORT_MEMORY.size <= ABORT_MEMORY_MAX) {
101
+ return;
102
+ }
103
+ const excess = ABORT_MEMORY.size - ABORT_MEMORY_MAX;
104
+ let removed = 0;
105
+ for (const entryKey of ABORT_MEMORY.keys()) {
106
+ ABORT_MEMORY.delete(entryKey);
107
+ removed += 1;
108
+ if (removed >= excess) {
109
+ break;
110
+ }
111
+ }
112
+ }
113
+
114
+ export function setAbortMemory(key: string, value: boolean): void {
115
+ const normalized = key.trim();
116
+ if (!normalized) {
117
+ return;
118
+ }
119
+ if (!value) {
120
+ ABORT_MEMORY.delete(normalized);
121
+ return;
122
+ }
123
+ if (ABORT_MEMORY.has(normalized)) {
124
+ ABORT_MEMORY.delete(normalized);
125
+ }
126
+ ABORT_MEMORY.set(normalized, true);
127
+ pruneAbortMemory();
128
+ }
src/auto-reply/reply/abort.acp-boundary.test.ts ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Channel Stop initiates native and ACP cancellation independently of either drain. */
2
+ import type { AcpRuntime } from "@openclaw/acp-core/runtime/types";
3
+ import { expect, it, vi } from "vitest";
4
+ import { createDeferred } from "../../../test/helpers/promise.js";
5
+ import { getAcpSessionManager, testing as acpTesting } from "../../acp/control-plane/manager.js";
6
+ import { disposeAcpSessionManagerInstance } from "../../acp/control-plane/manager.lifecycle.js";
7
+ import {
8
+ registerAcpRuntimeBackend,
9
+ unregisterAcpRuntimeBackend,
10
+ } from "../../acp/runtime/registry.js";
11
+ import { readAcpSessionMeta } from "../../acp/runtime/session-meta.js";
12
+ import {
13
+ createOperationalRunInstanceRef,
14
+ prepareAgentRunAdmission,
15
+ } from "../../agents/admitted-run-context.js";
16
+ import {
17
+ clearActiveEmbeddedRun,
18
+ setActiveEmbeddedRun,
19
+ } from "../../agents/embedded-agent-runner/runs.js";
20
+ import { createEmbeddedRunHandle } from "../../agents/embedded-agent-runner/runs.test-support.js";
21
+ import { registerSubagentRun } from "../../agents/subagents/registry/subagent-registry.js";
22
+ import { writeSubagentSessionEntry } from "../../agents/subagents/registry/subagent-registry.persistence.test-support.js";
23
+ import { getSubagentRunByChildSessionKey } from "../../agents/subagents/registry/subagent-registry.test-helpers.js";
24
+ import { enqueueSwarmRun, releaseSwarmRun } from "../../agents/subagents/swarm/swarm-scheduler.js";
25
+ import { getRuntimeConfig } from "../../config/config.js";
26
+ import { resolveSessionStorePathCore } from "../../config/sessions/paths.js";
27
+ import { loadExactSessionEntryReadOnly } from "../../config/sessions/session-accessor.js";
28
+ import { useChatAbortRegistryFixture } from "../../gateway/server-methods/chat.abort-registry.test-support.js";
29
+ import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
30
+ import {
31
+ captureActivePluginRegistrySnapshot,
32
+ restoreActivePluginRegistrySnapshot,
33
+ setActivePluginRegistry,
34
+ } from "../../plugins/runtime.js";
35
+ import {
36
+ beginSessionWorkAdmission,
37
+ isSessionWorkAdmissionActive,
38
+ type SessionWorkAdmissionLease,
39
+ } from "../../sessions/session-lifecycle-admission.js";
40
+ import {
41
+ createChannelTestPluginBase,
42
+ createTestRegistry,
43
+ } from "../../test-utils/channel-plugins.js";
44
+ import { tryFastAbortFromMessage } from "./abort.js";
45
+ import { createReplyOperation } from "./reply-run-registry.js";
46
+ import { buildTestCtx } from "./test-ctx.js";
47
+
48
+ const fixture = useChatAbortRegistryFixture();
49
+
50
+ it.each(
51
+ ["idle", "active", "native failure"].flatMap((scenario) =>
52
+ ["resolve", "reject"].map((completion) => ({ scenario, completion })),
53
+ ),
54
+ )(
55
+ "native and bound ACP cancellation initiate before either drain ($scenario, $completion)",
56
+ async ({ scenario, completion }) => {
57
+ const active = scenario !== "idle";
58
+ const nativeFailure = scenario === "native failure";
59
+ const nativeError = new Error("native cancellation failed");
60
+ const sourceKey = "agent:main:stop-test:direct:room";
61
+ const acpKey = "agent:main:acp:bound-stop";
62
+ const runningKey = "agent:main:subagent:running";
63
+ const queuedKey = "agent:main:subagent:queued";
64
+ for (const [runId, sessionKey] of [
65
+ ["source", sourceKey],
66
+ ["acp", acpKey],
67
+ ["running", runningKey],
68
+ ["queued", queuedKey],
69
+ ] as const) {
70
+ await writeSubagentSessionEntry({
71
+ stateDir: fixture.stateDir,
72
+ agentId: "main",
73
+ sessionKey,
74
+ defaultSessionId: `${runId}-session`,
75
+ });
76
+ }
77
+ const cfg = getRuntimeConfig();
78
+ const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId: "main" });
79
+ const registry = captureActivePluginRegistrySnapshot();
80
+ setActivePluginRegistry(
81
+ createTestRegistry([
82
+ {
83
+ pluginId: "stop-test",
84
+ source: "test",
85
+ plugin: {
86
+ ...createChannelTestPluginBase({ id: "stop-test" }),
87
+ conversationBindings: { supportsCurrentConversationBinding: true },
88
+ },
89
+ },
90
+ ]),
91
+ );
92
+ const binding = await getSessionBindingService().bind({
93
+ targetSessionKey: acpKey,
94
+ targetKind: "session",
95
+ conversation: { channel: "stop-test", accountId: "default", conversationId: "room" },
96
+ placement: "current",
97
+ });
98
+ const entered = createDeferred();
99
+ const proceed = createDeferred();
100
+ const cancelFinished = createDeferred();
101
+ const turnStarted = createDeferred<AbortSignal>();
102
+ const finishTurn = createDeferred();
103
+ const nativeInterrupted = createDeferred();
104
+ const cancel = vi.fn<AcpRuntime["cancel"]>(async () => {
105
+ entered.resolve();
106
+ await proceed.promise;
107
+ cancelFinished.resolve();
108
+ if (completion === "reject") {
109
+ throw new Error("backend cancellation failed");
110
+ }
111
+ });
112
+ registerAcpRuntimeBackend({
113
+ id: "stop-test",
114
+ runtime: {
115
+ ensureSession: async ({ sessionKey }) => ({
116
+ sessionKey,
117
+ backend: "stop-test",
118
+ runtimeSessionName: "mock-runtime",
119
+ }),
120
+ async *runTurn({ signal }) {
121
+ if (!signal) {
122
+ throw new Error("ACP manager did not provide its active turn signal");
123
+ }
124
+ turnStarted.resolve(signal);
125
+ await finishTurn.promise;
126
+ yield { type: "done", status: "cancelled" };
127
+ },
128
+ cancel,
129
+ close: async () => {},
130
+ },
131
+ });
132
+ acpTesting.resetAcpSessionManagerForTests();
133
+ const manager = getAcpSessionManager();
134
+ const native = createReplyOperation({
135
+ sessionKey: sourceKey,
136
+ sessionId: "source-session",
137
+ resetTriggered: false,
138
+ });
139
+ native.attachBackend({
140
+ kind: "embedded",
141
+ isStreaming: () => true,
142
+ cancel: () => {
143
+ releaseSwarmRun("capacity");
144
+ if (nativeFailure) {
145
+ throw nativeError;
146
+ }
147
+ },
148
+ });
149
+ const runningAbort = vi.fn();
150
+ const handle = createEmbeddedRunHandle({ runId: "running", abort: runningAbort });
151
+ setActiveEmbeddedRun("running-session", handle, runningKey);
152
+ const selectedDispatch = vi.fn(async () => {});
153
+ const survivorDispatch = vi.fn(async () => {});
154
+ let pending: ReturnType<typeof tryFastAbortFromMessage> | undefined;
155
+ let childAdmission: SessionWorkAdmissionLease | undefined;
156
+ const preparedAdmission = prepareAgentRunAdmission({
157
+ cfg,
158
+ operationalRunInstance: createOperationalRunInstanceRef("acp-steer"),
159
+ facts: {
160
+ runId: "acp-steer",
161
+ agentId: "main",
162
+ ingress: { kind: "acp", boundary: "acp.command.steer", state: "absent" },
163
+ },
164
+ });
165
+ let turn: Promise<void> | undefined;
166
+ let acpSignal: AbortSignal | undefined;
167
+ let stopSettled = false;
168
+ try {
169
+ await manager.initializeSession({
170
+ cfg,
171
+ sessionKey: acpKey,
172
+ agent: "main",
173
+ mode: "persistent",
174
+ backendId: "stop-test",
175
+ });
176
+ if (active) {
177
+ const admittedRunContext = await preparedAdmission.admit("acp");
178
+ // Match /acp steer: the manager owns the only cancellation signal.
179
+ turn = manager
180
+ .runTurn({
181
+ cfg,
182
+ admittedRunContext,
183
+ sessionKey: acpKey,
184
+ requestId: "acp-steer",
185
+ mode: "steer",
186
+ provenance: "agent",
187
+ text: "keep working",
188
+ })
189
+ .finally(() => preparedAdmission.close());
190
+ acpSignal = await Promise.race([
191
+ turnStarted.promise,
192
+ turn.then(() => {
193
+ throw new Error("ACP steer completed before the active-turn gate");
194
+ }),
195
+ ]);
196
+ expect(acpSignal.aborted).toBe(false);
197
+ expect(manager.getObservabilitySnapshot().turns.active).toBe(1);
198
+ childAdmission = await beginSessionWorkAdmission({
199
+ scope: storePath,
200
+ identities: [runningKey, "running-session"],
201
+ assertAllowed: () => {},
202
+ onInterrupt: () => nativeInterrupted.resolve(),
203
+ });
204
+ }
205
+ for (const [runId, childSessionKey] of [
206
+ ["running", runningKey],
207
+ ["queued", queuedKey],
208
+ ] as const) {
209
+ registerSubagentRun({
210
+ runId,
211
+ childSessionKey,
212
+ requesterSessionKey: sourceKey,
213
+ requesterAgentId: "main",
214
+ requesterDisplayKey: sourceKey,
215
+ task: runId,
216
+ cleanup: "keep",
217
+ collect: true,
218
+ queued: runId === "queued",
219
+ expectsCompletionMessage: false,
220
+ });
221
+ }
222
+ for (const [runId, start] of [
223
+ ["queued", selectedDispatch],
224
+ ["survivor", survivorDispatch],
225
+ ] as const) {
226
+ enqueueSwarmRun({
227
+ groupId: "bound-acp",
228
+ runId,
229
+ maxConcurrent: 1,
230
+ activeRunIds: ["capacity"],
231
+ start,
232
+ onStartFailure: () => true,
233
+ });
234
+ }
235
+ pending = tryFastAbortFromMessage({
236
+ cfg,
237
+ ctx: buildTestCtx({
238
+ SessionKey: sourceKey,
239
+ CommandBody: "/stop",
240
+ RawBody: "/stop",
241
+ CommandAuthorized: true,
242
+ Provider: "stop-test",
243
+ Surface: "stop-test",
244
+ From: "stop-test:room",
245
+ To: "stop-test:room",
246
+ MessageSid: "77",
247
+ Timestamp: 1234567890000,
248
+ }),
249
+ }).finally(() => {
250
+ stopSettled = true;
251
+ });
252
+ const outcome = pending.then(
253
+ (result) => ({ result }),
254
+ (error: unknown) => ({ error }),
255
+ );
256
+ if (nativeFailure) {
257
+ await Promise.race([
258
+ entered.promise,
259
+ outcome.then(() => {
260
+ throw new Error("Native failure escaped before ACP cancellation started");
261
+ }),
262
+ ]);
263
+ expect(native.abortSignal.aborted).toBe(true);
264
+ expect(acpSignal?.aborted).toBe(true);
265
+ expect(stopSettled).toBe(false);
266
+ proceed.resolve();
267
+ expect(await outcome).toEqual({ error: nativeError });
268
+ expect(cancel).toHaveBeenCalledOnce();
269
+ return;
270
+ }
271
+ if (active) {
272
+ await nativeInterrupted.promise;
273
+ expect(native.abortSignal.aborted).toBe(true);
274
+ expect(isSessionWorkAdmissionActive(storePath, [runningKey, "running-session"])).toBe(true);
275
+ await vi.waitFor(() => expect(acpSignal?.aborted).toBe(true));
276
+ expect(cancel).toHaveBeenCalledOnce();
277
+ expect(stopSettled).toBe(false);
278
+ expect(selectedDispatch).not.toHaveBeenCalled();
279
+ if (completion === "reject") {
280
+ proceed.resolve();
281
+ await cancelFinished.promise;
282
+ // Let rejection observers run while the native admission is still retained.
283
+ await new Promise<void>((resolve) => {
284
+ setImmediate(resolve);
285
+ });
286
+ expect(stopSettled).toBe(false);
287
+ }
288
+ childAdmission?.release();
289
+ }
290
+ await Promise.race([
291
+ entered.promise,
292
+ pending.then(() => {
293
+ throw new Error("Stop missed the bound ACP backend");
294
+ }),
295
+ ]);
296
+ expect(native.abortSignal.aborted).toBe(true);
297
+ // Synchronize on native terminal state, never on the still-held ACP completion.
298
+ await vi.waitFor(() => expect(runningAbort).toHaveBeenCalledOnce());
299
+ await vi.waitFor(() => expect(survivorDispatch).toHaveBeenCalledOnce());
300
+ expect(selectedDispatch).not.toHaveBeenCalled();
301
+ for (const key of [runningKey, queuedKey]) {
302
+ expect(getSubagentRunByChildSessionKey(key)?.endedReason).toBe("subagent-killed");
303
+ }
304
+ await vi.waitFor(() =>
305
+ expect(loadExactSessionEntryReadOnly({ sessionKey: sourceKey })?.entry).toMatchObject({
306
+ abortedLastRun: true,
307
+ abortCutoffMessageSid: "77",
308
+ abortCutoffTimestamp: 1234567890000,
309
+ }),
310
+ );
311
+ if (!active || completion !== "reject") {
312
+ expect(stopSettled).toBe(false);
313
+ }
314
+ proceed.resolve();
315
+ expect(await pending).toEqual({
316
+ handled: true,
317
+ aborted: true,
318
+ stoppedSubagents: 2,
319
+ failedSubagents: 0,
320
+ });
321
+ expect(cancel).toHaveBeenCalledExactlyOnceWith({
322
+ handle: expect.objectContaining({ sessionKey: acpKey }),
323
+ reason: "fast-abort",
324
+ });
325
+ finishTurn.resolve();
326
+ await turn;
327
+ expect(readAcpSessionMeta({ cfg, sessionKey: acpKey })?.state).toBe(
328
+ !active && completion === "reject" ? "error" : "idle",
329
+ );
330
+ } finally {
331
+ childAdmission?.release();
332
+ proceed.resolve();
333
+ finishTurn.resolve();
334
+ await pending?.catch(() => undefined);
335
+ await turn?.catch(() => undefined);
336
+ preparedAdmission.close();
337
+ native.complete();
338
+ clearActiveEmbeddedRun("running-session", handle, runningKey);
339
+ releaseSwarmRun("capacity");
340
+ releaseSwarmRun("queued");
341
+ releaseSwarmRun("survivor");
342
+ await disposeAcpSessionManagerInstance(manager, "test-cleanup");
343
+ acpTesting.resetAcpSessionManagerForTests();
344
+ unregisterAcpRuntimeBackend("stop-test");
345
+ await getSessionBindingService().unbind({
346
+ bindingId: binding.bindingId,
347
+ reason: "test-cleanup",
348
+ });
349
+ restoreActivePluginRegistrySnapshot(registry);
350
+ }
351
+ },
352
+ );
src/auto-reply/reply/abort.lazy.test.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, it, vi } from "vitest";
2
+ import { buildTestCtx } from "./test-ctx.js";
3
+
4
+ vi.mock("../../agents/subagents/registry/subagent-control.js", () => {
5
+ throw new Error("ordinary messages must not initialize cancellation owners");
6
+ });
7
+
8
+ it.each(["direct", "group"])(
9
+ "keeps %s ordinary messages outside cancellation runtime",
10
+ async (chatType) => {
11
+ const { tryFastAbortFromMessage } = await import("./abort.js");
12
+ await expect(
13
+ tryFastAbortFromMessage({
14
+ ctx: buildTestCtx({ CommandBody: "continue the conversation", ChatType: chatType }),
15
+ cfg: {},
16
+ }),
17
+ ).resolves.toEqual({ handled: false, aborted: false });
18
+ },
19
+ );
src/auto-reply/reply/abort.runtime-types.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared abort runtime types for cancellation and cutoff persistence.
2
+ import type { OpenClawConfig } from "../../config/types.openclaw.js";
3
+ import type { FinalizedRuntimeMsgContext } from "../templating.js";
4
+
5
+ /** Result from the fast abort path before normal reply dispatch starts. */
6
+ type FastAbortResult = {
7
+ handled: boolean;
8
+ aborted: boolean;
9
+ rejectionReason?: "finalizing";
10
+ stoppedSubagents?: number;
11
+ failedSubagents?: number;
12
+ };
13
+
14
+ /** Runtime hook that may convert a message into an immediate abort action. */
15
+ export type TryFastAbortFromMessage = (params: {
16
+ ctx: FinalizedRuntimeMsgContext;
17
+ cfg: OpenClawConfig;
18
+ isCommandTargetCurrent?: () => boolean;
19
+ }) => Promise<FastAbortResult>;
20
+
21
+ /** Formats the user-visible abort acknowledgement text. */
22
+ export type FormatAbortReplyText = (
23
+ stoppedSubagents?: number,
24
+ rejectionReason?: FastAbortResult["rejectionReason"],
25
+ failedSubagents?: number,
26
+ ) => string;
src/auto-reply/reply/abort.runtime.ts ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ /** Runtime facade for fast abort handling used by reply dispatch. */
2
+ export { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
src/auto-reply/reply/abort.target-owner.test.ts ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from "node:path";
2
+ import { afterAll, afterEach, beforeAll, describe, expect, it, onTestFinished } from "vitest";
3
+ import { createDeferred } from "../../../test/helpers/promise.js";
4
+ import {
5
+ loadSessionEntry,
6
+ replaceSessionEntry,
7
+ replaceSessionEntrySync,
8
+ } from "../../config/sessions/session-accessor.js";
9
+ import {
10
+ getConversationSession,
11
+ normalizeSessionDeliveryState,
12
+ patchSessionEntry,
13
+ } from "../../plugin-sdk/session-store-runtime.js";
14
+ import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js";
15
+ import { tryFastAbortFromMessage } from "./abort.js";
16
+ import { handleStopCommand } from "./commands-session-abort.js";
17
+ import type { HandleCommandsParams } from "./commands-types.js";
18
+ import { parseInlineSessionDirectives } from "./directive-handling.parse.js";
19
+ import { clearSessionQueues, enqueueFollowupRun, getFollowupQueueDepth } from "./queue.js";
20
+ import { createQueueTestRun } from "./queue.test-helpers.js";
21
+ import { createReplyOperation } from "./reply-run-registry.js";
22
+ import { testing } from "./reply-run-registry.test-support.js";
23
+ import { buildTestCtx } from "./test-ctx.js";
24
+
25
+ const dirs = createSuiteTempRootTracker({ prefix: "openclaw-stop-owner-" });
26
+ const sessionKey = "agent:main:slack:group:g12345678";
27
+
28
+ beforeAll(() => dirs.setup());
29
+ afterAll(() => dirs.cleanup());
30
+ afterEach(() => {
31
+ clearSessionQueues([sessionKey]);
32
+ testing.resetReplyRunRegistry();
33
+ });
34
+
35
+ async function setupStop() {
36
+ const root = await dirs.make("case");
37
+ const storePath = path.join(root, "sessions.json");
38
+ const cfg = { session: { store: storePath }, commands: { allowFrom: { "*": ["*"] } } };
39
+ const entry = { sessionId: "session-a", updatedAt: Date.now() };
40
+ await replaceSessionEntry({ storePath, sessionKey }, entry);
41
+ const ctx = buildTestCtx({
42
+ Body: "/stop",
43
+ CommandBody: "/stop",
44
+ RawBody: "/stop",
45
+ Provider: "slack",
46
+ Surface: "slack",
47
+ From: "slack:U12345678",
48
+ To: "slack:G12345678",
49
+ SenderId: "U12345678",
50
+ CommandSource: "native",
51
+ CommandAuthorized: true,
52
+ SessionKey: "slack:slash:U12345678",
53
+ CommandTargetSessionKey: sessionKey,
54
+ });
55
+ const isCommandTargetCurrent = () =>
56
+ loadSessionEntry({ storePath, sessionKey })?.sessionId === entry.sessionId;
57
+ const params: HandleCommandsParams = {
58
+ cfg,
59
+ ctx,
60
+ command: {
61
+ commandBodyNormalized: "/stop",
62
+ rawBodyNormalized: "/stop",
63
+ isAuthorizedSender: true,
64
+ senderIsOwner: true,
65
+ senderId: "U12345678",
66
+ channel: "slack",
67
+ surface: "slack",
68
+ ownerList: [],
69
+ },
70
+ agentId: "main",
71
+ directives: parseInlineSessionDirectives(""),
72
+ elevated: { enabled: false, allowed: false, failures: [] },
73
+ sessionKey: ctx.SessionKey ?? "",
74
+ sessionStore: { [sessionKey]: entry },
75
+ storePath,
76
+ workspaceDir: root,
77
+ defaultGroupActivation: () => "always",
78
+ resolvedVerboseLevel: "off",
79
+ resolvedReasoningLevel: "off",
80
+ resolvedBlockStreamingBreak: "text_end",
81
+ resolveDefaultThinkingLevel: async () => undefined,
82
+ provider: "openai",
83
+ model: "gpt-test",
84
+ contextTokens: 1000,
85
+ isGroup: true,
86
+ opts: { isCommandTargetCurrent },
87
+ };
88
+ return { cfg, ctx, entry, params, storePath, isCommandTargetCurrent };
89
+ }
90
+
91
+ describe.each(["fast", "command"] as const)("%s Stop current owner", (pathKind) => {
92
+ it("retires an idle MCP runtime on explicit Stop", async () => {
93
+ const state = await setupStop();
94
+ const { getOrCreateSessionMcpRuntime } =
95
+ await import("../../agents/agent-bundle-mcp-manager.test-support.js");
96
+ const { getSessionMcpRuntimeManagerForTesting } =
97
+ await import("../../agents/agent-bundle-mcp-manager-api.js");
98
+ const manager = getSessionMcpRuntimeManagerForTesting();
99
+ try {
100
+ await getOrCreateSessionMcpRuntime({
101
+ sessionId: state.entry.sessionId,
102
+ sessionKey,
103
+ workspaceDir: state.params.workspaceDir,
104
+ cfg: { mcp: { servers: {} } },
105
+ manifestRegistry: { plugins: [] },
106
+ });
107
+ if (pathKind === "fast") {
108
+ await tryFastAbortFromMessage({
109
+ ctx: state.ctx,
110
+ cfg: state.cfg,
111
+ isCommandTargetCurrent: state.isCommandTargetCurrent,
112
+ });
113
+ } else {
114
+ await handleStopCommand(state.params, true);
115
+ }
116
+ expect(manager.peekSession({ sessionId: state.entry.sessionId })).toBeUndefined();
117
+ } finally {
118
+ await manager.disposeAll();
119
+ }
120
+ });
121
+
122
+ it("stops the selected agent's global session in an explicit roster", async () => {
123
+ const state = await setupStop();
124
+ const agentId = "selected";
125
+ const globalKey = "global";
126
+ const storePath = path.join(
127
+ state.params.workspaceDir,
128
+ "agents",
129
+ agentId,
130
+ "sessions",
131
+ "sessions.json",
132
+ );
133
+ const cfg = {
134
+ ...state.cfg,
135
+ agents: { ownership: "explicit" as const, entries: { other: {}, [agentId]: {} } },
136
+ session: {
137
+ scope: "global" as const,
138
+ store: path.join(
139
+ state.params.workspaceDir,
140
+ "agents",
141
+ "{agentId}",
142
+ "sessions",
143
+ "sessions.json",
144
+ ),
145
+ },
146
+ };
147
+ const scope = { agentId, storePath, sessionKey: globalKey };
148
+ await replaceSessionEntry(scope, state.entry);
149
+ const ctx = { ...state.ctx, AgentId: agentId, CommandTargetSessionKey: globalKey };
150
+ const isCommandTargetCurrent = () =>
151
+ loadSessionEntry(scope)?.sessionId === state.entry.sessionId;
152
+ const operation = createReplyOperation({
153
+ sessionKey: globalKey,
154
+ sessionId: state.entry.sessionId,
155
+ resetTriggered: false,
156
+ });
157
+ operation.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
158
+ try {
159
+ const result = await (pathKind === "fast"
160
+ ? tryFastAbortFromMessage({ cfg, ctx, isCommandTargetCurrent })
161
+ : handleStopCommand(
162
+ {
163
+ ...state.params,
164
+ cfg,
165
+ ctx,
166
+ agentId,
167
+ sessionKey: globalKey,
168
+ sessionStore: { [globalKey]: state.entry },
169
+ storePath,
170
+ opts: { isCommandTargetCurrent },
171
+ },
172
+ true,
173
+ ));
174
+ expect(operation.abortSignal.aborted).toBe(true);
175
+ expect(result).toMatchObject(
176
+ pathKind === "fast"
177
+ ? { handled: true, aborted: true }
178
+ : { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } },
179
+ );
180
+ expect(loadSessionEntry(scope)).toMatchObject({
181
+ sessionId: state.entry.sessionId,
182
+ abortedLastRun: true,
183
+ });
184
+ } finally {
185
+ operation.complete();
186
+ clearSessionQueues([globalKey]);
187
+ }
188
+ });
189
+
190
+ it("does not reclaim a conversation reassigned after abort preparation", async () => {
191
+ const state = await setupStop();
192
+ const nextKey = `${sessionKey}:thread:123.456`;
193
+ const address = {
194
+ agentId: "main",
195
+ storePath: state.storePath,
196
+ channel: "slack",
197
+ accountId: "default",
198
+ kind: "group" as const,
199
+ peerId: "g12345678",
200
+ threadId: "123.456",
201
+ };
202
+ const delivery = normalizeSessionDeliveryState({
203
+ context: {
204
+ channel: "slack",
205
+ accountId: "default",
206
+ to: "group:g12345678",
207
+ threadId: "123.456",
208
+ },
209
+ });
210
+ await replaceSessionEntry(
211
+ { storePath: state.storePath, sessionKey },
212
+ { ...state.entry, updatedAt: 100, chatType: "group", delivery },
213
+ );
214
+ const operation = createReplyOperation({
215
+ sessionKey,
216
+ sessionId: state.entry.sessionId,
217
+ resetTriggered: false,
218
+ });
219
+ operation.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
220
+ let reassigned = false;
221
+ state.isCommandTargetCurrent = () => {
222
+ const current = getConversationSession(address);
223
+ if (
224
+ !reassigned &&
225
+ operation.abortSignal.aborted &&
226
+ (pathKind === "fast" || state.params.sessionStore?.[sessionKey]?.abortedLastRun === true)
227
+ ) {
228
+ reassigned = true;
229
+ // A separate synchronous writer commits while abort's prepared patch yields.
230
+ queueMicrotask(() => {
231
+ replaceSessionEntrySync(
232
+ { storePath: state.storePath, sessionKey: nextKey },
233
+ { sessionId: "session-b", updatedAt: 200, chatType: "group", delivery },
234
+ );
235
+ });
236
+ }
237
+ return current?.sessionKey === sessionKey;
238
+ };
239
+ state.params.opts = { isCommandTargetCurrent: state.isCommandTargetCurrent };
240
+ const failure = await (
241
+ pathKind === "fast" ? tryFastAbortFromMessage(state) : handleStopCommand(state.params, true)
242
+ ).then(
243
+ () => undefined,
244
+ (error: unknown) => error,
245
+ );
246
+ expect(operation.abortSignal.aborted).toBe(true);
247
+ expect(reassigned).toBe(true);
248
+ expect(getConversationSession(address)).toEqual({
249
+ sessionKey: nextKey,
250
+ sessionId: "session-b",
251
+ });
252
+ expect(loadSessionEntry({ storePath: state.storePath, sessionKey })?.abortedLastRun).not.toBe(
253
+ true,
254
+ );
255
+ if (failure) {
256
+ expect(failure).toMatchObject({
257
+ message: "The selected session changed before it could be stopped.",
258
+ });
259
+ }
260
+ operation.complete();
261
+ });
262
+
263
+ it("skips stale abort bookkeeping after waiting for a replacement writer", async () => {
264
+ const state = await setupStop();
265
+ const entered = createDeferred();
266
+ const release = createDeferred();
267
+ const writer = patchSessionEntry({
268
+ storePath: state.storePath,
269
+ sessionKey,
270
+ update: async () => {
271
+ entered.resolve();
272
+ await release.promise;
273
+ return { sessionId: "session-b", updatedAt: Date.now() };
274
+ },
275
+ });
276
+ await entered.promise;
277
+ const operation = createReplyOperation({
278
+ sessionKey,
279
+ sessionId: "session-a",
280
+ resetTriggered: false,
281
+ });
282
+ operation.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
283
+ const aborted = createDeferred();
284
+ const onAbort = () => aborted.resolve();
285
+ operation.abortSignal.addEventListener("abort", onAbort, { once: true });
286
+ const stopping =
287
+ pathKind === "fast" ? tryFastAbortFromMessage(state) : handleStopCommand(state.params, true);
288
+ onTestFinished(async () => {
289
+ operation.abortSignal.removeEventListener("abort", onAbort);
290
+ release.resolve();
291
+ await Promise.allSettled([writer, stopping]);
292
+ operation.complete();
293
+ });
294
+ void stopping.then(
295
+ () => {
296
+ if (!operation.abortSignal.aborted) {
297
+ aborted.reject(new Error("Stop completed without aborting the active operation"));
298
+ }
299
+ },
300
+ (error: unknown) => aborted.reject(error),
301
+ );
302
+ await aborted.promise;
303
+ expect(operation.abortSignal.aborted).toBe(true);
304
+ release.resolve();
305
+ await writer;
306
+ await stopping;
307
+ expect(loadSessionEntry({ storePath: state.storePath, sessionKey })).toMatchObject({
308
+ sessionId: "session-b",
309
+ });
310
+ expect(loadSessionEntry({ storePath: state.storePath, sessionKey })?.abortedLastRun).toBe(
311
+ false,
312
+ );
313
+ });
314
+
315
+ it("completes cancellation when its own abort releases the live publisher", async () => {
316
+ const state = await setupStop();
317
+ const operation = createReplyOperation({
318
+ sessionKey,
319
+ sessionId: "session-a",
320
+ resetTriggered: false,
321
+ });
322
+ operation.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
323
+ state.isCommandTargetCurrent = () => !operation.abortSignal.aborted;
324
+ state.params.opts = { isCommandTargetCurrent: state.isCommandTargetCurrent };
325
+ const result = await (pathKind === "fast"
326
+ ? tryFastAbortFromMessage(state)
327
+ : handleStopCommand(state.params, true));
328
+ expect(operation.abortSignal.aborted).toBe(true);
329
+ expect(result).toMatchObject(
330
+ pathKind === "fast" ? { handled: true, aborted: true } : { shouldContinue: false },
331
+ );
332
+ expect(
333
+ loadSessionEntry({ storePath: state.storePath, sessionKey })?.abortedLastRun,
334
+ ).toBeUndefined();
335
+ operation.complete();
336
+ });
337
+
338
+ it("preserves a replacement run and its queued input after dispatch handoff", async () => {
339
+ const state = await setupStop();
340
+ const admitted = createReplyOperation({
341
+ sessionKey,
342
+ sessionId: "session-a",
343
+ resetTriggered: false,
344
+ });
345
+ const dispatch = createDeferred();
346
+ expect(state.isCommandTargetCurrent()).toBe(true);
347
+ const pending = dispatch.promise.then(async () =>
348
+ pathKind === "fast" ? tryFastAbortFromMessage(state) : handleStopCommand(state.params, true),
349
+ );
350
+ admitted.complete();
351
+ await replaceSessionEntry(
352
+ { storePath: state.storePath, sessionKey },
353
+ {
354
+ sessionId: "session-b",
355
+ updatedAt: Date.now(),
356
+ },
357
+ );
358
+ const replacement = createReplyOperation({
359
+ sessionKey,
360
+ sessionId: "session-b",
361
+ resetTriggered: false,
362
+ });
363
+ replacement.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
364
+ enqueueFollowupRun(
365
+ sessionKey,
366
+ createQueueTestRun({ prompt: "next conversation" }),
367
+ { mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
368
+ "none",
369
+ );
370
+ dispatch.resolve();
371
+ const result = await pending.then(
372
+ () => undefined,
373
+ (error: unknown) => error,
374
+ );
375
+ expect(replacement.abortSignal.aborted).toBe(false);
376
+ expect(result).toBeInstanceOf(Error);
377
+ expect(getFollowupQueueDepth(sessionKey)).toBe(1);
378
+ expect(
379
+ loadSessionEntry({ storePath: state.storePath, sessionKey })?.abortedLastRun,
380
+ ).toBeUndefined();
381
+ replacement.complete();
382
+ });
383
+ });
src/auto-reply/reply/abort.test.ts ADDED
@@ -0,0 +1,1559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tests abort request handling, cutoff persistence, and active run cleanup.
2
+ import path from "node:path";
3
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { createDeferred } from "../../../test/helpers/promise.js";
5
+ import { registerSubagentRun } from "../../agents/subagents/registry/subagent-registry.js";
6
+ import { settleSubagentRegistryPersistenceWork } from "../../agents/subagents/registry/subagent-registry.persistence.test-support.js";
7
+ import {
8
+ addSubagentRunForTests,
9
+ getSubagentRunByChildSessionKey,
10
+ resetSubagentRegistryForTests,
11
+ testing as subagentRegistryTesting,
12
+ } from "../../agents/subagents/registry/subagent-registry.test-helpers.js";
13
+ import type { OpenClawConfig } from "../../config/config.js";
14
+ import {
15
+ loadSessionEntry,
16
+ markSessionAbortTarget,
17
+ replaceSessionEntry,
18
+ resolveSessionAbortTarget,
19
+ type SessionAbortTargetResult,
20
+ } from "../../config/sessions/session-accessor.js";
21
+ import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
22
+ import { createSuiteTempRootTracker } from "../../test-helpers/temp-dir.js";
23
+ import { resolveAbortCutoffFromContext, shouldSkipMessageByAbortCutoff } from "./abort-cutoff.js";
24
+ import { stopSubagentsForRequester } from "./abort-operation.js";
25
+ import {
26
+ getAbortMemory,
27
+ isAbortRequestText,
28
+ isAbortTrigger,
29
+ setAbortMemory,
30
+ } from "./abort-primitives.js";
31
+ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
32
+ import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./queue.js";
33
+ import { clearFollowupQueue } from "./queue/state.js";
34
+ import { createReplyOperation, replyRunRegistry } from "./reply-run-registry.js";
35
+ import { testing as replyRunRegistryTesting } from "./reply-run-registry.test-support.js";
36
+ import { buildTestCtx } from "./test-ctx.js";
37
+
38
+ type SubagentRunFixture = Parameters<typeof addSubagentRunForTests>[0];
39
+
40
+ function addSubagentFixture(run: SubagentRunFixture) {
41
+ addSubagentRunForTests({ requesterAgentId: "main", ...run });
42
+ }
43
+
44
+ type AbortEmbeddedAgentRunOptions = Parameters<
45
+ typeof import("../../agents/embedded-agent-runner/runs.js").abortEmbeddedAgentRun
46
+ >[1];
47
+
48
+ vi.mock("../../agents/embedded-agent.js", () => ({
49
+ abortEmbeddedAgentRun: vi.fn().mockReturnValue(true),
50
+ resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
51
+ }));
52
+
53
+ const commandQueueMocks = vi.hoisted(() => ({
54
+ clearCommandLane: vi.fn(() => 1),
55
+ }));
56
+
57
+ vi.mock("../../process/command-queue.js", () => commandQueueMocks);
58
+
59
+ const acpManagerMocks = vi.hoisted(() => ({
60
+ resolveSession: vi.fn<
61
+ () =>
62
+ | { kind: "none" }
63
+ | {
64
+ kind: "ready";
65
+ sessionKey: string;
66
+ meta: unknown;
67
+ }
68
+ >(() => ({ kind: "none" })),
69
+ cancelSession: vi.fn(async () => {}),
70
+ }));
71
+
72
+ const runtimeAbortMocks = vi.hoisted(() => ({
73
+ abortEmbeddedAgentRun: vi.fn<
74
+ (sessionId: string | undefined, opts?: AbortEmbeddedAgentRunOptions) => boolean
75
+ >(() => true),
76
+ resolveActiveEmbeddedRunSessionId: vi.fn(() => undefined as string | undefined),
77
+ isEmbeddedAgentRunActive: vi.fn(() => false),
78
+ }));
79
+
80
+ vi.mock("../../agents/embedded-agent-runner/runs.js", () => ({
81
+ abortEmbeddedAgentRun: runtimeAbortMocks.abortEmbeddedAgentRun,
82
+ isEmbeddedAgentRunActive: runtimeAbortMocks.isEmbeddedAgentRunActive,
83
+ }));
84
+ vi.mock("../../agents/embedded-agent-runner/active-run-projections.js", () => ({
85
+ resolveActiveEmbeddedRunSessionId: runtimeAbortMocks.resolveActiveEmbeddedRunSessionId,
86
+ }));
87
+ vi.mock("../../config/sessions/session-accessor.js", async (importOriginal) => {
88
+ const actual = await importOriginal<typeof import("../../config/sessions/session-accessor.js")>();
89
+ return {
90
+ ...actual,
91
+ markSessionAbortTarget: vi.fn(actual.markSessionAbortTarget),
92
+ resolveSessionAbortTarget: vi.fn(actual.resolveSessionAbortTarget),
93
+ };
94
+ });
95
+
96
+ vi.mock("../../acp/control-plane/manager.js", () => ({
97
+ getAcpSessionManager: () => ({
98
+ resolveSession: acpManagerMocks.resolveSession,
99
+ cancelSession: acpManagerMocks.cancelSession,
100
+ }),
101
+ }));
102
+
103
+ const suiteTempDirs = createSuiteTempRootTracker({ prefix: "openclaw-abort-" });
104
+
105
+ describe("abort detection", () => {
106
+ const trackedAbortMemoryKeys = new Set<string>();
107
+
108
+ function setTrackedAbortMemory(key: string, value: boolean): void {
109
+ trackedAbortMemoryKeys.add(key);
110
+ setAbortMemory(key, value);
111
+ }
112
+
113
+ beforeAll(async () => {
114
+ await suiteTempDirs.setup();
115
+ });
116
+
117
+ afterAll(async () => {
118
+ await suiteTempDirs.cleanup();
119
+ });
120
+
121
+ async function writeSessionStore(
122
+ storePath: string,
123
+ sessionIdsByKey: Record<string, string>,
124
+ nowMs = Date.now(),
125
+ ) {
126
+ await Promise.all(
127
+ Object.entries(sessionIdsByKey).map(([sessionKey, sessionId]) =>
128
+ replaceSessionEntry({ storePath, sessionKey }, { sessionId, updatedAt: nowMs }),
129
+ ),
130
+ );
131
+ }
132
+
133
+ function readAbortSessionEntry(storePath: string, sessionKey: string) {
134
+ return loadSessionEntry({ storePath, sessionKey });
135
+ }
136
+
137
+ async function createAbortConfig(params?: {
138
+ commandsTextEnabled?: boolean;
139
+ sessionIdsByKey?: Record<string, string>;
140
+ nowMs?: number;
141
+ }) {
142
+ const root = await suiteTempDirs.make("case");
143
+ const storePath = path.join(root, "sessions.json");
144
+ const cfg = {
145
+ session: { store: storePath },
146
+ ...(typeof params?.commandsTextEnabled === "boolean"
147
+ ? { commands: { text: params.commandsTextEnabled } }
148
+ : {}),
149
+ } as OpenClawConfig;
150
+ if (params?.sessionIdsByKey) {
151
+ for (const sessionKey of Object.keys(params.sessionIdsByKey)) {
152
+ trackedAbortMemoryKeys.add(sessionKey);
153
+ }
154
+ await writeSessionStore(storePath, params.sessionIdsByKey, params.nowMs);
155
+ }
156
+ return { root, storePath, cfg };
157
+ }
158
+
159
+ async function runStopCommand(params: {
160
+ cfg: OpenClawConfig;
161
+ sessionKey?: string;
162
+ parentSessionKey?: string;
163
+ from: string;
164
+ to: string;
165
+ senderId?: string;
166
+ commandSource?: "native" | "text";
167
+ targetSessionKey?: string;
168
+ messageSid?: string;
169
+ timestamp?: number;
170
+ }) {
171
+ for (const key of [
172
+ params.sessionKey,
173
+ params.parentSessionKey,
174
+ params.targetSessionKey,
175
+ params.from,
176
+ params.to,
177
+ ]) {
178
+ if (key) {
179
+ trackedAbortMemoryKeys.add(key);
180
+ }
181
+ }
182
+ return tryFastAbortFromMessage({
183
+ ctx: buildTestCtx({
184
+ CommandBody: "/stop",
185
+ RawBody: "/stop",
186
+ CommandAuthorized: true,
187
+ Provider: "telegram",
188
+ Surface: "telegram",
189
+ From: params.from,
190
+ To: params.to,
191
+ ...(params.sessionKey ? { SessionKey: params.sessionKey } : {}),
192
+ ...(params.parentSessionKey ? { ParentSessionKey: params.parentSessionKey } : {}),
193
+ ...(params.senderId ? { SenderId: params.senderId } : {}),
194
+ ...(params.commandSource ? { CommandSource: params.commandSource } : {}),
195
+ ...(params.targetSessionKey ? { CommandTargetSessionKey: params.targetSessionKey } : {}),
196
+ ...(params.messageSid ? { MessageSid: params.messageSid } : {}),
197
+ ...(typeof params.timestamp === "number" ? { Timestamp: params.timestamp } : {}),
198
+ }),
199
+ cfg: params.cfg,
200
+ });
201
+ }
202
+
203
+ function enqueueQueuedFollowupRun(params: {
204
+ root: string;
205
+ cfg: OpenClawConfig;
206
+ sessionId: string;
207
+ sessionKey: string;
208
+ }) {
209
+ trackedAbortMemoryKeys.add(params.sessionKey);
210
+ const followupRun: FollowupRun = {
211
+ prompt: "queued",
212
+ enqueuedAt: Date.now(),
213
+ run: {
214
+ agentId: "main",
215
+ agentDir: path.join(params.root, "agent"),
216
+ sessionId: params.sessionId,
217
+ sessionKey: params.sessionKey,
218
+ messageProvider: "telegram",
219
+ agentAccountId: "acct",
220
+ sessionFile: path.join(params.root, "session.jsonl"),
221
+ workspaceDir: path.join(params.root, "workspace"),
222
+ config: params.cfg,
223
+ provider: "anthropic",
224
+ model: "claude-opus-4-6",
225
+ timeoutMs: 1000,
226
+ blockReplyBreak: "text_end",
227
+ },
228
+ };
229
+ enqueueFollowupRun(
230
+ params.sessionKey,
231
+ followupRun,
232
+ { mode: "collect", debounceMs: 0, cap: 20, dropPolicy: "summarize" },
233
+ "none",
234
+ );
235
+ }
236
+
237
+ function expectSessionLaneCleared(sessionKey: string) {
238
+ expect(commandQueueMocks.clearCommandLane).toHaveBeenCalledWith(`session:${sessionKey}`);
239
+ }
240
+
241
+ function bindAcpSessionForTest(targetSessionKey: string) {
242
+ vi.spyOn(getSessionBindingService(), "resolveByConversation").mockImplementation(
243
+ (conversation) => ({
244
+ bindingId: "test-acp-binding",
245
+ targetKind: "session",
246
+ targetSessionKey,
247
+ conversation,
248
+ status: "active",
249
+ boundAt: 0,
250
+ }),
251
+ );
252
+ }
253
+
254
+ beforeEach(() => {
255
+ subagentRegistryTesting.setDepsForTest({
256
+ persistSubagentRunsToDisk: () => {},
257
+ persistSubagentRunsToDiskOrThrow: () => {},
258
+ restoreSubagentRunsFromDisk: () => 0,
259
+ cleanupBrowserSessionsForLifecycleEnd: async () => {},
260
+ ensureContextEnginesInitialized: () => {},
261
+ loadAgentRuntimePluginRegistryHandle: () => undefined,
262
+ });
263
+ commandQueueMocks.clearCommandLane.mockClear().mockReturnValue(1);
264
+ });
265
+
266
+ afterEach(async () => {
267
+ for (const key of trackedAbortMemoryKeys) {
268
+ setAbortMemory(key, false);
269
+ clearFollowupQueue(key);
270
+ }
271
+ trackedAbortMemoryKeys.clear();
272
+ vi.restoreAllMocks();
273
+ vi.mocked(markSessionAbortTarget).mockReset();
274
+ vi.mocked(resolveSessionAbortTarget).mockReset();
275
+ replyRunRegistryTesting.resetReplyRunRegistry();
276
+ commandQueueMocks.clearCommandLane.mockClear().mockReturnValue(1);
277
+ acpManagerMocks.resolveSession.mockReset().mockReturnValue({ kind: "none" });
278
+ acpManagerMocks.cancelSession.mockReset().mockResolvedValue(undefined);
279
+ runtimeAbortMocks.abortEmbeddedAgentRun.mockReset().mockReturnValue(true);
280
+ runtimeAbortMocks.resolveActiveEmbeddedRunSessionId.mockReset().mockReturnValue(undefined);
281
+ await settleSubagentRegistryPersistenceWork();
282
+ resetSubagentRegistryForTests({ persist: false });
283
+ subagentRegistryTesting.setDepsForTest();
284
+ });
285
+
286
+ it("isAbortTrigger matches standalone abort trigger phrases", () => {
287
+ const positives = [
288
+ "stop",
289
+ "esc",
290
+ "abort",
291
+ "exit",
292
+ "interrupt",
293
+ "stop openclaw",
294
+ "openclaw stop",
295
+ "stop action",
296
+ "stop current action",
297
+ "stop run",
298
+ "stop current run",
299
+ "stop agent",
300
+ "stop the agent",
301
+ "stop don't do anything",
302
+ "stop dont do anything",
303
+ "stop do not do anything",
304
+ "stop doing anything",
305
+ "do not do that",
306
+ "please stop",
307
+ "stop please",
308
+ "STOP OPENCLAW",
309
+ "stop openclaw!!!",
310
+ "stop don’t do anything",
311
+ "detente",
312
+ "detén",
313
+ "arrête",
314
+ "停止",
315
+ "停下来",
316
+ "暂停",
317
+ "停下来!",
318
+ "やめて",
319
+ "止めて",
320
+ "रुको",
321
+ "توقف",
322
+ "стоп",
323
+ "остановись",
324
+ "останови",
325
+ "остановить",
326
+ "прекрати",
327
+ "halt",
328
+ "anhalten",
329
+ "aufhören",
330
+ "hoer auf",
331
+ "stopp",
332
+ "pare",
333
+ ];
334
+ for (const candidate of positives) {
335
+ expect(isAbortTrigger(candidate)).toBe(true);
336
+ }
337
+
338
+ expect(isAbortTrigger("hello")).toBe(false);
339
+ expect(isAbortTrigger("wait")).toBe(false);
340
+ expect(isAbortTrigger("please wait")).toBe(false);
341
+ expect(isAbortTrigger("please do not do that")).toBe(false);
342
+ // /stop is NOT matched by isAbortTrigger - it's handled separately.
343
+ expect(isAbortTrigger("/stop")).toBe(false);
344
+ });
345
+
346
+ it("isAbortRequestText aligns abort command semantics", () => {
347
+ expect(isAbortRequestText("/stop")).toBe(true);
348
+ expect(isAbortRequestText("/STOP")).toBe(true);
349
+ expect(isAbortRequestText("/stop!!!")).toBe(true);
350
+ expect(isAbortRequestText("/Stop!!!")).toBe(true);
351
+ expect(isAbortRequestText("stop")).toBe(true);
352
+ expect(isAbortRequestText("Stop")).toBe(true);
353
+ expect(isAbortRequestText("STOP")).toBe(true);
354
+ expect(isAbortRequestText("stop action")).toBe(true);
355
+ expect(isAbortRequestText("stop openclaw!!!")).toBe(true);
356
+ expect(isAbortRequestText("停下来")).toBe(true);
357
+ expect(isAbortRequestText("暂停")).toBe(true);
358
+ expect(isAbortRequestText("やめて")).toBe(true);
359
+ expect(isAbortRequestText("остановись")).toBe(true);
360
+ expect(isAbortRequestText("halt")).toBe(true);
361
+ expect(isAbortRequestText("stopp")).toBe(true);
362
+ expect(isAbortRequestText("pare")).toBe(true);
363
+ expect(isAbortRequestText(" توقف ")).toBe(true);
364
+ expect(isAbortRequestText("/stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true);
365
+ expect(isAbortRequestText("/Stop@openclaw_bot", { botUsername: "openclaw_bot" })).toBe(true);
366
+ expect(
367
+ isAbortRequestText("/stop@unresolved_bot", {
368
+ targetedCommandMode: "pre-identity",
369
+ }),
370
+ ).toBe(true);
371
+ expect(
372
+ isAbortRequestText("/stop@unresolved_bot!", {
373
+ targetedCommandMode: "pre-identity",
374
+ }),
375
+ ).toBe(true);
376
+ expect(
377
+ isAbortRequestText("/queue@unresolved_bot", {
378
+ targetedCommandMode: "pre-identity",
379
+ }),
380
+ ).toBe(false);
381
+ expect(
382
+ isAbortRequestText("/stop@some_other_bot", {
383
+ botUsername: "openclaw_bot",
384
+ targetedCommandMode: "pre-identity",
385
+ }),
386
+ ).toBe(false);
387
+
388
+ expect(isAbortRequestText("/status")).toBe(false);
389
+ expect(isAbortRequestText("wait")).toBe(false);
390
+ expect(isAbortRequestText("please wait")).toBe(false);
391
+ expect(isAbortRequestText("do not do that")).toBe(true);
392
+ expect(isAbortRequestText("please do not do that")).toBe(false);
393
+ expect(isAbortRequestText("/abort")).toBe(false);
394
+ });
395
+
396
+ it("removes abort memory entry when flag is reset", () => {
397
+ setTrackedAbortMemory("session-1", true);
398
+ expect(getAbortMemory("session-1")).toBe(true);
399
+
400
+ setTrackedAbortMemory("session-1", false);
401
+ expect(getAbortMemory("session-1")).toBeUndefined();
402
+ });
403
+
404
+ it("caps abort memory tracking to a bounded max size", () => {
405
+ for (let i = 0; i < 2105; i += 1) {
406
+ setTrackedAbortMemory(`bounded-memory-session-${i}`, true);
407
+ }
408
+ expect(getAbortMemory("bounded-memory-session-0")).toBeUndefined();
409
+ expect(getAbortMemory("bounded-memory-session-2104")).toBe(true);
410
+ });
411
+
412
+ it("extracts abort cutoff metadata from context", () => {
413
+ expect(
414
+ resolveAbortCutoffFromContext(
415
+ buildTestCtx({
416
+ MessageSid: "42",
417
+ Timestamp: 123,
418
+ }),
419
+ ),
420
+ ).toEqual({
421
+ messageSid: "42",
422
+ timestamp: 123,
423
+ });
424
+ });
425
+
426
+ it("treats numeric message IDs at or before cutoff as stale", () => {
427
+ expect(
428
+ shouldSkipMessageByAbortCutoff({
429
+ cutoffMessageSid: "200",
430
+ messageSid: "199",
431
+ }),
432
+ ).toBe(true);
433
+ expect(
434
+ shouldSkipMessageByAbortCutoff({
435
+ cutoffMessageSid: "200",
436
+ messageSid: "200",
437
+ }),
438
+ ).toBe(true);
439
+ expect(
440
+ shouldSkipMessageByAbortCutoff({
441
+ cutoffMessageSid: "200",
442
+ messageSid: "201",
443
+ }),
444
+ ).toBe(false);
445
+ });
446
+
447
+ it("falls back to timestamp cutoff when message IDs are unavailable", () => {
448
+ expect(
449
+ shouldSkipMessageByAbortCutoff({
450
+ cutoffTimestamp: 2000,
451
+ timestamp: 1999,
452
+ }),
453
+ ).toBe(true);
454
+ expect(
455
+ shouldSkipMessageByAbortCutoff({
456
+ cutoffTimestamp: 2000,
457
+ timestamp: 2000,
458
+ }),
459
+ ).toBe(true);
460
+ expect(
461
+ shouldSkipMessageByAbortCutoff({
462
+ cutoffTimestamp: 2000,
463
+ timestamp: 2001,
464
+ }),
465
+ ).toBe(false);
466
+ });
467
+
468
+ it("fast-aborts even when text commands are disabled", async () => {
469
+ const { cfg } = await createAbortConfig({ commandsTextEnabled: false });
470
+
471
+ const result = await runStopCommand({
472
+ cfg,
473
+ sessionKey: "telegram:123",
474
+ from: "telegram:123",
475
+ to: "telegram:123",
476
+ });
477
+
478
+ expect(result.handled).toBe(true);
479
+ });
480
+
481
+ it("resolves owner authorization after loading cancellation runtime", async () => {
482
+ const sessionKey = "telegram:123";
483
+ const sessionId = "session-123";
484
+ const { root, cfg } = await createAbortConfig({
485
+ sessionIdsByKey: { [sessionKey]: sessionId },
486
+ });
487
+ cfg.commands = { ownerAllowFrom: ["telegram:123"] };
488
+ enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
489
+ const pending = runStopCommand({
490
+ cfg,
491
+ sessionKey,
492
+ from: "telegram:123",
493
+ to: "telegram:123",
494
+ senderId: "123",
495
+ });
496
+ cfg.commands.ownerAllowFrom = ["telegram:other-owner"];
497
+
498
+ await expect(pending).resolves.toEqual({ handled: false, aborted: false });
499
+ expect(getFollowupQueueDepth(sessionKey)).toBe(1);
500
+ });
501
+
502
+ it("fast-aborts authorized text slash stop commands before they queue", async () => {
503
+ const sessionKey = "telegram:123";
504
+ const sessionId = "session-123";
505
+ const activeSessionId = "session-active";
506
+ const { root, cfg } = await createAbortConfig({
507
+ sessionIdsByKey: { [sessionKey]: sessionId },
508
+ });
509
+ cfg.commands = {
510
+ ...cfg.commands,
511
+ ownerAllowFrom: ["telegram:123"],
512
+ };
513
+ runtimeAbortMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(activeSessionId);
514
+ enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
515
+ expect(getFollowupQueueDepth(sessionKey)).toBe(1);
516
+
517
+ const result = await runStopCommand({
518
+ cfg,
519
+ sessionKey,
520
+ from: "telegram:123",
521
+ to: "telegram:123",
522
+ senderId: "123",
523
+ commandSource: "text",
524
+ });
525
+
526
+ expect(result.handled).toBe(true);
527
+ expect(runtimeAbortMocks.resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith(sessionKey);
528
+ expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(activeSessionId);
529
+ expect(getFollowupQueueDepth(sessionKey)).toBe(0);
530
+ expectSessionLaneCleared(sessionKey);
531
+ });
532
+
533
+ it("fast-abort clears queued followups and session lane", async () => {
534
+ const sessionKey = "telegram:123";
535
+ const sessionId = "session-123";
536
+ const { root, cfg } = await createAbortConfig({
537
+ sessionIdsByKey: { [sessionKey]: sessionId },
538
+ });
539
+ enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
540
+ expect(getFollowupQueueDepth(sessionKey)).toBe(1);
541
+
542
+ const result = await runStopCommand({
543
+ cfg,
544
+ sessionKey,
545
+ from: "telegram:123",
546
+ to: "telegram:123",
547
+ });
548
+
549
+ expect(result.handled).toBe(true);
550
+ expect(getFollowupQueueDepth(sessionKey)).toBe(0);
551
+ expectSessionLaneCleared(sessionKey);
552
+ });
553
+
554
+ it("fast-abort resolves canonical stored session identity before metadata persistence", async () => {
555
+ const storeKey = "agent:main:telegram:group:-1001234567890:topic:99";
556
+ const lookupKey = "Agent:Main:Telegram:Group:-1001234567890:Topic:99";
557
+ const sessionId = "agent-topic-99";
558
+ const { root, cfg } = await createAbortConfig({
559
+ sessionIdsByKey: { [storeKey]: sessionId },
560
+ });
561
+ enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey: storeKey });
562
+
563
+ const result = await runStopCommand({
564
+ cfg,
565
+ sessionKey: lookupKey,
566
+ from: "telegram:123",
567
+ to: "telegram:123",
568
+ });
569
+
570
+ expect(result.handled).toBe(true);
571
+ expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(sessionId);
572
+ expect(getFollowupQueueDepth(storeKey)).toBe(0);
573
+ expectSessionLaneCleared(storeKey);
574
+ });
575
+
576
+ it("fast-abort still stops active runs when abort metadata persistence fails", async () => {
577
+ const sessionKey = "telegram:persistence-failure";
578
+ const sessionId = "session-persistence-failure";
579
+ const activeSessionId = "active-persistence-failure";
580
+ const { root, cfg } = await createAbortConfig({
581
+ sessionIdsByKey: { [sessionKey]: sessionId },
582
+ });
583
+ runtimeAbortMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(activeSessionId);
584
+ vi.mocked(markSessionAbortTarget).mockRejectedValueOnce(
585
+ new Error("simulated persistence failure"),
586
+ );
587
+ enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
588
+
589
+ const result = await runStopCommand({
590
+ cfg,
591
+ sessionKey,
592
+ from: "telegram:123",
593
+ to: "telegram:123",
594
+ });
595
+
596
+ expect(result.handled).toBe(true);
597
+ expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(activeSessionId);
598
+ expect(getFollowupQueueDepth(sessionKey)).toBe(0);
599
+ expectSessionLaneCleared(sessionKey);
600
+ expect(getAbortMemory(sessionKey)).toBeUndefined();
601
+ });
602
+
603
+ it("fast-abort uses resolved target identity when abort metadata save fails", async () => {
604
+ const requestedKey = "Agent:Main:Telegram:Group:-1001234567890:Topic:99";
605
+ const canonicalKey = "agent:main:telegram:group:-1001234567890:topic:99";
606
+ const sessionId = "resolved-persistence-failure";
607
+ const { root, cfg } = await createAbortConfig();
608
+ vi.mocked(markSessionAbortTarget).mockResolvedValueOnce({
609
+ entry: {
610
+ sessionId,
611
+ updatedAt: 10,
612
+ },
613
+ persisted: false,
614
+ persistenceError: "simulated persistence failure",
615
+ sessionId,
616
+ sessionKey: canonicalKey,
617
+ });
618
+ vi.mocked(resolveSessionAbortTarget).mockReturnValueOnce({
619
+ entry: {
620
+ sessionId,
621
+ updatedAt: 10,
622
+ },
623
+ sessionId,
624
+ sessionKey: canonicalKey,
625
+ });
626
+ enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey: canonicalKey });
627
+
628
+ const result = await runStopCommand({
629
+ cfg,
630
+ sessionKey: requestedKey,
631
+ from: "telegram:123",
632
+ to: "telegram:123",
633
+ });
634
+
635
+ expect(result.handled).toBe(true);
636
+ expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(sessionId);
637
+ expect(getFollowupQueueDepth(canonicalKey)).toBe(0);
638
+ expectSessionLaneCleared(canonicalKey);
639
+ expect(getAbortMemory(canonicalKey)).toBeUndefined();
640
+ });
641
+
642
+ it("fast-abort uses abort memory when no persisted target entry exists", async () => {
643
+ const sessionKey = "telegram:missing-persistence-target";
644
+ const { cfg } = await createAbortConfig();
645
+ vi.mocked(markSessionAbortTarget).mockResolvedValueOnce(null);
646
+ vi.mocked(resolveSessionAbortTarget).mockReturnValueOnce(null);
647
+
648
+ const result = await runStopCommand({
649
+ cfg,
650
+ sessionKey,
651
+ from: "telegram:123",
652
+ to: "telegram:123",
653
+ });
654
+
655
+ expect(result.handled).toBe(true);
656
+ expect(getAbortMemory(sessionKey)).toBe(true);
657
+ });
658
+
659
+ it("fast-abort does not wait for abort metadata persistence before stopping runs", async () => {
660
+ const sessionKey = "telegram:slow-persistence";
661
+ const childKey = "agent:main:subagent:slow-persistence-child";
662
+ const sessionId = "session-slow-persistence";
663
+ const childSessionId = "session-slow-persistence-child";
664
+ const { root, cfg } = await createAbortConfig({
665
+ sessionIdsByKey: {
666
+ [childKey]: childSessionId,
667
+ [sessionKey]: sessionId,
668
+ },
669
+ });
670
+ let finishPersistence: (() => void) | undefined;
671
+ const persistenceStarted = new Promise<void>((resolveStarted) => {
672
+ vi.mocked(markSessionAbortTarget).mockImplementationOnce(
673
+ () =>
674
+ new Promise<SessionAbortTargetResult | null>((resolvePersistence) => {
675
+ resolveStarted();
676
+ finishPersistence = () => {
677
+ resolvePersistence({
678
+ entry: {
679
+ sessionId,
680
+ updatedAt: 10,
681
+ },
682
+ persisted: true,
683
+ sessionId,
684
+ sessionKey,
685
+ });
686
+ };
687
+ }),
688
+ );
689
+ vi.mocked(resolveSessionAbortTarget).mockReturnValueOnce({
690
+ entry: {
691
+ sessionId,
692
+ updatedAt: 10,
693
+ },
694
+ sessionId,
695
+ sessionKey,
696
+ });
697
+ });
698
+ enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
699
+ addSubagentFixture({
700
+ runId: "slow-child-run",
701
+ childSessionKey: childKey,
702
+ requesterSessionKey: sessionKey,
703
+ requesterDisplayKey: sessionKey,
704
+ task: "slow child",
705
+ cleanup: "keep",
706
+ createdAt: Date.now(),
707
+ });
708
+
709
+ const resultPromise = runStopCommand({
710
+ cfg,
711
+ sessionKey,
712
+ from: "telegram:123",
713
+ to: "telegram:123",
714
+ });
715
+ await persistenceStarted;
716
+
717
+ expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(sessionId);
718
+ expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith(childSessionId);
719
+ expect(getSubagentRunByChildSessionKey(childKey)).toMatchObject({
720
+ endedReason: "subagent-killed",
721
+ killReconciliation: { suppressTaskDelivery: true },
722
+ });
723
+ expect(getFollowupQueueDepth(sessionKey)).toBe(0);
724
+ expectSessionLaneCleared(sessionKey);
725
+
726
+ finishPersistence?.();
727
+ await expect(resultPromise).resolves.toMatchObject({
728
+ aborted: true,
729
+ handled: true,
730
+ });
731
+ });
732
+
733
+ it("plain-language stop on ACP-bound session triggers ACP cancel", async () => {
734
+ const sessionKey = "agent:codex:acp:test-1";
735
+ const sessionId = "session-123";
736
+ const { cfg } = await createAbortConfig({
737
+ sessionIdsByKey: { [sessionKey]: sessionId },
738
+ });
739
+ acpManagerMocks.resolveSession.mockReturnValue({
740
+ kind: "ready",
741
+ sessionKey,
742
+ meta: {} as never,
743
+ });
744
+
745
+ const result = await runStopCommand({
746
+ cfg,
747
+ sessionKey,
748
+ from: "telegram:123",
749
+ to: "telegram:123",
750
+ targetSessionKey: sessionKey,
751
+ });
752
+
753
+ expect(result.handled).toBe(true);
754
+ expect(acpManagerMocks.cancelSession).toHaveBeenCalledWith({
755
+ cfg,
756
+ sessionKey,
757
+ reason: "fast-abort",
758
+ });
759
+ });
760
+
761
+ it("ACP cancel failures do not skip queue and lane cleanup", async () => {
762
+ const sessionKey = "agent:codex:acp:test-2";
763
+ const sessionId = "session-456";
764
+ const { root, cfg } = await createAbortConfig({
765
+ sessionIdsByKey: { [sessionKey]: sessionId },
766
+ });
767
+ enqueueQueuedFollowupRun({ root, cfg, sessionId, sessionKey });
768
+ acpManagerMocks.resolveSession.mockReturnValue({
769
+ kind: "ready",
770
+ sessionKey,
771
+ meta: {} as never,
772
+ });
773
+ acpManagerMocks.cancelSession.mockRejectedValueOnce(new Error("cancel failed"));
774
+
775
+ const result = await runStopCommand({
776
+ cfg,
777
+ sessionKey,
778
+ from: "telegram:123",
779
+ to: "telegram:123",
780
+ targetSessionKey: sessionKey,
781
+ });
782
+
783
+ expect(result.handled).toBe(true);
784
+ expect(getFollowupQueueDepth(sessionKey)).toBe(0);
785
+ expectSessionLaneCleared(sessionKey);
786
+ });
787
+
788
+ it("signals the native parent before deferred ACP cancellation and never retargets its replacement", async () => {
789
+ const sessionKey = "agent:main:discord:channel:deferred-acp";
790
+ const acpKey = "agent:main:acp:deferred-acp";
791
+ const { root, cfg } = await createAbortConfig({
792
+ sessionIdsByKey: { [sessionKey]: "native-session", [acpKey]: "acp-session" },
793
+ });
794
+ const native = createReplyOperation({
795
+ sessionKey,
796
+ sessionId: "native-session",
797
+ resetTriggered: false,
798
+ });
799
+ native.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
800
+ enqueueQueuedFollowupRun({ root, cfg, sessionId: "native-session", sessionKey });
801
+ bindAcpSessionForTest(acpKey);
802
+ acpManagerMocks.resolveSession.mockReturnValue({ kind: "ready", sessionKey: acpKey, meta: {} });
803
+ const entered = createDeferred();
804
+ const proceed = createDeferred();
805
+ acpManagerMocks.cancelSession.mockImplementationOnce(async () => {
806
+ entered.resolve();
807
+ await proceed.promise;
808
+ });
809
+ const pending = runStopCommand({
810
+ cfg,
811
+ sessionKey,
812
+ from: "discord:deferred-acp",
813
+ to: "discord:deferred-acp",
814
+ });
815
+ let replacement: ReturnType<typeof createReplyOperation> | undefined;
816
+ try {
817
+ await entered.promise;
818
+ const signaledBeforeAcpWait = native.abortSignal.aborted;
819
+ if (!signaledBeforeAcpWait) {
820
+ // This is still-live parent work, not a post-closure registration claim.
821
+ registerSubagentRun({
822
+ runId: "during-acp-wait",
823
+ childSessionKey: "agent:main:subagent:during-acp-wait",
824
+ requesterSessionKey: sessionKey,
825
+ requesterAgentId: "main",
826
+ requesterDisplayKey: sessionKey,
827
+ task: "registered before native parent was signaled",
828
+ cleanup: "keep",
829
+ collect: true,
830
+ queued: true,
831
+ });
832
+ }
833
+ const queueClearedBeforeAcpWait = getFollowupQueueDepth(sessionKey) === 0;
834
+ native.complete();
835
+ replacement = createReplyOperation({
836
+ sessionKey,
837
+ sessionId: "replacement-session",
838
+ resetTriggered: false,
839
+ });
840
+ replacement.attachBackend({ kind: "embedded", cancel: () => {}, isStreaming: () => true });
841
+ proceed.resolve();
842
+ await pending;
843
+ expect(
844
+ signaledBeforeAcpWait,
845
+ "native parent must be signaled before the independent ACP await",
846
+ ).toBe(true);
847
+ expect(queueClearedBeforeAcpWait).toBe(true);
848
+ expect(
849
+ replacement.abortSignal.aborted,
850
+ "do not rediscover a replacement parent after ACP settles",
851
+ ).toBe(false);
852
+ expect(getSubagentRunByChildSessionKey("agent:main:subagent:during-acp-wait")).toBeNull();
853
+ } finally {
854
+ proceed.resolve();
855
+ await pending;
856
+ native.complete();
857
+ replacement?.complete();
858
+ }
859
+ });
860
+
861
+ it.each([undefined, "agent:main:main"])(
862
+ "propagates a zero-child callback failure for requester %s",
863
+ async (requesterSessionKey) => {
864
+ const beforeKill = vi.fn(() => {
865
+ throw new Error("parent cancellation failed");
866
+ });
867
+ await expect(
868
+ stopSubagentsForRequester({ cfg: {}, requesterSessionKey, beforeKill }),
869
+ ).rejects.toThrow("parent cancellation failed");
870
+ expect(beforeKill).toHaveBeenCalledOnce();
871
+ expect(runtimeAbortMocks.abortEmbeddedAgentRun).not.toHaveBeenCalled();
872
+ },
873
+ );
874
+
875
+ it("fast-abort of an ACP target also aborts the bound source dispatch lane", async () => {
876
+ const sourceSessionKey = "agent:main:discord:channel:C1";
877
+ const acpSessionKey = "agent:codex:acp:bound-session";
878
+ const { root, cfg } = await createAbortConfig({
879
+ sessionIdsByKey: {
880
+ [sourceSessionKey]: "source-store-session",
881
+ [acpSessionKey]: "acp-store-session",
882
+ },
883
+ });
884
+ const sourceOperation = createReplyOperation({
885
+ sessionKey: sourceSessionKey,
886
+ sessionId: "source-active-session",
887
+ resetTriggered: false,
888
+ });
889
+ enqueueQueuedFollowupRun({
890
+ root,
891
+ cfg,
892
+ sessionId: "source-active-session",
893
+ sessionKey: sourceSessionKey,
894
+ });
895
+ enqueueQueuedFollowupRun({
896
+ root,
897
+ cfg,
898
+ sessionId: "acp-store-session",
899
+ sessionKey: acpSessionKey,
900
+ });
901
+ bindAcpSessionForTest(acpSessionKey);
902
+ acpManagerMocks.resolveSession.mockReturnValue({
903
+ kind: "ready",
904
+ sessionKey: acpSessionKey,
905
+ meta: {} as never,
906
+ });
907
+
908
+ const result = await runStopCommand({
909
+ cfg,
910
+ sessionKey: sourceSessionKey,
911
+ from: "discord:C1",
912
+ to: "discord:C1",
913
+ targetSessionKey: acpSessionKey,
914
+ commandSource: "native",
915
+ });
916
+
917
+ expect(result.handled).toBe(true);
918
+ expect(sourceOperation.result).toEqual({ kind: "aborted", code: "aborted_by_user" });
919
+ expect(replyRunRegistry.isActive(sourceSessionKey)).toBe(false);
920
+ expect(getFollowupQueueDepth(sourceSessionKey)).toBe(0);
921
+ expect(getFollowupQueueDepth(acpSessionKey)).toBe(0);
922
+ expectSessionLaneCleared(sourceSessionKey);
923
+ expectSessionLaneCleared(acpSessionKey);
924
+ expect(acpManagerMocks.cancelSession).toHaveBeenCalledWith({
925
+ cfg,
926
+ sessionKey: acpSessionKey,
927
+ reason: "fast-abort",
928
+ });
929
+ });
930
+
931
+ it("does not report /stop success after the active backend freezes its outcome", async () => {
932
+ const sessionKey = "agent:main:telegram:direct:finalizing";
933
+ const sessionId = "session-finalizing";
934
+ const { cfg } = await createAbortConfig({
935
+ sessionIdsByKey: { [sessionKey]: sessionId },
936
+ });
937
+ const cancel = vi.fn();
938
+ const operation = createReplyOperation({
939
+ sessionKey,
940
+ sessionId,
941
+ resetTriggered: false,
942
+ });
943
+ operation.attachBackend({
944
+ kind: "embedded",
945
+ cancel,
946
+ isStreaming: () => false,
947
+ isAbortable: () => false,
948
+ });
949
+ operation.setPhase("running");
950
+ runtimeAbortMocks.abortEmbeddedAgentRun.mockReturnValue(false);
951
+ runtimeAbortMocks.resolveActiveEmbeddedRunSessionId.mockReturnValue(sessionId);
952
+ vi.mocked(markSessionAbortTarget).mockClear();
953
+
954
+ const result = await runStopCommand({
955
+ cfg,
956
+ sessionKey,
957
+ from: "telegram:finalizing",
958
+ to: "telegram:finalizing",
959
+ });
960
+
961
+ expect(result).toMatchObject({
962
+ handled: true,
963
+ aborted: false,
964
+ rejectionReason: "finalizing",
965
+ });
966
+ expect(operation.result).toBeNull();
967
+ expect(replyRunRegistry.isActive(sessionKey)).toBe(true);
968
+ expect(cancel).not.toHaveBeenCalled();
969
+ expect(markSessionAbortTarget).not.toHaveBeenCalled();
970
+ expect(getAbortMemory(sessionKey)).toBeUndefined();
971
+ expect(formatAbortReplyText(undefined, result.rejectionReason)).toBe(
972
+ "Agent reply is already finalizing and can no longer be aborted.",
973
+ );
974
+ expect(formatAbortReplyText(0, undefined, 1)).toBe(
975
+ "⚙️ Agent was aborted. Cancellation was incomplete for 1 sub-agent. Retry /stop.",
976
+ );
977
+ operation.complete();
978
+ });
979
+
980
+ it("fast-abort of an ACP target aborts the source stored session when no source reply operation is registered", async () => {
981
+ const sourceSessionKey = "agent:main:discord:channel:C2";
982
+ const acpSessionKey = "agent:codex:acp:bound-session-stored-source";
983
+ const { root, cfg } = await createAbortConfig({
984
+ sessionIdsByKey: {
985
+ [sourceSessionKey]: "source-store-session",
986
+ [acpSessionKey]: "acp-store-session",
987
+ },
988
+ });
989
+ enqueueQueuedFollowupRun({
990
+ root,
991
+ cfg,
992
+ sessionId: "source-store-session",
993
+ sessionKey: sourceSessionKey,
994
+ });
995
+ enqueueQueuedFollowupRun({
996
+ root,
997
+ cfg,
998
+ sessionId: "acp-store-session",
999
+ sessionKey: acpSessionKey,
1000
+ });
1001
+ bindAcpSessionForTest(acpSessionKey);
1002
+ acpManagerMocks.resolveSession.mockReturnValue({
1003
+ kind: "ready",
1004
+ sessionKey: acpSessionKey,
1005
+ meta: {} as never,
1006
+ });
1007
+
1008
+ const result = await runStopCommand({
1009
+ cfg,
1010
+ sessionKey: sourceSessionKey,
1011
+ from: "discord:C2",
1012
+ to: "discord:C2",
1013
+ targetSessionKey: acpSessionKey,
1014
+ commandSource: "native",
1015
+ });
1016
+
1017
+ expect(result.handled).toBe(true);
1018
+ expect(runtimeAbortMocks.abortEmbeddedAgentRun).toHaveBeenCalledWith("source-store-session");
1019
+ expect(getFollowupQueueDepth(sourceSessionKey)).toBe(0);
1020
+ expect(getFollowupQueueDepth(acpSessionKey)).toBe(0);
1021
+ expectSessionLaneCleared(sourceSessionKey);
1022
+ expectSessionLaneCleared(acpSessionKey);
1023
+ });
1024
+
1025
+ it("does not abort the caller source lane for an unbound explicit ACP target", async () => {
1026
+ const sourceSessionKey = "agent:main:discord:channel:C3";
1027
+ const acpSessionKey = "agent:codex:acp:unbound-explicit-target";
1028
+ const { cfg } = await createAbortConfig({
1029
+ sessionIdsByKey: {
1030
+ [sourceSessionKey]: "source-store-session",
1031
+ [acpSessionKey]: "acp-store-session",
1032
+ },
1033
+ });
1034
+ const sourceOperation = createReplyOperation({
1035
+ sessionKey: sourceSessionKey,
1036
+ sessionId: "source-active-session",
1037
+ resetTriggered: false,
1038
+ });
1039
+ acpManagerMocks.resolveSession.mockReturnValue({
1040
+ kind: "ready",
1041
+ sessionKey: acpSessionKey,
1042
+ meta: {} as never,
1043
+ });
1044
+
1045
+ const result = await runStopCommand({
1046
+ cfg,
1047
+ sessionKey: sourceSessionKey,
1048
+ from: "discord:C3",
1049
+ to: "discord:C3",
1050
+ targetSessionKey: acpSessionKey,
1051
+ commandSource: "native",
1052
+ });
1053
+
1054
+ expect(result.handled).toBe(true);
1055
+ expect(sourceOperation.result).toBeNull();
1056
+ expect(replyRunRegistry.isActive(sourceSessionKey)).toBe(true);
1057
+ expect(acpManagerMocks.cancelSession).toHaveBeenCalledWith({
1058
+ cfg,
1059
+ sessionKey: acpSessionKey,
1060
+ reason: "fast-abort",
1061
+ });
1062
+ sourceOperation.complete();
1063
+ });
1064
+
1065
+ it("uses ParentSessionKey as the source lane for a bound explicit ACP target", async () => {
1066
+ const sourceSessionKey = "agent:main:discord:channel:C4";
1067
+ const acpSessionKey = "agent:codex:acp:bound-parent-source";
1068
+ const { cfg } = await createAbortConfig({
1069
+ sessionIdsByKey: {
1070
+ [sourceSessionKey]: "source-store-session",
1071
+ [acpSessionKey]: "acp-store-session",
1072
+ },
1073
+ });
1074
+ const sourceOperation = createReplyOperation({
1075
+ sessionKey: sourceSessionKey,
1076
+ sessionId: "source-active-session",
1077
+ resetTriggered: false,
1078
+ });
1079
+ bindAcpSessionForTest(acpSessionKey);
1080
+ acpManagerMocks.resolveSession.mockReturnValue({
1081
+ kind: "ready",
1082
+ sessionKey: acpSessionKey,
1083
+ meta: {} as never,
1084
+ });
1085
+
1086
+ const result = await runStopCommand({
1087
+ cfg,
1088
+ parentSessionKey: sourceSessionKey,
1089
+ from: "discord:C4",
1090
+ to: "discord:C4",
1091
+ targetSessionKey: acpSessionKey,
1092
+ commandSource: "native",
1093
+ });
1094
+
1095
+ expect(result.handled).toBe(true);
1096
+ expect(sourceOperation.result).toEqual({ kind: "aborted", code: "aborted_by_user" });
1097
+ expect(replyRunRegistry.isActive(sourceSessionKey)).toBe(false);
1098
+ });
1099
+
1100
+ it("fast-abort from an ACP-bound source conversation aborts source and bound ACP lanes", async () => {
1101
+ const sourceSessionKey = "agent:main:telegram:direct:source-1";
1102
+ const acpSessionKey = "agent:codex:acp:bound-source-stop";
1103
+ const { root, storePath, cfg } = await createAbortConfig({
1104
+ sessionIdsByKey: {
1105
+ [sourceSessionKey]: "source-store-session",
1106
+ [acpSessionKey]: "acp-store-session",
1107
+ },
1108
+ });
1109
+ const sourceOperation = createReplyOperation({
1110
+ sessionKey: sourceSessionKey,
1111
+ sessionId: "source-active-session",
1112
+ resetTriggered: false,
1113
+ });
1114
+ const acpOperation = createReplyOperation({
1115
+ sessionKey: acpSessionKey,
1116
+ sessionId: "acp-active-session",
1117
+ resetTriggered: false,
1118
+ });
1119
+ enqueueQueuedFollowupRun({
1120
+ root,
1121
+ cfg,
1122
+ sessionId: "source-active-session",
1123
+ sessionKey: sourceSessionKey,
1124
+ });
1125
+ enqueueQueuedFollowupRun({
1126
+ root,
1127
+ cfg,
1128
+ sessionId: "acp-active-session",
1129
+ sessionKey: acpSessionKey,
1130
+ });
1131
+ bindAcpSessionForTest(acpSessionKey);
1132
+ acpManagerMocks.resolveSession.mockReturnValue({
1133
+ kind: "ready",
1134
+ sessionKey: acpSessionKey,
1135
+ meta: {} as never,
1136
+ });
1137
+
1138
+ const result = await runStopCommand({
1139
+ cfg,
1140
+ sessionKey: sourceSessionKey,
1141
+ from: "telegram:source-1",
1142
+ to: "telegram:source-1",
1143
+ messageSid: "77",
1144
+ timestamp: 1234567890000,
1145
+ });
1146
+
1147
+ expect(result.handled).toBe(true);
1148
+ expect(sourceOperation.result).toEqual({ kind: "aborted", code: "aborted_by_user" });
1149
+ expect(acpOperation.result).toEqual({ kind: "aborted", code: "aborted_by_user" });
1150
+ expect(replyRunRegistry.isActive(sourceSessionKey)).toBe(false);
1151
+ expect(replyRunRegistry.isActive(acpSessionKey)).toBe(false);
1152
+ expect(getFollowupQueueDepth(sourceSessionKey)).toBe(0);
1153
+ expect(getFollowupQueueDepth(acpSessionKey)).toBe(0);
1154
+ expectSessionLaneCleared(sourceSessionKey);
1155
+ expectSessionLaneCleared(acpSessionKey);
1156
+ expect(acpManagerMocks.cancelSession).toHaveBeenCalledWith({
1157
+ cfg,
1158
+ sessionKey: acpSessionKey,
1159
+ reason: "fast-abort",
1160
+ });
1161
+ const sourceEntry = readAbortSessionEntry(storePath, sourceSessionKey);
1162
+ const acpEntry = readAbortSessionEntry(storePath, acpSessionKey);
1163
+ expect(sourceEntry?.abortCutoffMessageSid).toBe("77");
1164
+ expect(sourceEntry?.abortCutoffTimestamp).toBe(1234567890000);
1165
+ expect(acpEntry?.abortCutoffMessageSid).toBeUndefined();
1166
+ expect(acpEntry?.abortCutoffTimestamp).toBeUndefined();
1167
+ });
1168
+
1169
+ it("persists abort cutoff metadata on /stop when command and target session match", async () => {
1170
+ const sessionKey = "telegram:123";
1171
+ const sessionId = "session-123";
1172
+ const { storePath, cfg } = await createAbortConfig({
1173
+ sessionIdsByKey: { [sessionKey]: sessionId },
1174
+ });
1175
+
1176
+ const result = await runStopCommand({
1177
+ cfg,
1178
+ sessionKey,
1179
+ from: "telegram:123",
1180
+ to: "telegram:123",
1181
+ messageSid: "55",
1182
+ timestamp: 1234567890000,
1183
+ });
1184
+
1185
+ expect(result.handled).toBe(true);
1186
+ const entry = readAbortSessionEntry(storePath, sessionKey);
1187
+ expect(entry?.abortedLastRun).toBe(true);
1188
+ expect(entry?.abortCutoffMessageSid).toBe("55");
1189
+ expect(entry?.abortCutoffTimestamp).toBe(1234567890000);
1190
+ });
1191
+
1192
+ it("persists abort cutoff metadata when only ParentSessionKey identifies the command session", async () => {
1193
+ const sessionKey = "telegram:parent-only";
1194
+ const sessionId = "session-parent-only";
1195
+ const { storePath, cfg } = await createAbortConfig({
1196
+ sessionIdsByKey: { [sessionKey]: sessionId },
1197
+ });
1198
+
1199
+ const result = await runStopCommand({
1200
+ cfg,
1201
+ parentSessionKey: sessionKey,
1202
+ from: "telegram:parent-only",
1203
+ to: "telegram:parent-only",
1204
+ messageSid: "56",
1205
+ timestamp: 1234567890001,
1206
+ });
1207
+
1208
+ expect(result.handled).toBe(true);
1209
+ const entry = readAbortSessionEntry(storePath, sessionKey);
1210
+ expect(entry?.abortedLastRun).toBe(true);
1211
+ expect(entry?.abortCutoffMessageSid).toBe("56");
1212
+ expect(entry?.abortCutoffTimestamp).toBe(1234567890001);
1213
+ });
1214
+
1215
+ it("does not persist cutoff metadata when native /stop targets a different session", async () => {
1216
+ const slashSessionKey = "telegram:slash:123";
1217
+ const targetSessionKey = "agent:main:telegram:group:123";
1218
+ const targetSessionId = "session-target";
1219
+ const { storePath, cfg } = await createAbortConfig({
1220
+ sessionIdsByKey: { [targetSessionKey]: targetSessionId },
1221
+ });
1222
+
1223
+ const result = await runStopCommand({
1224
+ cfg,
1225
+ sessionKey: slashSessionKey,
1226
+ from: "telegram:123",
1227
+ to: "telegram:123",
1228
+ targetSessionKey,
1229
+ messageSid: "999",
1230
+ timestamp: 1234567890000,
1231
+ });
1232
+
1233
+ expect(result.handled).toBe(true);
1234
+ const entry = readAbortSessionEntry(storePath, targetSessionKey);
1235
+ expect(entry?.abortedLastRun).toBe(true);
1236
+ expect(entry?.abortCutoffMessageSid).toBeUndefined();
1237
+ expect(entry?.abortCutoffTimestamp).toBeUndefined();
1238
+ });
1239
+
1240
+ it("fast-abort stops active subagent runs for requester session", async () => {
1241
+ const sessionKey = "telegram:parent";
1242
+ const childKey = "agent:main:subagent:child-1";
1243
+ const sessionId = "session-parent";
1244
+ const childSessionId = "session-child";
1245
+ const { cfg } = await createAbortConfig({
1246
+ sessionIdsByKey: {
1247
+ [sessionKey]: sessionId,
1248
+ [childKey]: childSessionId,
1249
+ },
1250
+ });
1251
+
1252
+ addSubagentFixture({
1253
+ runId: "run-1",
1254
+ childSessionKey: childKey,
1255
+ requesterSessionKey: sessionKey,
1256
+ requesterDisplayKey: "telegram:parent",
1257
+ task: "do work",
1258
+ cleanup: "keep",
1259
+ createdAt: Date.now(),
1260
+ });
1261
+
1262
+ const result = await runStopCommand({
1263
+ cfg,
1264
+ sessionKey,
1265
+ from: "telegram:parent",
1266
+ to: "telegram:parent",
1267
+ });
1268
+
1269
+ expect(result.stoppedSubagents).toBe(1);
1270
+ expectSessionLaneCleared(childKey);
1271
+ });
1272
+
1273
+ it("continues stopping siblings when one termination persistence write fails", async () => {
1274
+ const sessionKey = "telegram:persistence-failure-parent";
1275
+ const firstChildKey = "agent:main:subagent:persistence-failure-first";
1276
+ const secondChildKey = "agent:main:subagent:persistence-failure-second";
1277
+ const run = (runId: string, childSessionKey: string): SubagentRunFixture => ({
1278
+ runId,
1279
+ childSessionKey,
1280
+ requesterSessionKey: sessionKey,
1281
+ requesterDisplayKey: sessionKey,
1282
+ task: "stop despite persistence failure",
1283
+ cleanup: "keep",
1284
+ createdAt: Date.now(),
1285
+ });
1286
+ for (const fixture of [
1287
+ run("run-persistence-failure-first", firstChildKey),
1288
+ run("run-persistence-failure-second", secondChildKey),
1289
+ ]) {
1290
+ addSubagentFixture(fixture);
1291
+ }
1292
+ let failedTombstone = false;
1293
+ subagentRegistryTesting.setDepsForTest({
1294
+ persistSubagentRunsToDiskOrThrow: (runs, changedRunIds) => {
1295
+ const first = runs.get("run-persistence-failure-first");
1296
+ if (
1297
+ !failedTombstone &&
1298
+ changedRunIds?.includes("run-persistence-failure-first") &&
1299
+ first?.execution.status === "terminal" &&
1300
+ first.endedReason === "subagent-killed"
1301
+ ) {
1302
+ failedTombstone = true;
1303
+ throw new Error("sqlite busy");
1304
+ }
1305
+ },
1306
+ });
1307
+
1308
+ await expect(
1309
+ stopSubagentsForRequester({
1310
+ cfg: {} as OpenClawConfig,
1311
+ requesterSessionKey: sessionKey,
1312
+ }),
1313
+ ).resolves.toEqual({ stopped: 1, failed: 1 });
1314
+ expect(failedTombstone).toBe(true);
1315
+ expect(getSubagentRunByChildSessionKey(firstChildKey)?.killIntent).toBeDefined();
1316
+ expect(getSubagentRunByChildSessionKey(secondChildKey)?.endedReason).toBe("subagent-killed");
1317
+ expectSessionLaneCleared(firstChildKey);
1318
+ expectSessionLaneCleared(secondChildKey);
1319
+ });
1320
+
1321
+ it("cascade stop kills depth-2 children when stopping depth-1 agent", async () => {
1322
+ const sessionKey = "telegram:parent";
1323
+ const depth1Key = "agent:main:subagent:child-1";
1324
+ const depth2Key = "agent:main:subagent:child-1:subagent:grandchild-1";
1325
+ const sessionId = "session-parent";
1326
+ const depth1SessionId = "session-child";
1327
+ const depth2SessionId = "session-grandchild";
1328
+ const { cfg } = await createAbortConfig({
1329
+ sessionIdsByKey: {
1330
+ [sessionKey]: sessionId,
1331
+ [depth1Key]: depth1SessionId,
1332
+ [depth2Key]: depth2SessionId,
1333
+ },
1334
+ });
1335
+
1336
+ addSubagentFixture({
1337
+ runId: "run-1",
1338
+ childSessionKey: depth1Key,
1339
+ requesterSessionKey: sessionKey,
1340
+ requesterDisplayKey: "telegram:parent",
1341
+ task: "orchestrator",
1342
+ cleanup: "keep",
1343
+ createdAt: Date.now(),
1344
+ });
1345
+ addSubagentFixture({
1346
+ runId: "run-2",
1347
+ childSessionKey: depth2Key,
1348
+ requesterSessionKey: depth1Key,
1349
+ requesterDisplayKey: depth1Key,
1350
+ task: "leaf worker",
1351
+ cleanup: "keep",
1352
+ createdAt: Date.now(),
1353
+ });
1354
+
1355
+ const result = await runStopCommand({
1356
+ cfg,
1357
+ sessionKey,
1358
+ from: "telegram:parent",
1359
+ to: "telegram:parent",
1360
+ });
1361
+
1362
+ // Should stop both depth-1 and depth-2 agents (cascade)
1363
+ expect(result.stoppedSubagents).toBe(2);
1364
+ expectSessionLaneCleared(depth1Key);
1365
+ expectSessionLaneCleared(depth2Key);
1366
+ });
1367
+
1368
+ it("stops a subagent that is paused after yielding", async () => {
1369
+ const sessionKey = "telegram:yield-parent";
1370
+ const childKey = "agent:main:subagent:yield-child";
1371
+ const now = Date.now();
1372
+ addSubagentFixture({
1373
+ runId: "run-yield-child",
1374
+ childSessionKey: childKey,
1375
+ requesterSessionKey: sessionKey,
1376
+ requesterDisplayKey: sessionKey,
1377
+ task: "paused worker",
1378
+ cleanup: "keep",
1379
+ createdAt: now - 1_000,
1380
+ endedAt: now - 500,
1381
+ pauseReason: "sessions_yield",
1382
+ });
1383
+
1384
+ const result = await stopSubagentsForRequester({
1385
+ cfg: {} as OpenClawConfig,
1386
+ requesterSessionKey: sessionKey,
1387
+ });
1388
+
1389
+ expect(result).toEqual({ stopped: 1, failed: 0 });
1390
+ expectSessionLaneCleared(childKey);
1391
+ expect(getSubagentRunByChildSessionKey(childKey)).toMatchObject({
1392
+ endedReason: "subagent-killed",
1393
+ killReconciliation: { suppressTaskDelivery: true },
1394
+ });
1395
+ });
1396
+
1397
+ it("cascade stop traverses ended depth-1 parents to stop active depth-2 children", async () => {
1398
+ const sessionKey = "telegram:parent";
1399
+ const depth1Key = "agent:main:subagent:child-ended";
1400
+ const depth2Key = "agent:main:subagent:child-ended:subagent:grandchild-active";
1401
+ const now = Date.now();
1402
+ const { cfg } = await createAbortConfig({
1403
+ nowMs: now,
1404
+ sessionIdsByKey: {
1405
+ [sessionKey]: "session-parent",
1406
+ [depth1Key]: "session-child-ended",
1407
+ [depth2Key]: "session-grandchild-active",
1408
+ },
1409
+ });
1410
+
1411
+ addSubagentFixture({
1412
+ runId: "run-1",
1413
+ childSessionKey: depth1Key,
1414
+ requesterSessionKey: sessionKey,
1415
+ requesterDisplayKey: "telegram:parent",
1416
+ task: "orchestrator",
1417
+ cleanup: "keep",
1418
+ createdAt: now - 1_000,
1419
+ endedAt: now - 500,
1420
+ outcome: { status: "ok" },
1421
+ });
1422
+ addSubagentFixture({
1423
+ runId: "run-2",
1424
+ childSessionKey: depth2Key,
1425
+ requesterSessionKey: depth1Key,
1426
+ requesterDisplayKey: depth1Key,
1427
+ task: "leaf worker",
1428
+ cleanup: "keep",
1429
+ createdAt: now - 500,
1430
+ });
1431
+
1432
+ const result = await runStopCommand({
1433
+ cfg,
1434
+ sessionKey,
1435
+ from: "telegram:parent",
1436
+ to: "telegram:parent",
1437
+ });
1438
+
1439
+ // Should skip killing the ended depth-1 run itself, but still kill depth-2.
1440
+ expect(result.stoppedSubagents).toBe(1);
1441
+ expectSessionLaneCleared(depth2Key);
1442
+ expect(getSubagentRunByChildSessionKey(depth1Key)?.endedReason).not.toBe("subagent-killed");
1443
+ expect(getSubagentRunByChildSessionKey(depth2Key)?.endedReason).toBe("subagent-killed");
1444
+ });
1445
+
1446
+ it("cascade stop still traverses an ended current parent when a stale older active row exists", async () => {
1447
+ const sessionKey = "telegram:parent";
1448
+ const depth1Key = "agent:main:subagent:child-ended-stale";
1449
+ const depth2Key = "agent:main:subagent:child-ended-stale:subagent:grandchild-active";
1450
+ const now = Date.now();
1451
+ const { cfg } = await createAbortConfig({
1452
+ nowMs: now,
1453
+ sessionIdsByKey: {
1454
+ [sessionKey]: "session-parent",
1455
+ [depth1Key]: "session-child-ended-stale",
1456
+ [depth2Key]: "session-grandchild-active",
1457
+ },
1458
+ });
1459
+
1460
+ for (const fixture of [
1461
+ {
1462
+ runId: "run-stale-parent",
1463
+ childSessionKey: depth1Key,
1464
+ requesterSessionKey: sessionKey,
1465
+ requesterDisplayKey: "telegram:parent",
1466
+ task: "stale orchestrator",
1467
+ cleanup: "keep",
1468
+ createdAt: now - 2_000,
1469
+ startedAt: now - 1_900,
1470
+ },
1471
+ {
1472
+ runId: "run-current-parent",
1473
+ childSessionKey: depth1Key,
1474
+ requesterSessionKey: sessionKey,
1475
+ requesterDisplayKey: "telegram:parent",
1476
+ task: "current orchestrator",
1477
+ cleanup: "keep",
1478
+ createdAt: now - 1_000,
1479
+ startedAt: now - 900,
1480
+ endedAt: now - 500,
1481
+ outcome: { status: "ok" },
1482
+ },
1483
+ ] satisfies SubagentRunFixture[]) {
1484
+ addSubagentFixture(fixture);
1485
+ }
1486
+ addSubagentFixture({
1487
+ runId: "run-active-child",
1488
+ childSessionKey: depth2Key,
1489
+ requesterSessionKey: depth1Key,
1490
+ requesterDisplayKey: depth1Key,
1491
+ task: "leaf worker",
1492
+ cleanup: "keep",
1493
+ createdAt: now - 400,
1494
+ });
1495
+
1496
+ const result = await runStopCommand({
1497
+ cfg,
1498
+ sessionKey,
1499
+ from: "telegram:parent",
1500
+ to: "telegram:parent",
1501
+ });
1502
+
1503
+ expect(result.stoppedSubagents).toBe(1);
1504
+ expectSessionLaneCleared(depth2Key);
1505
+ expect(getSubagentRunByChildSessionKey(depth1Key)?.endedReason).not.toBe("subagent-killed");
1506
+ expect(getSubagentRunByChildSessionKey(depth2Key)?.endedReason).toBe("subagent-killed");
1507
+ });
1508
+
1509
+ it("stopSubagentsForRequester does not traverse a child that moved to a newer parent", async () => {
1510
+ const oldParentKey = "agent:main:subagent:old-parent";
1511
+ const newParentKey = "agent:main:subagent:new-parent";
1512
+ const childKey = "agent:main:subagent:shared-child";
1513
+ const leafKey = `${childKey}:subagent:leaf`;
1514
+ const now = Date.now();
1515
+
1516
+ addSubagentFixture({
1517
+ runId: "run-shared-child-stale-parent",
1518
+ childSessionKey: childKey,
1519
+ requesterSessionKey: oldParentKey,
1520
+ controllerSessionKey: oldParentKey,
1521
+ requesterDisplayKey: oldParentKey,
1522
+ task: "shared child stale parent",
1523
+ cleanup: "keep",
1524
+ createdAt: now - 2_000,
1525
+ endedAt: now - 1_000,
1526
+ outcome: { status: "ok" },
1527
+ });
1528
+ addSubagentFixture({
1529
+ runId: "run-leaf-active",
1530
+ childSessionKey: leafKey,
1531
+ requesterSessionKey: childKey,
1532
+ controllerSessionKey: childKey,
1533
+ requesterDisplayKey: childKey,
1534
+ task: "leaf worker",
1535
+ cleanup: "keep",
1536
+ createdAt: now - 500,
1537
+ });
1538
+ addSubagentFixture({
1539
+ runId: "run-shared-child-current-parent",
1540
+ childSessionKey: childKey,
1541
+ requesterSessionKey: newParentKey,
1542
+ controllerSessionKey: newParentKey,
1543
+ requesterDisplayKey: newParentKey,
1544
+ task: "shared child current parent",
1545
+ cleanup: "keep",
1546
+ createdAt: now - 250,
1547
+ });
1548
+
1549
+ const result = await stopSubagentsForRequester({
1550
+ cfg: {} as OpenClawConfig,
1551
+ requesterSessionKey: oldParentKey,
1552
+ });
1553
+
1554
+ expect(result).toEqual({ stopped: 0, failed: 0 });
1555
+ expect(getSubagentRunByChildSessionKey(childKey)?.execution.endedAt).toBeUndefined();
1556
+ expect(getSubagentRunByChildSessionKey(leafKey)?.execution.endedAt).toBeUndefined();
1557
+ });
1558
+ });
1559
+ /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
src/auto-reply/reply/abort.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Recognizes stop requests before loading active-run cancellation machinery.
2
+ import {
3
+ normalizeOptionalLowercaseString,
4
+ normalizeOptionalString,
5
+ } from "@openclaw/normalization-core/string-coerce";
6
+ import { resolveSessionAgentId } from "../../agents/agent-scope.js";
7
+ import type { OpenClawConfig } from "../../config/types.openclaw.js";
8
+ import type { FinalizedRuntimeMsgContext } from "../templating.js";
9
+ import { isAbortRequestText } from "./abort-primitives.js";
10
+ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
11
+
12
+ export type FastAbortRequestParams = {
13
+ ctx: FinalizedRuntimeMsgContext;
14
+ cfg: OpenClawConfig;
15
+ isCommandTargetCurrent?: () => boolean;
16
+ };
17
+
18
+ export type FastAbortResult = {
19
+ handled: boolean;
20
+ aborted: boolean;
21
+ rejectionReason?: "finalizing";
22
+ stoppedSubagents?: number;
23
+ failedSubagents?: number;
24
+ };
25
+
26
+ export function formatAbortReplyText(
27
+ stoppedSubagents?: number,
28
+ rejectionReason?: "finalizing",
29
+ failedSubagents?: number,
30
+ ): string {
31
+ const failureSuffix =
32
+ typeof failedSubagents === "number" && failedSubagents > 0
33
+ ? ` Cancellation was incomplete for ${failedSubagents} sub-agent${failedSubagents === 1 ? "" : "s"}. Retry /stop.`
34
+ : "";
35
+ if (rejectionReason === "finalizing") {
36
+ const base = "Agent reply is already finalizing and can no longer be aborted.";
37
+ if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) {
38
+ return `${base}${failureSuffix}`;
39
+ }
40
+ const label = stoppedSubagents === 1 ? "sub-agent" : "sub-agents";
41
+ return `${base} Stopped ${stoppedSubagents} ${label}.${failureSuffix}`;
42
+ }
43
+ if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) {
44
+ return `⚙️ Agent was aborted.${failureSuffix}`;
45
+ }
46
+ const label = stoppedSubagents === 1 ? "sub-agent" : "sub-agents";
47
+ return `⚙️ Agent was aborted. Stopped ${stoppedSubagents} ${label}.${failureSuffix}`;
48
+ }
49
+
50
+ /** Normalize ingress once; current authorization belongs to the loaded operation. */
51
+ function resolveFastAbortRequest(params: FastAbortRequestParams) {
52
+ const { ctx, cfg } = params;
53
+ const commandSessionKey =
54
+ normalizeOptionalString(ctx.SessionKey) ?? normalizeOptionalString(ctx.ParentSessionKey);
55
+ const targetKey = normalizeOptionalString(ctx.CommandTargetSessionKey) ?? commandSessionKey;
56
+ const resolveTargetAgentId = () =>
57
+ resolveSessionAgentId({
58
+ sessionKey: targetKey ?? ctx.SessionKey ?? "",
59
+ config: cfg,
60
+ fallbackAgentId: ctx.AgentId,
61
+ });
62
+ const raw = stripStructuralPrefixes(ctx.commandText);
63
+ const isGroup = normalizeOptionalLowercaseString(ctx.ChatType) === "group";
64
+ const stripped = isGroup ? stripMentions(raw, ctx, cfg, resolveTargetAgentId()) : raw;
65
+ const abortRequested = isAbortRequestText(stripped);
66
+ if (!abortRequested) {
67
+ return undefined;
68
+ }
69
+
70
+ return { commandSessionKey, targetKey, resolveTargetAgentId };
71
+ }
72
+
73
+ export type PreparedFastAbortRequest = NonNullable<ReturnType<typeof resolveFastAbortRequest>>;
74
+
75
+ export async function tryFastAbortFromMessage(
76
+ params: FastAbortRequestParams,
77
+ ): Promise<FastAbortResult> {
78
+ const request = resolveFastAbortRequest(params);
79
+ if (!request) {
80
+ return { handled: false, aborted: false };
81
+ }
82
+ // Normalized ingress is not authority. The operation resolves authorization
83
+ // and retains current-target validation after loading cancellation machinery.
84
+ const { executeFastAbortRequest } = await import("./abort-operation.js");
85
+ return executeFastAbortRequest(params, request);
86
+ }
src/auto-reply/reply/acp-elicitation-handler-lazy.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AcpElicitationHandler } from "@openclaw/acp-core/runtime/types";
2
+ import type { AcpElicitationHandlerParams } from "./acp-elicitation-handler.js";
3
+
4
+ /** Defers the structured-input stack while preserving one exact handler per turn. */
5
+ export function createLazyAcpElicitationHandler(
6
+ params: AcpElicitationHandlerParams,
7
+ ): AcpElicitationHandler {
8
+ let handler: Promise<AcpElicitationHandler> | undefined;
9
+ return async (request, context) => {
10
+ handler ??= import("./acp-elicitation-handler.js").then(({ createAcpElicitationHandler }) =>
11
+ createAcpElicitationHandler(params),
12
+ );
13
+ return (await handler)(request, context);
14
+ };
15
+ }
src/auto-reply/reply/acp-elicitation-handler.test.ts ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AcpElicitationRequest } from "@openclaw/acp-core/runtime/types";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { claimPendingAgentQuestionAnswer } from "../../agents/harness/gateway-question.js";
4
+
5
+ const gatewayCallMock = vi.hoisted(() => vi.fn());
6
+
7
+ vi.mock("../../agents/tools/gateway.js", () => ({ callGatewayTool: gatewayCallMock }));
8
+
9
+ import { createAcpElicitationHandler } from "./acp-elicitation-handler.js";
10
+
11
+ type GatewayQuestion = { questionId: string; options?: Array<{ label: string }> };
12
+ type GatewayRequest = {
13
+ id: string;
14
+ questions: GatewayQuestion[];
15
+ sessionKey?: string;
16
+ runId?: string;
17
+ };
18
+
19
+ function formRequest(
20
+ properties: Record<string, unknown>,
21
+ required: string[] = [],
22
+ ): AcpElicitationRequest {
23
+ return {
24
+ mode: "form",
25
+ sessionId: "acp-session",
26
+ toolCallId: "tool-call",
27
+ message: "ACP needs input",
28
+ requestedSchema: { type: "object", properties, required },
29
+ } as AcpElicitationRequest;
30
+ }
31
+
32
+ function urlRequest(overrides: Partial<Record<string, unknown>> = {}): AcpElicitationRequest {
33
+ return {
34
+ mode: "url",
35
+ sessionId: "acp-session",
36
+ toolCallId: "tool-call",
37
+ message: "Authenticate",
38
+ elicitationId: "login-1",
39
+ url: "https://example.com/login",
40
+ ...overrides,
41
+ } as AcpElicitationRequest;
42
+ }
43
+
44
+ function createFixture() {
45
+ const delivered: string[] = [];
46
+ let active = true;
47
+ const handler = createAcpElicitationHandler({
48
+ sourceSessionKey: "agent:main:source",
49
+ targetSessionKey: "agent:codex:acp:target",
50
+ outerRequestId: "outer-turn",
51
+ agentId: "main",
52
+ runId: "run-1",
53
+ delivery: {
54
+ deliver: vi.fn(async (_kind, payload) => {
55
+ if (payload.text) {
56
+ delivered.push(payload.text);
57
+ }
58
+ return true;
59
+ }),
60
+ },
61
+ isActive: () => active,
62
+ });
63
+ return {
64
+ delivered,
65
+ handler,
66
+ deactivate: () => {
67
+ active = false;
68
+ },
69
+ };
70
+ }
71
+
72
+ function answerGateway(
73
+ answer: (questions: GatewayQuestion[]) => Record<string, string[]>,
74
+ ): GatewayRequest[] {
75
+ const requests = new Map<string, GatewayRequest>();
76
+ const ordered: GatewayRequest[] = [];
77
+ gatewayCallMock.mockImplementation(async (method: string, _opts: unknown, rawParams: unknown) => {
78
+ const params = rawParams as GatewayRequest;
79
+ if (method === "question.request") {
80
+ requests.set(params.id, params);
81
+ ordered.push(params);
82
+ return { id: params.id };
83
+ }
84
+ if (method === "question.waitAnswer") {
85
+ const request = requests.get(params.id);
86
+ if (!request) {
87
+ throw new Error("missing question registration");
88
+ }
89
+ return { status: "answered", answers: { answers: answer(request.questions) } };
90
+ }
91
+ if (method === "question.resolve") {
92
+ return { status: "cancelled" };
93
+ }
94
+ throw new Error(`unexpected gateway method ${method}`);
95
+ });
96
+ return ordered;
97
+ }
98
+
99
+ describe("ACP elicitation delivery", () => {
100
+ beforeEach(() => {
101
+ gatewayCallMock.mockReset();
102
+ });
103
+
104
+ it("rejects accessor-backed request objects without invoking the accessor", async () => {
105
+ let reads = 0;
106
+ const request = Object.defineProperty(
107
+ {
108
+ mode: "form",
109
+ sessionId: "acp-session",
110
+ message: "Input",
111
+ },
112
+ "requestedSchema",
113
+ {
114
+ enumerable: true,
115
+ get() {
116
+ reads += 1;
117
+ return { type: "object", properties: { value: { type: "string" } } };
118
+ },
119
+ },
120
+ ) as AcpElicitationRequest;
121
+ const { delivered, handler } = createFixture();
122
+
123
+ const response = await handler(request, {
124
+ requestId: "accessor",
125
+ signal: new AbortController().signal,
126
+ });
127
+
128
+ expect(response.action).toBe("decline");
129
+ expect(reads).toBe(0);
130
+ expect(delivered.join("\n")).toContain("malformed");
131
+ });
132
+
133
+ it("uses outer, scope, session, and tool-call correlation in Gateway records", async () => {
134
+ const requests = answerGateway((questions) => ({ [questions[0]!.questionId]: ["Continue"] }));
135
+ const { handler } = createFixture();
136
+
137
+ await handler(urlRequest(), {
138
+ requestId: 1,
139
+ signal: new AbortController().signal,
140
+ });
141
+ await handler(
142
+ {
143
+ mode: "url",
144
+ requestId: "auth-request",
145
+ message: "Authenticate",
146
+ elicitationId: "login-2",
147
+ url: "https://example.com/login",
148
+ },
149
+ { requestId: 2, signal: new AbortController().signal },
150
+ );
151
+
152
+ expect(requests).toHaveLength(2);
153
+ expect(requests[0]?.id).toMatch(/^acp_[a-f0-9]{24}_0$/u);
154
+ expect(requests[1]?.id).toMatch(/^acp_[a-f0-9]{24}_0$/u);
155
+ expect(requests[0]?.id).not.toBe(requests[1]?.id);
156
+ expect(requests[0]).toMatchObject({
157
+ sessionKey: "agent:codex:acp:target",
158
+ runId: "run-1",
159
+ });
160
+ });
161
+
162
+ it("cancels an aborted request and rejects a late Gateway answer", async () => {
163
+ let registered = false;
164
+ gatewayCallMock.mockImplementation(
165
+ async (
166
+ method: string,
167
+ _opts: unknown,
168
+ rawParams: unknown,
169
+ extra?: { signal?: AbortSignal },
170
+ ) => {
171
+ if (method === "question.request") {
172
+ registered = true;
173
+ return { id: (rawParams as { id: string }).id };
174
+ }
175
+ if (method === "question.waitAnswer") {
176
+ return await new Promise((_, reject) => {
177
+ extra?.signal?.addEventListener("abort", () => reject(new Error("late answer")), {
178
+ once: true,
179
+ });
180
+ });
181
+ }
182
+ return { status: "cancelled" };
183
+ },
184
+ );
185
+ const controller = new AbortController();
186
+ const { handler } = createFixture();
187
+ const response = handler(urlRequest(), { requestId: "abort", signal: controller.signal });
188
+ await vi.waitFor(() => expect(registered).toBe(true));
189
+
190
+ controller.abort();
191
+
192
+ await expect(response).resolves.toMatchObject({ action: "cancel" });
193
+ });
194
+
195
+ it("keeps explicitly marked Codex secrets out of Gateway question records", async () => {
196
+ gatewayCallMock.mockRejectedValue(new Error("secret must not reach Gateway questions"));
197
+ const { delivered, handler } = createFixture();
198
+ const response = handler(
199
+ formRequest(
200
+ {
201
+ token: {
202
+ type: "string",
203
+ title: "Token",
204
+ _meta: { codex: { isSecret: true } },
205
+ },
206
+ },
207
+ ["token"],
208
+ ),
209
+ { requestId: "secret", signal: new AbortController().signal },
210
+ );
211
+ await vi.waitFor(() => expect(delivered.join("\n")).toContain("may show your reply"));
212
+
213
+ await expect(
214
+ claimPendingAgentQuestionAnswer({
215
+ sessionKey: "agent:codex:acp:target",
216
+ text: "private-value",
217
+ }),
218
+ ).resolves.toBe(true);
219
+
220
+ await expect(response).resolves.toEqual({
221
+ action: "accept",
222
+ content: { token: "private-value" },
223
+ });
224
+ expect(gatewayCallMock).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it("accepts an ACP form elicitation with an empty schema instead of declining it", async () => {
228
+ const { handler } = createFixture();
229
+
230
+ const response = handler(formRequest({}), {
231
+ requestId: "empty-form",
232
+ signal: new AbortController().signal,
233
+ });
234
+
235
+ await expect(response).resolves.toEqual({
236
+ action: "accept",
237
+ content: {},
238
+ });
239
+ });
240
+ });
src/auto-reply/reply/acp-elicitation-handler.ts ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ AcpElicitationHandler,
4
+ AcpElicitationResponse,
5
+ AcpJsonRpcId,
6
+ } from "@openclaw/acp-core/runtime/types";
7
+ import { runStructuredInput } from "../../agents/harness/structured-input-execution.js";
8
+ import type { ReplyPayload } from "../types.js";
9
+ import { parseAcpElicitationRequest } from "./acp-elicitation.js";
10
+
11
+ const DEFAULT_ELICITATION_TIMEOUT_MS = 15 * 60_000;
12
+ const MAX_REQUEST_ID_TEXT = 128;
13
+
14
+ type AcpElicitationDelivery = {
15
+ deliver: (kind: "block", payload: ReplyPayload) => Promise<boolean>;
16
+ };
17
+
18
+ export type AcpElicitationHandlerParams = {
19
+ sourceSessionKey: string;
20
+ targetSessionKey: string;
21
+ outerRequestId: string;
22
+ agentId: string;
23
+ runId: string;
24
+ delivery: AcpElicitationDelivery;
25
+ isActive: () => boolean;
26
+ };
27
+
28
+ function questionId(params: {
29
+ contextRequestId: AcpJsonRpcId;
30
+ correlation: unknown;
31
+ sourceSessionKey: string;
32
+ targetSessionKey: string;
33
+ outerRequestId: string;
34
+ batch: number;
35
+ }): string {
36
+ const digest = createHash("sha256").update(JSON.stringify(params)).digest("hex").slice(0, 24);
37
+ return `acp_${digest}_${params.batch}`;
38
+ }
39
+
40
+ function cancellation(message: string): AcpElicitationResponse {
41
+ return { action: "cancel", _meta: { message } };
42
+ }
43
+
44
+ function decline(message?: string): AcpElicitationResponse {
45
+ return { action: "decline", ...(message ? { _meta: { message } } : {}) };
46
+ }
47
+
48
+ function isContextRequestIdValid(value: AcpJsonRpcId): boolean {
49
+ return (
50
+ value === null ||
51
+ (typeof value === "number" && Number.isFinite(value)) ||
52
+ (typeof value === "string" && value.length > 0 && value.length <= MAX_REQUEST_ID_TEXT)
53
+ );
54
+ }
55
+
56
+ /** Creates the turn-owned ACP form/URL bridge used by channel delivery. */
57
+ export function createAcpElicitationHandler(
58
+ params: AcpElicitationHandlerParams,
59
+ ): AcpElicitationHandler {
60
+ const delivery = {
61
+ onBlockReply: async (payload: ReplyPayload) => {
62
+ await params.delivery.deliver("block", payload);
63
+ },
64
+ };
65
+ return async (request, context) => {
66
+ if (
67
+ !isContextRequestIdValid(context.requestId) ||
68
+ context.signal.aborted ||
69
+ !params.isActive()
70
+ ) {
71
+ return cancellation("ACP input request is no longer active.");
72
+ }
73
+ const parsed = parseAcpElicitationRequest(request);
74
+ const result = await runStructuredInput({
75
+ input: parsed.input,
76
+ sessionKey: params.targetSessionKey,
77
+ agentId: params.agentId,
78
+ runId: params.runId,
79
+ timeoutMs: DEFAULT_ELICITATION_TIMEOUT_MS,
80
+ delivery,
81
+ signal: context.signal,
82
+ isActive: params.isActive,
83
+ questionId: (batch) =>
84
+ questionId({
85
+ contextRequestId: context.requestId,
86
+ correlation: parsed.correlation,
87
+ sourceSessionKey: params.sourceSessionKey,
88
+ targetSessionKey: params.targetSessionKey,
89
+ outerRequestId: params.outerRequestId,
90
+ batch,
91
+ }),
92
+ promptOptions: {
93
+ unsupportedIntro: "ACP input request could not be shown:",
94
+ urlIntro: "ACP needs confirmation:",
95
+ },
96
+ });
97
+ if (result.status === "answered") {
98
+ return parsed.input.kind === "ready" && parsed.input.plan.kind === "url"
99
+ ? { action: "accept" }
100
+ : { action: "accept", content: result.content };
101
+ }
102
+ if (result.status === "declined") {
103
+ return decline(result.message);
104
+ }
105
+ if (result.status === "unsupported") {
106
+ return decline(result.message);
107
+ }
108
+ return cancellation(result.message ?? "ACP input request was cancelled.");
109
+ };
110
+ }
src/auto-reply/reply/acp-elicitation.ts ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AcpElicitationRequest } from "@openclaw/acp-core/runtime/types";
2
+ import {
3
+ compileStructuredInputForm,
4
+ compileStructuredInputUrl,
5
+ isStructuredInputRecord,
6
+ snapshotStructuredInput,
7
+ type StructuredInputCompileResult,
8
+ type StructuredInputRecord,
9
+ type StructuredInputValue,
10
+ } from "../../agents/harness/structured-input.js";
11
+
12
+ const MAX_CORRELATION_TEXT = 128;
13
+
14
+ type ParsedAcpElicitationRequest = {
15
+ input: StructuredInputCompileResult;
16
+ correlation?:
17
+ | { sessionId: string; toolCallId?: string | null }
18
+ | { requestId: string | number | null };
19
+ };
20
+
21
+ /** Adapts ACP wire scope and codex-acp metadata into the shared structured-input compiler. */
22
+ export function parseAcpElicitationRequest(
23
+ request: AcpElicitationRequest,
24
+ ): ParsedAcpElicitationRequest {
25
+ const snapshot = snapshotStructuredInput(request);
26
+ if (!isStructuredInputRecord(snapshot)) {
27
+ return unsupported("OpenClaw declined a malformed or over-limit ACP input request.");
28
+ }
29
+ const correlation = readScope(snapshot);
30
+ if (typeof correlation === "string") {
31
+ return unsupported(correlation);
32
+ }
33
+ const mode = readAcpElicitationString(snapshot, "mode");
34
+ if (mode === "url") {
35
+ return {
36
+ correlation,
37
+ input: compileStructuredInputUrl({
38
+ url: readValue(snapshot, "url"),
39
+ elicitationId: readValue(snapshot, "elicitationId"),
40
+ message: readValue(snapshot, "message"),
41
+ fallbackMessage: "ACP provided a URL",
42
+ protocolName: "ACP",
43
+ }),
44
+ };
45
+ }
46
+ if (mode !== "form") {
47
+ return unsupported(
48
+ `OpenClaw does not support ACP elicitation mode ${JSON.stringify(mode ?? "unknown")}.`,
49
+ );
50
+ }
51
+ return {
52
+ correlation,
53
+ input: compileStructuredInputForm({
54
+ schema: readValue(snapshot, "requestedSchema"),
55
+ message: readAcpElicitationString(snapshot, "message"),
56
+ fallbackMessage: "ACP needs input",
57
+ options: {
58
+ protocolName: "ACP",
59
+ allowEmptyForm: true,
60
+ minimumChoiceCount: 2,
61
+ booleanLabels: ["True", "False"],
62
+ metadata: {
63
+ secretPath: ["_meta", "codex", "isSecret"],
64
+ otherAnswerPath: ["_meta", "codex", "isOtherAnswer"],
65
+ otherQuestionIdPath: ["_meta", "codex", "questionId"],
66
+ },
67
+ },
68
+ }),
69
+ };
70
+ }
71
+
72
+ function readScope(
73
+ request: StructuredInputRecord,
74
+ ): ParsedAcpElicitationRequest["correlation"] | string {
75
+ const sessionId = readValue(request, "sessionId");
76
+ const requestId = readValue(request, "requestId");
77
+ const hasSession = sessionId !== undefined;
78
+ const hasRequest = requestId !== undefined;
79
+ if (hasSession === hasRequest) {
80
+ return "OpenClaw declined an ACP input request with an invalid or ambiguous scope.";
81
+ }
82
+ if (hasSession) {
83
+ const normalizedSessionId = readCorrelationText(sessionId);
84
+ if (!normalizedSessionId) {
85
+ return "OpenClaw declined an ACP input request with an invalid session id.";
86
+ }
87
+ const toolCallId = readValue(request, "toolCallId");
88
+ const normalizedToolCallId =
89
+ toolCallId === undefined || toolCallId === null
90
+ ? toolCallId
91
+ : readCorrelationText(toolCallId);
92
+ if (toolCallId !== undefined && toolCallId !== null && !normalizedToolCallId) {
93
+ return "OpenClaw declined an ACP input request with an invalid tool-call id.";
94
+ }
95
+ return {
96
+ sessionId: normalizedSessionId,
97
+ ...(toolCallId === undefined ? {} : { toolCallId: normalizedToolCallId ?? null }),
98
+ };
99
+ }
100
+ if (requestId === null) {
101
+ return { requestId };
102
+ }
103
+ if (typeof requestId === "number") {
104
+ return { requestId };
105
+ }
106
+ const normalizedRequestId = readCorrelationText(requestId);
107
+ return normalizedRequestId
108
+ ? { requestId: normalizedRequestId }
109
+ : "OpenClaw declined an ACP input request with an invalid request scope.";
110
+ }
111
+
112
+ function readCorrelationText(value: unknown): string | undefined {
113
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_CORRELATION_TEXT) {
114
+ return undefined;
115
+ }
116
+ for (const character of value) {
117
+ const codePoint = character.codePointAt(0) ?? 0;
118
+ if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
119
+ return undefined;
120
+ }
121
+ }
122
+ return value;
123
+ }
124
+
125
+ function readValue(record: StructuredInputRecord, key: string): StructuredInputValue | undefined {
126
+ return Object.hasOwn(record, key) ? record[key] : undefined;
127
+ }
128
+
129
+ function readAcpElicitationString(record: StructuredInputRecord, key: string): string | undefined {
130
+ const value = readValue(record, key);
131
+ return typeof value === "string" ? value : undefined;
132
+ }
133
+
134
+ function unsupported(message: string): ParsedAcpElicitationRequest {
135
+ return { input: { kind: "unsupported", message } };
136
+ }
src/auto-reply/reply/acp-projector.test.ts ADDED
@@ -0,0 +1,688 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tests ACP event projection into session updates and reply payloads.
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { prefixSystemMessage } from "../../infra/system-message.js";
4
+ import { createAcpReplyProjector } from "./acp-projector.js";
5
+ import { createAcpTestConfig as createCfg } from "./test-fixtures/acp-runtime.js";
6
+
7
+ type Delivery = { kind: string; text?: string };
8
+
9
+ function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
10
+ let count = 0;
11
+ for (const item of items) {
12
+ if (predicate(item)) {
13
+ count += 1;
14
+ }
15
+ }
16
+ return count;
17
+ }
18
+
19
+ function createProjectorHarness(
20
+ cfgOverrides?: Parameters<typeof createCfg>[0],
21
+ opts?: {
22
+ onProgress?: () => void;
23
+ shouldSendToolSummaries?: boolean;
24
+ shouldSendToolSummariesNow?: () => boolean;
25
+ shouldSendFullToolDetails?: boolean;
26
+ getConversationContext?: () => string | undefined;
27
+ },
28
+ ) {
29
+ const deliveries: Delivery[] = [];
30
+ const projector = createAcpReplyProjector({
31
+ cfg: createCfg(cfgOverrides),
32
+ shouldSendToolSummaries: opts?.shouldSendToolSummaries ?? true,
33
+ shouldSendToolSummariesNow: opts?.shouldSendToolSummariesNow,
34
+ shouldSendFullToolDetails: opts?.shouldSendFullToolDetails ?? false,
35
+ deliver: async (kind, payload) => {
36
+ deliveries.push({ kind, text: payload.text });
37
+ return true;
38
+ },
39
+ getConversationContext: opts?.getConversationContext,
40
+ onProgress: opts?.onProgress,
41
+ });
42
+ return { deliveries, projector };
43
+ }
44
+
45
+ function createStreamHarness(
46
+ deliveryMode: "live" | "final_only",
47
+ streamOverrides: Record<string, unknown> = {},
48
+ opts?: Parameters<typeof createProjectorHarness>[1],
49
+ ) {
50
+ return createProjectorHarness(
51
+ {
52
+ acp: { enabled: true, stream: { deliveryMode, ...streamOverrides } },
53
+ } as Parameters<typeof createCfg>[0],
54
+ opts,
55
+ );
56
+ }
57
+
58
+ function blockDeliveries(deliveries: Delivery[]) {
59
+ return deliveries.filter((entry) => entry.kind === "block");
60
+ }
61
+
62
+ function combinedBlockText(deliveries: Delivery[]) {
63
+ return blockDeliveries(deliveries)
64
+ .map((entry) => entry.text ?? "")
65
+ .join("");
66
+ }
67
+
68
+ function expectToolCallSummary(delivery: Delivery | undefined) {
69
+ expect(delivery?.kind).toBe("tool");
70
+ expect(delivery?.text).toContain("Tool Call");
71
+ }
72
+
73
+ const statusAndToolTagVisibility = { available_commands_update: true, tool_call: true };
74
+
75
+ function createFinalOnlyStatusToolHarness(opts?: Parameters<typeof createProjectorHarness>[1]) {
76
+ return createStreamHarness("final_only", { tagVisibility: statusAndToolTagVisibility }, opts);
77
+ }
78
+
79
+ function createLiveToolLifecycleHarness(params?: {
80
+ repeatSuppression?: boolean;
81
+ includeStatus?: boolean;
82
+ }) {
83
+ const { includeStatus = false, ...streamOverrides } = params ?? {};
84
+ return createStreamHarness("live", {
85
+ ...streamOverrides,
86
+ tagVisibility: {
87
+ ...(includeStatus ? { available_commands_update: true } : {}),
88
+ tool_call: true,
89
+ tool_call_update: true,
90
+ },
91
+ });
92
+ }
93
+
94
+ type Projector = ReturnType<typeof createProjectorHarness>["projector"];
95
+ type ProjectorEvent = Parameters<Projector["onEvent"]>[0];
96
+ type EventOf<T extends ProjectorEvent["type"]> = Extract<ProjectorEvent, { type: T }>;
97
+
98
+ function emitText(
99
+ projector: Projector,
100
+ text: string,
101
+ overrides: Partial<Omit<EventOf<"text_delta">, "type" | "text">> = {},
102
+ ) {
103
+ return projector.onEvent({ type: "text_delta", text, tag: "agent_message_chunk", ...overrides });
104
+ }
105
+
106
+ function emitStatus(
107
+ projector: Projector,
108
+ text: string,
109
+ tag: EventOf<"status">["tag"],
110
+ overrides: Partial<Omit<EventOf<"status">, "type" | "text" | "tag">> = {},
111
+ ) {
112
+ return projector.onEvent({ type: "status", text, tag, ...overrides });
113
+ }
114
+
115
+ function emitTool(projector: Projector, event: Omit<EventOf<"tool_call">, "type">) {
116
+ return projector.onEvent({ type: "tool_call", ...event });
117
+ }
118
+
119
+ async function runHiddenBoundaryCase(params: {
120
+ streamOverrides?: Record<string, unknown>;
121
+ toolCallId: string;
122
+ includeNonTerminalUpdate?: boolean;
123
+ firstText?: string;
124
+ secondText?: string;
125
+ expectedText: string;
126
+ }) {
127
+ const { deliveries, projector } = createStreamHarness("live", params.streamOverrides);
128
+ await emitText(projector, params.firstText ?? "fallback.");
129
+ await emitTool(projector, {
130
+ tag: "tool_call",
131
+ toolCallId: params.toolCallId,
132
+ status: "in_progress",
133
+ title: "Run test",
134
+ text: "Run test (in_progress)",
135
+ });
136
+ if (params.includeNonTerminalUpdate) {
137
+ await emitTool(projector, {
138
+ tag: "tool_call_update",
139
+ toolCallId: params.toolCallId,
140
+ status: "in_progress",
141
+ title: "Run test",
142
+ text: "Run test (in_progress)",
143
+ });
144
+ }
145
+ await emitText(projector, params.secondText ?? "I don't");
146
+ await projector.flush(true);
147
+
148
+ expect(combinedBlockText(deliveries)).toBe(params.expectedText);
149
+ }
150
+
151
+ describe("createAcpReplyProjector", () => {
152
+ it("shows execute details only in full verbose mode", async () => {
153
+ const summary = createLiveToolLifecycleHarness();
154
+ await emitTool(summary.projector, {
155
+ tag: "tool_call",
156
+ toolCallId: "call_execute_summary",
157
+ kind: "execute",
158
+ status: "in_progress",
159
+ title: "exec: cat /private/operator-file",
160
+ text: "exec: cat /private/operator-file (in_progress)",
161
+ });
162
+ expect(summary.deliveries[0]?.text).not.toContain("cat /private/operator-file");
163
+ expect(summary.deliveries[0]?.text).toContain("status=in_progress");
164
+
165
+ const full = createStreamHarness(
166
+ "live",
167
+ { tagVisibility: { tool_call: true } },
168
+ { shouldSendFullToolDetails: true },
169
+ );
170
+ await emitTool(full.projector, {
171
+ tag: "tool_call",
172
+ toolCallId: "call_execute_full",
173
+ kind: "execute",
174
+ status: "in_progress",
175
+ title: "exec: cat /private/operator-file",
176
+ text: "exec: cat /private/operator-file (in_progress)",
177
+ });
178
+ expect(full.deliveries[0]?.text).toContain("cat /private/operator-file");
179
+ });
180
+
181
+ it("reports progress for ACP runtime events before delivery filtering", async () => {
182
+ const onProgress = vi.fn();
183
+ const { projector } = createProjectorHarness(undefined, { onProgress });
184
+
185
+ await emitText(projector, "hidden reasoning", { stream: "thought" });
186
+ await emitTool(projector, {
187
+ tag: "tool_call",
188
+ toolCallId: "tool-1",
189
+ status: "in_progress",
190
+ title: "Run command",
191
+ text: "Run command",
192
+ });
193
+
194
+ expect(onProgress).toHaveBeenCalledTimes(2);
195
+ });
196
+
197
+ it("buffers default final-only text into one final reply", async () => {
198
+ const { deliveries, projector } = createProjectorHarness();
199
+
200
+ await emitText(projector, "a".repeat(70));
201
+ await projector.flush(true);
202
+
203
+ expect(deliveries).toEqual([{ kind: "final", text: "a".repeat(70) }]);
204
+ });
205
+
206
+ it.each(["live", "final_only"] as const)(
207
+ "uses finalized and refreshed owner context to redact split private prompts in %s mode",
208
+ async (deliveryMode) => {
209
+ const marker = "[Current message - respond to this]";
210
+ let conversationContext = "";
211
+ const { deliveries, projector } = createStreamHarness(
212
+ deliveryMode,
213
+ {},
214
+ {
215
+ getConversationContext: () => conversationContext,
216
+ },
217
+ );
218
+ conversationContext = `${marker}\nPrivate secret. Keep hidden.`;
219
+
220
+ await emitText(projector, "Visible answer before. ");
221
+ conversationContext = `${marker}\nPrivate updated context. Keep hidden.`;
222
+ await emitText(projector, `${marker}\nPrivate updated context. `);
223
+ await emitText(projector, "Keep hidden. Visible answer after.");
224
+ await projector.flush(true);
225
+
226
+ expect(deliveries.map((delivery) => delivery.text).join("")).toBe(
227
+ "Visible answer before. Visible answer after.",
228
+ );
229
+ },
230
+ );
231
+
232
+ it("rechecks the dynamic tool-summary gate for each ACP event", async () => {
233
+ let allowToolSummaries = false;
234
+ const { deliveries, projector } = createStreamHarness(
235
+ "live",
236
+ {
237
+ tagVisibility: {
238
+ tool_call: true,
239
+ },
240
+ },
241
+ {
242
+ shouldSendToolSummaries: false,
243
+ shouldSendToolSummariesNow: () => allowToolSummaries,
244
+ },
245
+ );
246
+
247
+ await emitTool(projector, {
248
+ tag: "tool_call",
249
+ toolCallId: "tool-1",
250
+ status: "in_progress",
251
+ title: "Run hidden command",
252
+ text: "Run hidden command",
253
+ });
254
+ expect(deliveries).toEqual([]);
255
+
256
+ allowToolSummaries = true;
257
+ await emitTool(projector, {
258
+ tag: "tool_call",
259
+ toolCallId: "tool-2",
260
+ status: "in_progress",
261
+ title: "Run visible command",
262
+ text: "Run visible command",
263
+ });
264
+
265
+ expectToolCallSummary(deliveries[0]);
266
+ });
267
+
268
+ it("drops buffered final-only tool summaries when the dynamic gate turns off before flush", async () => {
269
+ let allowToolSummaries = true;
270
+ const { deliveries, projector } = createFinalOnlyStatusToolHarness({
271
+ shouldSendToolSummariesNow: () => allowToolSummaries,
272
+ });
273
+
274
+ await emitStatus(projector, "available commands updated (7)", "available_commands_update");
275
+ await emitTool(projector, {
276
+ tag: "tool_call",
277
+ toolCallId: "tool-1",
278
+ status: "in_progress",
279
+ title: "Run hidden command",
280
+ text: "Run hidden command",
281
+ });
282
+ await emitText(projector, "done");
283
+ allowToolSummaries = false;
284
+
285
+ await projector.flush(true);
286
+
287
+ expect(deliveries).toEqual([{ kind: "final", text: "done" }]);
288
+ });
289
+
290
+ it("preserves final-only text boundary when a buffered tool summary is dropped", async () => {
291
+ let allowToolSummaries = true;
292
+ const { deliveries, projector } = createStreamHarness(
293
+ "final_only",
294
+ {
295
+ tagVisibility: {
296
+ tool_call: true,
297
+ },
298
+ },
299
+ { shouldSendToolSummariesNow: () => allowToolSummaries },
300
+ );
301
+
302
+ await emitText(projector, "fallback.");
303
+ await emitTool(projector, {
304
+ tag: "tool_call",
305
+ toolCallId: "tool-dropped-before-flush",
306
+ status: "in_progress",
307
+ title: "Run test",
308
+ text: "Run test (in_progress)",
309
+ });
310
+ await emitText(projector, "I don't");
311
+ allowToolSummaries = false;
312
+
313
+ await projector.flush(true);
314
+
315
+ expect(deliveries).toEqual([{ kind: "final", text: "fallback.\n\nI don't" }]);
316
+ });
317
+
318
+ it("flushes staggered live text deltas after idle gaps", async () => {
319
+ vi.useFakeTimers();
320
+ try {
321
+ const { deliveries, projector } = createStreamHarness("live");
322
+
323
+ await emitText(projector, "A");
324
+ await vi.advanceTimersByTimeAsync(760);
325
+ await projector.flush(false);
326
+
327
+ await emitText(projector, "B");
328
+ await vi.advanceTimersByTimeAsync(760);
329
+ await projector.flush(false);
330
+
331
+ await emitText(projector, "C");
332
+ await vi.advanceTimersByTimeAsync(760);
333
+ await projector.flush(false);
334
+
335
+ expect(blockDeliveries(deliveries)).toEqual([
336
+ { kind: "block", text: "A" },
337
+ { kind: "block", text: "B" },
338
+ { kind: "block", text: "C" },
339
+ ]);
340
+ } finally {
341
+ vi.useRealTimers();
342
+ }
343
+ });
344
+
345
+ it("does not flush short live fragments mid-phrase on idle", async () => {
346
+ vi.useFakeTimers();
347
+ try {
348
+ const { deliveries, projector } = createStreamHarness("live");
349
+
350
+ await emitText(projector, "Yes. Send me the term(s), and I’ll run ");
351
+
352
+ await vi.advanceTimersByTimeAsync(1200);
353
+ expect(deliveries).toStrictEqual([]);
354
+
355
+ await emitText(projector, "`wd-cli` searches right away. ");
356
+ await projector.flush(false);
357
+
358
+ expect(deliveries).toEqual([
359
+ {
360
+ kind: "block",
361
+ text: "Yes. Send me the term(s), and I’ll run `wd-cli` searches right away. ",
362
+ },
363
+ ]);
364
+ } finally {
365
+ vi.useRealTimers();
366
+ }
367
+ });
368
+
369
+ it("supports deliveryMode=final_only by buffering all projected output until done", async () => {
370
+ const { deliveries, projector } = createFinalOnlyStatusToolHarness();
371
+
372
+ await emitText(projector, "What");
373
+ await emitStatus(projector, "available commands updated (7)", "available_commands_update");
374
+ await emitTool(projector, {
375
+ tag: "tool_call",
376
+ toolCallId: "call_1",
377
+ status: "in_progress",
378
+ title: "List files",
379
+ text: "List files (in_progress)",
380
+ });
381
+ await emitText(projector, " now?");
382
+ expect(deliveries).toStrictEqual([]);
383
+
384
+ await projector.flush(true);
385
+ expect(deliveries).toHaveLength(3);
386
+ expect(deliveries[0]).toEqual({
387
+ kind: "tool",
388
+ text: prefixSystemMessage("available commands updated (7)"),
389
+ });
390
+ expectToolCallSummary(deliveries[1]);
391
+ expect(deliveries[2]).toEqual({ kind: "final", text: "What now?" });
392
+ });
393
+
394
+ it("flushes buffered status/tool output on error in deliveryMode=final_only", async () => {
395
+ const { deliveries, projector } = createFinalOnlyStatusToolHarness();
396
+
397
+ await emitStatus(projector, "available commands updated (7)", "available_commands_update");
398
+ await emitTool(projector, {
399
+ tag: "tool_call",
400
+ toolCallId: "call_2",
401
+ status: "in_progress",
402
+ title: "Run tests",
403
+ text: "Run tests (in_progress)",
404
+ });
405
+ expect(deliveries).toStrictEqual([]);
406
+
407
+ await projector.flush(true);
408
+ expect(deliveries).toHaveLength(2);
409
+ expect(deliveries[0]).toEqual({
410
+ kind: "tool",
411
+ text: prefixSystemMessage("available commands updated (7)"),
412
+ });
413
+ expectToolCallSummary(deliveries[1]);
414
+ });
415
+
416
+ it("suppresses usage_update by default and allows deduped usage when tag-visible", async () => {
417
+ const { deliveries: hidden, projector: hiddenProjector } = createProjectorHarness();
418
+ await emitStatus(hiddenProjector, "usage updated: 10/100", "usage_update", {
419
+ used: 10,
420
+ size: 100,
421
+ });
422
+ expect(hidden).toStrictEqual([]);
423
+
424
+ const { deliveries: shown, projector: shownProjector } = createStreamHarness("live", {
425
+ tagVisibility: {
426
+ usage_update: true,
427
+ },
428
+ });
429
+
430
+ await emitStatus(shownProjector, "usage updated: 10/100", "usage_update", {
431
+ used: 10,
432
+ size: 100,
433
+ });
434
+ await emitStatus(shownProjector, "usage updated: 10/100", "usage_update", {
435
+ used: 10,
436
+ size: 100,
437
+ });
438
+ await emitStatus(shownProjector, "usage updated: 11/100", "usage_update", {
439
+ used: 11,
440
+ size: 100,
441
+ });
442
+
443
+ expect(shown).toEqual([
444
+ { kind: "tool", text: prefixSystemMessage("usage updated: 10/100") },
445
+ { kind: "tool", text: prefixSystemMessage("usage updated: 11/100") },
446
+ ]);
447
+ });
448
+
449
+ it("hides available_commands_update by default", async () => {
450
+ const { deliveries, projector } = createProjectorHarness();
451
+ await emitStatus(projector, "available commands updated (7)", "available_commands_update");
452
+
453
+ expect(deliveries).toStrictEqual([]);
454
+ });
455
+
456
+ it("dedupes repeated tool lifecycle updates when repeatSuppression is enabled", async () => {
457
+ const { deliveries, projector } = createLiveToolLifecycleHarness();
458
+
459
+ await emitTool(projector, {
460
+ tag: "tool_call",
461
+ toolCallId: "call_1",
462
+ status: "in_progress",
463
+ title: "List files",
464
+ text: "List files (in_progress)",
465
+ });
466
+ await emitTool(projector, {
467
+ tag: "tool_call_update",
468
+ toolCallId: "call_1",
469
+ status: "in_progress",
470
+ title: "List files",
471
+ text: "List files (in_progress)",
472
+ });
473
+ await emitTool(projector, {
474
+ tag: "tool_call_update",
475
+ toolCallId: "call_1",
476
+ status: "completed",
477
+ title: "List files",
478
+ text: "List files (completed)",
479
+ });
480
+ await emitTool(projector, {
481
+ tag: "tool_call_update",
482
+ toolCallId: "call_1",
483
+ status: "completed",
484
+ title: "List files",
485
+ text: "List files (completed)",
486
+ });
487
+
488
+ expect(deliveries.length).toBe(2);
489
+ expectToolCallSummary(deliveries[0]);
490
+ expectToolCallSummary(deliveries[1]);
491
+ });
492
+
493
+ it("keeps terminal tool updates even when rendered summaries are truncated", async () => {
494
+ const { deliveries, projector } = createLiveToolLifecycleHarness({});
495
+
496
+ const longTitle =
497
+ "Run an intentionally long command title that truncates before lifecycle status is visible";
498
+ await emitTool(projector, {
499
+ tag: "tool_call",
500
+ toolCallId: "call_truncated_status",
501
+ status: "in_progress",
502
+ title: longTitle,
503
+ text: `${longTitle} (in_progress)`,
504
+ });
505
+ await emitTool(projector, {
506
+ tag: "tool_call_update",
507
+ toolCallId: "call_truncated_status",
508
+ status: "completed",
509
+ title: longTitle,
510
+ text: `${longTitle} (completed)`,
511
+ });
512
+
513
+ expect(deliveries.length).toBe(2);
514
+ expectToolCallSummary(deliveries[0]);
515
+ expectToolCallSummary(deliveries[1]);
516
+ });
517
+
518
+ it("renders fallback tool labels without leaking call ids as primary label", async () => {
519
+ const { deliveries, projector } = createLiveToolLifecycleHarness();
520
+
521
+ await emitTool(projector, {
522
+ tag: "tool_call",
523
+ toolCallId: "call_ABC123",
524
+ status: "in_progress",
525
+ text: "call_ABC123 (in_progress)",
526
+ });
527
+
528
+ expectToolCallSummary(deliveries[0]);
529
+ expect(deliveries[0]?.text).not.toContain("call_ABC123 (");
530
+ });
531
+
532
+ it("allows repeated status/tool summaries when repeatSuppression is disabled", async () => {
533
+ const { deliveries, projector } = createLiveToolLifecycleHarness({
534
+ repeatSuppression: false,
535
+ includeStatus: true,
536
+ });
537
+
538
+ await emitStatus(projector, "available commands updated", "available_commands_update");
539
+ await emitStatus(projector, "available commands updated", "available_commands_update");
540
+ await emitTool(projector, {
541
+ text: "tool call",
542
+ tag: "tool_call",
543
+ toolCallId: "x",
544
+ status: "in_progress",
545
+ });
546
+ await emitTool(projector, {
547
+ text: "tool call",
548
+ tag: "tool_call_update",
549
+ toolCallId: "x",
550
+ status: "in_progress",
551
+ });
552
+ await emitText(projector, "hello");
553
+ await projector.flush(true);
554
+
555
+ expect(countMatching(deliveries, (entry) => entry.kind === "tool")).toBe(4);
556
+ expect(deliveries[0]).toEqual({
557
+ kind: "tool",
558
+ text: prefixSystemMessage("available commands updated"),
559
+ });
560
+ expect(deliveries[1]).toEqual({
561
+ kind: "tool",
562
+ text: prefixSystemMessage("available commands updated"),
563
+ });
564
+ expectToolCallSummary(deliveries[2]);
565
+ expectToolCallSummary(deliveries[3]);
566
+ expect(deliveries[4]).toEqual({ kind: "block", text: "hello" });
567
+ });
568
+
569
+ it("suppresses exact duplicate status updates when repeatSuppression is enabled", async () => {
570
+ const { deliveries, projector } = createStreamHarness("live", {
571
+ tagVisibility: {
572
+ available_commands_update: true,
573
+ },
574
+ });
575
+
576
+ await emitStatus(projector, "available commands updated (7)", "available_commands_update");
577
+ await emitStatus(projector, "available commands updated (7)", "available_commands_update");
578
+ await emitStatus(projector, "available commands updated (8)", "available_commands_update");
579
+
580
+ expect(deliveries).toEqual([
581
+ { kind: "tool", text: prefixSystemMessage("available commands updated (7)") },
582
+ { kind: "tool", text: prefixSystemMessage("available commands updated (8)") },
583
+ ]);
584
+ });
585
+
586
+ it("supports tagVisibility overrides for tool updates", async () => {
587
+ const { deliveries, projector } = createStreamHarness("live", {
588
+ tagVisibility: {
589
+ tool_call: true,
590
+ tool_call_update: false,
591
+ },
592
+ });
593
+
594
+ await emitTool(projector, {
595
+ tag: "tool_call",
596
+ toolCallId: "c1",
597
+ status: "in_progress",
598
+ title: "Run tests",
599
+ text: "Run tests (in_progress)",
600
+ });
601
+ await emitTool(projector, {
602
+ tag: "tool_call_update",
603
+ toolCallId: "c1",
604
+ status: "completed",
605
+ title: "Run tests",
606
+ text: "Run tests (completed)",
607
+ });
608
+
609
+ expect(deliveries.length).toBe(1);
610
+ expectToolCallSummary(deliveries[0]);
611
+ });
612
+
613
+ it("inserts a space boundary before visible text after hidden tool updates by default", async () => {
614
+ await runHiddenBoundaryCase({
615
+ toolCallId: "call_hidden_1",
616
+ expectedText: "fallback. I don't",
617
+ });
618
+ });
619
+
620
+ it("preserves hidden boundary when the dynamic tool-summary gate hides a visible tool event", async () => {
621
+ const { deliveries, projector } = createStreamHarness(
622
+ "live",
623
+ {
624
+ tagVisibility: {
625
+ tool_call: true,
626
+ },
627
+ },
628
+ {
629
+ shouldSendToolSummaries: false,
630
+ shouldSendToolSummariesNow: () => false,
631
+ },
632
+ );
633
+
634
+ await emitText(projector, "fallback.");
635
+ await emitTool(projector, {
636
+ tag: "tool_call",
637
+ toolCallId: "call_dynamic_hidden",
638
+ status: "in_progress",
639
+ title: "Run test",
640
+ text: "Run test (in_progress)",
641
+ });
642
+ await emitText(projector, "I don't");
643
+ await projector.flush(true);
644
+
645
+ expect(combinedBlockText(deliveries)).toBe("fallback. I don't");
646
+ expect(deliveries.some((delivery) => delivery.kind === "tool")).toBe(false);
647
+ });
648
+
649
+ it("preserves hidden boundary across nonterminal hidden tool updates", async () => {
650
+ await runHiddenBoundaryCase({
651
+ streamOverrides: {
652
+ tagVisibility: {
653
+ tool_call: false,
654
+ tool_call_update: false,
655
+ },
656
+ },
657
+ toolCallId: "hidden_boundary_1",
658
+ includeNonTerminalUpdate: true,
659
+ expectedText: "fallback. I don't",
660
+ });
661
+ });
662
+
663
+ it("uses the built-in space separator for hidden live boundaries", async () => {
664
+ await runHiddenBoundaryCase({
665
+ toolCallId: "call_hidden_2",
666
+ expectedText: "fallback. I don't",
667
+ });
668
+ });
669
+
670
+ it("does not duplicate newlines when previous visible text already ends with newline", async () => {
671
+ await runHiddenBoundaryCase({
672
+ toolCallId: "call_hidden_4",
673
+ firstText: "fallback.\n",
674
+ expectedText: "fallback.\nI don't",
675
+ });
676
+ });
677
+
678
+ it("does not insert boundary separator for hidden non-tool status updates", async () => {
679
+ const { deliveries, projector } = createStreamHarness("live");
680
+
681
+ await emitText(projector, "A");
682
+ await emitStatus(projector, "available commands updated", "available_commands_update");
683
+ await emitText(projector, "B");
684
+ await projector.flush(true);
685
+
686
+ expect(combinedBlockText(deliveries)).toBe("AB");
687
+ });
688
+ });
src/auto-reply/reply/acp-projector.ts ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Projects ACP runtime events into OpenClaw-visible session update records.
2
+ import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "@openclaw/acp-core/runtime/types";
3
+ import {
4
+ normalizeOptionalLowercaseString,
5
+ normalizeOptionalString,
6
+ } from "@openclaw/normalization-core/string-coerce";
7
+ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
8
+ import { resolveAcpToolTerminalOutcome } from "../../acp/tool-status.js";
9
+ import { EmbeddedBlockChunker } from "../../agents/embedded-agent-block-chunker.js";
10
+ import { createVerifiedConversationContextStreamFilter } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
11
+ import { formatToolSummary, resolveToolDisplay } from "../../agents/tool-display.js";
12
+ import type { OpenClawConfig } from "../../config/types.openclaw.js";
13
+ import { prefixSystemMessage } from "../../infra/system-message.js";
14
+ import { truncateUtf16WithEllipsis as truncateText } from "../../shared/text-truncate.js";
15
+ import type { ReplyPayload } from "../types.js";
16
+ import {
17
+ type AcpHiddenBoundarySeparator,
18
+ isAcpTagVisible,
19
+ resolveAcpProjectionSettings,
20
+ resolveAcpStreamingConfig,
21
+ } from "./acp-stream-settings.js";
22
+ import { createBlockReplyPipeline } from "./block-reply-pipeline.js";
23
+ import type { ReplyDispatchKind } from "./reply-dispatcher.types.js";
24
+
25
+ const ACP_BLOCK_REPLY_TIMEOUT_MS = 15_000;
26
+ const ACP_LIVE_IDLE_FLUSH_FLOOR_MS = 750;
27
+ const ACP_LIVE_IDLE_MIN_CHARS = 80;
28
+ const ACP_LIVE_SOFT_FLUSH_CHARS = 220;
29
+ const ACP_LIVE_HARD_FLUSH_CHARS = 480;
30
+
31
+ const HIDDEN_BOUNDARY_TAGS = new Set<AcpSessionUpdateTag>(["tool_call", "tool_call_update"]);
32
+
33
+ type AcpProjectedDeliveryMeta = {
34
+ tag?: AcpSessionUpdateTag;
35
+ toolCallId?: string;
36
+ toolStatus?: string;
37
+ allowEdit?: boolean;
38
+ };
39
+
40
+ type ToolLifecycleState = {
41
+ started: boolean;
42
+ terminal: boolean;
43
+ lastRenderedHash?: string;
44
+ };
45
+
46
+ type BufferedToolDelivery = {
47
+ payload: ReplyPayload;
48
+ meta?: AcpProjectedDeliveryMeta;
49
+ };
50
+
51
+ function hashText(text: string): string {
52
+ return text.trim();
53
+ }
54
+
55
+ function normalizeToolStatus(status: string | undefined): string | undefined {
56
+ const normalized = normalizeOptionalLowercaseString(status);
57
+ return normalized || undefined;
58
+ }
59
+
60
+ function resolveHiddenBoundarySeparatorText(mode: AcpHiddenBoundarySeparator): string {
61
+ if (mode === "space") {
62
+ return " ";
63
+ }
64
+ if (mode === "newline") {
65
+ return "\n";
66
+ }
67
+ if (mode === "paragraph") {
68
+ return "\n\n";
69
+ }
70
+ return "";
71
+ }
72
+
73
+ function shouldInsertSeparator(params: {
74
+ separator: string;
75
+ previousTail: string | undefined;
76
+ nextText: string;
77
+ }): boolean {
78
+ if (!params.separator) {
79
+ return false;
80
+ }
81
+ if (!params.nextText) {
82
+ return false;
83
+ }
84
+ const firstChar = params.nextText[0];
85
+ if (typeof firstChar === "string" && /\s/.test(firstChar)) {
86
+ return false;
87
+ }
88
+ const tail = params.previousTail ?? "";
89
+ if (!tail) {
90
+ return false;
91
+ }
92
+ if (params.separator === " " && /\s$/.test(tail)) {
93
+ return false;
94
+ }
95
+ if ((params.separator === "\n" || params.separator === "\n\n") && tail.endsWith("\n")) {
96
+ return false;
97
+ }
98
+ return true;
99
+ }
100
+
101
+ function shouldFlushLiveBufferOnBoundary(text: string): boolean {
102
+ if (!text) {
103
+ return false;
104
+ }
105
+ if (text.length >= ACP_LIVE_HARD_FLUSH_CHARS) {
106
+ return true;
107
+ }
108
+ if (text.endsWith("\n\n")) {
109
+ return true;
110
+ }
111
+ if (/[.!?][)"'`]*\s$/.test(text)) {
112
+ return true;
113
+ }
114
+ if (text.length >= ACP_LIVE_SOFT_FLUSH_CHARS && /\s$/.test(text)) {
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+
120
+ function shouldFlushLiveBufferOnIdle(text: string): boolean {
121
+ if (!text) {
122
+ return false;
123
+ }
124
+ if (text.length >= ACP_LIVE_IDLE_MIN_CHARS) {
125
+ return true;
126
+ }
127
+ if (/[.!?][)"'`]*$/.test(text.trimEnd())) {
128
+ return true;
129
+ }
130
+ if (text.includes("\n")) {
131
+ return true;
132
+ }
133
+ return false;
134
+ }
135
+
136
+ function renderToolSummaryText(
137
+ event: Extract<AcpRuntimeEvent, { type: "tool_call" }>,
138
+ shouldSendFullToolDetails: boolean,
139
+ ): string {
140
+ const detailParts: string[] = [];
141
+ const commandBearing = normalizeOptionalLowercaseString(event.kind) === "execute";
142
+ const title =
143
+ shouldSendFullToolDetails || !commandBearing ? normalizeOptionalString(event.title) : undefined;
144
+ if (title) {
145
+ detailParts.push(title);
146
+ }
147
+ const status = normalizeOptionalString(event.status);
148
+ if (status) {
149
+ detailParts.push(`status=${status}`);
150
+ }
151
+ const fallback =
152
+ shouldSendFullToolDetails || !commandBearing ? normalizeOptionalString(event.text) : undefined;
153
+ if (detailParts.length === 0 && fallback) {
154
+ detailParts.push(fallback);
155
+ }
156
+ const display = resolveToolDisplay({
157
+ name: "tool_call",
158
+ meta: detailParts.join(" · ") || "tool call",
159
+ });
160
+ return formatToolSummary(display);
161
+ }
162
+
163
+ type AcpReplyProjector = {
164
+ onEvent: (event: AcpRuntimeEvent) => Promise<void>;
165
+ flush: (force?: boolean) => Promise<void>;
166
+ };
167
+
168
+ export function createAcpReplyProjector(params: {
169
+ cfg: OpenClawConfig;
170
+ shouldSendToolSummaries: boolean;
171
+ shouldSendToolSummariesNow?: () => boolean;
172
+ shouldSendFullToolDetails: boolean;
173
+ deliver: (
174
+ kind: ReplyDispatchKind,
175
+ payload: ReplyPayload,
176
+ meta?: AcpProjectedDeliveryMeta,
177
+ ) => Promise<boolean>;
178
+ getConversationContext?: () => string | undefined;
179
+ onProgress?: () => void;
180
+ provider?: string;
181
+ accountId?: string;
182
+ }): AcpReplyProjector {
183
+ const settings = resolveAcpProjectionSettings(params.cfg);
184
+ const streaming = resolveAcpStreamingConfig({
185
+ cfg: params.cfg,
186
+ provider: params.provider,
187
+ accountId: params.accountId,
188
+ deliveryMode: settings.deliveryMode,
189
+ });
190
+ const blockReplyPipeline = createBlockReplyPipeline({
191
+ onBlockReply: async (payload) => {
192
+ await params.deliver("block", payload);
193
+ },
194
+ timeoutMs: ACP_BLOCK_REPLY_TIMEOUT_MS,
195
+ coalescing: settings.deliveryMode === "live" ? undefined : streaming.coalescing,
196
+ });
197
+ const chunker = new EmbeddedBlockChunker(streaming.chunking);
198
+ const filterConversationContext = createVerifiedConversationContextStreamFilter(
199
+ params.getConversationContext,
200
+ );
201
+ const liveIdleFlushMs = Math.max(streaming.coalescing.idleMs, ACP_LIVE_IDLE_FLUSH_FLOOR_MS);
202
+
203
+ let emittedOutputChars = 0;
204
+ let truncationNoticeEmitted = false;
205
+ let lastStatusHash: string | undefined;
206
+ let lastToolHash: string | undefined;
207
+ let lastUsageTuple: string | undefined;
208
+ let lastVisibleOutputTail: string | undefined;
209
+ let pendingHiddenBoundary = false;
210
+ let liveBufferText = "";
211
+ let finalOnlyOutputText = "";
212
+ let liveIdleTimer: NodeJS.Timeout | undefined;
213
+ const pendingToolDeliveries: BufferedToolDelivery[] = [];
214
+ const toolLifecycleById = new Map<string, ToolLifecycleState>();
215
+
216
+ const shouldSendToolSummaries = () =>
217
+ params.shouldSendToolSummariesNow?.() ?? params.shouldSendToolSummaries;
218
+
219
+ const clearLiveIdleTimer = () => {
220
+ if (!liveIdleTimer) {
221
+ return;
222
+ }
223
+ clearTimeout(liveIdleTimer);
224
+ liveIdleTimer = undefined;
225
+ };
226
+
227
+ const drainChunker = (force: boolean) => {
228
+ if (settings.deliveryMode === "final_only" && !force) {
229
+ return;
230
+ }
231
+ chunker.drain({
232
+ force,
233
+ emit: (chunk) => {
234
+ blockReplyPipeline.enqueue({ text: chunk });
235
+ },
236
+ });
237
+ };
238
+
239
+ const flushLiveBuffer = (opts?: { force?: boolean; idle?: boolean }) => {
240
+ if (settings.deliveryMode !== "live") {
241
+ return;
242
+ }
243
+ if (!liveBufferText) {
244
+ return;
245
+ }
246
+ if (opts?.idle && !shouldFlushLiveBufferOnIdle(liveBufferText)) {
247
+ return;
248
+ }
249
+ const text = liveBufferText;
250
+ liveBufferText = "";
251
+ chunker.append(text);
252
+ drainChunker(opts?.force === true);
253
+ };
254
+
255
+ const scheduleLiveIdleFlush = () => {
256
+ if (settings.deliveryMode !== "live") {
257
+ return;
258
+ }
259
+ if (liveIdleFlushMs <= 0 || !liveBufferText) {
260
+ return;
261
+ }
262
+ clearLiveIdleTimer();
263
+ liveIdleTimer = setTimeout(() => {
264
+ flushLiveBuffer({ force: true, idle: true });
265
+ if (liveBufferText) {
266
+ scheduleLiveIdleFlush();
267
+ }
268
+ }, liveIdleFlushMs);
269
+ };
270
+
271
+ const flushBufferedToolDeliveries = async (force: boolean) => {
272
+ if (!(settings.deliveryMode === "final_only" && force)) {
273
+ return;
274
+ }
275
+ if (!shouldSendToolSummaries()) {
276
+ pendingToolDeliveries.length = 0;
277
+ return;
278
+ }
279
+ for (const entry of pendingToolDeliveries.splice(0)) {
280
+ await params.deliver("tool", entry.payload, entry.meta);
281
+ }
282
+ };
283
+
284
+ const flush = async (force = false): Promise<void> => {
285
+ if (settings.deliveryMode === "live") {
286
+ clearLiveIdleTimer();
287
+ flushLiveBuffer({ force: true });
288
+ }
289
+ await flushBufferedToolDeliveries(force);
290
+ if (settings.deliveryMode === "final_only") {
291
+ if (force && finalOnlyOutputText.trim().length > 0) {
292
+ const text = finalOnlyOutputText;
293
+ finalOnlyOutputText = "";
294
+ await params.deliver("final", { text });
295
+ }
296
+ } else {
297
+ drainChunker(force);
298
+ }
299
+ await blockReplyPipeline.flush({ force });
300
+ };
301
+
302
+ const emitSystemStatus = async (
303
+ text: string,
304
+ meta?: AcpProjectedDeliveryMeta,
305
+ opts?: { dedupe?: boolean },
306
+ ) => {
307
+ if (!shouldSendToolSummaries()) {
308
+ return;
309
+ }
310
+ const bounded = truncateText(text.trim(), settings.maxSessionUpdateChars);
311
+ if (!bounded) {
312
+ return;
313
+ }
314
+ const formatted = prefixSystemMessage(bounded);
315
+ const hash = hashText(formatted);
316
+ const shouldDedupe = settings.repeatSuppression && opts?.dedupe !== false;
317
+ if (shouldDedupe && lastStatusHash === hash) {
318
+ return;
319
+ }
320
+ if (settings.deliveryMode === "final_only") {
321
+ pendingToolDeliveries.push({
322
+ payload: { text: formatted },
323
+ meta,
324
+ });
325
+ } else {
326
+ await flush(true);
327
+ await params.deliver("tool", { text: formatted }, meta);
328
+ }
329
+ lastStatusHash = hash;
330
+ };
331
+
332
+ const markHiddenToolBoundary = (event: Extract<AcpRuntimeEvent, { type: "tool_call" }>) => {
333
+ if (!event.tag || !HIDDEN_BOUNDARY_TAGS.has(event.tag)) {
334
+ return;
335
+ }
336
+ const status = normalizeToolStatus(event.status);
337
+ const isTerminal = resolveAcpToolTerminalOutcome(status) !== undefined;
338
+ pendingHiddenBoundary = pendingHiddenBoundary || event.tag === "tool_call" || isTerminal;
339
+ };
340
+
341
+ const emitToolSummary = async (event: Extract<AcpRuntimeEvent, { type: "tool_call" }>) => {
342
+ if (!shouldSendToolSummaries()) {
343
+ markHiddenToolBoundary(event);
344
+ return;
345
+ }
346
+ if (!isAcpTagVisible(settings, event.tag)) {
347
+ return;
348
+ }
349
+
350
+ const renderedToolSummary = renderToolSummaryText(event, params.shouldSendFullToolDetails);
351
+ const toolSummary = truncateText(renderedToolSummary, settings.maxSessionUpdateChars);
352
+ const hash = hashText(renderedToolSummary);
353
+ const toolCallId = normalizeOptionalString(event.toolCallId);
354
+ const status = normalizeToolStatus(event.status);
355
+ const isTerminal = resolveAcpToolTerminalOutcome(status) !== undefined;
356
+ const isStart = status === "in_progress" || event.tag === "tool_call";
357
+
358
+ if (settings.repeatSuppression) {
359
+ if (toolCallId) {
360
+ const state = toolLifecycleById.get(toolCallId) ?? {
361
+ started: false,
362
+ terminal: false,
363
+ };
364
+ if (isTerminal && state.terminal) {
365
+ return;
366
+ }
367
+ if (isStart && state.started) {
368
+ return;
369
+ }
370
+ if (state.lastRenderedHash === hash) {
371
+ return;
372
+ }
373
+ if (isStart) {
374
+ state.started = true;
375
+ }
376
+ if (isTerminal) {
377
+ state.terminal = true;
378
+ }
379
+ state.lastRenderedHash = hash;
380
+ toolLifecycleById.set(toolCallId, state);
381
+ } else if (lastToolHash === hash) {
382
+ return;
383
+ }
384
+ }
385
+
386
+ const deliveryMeta: AcpProjectedDeliveryMeta = {
387
+ ...(event.tag ? { tag: event.tag } : {}),
388
+ ...(toolCallId ? { toolCallId } : {}),
389
+ ...(status ? { toolStatus: status } : {}),
390
+ allowEdit: Boolean(toolCallId && event.tag === "tool_call_update"),
391
+ };
392
+ if (settings.deliveryMode === "final_only") {
393
+ pendingToolDeliveries.push({
394
+ payload: { text: toolSummary },
395
+ meta: deliveryMeta,
396
+ });
397
+ markHiddenToolBoundary(event);
398
+ } else {
399
+ await flush(true);
400
+ await params.deliver("tool", { text: toolSummary }, deliveryMeta);
401
+ }
402
+ lastToolHash = hash;
403
+ };
404
+
405
+ const emitTruncationNotice = async () => {
406
+ if (truncationNoticeEmitted) {
407
+ return;
408
+ }
409
+ truncationNoticeEmitted = true;
410
+ await emitSystemStatus(
411
+ "output truncated",
412
+ {
413
+ tag: "session_info_update",
414
+ },
415
+ {
416
+ dedupe: false,
417
+ },
418
+ );
419
+ };
420
+
421
+ // One projector serves one dispatch; terminal settlement belongs to tryDispatchAcpReply.
422
+ const onEvent = async (event: AcpRuntimeEvent): Promise<void> => {
423
+ params.onProgress?.();
424
+ if (event.type === "text_delta") {
425
+ if (event.stream && event.stream !== "output") {
426
+ return;
427
+ }
428
+ if (!isAcpTagVisible(settings, event.tag)) {
429
+ return;
430
+ }
431
+ let text = event.text;
432
+ if (!text) {
433
+ return;
434
+ }
435
+ if (
436
+ pendingHiddenBoundary &&
437
+ shouldInsertSeparator({
438
+ separator: resolveHiddenBoundarySeparatorText(settings.hiddenBoundarySeparator),
439
+ previousTail: lastVisibleOutputTail,
440
+ nextText: text,
441
+ })
442
+ ) {
443
+ text = `${resolveHiddenBoundarySeparatorText(settings.hiddenBoundarySeparator)}${text}`;
444
+ }
445
+ pendingHiddenBoundary = false;
446
+ if (emittedOutputChars >= settings.maxOutputChars) {
447
+ await emitTruncationNotice();
448
+ return;
449
+ }
450
+ const remaining = settings.maxOutputChars - emittedOutputChars;
451
+ const accepted = remaining < text.length ? truncateUtf16Safe(text, remaining) : text;
452
+ if (accepted.length > 0) {
453
+ emittedOutputChars += accepted.length;
454
+ const safeText = filterConversationContext(accepted);
455
+ lastVisibleOutputTail = safeText.slice(-1) || lastVisibleOutputTail;
456
+ if (settings.deliveryMode === "live") {
457
+ liveBufferText += safeText;
458
+ if (shouldFlushLiveBufferOnBoundary(liveBufferText)) {
459
+ clearLiveIdleTimer();
460
+ flushLiveBuffer({ force: true });
461
+ } else {
462
+ scheduleLiveIdleFlush();
463
+ }
464
+ } else {
465
+ finalOnlyOutputText += safeText;
466
+ }
467
+ }
468
+ if (accepted.length < text.length) {
469
+ // A split code point can leave the accepted prefix shorter than the remaining budget.
470
+ // Exhaust it after any drop so later deltas cannot skip past omitted text.
471
+ emittedOutputChars = settings.maxOutputChars;
472
+ await emitTruncationNotice();
473
+ }
474
+ return;
475
+ }
476
+
477
+ if (event.type === "status") {
478
+ if (!isAcpTagVisible(settings, event.tag)) {
479
+ return;
480
+ }
481
+ if (event.tag === "usage_update" && settings.repeatSuppression) {
482
+ const usageTuple =
483
+ typeof event.used === "number" && typeof event.size === "number"
484
+ ? `${event.used}/${event.size}`
485
+ : hashText(event.text);
486
+ if (usageTuple === lastUsageTuple) {
487
+ return;
488
+ }
489
+ lastUsageTuple = usageTuple;
490
+ }
491
+ await emitSystemStatus(event.text, event.tag ? { tag: event.tag } : undefined, {
492
+ dedupe: true,
493
+ });
494
+ return;
495
+ }
496
+
497
+ if (event.type === "tool_call") {
498
+ if (!isAcpTagVisible(settings, event.tag)) {
499
+ markHiddenToolBoundary(event);
500
+ return;
501
+ }
502
+ await emitToolSummary(event);
503
+ }
504
+ };
505
+
506
+ return {
507
+ onEvent,
508
+ flush,
509
+ };
510
+ }
src/auto-reply/reply/acp-reset-target.ts ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Resolves ACP reset targets from sessions and command directives.
2
+ import {
3
+ normalizeLowercaseStringOrEmpty,
4
+ normalizeOptionalString,
5
+ } from "@openclaw/normalization-core/string-coerce";
6
+ import {
7
+ buildConfiguredAcpSessionKey,
8
+ normalizeBindingConfig,
9
+ type ConfiguredAcpBindingChannel,
10
+ } from "../../acp/persistent-bindings.types.js";
11
+ import { resolveConfiguredBindingRecord } from "../../channels/plugins/configured-binding-registry.js";
12
+ import { listAcpBindings } from "../../config/bindings.js";
13
+ import type { OpenClawConfig } from "../../config/types.openclaw.js";
14
+ import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
15
+ import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
16
+
17
+ function resolveResetTargetAccountId(params: {
18
+ cfg: OpenClawConfig;
19
+ channel: string;
20
+ accountId?: string | null;
21
+ }): string {
22
+ const explicit = normalizeOptionalString(params.accountId) ?? "";
23
+ if (explicit) {
24
+ return explicit;
25
+ }
26
+
27
+ const channelCfg = (
28
+ params.cfg.channels as Record<string, { defaultAccount?: unknown } | undefined>
29
+ )[params.channel];
30
+ const configuredDefault = channelCfg?.defaultAccount;
31
+ return normalizeOptionalString(configuredDefault) ?? DEFAULT_ACCOUNT_ID;
32
+ }
33
+
34
+ function resolveRawConfiguredAcpSessionKey(params: {
35
+ cfg: OpenClawConfig;
36
+ channel: string;
37
+ accountId: string;
38
+ conversationId: string;
39
+ parentConversationId?: string;
40
+ }): string | undefined {
41
+ for (const binding of listAcpBindings(params.cfg)) {
42
+ const bindingChannel = normalizeLowercaseStringOrEmpty(
43
+ normalizeOptionalString(binding.match.channel),
44
+ );
45
+ if (!bindingChannel || bindingChannel !== params.channel) {
46
+ continue;
47
+ }
48
+
49
+ const bindingAccountId = normalizeOptionalString(binding.match.accountId) ?? "";
50
+ if (bindingAccountId && bindingAccountId !== "*" && bindingAccountId !== params.accountId) {
51
+ continue;
52
+ }
53
+
54
+ const peerId = normalizeOptionalString(binding.match.peer?.id) ?? "";
55
+ const matchedConversationId =
56
+ peerId === params.conversationId
57
+ ? params.conversationId
58
+ : peerId && peerId === params.parentConversationId
59
+ ? params.parentConversationId
60
+ : undefined;
61
+ if (!matchedConversationId) {
62
+ continue;
63
+ }
64
+
65
+ const acp = normalizeBindingConfig(binding.acp);
66
+ return buildConfiguredAcpSessionKey({
67
+ channel: params.channel as ConfiguredAcpBindingChannel,
68
+ accountId: bindingAccountId && bindingAccountId !== "*" ? bindingAccountId : params.accountId,
69
+ conversationId: matchedConversationId,
70
+ ...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}),
71
+ agentId: binding.agentId,
72
+ mode: acp.mode === "oneshot" ? "oneshot" : "persistent",
73
+ ...(acp.cwd ? { cwd: acp.cwd } : {}),
74
+ ...(acp.backend ? { backend: acp.backend } : {}),
75
+ ...(acp.label ? { label: acp.label } : {}),
76
+ });
77
+ }
78
+
79
+ return undefined;
80
+ }
81
+
82
+ export function resolveEffectiveResetTargetSessionKey(params: {
83
+ cfg: OpenClawConfig;
84
+ channel?: string | null;
85
+ accountId?: string | null;
86
+ conversationId?: string | null;
87
+ parentConversationId?: string | null;
88
+ activeSessionKey?: string | null;
89
+ allowNonAcpBindingSessionKey?: boolean;
90
+ skipConfiguredFallbackWhenActiveSessionNonAcp?: boolean;
91
+ fallbackToActiveAcpWhenUnbound?: boolean;
92
+ }): string | undefined {
93
+ const activeSessionKey = normalizeOptionalString(params.activeSessionKey);
94
+ const activeAcpSessionKey =
95
+ activeSessionKey && isAcpSessionKey(activeSessionKey) ? activeSessionKey : undefined;
96
+ const activeIsNonAcp = Boolean(activeSessionKey) && !activeAcpSessionKey;
97
+
98
+ const channel = normalizeLowercaseStringOrEmpty(normalizeOptionalString(params.channel));
99
+ const conversationId = normalizeOptionalString(params.conversationId) ?? "";
100
+ if (!channel || !conversationId) {
101
+ return activeAcpSessionKey;
102
+ }
103
+ const accountId = resolveResetTargetAccountId({
104
+ cfg: params.cfg,
105
+ channel,
106
+ accountId: params.accountId,
107
+ });
108
+ const parentConversationId = normalizeOptionalString(params.parentConversationId) || undefined;
109
+ const allowNonAcpBindingSessionKey = Boolean(params.allowNonAcpBindingSessionKey);
110
+
111
+ const serviceBinding = getSessionBindingService().resolveByConversation({
112
+ channel,
113
+ accountId,
114
+ conversationId,
115
+ parentConversationId,
116
+ });
117
+ const serviceSessionKey =
118
+ serviceBinding?.targetKind === "session" ? serviceBinding.targetSessionKey.trim() : "";
119
+ if (serviceSessionKey) {
120
+ if (allowNonAcpBindingSessionKey) {
121
+ return serviceSessionKey;
122
+ }
123
+ return isAcpSessionKey(serviceSessionKey) ? serviceSessionKey : undefined;
124
+ }
125
+
126
+ if (activeIsNonAcp && params.skipConfiguredFallbackWhenActiveSessionNonAcp) {
127
+ return undefined;
128
+ }
129
+
130
+ const configuredBinding = resolveConfiguredBindingRecord({
131
+ cfg: params.cfg,
132
+ channel,
133
+ accountId,
134
+ conversationId,
135
+ parentConversationId,
136
+ });
137
+ const configuredSessionKey =
138
+ configuredBinding?.record.targetKind === "session"
139
+ ? configuredBinding.record.targetSessionKey.trim()
140
+ : "";
141
+ if (configuredSessionKey) {
142
+ if (allowNonAcpBindingSessionKey) {
143
+ return configuredSessionKey;
144
+ }
145
+ return isAcpSessionKey(configuredSessionKey) ? configuredSessionKey : undefined;
146
+ }
147
+
148
+ const rawConfiguredSessionKey = resolveRawConfiguredAcpSessionKey({
149
+ cfg: params.cfg,
150
+ channel,
151
+ accountId,
152
+ conversationId,
153
+ ...(parentConversationId ? { parentConversationId } : {}),
154
+ });
155
+ if (rawConfiguredSessionKey) {
156
+ return rawConfiguredSessionKey;
157
+ }
158
+
159
+ if (params.fallbackToActiveAcpWhenUnbound === false) {
160
+ return undefined;
161
+ }
162
+ return activeAcpSessionKey;
163
+ }
src/auto-reply/reply/acp-stream-settings.test.ts ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Tests ACP stream settings normalization from config. */
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ isAcpTagVisible,
5
+ resolveAcpProjectionSettings,
6
+ resolveAcpStreamingConfig,
7
+ } from "./acp-stream-settings.js";
8
+ import { createAcpTestConfig } from "./test-fixtures/acp-runtime.js";
9
+
10
+ describe("acp stream settings", () => {
11
+ it("resolves stable defaults", () => {
12
+ const settings = resolveAcpProjectionSettings(createAcpTestConfig());
13
+ expect(settings.deliveryMode).toBe("final_only");
14
+ expect(settings.hiddenBoundarySeparator).toBe("paragraph");
15
+ expect(settings.repeatSuppression).toBe(true);
16
+ expect(settings.maxOutputChars).toBe(24_000);
17
+ expect(settings.maxSessionUpdateChars).toBe(320);
18
+ });
19
+
20
+ it("applies retained stream overrides while preserving built-in tuning", () => {
21
+ const settings = resolveAcpProjectionSettings(
22
+ createAcpTestConfig({
23
+ acp: {
24
+ enabled: true,
25
+ stream: {
26
+ deliveryMode: "final_only",
27
+ repeatSuppression: false,
28
+ tagVisibility: {
29
+ usage_update: true,
30
+ },
31
+ },
32
+ },
33
+ }),
34
+ );
35
+ expect(settings.deliveryMode).toBe("final_only");
36
+ expect(settings.hiddenBoundarySeparator).toBe("paragraph");
37
+ expect(settings.repeatSuppression).toBe(false);
38
+ expect(settings.maxOutputChars).toBe(24_000);
39
+ expect(settings.maxSessionUpdateChars).toBe(320);
40
+ expect(settings.tagVisibility.usage_update).toBe(true);
41
+ });
42
+
43
+ it("accepts explicit deliveryMode=live override", () => {
44
+ const settings = resolveAcpProjectionSettings(
45
+ createAcpTestConfig({
46
+ acp: {
47
+ enabled: true,
48
+ stream: {
49
+ deliveryMode: "live",
50
+ },
51
+ },
52
+ }),
53
+ );
54
+ expect(settings.deliveryMode).toBe("live");
55
+ expect(settings.hiddenBoundarySeparator).toBe("space");
56
+ });
57
+
58
+ it("uses default tag visibility when no override is provided", () => {
59
+ const settings = resolveAcpProjectionSettings(createAcpTestConfig());
60
+ expect(isAcpTagVisible(settings, "tool_call")).toBe(false);
61
+ expect(isAcpTagVisible(settings, "tool_call_update")).toBe(false);
62
+ expect(isAcpTagVisible(settings, "usage_update")).toBe(false);
63
+ });
64
+
65
+ it("respects tag visibility overrides", () => {
66
+ const settings = resolveAcpProjectionSettings(
67
+ createAcpTestConfig({
68
+ acp: {
69
+ enabled: true,
70
+ stream: {
71
+ tagVisibility: {
72
+ usage_update: true,
73
+ tool_call: false,
74
+ },
75
+ },
76
+ },
77
+ }),
78
+ );
79
+ expect(isAcpTagVisible(settings, "usage_update")).toBe(true);
80
+ expect(isAcpTagVisible(settings, "tool_call")).toBe(false);
81
+ });
82
+
83
+ it("resolves built-in ACP chunking and coalescing", () => {
84
+ const streaming = resolveAcpStreamingConfig({
85
+ cfg: createAcpTestConfig(),
86
+ provider: "quietchat",
87
+ });
88
+ expect(streaming.chunking.maxChars).toBe(1800);
89
+ expect(streaming.coalescing.idleMs).toBe(350);
90
+ });
91
+
92
+ it("applies live-mode delivery with built-in streaming tuning", () => {
93
+ const streaming = resolveAcpStreamingConfig({
94
+ cfg: createAcpTestConfig({
95
+ acp: {
96
+ enabled: true,
97
+ stream: {
98
+ deliveryMode: "live",
99
+ },
100
+ },
101
+ }),
102
+ provider: "quietchat",
103
+ deliveryMode: "live",
104
+ });
105
+ expect(streaming.chunking.minChars).toBe(1);
106
+ expect(streaming.chunking.maxChars).toBe(1800);
107
+ expect(streaming.coalescing.minChars).toBe(1);
108
+ expect(streaming.coalescing.maxChars).toBe(1800);
109
+ expect(streaming.coalescing.joiner).toBe("");
110
+ expect(streaming.coalescing.idleMs).toBe(350);
111
+ });
112
+ });
src/auto-reply/reply/acp-stream-settings.ts ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** ACP streaming and projection settings derived from config. */
2
+ import type { AcpSessionUpdateTag } from "@openclaw/acp-core/runtime/types";
3
+ import type { OpenClawConfig } from "../../config/types.openclaw.js";
4
+ import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
5
+
6
+ const DEFAULT_ACP_STREAM_COALESCE_IDLE_MS = 350;
7
+ const DEFAULT_ACP_STREAM_MAX_CHUNK_CHARS = 1800;
8
+ const DEFAULT_ACP_REPEAT_SUPPRESSION = true;
9
+ const DEFAULT_ACP_DELIVERY_MODE = "final_only";
10
+ const DEFAULT_ACP_HIDDEN_BOUNDARY_SEPARATOR = "paragraph";
11
+ const DEFAULT_ACP_HIDDEN_BOUNDARY_SEPARATOR_LIVE = "space";
12
+ const DEFAULT_ACP_MAX_OUTPUT_CHARS = 24_000;
13
+ const DEFAULT_ACP_MAX_SESSION_UPDATE_CHARS = 320;
14
+
15
+ const ACP_TAG_VISIBILITY_DEFAULTS: Record<AcpSessionUpdateTag, boolean> = {
16
+ agent_message_chunk: true,
17
+ tool_call: false,
18
+ tool_call_update: false,
19
+ usage_update: false,
20
+ available_commands_update: false,
21
+ current_mode_update: false,
22
+ config_option_update: false,
23
+ session_info_update: false,
24
+ plan: false,
25
+ agent_thought_chunk: false,
26
+ };
27
+
28
+ function isAcpSessionUpdateTag(tag: string): tag is AcpSessionUpdateTag {
29
+ return Object.hasOwn(ACP_TAG_VISIBILITY_DEFAULTS, tag);
30
+ }
31
+
32
+ /** ACP delivery strategy for projected assistant output. */
33
+ type AcpDeliveryMode = "live" | "final_only";
34
+ export type AcpHiddenBoundarySeparator = "none" | "space" | "newline" | "paragraph";
35
+
36
+ /** Normalized ACP projection settings consumed by stream projectors. */
37
+ export type AcpProjectionSettings = {
38
+ deliveryMode: AcpDeliveryMode;
39
+ hiddenBoundarySeparator: AcpHiddenBoundarySeparator;
40
+ repeatSuppression: boolean;
41
+ maxOutputChars: number;
42
+ maxSessionUpdateChars: number;
43
+ tagVisibility: Partial<Record<AcpSessionUpdateTag, boolean>>;
44
+ };
45
+
46
+ function clampBoolean(value: unknown, fallback: boolean): boolean {
47
+ return typeof value === "boolean" ? value : fallback;
48
+ }
49
+
50
+ function resolveAcpDeliveryMode(value: unknown): AcpDeliveryMode {
51
+ if (value === "live" || value === "final_only") {
52
+ return value;
53
+ }
54
+ return DEFAULT_ACP_DELIVERY_MODE;
55
+ }
56
+
57
+ function resolveAcpStreamCoalesceIdleMs(): number {
58
+ return DEFAULT_ACP_STREAM_COALESCE_IDLE_MS;
59
+ }
60
+
61
+ function resolveAcpStreamMaxChunkChars(): number {
62
+ return DEFAULT_ACP_STREAM_MAX_CHUNK_CHARS;
63
+ }
64
+
65
+ /** Resolves ACP projection settings with bounded defaults. */
66
+ export function resolveAcpProjectionSettings(cfg: OpenClawConfig): AcpProjectionSettings {
67
+ const stream = cfg.acp?.stream;
68
+ const deliveryMode = resolveAcpDeliveryMode(stream?.deliveryMode);
69
+ const hiddenBoundaryFallback: AcpHiddenBoundarySeparator =
70
+ deliveryMode === "live"
71
+ ? DEFAULT_ACP_HIDDEN_BOUNDARY_SEPARATOR_LIVE
72
+ : DEFAULT_ACP_HIDDEN_BOUNDARY_SEPARATOR;
73
+ return {
74
+ deliveryMode,
75
+ hiddenBoundarySeparator: hiddenBoundaryFallback,
76
+ repeatSuppression: clampBoolean(stream?.repeatSuppression, DEFAULT_ACP_REPEAT_SUPPRESSION),
77
+ maxOutputChars: DEFAULT_ACP_MAX_OUTPUT_CHARS,
78
+ maxSessionUpdateChars: DEFAULT_ACP_MAX_SESSION_UPDATE_CHARS,
79
+ tagVisibility: stream?.tagVisibility ?? {},
80
+ };
81
+ }
82
+
83
+ /** Resolves ACP streaming chunk/coalescing settings. */
84
+ export function resolveAcpStreamingConfig(params: {
85
+ cfg: OpenClawConfig;
86
+ provider?: string;
87
+ accountId?: string;
88
+ deliveryMode?: AcpDeliveryMode;
89
+ }) {
90
+ const resolved = resolveEffectiveBlockStreamingConfig({
91
+ cfg: params.cfg,
92
+ provider: params.provider,
93
+ accountId: params.accountId,
94
+ maxChunkChars: resolveAcpStreamMaxChunkChars(),
95
+ coalesceIdleMs: resolveAcpStreamCoalesceIdleMs(),
96
+ });
97
+
98
+ // In live mode, ACP text deltas should flush promptly and never be held
99
+ // behind large generic min-char thresholds.
100
+ if (params.deliveryMode === "live") {
101
+ return {
102
+ chunking: {
103
+ ...resolved.chunking,
104
+ minChars: 1,
105
+ },
106
+ coalescing: {
107
+ ...resolved.coalescing,
108
+ minChars: 1,
109
+ // ACP delta streams already carry spacing/newlines; preserve exact text.
110
+ joiner: "",
111
+ },
112
+ };
113
+ }
114
+
115
+ return resolved;
116
+ }
117
+
118
+ export function isAcpTagVisible(settings: AcpProjectionSettings, tag: string | undefined): boolean {
119
+ if (!tag) {
120
+ return true;
121
+ }
122
+ if (!isAcpSessionUpdateTag(tag)) {
123
+ return true;
124
+ }
125
+ const override = settings.tagVisibility[tag];
126
+ if (typeof override === "boolean") {
127
+ return override;
128
+ }
129
+ const defaultVisibility = ACP_TAG_VISIBILITY_DEFAULTS[tag];
130
+ if (defaultVisibility === undefined) {
131
+ throw new Error(`Missing ACP visibility default for ${tag}`);
132
+ }
133
+ return defaultVisibility;
134
+ }
src/auto-reply/reply/agent-event-bridge.test.ts ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Covers the shared CLI delivery bridge's subscription scope. Every concurrent
2
+ // CLI run builds up to eight of these bridges, so a bridge that subscribes
3
+ // globally and discards foreign runs makes per-delta cost grow with agent count.
4
+ import { beforeEach, describe, expect, test } from "vitest";
5
+ import { emitAgentEvent, onAgentEvent, resetAgentEventsForTest } from "../../infra/agent-events.js";
6
+ import { registerAgentRunContext } from "../../infra/agent-run-registry.js";
7
+ import { createAgentEventBridge } from "./agent-event-bridge.js";
8
+
9
+ function textBridge(runId: string, sink: string[]) {
10
+ return createAgentEventBridge<string>({
11
+ runId,
12
+ read: (evt) => (typeof evt.data.text === "string" ? evt.data.text : undefined),
13
+ deliver: async (text) => {
14
+ sink.push(text);
15
+ },
16
+ });
17
+ }
18
+
19
+ describe("agent event delivery bridge", () => {
20
+ beforeEach(() => {
21
+ resetAgentEventsForTest();
22
+ });
23
+
24
+ test.each([1, 4, 16, 64])(
25
+ "isolates every delivery bridge while %s CLI runs stream interleaved events",
26
+ async (runs) => {
27
+ let predicateReads = 0;
28
+ const bridges = Array.from({ length: runs }, (_, runIndex) => {
29
+ const runId = `run-${runIndex}`;
30
+ registerAgentRunContext(runId, { sessionKey: `session-${runIndex}` });
31
+ return Array.from({ length: 8 }, (_bridge, bridgeIndex) => {
32
+ const delivered: string[] = [];
33
+ const bridge = createAgentEventBridge<string>({
34
+ get runId() {
35
+ predicateReads++;
36
+ return runId;
37
+ },
38
+ read: (event) => (typeof event.data.text === "string" ? event.data.text : undefined),
39
+ deliver: async (text) => {
40
+ delivered.push(text);
41
+ },
42
+ });
43
+ return { ...bridge, delivered, runIndex, bridgeIndex };
44
+ });
45
+ }).flat();
46
+ try {
47
+ predicateReads = 0;
48
+ emitAgentEvent({ runId: "run-0", stream: "assistant", data: { text: "a1" } });
49
+ // Source-pinned work measurement only: hoisting the filter can change this
50
+ // count without changing dispatch complexity. Output assertions own the test.
51
+ console.info("bridge predicate-read diagnostic (not latency)", {
52
+ runs,
53
+ bridgesPerRun: 8,
54
+ predicateReads,
55
+ });
56
+ emitAgentEvent({ runId: "run-1", stream: "assistant", data: { text: "b1" } });
57
+ emitAgentEvent({ runId: "run-0", stream: "assistant", data: { text: "a2" } });
58
+ await Promise.all(bridges.map((bridge) => bridge.drain()));
59
+ for (const { delivered, runIndex, bridgeIndex } of bridges) {
60
+ expect(delivered, `run-${runIndex}/bridge-${bridgeIndex}`).toEqual(
61
+ runIndex === 0 ? ["a1", "a2"] : runIndex === 1 ? ["b1"] : [],
62
+ );
63
+ }
64
+ } finally {
65
+ for (const bridge of bridges) {
66
+ bridge.unsubscribe();
67
+ }
68
+ }
69
+ },
70
+ );
71
+
72
+ test("keeps stream delivery order when a later global listener emits another event", async () => {
73
+ registerAgentRunContext("run-nested", { sessionKey: "session-nested" });
74
+ const delivered: string[] = [];
75
+ const bridge = textBridge("run-nested", delivered);
76
+ const stopGlobal = onAgentEvent((evt) => {
77
+ if (evt.runId === "run-nested" && evt.data.text === "outer") {
78
+ emitAgentEvent({ runId: "run-nested", stream: "assistant", data: { text: "inner" } });
79
+ }
80
+ });
81
+ try {
82
+ emitAgentEvent({ runId: "run-nested", stream: "assistant", data: { text: "outer" } });
83
+ await bridge.drain();
84
+ expect(delivered).toEqual(["outer", "inner"]);
85
+ } finally {
86
+ stopGlobal();
87
+ bridge.unsubscribe();
88
+ }
89
+ });
90
+
91
+ test("delivers the current event before a later global listener unsubscribes the bridge", async () => {
92
+ registerAgentRunContext("run-unsubscribe", { sessionKey: "session-unsubscribe" });
93
+ const delivered: string[] = [];
94
+ const bridge = textBridge("run-unsubscribe", delivered);
95
+ const stopGlobal = onAgentEvent((evt) => {
96
+ if (evt.runId === "run-unsubscribe") {
97
+ bridge.unsubscribe();
98
+ }
99
+ });
100
+ try {
101
+ emitAgentEvent({ runId: "run-unsubscribe", stream: "assistant", data: { text: "first" } });
102
+ emitAgentEvent({ runId: "run-unsubscribe", stream: "assistant", data: { text: "second" } });
103
+ await bridge.drain();
104
+ expect(delivered).toEqual(["first"]);
105
+ } finally {
106
+ stopGlobal();
107
+ bridge.unsubscribe();
108
+ }
109
+ });
110
+
111
+ test("routes a changed event identity only to later matching bridges", async () => {
112
+ registerAgentRunContext("run-a", { sessionKey: "session-a" });
113
+ registerAgentRunContext("run-b", { sessionKey: "session-b" });
114
+ const earlierB: string[] = [];
115
+ const laterB: string[] = [];
116
+ const laterA: string[] = [];
117
+ const first = textBridge("run-b", earlierB);
118
+ const stopGlobal = onAgentEvent((event) => {
119
+ event.runId = "run-b";
120
+ });
121
+ const second = textBridge("run-b", laterB);
122
+ const third = textBridge("run-a", laterA);
123
+ try {
124
+ emitAgentEvent({ runId: "run-a", stream: "assistant", data: { text: "changed" } });
125
+ await Promise.all([first.drain(), second.drain(), third.drain()]);
126
+ expect(earlierB).toEqual([]);
127
+ expect(laterB).toEqual(["changed"]);
128
+ expect(laterA).toEqual([]);
129
+ } finally {
130
+ stopGlobal();
131
+ first.unsubscribe();
132
+ second.unsubscribe();
133
+ third.unsubscribe();
134
+ }
135
+ });
136
+ });
src/auto-reply/reply/agent-event-bridge.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Generic agent-event bridge machinery shared by the CLI runner's per-stream
2
+ // delivery bridges (assistant, reasoning, commentary, plan).
3
+ import { type AgentEventPayload, onAgentEventForRun } from "../../infra/agent-events.js";
4
+
5
+ export type AgentEventDeliveryStartOrder = {
6
+ schedule: (deliver: () => Promise<unknown>) => Promise<void>;
7
+ };
8
+
9
+ export function createAgentEventDeliveryStartOrder(): AgentEventDeliveryStartOrder {
10
+ let startTail = Promise.resolve();
11
+ return {
12
+ schedule: async (deliver) => {
13
+ // Reserve at raw event receipt, then release at callback invocation. CLI streams drain
14
+ // independently, so waiting for callback completion here would reorder later streams.
15
+ const previousStart = startTail;
16
+ let releaseStart: (() => void) | undefined;
17
+ startTail = new Promise<void>((resolve) => {
18
+ releaseStart = resolve;
19
+ });
20
+ await previousStart;
21
+ let delivery: Promise<unknown>;
22
+ try {
23
+ delivery = deliver();
24
+ } finally {
25
+ releaseStart?.();
26
+ }
27
+ await delivery;
28
+ },
29
+ };
30
+ }
31
+
32
+ export function createAgentEventBridge<T>(params: {
33
+ runId: string;
34
+ suppressed?: boolean;
35
+ read: (evt: AgentEventPayload) => T | undefined;
36
+ deliver?: (payload: T) => Promise<unknown>;
37
+ startOrder?: AgentEventDeliveryStartOrder;
38
+ }) {
39
+ const deliver = params.deliver;
40
+ if (!deliver) {
41
+ return {
42
+ unsubscribe: () => undefined,
43
+ drain: async (): Promise<void> => undefined,
44
+ };
45
+ }
46
+ let unsubscribed = false;
47
+ let delivery: Promise<unknown> = Promise.resolve();
48
+ const rawUnsubscribe = onAgentEventForRun(params.runId, (evt) => {
49
+ if (evt.runId !== params.runId) {
50
+ return;
51
+ }
52
+ if (params.suppressed) {
53
+ return;
54
+ }
55
+ const payload = params.read(evt);
56
+ if (payload === undefined) {
57
+ return;
58
+ }
59
+ if (!params.startOrder) {
60
+ delivery = delivery.then(() => deliver(payload)).catch(() => undefined);
61
+ return;
62
+ }
63
+ const scheduled = params.startOrder.schedule(() => deliver(payload)).catch(() => undefined);
64
+ // Start ordering stays global; each bridge still owns and drains its callback completion.
65
+ delivery = Promise.all([delivery, scheduled]).then(() => undefined);
66
+ });
67
+ return {
68
+ unsubscribe() {
69
+ if (unsubscribed) {
70
+ return;
71
+ }
72
+ unsubscribed = true;
73
+ rawUnsubscribe();
74
+ },
75
+ async drain(): Promise<void> {
76
+ await delivery;
77
+ },
78
+ };
79
+ }
src/auto-reply/reply/agent-lifecycle-terminal.test.ts ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { OAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js";
3
+ import { FailoverError } from "../../agents/failover-error.js";
4
+ import { renderFailoverCodeUserCopy } from "../../agents/failover/user-copy.js";
5
+ import * as providerFailover from "../../plugins/provider-failover.js";
6
+ import { createAgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
7
+
8
+ const { emitAgentEvent } = vi.hoisted(() => ({ emitAgentEvent: vi.fn() }));
9
+
10
+ vi.mock("../../infra/agent-events.js", () => ({ emitAgentEvent }));
11
+
12
+ describe("createAgentLifecycleTerminalBackstop", () => {
13
+ it.each([false, true])("keeps only the selected attempt receipt (retry=%s)", (retry) => {
14
+ emitAgentEvent.mockClear();
15
+ const terminal = createAgentLifecycleTerminalBackstop({
16
+ runId: "run",
17
+ getLifecycleGeneration: () => "generation",
18
+ resolveTerminationFields: () => ({}),
19
+ });
20
+ terminal.note({
21
+ stream: "lifecycle",
22
+ data: {
23
+ phase: "finishing",
24
+ error: "first failure",
25
+ assistantTranscriptIdempotencyKey: "saved-A",
26
+ },
27
+ });
28
+ terminal.capture("error", new Error("first failure"));
29
+ expect(emitAgentEvent).not.toHaveBeenCalled();
30
+ if (retry) {
31
+ // Preparation fails before the next lifecycle start can be emitted.
32
+ terminal.beginAttempt();
33
+ }
34
+ terminal.emit("error", new Error(retry ? "preparation failed" : "first failure"));
35
+ expect(emitAgentEvent).toHaveBeenCalledOnce();
36
+ const data = emitAgentEvent.mock.calls[0]?.[0]?.data;
37
+ expect(data.assistantTranscriptIdempotencyKey).toBe(retry ? undefined : "saved-A");
38
+ expect(data.error).toBe(retry ? "preparation failed" : "first failure");
39
+ expect(data.executionSettled).toBe(true);
40
+ });
41
+
42
+ it("publishes the provider-owned OAuth summary instead of the wrapped diagnostic", () => {
43
+ emitAgentEvent.mockClear();
44
+ const summary =
45
+ "Your refresh token has already been used to generate a new access token. Please try signing in again.";
46
+ const rawDiagnostic = `OAuth token refresh failed for openai: {"error":{"message":"${summary}"}}`;
47
+ const oauthError = new OAuthRefreshFailureError({
48
+ provider: "openai",
49
+ message: rawDiagnostic,
50
+ errorType: "invalid_request_error",
51
+ reason: "refresh_token_reused",
52
+ status: 401,
53
+ summary,
54
+ });
55
+ const error = new Error("wrapped OAuth refresh failure", { cause: oauthError });
56
+ const terminal = createAgentLifecycleTerminalBackstop({
57
+ runId: "oauth-refresh-failure",
58
+ getLifecycleGeneration: () => "test-generation",
59
+ resolveTerminationFields: () => ({}),
60
+ });
61
+
62
+ terminal.emit("error", error);
63
+
64
+ const event = emitAgentEvent.mock.calls[0]?.[0];
65
+ expect(event.data.error).toBe(`⚠️ ${summary}`);
66
+ expect(event.data.errorObservation).toEqual({
67
+ provider: "openai",
68
+ failoverReason: "refresh_token_reused",
69
+ providerRuntimeFailureKind: "auth_refresh",
70
+ providerErrorType: "invalid_request_error",
71
+ httpStatus: 401,
72
+ });
73
+ expect(JSON.stringify(event)).not.toContain(rawDiagnostic);
74
+ });
75
+
76
+ it.each(["typed", "raw"] as const)(
77
+ "publishes bounded selected-profile recovery from %s failures without discovering providers",
78
+ (kind) => {
79
+ const classifyProvider = vi
80
+ .spyOn(providerFailover, "classifyProviderFailoverSignalWithPlugin")
81
+ .mockImplementation(() => {
82
+ throw new Error("Terminal presentation must not discover providers");
83
+ });
84
+ emitAgentEvent.mockClear();
85
+ try {
86
+ const profileId = "openai:private-profile";
87
+ const rawCause = `Codex app-server auth profile "${profileId}" was not found`;
88
+ const terminal = createAgentLifecycleTerminalBackstop({
89
+ runId: "missing-selected-profile",
90
+ sessionKey: "agent:main:test",
91
+ getLifecycleGeneration: () => "test-generation",
92
+ resolveTerminationFields: () => ({}),
93
+ });
94
+
95
+ const error =
96
+ kind === "typed"
97
+ ? new FailoverError(rawCause, {
98
+ reason: "auth",
99
+ code: "selected_auth_profile_unavailable",
100
+ profileId,
101
+ cause: new Error(rawCause),
102
+ })
103
+ : Object.assign(new Error(rawCause), {
104
+ code: "selected_auth_profile_unavailable",
105
+ });
106
+ terminal.emit("error", error);
107
+
108
+ const event = emitAgentEvent.mock.calls[0]?.[0];
109
+ expect(event.data.error).toBe(
110
+ renderFailoverCodeUserCopy("selected_auth_profile_unavailable"),
111
+ );
112
+ expect(JSON.stringify(event)).not.toContain(profileId);
113
+ expect(JSON.stringify(event)).not.toContain(rawCause);
114
+ expect(classifyProvider).not.toHaveBeenCalled();
115
+ } finally {
116
+ classifyProvider.mockRestore();
117
+ }
118
+ },
119
+ );
120
+ });
src/auto-reply/reply/agent-lifecycle-terminal.ts ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readStringValue } from "@openclaw/normalization-core/string-coerce";
2
+ import { classifyOAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js";
3
+ import { getFailoverErrorCode } from "../../agents/failover/error.js";
4
+ import { renderFailoverCodeUserCopy } from "../../agents/failover/user-copy.js";
5
+ import { AGENT_RUN_RESTART_ABORT_STOP_REASON } from "../../agents/run-termination.js";
6
+ import { emitAgentEvent } from "../../infra/agent-events.js";
7
+ import { formatErrorMessage } from "../../infra/errors.js";
8
+
9
+ export type AgentLifecycleTerminalBackstop = {
10
+ beginAttempt: () => void;
11
+ capture: AgentLifecycleTerminalBackstop["emit"];
12
+ emit: (
13
+ phase: "end" | "error",
14
+ resultOrError: unknown,
15
+ extraData?: Record<string, unknown>,
16
+ ) => void;
17
+ getDeferredError: () => string | undefined;
18
+ note: (evt: { stream: string; data: Record<string, unknown> }) => void;
19
+ };
20
+
21
+ type PendingLifecycleTerminal = {
22
+ kind: "pending";
23
+ deferredError?: string;
24
+ metadata: Record<string, unknown>;
25
+ };
26
+
27
+ type LifecycleTerminalState =
28
+ | PendingLifecycleTerminal
29
+ | { kind: "captured"; event: Parameters<typeof emitAgentEvent>[0] }
30
+ | { kind: "emitted" };
31
+
32
+ const DEFERRED_TERMINAL_METADATA_KEYS = [
33
+ "stopReason",
34
+ "yielded",
35
+ "timeoutPhase",
36
+ "providerStarted",
37
+ "aborted",
38
+ "livenessState",
39
+ "replayInvalid",
40
+ "errorObservation",
41
+ "assistantTranscriptIdempotencyKey",
42
+ ] as const;
43
+
44
+ export function resolveAgentLifecycleTerminalMetadata(meta: unknown): Record<string, unknown> {
45
+ const metadata: Record<string, unknown> = {};
46
+ if (!meta || typeof meta !== "object") {
47
+ return metadata;
48
+ }
49
+ const record = meta as Record<string, unknown>;
50
+ for (const key of DEFERRED_TERMINAL_METADATA_KEYS) {
51
+ if (Object.hasOwn(record, key)) {
52
+ metadata[key] = record[key];
53
+ }
54
+ }
55
+ return metadata;
56
+ }
57
+
58
+ export function createAgentLifecycleTerminalBackstop(params: {
59
+ runId: string;
60
+ sessionKey?: string;
61
+ startedAt?: number;
62
+ getLifecycleGeneration: () => string;
63
+ onTerminalEvent?: (event: Parameters<typeof emitAgentEvent>[0]) => void;
64
+ resolveTerminationFields: (error?: unknown) => {
65
+ aborted?: true;
66
+ stopReason?: string;
67
+ timeoutPhase?: string;
68
+ };
69
+ }): AgentLifecycleTerminalBackstop {
70
+ let state: LifecycleTerminalState = { kind: "pending", metadata: {} };
71
+ // Preparation can fail before a lifecycle start. Capture its real boundary
72
+ // without signaling readiness; an observed model start replaces it below.
73
+ let startedAt = params.startedAt ?? Date.now();
74
+ const beginAttempt = () => {
75
+ if (state.kind !== "emitted") {
76
+ state = { kind: "pending", metadata: {} };
77
+ }
78
+ };
79
+
80
+ const note = (evt: { stream: string; data: Record<string, unknown> }) => {
81
+ if (state.kind === "emitted" || evt.stream !== "lifecycle") {
82
+ return;
83
+ }
84
+ const phase = readStringValue(evt.data.phase);
85
+ if (phase === "start") {
86
+ beginAttempt();
87
+ if (typeof evt.data.startedAt === "number") {
88
+ startedAt = evt.data.startedAt;
89
+ }
90
+ }
91
+ if (phase === "finishing" && state.kind === "pending") {
92
+ state.deferredError = readStringValue(evt.data.error) ?? state.deferredError;
93
+ Object.assign(state.metadata, resolveAgentLifecycleTerminalMetadata(evt.data));
94
+ }
95
+ if (phase === "end" || phase === "error") {
96
+ state = { kind: "emitted" };
97
+ }
98
+ };
99
+
100
+ const prepareTerminal = (
101
+ pending: PendingLifecycleTerminal,
102
+ phase: "end" | "error",
103
+ resultOrError: unknown,
104
+ extraData?: Record<string, unknown>,
105
+ ): Parameters<typeof emitAgentEvent>[0] => {
106
+ const terminationFields = params.resolveTerminationFields(
107
+ phase === "error" ? resultOrError : undefined,
108
+ );
109
+ const restartAbort = terminationFields.stopReason === AGENT_RUN_RESTART_ABORT_STOP_REASON;
110
+ const data: Record<string, unknown> = {
111
+ ...pending.metadata,
112
+ phase: restartAbort ? "end" : phase,
113
+ endedAt: Date.now(),
114
+ startedAt,
115
+ };
116
+ if (restartAbort) {
117
+ data.aborted = true;
118
+ data.stopReason = AGENT_RUN_RESTART_ABORT_STOP_REASON;
119
+ } else if (phase === "error") {
120
+ const oauthFailure = classifyOAuthRefreshFailureError(resultOrError);
121
+ data.error =
122
+ renderFailoverCodeUserCopy(getFailoverErrorCode(resultOrError)) ??
123
+ (oauthFailure?.summary ? `⚠️ ${oauthFailure.summary}` : undefined) ??
124
+ formatErrorMessage(resultOrError);
125
+ if (oauthFailure?.summary) {
126
+ data.errorObservation = {
127
+ ...(oauthFailure.provider ? { provider: oauthFailure.provider } : {}),
128
+ ...(oauthFailure.reason ? { failoverReason: oauthFailure.reason } : {}),
129
+ providerRuntimeFailureKind: "auth_refresh",
130
+ ...(oauthFailure.errorType ? { providerErrorType: oauthFailure.errorType } : {}),
131
+ ...(oauthFailure.status ? { httpStatus: oauthFailure.status } : {}),
132
+ };
133
+ }
134
+ Object.assign(data, terminationFields);
135
+ } else {
136
+ const meta =
137
+ resultOrError && typeof resultOrError === "object" && "meta" in resultOrError
138
+ ? (resultOrError as { meta?: Record<string, unknown> }).meta
139
+ : undefined;
140
+ Object.assign(data, resolveAgentLifecycleTerminalMetadata(meta));
141
+ if (terminationFields.aborted === true) {
142
+ data.aborted = true;
143
+ }
144
+ if (terminationFields.stopReason && !readStringValue(data.stopReason)) {
145
+ data.stopReason = terminationFields.stopReason;
146
+ }
147
+ }
148
+ if (extraData) {
149
+ Object.assign(data, extraData);
150
+ }
151
+ return {
152
+ runId: params.runId,
153
+ lifecycleGeneration: params.getLifecycleGeneration(),
154
+ ...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
155
+ stream: "lifecycle",
156
+ data,
157
+ };
158
+ };
159
+
160
+ const capture: AgentLifecycleTerminalBackstop["capture"] = (phase, resultOrError, extraData) => {
161
+ if (state.kind === "pending") {
162
+ // Record bounded terminal facts before awaited bookkeeping can change
163
+ // the abort signal, lifecycle generation, or subsequent workflow outcome.
164
+ const event = prepareTerminal(state, phase, resultOrError, extraData);
165
+ state = { kind: "captured", event };
166
+ }
167
+ };
168
+ const emit: AgentLifecycleTerminalBackstop["emit"] = (phase, resultOrError, extraData) => {
169
+ const current = state;
170
+ if (current.kind === "emitted") {
171
+ return;
172
+ }
173
+ state = { kind: "emitted" };
174
+ const event =
175
+ current.kind === "captured"
176
+ ? current.event
177
+ : prepareTerminal(current, phase, resultOrError, extraData);
178
+ // Captured candidates can still be replaced by retries. Only publication
179
+ // settles execution; delivery and yielded-parent continuation remain separate.
180
+ const settled = { ...event, data: { ...event.data, executionSettled: true } };
181
+ emitAgentEvent(settled);
182
+ params.onTerminalEvent?.(settled);
183
+ };
184
+
185
+ return {
186
+ beginAttempt,
187
+ capture,
188
+ emit,
189
+ getDeferredError: () => (state.kind === "pending" ? state.deferredError : undefined),
190
+ note,
191
+ };
192
+ }
src/auto-reply/reply/agent-runner-auth-profile.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Resolves auth profile settings that agent runner forwards to providers.
2
+ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
3
+ import {
4
+ resolveProviderIdForAuth,
5
+ type ProviderAuthAliasLookupParams,
6
+ } from "../../agents/provider-auth-aliases.js";
7
+ import type { FollowupRun } from "./queue.js";
8
+
9
+ /** Keeps an auth profile only when the current provider shares the primary auth scope. */
10
+ export function resolveProviderScopedAuthProfile(params: {
11
+ provider: string;
12
+ primaryProvider: string;
13
+ authProfileId?: string;
14
+ authProfileIdSource?: "auto" | "user";
15
+ config?: ProviderAuthAliasLookupParams["config"];
16
+ workspaceDir?: ProviderAuthAliasLookupParams["workspaceDir"];
17
+ }): { authProfileId?: string; authProfileIdSource?: "auto" | "user" } {
18
+ const aliasParams = { config: params.config, workspaceDir: params.workspaceDir };
19
+ const providerId = normalizeProviderId(params.provider);
20
+ const primaryProviderId = normalizeProviderId(params.primaryProvider);
21
+ const sharesAuthScope =
22
+ (providerId !== "" && providerId === primaryProviderId) ||
23
+ resolveProviderIdForAuth(params.provider, aliasParams) ===
24
+ resolveProviderIdForAuth(params.primaryProvider, aliasParams);
25
+ const authProfileId = sharesAuthScope ? params.authProfileId : undefined;
26
+ return {
27
+ authProfileId,
28
+ authProfileIdSource: authProfileId ? params.authProfileIdSource : undefined,
29
+ };
30
+ }
31
+
32
+ /** Resolves the auth profile override for a queued follow-up run. */
33
+ export function resolveRunAuthProfile(
34
+ run: FollowupRun["run"],
35
+ provider: string,
36
+ params?: { config?: ProviderAuthAliasLookupParams["config"] },
37
+ ) {
38
+ return resolveProviderScopedAuthProfile({
39
+ provider,
40
+ primaryProvider: run.provider,
41
+ authProfileId: run.authProfileId,
42
+ authProfileIdSource: run.authProfileIdSource,
43
+ config: params?.config ?? run.config,
44
+ workspaceDir: run.workspaceDir,
45
+ });
46
+ }
47
+
48
+ /** Applies an auto-fallback probe's pinned auth to its fallback candidate. */
49
+ export function resolveFallbackCandidateRun(
50
+ run: FollowupRun["run"],
51
+ provider: string,
52
+ model: string,
53
+ ): FollowupRun["run"] {
54
+ const probe = run.autoFallbackPrimaryProbe;
55
+ const isPrimaryProbeCandidate = probe && provider === probe.provider && model === probe.model;
56
+ if (
57
+ !probe ||
58
+ provider !== probe.fallbackProvider ||
59
+ isPrimaryProbeCandidate ||
60
+ !probe.fallbackAuthProfileId
61
+ ) {
62
+ return run;
63
+ }
64
+ const candidateRun: FollowupRun["run"] = {
65
+ ...run,
66
+ provider,
67
+ model,
68
+ authProfileId: probe.fallbackAuthProfileId,
69
+ };
70
+ if (probe.fallbackAuthProfileIdSource) {
71
+ candidateRun.authProfileIdSource = probe.fallbackAuthProfileIdSource;
72
+ } else {
73
+ delete candidateRun.authProfileIdSource;
74
+ }
75
+ return candidateRun;
76
+ }
src/auto-reply/reply/agent-runner-auto-fallback.test.ts ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { SessionEntry } from "../../config/sessions.js";
3
+ import type { FollowupRun } from "./queue.js";
4
+
5
+ const state = vi.hoisted(() => ({
6
+ updateSessionEntryMock: vi.fn(),
7
+ }));
8
+
9
+ vi.mock("../../config/sessions/session-accessor.js", () => ({
10
+ updateSessionEntry: (...args: unknown[]) => state.updateSessionEntryMock(...args),
11
+ }));
12
+
13
+ import { clearRecoveredAutoFallbackPrimaryProbeSelection } from "./agent-runner-auto-fallback.js";
14
+
15
+ describe("clearRecoveredAutoFallbackPrimaryProbeSelection", () => {
16
+ beforeEach(() => {
17
+ state.updateSessionEntryMock.mockReset();
18
+ });
19
+
20
+ it("refreshes the local selection when the persisted comparison rejects the probe", async () => {
21
+ const probe = {
22
+ provider: "anthropic",
23
+ model: "claude-sonnet-4-6",
24
+ fallbackProvider: "openai",
25
+ fallbackModel: "gpt-5.4",
26
+ };
27
+ const staleAutoEntry: SessionEntry = {
28
+ sessionId: "session",
29
+ updatedAt: 1,
30
+ providerOverride: probe.fallbackProvider,
31
+ modelOverride: probe.fallbackModel,
32
+ modelOverrideSource: "auto",
33
+ modelOverrideFallbackOriginProvider: probe.provider,
34
+ modelOverrideFallbackOriginModel: probe.model,
35
+ };
36
+ const newerUserEntry: SessionEntry = {
37
+ sessionId: "newer-session",
38
+ updatedAt: 2,
39
+ providerOverride: "openai",
40
+ modelOverride: "gpt-5.5",
41
+ modelOverrideSource: "user",
42
+ };
43
+ const activeSessionStore = { main: staleAutoEntry };
44
+ state.updateSessionEntryMock.mockImplementationOnce(
45
+ async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
46
+ expect(await update(newerUserEntry)).toBeNull();
47
+ return null;
48
+ },
49
+ );
50
+
51
+ await clearRecoveredAutoFallbackPrimaryProbeSelection({
52
+ run: {
53
+ provider: probe.provider,
54
+ model: probe.model,
55
+ autoFallbackPrimaryProbe: probe,
56
+ } as FollowupRun["run"],
57
+ provider: probe.provider,
58
+ model: probe.model,
59
+ sessionKey: "main",
60
+ activeSessionStore,
61
+ getActiveSessionEntry: () => staleAutoEntry,
62
+ storePath: "/tmp/sessions.sqlite",
63
+ });
64
+
65
+ expect(activeSessionStore.main).toBe(newerUserEntry);
66
+ expect(activeSessionStore.main).toMatchObject({
67
+ sessionId: "newer-session",
68
+ providerOverride: "openai",
69
+ modelOverride: "gpt-5.5",
70
+ modelOverrideSource: "user",
71
+ });
72
+ });
73
+
74
+ it("preserves an identically reselected persisted fallback generation", async () => {
75
+ const probe = {
76
+ provider: "anthropic",
77
+ model: "claude-sonnet-4-6",
78
+ fallbackProvider: "openai",
79
+ fallbackModel: "gpt-5.4",
80
+ };
81
+ const staleAutoEntry: SessionEntry = {
82
+ sessionId: "session",
83
+ updatedAt: 1,
84
+ providerOverride: probe.fallbackProvider,
85
+ modelOverride: probe.fallbackModel,
86
+ modelOverrideSource: "auto",
87
+ modelOverrideFallbackOriginProvider: probe.provider,
88
+ modelOverrideFallbackOriginModel: probe.model,
89
+ };
90
+ const newerAutoEntry: SessionEntry = {
91
+ ...staleAutoEntry,
92
+ updatedAt: 2,
93
+ };
94
+ const activeSessionStore = { main: staleAutoEntry };
95
+ state.updateSessionEntryMock.mockImplementationOnce(
96
+ async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
97
+ expect(await update(newerAutoEntry)).toBeNull();
98
+ return null;
99
+ },
100
+ );
101
+
102
+ await clearRecoveredAutoFallbackPrimaryProbeSelection({
103
+ run: {
104
+ provider: probe.provider,
105
+ model: probe.model,
106
+ autoFallbackPrimaryProbe: probe,
107
+ } as FollowupRun["run"],
108
+ provider: probe.provider,
109
+ model: probe.model,
110
+ sessionKey: "main",
111
+ activeSessionStore,
112
+ getActiveSessionEntry: () => staleAutoEntry,
113
+ storePath: "/tmp/sessions.sqlite",
114
+ });
115
+
116
+ expect(activeSessionStore.main).toBe(newerAutoEntry);
117
+ expect(activeSessionStore.main).toMatchObject({
118
+ providerOverride: probe.fallbackProvider,
119
+ modelOverride: probe.fallbackModel,
120
+ modelOverrideSource: "auto",
121
+ updatedAt: 2,
122
+ });
123
+ });
124
+
125
+ it("keeps a newer local selection installed while persistence is pending", async () => {
126
+ const probe = {
127
+ provider: "anthropic",
128
+ model: "claude-sonnet-4-6",
129
+ fallbackProvider: "openai",
130
+ fallbackModel: "gpt-5.4",
131
+ };
132
+ const staleAutoEntry: SessionEntry = {
133
+ sessionId: "session",
134
+ updatedAt: 1,
135
+ providerOverride: probe.fallbackProvider,
136
+ modelOverride: probe.fallbackModel,
137
+ modelOverrideSource: "auto",
138
+ modelOverrideFallbackOriginProvider: probe.provider,
139
+ modelOverrideFallbackOriginModel: probe.model,
140
+ };
141
+ const newerUserEntry: SessionEntry = {
142
+ sessionId: "session",
143
+ updatedAt: 2,
144
+ providerOverride: "openai",
145
+ modelOverride: "gpt-5.5",
146
+ modelOverrideSource: "user",
147
+ };
148
+ const activeSessionStore = { main: staleAutoEntry };
149
+ state.updateSessionEntryMock.mockImplementationOnce(
150
+ async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
151
+ const persistedEntry = { ...staleAutoEntry };
152
+ const patch = await update(persistedEntry);
153
+ activeSessionStore.main = newerUserEntry;
154
+ return { ...persistedEntry, ...(patch as Partial<SessionEntry>) };
155
+ },
156
+ );
157
+
158
+ await clearRecoveredAutoFallbackPrimaryProbeSelection({
159
+ run: {
160
+ provider: probe.provider,
161
+ model: probe.model,
162
+ autoFallbackPrimaryProbe: probe,
163
+ } as FollowupRun["run"],
164
+ provider: probe.provider,
165
+ model: probe.model,
166
+ sessionKey: "main",
167
+ activeSessionStore,
168
+ getActiveSessionEntry: () => staleAutoEntry,
169
+ storePath: "/tmp/sessions.sqlite",
170
+ });
171
+
172
+ expect(activeSessionStore.main).toBe(newerUserEntry);
173
+ });
174
+
175
+ it("preserves an in-place auth selection while applying persisted probe cleanup", async () => {
176
+ const probe = {
177
+ provider: "anthropic",
178
+ model: "claude-sonnet-4-6",
179
+ fallbackProvider: "openai",
180
+ fallbackModel: "gpt-5.4",
181
+ };
182
+ const staleAutoEntry: SessionEntry = {
183
+ sessionId: "session",
184
+ updatedAt: 1,
185
+ providerOverride: probe.fallbackProvider,
186
+ modelOverride: probe.fallbackModel,
187
+ modelOverrideSource: "auto",
188
+ modelOverrideFallbackOriginProvider: probe.provider,
189
+ modelOverrideFallbackOriginModel: probe.model,
190
+ authProfileOverride: "openai:fallback",
191
+ authProfileOverrideSource: "auto",
192
+ };
193
+ const activeSessionStore = { main: staleAutoEntry };
194
+ state.updateSessionEntryMock.mockImplementationOnce(
195
+ async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
196
+ const persistedEntry = { ...staleAutoEntry };
197
+ const patch = await update(persistedEntry);
198
+ Object.assign(staleAutoEntry, {
199
+ authProfileOverrideSource: "user",
200
+ updatedAt: 3,
201
+ });
202
+ return { ...persistedEntry, ...(patch as Partial<SessionEntry>) };
203
+ },
204
+ );
205
+
206
+ await clearRecoveredAutoFallbackPrimaryProbeSelection({
207
+ run: {
208
+ provider: probe.provider,
209
+ model: probe.model,
210
+ autoFallbackPrimaryProbe: probe,
211
+ } as FollowupRun["run"],
212
+ provider: probe.provider,
213
+ model: probe.model,
214
+ sessionKey: "main",
215
+ activeSessionStore,
216
+ getActiveSessionEntry: () => staleAutoEntry,
217
+ storePath: "/tmp/sessions.sqlite",
218
+ });
219
+
220
+ expect(activeSessionStore.main).not.toBe(staleAutoEntry);
221
+ expect(activeSessionStore.main).toMatchObject({
222
+ authProfileOverride: "openai:fallback",
223
+ authProfileOverrideSource: "user",
224
+ providerOverride: probe.fallbackProvider,
225
+ modelOverride: probe.fallbackModel,
226
+ });
227
+ expect(activeSessionStore.main.updatedAt).toBeGreaterThanOrEqual(3);
228
+ });
229
+
230
+ it("preserves a value-identical same-session cache replacement", async () => {
231
+ const probe = {
232
+ provider: "anthropic",
233
+ model: "claude-sonnet-4-6",
234
+ fallbackProvider: "openai",
235
+ fallbackModel: "gpt-5.4",
236
+ };
237
+ const staleAutoEntry: SessionEntry = {
238
+ sessionId: "session",
239
+ updatedAt: 1,
240
+ providerOverride: probe.fallbackProvider,
241
+ modelOverride: probe.fallbackModel,
242
+ modelOverrideSource: "auto",
243
+ modelOverrideFallbackOriginProvider: probe.provider,
244
+ modelOverrideFallbackOriginModel: probe.model,
245
+ authProfileOverride: "openai:fallback",
246
+ authProfileOverrideSource: "auto",
247
+ };
248
+ const replacementEntry: SessionEntry = {
249
+ ...staleAutoEntry,
250
+ updatedAt: 3,
251
+ };
252
+ const activeSessionStore = { main: staleAutoEntry };
253
+ state.updateSessionEntryMock.mockImplementationOnce(
254
+ async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
255
+ const persistedEntry = { ...staleAutoEntry };
256
+ const patch = await update(persistedEntry);
257
+ activeSessionStore.main = replacementEntry;
258
+ return { ...persistedEntry, ...(patch as Partial<SessionEntry>) };
259
+ },
260
+ );
261
+
262
+ await clearRecoveredAutoFallbackPrimaryProbeSelection({
263
+ run: {
264
+ provider: probe.provider,
265
+ model: probe.model,
266
+ autoFallbackPrimaryProbe: probe,
267
+ } as FollowupRun["run"],
268
+ provider: probe.provider,
269
+ model: probe.model,
270
+ sessionKey: "main",
271
+ activeSessionStore,
272
+ getActiveSessionEntry: () => staleAutoEntry,
273
+ storePath: "/tmp/sessions.sqlite",
274
+ });
275
+
276
+ expect(activeSessionStore.main).toBe(replacementEntry);
277
+ expect(activeSessionStore.main).toMatchObject({
278
+ authProfileOverride: "openai:fallback",
279
+ authProfileOverrideSource: "auto",
280
+ providerOverride: probe.fallbackProvider,
281
+ modelOverride: probe.fallbackModel,
282
+ updatedAt: 3,
283
+ });
284
+ });
285
+
286
+ it("preserves a same-session cache replacement when persistence has no row", async () => {
287
+ const probe = {
288
+ provider: "anthropic",
289
+ model: "claude-sonnet-4-6",
290
+ fallbackProvider: "openai",
291
+ fallbackModel: "gpt-5.4",
292
+ };
293
+ const staleAutoEntry: SessionEntry = {
294
+ sessionId: "session",
295
+ updatedAt: 1,
296
+ providerOverride: probe.fallbackProvider,
297
+ modelOverride: probe.fallbackModel,
298
+ modelOverrideSource: "auto",
299
+ modelOverrideFallbackOriginProvider: probe.provider,
300
+ modelOverrideFallbackOriginModel: probe.model,
301
+ };
302
+ const replacementEntry: SessionEntry = {
303
+ ...staleAutoEntry,
304
+ updatedAt: 2,
305
+ };
306
+ const activeSessionStore = { main: staleAutoEntry };
307
+ state.updateSessionEntryMock.mockImplementationOnce(async () => {
308
+ activeSessionStore.main = replacementEntry;
309
+ return undefined;
310
+ });
311
+
312
+ await clearRecoveredAutoFallbackPrimaryProbeSelection({
313
+ run: {
314
+ provider: probe.provider,
315
+ model: probe.model,
316
+ autoFallbackPrimaryProbe: probe,
317
+ } as FollowupRun["run"],
318
+ provider: probe.provider,
319
+ model: probe.model,
320
+ sessionKey: "main",
321
+ activeSessionStore,
322
+ getActiveSessionEntry: () => staleAutoEntry,
323
+ storePath: "/tmp/sessions.sqlite",
324
+ });
325
+
326
+ expect(activeSessionStore.main).toBe(replacementEntry);
327
+ });
328
+
329
+ it("preserves an in-place cache update when persistence has no row", async () => {
330
+ const probe = {
331
+ provider: "anthropic",
332
+ model: "claude-sonnet-4-6",
333
+ fallbackProvider: "openai",
334
+ fallbackModel: "gpt-5.4",
335
+ };
336
+ const staleAutoEntry: SessionEntry = {
337
+ sessionId: "session",
338
+ updatedAt: 1,
339
+ providerOverride: probe.fallbackProvider,
340
+ modelOverride: probe.fallbackModel,
341
+ modelOverrideSource: "auto",
342
+ modelOverrideFallbackOriginProvider: probe.provider,
343
+ modelOverrideFallbackOriginModel: probe.model,
344
+ authProfileOverride: "openai:fallback",
345
+ authProfileOverrideSource: "auto",
346
+ };
347
+ const activeSessionStore = { main: staleAutoEntry };
348
+ state.updateSessionEntryMock.mockImplementationOnce(async () => {
349
+ Object.assign(staleAutoEntry, {
350
+ authProfileOverrideSource: "user",
351
+ updatedAt: 2,
352
+ });
353
+ return undefined;
354
+ });
355
+
356
+ await clearRecoveredAutoFallbackPrimaryProbeSelection({
357
+ run: {
358
+ provider: probe.provider,
359
+ model: probe.model,
360
+ autoFallbackPrimaryProbe: probe,
361
+ } as FollowupRun["run"],
362
+ provider: probe.provider,
363
+ model: probe.model,
364
+ sessionKey: "main",
365
+ activeSessionStore,
366
+ getActiveSessionEntry: () => staleAutoEntry,
367
+ storePath: "/tmp/sessions.sqlite",
368
+ });
369
+
370
+ expect(activeSessionStore.main).toBe(staleAutoEntry);
371
+ expect(activeSessionStore.main).toMatchObject({
372
+ authProfileOverride: "openai:fallback",
373
+ authProfileOverrideSource: "user",
374
+ updatedAt: 2,
375
+ });
376
+ });
377
+
378
+ it("preserves an in-place nested cache update", async () => {
379
+ const probe = {
380
+ provider: "anthropic",
381
+ model: "claude-sonnet-4-6",
382
+ fallbackProvider: "openai",
383
+ fallbackModel: "gpt-5.4",
384
+ };
385
+ const staleAutoEntry: SessionEntry = {
386
+ sessionId: "session",
387
+ updatedAt: 1,
388
+ providerOverride: probe.fallbackProvider,
389
+ modelOverride: probe.fallbackModel,
390
+ modelOverrideSource: "auto",
391
+ modelOverrideFallbackOriginProvider: probe.provider,
392
+ modelOverrideFallbackOriginModel: probe.model,
393
+ cliSessionBindings: {
394
+ codex: { sessionId: "old-cli-session" },
395
+ },
396
+ };
397
+ const activeSessionStore = { main: staleAutoEntry };
398
+ state.updateSessionEntryMock.mockImplementationOnce(
399
+ async (_scope: unknown, update: (entry: SessionEntry) => unknown) => {
400
+ const persistedEntry = structuredClone(staleAutoEntry);
401
+ const patch = await update(persistedEntry);
402
+ staleAutoEntry.cliSessionBindings!.codex!.sessionId = "new-cli-session";
403
+ staleAutoEntry.updatedAt = 2;
404
+ return { ...persistedEntry, ...(patch as Partial<SessionEntry>) };
405
+ },
406
+ );
407
+
408
+ await clearRecoveredAutoFallbackPrimaryProbeSelection({
409
+ run: {
410
+ provider: probe.provider,
411
+ model: probe.model,
412
+ autoFallbackPrimaryProbe: probe,
413
+ } as FollowupRun["run"],
414
+ provider: probe.provider,
415
+ model: probe.model,
416
+ sessionKey: "main",
417
+ activeSessionStore,
418
+ getActiveSessionEntry: () => staleAutoEntry,
419
+ storePath: "/tmp/sessions.sqlite",
420
+ });
421
+
422
+ expect(activeSessionStore.main).not.toBe(staleAutoEntry);
423
+ expect(activeSessionStore.main.cliSessionBindings?.codex?.sessionId).toBe("new-cli-session");
424
+ expect(activeSessionStore.main.updatedAt).toBeGreaterThanOrEqual(2);
425
+ expect(activeSessionStore.main.modelOverride).toBeUndefined();
426
+ });
427
+ });
src/auto-reply/reply/agent-runner-auto-fallback.ts ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
3
+ import {
4
+ clearAutoFallbackPrimaryProbeSelection,
5
+ entryMatchesAutoFallbackPrimaryProbe,
6
+ hasSessionAutoModelFallbackProvenance,
7
+ resolveAutoFallbackPrimaryProbe,
8
+ } from "../../agents/agent-scope.js";
9
+ import { resolvePersistedOverrideModelRef } from "../../agents/model-selection.js";
10
+ import type { SessionEntry } from "../../config/sessions.js";
11
+ import {
12
+ resolveCollapsedSessionAuthPinSource,
13
+ resolveSessionAuthProfileOverrideSource,
14
+ } from "../../config/sessions/auth-profile-override-provenance.js";
15
+ import { resolveSessionModelOverrideRouteResolution } from "../../config/sessions/model-override-provenance.js";
16
+ import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
17
+ import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
18
+ import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
19
+ import type { FollowupRun } from "./queue.js";
20
+
21
+ function sessionEntryMatchesSnapshot(entry: SessionEntry, snapshot: SessionEntry): boolean {
22
+ return isDeepStrictEqual(entry, snapshot);
23
+ }
24
+
25
+ function sessionEntryOnlyUpdatedAtChanged(entry: SessionEntry, snapshot: SessionEntry): boolean {
26
+ if (entry.updatedAt === snapshot.updatedAt) {
27
+ return false;
28
+ }
29
+ const entryWithoutUpdatedAt = { ...entry, updatedAt: snapshot.updatedAt };
30
+ return isDeepStrictEqual(entryWithoutUpdatedAt, snapshot);
31
+ }
32
+
33
+ /** Decides whether to retry after rechecking auto-fallback primary probe state. */
34
+ export function resolveRunAfterAutoFallbackPrimaryProbeRecheck(params: {
35
+ run: FollowupRun["run"];
36
+ entry?: SessionEntry;
37
+ sessionKey?: string;
38
+ }): FollowupRun["run"] {
39
+ const probe = params.run.autoFallbackPrimaryProbe;
40
+ if (!probe || !params.sessionKey || !params.entry) {
41
+ return params.run;
42
+ }
43
+ const resolveEntrySelectionRun = (): FollowupRun["run"] => {
44
+ const entryRef = resolvePersistedOverrideModelRef({
45
+ defaultProvider: params.run.provider,
46
+ overrideProvider: params.entry?.providerOverride,
47
+ overrideModel: params.entry?.modelOverride,
48
+ });
49
+ const hasEntryModelOverride = Boolean(entryRef);
50
+ const authProfileId = normalizeOptionalString(params.entry?.authProfileOverride);
51
+ const fallbackRun: FollowupRun["run"] = {
52
+ ...params.run,
53
+ provider: entryRef?.provider ?? params.run.provider,
54
+ model: entryRef?.model ?? params.run.model,
55
+ requestedRouteResolution: entryRef
56
+ ? resolveSessionModelOverrideRouteResolution(params.entry)
57
+ : params.run.requestedRouteResolution,
58
+ autoFallbackPrimaryProbe: undefined,
59
+ };
60
+ if (hasEntryModelOverride) {
61
+ fallbackRun.hasSessionModelOverride = true;
62
+ fallbackRun.hasAutoFallbackProvenance =
63
+ hasSessionAutoModelFallbackProvenance(params.entry) || undefined;
64
+ } else {
65
+ delete fallbackRun.hasSessionModelOverride;
66
+ delete fallbackRun.hasAutoFallbackProvenance;
67
+ }
68
+ const modelOverrideSource = params.entry?.modelOverrideSource;
69
+ if (hasEntryModelOverride && modelOverrideSource && modelOverrideSource !== "default") {
70
+ fallbackRun.modelOverrideSource = modelOverrideSource;
71
+ } else {
72
+ delete fallbackRun.modelOverrideSource;
73
+ }
74
+ if (hasEntryModelOverride && authProfileId) {
75
+ fallbackRun.authProfileId = authProfileId;
76
+ const authProfileIdSource = resolveCollapsedSessionAuthPinSource(params.entry);
77
+ if (authProfileIdSource) {
78
+ fallbackRun.authProfileIdSource = authProfileIdSource;
79
+ } else {
80
+ delete fallbackRun.authProfileIdSource;
81
+ }
82
+ } else if (hasEntryModelOverride) {
83
+ delete fallbackRun.authProfileId;
84
+ delete fallbackRun.authProfileIdSource;
85
+ }
86
+ return fallbackRun;
87
+ };
88
+ const refreshedProbe = resolveAutoFallbackPrimaryProbe({
89
+ entry: params.entry,
90
+ sessionKey: params.sessionKey,
91
+ primaryProvider: probe.provider,
92
+ primaryModel: probe.model,
93
+ });
94
+ if (!refreshedProbe) {
95
+ return resolveEntrySelectionRun();
96
+ }
97
+ return {
98
+ ...params.run,
99
+ provider: refreshedProbe.provider,
100
+ model: refreshedProbe.model,
101
+ requestedRouteResolution: "resolved",
102
+ autoFallbackPrimaryProbe: refreshedProbe,
103
+ };
104
+ }
105
+
106
+ /** Clears a recovered primary probe without overwriting a newer session selection. */
107
+ export async function clearRecoveredAutoFallbackPrimaryProbeSelection(params: {
108
+ run: FollowupRun["run"];
109
+ provider: string;
110
+ model: string;
111
+ sessionKey?: string;
112
+ activeSessionStore?: Record<string, SessionEntry>;
113
+ getActiveSessionEntry: () => SessionEntry | undefined;
114
+ storePath?: string;
115
+ }): Promise<void> {
116
+ if (shouldPreserveUserFacingSessionStateForInputProvenance(params.run.inputProvenance)) {
117
+ return;
118
+ }
119
+ const probe = params.run.autoFallbackPrimaryProbe;
120
+ if (!probe || params.provider !== probe.provider || params.model !== probe.model) {
121
+ return;
122
+ }
123
+ if (!params.sessionKey || !params.activeSessionStore) {
124
+ return;
125
+ }
126
+ const cachedSessionEntry = params.activeSessionStore[params.sessionKey];
127
+ const activeSessionEntry = cachedSessionEntry ?? params.getActiveSessionEntry();
128
+ if (!activeSessionEntry || !entryMatchesAutoFallbackPrimaryProbe(activeSessionEntry, probe)) {
129
+ return;
130
+ }
131
+ const activeSessionEntryBeforeUpdate = structuredClone(activeSessionEntry);
132
+ if (!params.storePath) {
133
+ clearAutoFallbackPrimaryProbeSelection(activeSessionEntry);
134
+ params.activeSessionStore[params.sessionKey] = activeSessionEntry;
135
+ return;
136
+ }
137
+ let comparedEntry: SessionEntry | undefined;
138
+ const updatedEntry = await updateSessionEntry(
139
+ { storePath: params.storePath, sessionKey: params.sessionKey },
140
+ (persistedEntry) => {
141
+ comparedEntry = persistedEntry;
142
+ if (
143
+ persistedEntry.sessionId !== activeSessionEntryBeforeUpdate.sessionId ||
144
+ persistedEntry.updatedAt !== activeSessionEntryBeforeUpdate.updatedAt ||
145
+ !entryMatchesAutoFallbackPrimaryProbe(persistedEntry, probe)
146
+ ) {
147
+ return null;
148
+ }
149
+ const shouldClearAuthProfile =
150
+ resolveSessionAuthProfileOverrideSource(persistedEntry) === "auto";
151
+ clearAutoFallbackPrimaryProbeSelection(persistedEntry);
152
+ return {
153
+ providerOverride: undefined,
154
+ modelOverride: undefined,
155
+ modelOverrideSource: undefined,
156
+ modelOverrideRouteResolution: undefined,
157
+ modelOverrideFallbackOriginProvider: undefined,
158
+ modelOverrideFallbackOriginModel: undefined,
159
+ ...(shouldClearAuthProfile
160
+ ? {
161
+ authProfileOverride: undefined,
162
+ authProfileOverrideSource: undefined,
163
+ authProfileOverrideCompactionCount: undefined,
164
+ }
165
+ : {}),
166
+ fallbackNotice: undefined,
167
+ updatedAt: persistedEntry.updatedAt,
168
+ };
169
+ },
170
+ );
171
+ // The persisted comparison owns selection freshness. Publish its updated
172
+ // result, or refresh the cache from the entry that rejected this probe.
173
+ const authoritativeEntry = updatedEntry ?? comparedEntry;
174
+ const currentCachedEntry = params.activeSessionStore[params.sessionKey];
175
+ // Object replacement is a new cache generation even when values match.
176
+ // Preserve it to avoid clearing an identically reselected fallback.
177
+ if (currentCachedEntry !== cachedSessionEntry) {
178
+ return;
179
+ }
180
+ const currentEntry = currentCachedEntry ?? (cachedSessionEntry ? undefined : activeSessionEntry);
181
+ if (!currentEntry) {
182
+ return;
183
+ }
184
+ if (authoritativeEntry) {
185
+ if (sessionEntryMatchesSnapshot(currentEntry, activeSessionEntryBeforeUpdate)) {
186
+ params.activeSessionStore[params.sessionKey] = authoritativeEntry;
187
+ return;
188
+ }
189
+ if (
190
+ currentEntry.sessionId !== activeSessionEntryBeforeUpdate.sessionId ||
191
+ sessionEntryOnlyUpdatedAtChanged(currentEntry, activeSessionEntryBeforeUpdate)
192
+ ) {
193
+ return;
194
+ }
195
+ params.activeSessionStore[params.sessionKey] = mergeSessionSnapshotChanges({
196
+ initial: activeSessionEntryBeforeUpdate,
197
+ next: authoritativeEntry,
198
+ current: currentEntry,
199
+ });
200
+ } else if (sessionEntryMatchesSnapshot(currentEntry, activeSessionEntryBeforeUpdate)) {
201
+ delete params.activeSessionStore[params.sessionKey];
202
+ }
203
+ }
src/auto-reply/reply/agent-runner-cli-candidate.ts ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
2
+ import {
3
+ cliBackendAcceptsAuthProfileForwarding,
4
+ resolveCliExecutionAuthProfileId,
5
+ } from "../../agents/cli-execution-auth.js";
6
+ import { buildCliMcpDelegationCapabilityBinding } from "../../agents/cli-runner/mcp-grant-context.js";
7
+ import {
8
+ clearCliSessionInStore,
9
+ persistCliSessionBindingResult,
10
+ settleCliSessionResult,
11
+ } from "../../agents/cli-session-store.js";
12
+ import {
13
+ getCliSessionBinding,
14
+ shouldClearFailedCliSessionBinding,
15
+ } from "../../agents/cli-session.js";
16
+ import { resolveDelegationCapability } from "../../agents/delegation-capability.js";
17
+ import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent-runner/types.js";
18
+ import { findModelInCatalog } from "../../agents/model-catalog-lookup.js";
19
+ import type { ModelFallbackResultClassification } from "../../agents/model-fallback-attempt.js";
20
+ import { createAgentRunSupersededAbortError } from "../../agents/run-termination.js";
21
+ import { withLocalSessionPlacementTurnSettlement } from "../../agents/session-placement-admission.js";
22
+ import { normalizeChatType } from "../../channels/chat-type.js";
23
+ import { loadSessionEntry } from "../../config/sessions/session-accessor.js";
24
+ import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
25
+ import {
26
+ getGeneratedMediaTaskIdsForSessionKey,
27
+ hasNewGeneratedMediaTaskForSessionKey,
28
+ } from "../../tasks/task-status-access.js";
29
+ import { createAgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
30
+ import { resolveRunAuthProfile } from "./agent-runner-auth-profile.js";
31
+ import {
32
+ createCliReasoningStreamBridge,
33
+ createCliToolSummaryTracker,
34
+ keepCliSessionBindingOnlyWhenReused,
35
+ runCliAgentWithLifecycle,
36
+ } from "./agent-runner-cli-dispatch.js";
37
+ import { buildCommandOutputFromToolResultEvent } from "./agent-runner-command-output.js";
38
+ import type { AgentFallbackCandidateCommonParams } from "./agent-runner-fallback-cycle.types.js";
39
+ import { resolveRunModelHasVision } from "./agent-runner-run-params.js";
40
+ import { shouldBridgeCliPreambleEvents } from "./get-reply.types.js";
41
+ import { hasInboundAudio } from "./inbound-media.js";
42
+ import { resolveOriginMessageProvider } from "./origin-routing.js";
43
+ import { resolveReplyOperationTerminationFields } from "./reply-operation-abort.js";
44
+
45
+ export async function runCliFallbackCandidate(
46
+ params: AgentFallbackCandidateCommonParams & {
47
+ cliExecutionProvider: string;
48
+ classifyResult: (result: EmbeddedAgentRunResult) => ModelFallbackResultClassification;
49
+ lifecycleGeneration: string;
50
+ },
51
+ ): Promise<{
52
+ result: Awaited<ReturnType<typeof runCliAgentWithLifecycle>>;
53
+ bootstrapPromptWarningSignaturesSeen: string[];
54
+ }> {
55
+ const turn = params.turn;
56
+ const expectedLifecycleRevision = turn.getActiveSessionEntry()?.lifecycleRevision;
57
+ const selectedModelEntry = findModelInCatalog(
58
+ params.candidateRun.thinkingCatalog ?? [],
59
+ params.provider,
60
+ params.model,
61
+ );
62
+ const modelHasVision = await resolveRunModelHasVision({
63
+ run: params.candidateRun,
64
+ provider: params.provider,
65
+ model: params.model,
66
+ });
67
+ const sessionKey = turn.sessionKey ?? turn.followupRun.run.sessionKey;
68
+ const sessionTarget =
69
+ sessionKey && turn.storePath
70
+ ? {
71
+ agentId: turn.followupRun.run.agentId,
72
+ sessionId: turn.followupRun.run.sessionId,
73
+ sessionKey,
74
+ storePath: turn.storePath,
75
+ }
76
+ : undefined;
77
+ const cliLifecycleStartedAt = Date.now();
78
+ const lifecycleBackstop = createAgentLifecycleTerminalBackstop({
79
+ runId: params.runId,
80
+ sessionKey: turn.sessionKey,
81
+ startedAt: cliLifecycleStartedAt,
82
+ getLifecycleGeneration: () => params.lifecycleGeneration,
83
+ resolveTerminationFields: (error) =>
84
+ resolveReplyOperationTerminationFields(error, params.runAbortSignal, turn.replyOperation),
85
+ });
86
+ params.onLifecycleBackstop(lifecycleBackstop);
87
+ const allowCliAuthProfileForwarding = cliBackendAcceptsAuthProfileForwarding({
88
+ provider: params.cliExecutionProvider,
89
+ config: params.runtimeConfig,
90
+ agentId: params.candidateRun.agentId,
91
+ });
92
+ const hookMessageProvider = resolveOriginMessageProvider({
93
+ originatingChannel: turn.followupRun.originatingChannel,
94
+ provider: turn.sessionCtx.Provider,
95
+ });
96
+ const cliCurrentThreadId =
97
+ turn.followupRun.originatingThreadId ?? turn.sessionCtx.MessageThreadId;
98
+ const isRestartSentinelContinuation =
99
+ turn.sessionCtx.InputProvenance?.kind === "internal_system" &&
100
+ turn.sessionCtx.InputProvenance.sourceTool === "restart-sentinel";
101
+ const cliCurrentMessageId = isRestartSentinelContinuation
102
+ ? turn.sessionCtx.ReplyToId
103
+ : (turn.sessionCtx.MessageSidFull ?? turn.sessionCtx.MessageSid);
104
+ const commandDetailsVisible = turn.resolvedVerboseLevel === "full";
105
+ const cliToolSummaryTracker = createCliToolSummaryTracker({
106
+ detailMode: turn.toolProgressDetail,
107
+ commandDetailsVisible,
108
+ shouldEmitToolResult: turn.shouldEmitToolResult,
109
+ shouldEmitToolOutput: turn.shouldEmitToolOutput,
110
+ deliver: async (payload) => {
111
+ await turn.opts?.onToolResult?.(payload);
112
+ },
113
+ });
114
+ // CLI backends report a tool's outcome on the result event and never repeat it,
115
+ // so the terminal fact has to be projected here. The embedded path gets this
116
+ // from the shared agent-event handler; without it a failed CLI command renders
117
+ // exactly like one that succeeded.
118
+ const deliverCliCommandOutcome = async (
119
+ payload: {
120
+ name: string | undefined;
121
+ phase: "start" | "update" | "result";
122
+ args: Record<string, unknown> | undefined;
123
+ toolCallId?: string;
124
+ isError?: boolean;
125
+ result?: unknown;
126
+ },
127
+ commandBearing: boolean,
128
+ ) => {
129
+ const onCommandOutput = turn.opts?.onCommandOutput;
130
+ if (!onCommandOutput) {
131
+ return;
132
+ }
133
+ const commandOutput = buildCommandOutputFromToolResultEvent({
134
+ stream: "tool",
135
+ data: { ...payload, commandBearing },
136
+ });
137
+ if (commandOutput) {
138
+ await onCommandOutput(commandOutput);
139
+ }
140
+ };
141
+ const bridgeCliPreambleProgress =
142
+ Boolean(turn.opts?.onItemEvent) && shouldBridgeCliPreambleEvents(turn.opts);
143
+ const bridgeCliDurableCommentary =
144
+ Boolean(params.presentation.blockReplyHandler) &&
145
+ (turn.blockStreamingEnabled || turn.opts?.commentaryPayloadsEnabled === true);
146
+ const toolAuthorityRoute = { provider: params.provider, model: params.model };
147
+ const toolAuthorityFingerprint = turn.replyOperation?.bindToolAuthorityRoute(toolAuthorityRoute);
148
+ const result = await params.timing.measure("cli_run", () =>
149
+ withLocalSessionPlacementTurnSettlement(
150
+ {
151
+ sessionId: turn.followupRun.run.sessionId,
152
+ sessionKey,
153
+ agentId: turn.followupRun.run.agentId,
154
+ runId: params.runId,
155
+ },
156
+ async (assertSettlementCurrent) => {
157
+ // Placement admission may wait behind an older turn. Snapshot placement,
158
+ // permission, and native resume identity only after this turn owns it.
159
+ const sessionEntry = sessionTarget
160
+ ? loadSessionEntry({ ...sessionTarget, readConsistency: "latest" })
161
+ : turn.getActiveSessionEntry();
162
+ if (
163
+ sessionTarget &&
164
+ (sessionEntry?.sessionId !== sessionTarget.sessionId ||
165
+ sessionEntry.lifecycleRevision !== expectedLifecycleRevision)
166
+ ) {
167
+ throw createAgentRunSupersededAbortError();
168
+ }
169
+ const cliSessionBinding = getCliSessionBinding(sessionEntry, params.cliExecutionProvider);
170
+ // The CLI owner must see explicit pins before provider scoping can discard them.
171
+ const authProfileId = allowCliAuthProfileForwarding
172
+ ? resolveCliExecutionAuthProfileId({
173
+ cliExecutionProvider: params.cliExecutionProvider,
174
+ authProfileProvider: params.provider,
175
+ config: params.runtimeConfig,
176
+ agentDir: params.candidateRun.agentDir,
177
+ selected: params.candidateRun,
178
+ sessionBinding: cliSessionBinding,
179
+ })
180
+ : resolveRunAuthProfile(params.candidateRun, params.cliExecutionProvider, {
181
+ config: params.runtimeConfig,
182
+ }).authProfileId;
183
+ const diagnosticOwner = params.deferredLifecycle.handoffToCli();
184
+ const mediaTaskIdsBefore = getGeneratedMediaTaskIdsForSessionKey(turn.sessionKey);
185
+ let droppedCliSessionReplacement = false;
186
+ const candidateResult = await runCliAgentWithLifecycle({
187
+ runId: params.runId,
188
+ lifecycleGeneration: params.lifecycleGeneration,
189
+ provider: params.cliExecutionProvider,
190
+ startedAt: cliLifecycleStartedAt,
191
+ emitLifecycleTerminal: false,
192
+ onAgentRunStart: params.notifyAgentRunStart,
193
+ suppressAssistantBridge: turn.followupRun.run.silentExpected,
194
+ onActivity: () => turn.replyOperation?.recordActivity(),
195
+ onErrorBeforeLifecycle:
196
+ params.cliExecutionProvider === "claude-cli" && cliSessionBinding?.sessionId
197
+ ? async (error) => {
198
+ if (
199
+ !shouldClearFailedCliSessionBinding({
200
+ error,
201
+ binding: cliSessionBinding,
202
+ hasNewGeneratedMediaTask: hasNewGeneratedMediaTaskForSessionKey(
203
+ turn.sessionKey,
204
+ mediaTaskIdsBefore,
205
+ ),
206
+ })
207
+ ) {
208
+ return;
209
+ }
210
+ await clearCliSessionInStore({
211
+ provider: params.cliExecutionProvider,
212
+ expectedCliSessionId: cliSessionBinding.sessionId,
213
+ expectedSessionId: sessionEntry?.sessionId,
214
+ assertCommitAllowed: assertSettlementCurrent,
215
+ sessionKey: turn.sessionKey,
216
+ sessionStore: turn.activeSessionStore,
217
+ storePath: turn.storePath,
218
+ activeSessionEntry: sessionEntry,
219
+ });
220
+ }
221
+ : undefined,
222
+ preserveProgressCallbackStartOrder: params.preserveProgressCallbackStartOrder,
223
+ onAssistantText: async (text) => {
224
+ const classified = params.presentation.classifyStreamingPartial({ text });
225
+ if (classified.skip || !classified.text) {
226
+ return;
227
+ }
228
+ const textForTyping = classified.text;
229
+ const sanitized = params.presentation.sanitizeStreamingText(textForTyping, false);
230
+ const onPartialReply = turn.opts?.onPartialReply;
231
+ return await params.presentation.presentWithTyping(
232
+ turn.typingSignals.signalTextDelta(textForTyping),
233
+ () =>
234
+ sanitized.skip || !sanitized.text || !onPartialReply
235
+ ? false
236
+ : onPartialReply({ text: sanitized.text }),
237
+ );
238
+ },
239
+ onReasoningText: createCliReasoningStreamBridge(turn.opts?.onReasoningStream),
240
+ onPlanUpdate: turn.opts?.onPlanUpdate,
241
+ onReasoningProgress: async (payload) => {
242
+ await turn.opts?.onReasoningProgress?.(payload);
243
+ },
244
+ onCompactionStart: turn.opts?.onCompactionStart,
245
+ onCompactionEnd: turn.opts?.onCompactionEnd,
246
+ onToolEvent: async (payload) => {
247
+ if (!params.preserveProgressCallbackStartOrder) {
248
+ const commandBearing = await cliToolSummaryTracker.noteToolEvent(payload);
249
+ if (payload.phase === "result") {
250
+ await deliverCliCommandOutcome(payload, commandBearing);
251
+ return;
252
+ }
253
+ const { name, phase, args, toolCallId } = payload;
254
+ await Promise.all([
255
+ turn.typingSignals.signalToolStart(),
256
+ turn.opts?.onToolStart?.({
257
+ ...(toolCallId ? { toolCallId } : {}),
258
+ name,
259
+ phase,
260
+ args,
261
+ detailMode: turn.toolProgressDetail,
262
+ }),
263
+ ]);
264
+ return;
265
+ }
266
+ const summaryPromise = cliToolSummaryTracker.noteToolEvent(payload);
267
+ if (payload.phase === "result") {
268
+ const commandBearing = await summaryPromise;
269
+ await deliverCliCommandOutcome(payload, commandBearing);
270
+ return;
271
+ }
272
+ const { name, phase, args, toolCallId } = payload;
273
+ // Tool and assistant bridges drain independently. Preserve source order.
274
+ await Promise.all([
275
+ summaryPromise,
276
+ params.presentation.presentWithTyping(
277
+ turn.typingSignals.signalToolStart(),
278
+ async () => {
279
+ await turn.opts?.onToolStart?.({
280
+ ...(toolCallId ? { toolCallId } : {}),
281
+ name,
282
+ phase,
283
+ args,
284
+ detailMode: turn.toolProgressDetail,
285
+ });
286
+ },
287
+ ),
288
+ ]);
289
+ },
290
+ onCommentaryText:
291
+ bridgeCliPreambleProgress || bridgeCliDurableCommentary
292
+ ? async (payload) => {
293
+ const deliveries: unknown[] = [];
294
+ if (bridgeCliPreambleProgress) {
295
+ deliveries.push(
296
+ turn.opts?.onItemEvent?.({
297
+ itemId: payload.itemId,
298
+ kind: "preamble",
299
+ progressText: payload.text,
300
+ // The block bridge owns durability; this event remains a progress preview.
301
+ ...(bridgeCliDurableCommentary ? { suppressDurableProgress: true } : {}),
302
+ }),
303
+ );
304
+ }
305
+ if (bridgeCliDurableCommentary) {
306
+ // Block mode treats completed CLI text as an ordinary answer block so
307
+ // the existing pipeline owns coalescing and final-payload dedupe.
308
+ deliveries.push(
309
+ params.presentation.blockReplyHandler?.({
310
+ text: payload.text,
311
+ ...(turn.blockStreamingEnabled ? {} : { isCommentary: true }),
312
+ }),
313
+ );
314
+ }
315
+ await Promise.all(deliveries);
316
+ }
317
+ : undefined,
318
+ onFastModeAutoProgress: async (payload) => {
319
+ await turn.opts?.onToolResult?.(payload);
320
+ },
321
+ transformResult:
322
+ turn.followupRun.currentInboundEventKind === "room_event"
323
+ ? (resultLocal) =>
324
+ keepCliSessionBindingOnlyWhenReused({
325
+ result: resultLocal,
326
+ existingSessionId: cliSessionBinding?.sessionId,
327
+ onDroppedReplacement: () => {
328
+ droppedCliSessionReplacement = true;
329
+ },
330
+ })
331
+ : undefined,
332
+ runParams: {
333
+ preparedRunAdmission: params.preparedRunAdmission,
334
+ messageActionTurnCapability: params.messageActionTurnCapability,
335
+ diagnosticOwner,
336
+ sessionId: turn.followupRun.run.sessionId,
337
+ sessionKey,
338
+ sessionTarget,
339
+ sessionEntry,
340
+ chatType:
341
+ normalizeChatType(turn.followupRun.originatingChatType) ??
342
+ normalizeChatType(turn.sessionCtx.ChatType) ??
343
+ params.candidateRun.chatType,
344
+ runtimePolicySessionKey:
345
+ turn.followupRun.run.runtimePolicySessionKey ?? turn.runtimePolicySessionKey,
346
+ agentId: turn.followupRun.run.agentId,
347
+ trigger: turn.isHeartbeat ? "heartbeat" : "user",
348
+ sessionFile: turn.followupRun.run.sessionFile,
349
+ workspaceDir: turn.followupRun.run.workspaceDir,
350
+ cwd: turn.followupRun.run.cwd,
351
+ config: params.runtimeConfig,
352
+ toolOverrides: turn.followupRun.run.toolOverrides,
353
+ prompt: turn.commandBody,
354
+ transcriptPrompt: turn.transcriptCommandBody,
355
+ media: turn.followupRun.media,
356
+ suppressNextUserMessagePersistence: params.suppressQueuedUserPersistenceForCandidate,
357
+ userTurnTranscriptRecorder: params.userTurnTranscriptRecorder,
358
+ contextEngineLogicalTurnLease: params.contextEngineLogicalTurnLease,
359
+ onContextEngineTurnCandidate: params.onContextEngineTurnCandidate,
360
+ onUserMessagePersisted: params.notifyUserMessagePersisted,
361
+ prepareAssistantTranscriptMessage: turn.opts?.prepareAssistantTranscriptMessage,
362
+ persistAssistantTranscript:
363
+ turn.followupRun.currentInboundEventKind !== "room_event" &&
364
+ turn.followupRun.run.suppressTranscriptOnlyAssistantPersistence !== true,
365
+ storePath: turn.storePath,
366
+ currentInboundEventKind: turn.followupRun.currentInboundEventKind,
367
+ currentInboundContext: turn.followupRun.currentInboundContext,
368
+ inputProvenance: turn.followupRun.run.inputProvenance,
369
+ // Candidate zero is the primary attempt; later candidates are
370
+ // fallbacks. Carry the runner-owned fact instead of inferring from
371
+ // this shared dispatch path, or primary CLI runs lose delegation.
372
+ ...buildCliMcpDelegationCapabilityBinding(
373
+ resolveDelegationCapability({
374
+ fallbackActive: params.isFallbackRetry,
375
+ inputProvenance: turn.followupRun.run.inputProvenance,
376
+ disableTools: turn.opts?.disableTools,
377
+ toolsAllow: turn.opts?.toolsAllow,
378
+ }),
379
+ ),
380
+ modelProvider: params.provider,
381
+ requesterModel: { provider: params.provider, model: params.model },
382
+ modelHasVision,
383
+ modelContextWindow: selectedModelEntry?.contextWindow,
384
+ modelContextTokens: selectedModelEntry?.contextTokens,
385
+ contextWindow: sessionEntry?.contextWindow,
386
+ provider: params.cliExecutionProvider,
387
+ execOverrides: turn.followupRun.run.execOverrides,
388
+ bashElevated: turn.followupRun.run.bashElevated,
389
+ model: params.model,
390
+ thinkLevel: params.candidateThinkLevel,
391
+ fastMode: params.candidateFastMode.fastMode,
392
+ fastModeStartedAtMs: params.fastModeStartedAtMs,
393
+ fastModeAutoOnSeconds: params.candidateFastMode.fastModeAutoOnSeconds,
394
+ fastModeAutoProgressState: params.fastModeAutoProgressState,
395
+ isFinalFallbackAttempt: params.isFinalFallbackAttempt,
396
+ timeoutMs: turn.followupRun.run.timeoutMs,
397
+ runTimeoutOverrideMs: turn.followupRun.run.runTimeoutOverrideMs,
398
+ runId: params.runId,
399
+ lane: params.runLane,
400
+ extraSystemPrompt: turn.followupRun.run.extraSystemPrompt,
401
+ sourceReplyDeliveryMode: turn.followupRun.run.sourceReplyDeliveryMode,
402
+ taskSuggestionDeliveryMode: turn.followupRun.run.taskSuggestionDeliveryMode,
403
+ // Heartbeat ambient routes are never implicit message recipients.
404
+ ...(turn.isHeartbeat ? { requireExplicitMessageTarget: true } : {}),
405
+ cleanupBundleMcpOnRunEnd: turn.opts?.cleanupBundleMcpOnRunEnd,
406
+ silentReplyPromptMode: turn.followupRun.run.silentReplyPromptMode,
407
+ allowEmptyAssistantReplyAsSilent: turn.followupRun.run.allowEmptyAssistantReplyAsSilent,
408
+ extraSystemPromptStatic: turn.followupRun.run.extraSystemPromptStatic,
409
+ cliSessionBindingFacts: turn.followupRun.run.cliSessionBindingFacts,
410
+ ownerNumbers: turn.followupRun.run.ownerNumbers,
411
+ cliSessionId: cliSessionBinding?.sessionId,
412
+ cliSessionBinding,
413
+ authProfileId,
414
+ bootstrapContextMode: turn.opts?.bootstrapContextMode,
415
+ bootstrapContextRunKind: params.bootstrapContextRunKind,
416
+ bootstrapPromptWarningSignaturesSeen: params.bootstrapPromptWarningSignaturesSeen,
417
+ bootstrapPromptWarningSignature:
418
+ params.bootstrapPromptWarningSignaturesSeen[
419
+ params.bootstrapPromptWarningSignaturesSeen.length - 1
420
+ ],
421
+ images: params.currentTurnImages.images,
422
+ imageOrder: params.currentTurnImages.imageOrder,
423
+ mediaImageLayout: params.currentTurnImages.mediaImageLayout,
424
+ skillsSnapshot: turn.followupRun.run.skillsSnapshot,
425
+ messageChannel: turn.followupRun.originatingChannel ?? undefined,
426
+ messageProvider: hookMessageProvider,
427
+ clientCaps: turn.followupRun.run.clientCaps,
428
+ gatewayUiCommandTarget: turn.followupRun.run.gatewayUiCommandTarget,
429
+ currentChannelId:
430
+ turn.followupRun.originatingTo ?? turn.sessionCtx.OriginatingTo ?? turn.sessionCtx.To,
431
+ senderId: turn.followupRun.run.senderId,
432
+ senderName: turn.followupRun.run.senderName,
433
+ senderUsername: turn.followupRun.run.senderUsername,
434
+ senderE164: turn.followupRun.run.senderE164,
435
+ groupId: turn.followupRun.run.groupId,
436
+ groupChannel: turn.followupRun.run.groupChannel,
437
+ groupSpace: turn.followupRun.run.groupSpace,
438
+ spawnedBy: turn.followupRun.run.spawnedBy,
439
+ chatId: turn.followupRun.originatingChatId,
440
+ channelContext: turn.followupRun.run.channelContext,
441
+ currentThreadTs: cliCurrentThreadId != null ? String(cliCurrentThreadId) : undefined,
442
+ currentMessageId: cliCurrentMessageId,
443
+ replyToMode: turn.followupRun.originatingReplyToMode ?? turn.sessionCtx.ReplyToMode,
444
+ currentInboundAudio: hasInboundAudio(turn.sessionCtx),
445
+ agentAccountId: turn.followupRun.run.agentAccountId,
446
+ senderIsOwner: turn.followupRun.run.senderIsOwner,
447
+ approvalReviewerDeviceId: turn.followupRun.run.approvalReviewerDeviceId,
448
+ toolsAllow: turn.opts?.toolsAllow,
449
+ skillWorkshopProposalRevision: params.candidateRun.skillWorkshopProposalRevision,
450
+ skillLibraryAuthoring: params.candidateRun.skillLibraryAuthoring,
451
+ disableTools: turn.opts?.disableTools,
452
+ toolAuthorityFingerprint,
453
+ abortSignal: params.runAbortSignal,
454
+ // Native input is already host-authored. Keep its stable delivery
455
+ // context out of the model-output normalization wrapper.
456
+ onBlockReply: turn.opts?.onBlockReply,
457
+ onPartialReply: turn.opts?.onPartialReply,
458
+ onExecutionPhase: params.signalExecutionPhaseForTyping,
459
+ replyOperation: turn.replyOperation,
460
+ },
461
+ });
462
+ if (droppedCliSessionReplacement) {
463
+ // The room-event transform removed native continuity; only its guarded
464
+ // invalidation remains, and failure must retain the returned turn.
465
+ return await settleCliSessionResult(candidateResult, async () => {
466
+ await clearCliSessionInStore({
467
+ provider: params.cliExecutionProvider,
468
+ expectedCliSessionId: cliSessionBinding?.sessionId,
469
+ expectedSessionId: sessionEntry?.sessionId,
470
+ assertCommitAllowed: assertSettlementCurrent,
471
+ sessionKey: turn.sessionKey,
472
+ sessionStore: turn.activeSessionStore,
473
+ storePath: turn.storePath,
474
+ activeSessionEntry: sessionEntry,
475
+ });
476
+ params.classifyResult(candidateResult);
477
+ });
478
+ }
479
+ const classification = params.classifyResult(candidateResult);
480
+ if (
481
+ (!classification || candidateResult.meta.agentMeta?.clearCliSessionBinding === true) &&
482
+ !shouldPreserveUserFacingSessionStateForInputProvenance(
483
+ turn.followupRun.run.inputProvenance,
484
+ )
485
+ ) {
486
+ return await persistCliSessionBindingResult({
487
+ provider: params.cliExecutionProvider,
488
+ result: candidateResult,
489
+ sessionKey,
490
+ storePath: turn.storePath,
491
+ sessionStore: turn.activeSessionStore,
492
+ expectedSession: sessionEntry,
493
+ assertSettlementCurrent,
494
+ abortSignal: params.runAbortSignal,
495
+ });
496
+ }
497
+ return candidateResult;
498
+ },
499
+ {
500
+ preparedRunAdmission: params.preparedRunAdmission,
501
+ lifecycleGeneration: params.lifecycleGeneration,
502
+ abortSignal: params.runAbortSignal,
503
+ trigger: turn.isHeartbeat ? "heartbeat" : "user",
504
+ inputProvenance: turn.followupRun.run.inputProvenance,
505
+ },
506
+ ),
507
+ );
508
+ return {
509
+ result,
510
+ bootstrapPromptWarningSignaturesSeen: resolveBootstrapWarningSignaturesSeen(
511
+ result.meta?.systemPromptReport,
512
+ ),
513
+ };
514
+ }
src/auto-reply/reply/agent-runner-cli-dispatch.test.ts ADDED
@@ -0,0 +1,1051 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tests CLI dispatch arguments and runtime selection for agent runner turns.
2
+ import path from "node:path";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
5
+ import { withTestAdmittedRunContext } from "../../agents/admitted-run-context.test-support.js";
6
+ import { createCliTimeoutError } from "../../agents/cli-runner/no-output-timeout-policy.js";
7
+ import { clearCliSessionInStore } from "../../agents/cli-session-store.js";
8
+ import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent-runner/types.js";
9
+ import { createAgentRunRestartAbortError } from "../../agents/run-termination.js";
10
+ import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
11
+ import {
12
+ emitAgentEvent,
13
+ getAgentEventLifecycleGeneration,
14
+ onAgentEvent,
15
+ resetAgentEventsForTest,
16
+ } from "../../infra/agent-events.js";
17
+ import {
18
+ createCliToolSummaryTracker,
19
+ keepCliSessionBindingOnlyWhenReused,
20
+ runCliAgentWithLifecycle as runCliAgentWithLifecycleProduction,
21
+ } from "./agent-runner-cli-dispatch.js";
22
+
23
+ type ProductionLifecycleParams = Parameters<typeof runCliAgentWithLifecycleProduction>[0];
24
+ type RunCliAgentWithLifecycleParams = Omit<ProductionLifecycleParams, "runParams"> & {
25
+ runParams: Omit<ProductionLifecycleParams["runParams"], "admittedRunContext">;
26
+ };
27
+ const runCliAgentWithLifecycle = (params: RunCliAgentWithLifecycleParams) =>
28
+ runCliAgentWithLifecycleProduction({
29
+ ...params,
30
+ runParams: withTestAdmittedRunContext(params.runParams),
31
+ });
32
+ type ReasoningTextPayload = Parameters<
33
+ NonNullable<RunCliAgentWithLifecycleParams["onReasoningText"]>
34
+ >[0];
35
+ type ReasoningProgressPayload = Parameters<
36
+ NonNullable<RunCliAgentWithLifecycleParams["onReasoningProgress"]>
37
+ >[0];
38
+
39
+ const cliDispatchState = vi.hoisted(() => ({
40
+ runCliAgentMock: vi.fn(),
41
+ }));
42
+ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
43
+
44
+ vi.mock("../../agents/cli-runner.js", () => ({
45
+ runCliAgent: (...args: unknown[]) => cliDispatchState.runCliAgentMock(...args),
46
+ }));
47
+
48
+ afterEach(() => {
49
+ vi.useRealTimers();
50
+ resetAgentEventsForTest();
51
+ cliDispatchState.runCliAgentMock.mockReset();
52
+ });
53
+
54
+ describe("runCliAgentWithLifecycle", () => {
55
+ it("bridges completed CLI compaction lifecycles to reply callbacks", async () => {
56
+ cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
57
+ emitAgentEvent({
58
+ runId: params.runId,
59
+ stream: "compaction",
60
+ data: { phase: "start", backend: "claude-cli" },
61
+ });
62
+ emitAgentEvent({
63
+ runId: params.runId,
64
+ stream: "compaction",
65
+ data: { phase: "end", backend: "claude-cli", completed: false },
66
+ });
67
+ emitAgentEvent({
68
+ runId: params.runId,
69
+ stream: "compaction",
70
+ data: { phase: "start", backend: "claude-cli" },
71
+ });
72
+ emitAgentEvent({
73
+ runId: params.runId,
74
+ stream: "compaction",
75
+ data: { phase: "end", backend: "claude-cli", completed: true },
76
+ });
77
+ return { payloads: [], meta: { durationMs: 1 } };
78
+ });
79
+ const callbacks: string[] = [];
80
+
81
+ await runCliAgentWithLifecycle({
82
+ runId: "run-compaction-bridge",
83
+ provider: "claude-cli",
84
+ onCompactionStart: async () => {
85
+ callbacks.push("start");
86
+ },
87
+ onCompactionEnd: async (payload) => {
88
+ callbacks.push(payload?.completed === false ? "incomplete" : "end");
89
+ },
90
+ runParams: {
91
+ sessionId: "session-1",
92
+ sessionFile: "/tmp/session.jsonl",
93
+ workspaceDir: "/tmp/workspace",
94
+ prompt: "hello",
95
+ provider: "claude-cli",
96
+ model: "claude-opus-4-8",
97
+ thinkLevel: "high",
98
+ timeoutMs: 1_000,
99
+ runId: "run-compaction-bridge",
100
+ },
101
+ });
102
+
103
+ expect(callbacks).toEqual(["start", "incomplete", "start", "end"]);
104
+ });
105
+
106
+ it("bridges typed CLI plan events", async () => {
107
+ cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
108
+ emitAgentEvent({
109
+ runId: params.runId,
110
+ stream: "plan",
111
+ data: {
112
+ phase: "update",
113
+ title: "Plan updated",
114
+ source: "codex-exec",
115
+ steps: [
116
+ { step: "Inspect", status: "completed" },
117
+ { step: "Patch", status: "pending" },
118
+ ],
119
+ },
120
+ });
121
+ return { payloads: [], meta: { durationMs: 1 } };
122
+ });
123
+ const onPlanUpdate = vi.fn(async () => undefined);
124
+
125
+ await runCliAgentWithLifecycle({
126
+ runId: "run-plan-bridge",
127
+ provider: "codex-cli",
128
+ onPlanUpdate,
129
+ runParams: {
130
+ sessionId: "session-1",
131
+ sessionFile: "/tmp/session.jsonl",
132
+ workspaceDir: "/tmp/workspace",
133
+ prompt: "hello",
134
+ provider: "codex-cli",
135
+ model: "codex",
136
+ thinkLevel: "high",
137
+ timeoutMs: 1_000,
138
+ runId: "run-plan-bridge",
139
+ },
140
+ });
141
+
142
+ expect(onPlanUpdate).toHaveBeenCalledWith({
143
+ phase: "update",
144
+ title: "Plan updated",
145
+ explanation: undefined,
146
+ source: "codex-exec",
147
+ steps: [
148
+ { step: "Inspect", status: "completed" },
149
+ { step: "Patch", status: "pending" },
150
+ ],
151
+ });
152
+ });
153
+
154
+ it("normalizes shipped string-step plan events to pending typed steps", async () => {
155
+ cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
156
+ emitAgentEvent({
157
+ runId: params.runId,
158
+ stream: "plan",
159
+ data: {
160
+ phase: "update",
161
+ title: "Plan updated",
162
+ source: "codex-exec",
163
+ steps: ["Inspect", " ", "Patch"],
164
+ },
165
+ });
166
+ return { payloads: [], meta: { durationMs: 1 } };
167
+ });
168
+ const onPlanUpdate = vi.fn(async () => undefined);
169
+
170
+ await runCliAgentWithLifecycle({
171
+ runId: "run-plan-legacy",
172
+ provider: "codex-cli",
173
+ onPlanUpdate,
174
+ runParams: {
175
+ sessionId: "session-1",
176
+ sessionFile: "/tmp/session.jsonl",
177
+ workspaceDir: "/tmp/workspace",
178
+ prompt: "hello",
179
+ provider: "codex-cli",
180
+ model: "codex",
181
+ thinkLevel: "high",
182
+ timeoutMs: 1_000,
183
+ runId: "run-plan-legacy",
184
+ },
185
+ });
186
+
187
+ expect(onPlanUpdate).toHaveBeenCalledWith(
188
+ expect.objectContaining({
189
+ steps: [
190
+ { step: "Inspect", status: "pending" },
191
+ { step: "Patch", status: "pending" },
192
+ ],
193
+ }),
194
+ );
195
+ });
196
+
197
+ it("bridges thinking events to reasoning text and dedupes identical snapshots", async () => {
198
+ cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
199
+ emitAgentEvent({
200
+ runId: params.runId,
201
+ stream: "thinking",
202
+ data: { text: "Thinking", delta: "Thinking", isReasoningSnapshot: true },
203
+ });
204
+ emitAgentEvent({
205
+ runId: params.runId,
206
+ stream: "thinking",
207
+ data: { text: "Thinking", delta: "", isReasoningSnapshot: true },
208
+ });
209
+ emitAgentEvent({
210
+ runId: params.runId,
211
+ stream: "thinking",
212
+ data: { text: "Thinking more", delta: " more", isReasoningSnapshot: true },
213
+ });
214
+ emitAgentEvent({
215
+ runId: params.runId,
216
+ stream: "assistant",
217
+ data: { text: "Visible answer", delta: "Visible answer" },
218
+ });
219
+ return { payloads: [{ text: "Visible answer" }], meta: { durationMs: 1 } };
220
+ });
221
+ const onReasoningText = vi.fn<(payload: ReasoningTextPayload) => Promise<void>>(
222
+ async () => undefined,
223
+ );
224
+
225
+ const result = await runCliAgentWithLifecycle({
226
+ runId: "run-thinking-bridge",
227
+ provider: "claude-cli",
228
+ onReasoningText,
229
+ runParams: {
230
+ sessionId: "session-1",
231
+ sessionFile: "/tmp/session.jsonl",
232
+ workspaceDir: "/tmp/workspace",
233
+ prompt: "hello",
234
+ provider: "claude-cli",
235
+ model: "claude",
236
+ thinkLevel: "high",
237
+ timeoutMs: 1_000,
238
+ runId: "run-thinking-bridge",
239
+ },
240
+ });
241
+
242
+ expect(onReasoningText).toHaveBeenCalledTimes(2);
243
+ expect(onReasoningText.mock.calls.map((call) => call[0])).toEqual([
244
+ { text: "Thinking", isReasoningSnapshot: true },
245
+ { text: "Thinking more", isReasoningSnapshot: true },
246
+ ]);
247
+ expect(result.payloads).toEqual([
248
+ { text: "Thinking more", isReasoning: true },
249
+ { text: "Visible answer" },
250
+ ]);
251
+ });
252
+
253
+ it("keeps durable reasoning when the CLI has no visible final answer", async () => {
254
+ cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
255
+ emitAgentEvent({
256
+ runId: params.runId,
257
+ stream: "thinking",
258
+ data: { text: "Only thinking", delta: "Only thinking", isReasoningSnapshot: true },
259
+ });
260
+ emitAgentEvent({
261
+ runId: params.runId,
262
+ stream: "thinking",
263
+ data: { text: "Only thinking more", delta: " more", isReasoningSnapshot: true },
264
+ });
265
+ return { payloads: [], meta: { durationMs: 1 } };
266
+ });
267
+
268
+ const result = await runCliAgentWithLifecycle({
269
+ runId: "run-thinking-without-answer",
270
+ provider: "claude-cli",
271
+ runParams: {
272
+ sessionId: "session-1",
273
+ sessionFile: "/tmp/session.jsonl",
274
+ workspaceDir: "/tmp/workspace",
275
+ prompt: "hello",
276
+ provider: "claude-cli",
277
+ model: "claude",
278
+ thinkLevel: "high",
279
+ timeoutMs: 1_000,
280
+ runId: "run-thinking-without-answer",
281
+ },
282
+ });
283
+
284
+ expect(result.payloads).toEqual([{ text: "Only thinking more", isReasoning: true }]);
285
+ });
286
+
287
+ it("bridges thinking token progress without adding durable reasoning", async () => {
288
+ cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
289
+ emitAgentEvent({
290
+ runId: params.runId,
291
+ stream: "thinking",
292
+ data: { progressTokens: 50 },
293
+ });
294
+ emitAgentEvent({
295
+ runId: params.runId,
296
+ stream: "thinking",
297
+ data: { progressTokens: 50 },
298
+ });
299
+ emitAgentEvent({
300
+ runId: params.runId,
301
+ stream: "thinking",
302
+ data: { progressTokens: 200 },
303
+ });
304
+ return { payloads: [{ text: "Visible answer" }], meta: { durationMs: 1 } };
305
+ });
306
+ const onReasoningProgress = vi.fn<(payload: ReasoningProgressPayload) => Promise<void>>(
307
+ async () => undefined,
308
+ );
309
+
310
+ const result = await runCliAgentWithLifecycle({
311
+ runId: "run-thinking-progress",
312
+ provider: "claude-cli",
313
+ onReasoningProgress,
314
+ runParams: {
315
+ sessionId: "session-1",
316
+ sessionFile: "/tmp/session.jsonl",
317
+ workspaceDir: "/tmp/workspace",
318
+ prompt: "hello",
319
+ provider: "claude-cli",
320
+ model: "claude",
321
+ thinkLevel: "high",
322
+ timeoutMs: 1_000,
323
+ runId: "run-thinking-progress",
324
+ },
325
+ });
326
+
327
+ expect(onReasoningProgress.mock.calls.map((call) => call[0])).toEqual([
328
+ { progressTokens: 50 },
329
+ { progressTokens: 200 },
330
+ ]);
331
+ expect(result.payloads).toEqual([{ text: "Visible answer" }]);
332
+ });
333
+
334
+ it("stamps onActivity for every delivered bridge event, including reasoning-only progress", async () => {
335
+ cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
336
+ emitAgentEvent({
337
+ runId: params.runId,
338
+ stream: "thinking",
339
+ data: { progressTokens: 50 },
340
+ });
341
+ emitAgentEvent({
342
+ runId: params.runId,
343
+ stream: "thinking",
344
+ data: { progressTokens: 50 },
345
+ });
346
+ emitAgentEvent({
347
+ runId: params.runId,
348
+ stream: "thinking",
349
+ data: { progressTokens: 200 },
350
+ });
351
+ return { payloads: [], meta: { durationMs: 1 } };
352
+ });
353
+ const onActivity = vi.fn();
354
+ const onReasoningProgress = vi.fn<(payload: ReasoningProgressPayload) => Promise<void>>(
355
+ async () => undefined,
356
+ );
357
+
358
+ await runCliAgentWithLifecycle({
359
+ runId: "run-activity-reasoning-progress",
360
+ provider: "claude-cli",
361
+ onActivity,
362
+ onReasoningProgress,
363
+ runParams: {
364
+ sessionId: "session-1",
365
+ sessionFile: "/tmp/session.jsonl",
366
+ workspaceDir: "/tmp/workspace",
367
+ prompt: "hello",
368
+ provider: "claude-cli",
369
+ model: "claude",
370
+ thinkLevel: "high",
371
+ timeoutMs: 1_000,
372
+ runId: "run-activity-reasoning-progress",
373
+ },
374
+ });
375
+
376
+ // A run in a long pure-reasoning stretch must keep stamping activity, or
377
+ // stale-takeover reclaims it while it is visibly thinking. Stamps are
378
+ // per-event (delivery-independent), so dedupe downstream does not matter.
379
+ expect(onReasoningProgress).toHaveBeenCalledTimes(2);
380
+ expect(onActivity).toHaveBeenCalledTimes(3);
381
+ });
382
+
383
+ it("keeps stamping onActivity when bridges are suppressed for silent runs", async () => {
384
+ cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
385
+ emitAgentEvent({
386
+ runId: params.runId,
387
+ stream: "thinking",
388
+ data: { progressTokens: 50 },
389
+ });
390
+ emitAgentEvent({
391
+ runId: params.runId,
392
+ stream: "assistant",
393
+ data: { text: "Silent answer", delta: "Silent answer" },
394
+ });
395
+ return { payloads: [], meta: { durationMs: 1 } };
396
+ });
397
+ const onActivity = vi.fn();
398
+ const onAssistantText = vi.fn<(text: string) => Promise<void>>(async () => undefined);
399
+ const onReasoningProgress = vi.fn<(payload: ReasoningProgressPayload) => Promise<void>>(
400
+ async () => undefined,
401
+ );
402
+
403
+ await runCliAgentWithLifecycle({
404
+ runId: "run-activity-suppressed",
405
+ provider: "claude-cli",
406
+ suppressAssistantBridge: true,
407
+ onActivity,
408
+ onAssistantText,
409
+ onReasoningProgress,
410
+ runParams: {
411
+ sessionId: "session-1",
412
+ sessionFile: "/tmp/session.jsonl",
413
+ workspaceDir: "/tmp/workspace",
414
+ prompt: "hello",
415
+ provider: "claude-cli",
416
+ model: "claude",
417
+ thinkLevel: "high",
418
+ timeoutMs: 1_000,
419
+ runId: "run-activity-suppressed",
420
+ },
421
+ });
422
+
423
+ // silentExpected runs suppress deliveries, but their events are still real
424
+ // liveness evidence — without these stamps a healthy silent run would be
425
+ // reclaimed as run_stalled at the takeover window.
426
+ expect(onAssistantText).not.toHaveBeenCalled();
427
+ expect(onReasoningProgress).not.toHaveBeenCalled();
428
+ expect(onActivity).toHaveBeenCalledTimes(2);
429
+ });
430
+
431
+ it("stamps onActivity for assistant text without caller callbacks for that stream", async () => {
432
+ cliDispatchState.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
433
+ emitAgentEvent({
434
+ runId: params.runId,
435
+ stream: "thinking",
436
+ data: { text: "Deep thought", delta: "Deep thought", isReasoningSnapshot: true },
437
+ });
438
+ emitAgentEvent({
439
+ runId: params.runId,
440
+ stream: "assistant",
441
+ data: { text: "Visible answer", delta: "Visible answer" },
442
+ });
443
+ return { payloads: [{ text: "Visible answer" }], meta: { durationMs: 1 } };
444
+ });
445
+ const onActivity = vi.fn();
446
+ const onAssistantText = vi.fn<(text: string) => Promise<void>>(async () => undefined);
447
+
448
+ await runCliAgentWithLifecycle({
449
+ runId: "run-activity-assistant",
450
+ provider: "claude-cli",
451
+ onActivity,
452
+ onAssistantText,
453
+ runParams: {
454
+ sessionId: "session-1",
455
+ sessionFile: "/tmp/session.jsonl",
456
+ workspaceDir: "/tmp/workspace",
457
+ prompt: "hello",
458
+ provider: "claude-cli",
459
+ model: "claude",
460
+ thinkLevel: "high",
461
+ timeoutMs: 1_000,
462
+ runId: "run-activity-assistant",
463
+ },
464
+ });
465
+
466
+ // Every real event stamps, independent of which callbacks are registered.
467
+ expect(onAssistantText).toHaveBeenCalledTimes(1);
468
+ expect(onActivity).toHaveBeenCalledTimes(2);
469
+ });
470
+
471
+ it("does not add a durable reasoning payload when the CLI emits no thinking", async () => {
472
+ cliDispatchState.runCliAgentMock.mockResolvedValueOnce({
473
+ payloads: [{ text: "Visible answer" }],
474
+ meta: { durationMs: 1 },
475
+ } satisfies EmbeddedAgentRunResult);
476
+
477
+ const result = await runCliAgentWithLifecycle({
478
+ runId: "run-no-thinking",
479
+ provider: "claude-cli",
480
+ runParams: {
481
+ sessionId: "session-1",
482
+ sessionFile: "/tmp/session.jsonl",
483
+ workspaceDir: "/tmp/workspace",
484
+ prompt: "hello",
485
+ provider: "claude-cli",
486
+ model: "claude",
487
+ thinkLevel: "high",
488
+ timeoutMs: 1_000,
489
+ runId: "run-no-thinking",
490
+ },
491
+ });
492
+
493
+ expect(result.payloads).toEqual([{ text: "Visible answer" }]);
494
+ });
495
+
496
+ it("keeps the captured lifecycle generation on start and terminal events", async () => {
497
+ const events: Array<{
498
+ stream?: string;
499
+ lifecycleGeneration?: string;
500
+ agentId?: string;
501
+ data?: Record<string, unknown>;
502
+ }> = [];
503
+ const lifecycleGeneration = getAgentEventLifecycleGeneration();
504
+ const stop = onAgentEvent((event) => {
505
+ if (event.runId === "run-before-restart") {
506
+ events.push(event);
507
+ }
508
+ });
509
+ cliDispatchState.runCliAgentMock.mockResolvedValueOnce({
510
+ payloads: [],
511
+ meta: { durationMs: 1 },
512
+ } satisfies EmbeddedAgentRunResult);
513
+
514
+ try {
515
+ await runCliAgentWithLifecycle({
516
+ runId: "run-before-restart",
517
+ lifecycleGeneration,
518
+ startedAt: 1_000,
519
+ provider: "claude-cli",
520
+ runParams: {
521
+ sessionId: "session-1",
522
+ agentId: "support",
523
+ sessionFile: "/tmp/session.jsonl",
524
+ workspaceDir: "/tmp/workspace",
525
+ prompt: "hello",
526
+ provider: "claude-cli",
527
+ model: "claude",
528
+ thinkLevel: "off",
529
+ timeoutMs: 1_000,
530
+ runId: "run-before-restart",
531
+ },
532
+ });
533
+ } finally {
534
+ stop();
535
+ }
536
+
537
+ const lifecycleEvents = events.filter((event) => event.stream === "lifecycle");
538
+ expect(lifecycleEvents).toHaveLength(2);
539
+ expect(
540
+ lifecycleEvents.every((event) => event.lifecycleGeneration === lifecycleGeneration),
541
+ ).toBe(true);
542
+ expect(lifecycleEvents.every((event) => event.agentId === "support")).toBe(true);
543
+ expect(lifecycleEvents.every((event) => event.data?.startedAt === 1_000)).toBe(true);
544
+ });
545
+
546
+ it("preserves restart ownership when the CLI resolves after cancellation", async () => {
547
+ const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
548
+ const stop = onAgentEvent((event) => {
549
+ if (event.runId === "run-restart") {
550
+ events.push(event);
551
+ }
552
+ });
553
+ const controller = new AbortController();
554
+ cliDispatchState.runCliAgentMock.mockImplementationOnce(async () => {
555
+ controller.abort(createAgentRunRestartAbortError());
556
+ return {
557
+ payloads: [{ text: "stale result" }],
558
+ meta: { durationMs: 1 },
559
+ } satisfies EmbeddedAgentRunResult;
560
+ });
561
+
562
+ await expect(
563
+ runCliAgentWithLifecycle({
564
+ runId: "run-restart",
565
+ provider: "claude-cli",
566
+ runParams: {
567
+ sessionId: "session-1",
568
+ sessionFile: "/tmp/session.jsonl",
569
+ workspaceDir: "/tmp/workspace",
570
+ prompt: "hello",
571
+ provider: "claude-cli",
572
+ model: "claude",
573
+ thinkLevel: "off",
574
+ timeoutMs: 1_000,
575
+ runId: "run-restart",
576
+ abortSignal: controller.signal,
577
+ },
578
+ }),
579
+ ).rejects.toThrow("agent run aborted for restart");
580
+ stop();
581
+
582
+ const terminal = events.find(
583
+ (event) => event.stream === "lifecycle" && event.data?.phase === "error",
584
+ );
585
+ expect(terminal?.data).toMatchObject({
586
+ aborted: true,
587
+ stopReason: "restart",
588
+ });
589
+ expect(events.some((event) => event.stream === "assistant")).toBe(false);
590
+ });
591
+
592
+ it("attributes a structured CLI watchdog timeout on the terminal event", async () => {
593
+ const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
594
+ const stop = onAgentEvent((event) => {
595
+ if (event.runId === "run-timeout") {
596
+ events.push(event);
597
+ }
598
+ });
599
+ cliDispatchState.runCliAgentMock.mockRejectedValueOnce(
600
+ createCliTimeoutError(
601
+ { provider: "claude-cli", model: "claude", sessionId: "session-1" },
602
+ {
603
+ mode: "no-output",
604
+ timeoutSeconds: 1,
605
+ observedActivity: false,
606
+ activeToolCount: 0,
607
+ backgroundTaskCount: 0,
608
+ },
609
+ ),
610
+ );
611
+
612
+ await expect(
613
+ runCliAgentWithLifecycle({
614
+ runId: "run-timeout",
615
+ provider: "claude-cli",
616
+ runParams: {
617
+ sessionId: "session-1",
618
+ sessionFile: "/tmp/session.jsonl",
619
+ workspaceDir: "/tmp/workspace",
620
+ prompt: "hello",
621
+ provider: "claude-cli",
622
+ model: "claude",
623
+ thinkLevel: "off",
624
+ timeoutMs: 1_000,
625
+ runId: "run-timeout",
626
+ },
627
+ }),
628
+ ).rejects.toThrow("CLI produced no output");
629
+ stop();
630
+
631
+ expect(
632
+ events.find((event) => event.stream === "lifecycle" && event.data?.phase === "error")?.data,
633
+ ).toMatchObject({
634
+ stopReason: "timeout",
635
+ timeoutPhase: "provider",
636
+ });
637
+ });
638
+
639
+ it("propagates yielded result metadata on lifecycle end", async () => {
640
+ const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
641
+ const stop = onAgentEvent((event) => {
642
+ if (event.runId === "run-yielded") {
643
+ events.push(event);
644
+ }
645
+ });
646
+ cliDispatchState.runCliAgentMock.mockResolvedValueOnce({
647
+ payloads: [],
648
+ meta: {
649
+ durationMs: 1,
650
+ yielded: true,
651
+ livenessState: "paused",
652
+ stopReason: "end_turn",
653
+ },
654
+ } satisfies EmbeddedAgentRunResult);
655
+
656
+ try {
657
+ await runCliAgentWithLifecycle({
658
+ runId: "run-yielded",
659
+ provider: "claude-cli",
660
+ runParams: {
661
+ sessionId: "session-1",
662
+ sessionFile: "/tmp/session.jsonl",
663
+ workspaceDir: "/tmp/workspace",
664
+ prompt: "hello",
665
+ provider: "claude-cli",
666
+ model: "claude",
667
+ thinkLevel: "off",
668
+ timeoutMs: 1_000,
669
+ runId: "run-yielded",
670
+ },
671
+ });
672
+ } finally {
673
+ stop();
674
+ }
675
+
676
+ const terminal = events.find(
677
+ (event) => event.stream === "lifecycle" && event.data?.phase === "end",
678
+ );
679
+ expect(terminal?.data).toMatchObject({
680
+ yielded: true,
681
+ livenessState: "paused",
682
+ stopReason: "end_turn",
683
+ });
684
+ });
685
+ });
686
+
687
+ describe("keepCliSessionBindingOnlyWhenReused", () => {
688
+ it("keeps the first room-event CLI binding when no binding exists yet", () => {
689
+ const result = {
690
+ payloads: [],
691
+ meta: {
692
+ durationMs: 1,
693
+ agentMeta: {
694
+ sessionId: "new-cli-session",
695
+ provider: "claude-cli",
696
+ model: "claude-opus-4-8",
697
+ cliSessionBinding: {
698
+ sessionId: "new-cli-session",
699
+ authProfileId: "profile",
700
+ },
701
+ },
702
+ },
703
+ } satisfies EmbeddedAgentRunResult;
704
+
705
+ expect(keepCliSessionBindingOnlyWhenReused({ result })).toBe(result);
706
+ });
707
+
708
+ it("drops a replacement room-event CLI binding when an existing binding was reused", () => {
709
+ const onDroppedReplacement = vi.fn();
710
+ const result = keepCliSessionBindingOnlyWhenReused({
711
+ existingSessionId: "existing-cli-session",
712
+ onDroppedReplacement,
713
+ result: {
714
+ payloads: [],
715
+ meta: {
716
+ durationMs: 1,
717
+ agentMeta: {
718
+ sessionId: "replacement-cli-session",
719
+ provider: "claude-cli",
720
+ model: "claude-opus-4-8",
721
+ cliSessionBinding: {
722
+ sessionId: "replacement-cli-session",
723
+ authProfileId: "profile",
724
+ },
725
+ },
726
+ },
727
+ } satisfies EmbeddedAgentRunResult,
728
+ });
729
+
730
+ expect(onDroppedReplacement).toHaveBeenCalledOnce();
731
+ expect(result.meta.agentMeta?.sessionId).toBe("");
732
+ expect(result.meta.agentMeta?.cliSessionBinding).toBeUndefined();
733
+ });
734
+ });
735
+
736
+ describe("clearCliSessionInStore", () => {
737
+ it.each(["current", "closed"])(
738
+ "clears active and stored entries only for a %s owner",
739
+ async (owner) => {
740
+ const activeEntry = {
741
+ sessionId: "openclaw-active",
742
+ updatedAt: 1,
743
+ cliSessionBindings: { "claude-cli": { sessionId: "stale-session" } },
744
+ cliSessionIds: { "claude-cli": "stale-session" },
745
+ claudeCliSessionId: "stale-session",
746
+ };
747
+ const storedEntry = structuredClone(activeEntry);
748
+ const storePath = path.join(tempDirs.make("cli-session-cleanup-"), "sessions.json");
749
+ await replaceSessionEntry({ storePath, sessionKey: "main" }, structuredClone(activeEntry));
750
+
751
+ let open = true;
752
+ const clear = clearCliSessionInStore({
753
+ provider: "claude-cli",
754
+ expectedCliSessionId: "stale-session",
755
+ expectedSessionId: activeEntry.sessionId,
756
+ sessionKey: "main",
757
+ sessionStore: { main: storedEntry },
758
+ storePath,
759
+ activeSessionEntry: activeEntry,
760
+ assertCommitAllowed: () => {
761
+ if (!open) {
762
+ throw new Error("owner closed");
763
+ }
764
+ },
765
+ });
766
+ if (owner === "closed") {
767
+ open = false;
768
+ await expect(clear).rejects.toThrow("owner closed");
769
+ for (const entry of [
770
+ activeEntry,
771
+ storedEntry,
772
+ loadSessionEntry({ storePath, sessionKey: "main" }),
773
+ ]) {
774
+ expect(entry?.cliSessionBindings?.["claude-cli"]?.sessionId).toBe("stale-session");
775
+ }
776
+ return;
777
+ }
778
+ await clear;
779
+
780
+ for (const entry of [activeEntry, storedEntry]) {
781
+ expect(entry.cliSessionBindings?.["claude-cli"]).toBeUndefined();
782
+ expect(entry.cliSessionIds?.["claude-cli"]).toBeUndefined();
783
+ expect(entry.claudeCliSessionId).toBeUndefined();
784
+ expect(entry.updatedAt).toBeGreaterThan(1);
785
+ }
786
+ const persisted = loadSessionEntry({ storePath, sessionKey: "main" });
787
+ expect(persisted?.cliSessionBindings?.["claude-cli"]).toBeUndefined();
788
+ expect(persisted?.cliSessionIds?.["claude-cli"]).toBeUndefined();
789
+ expect(persisted?.claudeCliSessionId).toBeUndefined();
790
+ },
791
+ );
792
+
793
+ it("does not clear a replacement binding adopted by another turn", async () => {
794
+ const entry = {
795
+ sessionId: "openclaw-active",
796
+ updatedAt: 1,
797
+ cliSessionBindings: { "claude-cli": { sessionId: "replacement-session" } },
798
+ cliSessionIds: { "claude-cli": "replacement-session" },
799
+ claudeCliSessionId: "replacement-session",
800
+ };
801
+
802
+ await clearCliSessionInStore({
803
+ provider: "claude-cli",
804
+ expectedCliSessionId: "stale-session",
805
+ activeSessionEntry: entry,
806
+ });
807
+
808
+ expect(entry.cliSessionBindings["claude-cli"].sessionId).toBe("replacement-session");
809
+ expect(entry.cliSessionIds["claude-cli"]).toBe("replacement-session");
810
+ expect(entry.claudeCliSessionId).toBe("replacement-session");
811
+ expect(entry.updatedAt).toBe(1);
812
+ });
813
+ });
814
+
815
+ describe("createCliToolSummaryTracker", () => {
816
+ const startEvent = {
817
+ name: "exec",
818
+ phase: "start" as const,
819
+ args: { command: "date -u" },
820
+ toolCallId: "tool-1",
821
+ };
822
+ const resultEvent = {
823
+ name: "exec",
824
+ phase: "result" as const,
825
+ args: undefined,
826
+ toolCallId: "tool-1",
827
+ isError: false,
828
+ result: { content: [{ type: "text", text: "Wed Jun 10 2026" }] },
829
+ };
830
+
831
+ it.each(["exec", "server.exec"])(
832
+ "delivers a safe %s summary using metadata captured at start",
833
+ async (name) => {
834
+ const deliver = vi.fn();
835
+ const tracker = createCliToolSummaryTracker({
836
+ commandDetailsVisible: false,
837
+ shouldEmitToolResult: () => true,
838
+ shouldEmitToolOutput: () => false,
839
+ deliver,
840
+ });
841
+ await tracker.noteToolEvent({ ...startEvent, name });
842
+ const commandBearing = await tracker.noteToolEvent({ ...resultEvent, name });
843
+ expect(commandBearing).toBe(true);
844
+ expect(deliver).toHaveBeenCalledTimes(1);
845
+ const payload = deliver.mock.calls[0]?.[0] as { text: string; isError?: boolean };
846
+ expect(payload.text).toContain(name === "exec" ? "Exec" : "Server.exec");
847
+ expect(payload.text).not.toContain("date -u");
848
+ expect(payload.text).not.toContain("Wed Jun 10 2026");
849
+ expect(payload.isError).toBeUndefined();
850
+ },
851
+ );
852
+
853
+ it("appends the tool output block when full verbose output is enabled", async () => {
854
+ const deliver = vi.fn();
855
+ const tracker = createCliToolSummaryTracker({
856
+ commandDetailsVisible: true,
857
+ shouldEmitToolResult: () => true,
858
+ shouldEmitToolOutput: () => true,
859
+ deliver,
860
+ });
861
+ await tracker.noteToolEvent(startEvent);
862
+ await tracker.noteToolEvent(resultEvent);
863
+ const payload = deliver.mock.calls[0]?.[0] as { text: string };
864
+ expect(payload.text).toContain("```txt");
865
+ expect(payload.text).toContain("Wed Jun 10 2026");
866
+ });
867
+
868
+ it("renders top-level structured CLI results in full verbose output", async () => {
869
+ const deliver = vi.fn();
870
+ const tracker = createCliToolSummaryTracker({
871
+ commandDetailsVisible: true,
872
+ shouldEmitToolResult: () => true,
873
+ shouldEmitToolOutput: () => true,
874
+ deliver,
875
+ });
876
+ await tracker.noteToolEvent(startEvent);
877
+ await tracker.noteToolEvent({
878
+ ...resultEvent,
879
+ result: [{ type: "web_search_result", title: "OpenClaw", url: "https://example.com" }],
880
+ });
881
+
882
+ const payload = deliver.mock.calls[0]?.[0] as { text: string };
883
+ expect(payload.text).toContain('"type":"web_search_result"');
884
+ expect(payload.text).toContain('"title":"OpenClaw"');
885
+ });
886
+
887
+ it("emits nothing while tool summaries are disabled", async () => {
888
+ const deliver = vi.fn();
889
+ const tracker = createCliToolSummaryTracker({
890
+ commandDetailsVisible: false,
891
+ shouldEmitToolResult: () => false,
892
+ shouldEmitToolOutput: () => false,
893
+ deliver,
894
+ });
895
+ await tracker.noteToolEvent(startEvent);
896
+ await tracker.noteToolEvent(resultEvent);
897
+ expect(deliver).not.toHaveBeenCalled();
898
+ });
899
+
900
+ it("leaves plan tools to the authoritative plan event instead of summarizing arguments", async () => {
901
+ const deliver = vi.fn();
902
+ const tracker = createCliToolSummaryTracker({
903
+ commandDetailsVisible: true,
904
+ shouldEmitToolResult: () => true,
905
+ shouldEmitToolOutput: () => true,
906
+ deliver,
907
+ });
908
+ await tracker.noteToolEvent({
909
+ name: "progress_card",
910
+ phase: "start",
911
+ args: {
912
+ markdown: '<progress aria-label="CI · 2/3" value="2" max="3"></progress>',
913
+ },
914
+ toolCallId: "plan-1",
915
+ });
916
+ await tracker.noteToolEvent({
917
+ name: "progress_card",
918
+ phase: "result",
919
+ args: undefined,
920
+ toolCallId: "plan-1",
921
+ isError: false,
922
+ result: { content: [{ type: "text", text: "Progress card updated" }] },
923
+ });
924
+
925
+ expect(deliver).not.toHaveBeenCalled();
926
+ });
927
+
928
+ it.each([false, true])(
929
+ "keeps card errors visible without arguments (full output: %s)",
930
+ async (fullOutput) => {
931
+ const deliver = vi.fn();
932
+ const tracker = createCliToolSummaryTracker({
933
+ commandDetailsVisible: false,
934
+ shouldEmitToolResult: () => true,
935
+ shouldEmitToolOutput: () => fullOutput,
936
+ deliver,
937
+ });
938
+ await tracker.noteToolEvent({
939
+ name: "progress_card",
940
+ phase: "start",
941
+ args: { markdown: '<progress aria-label="private" value="1" max="2"></progress>' },
942
+ toolCallId: "plan-error",
943
+ });
944
+ await tracker.noteToolEvent({
945
+ name: undefined,
946
+ phase: "result",
947
+ args: undefined,
948
+ toolCallId: "plan-error",
949
+ isError: true,
950
+ result: { content: [{ type: "text", text: "write failed" }] },
951
+ });
952
+
953
+ expect(deliver).toHaveBeenCalledWith({
954
+ text: fullOutput ? "🗺️ Progress Card\n```txt\nwrite failed\n```" : "🗺️ Progress Card",
955
+ isError: true,
956
+ });
957
+ },
958
+ );
959
+
960
+ it("propagates tool errors on the summary payload", async () => {
961
+ const deliver = vi.fn();
962
+ const tracker = createCliToolSummaryTracker({
963
+ commandDetailsVisible: false,
964
+ shouldEmitToolResult: () => true,
965
+ shouldEmitToolOutput: () => false,
966
+ deliver,
967
+ });
968
+ await tracker.noteToolEvent(startEvent);
969
+ await tracker.noteToolEvent({ ...resultEvent, isError: true });
970
+ const payload = deliver.mock.calls[0]?.[0] as { isError?: boolean };
971
+ expect(payload.isError).toBe(true);
972
+ });
973
+
974
+ it("summarizes results without a tracked start event", async () => {
975
+ const deliver = vi.fn();
976
+ const tracker = createCliToolSummaryTracker({
977
+ commandDetailsVisible: false,
978
+ shouldEmitToolResult: () => true,
979
+ shouldEmitToolOutput: () => false,
980
+ deliver,
981
+ });
982
+ await tracker.noteToolEvent({ ...resultEvent, toolCallId: "unseen" });
983
+ expect(deliver).toHaveBeenCalledTimes(1);
984
+ });
985
+ });
986
+
987
+ describe("runCliAgentWithLifecycle fast auto progress", () => {
988
+ it("emits auto-off after the first CLI tool boundary past the threshold", async () => {
989
+ vi.useFakeTimers();
990
+ vi.setSystemTime(1_000);
991
+ const events: Array<{ stream: string; data: Record<string, unknown> }> = [];
992
+ const stop = onAgentEvent((evt) => {
993
+ if (evt.runId === "run-fast-cli") {
994
+ events.push({ stream: evt.stream, data: evt.data });
995
+ }
996
+ });
997
+ const progressPayloads: string[] = [];
998
+ cliDispatchState.runCliAgentMock.mockImplementation(async () => {
999
+ emitAgentEvent({
1000
+ runId: "run-fast-cli",
1001
+ stream: "tool",
1002
+ data: { phase: "start", name: "bash", toolCallId: "call-1" },
1003
+ });
1004
+ vi.setSystemTime(7_100);
1005
+ emitAgentEvent({
1006
+ runId: "run-fast-cli",
1007
+ stream: "tool",
1008
+ data: { phase: "result", name: "bash", toolCallId: "call-1" },
1009
+ });
1010
+ return {
1011
+ payloads: [{ text: "done" }],
1012
+ meta: {
1013
+ durationMs: 7_100,
1014
+ agentMeta: { sessionId: "session-1", provider: "codex-cli", model: "gpt-5.5" },
1015
+ },
1016
+ } satisfies EmbeddedAgentRunResult;
1017
+ });
1018
+
1019
+ await runCliAgentWithLifecycle({
1020
+ runId: "run-fast-cli",
1021
+ provider: "codex-cli",
1022
+ runParams: {
1023
+ sessionId: "session-1",
1024
+ sessionKey: "agent:main:cli-fast",
1025
+ sessionFile: "/tmp/session.jsonl",
1026
+ workspaceDir: "/tmp/workspace",
1027
+ prompt: "run one tool",
1028
+ provider: "codex-cli",
1029
+ model: "gpt-5.5",
1030
+ timeoutMs: 60_000,
1031
+ runId: "run-fast-cli",
1032
+ fastMode: "auto",
1033
+ fastModeStartedAtMs: 1_000,
1034
+ fastModeAutoOnSeconds: 5,
1035
+ },
1036
+ onFastModeAutoProgress: async (payload) => {
1037
+ if (payload.text) {
1038
+ progressPayloads.push(payload.text);
1039
+ }
1040
+ },
1041
+ });
1042
+ stop();
1043
+
1044
+ const summaries = events
1045
+ .filter((event) => event.stream === "item")
1046
+ .map((event) => event.data.summary);
1047
+ expect(summaries).toContain("💨Fast: auto-off(6s>=5s)");
1048
+ expect(summaries).toContain("💨Fast: auto-on");
1049
+ expect(progressPayloads).toEqual(["💨Fast: auto-off(6s>=5s)", "💨Fast: auto-on"]);
1050
+ });
1051
+ });
src/auto-reply/reply/agent-runner-cli-dispatch.ts ADDED
@@ -0,0 +1,705 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Builds CLI runtime dispatch inputs for agent runner executions.
2
+ import { isRecord } from "@openclaw/normalization-core/record-coerce";
3
+ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
4
+ import { runCliAgent } from "../../agents/cli-runner.js";
5
+ import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
6
+ import type { MediaImageLayout } from "../../agents/embedded-agent-runner/run/prompt-image-metadata.js";
7
+ import { extractToolResultText } from "../../agents/embedded-agent-tool-results.js";
8
+ import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent.js";
9
+ import {
10
+ DEFAULT_FAST_MODE_AUTO_ON_SECONDS,
11
+ formatFastModeAutoProgressText,
12
+ resolveFastModeForElapsed,
13
+ type FastModeAutoProgressState,
14
+ } from "../../agents/fast-mode.js";
15
+ import {
16
+ isAgentRunRestartAbortReason,
17
+ resolveAgentRunAbortLifecycleFields,
18
+ resolveAgentRunErrorLifecycleFields,
19
+ } from "../../agents/run-termination.js";
20
+ import { inferToolMetaFromArgsCore, isCommandBearingToolCall } from "../../agents/tool-display.js";
21
+ import { normalizeAgentPlanSteps } from "../../channels/streaming.js";
22
+ import type { AgentEventPayload } from "../../infra/agent-events.js";
23
+ import { emitAgentEvent, withAgentRunLifecycleGeneration } from "../../infra/agent-events.js";
24
+ import { isAgentPlanProgressToolName } from "../../session-cards/progress-card-channel-summary.js";
25
+ import { FAST_MODE_AUTO_PROGRESS_KIND, type ReplyPayload } from "../reply-payload.js";
26
+ import { formatToolAggregate } from "../tool-meta.js";
27
+ import type { GetReplyOptions } from "../types.js";
28
+ import {
29
+ type AgentEventDeliveryStartOrder,
30
+ createAgentEventBridge,
31
+ createAgentEventDeliveryStartOrder,
32
+ } from "./agent-event-bridge.js";
33
+ import { resolveAgentLifecycleTerminalMetadata } from "./agent-lifecycle-terminal.js";
34
+
35
+ type RunCliAgentInternalParams = RunCliAgentParams & {
36
+ mediaImageLayout?: MediaImageLayout;
37
+ };
38
+
39
+ type AgentEventBridge = {
40
+ unsubscribe: () => void;
41
+ drain: () => Promise<void>;
42
+ };
43
+
44
+ async function stopAgentEventBridges(bridges: readonly AgentEventBridge[]): Promise<void> {
45
+ for (const bridge of bridges) {
46
+ bridge.unsubscribe();
47
+ }
48
+ for (const bridge of bridges) {
49
+ await bridge.drain();
50
+ }
51
+ }
52
+
53
+ function createAssistantTextBridge(params: {
54
+ runId: string;
55
+ suppressed?: boolean;
56
+ deliver?: (text: string) => Promise<boolean | void>;
57
+ startOrder?: AgentEventDeliveryStartOrder;
58
+ }) {
59
+ let lastText: string | undefined;
60
+ return createAgentEventBridge({
61
+ runId: params.runId,
62
+ suppressed: params.suppressed,
63
+ deliver: params.deliver,
64
+ startOrder: params.startOrder,
65
+ read: (evt) => {
66
+ if (evt.stream !== "assistant") {
67
+ return undefined;
68
+ }
69
+ const text = typeof evt.data.text === "string" ? evt.data.text : undefined;
70
+ if (text === undefined || text === lastText) {
71
+ return undefined;
72
+ }
73
+ lastText = text;
74
+ return text;
75
+ },
76
+ });
77
+ }
78
+
79
+ type ReasoningTextPayload = {
80
+ text: string;
81
+ isReasoningSnapshot?: boolean;
82
+ };
83
+
84
+ type ReasoningProgressPayload = {
85
+ progressTokens: number;
86
+ };
87
+
88
+ export function createCliReasoningStreamBridge(
89
+ onReasoningStream: GetReplyOptions["onReasoningStream"] | undefined,
90
+ ): ((payload: ReasoningTextPayload) => Promise<void>) | undefined {
91
+ if (!onReasoningStream) {
92
+ return undefined;
93
+ }
94
+ return async ({ text, isReasoningSnapshot }) => {
95
+ await onReasoningStream({
96
+ text,
97
+ ...(isReasoningSnapshot ? { isReasoningSnapshot } : {}),
98
+ requiresReasoningProgressOptIn: true,
99
+ });
100
+ };
101
+ }
102
+
103
+ function createReasoningTextBridge(params: {
104
+ runId: string;
105
+ suppressed?: boolean;
106
+ deliver?: (payload: ReasoningTextPayload) => Promise<void>;
107
+ startOrder?: AgentEventDeliveryStartOrder;
108
+ }) {
109
+ let lastText: string | undefined;
110
+ return createAgentEventBridge({
111
+ runId: params.runId,
112
+ suppressed: params.suppressed,
113
+ deliver: params.deliver,
114
+ startOrder: params.startOrder,
115
+ read: (evt) => {
116
+ if (evt.stream !== "thinking") {
117
+ return undefined;
118
+ }
119
+ const text = typeof evt.data.text === "string" ? evt.data.text : undefined;
120
+ if (text === undefined || text === lastText) {
121
+ return undefined;
122
+ }
123
+ lastText = text;
124
+ return {
125
+ text,
126
+ ...(evt.data.isReasoningSnapshot === true ? { isReasoningSnapshot: true } : {}),
127
+ };
128
+ },
129
+ });
130
+ }
131
+
132
+ function createReasoningProgressBridge(params: {
133
+ runId: string;
134
+ suppressed?: boolean;
135
+ deliver?: (payload: ReasoningProgressPayload) => Promise<void>;
136
+ startOrder?: AgentEventDeliveryStartOrder;
137
+ }) {
138
+ let lastProgressTokens: number | undefined;
139
+ return createAgentEventBridge({
140
+ runId: params.runId,
141
+ suppressed: params.suppressed,
142
+ deliver: params.deliver,
143
+ startOrder: params.startOrder,
144
+ read: (evt) => {
145
+ if (evt.stream !== "thinking") {
146
+ return undefined;
147
+ }
148
+ const progressTokens = evt.data.progressTokens;
149
+ if (
150
+ typeof progressTokens !== "number" ||
151
+ !Number.isFinite(progressTokens) ||
152
+ progressTokens <= 0 ||
153
+ progressTokens === lastProgressTokens
154
+ ) {
155
+ return undefined;
156
+ }
157
+ lastProgressTokens = progressTokens;
158
+ return { progressTokens };
159
+ },
160
+ });
161
+ }
162
+
163
+ type CommentaryTextPayload = {
164
+ text: string;
165
+ itemId?: string;
166
+ };
167
+
168
+ function readCommentaryTextPayload(evt: AgentEventPayload): CommentaryTextPayload | undefined {
169
+ if (evt.stream !== "item" || evt.data.kind !== "preamble") {
170
+ return undefined;
171
+ }
172
+ const text = typeof evt.data.progressText === "string" ? evt.data.progressText.trim() : "";
173
+ if (!text) {
174
+ return undefined;
175
+ }
176
+ return {
177
+ text,
178
+ ...(typeof evt.data.itemId === "string" ? { itemId: evt.data.itemId } : {}),
179
+ };
180
+ }
181
+
182
+ type CliToolEventPayload = {
183
+ name: string | undefined;
184
+ phase: "start" | "update" | "result";
185
+ args: Record<string, unknown> | undefined;
186
+ toolCallId?: string;
187
+ isError?: boolean;
188
+ result?: unknown;
189
+ };
190
+
191
+ export function keepCliSessionBindingOnlyWhenReused(params: {
192
+ result: EmbeddedAgentRunResult;
193
+ existingSessionId?: string;
194
+ onDroppedReplacement?: () => void;
195
+ }): EmbeddedAgentRunResult {
196
+ const existingSessionId = normalizeOptionalString(params.existingSessionId);
197
+ const agentMeta = params.result.meta.agentMeta;
198
+ const returnedSessionId = normalizeOptionalString(agentMeta?.cliSessionBinding?.sessionId);
199
+ const shouldClearStoredSession = agentMeta?.clearCliSessionBinding === true;
200
+ if (
201
+ agentMeta === undefined ||
202
+ (!shouldClearStoredSession && existingSessionId === undefined) ||
203
+ returnedSessionId === existingSessionId
204
+ ) {
205
+ return params.result;
206
+ }
207
+ if (returnedSessionId || shouldClearStoredSession) {
208
+ params.onDroppedReplacement?.();
209
+ }
210
+ return {
211
+ ...params.result,
212
+ meta: {
213
+ ...params.result.meta,
214
+ agentMeta: {
215
+ ...agentMeta,
216
+ sessionId: "",
217
+ cliSessionBinding: undefined,
218
+ clearCliSessionBinding: undefined,
219
+ },
220
+ },
221
+ };
222
+ }
223
+
224
+ function createToolEventBridge(params: {
225
+ runId: string;
226
+ suppressed?: boolean;
227
+ deliver?: (payload: CliToolEventPayload) => Promise<void>;
228
+ startOrder?: AgentEventDeliveryStartOrder;
229
+ }) {
230
+ return createAgentEventBridge({
231
+ runId: params.runId,
232
+ suppressed: params.suppressed,
233
+ deliver: params.deliver,
234
+ startOrder: params.startOrder,
235
+ read: (evt) => {
236
+ if (evt.stream !== "tool") {
237
+ return undefined;
238
+ }
239
+ const phaseValue = evt.data.phase;
240
+ if (phaseValue !== "start" && phaseValue !== "update" && phaseValue !== "result") {
241
+ return undefined;
242
+ }
243
+ const phase: CliToolEventPayload["phase"] =
244
+ phaseValue === "start" ? "start" : phaseValue === "update" ? "update" : "result";
245
+ return {
246
+ name: typeof evt.data.name === "string" ? evt.data.name : undefined,
247
+ phase,
248
+ args: isRecord(evt.data.args) ? evt.data.args : undefined,
249
+ toolCallId: typeof evt.data.toolCallId === "string" ? evt.data.toolCallId : undefined,
250
+ ...(phase === "result"
251
+ ? {
252
+ isError: evt.data.isError === true,
253
+ result: evt.data.result,
254
+ }
255
+ : {}),
256
+ };
257
+ },
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Tracks CLI tool start/result events and renders the same durable tool
263
+ * summaries the embedded runner emits: a formatToolAggregate line per result
264
+ * (args-derived meta captured at start), plus the output block under full
265
+ * verbose. Keeps CLI runs at tool-summary parity with embedded runs.
266
+ */
267
+ export function createCliToolSummaryTracker(params: {
268
+ detailMode?: "explain" | "raw";
269
+ commandDetailsVisible: boolean;
270
+ shouldEmitToolResult: () => boolean;
271
+ shouldEmitToolOutput: () => boolean;
272
+ deliver: (payload: { text: string; isError?: boolean }) => Promise<void> | void;
273
+ }) {
274
+ const toolByCallId = new Map<string, { name: string; meta?: string; commandBearing: boolean }>();
275
+ return {
276
+ noteToolEvent: async (payload: CliToolEventPayload): Promise<boolean> => {
277
+ if (payload.phase === "start") {
278
+ if (payload.toolCallId && payload.name) {
279
+ toolByCallId.set(payload.toolCallId, {
280
+ name: payload.name,
281
+ meta: inferToolMetaFromArgsCore(payload.name, payload.args, {
282
+ detailMode: params.detailMode ?? "explain",
283
+ }),
284
+ commandBearing: isCommandBearingToolCall(payload.name, payload.args),
285
+ });
286
+ }
287
+ return false;
288
+ }
289
+ if (payload.phase !== "result") {
290
+ return false;
291
+ }
292
+ const storedTool = payload.toolCallId ? toolByCallId.get(payload.toolCallId) : undefined;
293
+ const toolName = payload.name ?? storedTool?.name;
294
+ const meta =
295
+ params.commandDetailsVisible || !storedTool?.commandBearing ? storedTool?.meta : undefined;
296
+ if (payload.toolCallId) {
297
+ toolByCallId.delete(payload.toolCallId);
298
+ }
299
+ if (payload.isError !== true && isAgentPlanProgressToolName(toolName)) {
300
+ return false;
301
+ }
302
+ if (!params.shouldEmitToolResult()) {
303
+ return storedTool?.commandBearing === true;
304
+ }
305
+ const aggregate = formatToolAggregate(toolName, meta ? [meta] : undefined, {
306
+ markdown: true,
307
+ });
308
+ let text = aggregate;
309
+ if (params.shouldEmitToolOutput()) {
310
+ const output = extractToolResultText(payload.result)?.trim();
311
+ if (output) {
312
+ text = `${aggregate}\n\`\`\`txt\n${output}\n\`\`\``;
313
+ }
314
+ }
315
+ if (!text.trim()) {
316
+ return storedTool?.commandBearing === true;
317
+ }
318
+ await params.deliver({ text, ...(payload.isError === true ? { isError: true } : {}) });
319
+ return storedTool?.commandBearing === true;
320
+ },
321
+ };
322
+ }
323
+
324
+ function createCommentaryEventBridge(params: {
325
+ runId: string;
326
+ suppressed?: boolean;
327
+ deliver?: (payload: CommentaryTextPayload) => Promise<void>;
328
+ startOrder?: AgentEventDeliveryStartOrder;
329
+ }) {
330
+ return createAgentEventBridge({
331
+ runId: params.runId,
332
+ suppressed: params.suppressed,
333
+ deliver: params.deliver,
334
+ startOrder: params.startOrder,
335
+ read: readCommentaryTextPayload,
336
+ });
337
+ }
338
+
339
+ function createPlanUpdateBridge(params: {
340
+ runId: string;
341
+ suppressed?: boolean;
342
+ deliver?: GetReplyOptions["onPlanUpdate"];
343
+ startOrder?: AgentEventDeliveryStartOrder;
344
+ }) {
345
+ const deliver = params.deliver;
346
+ return createAgentEventBridge({
347
+ runId: params.runId,
348
+ suppressed: params.suppressed,
349
+ // GetReplyOptions callbacks may return void; the bridge awaits promises.
350
+ deliver: deliver
351
+ ? async (payload: Parameters<NonNullable<GetReplyOptions["onPlanUpdate"]>>[0]) => {
352
+ await deliver(payload);
353
+ }
354
+ : undefined,
355
+ startOrder: params.startOrder,
356
+ read: (evt) => {
357
+ if (evt.stream !== "plan") {
358
+ return undefined;
359
+ }
360
+ return {
361
+ phase: normalizeOptionalString(evt.data.phase),
362
+ title: normalizeOptionalString(evt.data.title),
363
+ explanation: normalizeOptionalString(evt.data.explanation),
364
+ ...(evt.data.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
365
+ steps: normalizeAgentPlanSteps(evt.data.steps),
366
+ source: normalizeOptionalString(evt.data.source),
367
+ };
368
+ },
369
+ });
370
+ }
371
+
372
+ function createToolBoundaryBridge(params: {
373
+ runId: string;
374
+ suppressed?: boolean;
375
+ deliver?: () => Promise<void>;
376
+ }) {
377
+ return createAgentEventBridge({
378
+ runId: params.runId,
379
+ suppressed: params.suppressed,
380
+ deliver: params.deliver,
381
+ read: (evt) => {
382
+ if (evt.stream !== "tool") {
383
+ return undefined;
384
+ }
385
+ const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
386
+ return ["completed", "end", "error", "result"].includes(phase) ? true : undefined;
387
+ },
388
+ });
389
+ }
390
+
391
+ type RunCliAgentWithLifecycleParams = {
392
+ runId: string;
393
+ lifecycleGeneration?: string;
394
+ provider: string;
395
+ runParams: RunCliAgentInternalParams;
396
+ startedAt?: number;
397
+ emitLifecycleTerminal?: boolean;
398
+ onAgentRunStart?: () => void;
399
+ suppressAssistantBridge?: boolean;
400
+ /**
401
+ * Stamped before every delivered CLI progress event (assistant, reasoning,
402
+ * tool, commentary, fast-mode). Callers wire this to the reply operation's
403
+ * activity evidence; per-callback stamps at call sites drift and a missed
404
+ * stamp lets stale-takeover reclaim a healthy run.
405
+ */
406
+ onActivity?: () => void;
407
+ preserveProgressCallbackStartOrder?: boolean;
408
+ onAssistantText?: (text: string) => Promise<boolean | void>;
409
+ onReasoningText?: (payload: ReasoningTextPayload) => Promise<void>;
410
+ onReasoningProgress?: (payload: ReasoningProgressPayload) => Promise<void>;
411
+ onCompactionStart?: GetReplyOptions["onCompactionStart"];
412
+ onCompactionEnd?: GetReplyOptions["onCompactionEnd"];
413
+ onToolEvent?: (payload: CliToolEventPayload) => Promise<void>;
414
+ onCommentaryText?: (payload: CommentaryTextPayload) => Promise<void>;
415
+ onPlanUpdate?: GetReplyOptions["onPlanUpdate"];
416
+ onFastModeAutoProgress?: (payload: ReplyPayload) => Promise<void>;
417
+ onErrorBeforeLifecycle?: (err: unknown) => Promise<void>;
418
+ transformResult?: (result: EmbeddedAgentRunResult) => EmbeddedAgentRunResult;
419
+ };
420
+
421
+ export function runCliAgentWithLifecycle(
422
+ params: RunCliAgentWithLifecycleParams,
423
+ ): Promise<EmbeddedAgentRunResult> {
424
+ if (!params.lifecycleGeneration) {
425
+ return runCliAgentWithLifecycleInternal(params);
426
+ }
427
+ return withAgentRunLifecycleGeneration(params.lifecycleGeneration, () =>
428
+ runCliAgentWithLifecycleInternal(params),
429
+ );
430
+ }
431
+
432
+ async function runCliAgentWithLifecycleInternal(
433
+ params: RunCliAgentWithLifecycleParams,
434
+ ): Promise<EmbeddedAgentRunResult> {
435
+ const startedAt = params.startedAt ?? Date.now();
436
+ const fastModeStartedAtMs = params.runParams.fastModeStartedAtMs ?? startedAt;
437
+ const fastModeAutoOnSeconds =
438
+ params.runParams.fastModeAutoOnSeconds ?? DEFAULT_FAST_MODE_AUTO_ON_SECONDS;
439
+ const fastModeAutoProgressState: FastModeAutoProgressState = params.runParams
440
+ .fastModeAutoProgressState ?? {
441
+ offAnnounced: false,
442
+ resetAnnounced: false,
443
+ };
444
+ const emitFastModeAutoProgress = async (payload: {
445
+ enabled: boolean;
446
+ elapsedSeconds: number;
447
+ fastAutoOnSeconds?: number;
448
+ }) => {
449
+ const summary = formatFastModeAutoProgressText(payload);
450
+ emitAgentEvent({
451
+ runId: params.runId,
452
+ stream: "item",
453
+ data: {
454
+ kind: "status",
455
+ title: "Fast",
456
+ phase: "update",
457
+ summary,
458
+ },
459
+ ...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
460
+ });
461
+ try {
462
+ await params.onFastModeAutoProgress?.({
463
+ text: summary,
464
+ channelData: { openclawProgressKind: FAST_MODE_AUTO_PROGRESS_KIND },
465
+ });
466
+ } catch {
467
+ // Progress hints are best-effort; a channel failure must not fail the agent turn.
468
+ }
469
+ };
470
+ const maybeAnnounceFastModeAutoOff = async () => {
471
+ if (params.runParams.fastMode !== "auto" || fastModeAutoProgressState.offAnnounced) {
472
+ return;
473
+ }
474
+ const next = resolveFastModeForElapsed({
475
+ mode: "auto",
476
+ startedAtMs: fastModeStartedAtMs,
477
+ fastAutoOnSeconds: fastModeAutoOnSeconds,
478
+ });
479
+ if (next.enabled) {
480
+ return;
481
+ }
482
+ fastModeAutoProgressState.offAnnounced = true;
483
+ await emitFastModeAutoProgress(next);
484
+ };
485
+ const maybeEmitFastModeAutoReset = async () => {
486
+ if (
487
+ params.runParams.fastMode !== "auto" ||
488
+ !fastModeAutoProgressState.offAnnounced ||
489
+ fastModeAutoProgressState.resetAnnounced
490
+ ) {
491
+ return;
492
+ }
493
+ fastModeAutoProgressState.resetAnnounced = true;
494
+ await emitFastModeAutoProgress({
495
+ enabled: true,
496
+ elapsedSeconds: 0,
497
+ fastAutoOnSeconds: fastModeAutoOnSeconds,
498
+ });
499
+ };
500
+ const emitLifecycleTerminal = params.emitLifecycleTerminal ?? true;
501
+ params.onAgentRunStart?.();
502
+ emitAgentEvent({
503
+ runId: params.runId,
504
+ ...(params.runParams.agentId ? { agentId: params.runParams.agentId } : {}),
505
+ ...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
506
+ ...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
507
+ ...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
508
+ stream: "lifecycle",
509
+ data: {
510
+ phase: "start",
511
+ startedAt,
512
+ },
513
+ });
514
+ // One delivery-independent activity seam for every CLI agent event.
515
+ // Suppressed (silentExpected) runs still emit real events and must keep
516
+ // stamping, or a healthy silent stream looks stale to the takeover window.
517
+ const activityBridge = params.onActivity
518
+ ? createAgentEventBridge<Record<string, never>>({
519
+ runId: params.runId,
520
+ read: () => ({}),
521
+ deliver: async () => {
522
+ params.onActivity?.();
523
+ },
524
+ })
525
+ : undefined;
526
+ const progressStartOrder = params.preserveProgressCallbackStartOrder
527
+ ? createAgentEventDeliveryStartOrder()
528
+ : undefined;
529
+ const assistantBridge = createAssistantTextBridge({
530
+ runId: params.runId,
531
+ suppressed: params.suppressAssistantBridge,
532
+ deliver: params.onAssistantText,
533
+ startOrder: progressStartOrder,
534
+ });
535
+ let finalReasoningText: string | undefined;
536
+ const reasoningBridge = createReasoningTextBridge({
537
+ runId: params.runId,
538
+ suppressed: params.suppressAssistantBridge,
539
+ startOrder: progressStartOrder,
540
+ deliver: async (payload: ReasoningTextPayload) => {
541
+ finalReasoningText = normalizeOptionalString(payload.text);
542
+ await params.onReasoningText?.(payload);
543
+ },
544
+ });
545
+ const reasoningProgressBridge = createReasoningProgressBridge({
546
+ runId: params.runId,
547
+ suppressed: params.suppressAssistantBridge,
548
+ deliver: params.onReasoningProgress,
549
+ startOrder: progressStartOrder,
550
+ });
551
+ const compactionBridge = createAgentEventBridge<
552
+ { phase: "start" } | { completed: boolean; phase: "end" }
553
+ >({
554
+ runId: params.runId,
555
+ suppressed: params.suppressAssistantBridge,
556
+ startOrder: progressStartOrder,
557
+ deliver: async (event) => {
558
+ if (event.phase === "start") {
559
+ await params.onCompactionStart?.();
560
+ } else {
561
+ await params.onCompactionEnd?.({ completed: event.completed });
562
+ }
563
+ },
564
+ read: (evt) => {
565
+ if (evt.stream !== "compaction") {
566
+ return undefined;
567
+ }
568
+ if (evt.data.phase === "start") {
569
+ return { phase: "start" };
570
+ }
571
+ return evt.data.phase === "end"
572
+ ? { phase: "end", completed: evt.data.completed === true }
573
+ : undefined;
574
+ },
575
+ });
576
+ const toolBridge = createToolEventBridge({
577
+ runId: params.runId,
578
+ suppressed: params.suppressAssistantBridge,
579
+ deliver: params.onToolEvent,
580
+ startOrder: progressStartOrder,
581
+ });
582
+ const commentaryBridge = createCommentaryEventBridge({
583
+ runId: params.runId,
584
+ suppressed: params.suppressAssistantBridge,
585
+ deliver: params.onCommentaryText,
586
+ startOrder: progressStartOrder,
587
+ });
588
+ const planBridge = createPlanUpdateBridge({
589
+ runId: params.runId,
590
+ suppressed: params.suppressAssistantBridge,
591
+ deliver: params.onPlanUpdate,
592
+ startOrder: progressStartOrder,
593
+ });
594
+ const toolBoundaryBridge = createToolBoundaryBridge({
595
+ runId: params.runId,
596
+ suppressed: params.suppressAssistantBridge,
597
+ deliver: maybeAnnounceFastModeAutoOff,
598
+ });
599
+ const bridges = [
600
+ activityBridge,
601
+ assistantBridge,
602
+ reasoningBridge,
603
+ reasoningProgressBridge,
604
+ compactionBridge,
605
+ toolBridge,
606
+ commentaryBridge,
607
+ planBridge,
608
+ toolBoundaryBridge,
609
+ ].filter((bridge): bridge is AgentEventBridge => bridge !== undefined);
610
+ let lifecycleTerminalEmitted = false;
611
+ try {
612
+ const rawResult = await runCliAgent({
613
+ ...params.runParams,
614
+ emitCommentaryText: params.runParams.emitCommentaryText ?? Boolean(params.onCommentaryText),
615
+ });
616
+ const restartAbortReason = params.runParams.abortSignal?.reason;
617
+ if (isAgentRunRestartAbortReason(restartAbortReason)) {
618
+ throw restartAbortReason;
619
+ }
620
+ const result = params.transformResult?.(rawResult) ?? rawResult;
621
+ await stopAgentEventBridges(bridges);
622
+
623
+ const cliText = normalizeOptionalString(result.payloads?.[0]?.text);
624
+ const durableReasoningText = normalizeOptionalString(finalReasoningText);
625
+ const resultWithReasoning = durableReasoningText
626
+ ? {
627
+ ...result,
628
+ payloads: [{ text: durableReasoningText, isReasoning: true }, ...(result.payloads ?? [])],
629
+ }
630
+ : result;
631
+ if (cliText) {
632
+ emitAgentEvent({
633
+ runId: params.runId,
634
+ stream: "assistant",
635
+ data: { text: cliText },
636
+ });
637
+ }
638
+
639
+ if (emitLifecycleTerminal) {
640
+ emitAgentEvent({
641
+ runId: params.runId,
642
+ ...(params.runParams.agentId ? { agentId: params.runParams.agentId } : {}),
643
+ ...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
644
+ ...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
645
+ ...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
646
+ stream: "lifecycle",
647
+ data: {
648
+ phase: "end",
649
+ startedAt,
650
+ endedAt: Date.now(),
651
+ ...resolveAgentLifecycleTerminalMetadata(result.meta),
652
+ ...resolveAgentRunAbortLifecycleFields(params.runParams.abortSignal),
653
+ },
654
+ });
655
+ lifecycleTerminalEmitted = true;
656
+ }
657
+ return resultWithReasoning;
658
+ } catch (err) {
659
+ await stopAgentEventBridges(bridges);
660
+ await params.onErrorBeforeLifecycle?.(err);
661
+ if (emitLifecycleTerminal) {
662
+ emitAgentEvent({
663
+ runId: params.runId,
664
+ ...(params.runParams.agentId ? { agentId: params.runParams.agentId } : {}),
665
+ ...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
666
+ ...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
667
+ ...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
668
+ stream: "lifecycle",
669
+ data: {
670
+ phase: "error",
671
+ startedAt,
672
+ endedAt: Date.now(),
673
+ error: String(err),
674
+ ...resolveAgentRunErrorLifecycleFields(err, params.runParams.abortSignal),
675
+ },
676
+ });
677
+ lifecycleTerminalEmitted = true;
678
+ }
679
+ throw err;
680
+ } finally {
681
+ for (const bridge of bridges) {
682
+ bridge.unsubscribe();
683
+ }
684
+ if (params.runParams.isFinalFallbackAttempt !== false) {
685
+ await maybeEmitFastModeAutoReset();
686
+ }
687
+ if (emitLifecycleTerminal && !lifecycleTerminalEmitted) {
688
+ emitAgentEvent({
689
+ runId: params.runId,
690
+ ...(params.runParams.agentId ? { agentId: params.runParams.agentId } : {}),
691
+ ...(params.runParams.sessionKey ? { sessionKey: params.runParams.sessionKey } : {}),
692
+ ...(params.runParams.sessionId ? { sessionId: params.runParams.sessionId } : {}),
693
+ ...(params.lifecycleGeneration ? { lifecycleGeneration: params.lifecycleGeneration } : {}),
694
+ stream: "lifecycle",
695
+ data: {
696
+ phase: "error",
697
+ startedAt,
698
+ endedAt: Date.now(),
699
+ error: "CLI run completed without lifecycle terminal event",
700
+ ...resolveAgentRunAbortLifecycleFields(params.runParams.abortSignal),
701
+ },
702
+ });
703
+ }
704
+ }
705
+ }
src/auto-reply/reply/agent-runner-command-output.test.ts ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildCommandOutputFromToolResultEvent } from "./agent-runner-command-output.js";
3
+
4
+ const BASH_ARGS = { command: "nope-not-a-command", description: "run missing binary" };
5
+
6
+ function buildFromCliResult(overrides: Record<string, unknown>) {
7
+ return buildCommandOutputFromToolResultEvent({
8
+ stream: "tool",
9
+ data: { phase: "result", name: "Bash", toolCallId: "call-1", args: BASH_ARGS, ...overrides },
10
+ });
11
+ }
12
+
13
+ describe("buildCommandOutputFromToolResultEvent", () => {
14
+ it("reports a CLI command failure whose result is only text", () => {
15
+ // CLI backends report the outcome plus raw content, never a structured
16
+ // record, so requiring a structured field dropped the failure entirely.
17
+ const built = buildFromCliResult({
18
+ isError: true,
19
+ result: "bash: nope-not-a-command: command not found",
20
+ });
21
+
22
+ expect(built?.status).toBe("failed");
23
+ expect(built?.output).toBe("bash: nope-not-a-command: command not found");
24
+ });
25
+
26
+ it("reads the outcome from streamed text blocks", () => {
27
+ const built = buildFromCliResult({
28
+ isError: true,
29
+ result: [
30
+ { type: "text", text: "line one" },
31
+ { type: "text", text: "line two" },
32
+ ],
33
+ });
34
+
35
+ expect(built?.status).toBe("failed");
36
+ expect(built?.output).toBe("line one\nline two");
37
+ });
38
+
39
+ it("describes the command that ran instead of what it printed", () => {
40
+ const built = buildFromCliResult({ isError: true, result: "some noisy stderr" });
41
+
42
+ // The title drives the visible progress line; without it the line would
43
+ // replace the request with the tool's output.
44
+ expect(built?.title).toContain("nope-not-a-command");
45
+ });
46
+
47
+ it("marks a successful CLI command completed", () => {
48
+ expect(buildFromCliResult({ isError: false, result: "alpha" })?.status).toBe("completed");
49
+ });
50
+
51
+ it("projects a safe terminal state from a namespaced command-bearing result", () => {
52
+ const built = buildCommandOutputFromToolResultEvent({
53
+ stream: "tool",
54
+ data: {
55
+ phase: "result",
56
+ name: "server.exec",
57
+ toolCallId: "call-1",
58
+ commandBearing: true,
59
+ isError: false,
60
+ },
61
+ });
62
+
63
+ expect(built).toMatchObject({
64
+ name: "server.exec",
65
+ status: "completed",
66
+ toolCallId: "call-1",
67
+ });
68
+ });
69
+
70
+ it("prefers an explicit status and structured fields when present", () => {
71
+ const built = buildCommandOutputFromToolResultEvent({
72
+ stream: "tool",
73
+ data: {
74
+ phase: "result",
75
+ name: "exec",
76
+ toolCallId: "call-1",
77
+ title: "false",
78
+ isError: true,
79
+ result: { exitCode: 2, output: "structured output", status: "exit 2" },
80
+ },
81
+ });
82
+
83
+ expect(built).toMatchObject({
84
+ status: "exit 2",
85
+ exitCode: 2,
86
+ output: "structured output",
87
+ title: "false",
88
+ });
89
+ });
90
+
91
+ it("ignores events that carry no outcome and no content", () => {
92
+ expect(
93
+ buildCommandOutputFromToolResultEvent({
94
+ stream: "tool",
95
+ data: { phase: "result", name: "Bash", toolCallId: "call-1" },
96
+ }),
97
+ ).toBeUndefined();
98
+ });
99
+
100
+ it("ignores non-command tools and non-result phases", () => {
101
+ expect(
102
+ buildCommandOutputFromToolResultEvent({
103
+ stream: "tool",
104
+ data: { phase: "result", name: "Read", toolCallId: "call-1", isError: true },
105
+ }),
106
+ ).toBeUndefined();
107
+ expect(buildFromCliResult({ phase: "start", isError: true })).toBeUndefined();
108
+ });
109
+ });
src/auto-reply/reply/agent-runner-command-output.ts ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { asFiniteNumber as readFiniteNumberValue } from "@openclaw/normalization-core/number-coercion";
2
+ import { asOptionalRecord as readRecordValue } from "@openclaw/normalization-core/record-coerce";
3
+ import { readStringValue } from "@openclaw/normalization-core/string-coerce";
4
+ import type { EmbeddedAgentEvent } from "../../agents/embedded-agent-subscribe.shared-types.js";
5
+ import { inferToolMetaFromArgsCore } from "../../agents/tool-display.js";
6
+ import type { GetReplyOptions } from "../types.js";
7
+
8
+ /**
9
+ * CLI backends report a tool result as its raw content: a string, or the text
10
+ * blocks the harness streamed. Structured runners send a record instead, so the
11
+ * command projection has to read both or every CLI command result is dropped.
12
+ */
13
+ function readToolResultText(value: unknown): string | undefined {
14
+ const direct = readStringValue(value);
15
+ if (direct !== undefined) {
16
+ return direct;
17
+ }
18
+ if (!Array.isArray(value)) {
19
+ return undefined;
20
+ }
21
+ const text = value
22
+ .map((block) => readStringValue(readRecordValue(block)?.text))
23
+ .filter((part): part is string => part !== undefined)
24
+ .join("\n")
25
+ .trim();
26
+ return text || undefined;
27
+ }
28
+
29
+ function readNullableNumberValue(value: unknown): number | null | undefined {
30
+ if (value === null) {
31
+ return null;
32
+ }
33
+ return readFiniteNumberValue(value);
34
+ }
35
+
36
+ function isCommandToolName(name: string | undefined): boolean {
37
+ const normalized = name?.trim().toLowerCase();
38
+ return normalized === "exec" || normalized === "bash" || normalized === "shell";
39
+ }
40
+
41
+ /** Projects a completed command-tool event into the channel command-output contract. */
42
+ export function buildCommandOutputFromToolResultEvent(
43
+ evt: EmbeddedAgentEvent,
44
+ ): Parameters<NonNullable<GetReplyOptions["onCommandOutput"]>>[0] | undefined {
45
+ if (evt.stream !== "tool" || evt.data.phase !== "result") {
46
+ return undefined;
47
+ }
48
+ const name = evt.data.name;
49
+ const commandBearing = evt.data.commandBearing === true;
50
+ if (!name || (!commandBearing && !isCommandToolName(name))) {
51
+ return undefined;
52
+ }
53
+ const result = readRecordValue(evt.data.result);
54
+ const details = readRecordValue(result?.details);
55
+ const output =
56
+ evt.data.output ??
57
+ readStringValue(result?.output) ??
58
+ readStringValue(details?.output) ??
59
+ readToolResultText(evt.data.result);
60
+ const explicitStatus =
61
+ evt.data.status ?? readStringValue(result?.status) ?? readStringValue(details?.status);
62
+ const exitCode = readNullableNumberValue(
63
+ result?.exitCode ?? details?.exitCode ?? evt.data.exitCode,
64
+ );
65
+ const durationMs = readFiniteNumberValue(
66
+ result?.durationMs ?? details?.durationMs ?? evt.data.durationMs,
67
+ );
68
+ const cwd = evt.data.cwd;
69
+ const errorStatus =
70
+ evt.data.isError === true ? "failed" : evt.data.isError === false ? "completed" : undefined;
71
+ // A bare result carries no outcome of its own: runners that report one send a
72
+ // separate command_output event, and synthesizing here would duplicate it.
73
+ // A CLI result is different because its content *is* the outcome, which
74
+ // readToolResultText surfaces as output above.
75
+ const hasConcreteCommandResult =
76
+ output !== undefined ||
77
+ explicitStatus !== undefined ||
78
+ exitCode !== undefined ||
79
+ durationMs !== undefined ||
80
+ cwd !== undefined ||
81
+ (commandBearing && typeof evt.data.isError === "boolean") ||
82
+ (result !== undefined && Object.keys(result).length > 0);
83
+ if (!hasConcreteCommandResult) {
84
+ return undefined;
85
+ }
86
+ // Keep the line describing the command, not its output: without a title the
87
+ // terminal line would replace the request with whatever the tool printed.
88
+ const args = evt.data.args;
89
+ const title =
90
+ evt.data.title ??
91
+ (args ? inferToolMetaFromArgsCore(name, args, { detailMode: "explain" }) : undefined);
92
+ return {
93
+ itemId: evt.data.itemId,
94
+ phase: "end",
95
+ title,
96
+ toolCallId: evt.data.toolCallId,
97
+ name,
98
+ output,
99
+ status: explicitStatus ?? errorStatus,
100
+ exitCode,
101
+ durationMs,
102
+ cwd,
103
+ };
104
+ }
src/auto-reply/reply/agent-runner-compaction-accounting.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ CompactionAccountingFact,
3
+ CompactionAccountingTarget,
4
+ } from "../../agents/embedded-agent-runner/run/internal-params.js";
5
+ import type { AgentTurnCompaction } from "./agent-runner-execution.types.js";
6
+
7
+ export function hasSameCompactionWriter(
8
+ previous: CompactionAccountingTarget | undefined,
9
+ current: CompactionAccountingTarget,
10
+ ): boolean {
11
+ // Physical session IDs may rotate while the binding and retained writer stay fixed.
12
+ return (
13
+ previous !== undefined &&
14
+ previous.agentId === current.agentId &&
15
+ previous.sessionKey === current.sessionKey &&
16
+ previous.storePath === current.storePath &&
17
+ previous.lifecycleRevision === current.lifecycleRevision &&
18
+ previous.activeWriterRunId === current.activeWriterRunId
19
+ );
20
+ }
21
+
22
+ /** A later opaque candidate may lose freshness, but must never fabricate it. */
23
+ export function invalidateTurnCompactionContext(compaction: AgentTurnCompaction): void {
24
+ compaction.durable = compaction.durable.map((fact) => ({
25
+ ...fact,
26
+ currentContextSnapshot: { tokens: undefined },
27
+ }));
28
+ }
29
+
30
+ /** Fold same-writer facts; only an ordered snapshot may refresh context. */
31
+ export function recordTurnCompaction(
32
+ compaction: AgentTurnCompaction,
33
+ fact: CompactionAccountingFact,
34
+ ): void {
35
+ if (fact.count < 0) {
36
+ return;
37
+ }
38
+ compaction.count += fact.count;
39
+ if (fact.kind !== "durable") {
40
+ return;
41
+ }
42
+ const index = compaction.durable.findIndex(({ target }) =>
43
+ hasSameCompactionWriter(target, fact.target),
44
+ );
45
+ const previous = compaction.durable[index];
46
+ if (!previous && fact.count === 0) {
47
+ return;
48
+ }
49
+ if (previous) {
50
+ compaction.durable.splice(index, 1);
51
+ }
52
+ // Custody without an observation cannot erase the prior candidate's explicit invalidation.
53
+ compaction.durable.push({
54
+ ...fact,
55
+ count: (previous?.count ?? 0) + fact.count,
56
+ currentContextSnapshot: fact.currentContextSnapshot ?? previous?.currentContextSnapshot,
57
+ });
58
+ }
src/auto-reply/reply/agent-runner-context-recovery.test.ts ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ModelDefinitionConfig } from "../../config/types.models.js";
3
+ import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js";
4
+
5
+ function makeTestModel(id: string, contextTokens: number): ModelDefinitionConfig {
6
+ return {
7
+ id,
8
+ name: id,
9
+ reasoning: false,
10
+ input: ["text"],
11
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
12
+ contextWindow: contextTokens,
13
+ contextTokens,
14
+ maxTokens: 4096,
15
+ };
16
+ }
17
+
18
+ describe("buildContextOverflowRecoveryText", () => {
19
+ it("uses the built-in recovery hint without heartbeat model evidence", () => {
20
+ const text = buildContextOverflowRecoveryText({
21
+ cfg: {},
22
+ primaryProvider: "openrouter",
23
+ primaryModel: "qwen3.6-plus",
24
+ });
25
+
26
+ expect(text).toContain("fresh session or using a model with a larger context window");
27
+ expect(text).not.toContain("reserveTokensFloor");
28
+ expect(text).not.toContain("heartbeat model bleed");
29
+ });
30
+
31
+ it("keeps the preserved-session copy with the built-in recovery hint", () => {
32
+ const text = buildContextOverflowRecoveryText({
33
+ preserveSessionMapping: true,
34
+ cfg: {},
35
+ primaryProvider: "openrouter",
36
+ primaryModel: "qwen3.6-plus",
37
+ });
38
+
39
+ expect(text).toContain("kept this conversation mapped to the current session");
40
+ expect(text).toContain("fresh session or using a model with a larger context window");
41
+ expect(text).not.toContain("reset our conversation");
42
+ });
43
+
44
+ it("does not use stale heartbeat hints for a different explicit runtime model", () => {
45
+ const text = buildContextOverflowRecoveryText({
46
+ cfg: {
47
+ agents: {
48
+ defaults: {
49
+ heartbeat: { model: "ollama/qwen3.5-9b-32k:latest" },
50
+ },
51
+ },
52
+ },
53
+ primaryProvider: "openrouter",
54
+ primaryModel: "qwen3.6-plus",
55
+ runtimeProvider: "custom",
56
+ runtimeModel: "uncataloged-32k",
57
+ activeSessionEntry: {
58
+ sessionId: "session",
59
+ updatedAt: 1,
60
+ modelProvider: "ollama",
61
+ model: "qwen3.5-9b-32k:latest",
62
+ contextTokens: 32_768,
63
+ },
64
+ });
65
+
66
+ expect(text).toContain("fresh session or using a model with a larger context window");
67
+ expect(text).not.toContain("heartbeat model bleed");
68
+ });
69
+
70
+ it("points to heartbeat model bleed when the session model matches heartbeat.model", () => {
71
+ const text = buildContextOverflowRecoveryText({
72
+ cfg: {
73
+ models: {
74
+ providers: {
75
+ openrouter: {
76
+ baseUrl: "https://openrouter.test",
77
+ models: [makeTestModel("qwen3.6-plus", 1_000_000)],
78
+ },
79
+ ollama: {
80
+ baseUrl: "http://ollama.test",
81
+ models: [makeTestModel("qwen3.5-9b-32k:latest", 32_768)],
82
+ },
83
+ },
84
+ },
85
+ agents: {
86
+ defaults: {
87
+ heartbeat: { model: "ollama/qwen3.5-9b-32k:latest" },
88
+ },
89
+ },
90
+ },
91
+ agentId: "agent",
92
+ primaryProvider: "openrouter",
93
+ primaryModel: "qwen3.6-plus",
94
+ activeSessionEntry: {
95
+ sessionId: "session",
96
+ updatedAt: 1,
97
+ modelProvider: "ollama",
98
+ model: "qwen3.5-9b-32k:latest",
99
+ contextTokens: 32_768,
100
+ },
101
+ });
102
+
103
+ expect(text).toContain("ollama/qwen3.5-9b-32k:latest (32k context)");
104
+ expect(text).toContain("openrouter/qwen3.6-plus");
105
+ expect(text).toContain("heartbeat model bleed");
106
+ expect(text).toContain("heartbeat.isolatedSession");
107
+ });
108
+
109
+ it("uses the stored context window for an uncataloged heartbeat model", () => {
110
+ const text = buildContextOverflowRecoveryText({
111
+ cfg: {
112
+ models: {
113
+ providers: {
114
+ openrouter: {
115
+ baseUrl: "https://openrouter.test",
116
+ models: [makeTestModel("qwen3.6-plus", 1_000_000)],
117
+ },
118
+ },
119
+ },
120
+ agents: {
121
+ defaults: {
122
+ heartbeat: { model: "ollama/custom-32k" },
123
+ },
124
+ },
125
+ },
126
+ agentId: "agent",
127
+ primaryProvider: "openrouter",
128
+ primaryModel: "qwen3.6-plus",
129
+ activeSessionEntry: {
130
+ sessionId: "session",
131
+ updatedAt: 1,
132
+ modelProvider: "ollama",
133
+ model: "custom-32k",
134
+ contextTokens: 32_768,
135
+ },
136
+ });
137
+
138
+ expect(text).toContain("ollama/custom-32k (32k context)");
139
+ expect(text).not.toContain("ollama/custom-32k (98k context)");
140
+ expect(text).toContain("heartbeat model bleed");
141
+ });
142
+ });
src/auto-reply/reply/agent-runner-context-recovery.ts ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ normalizeLowercaseStringOrEmpty,
3
+ normalizeOptionalString,
4
+ } from "@openclaw/normalization-core/string-coerce";
5
+ import { resolveAgentConfig } from "../../agents/agent-scope-config.js";
6
+ import { resolveContextTokensForModel } from "../../agents/context.js";
7
+ import { resolveModelRefFromString } from "../../agents/model-selection.js";
8
+ import type { SessionEntry } from "../../config/sessions.js";
9
+ import type { FollowupRun } from "./queue.js";
10
+
11
+ function buildContextOverflowResetHint(): string {
12
+ return "\n\nTry starting a fresh session or using a model with a larger context window.";
13
+ }
14
+
15
+ type ModelRefLike = {
16
+ provider: string;
17
+ model: string;
18
+ };
19
+
20
+ function resolveAgentHeartbeatModelRaw(params: {
21
+ cfg: FollowupRun["run"]["config"];
22
+ agentId?: string;
23
+ }): string | undefined {
24
+ const defaultModel = normalizeOptionalString(params.cfg.agents?.defaults?.heartbeat?.model);
25
+ const agentId = normalizeLowercaseStringOrEmpty(params.agentId);
26
+ const agentModel = agentId
27
+ ? normalizeOptionalString(resolveAgentConfig(params.cfg, agentId)?.heartbeat?.model)
28
+ : undefined;
29
+ return agentModel ?? defaultModel;
30
+ }
31
+
32
+ function normalizeModelRefForCompare(ref: ModelRefLike | undefined) {
33
+ if (!ref) {
34
+ return undefined;
35
+ }
36
+ const provider = normalizeLowercaseStringOrEmpty(ref.provider);
37
+ const model = normalizeLowercaseStringOrEmpty(ref.model);
38
+ return provider && model ? { provider, model } : undefined;
39
+ }
40
+
41
+ function modelRefsEqual(left: ModelRefLike | undefined, right: ModelRefLike | undefined) {
42
+ const normalizedLeft = normalizeModelRefForCompare(left);
43
+ const normalizedRight = normalizeModelRefForCompare(right);
44
+ return (
45
+ normalizedLeft !== undefined &&
46
+ normalizedRight !== undefined &&
47
+ normalizedLeft.provider === normalizedRight.provider &&
48
+ normalizedLeft.model === normalizedRight.model
49
+ );
50
+ }
51
+
52
+ function formatContextWindowLabel(tokens: number): string {
53
+ if (tokens >= 1_000_000) {
54
+ return `${Math.round((tokens / 1_000_000) * 10) / 10}M`;
55
+ }
56
+ return `${Math.round(tokens / 1024)}k`;
57
+ }
58
+
59
+ function normalizePositiveContextTokens(value: unknown): number | undefined {
60
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
61
+ return undefined;
62
+ }
63
+ return Math.floor(value);
64
+ }
65
+
66
+ function resolveContextWindowForHint(params: {
67
+ cfg: FollowupRun["run"]["config"];
68
+ agentId?: string;
69
+ ref: ModelRefLike;
70
+ activeSessionEntry?: SessionEntry;
71
+ }) {
72
+ const sessionContextTokens = normalizePositiveContextTokens(
73
+ params.activeSessionEntry?.contextTokens,
74
+ );
75
+ const modelContextTokens = resolveContextTokensForModel({
76
+ cfg: params.cfg,
77
+ provider: params.ref.provider,
78
+ model: params.ref.model,
79
+ allowAsyncLoad: false,
80
+ });
81
+ return modelContextTokens ?? sessionContextTokens;
82
+ }
83
+
84
+ function resolveHeartbeatBleedHint(params: {
85
+ cfg: FollowupRun["run"]["config"];
86
+ agentId?: string;
87
+ primaryProvider?: string;
88
+ primaryModel?: string;
89
+ activeSessionEntry?: SessionEntry;
90
+ }): string | undefined {
91
+ const primaryProvider = normalizeOptionalString(params.primaryProvider);
92
+ const primaryModel = normalizeOptionalString(params.primaryModel);
93
+ const runtimeProvider = normalizeOptionalString(params.activeSessionEntry?.modelProvider);
94
+ const runtimeModel = normalizeOptionalString(params.activeSessionEntry?.model);
95
+ if (!primaryProvider || !primaryModel || !runtimeProvider || !runtimeModel) {
96
+ return undefined;
97
+ }
98
+
99
+ const primaryRef = { provider: primaryProvider, model: primaryModel };
100
+ const runtimeRef = { provider: runtimeProvider, model: runtimeModel };
101
+ if (modelRefsEqual(primaryRef, runtimeRef)) {
102
+ return undefined;
103
+ }
104
+ const heartbeatModelRaw = resolveAgentHeartbeatModelRaw({
105
+ cfg: params.cfg,
106
+ agentId: params.agentId,
107
+ });
108
+ const heartbeatRef = heartbeatModelRaw
109
+ ? resolveModelRefFromString({
110
+ cfg: params.cfg,
111
+ raw: heartbeatModelRaw,
112
+ defaultProvider: primaryProvider,
113
+ })?.ref
114
+ : undefined;
115
+ if (!modelRefsEqual(runtimeRef, heartbeatRef)) {
116
+ return undefined;
117
+ }
118
+
119
+ const runtimeWindow = resolveContextWindowForHint({
120
+ cfg: params.cfg,
121
+ agentId: params.agentId,
122
+ ref: runtimeRef,
123
+ activeSessionEntry: params.activeSessionEntry,
124
+ });
125
+ const primaryWindow = resolveContextWindowForHint({
126
+ cfg: params.cfg,
127
+ agentId: params.agentId,
128
+ ref: primaryRef,
129
+ });
130
+ if (
131
+ typeof runtimeWindow === "number" &&
132
+ typeof primaryWindow === "number" &&
133
+ runtimeWindow >= primaryWindow
134
+ ) {
135
+ return undefined;
136
+ }
137
+
138
+ const runtimeLabel =
139
+ typeof runtimeWindow === "number" && runtimeWindow > 0
140
+ ? ` (${formatContextWindowLabel(runtimeWindow)} context)`
141
+ : "";
142
+ return (
143
+ `\n\nThe previous heartbeat turn left this session on ${runtimeProvider}/${runtimeModel}` +
144
+ `${runtimeLabel} instead of ${primaryProvider}/${primaryModel}. This matches the configured ` +
145
+ "`heartbeat.model`, so the overflow is likely heartbeat model bleed rather than a " +
146
+ "compaction-buffer problem. Set `heartbeat.isolatedSession: true`, enable " +
147
+ "`heartbeat.lightContext: true`, or use a heartbeat model with a larger context window."
148
+ );
149
+ }
150
+
151
+ /** Builds recovery instructions for context-overflow failures. */
152
+ export function buildContextOverflowRecoveryText(params: {
153
+ duringCompaction?: boolean;
154
+ preserveSessionMapping?: boolean;
155
+ cfg: FollowupRun["run"]["config"];
156
+ agentId?: string;
157
+ primaryProvider?: string;
158
+ primaryModel?: string;
159
+ runtimeProvider?: string;
160
+ runtimeModel?: string;
161
+ activeSessionEntry?: SessionEntry;
162
+ }): string {
163
+ const prefix = params.preserveSessionMapping
164
+ ? "⚠️ Auto-compaction could not recover this turn. I kept this conversation mapped to the current session. Please try again, use /compact, or use /new to start a fresh session."
165
+ : params.duringCompaction
166
+ ? "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again."
167
+ : "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.";
168
+ const explicitRuntimeMatchesSession =
169
+ !params.runtimeProvider ||
170
+ !params.runtimeModel ||
171
+ (params.runtimeProvider === params.activeSessionEntry?.modelProvider &&
172
+ params.runtimeModel === params.activeSessionEntry?.model);
173
+ const heartbeatBleedHint = explicitRuntimeMatchesSession
174
+ ? resolveHeartbeatBleedHint({
175
+ cfg: params.cfg,
176
+ agentId: params.agentId,
177
+ primaryProvider: params.primaryProvider,
178
+ primaryModel: params.primaryModel,
179
+ activeSessionEntry: params.activeSessionEntry,
180
+ })
181
+ : undefined;
182
+ return prefix + (heartbeatBleedHint ?? buildContextOverflowResetHint());
183
+ }
src/auto-reply/reply/agent-runner-core.test.ts ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, onTestFinished } from "vitest";
2
+ import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js";
3
+ import { resolveFallbackTransition } from "../fallback-state.js";
4
+ import type { TemplateContext } from "../templating.js";
5
+ import {
6
+ buildSilentFallbackFailurePayload,
7
+ resolveAdmittedRunSessionFile,
8
+ resolveReplyRunDeliveryContext,
9
+ } from "./agent-runner-core.js";
10
+ import type { RuntimeFallbackAttempt } from "./agent-runner-execution.types.js";
11
+
12
+ describe("resolveAdmittedRunSessionFile", () => {
13
+ it("uses the scoped session key when one is available", () => {
14
+ expect(
15
+ resolveAdmittedRunSessionFile({
16
+ agentId: "main",
17
+ sessionId: "session",
18
+ sessionFile: "legacy-target",
19
+ sessionKey: " agent:main:session ",
20
+ storePath: "/tmp/sessions.json",
21
+ }),
22
+ ).toBe("agent:main:session");
23
+ });
24
+
25
+ it("preserves the admitted fallback when a persisted run has no session key", () => {
26
+ expect(
27
+ resolveAdmittedRunSessionFile({
28
+ agentId: "main",
29
+ sessionId: "session",
30
+ sessionFile: "legacy-target",
31
+ storePath: "/tmp/sessions.json",
32
+ }),
33
+ ).toBe("legacy-target");
34
+ });
35
+ });
36
+
37
+ describe("resolveReplyRunDeliveryContext", () => {
38
+ it.each([
39
+ { name: "numeric message topic", messageThreadId: 99, threadId: 99 },
40
+ { name: "numeric transport topic", transportThreadId: 99, threadId: 99 },
41
+ { name: "message topic precedence", messageThreadId: 99, transportThreadId: 77, threadId: 99 },
42
+ { name: "string message topic", messageThreadId: "99", threadId: "99" },
43
+ { name: "session identity fallback", threadId: "12345:99" },
44
+ ])("preserves the $name", ({ messageThreadId, transportThreadId, threadId }) => {
45
+ expect(
46
+ resolveReplyRunDeliveryContext({
47
+ cfg: {},
48
+ sessionCtx: {
49
+ Provider: "telegram",
50
+ OriginatingChannel: "telegram",
51
+ OriginatingTo: "telegram:12345",
52
+ AccountId: "work",
53
+ MessageThreadId: messageThreadId,
54
+ TransportThreadId: transportThreadId,
55
+ SessionKey: "agent:main:telegram:direct:12345:thread:12345:99",
56
+ } as TemplateContext,
57
+ sessionKey: "agent:main:telegram:direct:12345:thread:12345:99",
58
+ }),
59
+ ).toEqual({
60
+ channel: "telegram",
61
+ to: "telegram:12345",
62
+ accountId: "work",
63
+ threadId,
64
+ });
65
+ });
66
+ });
67
+
68
+ describe("buildSilentFallbackFailurePayload", () => {
69
+ const selected = { provider: "openrouter", model: "z-ai/glm-5.3" };
70
+ const other = { provider: "amazon-bedrock", model: "claude-haiku" };
71
+ const transition = resolveFallbackTransition({
72
+ selectedProvider: selected.provider,
73
+ selectedModel: selected.model,
74
+ activeProvider: other.provider,
75
+ activeModel: other.model,
76
+ attempts: [],
77
+ });
78
+ const base = {
79
+ fallbackTransition: transition,
80
+ fallbackFailureKnown: true,
81
+ cfg: {},
82
+ isHeartbeat: false,
83
+ hasSuccessfulTerminalDelivery: false,
84
+ };
85
+ const attempt = (
86
+ reason: RuntimeFallbackAttempt["reason"],
87
+ identity: Partial<RuntimeFallbackAttempt> = {},
88
+ ): RuntimeFallbackAttempt => ({ ...selected, error: "provider failed", reason, ...identity });
89
+
90
+ it.each(["timeout", "server_error", "overloaded", "tls_certificate"] as const)(
91
+ "uses unreachable wording for selected-backend %s evidence",
92
+ (reason) => {
93
+ const payload = buildSilentFallbackFailurePayload({
94
+ ...base,
95
+ fallbackAttempts: [attempt(reason)],
96
+ });
97
+ expect(payload?.text).toContain("couldn't reach the configured model backend");
98
+ expect(payload?.text).toContain(transition.selectedModelRef);
99
+ expect(payload?.text).toContain(transition.activeModelRef);
100
+ expect(payload?.isError).toBe(true);
101
+ },
102
+ );
103
+
104
+ it.each(["format", "empty_response"] as const)(
105
+ "uses response wording for selected-backend %s evidence",
106
+ (reason) => {
107
+ const payload = buildSilentFallbackFailurePayload({
108
+ ...base,
109
+ fallbackAttempts: [attempt(reason)],
110
+ });
111
+ expect(payload?.text).toContain("responded but produced no usable reply");
112
+ expect(payload?.text).not.toContain("couldn't reach");
113
+ },
114
+ );
115
+
116
+ it.each([
117
+ { label: "empty evidence", attempts: [] },
118
+ { label: "unknown reason", attempts: [attempt("unknown")] },
119
+ { label: "auth skip", attempts: [attempt("auth")] },
120
+ { label: "auth with synthesized status", attempts: [attempt("auth", { status: 401 })] },
121
+ { label: "permanent auth", attempts: [attempt("auth_permanent")] },
122
+ { label: "billing", attempts: [attempt("billing")] },
123
+ { label: "rate limit", attempts: [attempt("rate_limit")] },
124
+ ...(["timeout", "overloaded", "format", "empty_response"] as const).map((reason) => ({
125
+ label: `local skip retaining ${reason}`,
126
+ attempts: [attempt(reason, { code: "MODEL_FALLBACK_SKIPPED" })],
127
+ })),
128
+ {
129
+ label: "local skip beside a response",
130
+ attempts: [attempt("format"), attempt("format", { code: "MODEL_FALLBACK_SKIPPED" })],
131
+ },
132
+ { label: "transport then response", attempts: [attempt("timeout"), attempt("format")] },
133
+ { label: "response then transport", attempts: [attempt("format"), attempt("timeout")] },
134
+ { label: "other backend only", attempts: [attempt("format", other)] },
135
+ { label: "other model only", attempts: [attempt("format", { model: "different" })] },
136
+ { label: "unattributed response", attempts: [attempt("format", { provider: "", model: "" })] },
137
+ {
138
+ label: "unattributed transport",
139
+ attempts: [attempt("timeout", { provider: "", model: "" })],
140
+ },
141
+ {
142
+ label: "incomplete evidence beside response",
143
+ attempts: [attempt("format"), attempt("timeout", { provider: "" })],
144
+ },
145
+ {
146
+ label: "incomplete evidence beside transport",
147
+ attempts: [attempt("timeout"), attempt("format", { model: " " })],
148
+ },
149
+ ])("keeps $label cause-neutral", ({ attempts }) => {
150
+ const payload = buildSilentFallbackFailurePayload({ ...base, fallbackAttempts: attempts });
151
+ expect(payload?.text).toContain("produced no usable reply");
152
+ expect(payload?.text).not.toContain("responded");
153
+ expect(payload?.text).not.toContain("couldn't reach");
154
+ });
155
+
156
+ it.each([
157
+ {
158
+ selectedReason: "format",
159
+ otherReason: "timeout",
160
+ expected: "responded but produced no usable reply",
161
+ },
162
+ {
163
+ selectedReason: "timeout",
164
+ otherReason: "format",
165
+ expected: "couldn't reach the configured model backend",
166
+ },
167
+ ] as const)(
168
+ "ignores another backend's $otherReason evidence",
169
+ ({ selectedReason, otherReason, expected }) => {
170
+ const payload = buildSilentFallbackFailurePayload({
171
+ ...base,
172
+ fallbackAttempts: [attempt(otherReason, other), attempt(selectedReason)],
173
+ });
174
+ expect(payload?.text).toContain(expected);
175
+ },
176
+ );
177
+
178
+ it("uses the configured runtime alias when attributing a response", () => {
179
+ // Exercise attribution with setup metadata; the plugin registration test owns
180
+ // the real Claude CLI descriptor and its canonical provider binding.
181
+ onTestFinished(() => cliBackendsTesting.resetDepsForTest());
182
+ cliBackendsTesting.setDepsForTest({
183
+ resolveRuntimeCliBackends: () => [],
184
+ resolvePluginSetupCliBackend: ({ backend }) =>
185
+ backend === "claude-cli"
186
+ ? {
187
+ pluginId: "anthropic",
188
+ backend: {
189
+ id: "claude-cli",
190
+ modelProvider: "anthropic",
191
+ config: { command: "claude" },
192
+ },
193
+ }
194
+ : undefined,
195
+ });
196
+ const cfg = { plugins: { allow: ["anthropic"], entries: { anthropic: { enabled: true } } } };
197
+ const fallbackTransition = resolveFallbackTransition({
198
+ selectedProvider: "anthropic",
199
+ selectedModel: "claude-sonnet-4-6",
200
+ activeProvider: "openai",
201
+ activeModel: "gpt-5.5",
202
+ attempts: [],
203
+ cfg,
204
+ });
205
+ const payload = buildSilentFallbackFailurePayload({
206
+ ...base,
207
+ cfg,
208
+ fallbackTransition,
209
+ fallbackAttempts: [attempt("format", { provider: "claude-cli", model: "claude-sonnet-4-6" })],
210
+ });
211
+ expect(payload?.text).toContain("responded but produced no usable reply");
212
+ });
213
+
214
+ it.each([
215
+ { isHeartbeat: true },
216
+ { hasSuccessfulTerminalDelivery: true },
217
+ { allowEmptyAssistantReplyAsSilent: true },
218
+ { silentExpected: true },
219
+ { hasExplicitSilentReply: true },
220
+ { fallbackFailureKnown: false },
221
+ { fallbackTransition: { ...transition, fallbackActive: false } },
222
+ ])("preserves warning suppression %j", (suppression) => {
223
+ expect(
224
+ buildSilentFallbackFailurePayload({
225
+ ...base,
226
+ ...suppression,
227
+ fallbackAttempts: [attempt("format")],
228
+ }),
229
+ ).toBeUndefined();
230
+ });
231
+ });
src/auto-reply/reply/agent-runner-core.ts ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
2
+ import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js";
3
+ import { hasVisibleCommittedMessagingToolDeliveryEvidence } from "../../agents/embedded-agent-runner/delivery-evidence.js";
4
+ import { MODEL_FALLBACK_SKIPPED_CODE } from "../../agents/model-fallback.types.js";
5
+ import type { ModelRef } from "../../agents/model-ref-shared.js";
6
+ import { areRuntimeModelRefsEquivalent } from "../../agents/model-runtime-aliases.js";
7
+ import type { OpenClawConfig } from "../../config/config.js";
8
+ import {
9
+ resolveSessionPluginStatusLines,
10
+ resolveSessionPluginTraceLines,
11
+ type SessionEntry,
12
+ } from "../../config/sessions.js";
13
+ import { loadSessionEntryReadOnly } from "../../config/sessions/session-accessor.js";
14
+ import type { TypingMode } from "../../config/types.js";
15
+ import { logVerbose } from "../../globals.js";
16
+ import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
17
+ import { resolveSendPolicy } from "../../sessions/send-policy.js";
18
+ import {
19
+ sessionDeliveryChannel,
20
+ type DeliveryContext,
21
+ normalizeDeliveryContext,
22
+ } from "../../utils/delivery-context.shared.js";
23
+ import { resolveFallbackTransition } from "../fallback-state.js";
24
+ import {
25
+ isReplyPayloadTerminalContent,
26
+ markReplyPayloadForSourceSuppressionDelivery,
27
+ setReplyPayloadMetadata,
28
+ } from "../reply-payload.js";
29
+ import type { TemplateContext } from "../templating.js";
30
+ import type { VerboseLevel } from "../thinking.js";
31
+ import { SILENT_REPLY_TOKEN } from "../tokens.js";
32
+ import type { GetReplyOptions, ReplyPayload } from "../types.js";
33
+ import type { RuntimeFallbackAttempt } from "./agent-runner-execution.types.js";
34
+ import {
35
+ buildKnownAgentRunFailureReplyPayload,
36
+ buildTerminalAgentRunFailureReplyPayload,
37
+ } from "./agent-runner-failure-reply.js";
38
+ import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
39
+ import { resolveEffectiveReplyRoute } from "./effective-reply-route.js";
40
+ import type { InternalGetReplyOptions } from "./get-reply.types.js";
41
+ import { normalizeReplyPayload } from "./normalize-reply.js";
42
+ import { sanitizePendingFinalDeliveryText } from "./pending-final-delivery-state.js";
43
+ import { type FollowupRun, type QueueSettings, scheduleFollowupDrain } from "./queue.js";
44
+ import { normalizeReplyPayloadDirectives, type DirectBlockDelivery } from "./reply-delivery.js";
45
+ import { isReplyOperationSuperseded } from "./reply-operation-abort.js";
46
+ import { type ReplyOperation, runAfterReplyOperationClear } from "./reply-run-registry.js";
47
+ import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
48
+ import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
49
+ import type { TypingController } from "./typing.js";
50
+ export const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000;
51
+
52
+ const RESTART_LIFECYCLE_REPLY_TEXT =
53
+ "⚠️ Gateway is restarting. Please wait a few seconds and try again.";
54
+
55
+ export function scheduleFollowupDrainAfterReplyOperationClear(params: {
56
+ operation: ReplyOperation;
57
+ queueKey: string;
58
+ runFollowup: (run: FollowupRun) => Promise<void>;
59
+ }): void {
60
+ runAfterReplyOperationClear(params.operation, (admissionSessionId) => {
61
+ const completedSessionId = params.operation.sessionId;
62
+ const runFollowupAfterClear =
63
+ admissionSessionId === completedSessionId
64
+ ? params.runFollowup
65
+ : (queued: FollowupRun) =>
66
+ params.runFollowup(
67
+ queued.run.sessionId === completedSessionId
68
+ ? { ...queued, admissionSessionId }
69
+ : queued,
70
+ );
71
+ scheduleFollowupDrain(params.queueKey, runFollowupAfterClear);
72
+ });
73
+ }
74
+
75
+ export function markBeforeAgentRunBlockedPayloads(payloads: ReplyPayload[]): ReplyPayload[] {
76
+ return payloads.map((payload) =>
77
+ setReplyPayloadMetadata(payload, { beforeAgentRunBlocked: true }),
78
+ );
79
+ }
80
+
81
+ export function buildSilentFallbackFailurePayload(params: {
82
+ fallbackTransition: ReturnType<typeof resolveFallbackTransition>;
83
+ fallbackFailureKnown: boolean;
84
+ fallbackAttempts: readonly RuntimeFallbackAttempt[];
85
+ cfg: OpenClawConfig;
86
+ isHeartbeat: boolean;
87
+ hasSuccessfulTerminalDelivery: boolean;
88
+ allowEmptyAssistantReplyAsSilent?: boolean;
89
+ silentExpected?: boolean;
90
+ hasExplicitSilentReply?: boolean;
91
+ }): ReplyPayload | undefined {
92
+ if (
93
+ params.isHeartbeat ||
94
+ params.allowEmptyAssistantReplyAsSilent === true ||
95
+ params.silentExpected === true ||
96
+ params.hasExplicitSilentReply === true ||
97
+ params.hasSuccessfulTerminalDelivery ||
98
+ !params.fallbackTransition.fallbackActive ||
99
+ !params.fallbackFailureKnown
100
+ ) {
101
+ return undefined;
102
+ }
103
+ const selected = params.fallbackTransition.selectedModelRef;
104
+ const active = params.fallbackTransition.activeModelRef;
105
+ const attempts = params.fallbackAttempts;
106
+ const selectedAttempts = attempts.filter((attempt) =>
107
+ areRuntimeModelRefsEquivalent(`${attempt.provider}/${attempt.model}`, selected, {
108
+ config: params.cfg,
109
+ }),
110
+ );
111
+ let primary = `⚠️ The configured model backend ${selected} produced no usable reply. `;
112
+ // Local skips retain old failure reasons, not evidence of a new backend attempt.
113
+ if (
114
+ selectedAttempts.length > 0 &&
115
+ selectedAttempts.every((attempt) => attempt.code !== MODEL_FALLBACK_SKIPPED_CODE) &&
116
+ attempts.every((attempt) => attempt.provider.trim() && attempt.model.trim())
117
+ ) {
118
+ if (
119
+ selectedAttempts.every(({ reason }) =>
120
+ ["timeout", "server_error", "overloaded", "tls_certificate"].includes(reason),
121
+ )
122
+ ) {
123
+ primary = `⚠️ I couldn't reach the configured model backend ${selected}. `;
124
+ } else if (
125
+ selectedAttempts.every(({ reason }) => reason === "format" || reason === "empty_response")
126
+ ) {
127
+ primary = `⚠️ The configured model backend ${selected} responded but produced no usable reply. `;
128
+ }
129
+ }
130
+ return markReplyPayloadForSourceSuppressionDelivery({
131
+ text: `${primary}Fallback used ${active}, but it produced no visible reply.`,
132
+ isError: true,
133
+ });
134
+ }
135
+
136
+ export function resolveSourceReplyPolicy(params: {
137
+ cfg: OpenClawConfig;
138
+ sessionCtx: TemplateContext;
139
+ sessionEntry?: SessionEntry;
140
+ sessionKey: string;
141
+ runtimePolicySessionKey?: string;
142
+ opts?: GetReplyOptions;
143
+ }): ReturnType<typeof resolveSourceReplyVisibilityPolicy> {
144
+ const sendPolicy = resolveSendPolicy({
145
+ cfg: params.cfg,
146
+ entry: params.sessionEntry,
147
+ sessionKey: params.runtimePolicySessionKey ?? params.sessionKey,
148
+ channel:
149
+ params.sessionCtx.OriginatingChannel ??
150
+ params.sessionCtx.Surface ??
151
+ params.sessionCtx.Provider ??
152
+ sessionDeliveryChannel(params.sessionEntry),
153
+ chatType: params.sessionEntry?.chatType,
154
+ });
155
+ return resolveSourceReplyVisibilityPolicy({
156
+ cfg: params.cfg,
157
+ ctx: params.sessionCtx,
158
+ requested: params.opts?.sourceReplyDeliveryMode,
159
+ sendPolicy,
160
+ });
161
+ }
162
+
163
+ export function resolveReplyRunDeliveryContext(params: {
164
+ cfg: OpenClawConfig;
165
+ sessionCtx: TemplateContext;
166
+ sessionEntry?: SessionEntry;
167
+ sessionKey: string;
168
+ runtimePolicySessionKey?: string;
169
+ opts?: GetReplyOptions;
170
+ }): DeliveryContext | undefined {
171
+ const sourceReplyPolicy = resolveSourceReplyPolicy(params);
172
+ if (
173
+ params.sessionCtx.InboundEventKind === "room_event" ||
174
+ sourceReplyPolicy.sendPolicyDenied ||
175
+ (sourceReplyPolicy.suppressDelivery &&
176
+ sourceReplyPolicy.sourceReplyDeliveryMode !== "message_tool_only")
177
+ ) {
178
+ return undefined;
179
+ }
180
+ return normalizeDeliveryContext({
181
+ ...resolveEffectiveReplyRoute({
182
+ ctx: params.sessionCtx,
183
+ entry: params.sessionEntry,
184
+ }),
185
+ threadId: resolveRoutedDeliveryThreadId({
186
+ ctx: params.sessionCtx,
187
+ sessionKey: params.sessionCtx.SessionKey ?? params.sessionKey,
188
+ }),
189
+ });
190
+ }
191
+
192
+ export function hasSuccessfulSourceReplyDelivery(params: {
193
+ blockReplyPipeline: { didStream: () => boolean; isAborted: () => boolean } | null;
194
+ directlySentBlockKeys?: Set<string>;
195
+ messagingToolSentTexts?: string[];
196
+ messagingToolSentMediaUrls?: string[];
197
+ messagingToolSentTargets?: unknown[];
198
+ }): boolean {
199
+ return (
200
+ params.blockReplyPipeline?.didStream() ||
201
+ (params.directlySentBlockKeys?.size ?? 0) > 0 ||
202
+ hasVisibleCommittedMessagingToolDeliveryEvidence(params)
203
+ );
204
+ }
205
+
206
+ export function hasSuccessfulTerminalSourceReplyDelivery(params: {
207
+ blockReplyPipeline: {
208
+ didStreamTerminalReply?: () => boolean;
209
+ isAborted: () => boolean;
210
+ } | null;
211
+ directBlockDeliveries?: DirectBlockDelivery[];
212
+ }): boolean {
213
+ const sentTerminalBlock = params.directBlockDeliveries?.some(
214
+ ({ payload, outcome, pending, source }) =>
215
+ outcome === "delivered" &&
216
+ !pending &&
217
+ source?.complete !== false &&
218
+ isReplyPayloadTerminalContent(payload) &&
219
+ normalizeReplyPayload(payload, { applyChannelTransforms: false }) !== null,
220
+ );
221
+ return (
222
+ params.blockReplyPipeline?.didStreamTerminalReply?.() === true || sentTerminalBlock === true
223
+ );
224
+ }
225
+
226
+ export function resolveFallbackOriginModel(params: {
227
+ run: FollowupRun["run"];
228
+ fallbackStateEntry?: SessionEntry;
229
+ runtimeModelSelection?: ModelRef;
230
+ }): { provider: string; model: string; persistedAutoFallback: boolean } {
231
+ // Runtime-owned selection is not a fallback from the caller's nominal model.
232
+ if (params.runtimeModelSelection) {
233
+ return { ...params.runtimeModelSelection, persistedAutoFallback: false };
234
+ }
235
+ const entry = params.fallbackStateEntry;
236
+ const isAutoFallbackOverride =
237
+ entry?.modelOverrideSource === "auto" ||
238
+ (entry !== undefined &&
239
+ entry.modelOverrideSource === undefined &&
240
+ hasSessionAutoModelFallbackProvenance(entry));
241
+ if (isAutoFallbackOverride && entry !== undefined) {
242
+ const originProvider = normalizeOptionalString(entry.modelOverrideFallbackOriginProvider);
243
+ const originModel = normalizeOptionalString(entry.modelOverrideFallbackOriginModel);
244
+ if (originProvider && originModel) {
245
+ return { provider: originProvider, model: originModel, persistedAutoFallback: true };
246
+ }
247
+ }
248
+ return {
249
+ provider: params.run.provider,
250
+ model: params.run.model,
251
+ persistedAutoFallback: false,
252
+ };
253
+ }
254
+
255
+ export function buildInlinePluginStatusPayload(params: {
256
+ entry: SessionEntry | undefined;
257
+ includeStatusLines: boolean;
258
+ includeTraceLines: boolean;
259
+ }): ReplyPayload | undefined {
260
+ const statusLines = params.includeStatusLines
261
+ ? resolveSessionPluginStatusLines(params.entry)
262
+ : [];
263
+ const traceLines = params.includeTraceLines ? resolveSessionPluginTraceLines(params.entry) : [];
264
+ const lines = [...statusLines, ...traceLines];
265
+ if (lines.length === 0) {
266
+ return undefined;
267
+ }
268
+ return { text: lines.join("\n") };
269
+ }
270
+
271
+ export function normalizeAssistantFinalDeliveryText(text: string): string {
272
+ const parsed = normalizeReplyPayloadDirectives({
273
+ payload: { text },
274
+ trimLeadingWhitespace: true,
275
+ parseMode: "auto",
276
+ });
277
+ return sanitizePendingFinalDeliveryText(parsed.payload.text ?? "");
278
+ }
279
+
280
+ export function refreshSessionEntryFromStore(params: {
281
+ storePath?: string;
282
+ sessionKey?: string;
283
+ fallbackEntry?: SessionEntry;
284
+ activeSessionStore?: Record<string, SessionEntry>;
285
+ expectedGeneration?: Pick<SessionEntry, "sessionId" | "lifecycleRevision">;
286
+ }): SessionEntry | undefined {
287
+ const { storePath, sessionKey, fallbackEntry, activeSessionStore } = params;
288
+ if (!storePath || !sessionKey) {
289
+ return fallbackEntry;
290
+ }
291
+ try {
292
+ const latestEntry = loadSessionEntryReadOnly({
293
+ storePath,
294
+ sessionKey,
295
+ });
296
+ if (!latestEntry) {
297
+ return fallbackEntry;
298
+ }
299
+ // Completion may refresh facts, but only admission can adopt a replacement generation.
300
+ if (
301
+ params.expectedGeneration &&
302
+ (latestEntry.sessionId !== params.expectedGeneration.sessionId ||
303
+ latestEntry.lifecycleRevision !== params.expectedGeneration.lifecycleRevision)
304
+ ) {
305
+ return fallbackEntry;
306
+ }
307
+ if (activeSessionStore) {
308
+ activeSessionStore[sessionKey] = latestEntry;
309
+ }
310
+ return latestEntry;
311
+ } catch {
312
+ return fallbackEntry;
313
+ }
314
+ }
315
+
316
+ export function resolveAdmittedRunSessionFile(params: {
317
+ agentId: string;
318
+ sessionId: string;
319
+ sessionFile?: string;
320
+ sessionKey?: string;
321
+ storePath?: string;
322
+ }): string | undefined {
323
+ if (params.sessionKey?.trim()) {
324
+ return params.sessionKey.trim();
325
+ }
326
+ return params.sessionFile;
327
+ }
328
+
329
+ export async function handleReplyAgentRunError(
330
+ error: unknown,
331
+ context: {
332
+ cfg: OpenClawConfig;
333
+ resolveVisibleReplyDelivery: () => Promise<boolean>;
334
+ isHeartbeat: boolean;
335
+ isRestartRecoveryArmed: () => boolean;
336
+ replyOperation: ReplyOperation;
337
+ resolvedVerboseLevel: VerboseLevel;
338
+ returnWithQueuedFollowupDrain: <T>(value: T) => T;
339
+ sessionCtx: TemplateContext;
340
+ },
341
+ ): Promise<ReplyPayload | undefined> {
342
+ const {
343
+ cfg,
344
+ resolveVisibleReplyDelivery,
345
+ isHeartbeat,
346
+ isRestartRecoveryArmed,
347
+ replyOperation,
348
+ resolvedVerboseLevel,
349
+ returnWithQueuedFollowupDrain,
350
+ sessionCtx,
351
+ } = context;
352
+
353
+ if (isReplyOperationSuperseded(replyOperation)) {
354
+ return { text: SILENT_REPLY_TOKEN };
355
+ }
356
+ if (
357
+ replyOperation.result?.kind === "aborted" &&
358
+ replyOperation.result.code === "aborted_by_user"
359
+ ) {
360
+ return returnWithQueuedFollowupDrain({ text: SILENT_REPLY_TOKEN });
361
+ }
362
+ if (
363
+ replyOperation.result?.kind === "aborted" &&
364
+ replyOperation.result.code === "aborted_for_restart"
365
+ ) {
366
+ if (isRestartRecoveryArmed()) {
367
+ return returnWithQueuedFollowupDrain({ text: SILENT_REPLY_TOKEN });
368
+ }
369
+ return returnWithQueuedFollowupDrain(
370
+ markReplyPayloadForSourceSuppressionDelivery({
371
+ text: RESTART_LIFECYCLE_REPLY_TEXT,
372
+ }),
373
+ );
374
+ }
375
+ if (error instanceof GatewayDrainingError) {
376
+ replyOperation.fail("gateway_draining", error);
377
+ return returnWithQueuedFollowupDrain(
378
+ markReplyPayloadForSourceSuppressionDelivery({
379
+ text: RESTART_LIFECYCLE_REPLY_TEXT,
380
+ }),
381
+ );
382
+ }
383
+ if (error instanceof CommandLaneClearedError) {
384
+ replyOperation.fail("command_lane_cleared", error);
385
+ return returnWithQueuedFollowupDrain(
386
+ markReplyPayloadForSourceSuppressionDelivery({
387
+ text: RESTART_LIFECYCLE_REPLY_TEXT,
388
+ }),
389
+ );
390
+ }
391
+ const knownFailurePayload = buildKnownAgentRunFailureReplyPayload({
392
+ err: error,
393
+ sessionCtx,
394
+ resolvedVerboseLevel,
395
+ cfg,
396
+ });
397
+ if (knownFailurePayload) {
398
+ replyOperation.fail("run_failed", error);
399
+ return returnWithQueuedFollowupDrain(knownFailurePayload);
400
+ }
401
+ const visibleReplyDelivered = await resolveVisibleReplyDelivery();
402
+ if (!isHeartbeat && visibleReplyDelivered && !replyOperation.abortSignal.aborted) {
403
+ replyOperation.fail("run_failed", error);
404
+ return returnWithQueuedFollowupDrain(
405
+ buildTerminalAgentRunFailureReplyPayload({
406
+ visibleReplyDelivered: true,
407
+ sessionCtx,
408
+ cfg,
409
+ }),
410
+ );
411
+ }
412
+ replyOperation.fail("run_failed", error);
413
+ // Keep the followup queue moving even when an unexpected exception escapes
414
+ // the run path; the caller still receives the original error.
415
+ returnWithQueuedFollowupDrain(undefined);
416
+ throw error;
417
+ }
418
+
419
+ export async function cleanupReplyAgentRun(context: {
420
+ blockReplyPipeline: BlockReplyPipeline | null;
421
+ clearRestartRecoveryDeliveryClaim: () => Promise<void>;
422
+ providedReplyOperation: ReplyOperation | undefined;
423
+ queueKey: string;
424
+ replyOperation: ReplyOperation;
425
+ runFollowupTurn: (queued: FollowupRun) => Promise<void>;
426
+ sessionKey: string | undefined;
427
+ shouldDrainQueuedFollowupsAfterClear: boolean;
428
+ typing: TypingController;
429
+ }): Promise<void> {
430
+ const {
431
+ blockReplyPipeline,
432
+ clearRestartRecoveryDeliveryClaim,
433
+ providedReplyOperation,
434
+ queueKey,
435
+ replyOperation,
436
+ runFollowupTurn,
437
+ sessionKey,
438
+ shouldDrainQueuedFollowupsAfterClear,
439
+ typing,
440
+ } = context;
441
+
442
+ try {
443
+ await clearRestartRecoveryDeliveryClaim();
444
+ } catch (error) {
445
+ logVerbose(
446
+ `failed to clear restart recovery delivery context for ${sessionKey ?? "unknown"}: ${String(
447
+ error,
448
+ )}`,
449
+ );
450
+ }
451
+ if (shouldDrainQueuedFollowupsAfterClear) {
452
+ scheduleFollowupDrainAfterReplyOperationClear({
453
+ operation: replyOperation,
454
+ queueKey,
455
+ runFollowup: runFollowupTurn,
456
+ });
457
+ if (!providedReplyOperation) {
458
+ replyOperation.complete();
459
+ }
460
+ } else if (!providedReplyOperation) {
461
+ replyOperation.complete();
462
+ }
463
+ blockReplyPipeline?.stop();
464
+ typing.markRunComplete();
465
+ // Safety net: the dispatcher's onIdle callback normally fires
466
+ // markDispatchIdle(), but if the dispatcher exits early, errors,
467
+ // or the reply path doesn't go through it cleanly, the second
468
+ // signal never fires and the typing keepalive loop runs forever.
469
+ // Repeated completion signals are harmless: cleanup() is guarded by
470
+ // the typing controller's sealed flag.
471
+ typing.markDispatchIdle();
472
+ }
473
+
474
+ export type RunReplyAgentParams = {
475
+ commandBody: string;
476
+ transcriptCommandBody?: string;
477
+ followupRun: FollowupRun;
478
+ queueKey: string;
479
+ resolvedQueue: QueueSettings;
480
+ shouldSteer: boolean;
481
+ shouldFollowup: boolean;
482
+ queueAdmissionState?: "empty" | "steering" | "ready";
483
+ isActive: boolean;
484
+ isRunActive?: () => boolean;
485
+ opts?: InternalGetReplyOptions;
486
+ typing: TypingController;
487
+ sessionEntry?: SessionEntry;
488
+ sessionStore?: Record<string, SessionEntry>;
489
+ sessionKey?: string;
490
+ runtimePolicySessionKey?: string;
491
+ storePath?: string;
492
+ defaultModel: string;
493
+ resolvedVerboseLevel: VerboseLevel;
494
+ toolProgressDetail?: "explain" | "raw";
495
+ isNewSession: boolean;
496
+ blockStreamingEnabled: boolean;
497
+ blockReplyChunking?: {
498
+ minChars: number;
499
+ maxChars: number;
500
+ breakPreference: "paragraph" | "newline" | "sentence";
501
+ flushOnParagraph?: boolean;
502
+ };
503
+ resolvedBlockStreamingBreak: "text_end" | "message_end";
504
+ sessionCtx: TemplateContext;
505
+ shouldInjectGroupIntro: boolean;
506
+ typingMode: TypingMode;
507
+ resetTriggered?: boolean;
508
+ replyThreadingOverride?: TemplateContext["ReplyThreading"];
509
+ replyOperation?: ReplyOperation;
510
+ };
src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts ADDED
@@ -0,0 +1,716 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Tests direct runtime config overrides passed into agent runner execution.
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { OAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js";
6
+ import { FailoverError } from "../../agents/failover-error.js";
7
+ import { SessionManager } from "../../agents/sessions/session-manager.js";
8
+ import {
9
+ appendTranscriptMessage,
10
+ loadSessionEntry,
11
+ replaceSessionEntry,
12
+ } from "../../config/sessions/session-accessor.js";
13
+ import type { SessionEntry } from "../../config/sessions/types.js";
14
+ import {
15
+ clearMemoryPluginState,
16
+ registerMemoryCapability,
17
+ } from "../../plugins/memory-state.test-fixtures.js";
18
+ import { withTestDir } from "../../test-helpers/temp-dir.js";
19
+ import { withReplyDispatcher } from "../dispatch-dispatcher.js";
20
+ import { getReplyPayloadMetadata } from "../reply-payload.js";
21
+ import type { TemplateContext } from "../templating.js";
22
+ import { SILENT_REPLY_TOKEN } from "../tokens.js";
23
+ import type { ReplyPayload } from "../types.js";
24
+ import { createTestFollowupRun, withTestModelContextTokens } from "./agent-runner.test-fixtures.js";
25
+ import type { QueueSettings } from "./queue.js";
26
+ import { createReplyDispatcher } from "./reply-dispatcher.js";
27
+ import type { ReplyDispatchKind } from "./reply-dispatcher.types.js";
28
+ import {
29
+ REPLY_OPERATION_RUN_STATE,
30
+ resolveReplyOperationAgentTurn,
31
+ type ReplyOperationRunState,
32
+ } from "./reply-operation-run-state.js";
33
+ import type { ReplyOperation } from "./reply-run-registry.js";
34
+ import { createMockReplyOperation, createMockTypingController } from "./test-helpers.js";
35
+
36
+ const freshCfg = { runtimeFresh: true };
37
+ const staleCfg = {
38
+ runtimeFresh: false,
39
+ skills: {
40
+ entries: {
41
+ whisper: {
42
+ apiKey: { source: "env" as const, provider: "default", id: "OPENAI_API_KEY" },
43
+ },
44
+ },
45
+ },
46
+ };
47
+ const sentinelError = new Error("stop-after-preflight");
48
+
49
+ const resolveQueuedReplyExecutionConfigMock = vi.fn();
50
+ const resolveReplyToModeMock = vi.fn();
51
+ const createReplyToModeFilterForChannelMock = vi.fn();
52
+ const createReplyMediaContextMock = vi.fn();
53
+ const createReplyMediaPathNormalizerMock = vi.fn();
54
+ const runSessionCompactionIfNeededMock = vi.fn();
55
+ const runMemoryFlushIfNeededMock = vi.fn();
56
+ const executeAgentTurnMock = vi.fn();
57
+ const resetReplyRunSessionMock = vi.fn();
58
+ const enqueueFollowupRunMock = vi.fn();
59
+
60
+ vi.mock("./agent-runner-utils.js", async () => {
61
+ const actual =
62
+ await vi.importActual<typeof import("./agent-runner-utils.js")>("./agent-runner-utils.js");
63
+ return {
64
+ ...actual,
65
+ resolveQueuedReplyExecutionConfig: (...args: unknown[]) =>
66
+ resolveQueuedReplyExecutionConfigMock(...args),
67
+ };
68
+ });
69
+
70
+ vi.mock("./reply-threading.js", async () => {
71
+ const actual =
72
+ await vi.importActual<typeof import("./reply-threading.js")>("./reply-threading.js");
73
+ return {
74
+ ...actual,
75
+ resolveReplyToMode: (...args: unknown[]) => resolveReplyToModeMock(...args),
76
+ createReplyToModeFilterForChannel: (...args: unknown[]) =>
77
+ createReplyToModeFilterForChannelMock(...args),
78
+ };
79
+ });
80
+
81
+ vi.mock("./reply-media-paths.js", () => ({
82
+ createReplyMediaContext: (...args: unknown[]) => {
83
+ createReplyMediaContextMock(...args);
84
+ return {
85
+ normalizePayload: createReplyMediaPathNormalizerMock(...args),
86
+ };
87
+ },
88
+ createReplyMediaPathNormalizer: (...args: unknown[]) =>
89
+ createReplyMediaPathNormalizerMock(...args),
90
+ }));
91
+
92
+ vi.mock("./agent-runner-memory.js", () => ({
93
+ runSessionCompactionIfNeeded: (...args: unknown[]) => runSessionCompactionIfNeededMock(...args),
94
+ runMemoryFlushIfNeeded: (...args: unknown[]) => runMemoryFlushIfNeededMock(...args),
95
+ }));
96
+
97
+ vi.mock("./agent-runner-execution.js", async () => {
98
+ const actual = await vi.importActual<typeof import("./agent-runner-execution.js")>(
99
+ "./agent-runner-execution.js",
100
+ );
101
+ return {
102
+ ...actual,
103
+ executeAgentTurn: (...args: unknown[]) => executeAgentTurnMock(...args),
104
+ };
105
+ });
106
+
107
+ vi.mock("./agent-runner-session-reset.js", async () => {
108
+ const actual = await vi.importActual<typeof import("./agent-runner-session-reset.js")>(
109
+ "./agent-runner-session-reset.js",
110
+ );
111
+ return {
112
+ ...actual,
113
+ resetReplyRunSession: (...args: unknown[]) => resetReplyRunSessionMock(...args),
114
+ };
115
+ });
116
+
117
+ vi.mock("./queue.js", async () => {
118
+ const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
119
+ return {
120
+ ...actual,
121
+ enqueueFollowupRun: (...args: unknown[]) => enqueueFollowupRunMock(...args),
122
+ };
123
+ });
124
+
125
+ const { runReplyAgent } = await import("./agent-runner.js");
126
+
127
+ function createTelegramSessionCtx(): TemplateContext {
128
+ return {
129
+ Provider: "telegram",
130
+ OriginatingChannel: "telegram",
131
+ OriginatingTo: "12345",
132
+ AccountId: "default",
133
+ ChatType: "dm",
134
+ MessageSid: "msg-1",
135
+ } as unknown as TemplateContext;
136
+ }
137
+
138
+ type TestReplyOperation = ReplyOperation & {
139
+ setPhase: ReturnType<typeof vi.fn<ReplyOperation["setPhase"]>>;
140
+ };
141
+
142
+ function createReplyOperation(): TestReplyOperation {
143
+ const { replyOperation } = createMockReplyOperation({ key: "test", sessionId: "session-1" });
144
+ return Object.assign(replyOperation, {
145
+ phase: "queued" as const,
146
+ setPhase: vi.fn<ReplyOperation["setPhase"]>(),
147
+ hasOwnedSessionId: vi.fn(() => false),
148
+ captureOwnedSessionIds: vi.fn(() => new Set()),
149
+ });
150
+ }
151
+
152
+ function createDirectRuntimeReplyParams({
153
+ shouldFollowup,
154
+ isActive,
155
+ }: {
156
+ shouldFollowup: boolean;
157
+ isActive: boolean;
158
+ }) {
159
+ const followupRun = createTestFollowupRun({
160
+ sessionId: "session-1",
161
+ sessionKey: "agent:main:telegram:default:direct:test",
162
+ messageProvider: "telegram",
163
+ config: staleCfg,
164
+ provider: "openai",
165
+ model: "gpt-5.4",
166
+ });
167
+ const resolvedQueue = { mode: "interrupt" } as QueueSettings;
168
+ const replyParams: Parameters<typeof runReplyAgent>[0] = {
169
+ commandBody: "hello",
170
+ followupRun,
171
+ queueKey: "main",
172
+ resolvedQueue,
173
+ shouldSteer: false,
174
+ shouldFollowup,
175
+ isActive,
176
+ typing: createMockTypingController(),
177
+ sessionCtx: createTelegramSessionCtx(),
178
+ defaultModel: "openai/gpt-5.4",
179
+ resolvedVerboseLevel: "off",
180
+ isNewSession: false,
181
+ blockStreamingEnabled: false,
182
+ resolvedBlockStreamingBreak: "message_end",
183
+ shouldInjectGroupIntro: false,
184
+ typingMode: "instant",
185
+ };
186
+
187
+ return { followupRun, resolvedQueue, replyParams };
188
+ }
189
+
190
+ function requireResolveQueuedReplyExecutionConfigCall(index = 0) {
191
+ const call = resolveQueuedReplyExecutionConfigMock.mock.calls[index] as
192
+ | [
193
+ unknown,
194
+ {
195
+ originatingChannel?: string;
196
+ messageProvider?: string;
197
+ },
198
+ ]
199
+ | undefined;
200
+ if (!call) {
201
+ throw new Error(`resolveQueuedReplyExecutionConfig call ${index} missing`);
202
+ }
203
+ return call;
204
+ }
205
+
206
+ type MockCallSource = {
207
+ mock: {
208
+ calls: unknown[][];
209
+ };
210
+ };
211
+
212
+ function requireMaintenanceCall(mock: MockCallSource, name: string, index = 0) {
213
+ const call = mock.mock.calls[index]?.[0] as
214
+ | {
215
+ cfg?: unknown;
216
+ followupRun?: unknown;
217
+ sessionKey?: string;
218
+ runtimePolicySessionKey?: string;
219
+ }
220
+ | undefined;
221
+ if (!call) {
222
+ throw new Error(`${name} call ${index} missing`);
223
+ }
224
+ return call;
225
+ }
226
+
227
+ type PreflightParams = Parameters<
228
+ typeof import("./agent-runner-memory.js").runSessionCompactionIfNeeded
229
+ >[0];
230
+
231
+ async function runRequiredCheckpoint(params: PreflightParams) {
232
+ if (!params.beforeCompaction) {
233
+ throw new Error("Expected the required preflight checkpoint");
234
+ }
235
+ return params.beforeCompaction(
236
+ params.sessionEntry ?? { sessionId: params.followupRun.run.sessionId, updatedAt: 1 },
237
+ );
238
+ }
239
+
240
+ describe("runReplyAgent runtime config", () => {
241
+ beforeEach(() => {
242
+ resolveQueuedReplyExecutionConfigMock.mockReset();
243
+ resolveReplyToModeMock.mockReset();
244
+ createReplyToModeFilterForChannelMock.mockReset();
245
+ createReplyMediaContextMock.mockReset();
246
+ createReplyMediaPathNormalizerMock.mockReset();
247
+ runSessionCompactionIfNeededMock.mockReset();
248
+ runMemoryFlushIfNeededMock.mockReset();
249
+ executeAgentTurnMock.mockReset();
250
+ resetReplyRunSessionMock.mockReset();
251
+ enqueueFollowupRunMock.mockReset();
252
+
253
+ resolveQueuedReplyExecutionConfigMock.mockResolvedValue(freshCfg);
254
+ resolveReplyToModeMock.mockReturnValue("all");
255
+ createReplyToModeFilterForChannelMock.mockReturnValue((payload: unknown) => payload);
256
+ createReplyMediaPathNormalizerMock.mockReturnValue((payload: unknown) => payload);
257
+ runSessionCompactionIfNeededMock.mockImplementation(async (params: PreflightParams) => {
258
+ await runRequiredCheckpoint(params);
259
+ throw sentinelError;
260
+ });
261
+ runMemoryFlushIfNeededMock.mockResolvedValue({ sessionEntry: undefined, outcome: "skipped" });
262
+ executeAgentTurnMock.mockResolvedValue({
263
+ runId: "runtime-config-test",
264
+ outcome: { kind: "rejected", payload: { text: "main reply" } },
265
+ });
266
+ resetReplyRunSessionMock.mockResolvedValue(false);
267
+ });
268
+
269
+ it("resolves direct reply runs before early helpers read config", async () => {
270
+ const { followupRun, replyParams } = createDirectRuntimeReplyParams({
271
+ shouldFollowup: false,
272
+ isActive: false,
273
+ });
274
+
275
+ await expect(runReplyAgent(replyParams)).rejects.toBe(sentinelError);
276
+
277
+ expect(followupRun.run.config).toBe(freshCfg);
278
+ expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledTimes(1);
279
+ const [configArg, configContextArg] = requireResolveQueuedReplyExecutionConfigCall();
280
+ expect(configArg).toBe(staleCfg);
281
+ expect(configContextArg.originatingChannel).toBe("telegram");
282
+ expect(configContextArg.messageProvider).toBe("telegram");
283
+ expect(resolveReplyToModeMock).toHaveBeenCalledWith(freshCfg, "telegram", "default", "dm");
284
+ expect(createReplyMediaContextMock).toHaveBeenCalledWith({
285
+ cfg: freshCfg,
286
+ agentId: "main",
287
+ sessionKey: undefined,
288
+ workspaceDir: followupRun.run.workspaceDir,
289
+ messageProvider: "telegram",
290
+ accountId: undefined,
291
+ groupId: undefined,
292
+ groupChannel: undefined,
293
+ groupSpace: undefined,
294
+ requesterSenderId: undefined,
295
+ requesterSenderName: undefined,
296
+ requesterSenderUsername: undefined,
297
+ requesterSenderE164: undefined,
298
+ });
299
+ expect(runSessionCompactionIfNeededMock).toHaveBeenCalledTimes(1);
300
+ expect(runMemoryFlushIfNeededMock).toHaveBeenCalledTimes(1);
301
+ expect(runSessionCompactionIfNeededMock.mock.invocationCallOrder[0]).toBeLessThan(
302
+ runMemoryFlushIfNeededMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
303
+ );
304
+ const memoryCall = requireMaintenanceCall(runMemoryFlushIfNeededMock, "runMemoryFlushIfNeeded");
305
+ expect(memoryCall.cfg).toBe(freshCfg);
306
+ expect(memoryCall.followupRun).toBe(followupRun);
307
+ const preflightCall = requireMaintenanceCall(
308
+ runSessionCompactionIfNeededMock,
309
+ "runSessionCompactionIfNeeded",
310
+ );
311
+ expect(preflightCall.cfg).toBe(freshCfg);
312
+ expect(preflightCall.followupRun).toBe(followupRun);
313
+ });
314
+
315
+ it("passes the derived runtime-policy key to pre-run maintenance", async () => {
316
+ const { followupRun, replyParams } = createDirectRuntimeReplyParams({
317
+ shouldFollowup: false,
318
+ isActive: false,
319
+ });
320
+ const runtimePolicySessionKey = "agent:main:telegram:default:direct:test";
321
+ followupRun.run.sessionKey = "agent:main:main";
322
+ followupRun.run.runtimePolicySessionKey = runtimePolicySessionKey;
323
+ replyParams.sessionKey = "agent:main:main";
324
+ replyParams.runtimePolicySessionKey = runtimePolicySessionKey;
325
+
326
+ await expect(runReplyAgent(replyParams)).rejects.toBe(sentinelError);
327
+
328
+ const preflightCall = requireMaintenanceCall(
329
+ runSessionCompactionIfNeededMock,
330
+ "runSessionCompactionIfNeeded",
331
+ );
332
+ expect(preflightCall.sessionKey).toBe("agent:main:main");
333
+ expect(preflightCall.runtimePolicySessionKey).toBe(runtimePolicySessionKey);
334
+ const memoryCall = requireMaintenanceCall(runMemoryFlushIfNeededMock, "runMemoryFlushIfNeeded");
335
+ expect(memoryCall.sessionKey).toBe("agent:main:main");
336
+ expect(memoryCall.runtimePolicySessionKey).toBe(runtimePolicySessionKey);
337
+ });
338
+
339
+ it("continues the main reply after a recorded memory-flush failure", async () => {
340
+ const { replyParams } = createDirectRuntimeReplyParams({
341
+ shouldFollowup: false,
342
+ isActive: false,
343
+ });
344
+ const onBlockReply = vi.fn();
345
+ const replyOperation = createReplyOperation();
346
+ replyParams.opts = { sourceReplyDeliveryMode: "message_tool_only", onBlockReply };
347
+ replyParams.replyOperation = replyOperation;
348
+ resolveQueuedReplyExecutionConfigMock.mockResolvedValue({
349
+ ...freshCfg,
350
+ agents: { defaults: { compaction: { notifyUser: true } } },
351
+ });
352
+ runSessionCompactionIfNeededMock.mockImplementation(runRequiredCheckpoint);
353
+ runMemoryFlushIfNeededMock.mockImplementation(
354
+ async (params: {
355
+ replyOperation: ReplyOperation;
356
+ onVisibleErrorPayloads?: (payloads: Array<{ text?: string; isError?: boolean }>) => void;
357
+ }) => {
358
+ params.replyOperation.setPhase("memory_flushing");
359
+ params.onVisibleErrorPayloads?.([
360
+ { text: "⚠️ memory flush preparation failed", isError: true },
361
+ ]);
362
+ return { sessionEntry: undefined, outcome: "failed" };
363
+ },
364
+ );
365
+
366
+ const result = await runReplyAgent(replyParams);
367
+
368
+ expect(result).toEqual({ text: "main reply" });
369
+ expect(onBlockReply).not.toHaveBeenCalled();
370
+ expect(executeAgentTurnMock).toHaveBeenCalledOnce();
371
+ expect(replyOperation.setPhase).toHaveBeenLastCalledWith("running");
372
+ });
373
+
374
+ it("preserves conversation without running byte-forced optional memory work before reply", async () => {
375
+ const memory = await vi.importActual<typeof import("./agent-runner-memory.js")>(
376
+ "./agent-runner-memory.js",
377
+ );
378
+ const reset = await vi.importActual<typeof import("./agent-runner-session-reset.js")>(
379
+ "./agent-runner-session-reset.js",
380
+ );
381
+ runMemoryFlushIfNeededMock.mockImplementation(memory.runMemoryFlushIfNeeded);
382
+ runSessionCompactionIfNeededMock.mockImplementation(memory.runSessionCompactionIfNeeded);
383
+ resetReplyRunSessionMock.mockImplementation(reset.resetReplyRunSession);
384
+ registerMemoryCapability("memory-core", {
385
+ flushPlanResolver: () => ({
386
+ softThresholdTokens: 4_000,
387
+ forceFlushTranscriptBytes: 1,
388
+ reserveTokensFloor: 20_000,
389
+ prompt: "Save durable notes. NO_REPLY",
390
+ systemPrompt: "Write memory to memory/notes.md.",
391
+ relativePath: "memory/notes.md",
392
+ }),
393
+ });
394
+ try {
395
+ await withTestDir({ prefix: "openclaw-direct-runtime-" }, async (tempDir) => {
396
+ const { replyParams, followupRun } = createDirectRuntimeReplyParams({
397
+ shouldFollowup: false,
398
+ isActive: false,
399
+ });
400
+ const sessionKey = "agent:main:telegram:default:direct:test";
401
+ const sessionEntry: SessionEntry = {
402
+ sessionId: "session-1",
403
+ lifecycleRevision: "before-maintenance",
404
+ updatedAt: 1,
405
+ totalTokens: 100,
406
+ totalTokensFresh: true,
407
+ totalTokensVersion: 1,
408
+ compactionCount: 4,
409
+ memoryFlush: { kind: "failed", failureCount: 2 },
410
+ };
411
+ const sessionStore = { [sessionKey]: sessionEntry };
412
+ const storePath = join(tempDir, "sessions.json");
413
+ const scope = { agentId: "main", sessionId: sessionEntry.sessionId, sessionKey, storePath };
414
+ await replaceSessionEntry(scope, sessionEntry);
415
+ const olderMessage = { role: "user", content: "Remember the older marker: amber-orchid" };
416
+ const messages = [
417
+ olderMessage,
418
+ { role: "assistant", content: "Noted." },
419
+ ...Array.from({ length: 3 }, (_, index) => [
420
+ { role: "user", content: `Recent question ${index}` },
421
+ { role: "assistant", content: `Recent answer ${index}` },
422
+ ]).flat(),
423
+ ];
424
+ for (const message of messages) {
425
+ await appendTranscriptMessage(scope, { message });
426
+ }
427
+ // A file blocks the memory directory: optional maintenance fails without token pressure.
428
+ await writeFile(join(tempDir, "memory"), "not a directory");
429
+ followupRun.run.workspaceDir = tempDir;
430
+ followupRun.run.provider = "anthropic";
431
+ followupRun.run.model = "claude-sonnet-4-6";
432
+ replyParams.sessionKey = sessionKey;
433
+ replyParams.storePath = storePath;
434
+ replyParams.sessionEntry = sessionEntry;
435
+ replyParams.sessionStore = sessionStore;
436
+ resolveQueuedReplyExecutionConfigMock.mockResolvedValue(
437
+ withTestModelContextTokens({
438
+ cfg: { agents: { defaults: { compaction: { notifyUser: true } } } },
439
+ followupRun,
440
+ defaultModel: replyParams.defaultModel,
441
+ contextTokens: 100_000,
442
+ }),
443
+ );
444
+ const onBlockReply = vi.fn();
445
+ replyParams.opts = { onBlockReply };
446
+ const replyOperation = createReplyOperation();
447
+ replyParams.replyOperation = replyOperation;
448
+ executeAgentTurnMock.mockImplementation(async () => {
449
+ expect(SessionManager.open(scope).buildSessionContext().messages).toEqual(
450
+ messages.map((message) => expect.objectContaining(message)),
451
+ );
452
+ return {
453
+ runId: "runtime-config-test",
454
+ outcome: { kind: "rejected", payload: { text: "main reply" } },
455
+ };
456
+ });
457
+
458
+ const result = await runReplyAgent(replyParams);
459
+
460
+ expect(result).toEqual({ text: "main reply" });
461
+ expect(resetReplyRunSessionMock).not.toHaveBeenCalled();
462
+ expect(followupRun.run.sessionId).toBe(sessionEntry.sessionId);
463
+ expect(replyOperation.sessionId).toBe(sessionEntry.sessionId);
464
+ expect(loadSessionEntry(scope)).toMatchObject({
465
+ sessionId: sessionEntry.sessionId,
466
+ lifecycleRevision: sessionEntry.lifecycleRevision,
467
+ compactionCount: 4,
468
+ totalTokens: 100,
469
+ totalTokensFresh: true,
470
+ memoryFlush: { kind: "failed", failureCount: 2 },
471
+ });
472
+ expect(sessionStore[sessionKey]).toMatchObject({
473
+ lifecycleRevision: sessionEntry.lifecycleRevision,
474
+ compactionCount: 4,
475
+ totalTokens: 100,
476
+ });
477
+ expect(runMemoryFlushIfNeededMock).not.toHaveBeenCalled();
478
+ expect(runSessionCompactionIfNeededMock).toHaveBeenCalledOnce();
479
+ expect(onBlockReply).not.toHaveBeenCalled();
480
+ expect(executeAgentTurnMock).toHaveBeenCalledOnce();
481
+ });
482
+ } finally {
483
+ clearMemoryPluginState();
484
+ }
485
+ });
486
+
487
+ it("keeps the compacted session when preflight recovers an exhausted memory flush", async () => {
488
+ const { replyParams } = createDirectRuntimeReplyParams({
489
+ shouldFollowup: false,
490
+ isActive: false,
491
+ });
492
+ const sessionEntry = {
493
+ sessionId: "session-1",
494
+ updatedAt: 1,
495
+ compactionCount: 4,
496
+ };
497
+ replyParams.sessionEntry = sessionEntry;
498
+ runMemoryFlushIfNeededMock.mockResolvedValue({
499
+ sessionEntry,
500
+ outcome: "exhausted",
501
+ });
502
+ runSessionCompactionIfNeededMock.mockImplementation(async (params: PreflightParams) => {
503
+ expect(params.sessionEntry?.sessionId).toBe("session-1");
504
+ const checkpointed = await runRequiredCheckpoint(params);
505
+ return { ...checkpointed, compactionCount: 5 };
506
+ });
507
+
508
+ await expect(runReplyAgent(replyParams)).resolves.toEqual({ text: "main reply" });
509
+
510
+ expect(resetReplyRunSessionMock).not.toHaveBeenCalled();
511
+ expect(executeAgentTurnMock).toHaveBeenCalledOnce();
512
+ });
513
+
514
+ it.each(["context_overflow", "auth profile mismatch"])(
515
+ "surfaces required preflight failure (%s) after memory exhaustion without resetting",
516
+ async (reason) => {
517
+ const { replyParams } = createDirectRuntimeReplyParams({
518
+ shouldFollowup: false,
519
+ isActive: false,
520
+ });
521
+ runMemoryFlushIfNeededMock.mockResolvedValue({
522
+ sessionEntry: { sessionId: "session-1", updatedAt: 1, compactionCount: 4 },
523
+ outcome: "exhausted",
524
+ });
525
+ runSessionCompactionIfNeededMock.mockImplementation(async (params: PreflightParams) => {
526
+ await runRequiredCheckpoint(params);
527
+ throw new Error(`Preflight compaction required but failed: ${reason}`);
528
+ });
529
+
530
+ const result = await runReplyAgent(replyParams);
531
+
532
+ if (!result || Array.isArray(result)) {
533
+ throw new Error("expected a single preflight compaction failure reply payload");
534
+ }
535
+ expect(result.text).toContain("auto-compaction could not recover");
536
+ expect(getReplyPayloadMetadata(result)?.deliverDespiteSourceReplySuppression).toBe(true);
537
+ expect(resetReplyRunSessionMock).not.toHaveBeenCalled();
538
+ expect(executeAgentTurnMock).not.toHaveBeenCalled();
539
+ },
540
+ );
541
+
542
+ it.each(["abortByUser", "abortForRestart"] as const)(
543
+ "records %s during memory flush as cancellation without starting the main turn",
544
+ async (abortMethod) => {
545
+ const { replyParams } = createDirectRuntimeReplyParams({
546
+ shouldFollowup: false,
547
+ isActive: false,
548
+ });
549
+ const runState: ReplyOperationRunState = {};
550
+ replyParams.opts = { [REPLY_OPERATION_RUN_STATE]: runState };
551
+ runSessionCompactionIfNeededMock.mockImplementation(runRequiredCheckpoint);
552
+ runMemoryFlushIfNeededMock.mockImplementation(
553
+ async (params: { replyOperation: ReplyOperation }) => {
554
+ expect(params.replyOperation[abortMethod]()).toBe(true);
555
+ return { sessionEntry: undefined, outcome: "failed" };
556
+ },
557
+ );
558
+
559
+ const result = await runReplyAgent(replyParams);
560
+
561
+ expect(result).toMatchObject({
562
+ text:
563
+ abortMethod === "abortByUser"
564
+ ? SILENT_REPLY_TOKEN
565
+ : "⚠️ Gateway is restarting. Please wait a few seconds and try again.",
566
+ });
567
+ expect(resolveReplyOperationAgentTurn(runState)).toBe("cancelled");
568
+ expect(executeAgentTurnMock).not.toHaveBeenCalled();
569
+ },
570
+ );
571
+
572
+ it.each(["user", "restart"] as const)(
573
+ "records an aborted %s agent turn as cancellation",
574
+ async (reason) => {
575
+ const { replyParams } = createDirectRuntimeReplyParams({
576
+ shouldFollowup: false,
577
+ isActive: false,
578
+ });
579
+ const runState: ReplyOperationRunState = {};
580
+ replyParams.opts = { [REPLY_OPERATION_RUN_STATE]: runState };
581
+ runSessionCompactionIfNeededMock.mockResolvedValue(undefined);
582
+ executeAgentTurnMock.mockResolvedValue({
583
+ runId: "cancelled-runtime-config-test",
584
+ outcome: { kind: "aborted", reason },
585
+ });
586
+
587
+ await runReplyAgent(replyParams);
588
+
589
+ expect(resolveReplyOperationAgentTurn(runState)).toBe("cancelled");
590
+ },
591
+ );
592
+
593
+ it("surfaces known pre-run Codex usage-limit failures instead of dropping the reply", async () => {
594
+ const { replyParams } = createDirectRuntimeReplyParams({
595
+ shouldFollowup: false,
596
+ isActive: false,
597
+ });
598
+ const codexMessage =
599
+ "You've reached your Codex subscription usage limit. Codex did not return a reset time for this limit. Run /codex account for current usage details.";
600
+ runSessionCompactionIfNeededMock.mockRejectedValue(
601
+ new FailoverError(codexMessage, {
602
+ reason: "rate_limit",
603
+ provider: "openai",
604
+ model: "gpt-5.5",
605
+ }),
606
+ );
607
+ runMemoryFlushIfNeededMock.mockResolvedValue({ sessionEntry: undefined, outcome: "skipped" });
608
+
609
+ const result = await runReplyAgent(replyParams);
610
+
611
+ if (!result || Array.isArray(result)) {
612
+ throw new Error("expected a single usage-limit reply payload");
613
+ }
614
+ expect(result.text).toBe(`⚠️ ${codexMessage}`);
615
+ const metadata = getReplyPayloadMetadata(result);
616
+ expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
617
+ });
618
+
619
+ it("delivers known pre-run OAuth refresh failures instead of dropping the reply", async () => {
620
+ const { replyParams } = createDirectRuntimeReplyParams({
621
+ shouldFollowup: false,
622
+ isActive: false,
623
+ });
624
+ runSessionCompactionIfNeededMock.mockRejectedValue(
625
+ new OAuthRefreshFailureError({
626
+ provider: "openai",
627
+ message: "refresh_token_invalidated",
628
+ }),
629
+ );
630
+ const delivered = vi.fn<(payload: ReplyPayload, kind: ReplyDispatchKind) => void>();
631
+ const dispatcher = createReplyDispatcher({
632
+ deliver: async (payload, { kind }) => {
633
+ delivered(payload, kind);
634
+ },
635
+ });
636
+
637
+ const result = await withReplyDispatcher({
638
+ dispatcher,
639
+ run: async () => {
640
+ const payload = await runReplyAgent(replyParams);
641
+ if (!payload || Array.isArray(payload)) {
642
+ throw new Error("expected a single pre-run failure reply payload");
643
+ }
644
+ dispatcher.sendFinalReply(payload);
645
+ return payload;
646
+ },
647
+ });
648
+
649
+ const metadata = getReplyPayloadMetadata(result);
650
+ expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
651
+ expect(executeAgentTurnMock).not.toHaveBeenCalled();
652
+ expect(delivered).toHaveBeenCalledExactlyOnceWith(
653
+ expect.objectContaining({ text: expect.stringContaining("/login openai"), isError: true }),
654
+ "final",
655
+ );
656
+ expect(delivered.mock.calls[0]?.[0].presentation).toEqual({
657
+ blocks: [
658
+ {
659
+ type: "buttons",
660
+ buttons: [
661
+ {
662
+ label: "Sign in",
663
+ action: { type: "command", command: "/login openai" },
664
+ },
665
+ ],
666
+ },
667
+ ],
668
+ });
669
+ });
670
+
671
+ it("surfaces preflight compaction failures before the agent starts", async () => {
672
+ const { replyParams } = createDirectRuntimeReplyParams({
673
+ shouldFollowup: false,
674
+ isActive: false,
675
+ });
676
+ runSessionCompactionIfNeededMock.mockRejectedValue(
677
+ new Error("Preflight compaction required but failed: auth profile mismatch"),
678
+ );
679
+ runMemoryFlushIfNeededMock.mockResolvedValue({ sessionEntry: undefined, outcome: "skipped" });
680
+
681
+ const result = await runReplyAgent(replyParams);
682
+
683
+ if (!result || Array.isArray(result)) {
684
+ throw new Error("expected a single preflight compaction failure reply payload");
685
+ }
686
+ expect(result.text).toContain("Context is too large");
687
+ expect(result.text).toContain("auto-compaction could not recover");
688
+ expect(result.text).toContain("/compact");
689
+ expect(result.text).toContain("/new");
690
+ const metadata = getReplyPayloadMetadata(result);
691
+ expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
692
+ });
693
+
694
+ it("does not resolve secrets before the enqueue-followup queue path", async () => {
695
+ const { followupRun, resolvedQueue, replyParams } = createDirectRuntimeReplyParams({
696
+ shouldFollowup: true,
697
+ isActive: true,
698
+ });
699
+ const runState: ReplyOperationRunState = {};
700
+ replyParams.opts = { [REPLY_OPERATION_RUN_STATE]: runState };
701
+ enqueueFollowupRunMock.mockReturnValueOnce(true);
702
+
703
+ await expect(runReplyAgent(replyParams)).resolves.toBeUndefined();
704
+
705
+ expect(runState.admission).toEqual({ status: "accepted", mode: "followup" });
706
+ expect(resolveQueuedReplyExecutionConfigMock).not.toHaveBeenCalled();
707
+ expect(enqueueFollowupRunMock).toHaveBeenCalledTimes(1);
708
+ const enqueueCall = enqueueFollowupRunMock.mock.calls.at(0);
709
+ expect(enqueueCall?.[0]).toBe("main");
710
+ expect(enqueueCall?.[1]).toBe(followupRun);
711
+ expect(enqueueCall?.[2]).toBe(resolvedQueue);
712
+ expect(enqueueCall?.[3]).toBe("message-id");
713
+ expect(typeof enqueueCall?.[4]).toBe("function");
714
+ expect(enqueueCall?.[5]).toBe(false);
715
+ });
716
+ });
src/auto-reply/reply/agent-runner-embedded-candidate.ts ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
2
+ import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
3
+ import type {
4
+ CompactionAccountingFact,
5
+ RunEmbeddedAgentInternalParams,
6
+ } from "../../agents/embedded-agent-runner/run/internal-params.js";
7
+ import { runEmbeddedAgent } from "../../agents/embedded-agent.js";
8
+ import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
9
+ import { resolveOpenAIRuntimeProvider } from "../../agents/openai-routing.js";
10
+ import type { CompactionRequestBudget } from "../../agents/sessions/compaction/request-budget.js";
11
+ import { resolveGroupSessionKey } from "../../config/sessions.js";
12
+ import { logVerbose } from "../../globals.js";
13
+ import { resolveSessionPinnedHarnessId } from "../../sessions/agent-harness-session-key.js";
14
+ import {
15
+ isMarkdownCapableMessageChannel,
16
+ resolveMessageChannel,
17
+ } from "../../utils/message-channel.js";
18
+ import type { PartialReplyPayload } from "../get-reply-options.types.js";
19
+ import type { ReplyPayload } from "../types.js";
20
+ import { createAgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
21
+ import {
22
+ createAgentRunEventHandler,
23
+ type MessageToolDeliveryState,
24
+ } from "./agent-runner-event-handler.js";
25
+ import type { CompletedAgentAuthSelection } from "./agent-runner-execution.types.js";
26
+ import type { AgentFallbackCandidateCommonParams } from "./agent-runner-fallback-cycle.types.js";
27
+ import { buildEmbeddedRunExecutionParams } from "./agent-runner-utils.js";
28
+ import { resolveReplyOperationTerminationFields } from "./reply-operation-abort.js";
29
+ import { markReplyOperationGlobalLaneWaitProgress } from "./reply-run-registry.js";
30
+ import {
31
+ bindSourceReplyDeliveryRuntime,
32
+ readSourceReplyDeliveryRuntime,
33
+ } from "./source-reply-delivery-runtime.js";
34
+
35
+ export async function runEmbeddedFallbackCandidate(
36
+ params: AgentFallbackCandidateCommonParams & {
37
+ effectiveRun: AgentFallbackCandidateCommonParams["candidateRun"];
38
+ sessionRuntimeOverride?: string;
39
+ getLifecycleGeneration: () => string;
40
+ onLifecycleGeneration: (generation: string) => void;
41
+ allowTransientCooldownProbe?: boolean;
42
+ notifyUserAboutCompaction: boolean;
43
+ messageToolDeliveryState: MessageToolDeliveryState;
44
+ githubPublicationAvailable: boolean;
45
+ onCompactionFacts: (facts: {
46
+ accounting?: CompactionAccountingFact;
47
+ postCompactionModelAttempted: boolean;
48
+ }) => void;
49
+ },
50
+ ): Promise<{
51
+ result: Awaited<ReturnType<typeof runEmbeddedAgent>>;
52
+ maintenanceAuthProfile?: CompletedAgentAuthSelection;
53
+ compactionRequestBudget?: CompactionRequestBudget;
54
+ bootstrapPromptWarningSignaturesSeen: string[];
55
+ }> {
56
+ const turn = params.turn;
57
+ let maintenanceAuthProfile: CompletedAgentAuthSelection | undefined;
58
+ let compactionRequestBudget: CompactionRequestBudget | undefined;
59
+ const sourceReplyDeliveryRuntime = readSourceReplyDeliveryRuntime(params.candidateRun);
60
+ const candidateRun = {
61
+ ...params.candidateRun,
62
+ ...params.candidateFastMode,
63
+ thinkLevel: params.candidateThinkLevel,
64
+ };
65
+ const { embeddedContext, senderContext, runBaseParams } = await buildEmbeddedRunExecutionParams({
66
+ run: candidateRun,
67
+ replyRoute: turn.followupRun,
68
+ sessionCtx: turn.sessionCtx,
69
+ hasRepliedRef: turn.opts?.hasRepliedRef,
70
+ provider: params.provider,
71
+ runId: params.runId,
72
+ promptCacheKey: turn.opts?.promptCacheKey,
73
+ allowTransientCooldownProbe: params.allowTransientCooldownProbe,
74
+ model: params.model,
75
+ });
76
+ if (sourceReplyDeliveryRuntime) {
77
+ bindSourceReplyDeliveryRuntime(runBaseParams, sourceReplyDeliveryRuntime);
78
+ }
79
+ const agentHarnessPolicy = params.sessionRuntimeOverride
80
+ ? ({ runtime: params.sessionRuntimeOverride, runtimeSource: "model" } as const)
81
+ : resolveAgentHarnessPolicy({
82
+ provider: params.provider,
83
+ modelId: params.model,
84
+ config: params.runtimeConfig,
85
+ agentId: turn.followupRun.run.agentId,
86
+ sessionKey: turn.followupRun.run.runtimePolicySessionKey ?? turn.sessionKey,
87
+ });
88
+ const embeddedRunProvider = resolveOpenAIRuntimeProvider({
89
+ provider: params.provider,
90
+ harnessRuntime: agentHarnessPolicy.runtime,
91
+ authProfileProvider: runBaseParams.authProfileId?.split(":", 1)[0],
92
+ authProfileId: runBaseParams.authProfileId,
93
+ config: params.runtimeConfig,
94
+ workspaceDir: turn.followupRun.run.workspaceDir,
95
+ });
96
+ const embeddedRunHarnessOverride =
97
+ params.sessionRuntimeOverride ??
98
+ (agentHarnessPolicy.runtime === "openclaw" && embeddedRunProvider !== params.provider
99
+ ? "openclaw"
100
+ : undefined);
101
+ let attemptCompactionCount = 0;
102
+ let postCompactionModelAttempted = false;
103
+ let compactionAccounting: CompactionAccountingFact | undefined;
104
+ const lifecycleBackstop = createAgentLifecycleTerminalBackstop({
105
+ runId: params.runId,
106
+ sessionKey: turn.sessionKey,
107
+ getLifecycleGeneration: params.getLifecycleGeneration,
108
+ resolveTerminationFields: (error) =>
109
+ resolveReplyOperationTerminationFields(error, params.runAbortSignal, turn.replyOperation),
110
+ });
111
+ params.onLifecycleBackstop(lifecycleBackstop);
112
+ try {
113
+ // Profiler milestone. Exposes pre-dispatch delay without normal-path logging.
114
+ params.timing.logMilestoneIfSlow({
115
+ runId: params.runId,
116
+ sessionId: turn.followupRun.run.sessionId,
117
+ sessionKey: turn.sessionKey,
118
+ milestone: "before_embedded_run",
119
+ });
120
+ let eventHandler: ReturnType<typeof createAgentRunEventHandler> | undefined;
121
+ const result = await params.timing.measure("embedded_run", () => {
122
+ const embeddedRunParams: RunEmbeddedAgentInternalParams = {
123
+ preparedRunAdmission: params.preparedRunAdmission,
124
+ githubPublicationAvailable: params.githubPublicationAvailable,
125
+ ...embeddedContext,
126
+ messageActionTurnCapability: params.messageActionTurnCapability,
127
+ lifecycleGeneration: params.getLifecycleGeneration(),
128
+ allowGatewaySubagentBinding: true,
129
+ trigger: turn.isHeartbeat ? "heartbeat" : "user",
130
+ cronCreatorAuthorityCapability: turn.opts?.cronCreatorAuthorityCapability,
131
+ cronCreatorAuthorityUnavailableReason:
132
+ turn.opts?.turnAdoptionLifecycle?.cronCreatorAuthorityUnavailable,
133
+ groupId: resolveGroupSessionKey(turn.sessionCtx)?.id,
134
+ groupChannel:
135
+ normalizeOptionalString(turn.sessionCtx.GroupChannel) ??
136
+ normalizeOptionalString(turn.sessionCtx.GroupSubject),
137
+ groupSpace: normalizeOptionalString(turn.sessionCtx.GroupSpace),
138
+ ...senderContext,
139
+ ...runBaseParams,
140
+ contextWindow: turn.getActiveSessionEntry()?.contextWindow,
141
+ lane: params.runLane,
142
+ provider: embeddedRunProvider,
143
+ agentHarnessId: resolveSessionPinnedHarnessId(turn.getActiveSessionEntry()),
144
+ agentHarnessRuntimeOverride: embeddedRunHarnessOverride,
145
+ agentHarnessRuntimePreparationHint:
146
+ agentHarnessPolicy.runtimeSource !== "implicit" ? agentHarnessPolicy.runtime : undefined,
147
+ fastModeStartedAtMs: params.fastModeStartedAtMs,
148
+ fastModeAutoProgressState: params.fastModeAutoProgressState,
149
+ isFinalFallbackAttempt: params.isFinalFallbackAttempt,
150
+ sandboxSessionKey: turn.runtimePolicySessionKey,
151
+ prompt: turn.commandBody,
152
+ transcriptPrompt: turn.transcriptCommandBody,
153
+ media: turn.followupRun.media,
154
+ userTurnTranscriptRecorder: params.userTurnTranscriptRecorder,
155
+ contextEngineLogicalTurnLease: params.contextEngineLogicalTurnLease,
156
+ onContextEngineTurnCandidate: params.onContextEngineTurnCandidate,
157
+ currentInboundEventKind: turn.followupRun.currentInboundEventKind,
158
+ currentInboundContext: turn.followupRun.currentInboundContext,
159
+ explicitSkillSelections: turn.followupRun.explicitSkillSelections,
160
+ extraSystemPrompt: turn.followupRun.run.extraSystemPrompt,
161
+ sourceReplyDeliveryMode: turn.followupRun.run.sourceReplyDeliveryMode,
162
+ forceMessageTool: turn.followupRun.run.sourceReplyDeliveryMode === "message_tool_only",
163
+ // Heartbeat ambient routes are delivery context, never implicit message recipients.
164
+ // Omit false so subagent sessions keep their downstream default.
165
+ ...(turn.isHeartbeat ? { requireExplicitMessageTarget: true } : {}),
166
+ cleanupBundleMcpOnRunEnd: turn.opts?.cleanupBundleMcpOnRunEnd,
167
+ silentReplyPromptMode: turn.followupRun.run.silentReplyPromptMode,
168
+ suppressNextUserMessagePersistence: params.suppressQueuedUserPersistenceForCandidate,
169
+ onUserMessagePersisted: params.notifyUserMessagePersisted,
170
+ suppressTranscriptOnlyAssistantPersistence:
171
+ turn.followupRun.run.suppressTranscriptOnlyAssistantPersistence,
172
+ assistantErrorTranscript: params.assistantErrorTranscript,
173
+ authProfileFailurePolicy: params.authProfileFailurePolicy,
174
+ prepareAssistantTranscriptMessage: turn.opts?.prepareAssistantTranscriptMessage,
175
+ onAutoCompactionSucceeded: (count) => {
176
+ attemptCompactionCount = Math.max(attemptCompactionCount, count);
177
+ },
178
+ toolResultFormat: (() => {
179
+ const channel = resolveMessageChannel(turn.sessionCtx.Surface, turn.sessionCtx.Provider);
180
+ return !channel || isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
181
+ })(),
182
+ toolProgressDetail: turn.toolProgressDetail,
183
+ toolsAllow: turn.opts?.toolsAllow,
184
+ disableTools: turn.opts?.disableTools,
185
+ // Marks reply-owned policy; final attempt preparation binds its concrete route.
186
+ toolAuthorityFingerprint: turn.replyOperation?.toolAuthorityFingerprint,
187
+ enableHeartbeatTool: turn.opts?.enableHeartbeatTool,
188
+ forceHeartbeatTool: turn.opts?.forceHeartbeatTool,
189
+ bootstrapContextMode: turn.opts?.bootstrapContextMode,
190
+ bootstrapContextRunKind: params.bootstrapContextRunKind,
191
+ images: params.currentTurnImages.images,
192
+ imageOrder: params.currentTurnImages.imageOrder,
193
+ abortSignal: params.runAbortSignal,
194
+ replyOperation: turn.replyOperation,
195
+ deferTerminalLifecycle: true,
196
+ onAttemptStart: lifecycleBackstop.beginAttempt,
197
+ onCompactionAccounting: (fact) => {
198
+ compactionAccounting = fact;
199
+ },
200
+ onCompactionRequestBudget: (budget) => {
201
+ compactionRequestBudget = budget;
202
+ },
203
+ onDeferredLifecycleOwner: params.deferredLifecycle.adopt,
204
+ onDeferredLifecycleAbort: params.deferredLifecycle.abort,
205
+ onRetryWait: params.deferredLifecycle.beginRetryWait,
206
+ onExecutionStarted: (info) => {
207
+ if (info?.lifecycleGeneration) {
208
+ params.onLifecycleGeneration(info.lifecycleGeneration);
209
+ }
210
+ },
211
+ onExecutionPhase: (info) => {
212
+ if (info.phase === "model_call_started" && attemptCompactionCount > 0) {
213
+ postCompactionModelAttempted = true;
214
+ }
215
+ params.signalExecutionPhaseForTyping(info);
216
+ },
217
+ onLaneWait: ({ waiting }) => {
218
+ const replyOperation = turn.replyOperation;
219
+ if (waiting && replyOperation) {
220
+ markReplyOperationGlobalLaneWaitProgress(replyOperation);
221
+ }
222
+ },
223
+ blockReplyBreak: turn.resolvedBlockStreamingBreak,
224
+ blockReplyChunking: turn.blockReplyChunking,
225
+ // Subscriber callbacks are detached. Stage channel presentation before typing I/O.
226
+ onPartialReply: async (payload) => {
227
+ const classified = params.presentation.classifyStreamingPartial(payload);
228
+ if (classified.skip || !classified.text) {
229
+ return false;
230
+ }
231
+ const textForTyping = classified.text;
232
+ let didMaterialize = false;
233
+ let materializedText: string | undefined;
234
+ const partialPayload: PartialReplyPayload = {
235
+ get text() {
236
+ if (!didMaterialize) {
237
+ const sanitized = params.presentation.sanitizeStreamingText(textForTyping, false);
238
+ materializedText = sanitized.skip ? undefined : sanitized.text;
239
+ didMaterialize = true;
240
+ }
241
+ return materializedText;
242
+ },
243
+ mediaUrls: payload.mediaUrls,
244
+ };
245
+ const onPartialReply = turn.opts?.onPartialReply;
246
+ return await params.presentation.presentWithTyping(
247
+ turn.typingSignals.signalTextDelta(textForTyping),
248
+ () => (onPartialReply ? onPartialReply(partialPayload) : false),
249
+ );
250
+ },
251
+ onAssistantMessageStart: async () => {
252
+ await params.presentation.presentWithTyping(turn.typingSignals.signalMessageStart(), () =>
253
+ turn.opts?.onAssistantMessageStart?.(),
254
+ );
255
+ },
256
+ onReasoningStream:
257
+ turn.typingSignals.shouldStartOnReasoning || turn.opts?.onReasoningStream
258
+ ? async (payload) => {
259
+ if (turn.followupRun.run.silentExpected) {
260
+ return;
261
+ }
262
+ await params.presentation.presentWithTyping(
263
+ turn.typingSignals.signalReasoningDelta(),
264
+ () =>
265
+ turn.opts?.onReasoningStream?.({
266
+ text: payload.text,
267
+ mediaUrls: payload.mediaUrls,
268
+ isReasoningSnapshot: payload.isReasoningSnapshot,
269
+ requiresReasoningProgressOptIn: payload.requiresReasoningProgressOptIn,
270
+ }),
271
+ );
272
+ }
273
+ : undefined,
274
+ streamReasoningInNonStreamModes: turn.opts?.streamReasoningInNonStreamModes,
275
+ onReasoningEnd: turn.opts?.onReasoningEnd
276
+ ? async () => {
277
+ await turn.opts?.onReasoningEnd?.();
278
+ }
279
+ : undefined,
280
+ onAgentEvent: (event) => {
281
+ eventHandler ??= createAgentRunEventHandler({
282
+ turn,
283
+ lifecycleBackstop,
284
+ notifyAgentRunStart: params.notifyAgentRunStart,
285
+ sourceRepliesAreToolOnly:
286
+ (sourceReplyDeliveryRuntime?.currentMode ??
287
+ turn.followupRun.run.sourceReplyDeliveryMode) === "message_tool_only",
288
+ messageToolDeliveryState: params.messageToolDeliveryState,
289
+ provider: params.provider,
290
+ model: params.model,
291
+ runId: params.runId,
292
+ effectiveSessionId: params.effectiveRun.sessionId,
293
+ notifyUserAboutCompaction: params.notifyUserAboutCompaction,
294
+ onCompactionCompleted: () => {
295
+ attemptCompactionCount += 1;
296
+ return attemptCompactionCount;
297
+ },
298
+ });
299
+ return eventHandler(event);
300
+ },
301
+ // Flush-before-tool requires a handler even when regular block streaming is off.
302
+ onBlockReply: params.presentation.blockReplyHandler,
303
+ onBlockReplyFlush:
304
+ turn.blockStreamingEnabled && turn.blockReplyPipeline
305
+ ? async () => {
306
+ await turn.blockReplyPipeline?.flush({ force: true });
307
+ }
308
+ : undefined,
309
+ shouldEmitToolResult: turn.shouldEmitToolResult,
310
+ shouldEmitToolOutput: turn.shouldEmitToolOutput,
311
+ bootstrapPromptWarningSignaturesSeen: params.bootstrapPromptWarningSignaturesSeen,
312
+ bootstrapPromptWarningSignature:
313
+ params.bootstrapPromptWarningSignaturesSeen[
314
+ params.bootstrapPromptWarningSignaturesSeen.length - 1
315
+ ],
316
+ onToolResult: turn.opts?.onToolResult
317
+ ? (() => {
318
+ // Serialized delivery preserves tool result order across detached callbacks.
319
+ let toolResultChain: Promise<void> = Promise.resolve();
320
+ return (payload: ReplyPayload) => {
321
+ const delivery = toolResultChain.then(async () => {
322
+ turn.replyOperation?.recordActivity();
323
+ const { text, skip } = params.presentation.normalizeStreamingText(payload);
324
+ if (skip) {
325
+ return;
326
+ }
327
+ if (text !== undefined) {
328
+ await turn.typingSignals.signalTextDelta(text);
329
+ }
330
+ await turn.opts?.onToolResult?.({ ...payload, text });
331
+ });
332
+ // Keep later results best-effort while exposing this delivery to awaiting owners.
333
+ toolResultChain = delivery.catch((err: unknown) => {
334
+ logVerbose(`tool result delivery failed: ${String(err)}`);
335
+ });
336
+ const task = toolResultChain.finally(() => {
337
+ turn.pendingToolTasks.delete(task);
338
+ });
339
+ turn.pendingToolTasks.add(task);
340
+ return delivery;
341
+ };
342
+ })()
343
+ : undefined,
344
+ };
345
+ embeddedRunParams.onSuccessfulAuthProfile = (profileId) => {
346
+ maintenanceAuthProfile = {
347
+ authProfileId: profileId,
348
+ authProfileIdSource: profileId
349
+ ? profileId === runBaseParams.authProfileId
350
+ ? runBaseParams.authProfileIdSource
351
+ : "auto"
352
+ : undefined,
353
+ };
354
+ };
355
+ return runEmbeddedAgent(embeddedRunParams);
356
+ });
357
+ const resultCompactionCount = Math.max(0, result.meta?.agentMeta?.compactionCount ?? 0);
358
+ attemptCompactionCount = Math.max(attemptCompactionCount, resultCompactionCount);
359
+ return {
360
+ result,
361
+ maintenanceAuthProfile,
362
+ compactionRequestBudget,
363
+ bootstrapPromptWarningSignaturesSeen: resolveBootstrapWarningSignaturesSeen(
364
+ result.meta?.systemPromptReport,
365
+ ),
366
+ };
367
+ } finally {
368
+ // Runtime event/result counts are observable, but cannot prove a durable write target.
369
+ const accounting: CompactionAccountingFact | undefined =
370
+ compactionAccounting ??
371
+ (attemptCompactionCount > 0
372
+ ? {
373
+ kind: "presentation-only",
374
+ count: attemptCompactionCount,
375
+ currentContextSnapshot: { tokens: undefined },
376
+ }
377
+ : undefined);
378
+ params.onCompactionFacts({ accounting, postCompactionModelAttempted });
379
+ }
380
+ }
src/auto-reply/reply/agent-runner-error-handler.ts ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js";
2
+ import {
3
+ classifyOAuthRefreshFailure,
4
+ classifyOAuthRefreshFailureError,
5
+ } from "../../agents/auth-profiles/oauth-refresh-failure.js";
6
+ import {
7
+ isCompactionFailureError,
8
+ isLikelyContextOverflowError,
9
+ } from "../../agents/embedded-agent-helpers.js";
10
+ import { findCliTimeoutError, isFailoverError } from "../../agents/failover-error.js";
11
+ import {
12
+ GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
13
+ HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
14
+ renderControlUiAgentFailureCopy,
15
+ renderFailoverCodeUserCopy,
16
+ } from "../../agents/failover/user-copy.js";
17
+ import { isAgentHarnessPreflightError } from "../../agents/harness/errors.js";
18
+ import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
19
+ import { formatErrorMessage } from "../../infra/errors.js";
20
+ import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
21
+ import { defaultRuntime } from "../../runtime.js";
22
+ import type { ReplyPayload } from "../types.js";
23
+ import { createAgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
24
+ import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js";
25
+ import type { AgentTurnInternalResult, AgentTurnParams } from "./agent-runner-execution.types.js";
26
+ import {
27
+ buildAuthProfileFailoverFailureText,
28
+ buildExternalRunFailureReply,
29
+ isNonDirectConversationContext,
30
+ isVerboseFailureDetailEnabled,
31
+ markAgentRunFailureReplyPayload,
32
+ resolveExternalRunFailureTextForConversation,
33
+ resolveReplyFailureSummary,
34
+ resolveReplyFailoverFacts,
35
+ } from "./agent-runner-failure-reply.js";
36
+ import type { AgentFallbackCycleState } from "./agent-runner-fallback-cycle.js";
37
+ import type { AgentTurnTimingTracker } from "./agent-runner-turn-timing.js";
38
+ import {
39
+ buildRestartLifecycleReplyText,
40
+ resolveReplyOperationAbortReason,
41
+ resolveReplyOperationTerminationFields,
42
+ resolveRestartLifecycleError,
43
+ } from "./reply-operation-abort.js";
44
+
45
+ const MAX_LIVE_SWITCH_RETRIES = 2;
46
+
47
+ type ErrorAction =
48
+ | { kind: "retry"; liveModelSwitchError?: LiveSessionModelSwitchError }
49
+ | Extract<AgentTurnInternalResult, { kind: "final" | "aborted" }>;
50
+
51
+ export async function handleAgentExecutionError(params: {
52
+ turn: AgentTurnParams;
53
+ error: unknown;
54
+ runtimeConfig: AgentTurnParams["followupRun"]["run"]["config"];
55
+ runId: string;
56
+ state: AgentFallbackCycleState;
57
+ liveModelSwitchRetries: number;
58
+ shouldSurfaceToControlUi: boolean;
59
+ timing: AgentTurnTimingTracker;
60
+ modelPatch: { fail: (error: unknown) => Promise<void> };
61
+ }): Promise<ErrorAction> {
62
+ const turn = params.turn;
63
+ const err = params.error;
64
+ // A failed candidate leaves its backstop pending; settlement takes it before later work.
65
+ // This keeps session-override failures from being mislabeled as model failures.
66
+ const postCompactionModelFailure =
67
+ params.state.postCompactionModelAttempted && params.state.pendingLifecycleTerminal
68
+ ? true
69
+ : undefined;
70
+ const takePendingLifecycleTerminal = () => {
71
+ const terminal =
72
+ params.state.pendingLifecycleTerminal?.backstop ??
73
+ createAgentLifecycleTerminalBackstop({
74
+ runId: params.runId,
75
+ sessionKey: turn.sessionKey,
76
+ startedAt: params.state.turnStartedAtMs,
77
+ getLifecycleGeneration: () => params.state.lifecycleGeneration,
78
+ resolveTerminationFields: (error) =>
79
+ resolveReplyOperationTerminationFields(
80
+ error,
81
+ turn.replyOperation?.abortSignal ?? turn.opts?.abortSignal,
82
+ turn.replyOperation,
83
+ ),
84
+ });
85
+ params.state.pendingLifecycleTerminal = undefined;
86
+ return terminal;
87
+ };
88
+ const settleFailure = async (
89
+ payload: ReplyPayload,
90
+ ): Promise<Extract<AgentTurnInternalResult, { kind: "final" }>> => {
91
+ takePendingLifecycleTerminal().emit("error", err);
92
+ turn.replyOperation?.fail("run_failed", err);
93
+ await params.modelPatch.fail(err);
94
+ return {
95
+ kind: "final",
96
+ payload: markAgentRunFailureReplyPayload(payload),
97
+ postCompactionModelFailure,
98
+ };
99
+ };
100
+ const resolveReplyOperationAbortAction = (abortError: unknown): ErrorAction | undefined => {
101
+ const reason = resolveReplyOperationAbortReason(turn.replyOperation, abortError);
102
+ if (!reason) {
103
+ return undefined;
104
+ }
105
+ // Preserve signal-owned timeout attribution; only normalized restart/supersession need metadata.
106
+ const terminalMetadata = reason === "user" ? undefined : { aborted: true, stopReason: reason };
107
+ takePendingLifecycleTerminal().emit(
108
+ reason === "restart" ? "end" : "error",
109
+ abortError,
110
+ terminalMetadata,
111
+ );
112
+ return { kind: "aborted", reason };
113
+ };
114
+ const replyOperationAbortAction = resolveReplyOperationAbortAction(err);
115
+ if (replyOperationAbortAction) {
116
+ return replyOperationAbortAction;
117
+ }
118
+ if (err instanceof LiveSessionModelSwitchError) {
119
+ if (params.liveModelSwitchRetries <= MAX_LIVE_SWITCH_RETRIES) {
120
+ params.state.pendingLifecycleTerminal = undefined;
121
+ return { kind: "retry", liveModelSwitchError: err };
122
+ }
123
+ const visibleReplyDelivered = await turn.resolveVisibleReplyDelivery?.();
124
+ defaultRuntime.error(
125
+ `Live model switch failed after ${MAX_LIVE_SWITCH_RETRIES} retries ` +
126
+ `(${sanitizeForLog(err.provider)}/${sanitizeForLog(err.model)}). The requested model may be unavailable.`,
127
+ );
128
+ takePendingLifecycleTerminal().emit("error", err);
129
+ const switchErrorText = params.shouldSurfaceToControlUi
130
+ ? renderControlUiAgentFailureCopy(
131
+ "model switch could not be completed. The requested model may be temporarily unavailable.",
132
+ )
133
+ : isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel)
134
+ ? "⚠️ Agent failed before reply: model switch could not be completed. " +
135
+ "The requested model may be temporarily unavailable. Please try again shortly."
136
+ : "⚠️ Model switch could not be completed. The requested model may be temporarily unavailable. Please try again shortly.";
137
+ turn.replyOperation?.fail("run_failed", err);
138
+ await params.modelPatch.fail(err);
139
+ return {
140
+ kind: "final",
141
+ payload: markAgentRunFailureReplyPayload({
142
+ text: resolveExternalRunFailureTextForConversation({
143
+ text: switchErrorText,
144
+ visibleReplyDelivered,
145
+ sessionCtx: turn.sessionCtx,
146
+ isGenericRunnerFailure: !params.shouldSurfaceToControlUi,
147
+ cfg: turn.followupRun.run.config,
148
+ }),
149
+ }),
150
+ };
151
+ }
152
+ const message = formatErrorMessage(err);
153
+ params.timing.logIfSlow({
154
+ runId: params.runId,
155
+ sessionId: turn.followupRun.run.sessionId,
156
+ sessionKey: turn.sessionKey,
157
+ outcome: "error",
158
+ error: message,
159
+ });
160
+ // The exhausted preflight is deliberate, even if its diagnostic cause looks
161
+ // like HTTP/overload. Settle delivery and normal diagnostic policy without replay.
162
+ if (isAgentHarnessPreflightError(err)) {
163
+ const externalReply = buildExternalRunFailureReply(
164
+ { message, error: err },
165
+ {
166
+ includeDetails: isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel),
167
+ isHeartbeat: turn.isHeartbeat,
168
+ },
169
+ );
170
+ const text = resolveExternalRunFailureTextForConversation({
171
+ text: params.shouldSurfaceToControlUi
172
+ ? renderControlUiAgentFailureCopy(message)
173
+ : externalReply.text,
174
+ visibleReplyDelivered: await turn.resolveVisibleReplyDelivery?.(),
175
+ sessionCtx: turn.sessionCtx,
176
+ isGenericRunnerFailure: externalReply.isGenericRunnerFailure,
177
+ cfg: turn.followupRun.run.config,
178
+ });
179
+ return await settleFailure({ text });
180
+ }
181
+ const failoverFacts = resolveReplyFailoverFacts(err, message);
182
+ const failureSummary = resolveReplyFailureSummary({
183
+ error: err,
184
+ message,
185
+ reason: failoverFacts.reason,
186
+ attempts: isFailoverError(err) ? err.attempts : undefined,
187
+ });
188
+ const failoverReason = failoverFacts.reason;
189
+ const isBilling = failureSummary?.kind === "billing";
190
+ const isContextOverflow =
191
+ !isBilling && (failoverReason === "context_overflow" || isLikelyContextOverflowError(message));
192
+ const isCompactionFailure = !isBilling && isCompactionFailureError(message);
193
+ const oauthRefreshFailure =
194
+ classifyOAuthRefreshFailureError(err) ?? classifyOAuthRefreshFailure(message);
195
+ const hasAuthProfileFailoverFailure = buildAuthProfileFailoverFailureText(err) !== null;
196
+ const providerRequestError =
197
+ !isBilling &&
198
+ !oauthRefreshFailure &&
199
+ !hasAuthProfileFailoverFailure &&
200
+ !params.shouldSurfaceToControlUi
201
+ ? failoverFacts.providerRequestError
202
+ : undefined;
203
+ const restartLifecycleError = resolveRestartLifecycleError(err);
204
+ if (
205
+ restartLifecycleError instanceof GatewayDrainingError ||
206
+ restartLifecycleError instanceof CommandLaneClearedError
207
+ ) {
208
+ takePendingLifecycleTerminal().emit("error", restartLifecycleError);
209
+ turn.replyOperation?.fail(
210
+ restartLifecycleError instanceof GatewayDrainingError
211
+ ? "gateway_draining"
212
+ : "command_lane_cleared",
213
+ restartLifecycleError,
214
+ );
215
+ return {
216
+ kind: "final",
217
+ payload: markAgentRunFailureReplyPayload({ text: buildRestartLifecycleReplyText() }),
218
+ };
219
+ }
220
+ if (isCompactionFailure) {
221
+ takePendingLifecycleTerminal().emit("error", err);
222
+ defaultRuntime.error(
223
+ `Auto-compaction failed (${message}). Preserving existing session mapping for ${turn.sessionKey ?? turn.followupRun.run.sessionId}.`,
224
+ );
225
+ turn.replyOperation?.fail("run_failed", err);
226
+ return {
227
+ kind: "final",
228
+ payload: markAgentRunFailureReplyPayload({
229
+ text: buildContextOverflowRecoveryText({
230
+ duringCompaction: true,
231
+ preserveSessionMapping: true,
232
+ cfg: params.runtimeConfig,
233
+ agentId: turn.followupRun.run.agentId,
234
+ primaryProvider: turn.followupRun.run.provider,
235
+ primaryModel: turn.followupRun.run.model,
236
+ runtimeProvider: params.state.attemptedRuntimeProvider,
237
+ runtimeModel: params.state.attemptedRuntimeModel,
238
+ activeSessionEntry: turn.getActiveSessionEntry(),
239
+ }),
240
+ }),
241
+ };
242
+ }
243
+ const replayPrevented = findCliTimeoutError(err)?.cliTimeout.observedActivity === true;
244
+ if (providerRequestError) {
245
+ return await settleFailure({
246
+ // Curated facet copy beats the generic classified summary; see
247
+ // buildExternalRunFailureReply for the same priority.
248
+ text: providerRequestError.userMessage,
249
+ });
250
+ }
251
+ defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
252
+ const externalRunFailureCandidate =
253
+ !failureSummary && !isContextOverflow
254
+ ? buildExternalRunFailureReply(
255
+ { message, error: err },
256
+ {
257
+ includeAuthProfileId: !isNonDirectConversationContext(turn.sessionCtx),
258
+ includeDetails: isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel),
259
+ isHeartbeat: turn.isHeartbeat,
260
+ replayPrevented,
261
+ failoverFacts,
262
+ },
263
+ )
264
+ : undefined;
265
+ const externalRunFailureReply =
266
+ !params.shouldSurfaceToControlUi ||
267
+ externalRunFailureCandidate?.presentation ||
268
+ renderFailoverCodeUserCopy(failoverFacts.code)
269
+ ? externalRunFailureCandidate
270
+ : undefined;
271
+ const fallbackText =
272
+ failureSummary?.text ??
273
+ (isContextOverflow
274
+ ? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
275
+ : (externalRunFailureReply?.text ??
276
+ (params.shouldSurfaceToControlUi
277
+ ? renderControlUiAgentFailureCopy(message)
278
+ : turn.isHeartbeat
279
+ ? HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT
280
+ : GENERIC_EXTERNAL_RUN_FAILURE_TEXT)));
281
+ const userVisibleFallbackText = resolveExternalRunFailureTextForConversation({
282
+ text: fallbackText,
283
+ visibleReplyDelivered: await turn.resolveVisibleReplyDelivery?.(),
284
+ sessionCtx: turn.sessionCtx,
285
+ isGenericRunnerFailure: externalRunFailureReply?.isGenericRunnerFailure ?? false,
286
+ cfg: turn.followupRun.run.config,
287
+ });
288
+ return await settleFailure({
289
+ text: userVisibleFallbackText,
290
+ ...(externalRunFailureReply?.presentation
291
+ ? { presentation: externalRunFailureReply.presentation }
292
+ : {}),
293
+ });
294
+ }
src/auto-reply/reply/agent-runner-event-handler.ts ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readStringValue } from "@openclaw/normalization-core/string-coerce";
2
+ import { isMessagingToolSendAction } from "../../agents/embedded-agent-messaging.js";
3
+ import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/run/params.js";
4
+ import { normalizeAgentPlanSteps } from "../../channels/streaming.js";
5
+ import { logVerbose } from "../../globals.js";
6
+ import { createSubsystemLogger } from "../../logging/subsystem.js";
7
+ import type { ReplyPayload } from "../types.js";
8
+ import type { AgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
9
+ import { buildCommandOutputFromToolResultEvent } from "./agent-runner-command-output.js";
10
+ import type { AgentTurnParams } from "./agent-runner-execution.types.js";
11
+ import {
12
+ createCompactionHookNoticePayload,
13
+ createCompactionNoticePayload,
14
+ formatCompactionModelRef,
15
+ readCompactionHookMessages,
16
+ } from "./compaction-notice.js";
17
+
18
+ const agentCompactionLog = createSubsystemLogger("auto-reply/compaction");
19
+ const CODEX_APP_SERVER_COMPACTION_BACKEND = "codex-app-server";
20
+
21
+ export type MessageToolDeliveryState = {
22
+ toolCallIds: Set<string>;
23
+ completed: boolean;
24
+ };
25
+
26
+ function readApprovalScopeValue(value: unknown): "turn" | "session" | undefined {
27
+ return value === "turn" || value === "session" ? value : undefined;
28
+ }
29
+
30
+ /** Bridges embedded-agent events into channel progress and compaction notices. */
31
+ export function createAgentRunEventHandler(params: {
32
+ turn: AgentTurnParams;
33
+ lifecycleBackstop: AgentLifecycleTerminalBackstop;
34
+ notifyAgentRunStart: () => void;
35
+ sourceRepliesAreToolOnly: boolean;
36
+ provider: string;
37
+ model: string;
38
+ runId: string;
39
+ effectiveSessionId?: string;
40
+ notifyUserAboutCompaction: boolean;
41
+ onCompactionCompleted: () => number;
42
+ messageToolDeliveryState: MessageToolDeliveryState;
43
+ }): NonNullable<RunEmbeddedAgentParams["onAgentEvent"]> {
44
+ const shouldSuppressProgressAfterMessageToolDelivery = () =>
45
+ params.sourceRepliesAreToolOnly &&
46
+ params.messageToolDeliveryState.completed &&
47
+ params.turn.opts?.allowProgressCallbacksWhenSourceDeliverySuppressed !== true;
48
+
49
+ const currentMessageId =
50
+ params.turn.sessionCtx.MessageSidFull ?? params.turn.sessionCtx.MessageSid;
51
+ const deliverCompactionNoticePayload = async (noticePayload: ReplyPayload, label: string) => {
52
+ const deliver = params.turn.opts?.onBlockReply ?? params.turn.onCompactionNoticePayload;
53
+ if (!deliver) {
54
+ return;
55
+ }
56
+ try {
57
+ await deliver(noticePayload);
58
+ } catch (err) {
59
+ logVerbose(`compaction ${label} notice delivery failed (non-fatal): ${String(err)}`);
60
+ }
61
+ };
62
+ const sendCompactionNotice = async (phase: "start" | "end" | "incomplete") => {
63
+ await deliverCompactionNoticePayload(
64
+ createCompactionNoticePayload({
65
+ phase,
66
+ currentMessageId,
67
+ applyReplyToMode: params.turn.applyReplyToMode,
68
+ }),
69
+ phase,
70
+ );
71
+ };
72
+ const sendCompactionHookMessages = async (messages: string[]) => {
73
+ const noticePayload = createCompactionHookNoticePayload({
74
+ messages,
75
+ currentMessageId,
76
+ applyReplyToMode: params.turn.applyReplyToMode,
77
+ });
78
+ if (noticePayload) {
79
+ await deliverCompactionNoticePayload(noticePayload, "hook");
80
+ }
81
+ };
82
+
83
+ return async (evt) => {
84
+ params.turn.replyOperation?.recordActivity();
85
+ params.lifecycleBackstop.note(evt);
86
+ const hasLifecyclePhase = evt.stream === "lifecycle" && typeof evt.data.phase === "string";
87
+ if (evt.stream !== "lifecycle" || hasLifecyclePhase) {
88
+ params.notifyAgentRunStart();
89
+ }
90
+ if (evt.stream === "tool" && evt.data.hideFromChannelProgress !== true) {
91
+ const phase = readStringValue(evt.data.phase) ?? "";
92
+ const name = readStringValue(evt.data.name);
93
+ const toolCallId = readStringValue(evt.data.toolCallId) ?? "";
94
+ const args =
95
+ evt.data.args && typeof evt.data.args === "object"
96
+ ? (evt.data.args as Record<string, unknown>)
97
+ : undefined;
98
+ if (
99
+ params.sourceRepliesAreToolOnly &&
100
+ toolCallId &&
101
+ name &&
102
+ (phase === "start" || phase === "update") &&
103
+ args &&
104
+ isMessagingToolSendAction(name, args)
105
+ ) {
106
+ params.messageToolDeliveryState.toolCallIds.add(toolCallId);
107
+ }
108
+ if (shouldSuppressProgressAfterMessageToolDelivery()) {
109
+ return;
110
+ }
111
+ if (phase === "start" || phase === "update") {
112
+ const toolStartProgressPromise = params.turn.opts?.onToolStart?.({
113
+ itemId: readStringValue(evt.data.itemId),
114
+ toolCallId: readStringValue(evt.data.toolCallId),
115
+ name,
116
+ phase,
117
+ args,
118
+ detailMode: params.turn.toolProgressDetail,
119
+ });
120
+ await Promise.all([params.turn.typingSignals.signalToolStart(), toolStartProgressPromise]);
121
+ }
122
+ const commandOutput = buildCommandOutputFromToolResultEvent(evt);
123
+ if (commandOutput) {
124
+ await params.turn.opts?.onCommandOutput?.(commandOutput);
125
+ }
126
+ }
127
+
128
+ const suppressItemChannelProgress =
129
+ evt.stream === "item" &&
130
+ evt.data.suppressChannelProgress === true &&
131
+ Boolean(params.turn.opts?.onToolStart);
132
+ const hideItemFromChannelProgress =
133
+ evt.stream === "item" && evt.data.hideFromChannelProgress === true;
134
+ const itemPhase = evt.stream === "item" ? readStringValue(evt.data.phase) : "";
135
+ const itemName = evt.stream === "item" ? readStringValue(evt.data.name) : "";
136
+ const itemStatus = evt.stream === "item" ? readStringValue(evt.data.status) : "";
137
+ const itemToolCallId =
138
+ evt.stream === "item" ? (readStringValue(evt.data.toolCallId) ?? "") : "";
139
+ const completedMessageToolDelivery =
140
+ params.sourceRepliesAreToolOnly &&
141
+ itemPhase === "end" &&
142
+ itemStatus === "completed" &&
143
+ itemToolCallId.length > 0 &&
144
+ params.messageToolDeliveryState.toolCallIds.has(itemToolCallId);
145
+ const suppressProgressAfterMessageToolDelivery =
146
+ shouldSuppressProgressAfterMessageToolDelivery();
147
+ if (completedMessageToolDelivery) {
148
+ params.messageToolDeliveryState.toolCallIds.delete(itemToolCallId);
149
+ params.messageToolDeliveryState.completed = true;
150
+ }
151
+
152
+ if (
153
+ evt.stream === "item" &&
154
+ !hideItemFromChannelProgress &&
155
+ !suppressItemChannelProgress &&
156
+ (!suppressProgressAfterMessageToolDelivery || completedMessageToolDelivery)
157
+ ) {
158
+ const itemSummary = readStringValue(evt.data.summary);
159
+ const itemProgressText = readStringValue(evt.data.progressText);
160
+ const itemMeta = readStringValue(evt.data.meta);
161
+ const itemCommandBearing =
162
+ typeof evt.data.commandBearing === "boolean" ? evt.data.commandBearing : undefined;
163
+ const itemApprovalId = readStringValue(evt.data.approvalId);
164
+ const itemApprovalSlug = readStringValue(evt.data.approvalSlug);
165
+ await params.turn.opts?.onItemEvent?.({
166
+ itemId: readStringValue(evt.data.itemId),
167
+ kind: readStringValue(evt.data.kind),
168
+ title: readStringValue(evt.data.title),
169
+ phase: itemPhase,
170
+ status: itemStatus,
171
+ ...(itemToolCallId ? { toolCallId: itemToolCallId } : {}),
172
+ ...(itemName ? { name: itemName } : {}),
173
+ ...(itemSummary !== undefined ? { summary: itemSummary } : {}),
174
+ ...(itemProgressText !== undefined ? { progressText: itemProgressText } : {}),
175
+ ...(itemMeta !== undefined ? { meta: itemMeta } : {}),
176
+ ...(itemCommandBearing !== undefined ? { commandBearing: itemCommandBearing } : {}),
177
+ ...(itemApprovalId !== undefined ? { approvalId: itemApprovalId } : {}),
178
+ ...(itemApprovalSlug !== undefined ? { approvalSlug: itemApprovalSlug } : {}),
179
+ });
180
+ }
181
+ if (evt.stream === "plan" && !shouldSuppressProgressAfterMessageToolDelivery()) {
182
+ await params.turn.opts?.onPlanUpdate?.({
183
+ phase: readStringValue(evt.data.phase),
184
+ title: readStringValue(evt.data.title),
185
+ explanation: readStringValue(evt.data.explanation),
186
+ ...(evt.data.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
187
+ steps: normalizeAgentPlanSteps(evt.data.steps),
188
+ source: readStringValue(evt.data.source),
189
+ });
190
+ }
191
+ if (evt.stream === "approval" && !shouldSuppressProgressAfterMessageToolDelivery()) {
192
+ await params.turn.opts?.onApprovalEvent?.({
193
+ phase: readStringValue(evt.data.phase),
194
+ kind: readStringValue(evt.data.kind),
195
+ status: readStringValue(evt.data.status),
196
+ title: readStringValue(evt.data.title),
197
+ itemId: readStringValue(evt.data.itemId),
198
+ toolCallId: readStringValue(evt.data.toolCallId),
199
+ approvalId: readStringValue(evt.data.approvalId),
200
+ approvalSlug: readStringValue(evt.data.approvalSlug),
201
+ command: readStringValue(evt.data.command),
202
+ host: readStringValue(evt.data.host),
203
+ reason: readStringValue(evt.data.reason),
204
+ scope: readApprovalScopeValue(evt.data.scope),
205
+ message: readStringValue(evt.data.message),
206
+ });
207
+ }
208
+ if (evt.stream === "command_output" && !shouldSuppressProgressAfterMessageToolDelivery()) {
209
+ await params.turn.opts?.onCommandOutput?.({
210
+ itemId: readStringValue(evt.data.itemId),
211
+ phase: readStringValue(evt.data.phase),
212
+ title: readStringValue(evt.data.title),
213
+ toolCallId: readStringValue(evt.data.toolCallId),
214
+ name: readStringValue(evt.data.name),
215
+ output: readStringValue(evt.data.output),
216
+ status: readStringValue(evt.data.status),
217
+ exitCode:
218
+ typeof evt.data.exitCode === "number" || evt.data.exitCode === null
219
+ ? evt.data.exitCode
220
+ : undefined,
221
+ durationMs: typeof evt.data.durationMs === "number" ? evt.data.durationMs : undefined,
222
+ cwd: readStringValue(evt.data.cwd),
223
+ });
224
+ }
225
+ if (evt.stream === "patch" && !shouldSuppressProgressAfterMessageToolDelivery()) {
226
+ await params.turn.opts?.onPatchSummary?.({
227
+ itemId: readStringValue(evt.data.itemId),
228
+ phase: readStringValue(evt.data.phase),
229
+ title: readStringValue(evt.data.title),
230
+ toolCallId: readStringValue(evt.data.toolCallId),
231
+ name: readStringValue(evt.data.name),
232
+ added: Array.isArray(evt.data.added)
233
+ ? evt.data.added.filter((entry): entry is string => typeof entry === "string")
234
+ : undefined,
235
+ modified: Array.isArray(evt.data.modified)
236
+ ? evt.data.modified.filter((entry): entry is string => typeof entry === "string")
237
+ : undefined,
238
+ deleted: Array.isArray(evt.data.deleted)
239
+ ? evt.data.deleted.filter((entry): entry is string => typeof entry === "string")
240
+ : undefined,
241
+ summary: readStringValue(evt.data.summary),
242
+ });
243
+ }
244
+ if (evt.stream !== "compaction") {
245
+ return;
246
+ }
247
+
248
+ const phase = readStringValue(evt.data.phase) ?? "";
249
+ const backend = readStringValue(evt.data.backend);
250
+ const hookMessages = readCompactionHookMessages(evt.data.messages);
251
+ const sendCompactionUserNotices = async (noticePhase: "start" | "end" | "incomplete") => {
252
+ if (hookMessages.length > 0) {
253
+ await sendCompactionHookMessages(hookMessages);
254
+ }
255
+ if (params.notifyUserAboutCompaction) {
256
+ await sendCompactionNotice(noticePhase);
257
+ }
258
+ };
259
+ if (phase === "start") {
260
+ await params.turn.opts?.onCompactionStart?.();
261
+ await sendCompactionUserNotices("start");
262
+ return;
263
+ }
264
+ if (phase !== "end") {
265
+ return;
266
+ }
267
+ if (evt.data.completed !== true) {
268
+ await params.turn.opts?.onCompactionEnd?.({ completed: false });
269
+ await sendCompactionUserNotices("incomplete");
270
+ return;
271
+ }
272
+
273
+ const compactionCount = params.onCompactionCompleted();
274
+ if (backend === CODEX_APP_SERVER_COMPACTION_BACKEND) {
275
+ const modelRef = formatCompactionModelRef(params.provider, params.model);
276
+ const consoleMessage =
277
+ `codex app-server auto-compaction succeeded for ${modelRef}; ` +
278
+ "refreshed session context";
279
+ agentCompactionLog.info("codex app-server auto-compaction succeeded", {
280
+ event: "codex_app_server_compaction_succeeded",
281
+ backend,
282
+ provider: params.provider,
283
+ model: params.model,
284
+ sessionKey: params.turn.sessionKey,
285
+ sessionId: params.effectiveSessionId,
286
+ threadId: readStringValue(evt.data.threadId),
287
+ turnId: readStringValue(evt.data.turnId),
288
+ itemId: readStringValue(evt.data.itemId),
289
+ compactionCount,
290
+ consoleMessage,
291
+ });
292
+ }
293
+ await params.turn.opts?.onCompactionEnd?.({ completed: true });
294
+ await sendCompactionUserNotices("end");
295
+ };
296
+ }
src/auto-reply/reply/agent-runner-execute.ts ADDED
@@ -0,0 +1,559 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from "node:crypto";
2
+ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
3
+ import type { OpenClawConfig } from "../../config/config.js";
4
+ import type { SessionEntry } from "../../config/sessions.js";
5
+ import { withBeforeAgentReplyObserver } from "../../plugins/before-agent-reply.js";
6
+ import { getGatewayContextResolver } from "../../plugins/runtime/gateway-request-scope.js";
7
+ import { readPendingUserTurnTranscriptAdmission } from "../../sessions/user-turn-transcript-admission.js";
8
+ import { setReplyPayloadMetadata } from "../reply-payload.js";
9
+ import type { OriginatingChannelType } from "../templating.js";
10
+ import { SILENT_REPLY_TOKEN } from "../tokens.js";
11
+ import type { ReplyPayload } from "../types.js";
12
+ import {
13
+ resolveReplyRunDeliveryContext,
14
+ resolveSourceReplyPolicy,
15
+ type RunReplyAgentParams,
16
+ } from "./agent-runner-core.js";
17
+ import { executeAgentTurn } from "./agent-runner-execution.js";
18
+ import { markPostCompactionModelFailurePayload } from "./agent-runner-failure-reply.js";
19
+ import { runMemoryFlushIfNeeded, runSessionCompactionIfNeeded } from "./agent-runner-memory.js";
20
+ import { accountAgentTurnCompaction } from "./agent-runner-result-accounting.js";
21
+ import { finalizeReplyAgentRun } from "./agent-runner-result.js";
22
+ import { buildThreadingToolContext } from "./agent-runner-utils.js";
23
+ import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
24
+ import type { CompactionNoticePhase } from "./compaction-notice.js";
25
+ import { createFollowupRunner } from "./followup-runner.js";
26
+ import {
27
+ buildRecoverablePendingFinalDeliveryText,
28
+ normalizePendingFinalDeliveryPayloads,
29
+ } from "./pending-final-delivery.js";
30
+ import type { FollowupRun } from "./queue.js";
31
+ import type { ReplyMediaContext } from "./reply-media-paths.js";
32
+ import { isReplyOperationSuperseded } from "./reply-operation-abort.js";
33
+ import { recordReplyOperationAgentTurn } from "./reply-operation-run-state.js";
34
+ import type { ReplyOperation } from "./reply-run-registry.js";
35
+ import { replyRunRegistry } from "./reply-run-registry.js";
36
+ import { resolveReplyToMode } from "./reply-threading.js";
37
+ import { createReplyRestartRecoveryClaimController } from "./restart-recovery-claim.js";
38
+ import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
39
+ import type { TypingSignaler } from "./typing-mode.js";
40
+ type ExecutePreparedReplyAgentRunInput = Pick<
41
+ RunReplyAgentParams,
42
+ | "blockReplyChunking"
43
+ | "blockStreamingEnabled"
44
+ | "commandBody"
45
+ | "defaultModel"
46
+ | "followupRun"
47
+ | "opts"
48
+ | "queueKey"
49
+ | "replyThreadingOverride"
50
+ | "resolvedBlockStreamingBreak"
51
+ | "resolvedQueue"
52
+ | "resolvedVerboseLevel"
53
+ | "runtimePolicySessionKey"
54
+ | "sessionCtx"
55
+ | "sessionKey"
56
+ | "shouldInjectGroupIntro"
57
+ | "storePath"
58
+ | "toolProgressDetail"
59
+ | "transcriptCommandBody"
60
+ | "typing"
61
+ | "typingMode"
62
+ > & {
63
+ activeSessionStore: Record<string, SessionEntry> | undefined;
64
+ admitUserTurn: ReturnType<typeof createReplyRestartRecoveryClaimController>["admitUserTurn"];
65
+ applyReplyToMode: (payload: ReplyPayload) => ReplyPayload;
66
+ beginBeforeAgentReply: ReturnType<
67
+ typeof createReplyRestartRecoveryClaimController
68
+ >["beginBeforeAgentReply"];
69
+ blockReplyPipeline: BlockReplyPipeline | null;
70
+ cfg: OpenClawConfig;
71
+ checkpointBeforeAgentReply: ReturnType<
72
+ typeof createReplyRestartRecoveryClaimController
73
+ >["checkpointBeforeAgentReply"];
74
+ resolveVisibleReplyDelivery: () => Promise<boolean>;
75
+ getActiveIsNewSession: () => boolean;
76
+ getActiveSessionEntry: () => SessionEntry | undefined;
77
+ isHeartbeat: boolean;
78
+ isRestartRecoveryArmed: () => boolean;
79
+ pendingToolTasks: Set<Promise<void>>;
80
+ replyMediaContext: ReplyMediaContext;
81
+ replyOperation: ReplyOperation;
82
+ replyRouteThreadId: ReturnType<typeof resolveRoutedDeliveryThreadId>;
83
+ replyToChannel: OriginatingChannelType | undefined;
84
+ replyToMode: ReturnType<typeof resolveReplyToMode>;
85
+ resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>;
86
+ returnWithQueuedFollowupDrain: <T>(value: T) => T;
87
+ runFollowupTurn: (queued: FollowupRun) => Promise<void>;
88
+ sendDirectCompactionNotice: ((phase: CompactionNoticePhase) => Promise<void>) | undefined;
89
+ setRunFollowupTurn: (runner: (queued: FollowupRun) => Promise<void>) => void;
90
+ setActiveSessionEntry: (entry: SessionEntry | undefined) => void;
91
+ shouldEmitToolOutput: () => boolean;
92
+ shouldEmitToolResult: () => boolean;
93
+ traceAgentPhase: <T>(name: string, run: () => Promise<T> | T) => Promise<T>;
94
+ turnAdoptionLifecycle: NonNullable<RunReplyAgentParams["opts"]>["turnAdoptionLifecycle"];
95
+ typingSignals: TypingSignaler;
96
+ };
97
+
98
+ function markPostCompactionFailureResult(
99
+ result: ReplyPayload | ReplyPayload[] | undefined,
100
+ postCompactionModelFailure: true | undefined,
101
+ ): ReplyPayload | ReplyPayload[] | undefined {
102
+ if (Array.isArray(result)) {
103
+ return result.map((payload) =>
104
+ markPostCompactionModelFailurePayload(postCompactionModelFailure, payload),
105
+ );
106
+ }
107
+ return result
108
+ ? markPostCompactionModelFailurePayload(postCompactionModelFailure, result)
109
+ : result;
110
+ }
111
+
112
+ export async function executePreparedReplyAgentRun(
113
+ context: ExecutePreparedReplyAgentRunInput,
114
+ ): Promise<ReplyPayload | ReplyPayload[] | undefined> {
115
+ const {
116
+ activeSessionStore,
117
+ admitUserTurn: admitUserTurnWithRecovery,
118
+ applyReplyToMode,
119
+ beginBeforeAgentReply: beginBeforeAgentReplyWithRecovery,
120
+ blockReplyChunking,
121
+ blockReplyPipeline,
122
+ blockStreamingEnabled,
123
+ cfg,
124
+ checkpointBeforeAgentReply: checkpointBeforeAgentReplyWithRecovery,
125
+ commandBody,
126
+ defaultModel,
127
+ followupRun,
128
+ getActiveIsNewSession,
129
+ getActiveSessionEntry,
130
+ isHeartbeat,
131
+ isRestartRecoveryArmed,
132
+ opts,
133
+ pendingToolTasks,
134
+ queueKey,
135
+ replyMediaContext,
136
+ replyOperation,
137
+ replyRouteThreadId,
138
+ replyThreadingOverride,
139
+ replyToChannel,
140
+ replyToMode,
141
+ resetSessionAfterRoleOrderingConflict,
142
+ resolvedBlockStreamingBreak,
143
+ resolvedQueue,
144
+ resolvedVerboseLevel,
145
+ returnWithQueuedFollowupDrain,
146
+ runtimePolicySessionKey,
147
+ sendDirectCompactionNotice,
148
+ sessionCtx,
149
+ sessionKey,
150
+ setActiveSessionEntry,
151
+ setRunFollowupTurn,
152
+ shouldEmitToolOutput,
153
+ shouldEmitToolResult,
154
+ shouldInjectGroupIntro,
155
+ storePath,
156
+ toolProgressDetail,
157
+ traceAgentPhase,
158
+ transcriptCommandBody,
159
+ turnAdoptionLifecycle,
160
+ typing,
161
+ typingMode,
162
+ typingSignals,
163
+ } = context;
164
+ let activeSessionEntry = getActiveSessionEntry();
165
+ const admitUserTurn = async (
166
+ ...args: Parameters<typeof admitUserTurnWithRecovery>
167
+ ): ReturnType<typeof admitUserTurnWithRecovery> => {
168
+ const result = await admitUserTurnWithRecovery(...args);
169
+ activeSessionEntry = getActiveSessionEntry();
170
+ return result;
171
+ };
172
+ const beginBeforeAgentReply = async (
173
+ ...args: Parameters<typeof beginBeforeAgentReplyWithRecovery>
174
+ ): ReturnType<typeof beginBeforeAgentReplyWithRecovery> => {
175
+ const result = await beginBeforeAgentReplyWithRecovery(...args);
176
+ activeSessionEntry = getActiveSessionEntry();
177
+ return result;
178
+ };
179
+ const checkpointBeforeAgentReply = async (
180
+ ...args: Parameters<typeof checkpointBeforeAgentReplyWithRecovery>
181
+ ): ReturnType<typeof checkpointBeforeAgentReplyWithRecovery> => {
182
+ const result = await checkpointBeforeAgentReplyWithRecovery(...args);
183
+ activeSessionEntry = getActiveSessionEntry();
184
+ return result;
185
+ };
186
+
187
+ await typingSignals.signalRunStart();
188
+
189
+ const preflightAdmission = readPendingUserTurnTranscriptAdmission(
190
+ followupRun.userTurnTranscriptRecorder,
191
+ );
192
+ const checkpointMemory = async (entry: SessionEntry) => {
193
+ const flushed = await traceAgentPhase("reply.memory_flush", () =>
194
+ runMemoryFlushIfNeeded({
195
+ preflightAdmission,
196
+ cfg,
197
+ followupRun,
198
+ promptForEstimate: followupRun.prompt,
199
+ opts,
200
+ defaultModel,
201
+ resolvedVerboseLevel,
202
+ sessionEntry: entry,
203
+ sessionStore: activeSessionStore,
204
+ sessionKey,
205
+ runtimePolicySessionKey,
206
+ storePath,
207
+ isHeartbeat,
208
+ replyOperation,
209
+ }),
210
+ );
211
+ setActiveSessionEntry(flushed.sessionEntry);
212
+ replyOperation.abortSignal.throwIfAborted();
213
+ if (flushed.outcome === "exhausted") {
214
+ await sendDirectCompactionNotice?.("memory_flush_degraded");
215
+ }
216
+ return flushed.sessionEntry;
217
+ };
218
+
219
+ const prePreflightCompactionCount = activeSessionEntry?.compactionCount ?? 0;
220
+ activeSessionEntry = await traceAgentPhase("reply.preflight_compaction", () =>
221
+ runSessionCompactionIfNeeded({
222
+ pendingUserEntryId: preflightAdmission?.entryId,
223
+ cfg,
224
+ followupRun,
225
+ promptForEstimate: followupRun.prompt,
226
+ defaultModel,
227
+ sessionEntry: activeSessionEntry,
228
+ sessionStore: activeSessionStore,
229
+ sessionKey,
230
+ runtimePolicySessionKey,
231
+ storePath,
232
+ isHeartbeat,
233
+ abortSignal: replyOperation.abortSignal,
234
+ beforeCompaction: checkpointMemory,
235
+ onCompactionStart: () => replyOperation.setPhase("preflight_compacting"),
236
+ onSessionIdChanged: (sessionId) => replyOperation.updateSessionId(sessionId),
237
+ onCompactionNotice: sendDirectCompactionNotice,
238
+ }),
239
+ );
240
+ setActiveSessionEntry(activeSessionEntry);
241
+ const preflightCompactionApplied =
242
+ (activeSessionEntry?.compactionCount ?? 0) > prePreflightCompactionCount;
243
+
244
+ const runFollowupTurn = createFollowupRunner({
245
+ resolveGatewayContext: getGatewayContextResolver(replyOperation),
246
+ opts,
247
+ typing,
248
+ typingMode,
249
+ sessionEntry: activeSessionEntry,
250
+ sessionStore: activeSessionStore,
251
+ sessionKey,
252
+ storePath,
253
+ defaultModel,
254
+ toolProgressDetail,
255
+ });
256
+ setRunFollowupTurn(runFollowupTurn);
257
+
258
+ replyOperation.setPhase("running");
259
+ const runStartedAt = Date.now();
260
+ const userTurnAdmission = await admitUserTurn(followupRun.userTurnTranscriptRecorder);
261
+ if (userTurnAdmission === "duplicate-source") {
262
+ return returnWithQueuedFollowupDrain(undefined);
263
+ }
264
+ // Adoption marks run start and must never be spool-replayed (would re-run tools).
265
+ // Suppressed delivery persists only the user transcript; crashed suppressed runs die
266
+ // silently. Deliverable turns atomically persist transcript plus recovery ownership.
267
+ await turnAdoptionLifecycle?.onAdopted();
268
+ const runOutcome = await withBeforeAgentReplyObserver(
269
+ {
270
+ beforeDispatch: async () => {
271
+ return await beginBeforeAgentReply();
272
+ },
273
+ afterDispatch: async (hookResult) => {
274
+ if (!hookResult?.handled) {
275
+ await checkpointBeforeAgentReply({ state: undefined });
276
+ return hookResult;
277
+ }
278
+ const hookReply = hookResult.reply ?? { text: SILENT_REPLY_TOKEN };
279
+ const hookFinalDeliveryText = buildRecoverablePendingFinalDeliveryText([hookReply]);
280
+ const normalizedHookReplies = normalizePendingFinalDeliveryPayloads([hookReply]);
281
+ let hookCheckpoint: Parameters<typeof checkpointBeforeAgentReply>[0] = {
282
+ state: normalizedHookReplies.length === 0 ? "handled-silent" : "pending",
283
+ };
284
+ if (sessionKey && storePath && normalizedHookReplies.length > 0) {
285
+ const sourceReplyPolicy = resolveSourceReplyPolicy({
286
+ cfg,
287
+ sessionCtx,
288
+ sessionEntry: activeSessionEntry,
289
+ sessionKey,
290
+ runtimePolicySessionKey,
291
+ opts,
292
+ });
293
+ if (!sourceReplyPolicy.suppressDelivery) {
294
+ const pendingFinalDeliveryIntentId = crypto.randomUUID();
295
+ const pendingFinalDeliveryDeliveryId = crypto.randomUUID();
296
+ setReplyPayloadMetadata(hookReply, {
297
+ pendingFinalDeliveryCompletion: {
298
+ deliveryId: pendingFinalDeliveryDeliveryId,
299
+ intentId: pendingFinalDeliveryIntentId,
300
+ ...(activeSessionEntry?.restartRecoveryDeliveryRunId
301
+ ? { recoveryRunId: activeSessionEntry.restartRecoveryDeliveryRunId }
302
+ : {}),
303
+ sessionId: replyOperation.sessionId,
304
+ sessionKey,
305
+ storePath,
306
+ },
307
+ });
308
+ hookCheckpoint = {
309
+ state: "handled-reply",
310
+ pendingFinalDelivery: {
311
+ text: hookFinalDeliveryText ?? "",
312
+ intentId: pendingFinalDeliveryIntentId,
313
+ deliveries: [{ id: pendingFinalDeliveryDeliveryId, state: "prepared" }],
314
+ context: resolveReplyRunDeliveryContext({
315
+ cfg,
316
+ sessionCtx,
317
+ sessionEntry: activeSessionEntry,
318
+ sessionKey,
319
+ runtimePolicySessionKey,
320
+ opts,
321
+ }),
322
+ },
323
+ };
324
+ } else {
325
+ // dispatch-from-config owns source visibility for every returned payload.
326
+ // This checkpoint records that recovery owes no delivery; the outer gate drops the reply.
327
+ hookCheckpoint = { state: "handled-silent" };
328
+ }
329
+ }
330
+ await checkpointBeforeAgentReply(hookCheckpoint);
331
+ return { ...hookResult, reply: hookReply };
332
+ },
333
+ },
334
+ () =>
335
+ traceAgentPhase("reply.run_agent_turn", () =>
336
+ executeAgentTurn({
337
+ commandBody,
338
+ transcriptCommandBody,
339
+ followupRun,
340
+ sessionCtx,
341
+ replyThreading: replyThreadingOverride ?? sessionCtx.ReplyThreading,
342
+ replyOperation,
343
+ opts,
344
+ resolveVisibleReplyDelivery: context.resolveVisibleReplyDelivery,
345
+ typingSignals,
346
+ blockReplyPipeline,
347
+ blockStreamingEnabled,
348
+ blockReplyChunking,
349
+ resolvedBlockStreamingBreak,
350
+ applyReplyToMode,
351
+ shouldEmitToolResult,
352
+ shouldEmitToolOutput,
353
+ pendingToolTasks,
354
+ resetSessionAfterRoleOrderingConflict,
355
+ isHeartbeat,
356
+ sessionKey,
357
+ runtimePolicySessionKey,
358
+ getActiveSessionEntry,
359
+ activeSessionStore,
360
+ storePath,
361
+ resolvedVerboseLevel,
362
+ toolProgressDetail,
363
+ replyMediaContext,
364
+ isRestartRecoveryArmed,
365
+ }),
366
+ ),
367
+ );
368
+ const operationSuperseded = isReplyOperationSuperseded(replyOperation);
369
+ recordReplyOperationAgentTurn(
370
+ followupRun.replyOperationRunStates,
371
+ replyOperation,
372
+ runOutcome.outcome,
373
+ );
374
+ activeSessionEntry = getActiveSessionEntry();
375
+ const activeIsNewSession = getActiveIsNewSession();
376
+
377
+ if (runOutcome.outcome.kind !== "settled") {
378
+ // Only captured facts cross cancellation; no successor adoption, hooks, or reply work.
379
+ await accountAgentTurnCompaction({
380
+ compaction: runOutcome.outcome.compaction,
381
+ sessionStore: activeSessionStore,
382
+ replyOperation,
383
+ });
384
+ }
385
+ if (operationSuperseded) {
386
+ return { text: SILENT_REPLY_TOKEN };
387
+ }
388
+ if (runOutcome.outcome.kind !== "settled") {
389
+ if (runOutcome.outcome.kind === "rejected" && !replyOperation.result) {
390
+ replyOperation.fail("run_failed", new Error("reply operation exited with final payload"));
391
+ }
392
+ return returnWithQueuedFollowupDrain(
393
+ runOutcome.outcome.kind === "rejected"
394
+ ? markPostCompactionModelFailurePayload(
395
+ runOutcome.outcome.postCompactionModelFailure,
396
+ runOutcome.outcome.payload,
397
+ )
398
+ : { text: SILENT_REPLY_TOKEN },
399
+ );
400
+ }
401
+
402
+ const result = await finalizeReplyAgentRun({
403
+ activeIsNewSession,
404
+ activeSessionEntry,
405
+ activeSessionStore,
406
+ blockReplyPipeline,
407
+ blockStreamingEnabled,
408
+ cfg,
409
+ commandBody,
410
+ defaultModel,
411
+ followupRun,
412
+ isHeartbeat,
413
+ opts,
414
+ pendingToolTasks,
415
+ preflightCompactionApplied,
416
+ queueKey,
417
+ replyMediaContext,
418
+ replyOperation,
419
+ replyRouteThreadId,
420
+ replyThreadingOverride,
421
+ replyToChannel,
422
+ replyToMode,
423
+ resolvedBlockStreamingBreak,
424
+ resolvedQueue,
425
+ resolvedVerboseLevel,
426
+ returnWithQueuedFollowupDrain,
427
+ runFollowupTurn,
428
+ execution: runOutcome.outcome,
429
+ runId: runOutcome.runId,
430
+ runStartedAt,
431
+ runtimePolicySessionKey,
432
+ sessionCtx,
433
+ sessionKey,
434
+ shouldInjectGroupIntro,
435
+ storePath,
436
+ typingSignals,
437
+ });
438
+ return markPostCompactionFailureResult(result, runOutcome.outcome.postCompactionModelFailure);
439
+ }
440
+
441
+ export function createReplyAgentRestartRecoveryController(
442
+ context: Pick<
443
+ RunReplyAgentParams,
444
+ "followupRun" | "opts" | "runtimePolicySessionKey" | "sessionCtx" | "sessionKey" | "storePath"
445
+ > & {
446
+ activeSessionStore: Record<string, SessionEntry> | undefined;
447
+ cfg: OpenClawConfig;
448
+ getActiveSessionEntry: () => SessionEntry | undefined;
449
+ replyOperation: ReplyOperation;
450
+ restartRecoverySourceTurnId: string | undefined;
451
+ setActiveSessionEntry: (entry: SessionEntry) => void;
452
+ },
453
+ ) {
454
+ const {
455
+ activeSessionStore,
456
+ cfg,
457
+ followupRun,
458
+ getActiveSessionEntry,
459
+ opts,
460
+ replyOperation,
461
+ restartRecoverySourceTurnId,
462
+ runtimePolicySessionKey,
463
+ sessionCtx,
464
+ sessionKey,
465
+ setActiveSessionEntry,
466
+ storePath,
467
+ } = context;
468
+
469
+ const restartRecoverySameChannelThreadRequired = restartRecoverySourceTurnId
470
+ ? buildThreadingToolContext({
471
+ sessionCtx,
472
+ config: cfg,
473
+ hasRepliedRef: undefined,
474
+ }).sameChannelThreadRequired
475
+ : undefined;
476
+ const {
477
+ admitUserTurn,
478
+ beginBeforeAgentReply,
479
+ checkpointBeforeAgentReply,
480
+ clear: clearRestartRecoveryDeliveryClaim,
481
+ isArmed: isRestartRecoveryArmed,
482
+ } = createReplyRestartRecoveryClaimController({
483
+ lifecycleGeneration: replyOperation.lifecycleGeneration,
484
+ admissionRunId:
485
+ normalizeOptionalString(sessionCtx.MessageSid) ??
486
+ normalizeOptionalString(sessionCtx.MessageSidFull),
487
+ getEntry: () =>
488
+ sessionKey
489
+ ? (activeSessionStore?.[sessionKey] ?? getActiveSessionEntry())
490
+ : getActiveSessionEntry(),
491
+ getSessionId: () => replyOperation.sessionId,
492
+ isRestartAbort: () =>
493
+ replyOperation.result?.kind === "aborted" &&
494
+ replyOperation.result.code === "aborted_for_restart",
495
+ resolveDeliveryContext: (entry) =>
496
+ sessionKey
497
+ ? resolveReplyRunDeliveryContext({
498
+ cfg,
499
+ sessionCtx,
500
+ sessionEntry: entry,
501
+ sessionKey,
502
+ runtimePolicySessionKey,
503
+ opts,
504
+ })
505
+ : undefined,
506
+ requesterAccountId:
507
+ followupRun.originatingAccountId ?? sessionCtx.AccountId ?? followupRun.run.agentAccountId,
508
+ requesterSenderId: sessionCtx.SenderId,
509
+ resolveUserTurnTarget: ({
510
+ entry,
511
+ sessionId,
512
+ sessionKey: targetSessionKey,
513
+ storePath: targetStorePath,
514
+ }) => ({
515
+ sessionId,
516
+ sessionKey: targetSessionKey,
517
+ sessionEntry: entry,
518
+ ...(activeSessionStore ? { sessionStore: activeSessionStore } : {}),
519
+ storePath: targetStorePath,
520
+ agentId: followupRun.run.agentId,
521
+ cwd: followupRun.run.workspaceDir,
522
+ config: cfg,
523
+ }),
524
+ ...(sessionKey ? { sessionKey } : {}),
525
+ setEntry: (entry) => {
526
+ setActiveSessionEntry(entry);
527
+ if (activeSessionStore && sessionKey) {
528
+ activeSessionStore[sessionKey] = entry;
529
+ }
530
+ },
531
+ sameChannelThreadRequired: restartRecoverySameChannelThreadRequired,
532
+ sourceTurnId: restartRecoverySourceTurnId,
533
+ sourceReplyDeliveryMode: sessionKey
534
+ ? resolveSourceReplyPolicy({
535
+ cfg,
536
+ sessionCtx,
537
+ sessionEntry: getActiveSessionEntry(),
538
+ sessionKey,
539
+ runtimePolicySessionKey,
540
+ opts,
541
+ }).sourceReplyDeliveryMode
542
+ : opts?.sourceReplyDeliveryMode,
543
+ ...(storePath ? { storePath } : {}),
544
+ });
545
+ const admitUserTurnWithSourceBinding: typeof admitUserTurn = async (...args) => {
546
+ const result = await admitUserTurn(...args);
547
+ if (result === "admitted" && restartRecoverySourceTurnId) {
548
+ replyRunRegistry.bindSourceTurnId(replyOperation, restartRecoverySourceTurnId);
549
+ }
550
+ return result;
551
+ };
552
+ return {
553
+ admitUserTurn: admitUserTurnWithSourceBinding,
554
+ beginBeforeAgentReply,
555
+ checkpointBeforeAgentReply,
556
+ clear: clearRestartRecoveryDeliveryClaim,
557
+ isArmed: isRestartRecoveryArmed,
558
+ };
559
+ }
src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts ADDED
@@ -0,0 +1,546 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { OAuthRefreshFailureError } from "../../agents/auth-profiles/oauth-refresh-failure.js";
3
+ import { createCliOutputFailoverError } from "../../agents/cli-runner/output-error.js";
4
+ import { FailoverError } from "../../agents/failover-error.js";
5
+ import { MissingProviderAuthError, ProviderAuthError } from "../../agents/model-auth.js";
6
+ import type { TemplateContext } from "../templating.js";
7
+ import {
8
+ setupAgentRunnerExecutionTestState,
9
+ getExecuteAgentTurnForTest,
10
+ createRunAgentTurnParams,
11
+ createFollowupRun,
12
+ createMinimalRunAgentTurnParams,
13
+ createTestFallbackSummaryError,
14
+ } from "./agent-runner-execution.test-support.js";
15
+
16
+ const state = await setupAgentRunnerExecutionTestState();
17
+
18
+ const providerLoginPresentation = (command: string) => ({
19
+ blocks: [
20
+ {
21
+ type: "buttons",
22
+ buttons: [
23
+ {
24
+ label: "Sign in",
25
+ action: { type: "command", command },
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ });
31
+ const PROVIDER_LOGIN_PRESENTATION = providerLoginPresentation("/login openai");
32
+
33
+ describe("executeAgentTurn: authentication failures", () => {
34
+ it("surfaces gateway reauth guidance without a profile id", async () => {
35
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
36
+ new OAuthRefreshFailureError({ provider: "openai", message: "refresh_token_reused" }),
37
+ );
38
+
39
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
40
+ const result = await executeAgentTurn(createRunAgentTurnParams(createFollowupRun()));
41
+
42
+ expect(result.kind).toBe("final");
43
+ if (result.kind === "final") {
44
+ expect(result.payload.text).toBe(
45
+ "⚠️ Your model provider needs a new login. Send `/login openai` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `openclaw models auth login --provider openai` on the gateway.",
46
+ );
47
+ expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
48
+ }
49
+ });
50
+
51
+ it("adds provider login recovery to raw forwarded refresh failures", async () => {
52
+ const message = "OAuth token refresh failed for openai: refresh_token_invalidated";
53
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
54
+ new FailoverError(message, {
55
+ reason: "auth_permanent",
56
+ provider: "openai",
57
+ model: "gpt-5.6-sol",
58
+ status: 401,
59
+ rawError: message,
60
+ }),
61
+ );
62
+
63
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
64
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
65
+
66
+ expect(result.kind).toBe("final");
67
+ if (result.kind === "final") {
68
+ expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
69
+ }
70
+ });
71
+
72
+ it("keeps provider login recovery actionable on Control UI turns", async () => {
73
+ state.isInternalMessageChannelMock.mockReturnValue(true);
74
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
75
+ new OAuthRefreshFailureError({ provider: "openai", message: "refresh_token_reused" }),
76
+ );
77
+ const followupRun = createFollowupRun();
78
+ followupRun.run.messageProvider = "webchat";
79
+
80
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
81
+ const result = await executeAgentTurn(
82
+ createMinimalRunAgentTurnParams({
83
+ followupRun,
84
+ sessionCtx: { Provider: "webchat", MessageSid: "msg" } as unknown as TemplateContext,
85
+ }),
86
+ );
87
+
88
+ expect(result.kind).toBe("final");
89
+ if (result.kind === "final") {
90
+ expect(result.payload.text).toBe(
91
+ "⚠️ Your model provider needs a new login. Send `/login openai` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `openclaw models auth login --provider openai` on the gateway.",
92
+ );
93
+ expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
94
+ }
95
+ });
96
+
97
+ it("surfaces gateway reauth guidance from typed OAuth refresh failures", async () => {
98
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
99
+ new OAuthRefreshFailureError({
100
+ provider: "openai",
101
+ profileId: "openai:user@example.com",
102
+ message: "invalid_grant",
103
+ }),
104
+ );
105
+
106
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
107
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
108
+
109
+ expect(result.kind).toBe("final");
110
+ if (result.kind === "final") {
111
+ expect(result.payload.text).toBe(
112
+ "⚠️ Your model provider needs a new login. Send `/login openai` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `openclaw models auth login --provider openai --profile-id 'openai:user@example.com'` on the gateway.",
113
+ );
114
+ expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
115
+ }
116
+ });
117
+
118
+ it("preserves OAuth profile guidance through failover wrappers", async () => {
119
+ const refreshError = new OAuthRefreshFailureError({
120
+ provider: "openai",
121
+ profileId: "openai:user@example.com",
122
+ message: "invalid_grant",
123
+ });
124
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
125
+ new FailoverError("OpenAI OAuth failed", {
126
+ reason: "auth",
127
+ provider: "openai",
128
+ model: "gpt-5.5",
129
+ profileId: "openai:user@example.com",
130
+ authProfileFailure: { allInCooldown: false },
131
+ status: 401,
132
+ cause: refreshError,
133
+ }),
134
+ );
135
+
136
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
137
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
138
+
139
+ expect(result.kind).toBe("final");
140
+ if (result.kind === "final") {
141
+ expect(result.payload.text).toContain("--profile-id 'openai:user@example.com'");
142
+ expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
143
+ }
144
+ });
145
+
146
+ it("preserves OAuth profile guidance through fallback summaries", async () => {
147
+ const refreshError = new OAuthRefreshFailureError({
148
+ provider: "openai",
149
+ profileId: "openai:user@example.com",
150
+ message: "invalid_grant",
151
+ });
152
+ const failoverError = new FailoverError("OpenAI OAuth failed", {
153
+ reason: "auth",
154
+ provider: "openai",
155
+ model: "gpt-5.5",
156
+ profileId: "openai:user@example.com",
157
+ authProfileFailure: { allInCooldown: false },
158
+ status: 401,
159
+ cause: refreshError,
160
+ });
161
+ const summaryError = createTestFallbackSummaryError({
162
+ message: "All models failed",
163
+ attempts: [
164
+ {
165
+ provider: "openai",
166
+ model: "gpt-5.5",
167
+ error: "OpenAI OAuth failed",
168
+ reason: "auth",
169
+ },
170
+ ],
171
+ soonestCooldownExpiry: null,
172
+ cause: failoverError,
173
+ });
174
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(summaryError);
175
+
176
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
177
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
178
+
179
+ expect(result.kind).toBe("final");
180
+ if (result.kind === "final") {
181
+ expect(result.payload.text).toContain("--profile-id 'openai:user@example.com'");
182
+ expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
183
+ }
184
+ });
185
+
186
+ it("omits OAuth profile ids from group reauth guidance", async () => {
187
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
188
+ new OAuthRefreshFailureError({
189
+ provider: "openai",
190
+ profileId: "openai:user@example.com",
191
+ message: "invalid_grant",
192
+ }),
193
+ );
194
+
195
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
196
+ const result = await executeAgentTurn(
197
+ createMinimalRunAgentTurnParams({
198
+ sessionCtx: {
199
+ Provider: "whatsapp",
200
+ MessageSid: "msg",
201
+ ChatType: "group",
202
+ } as unknown as TemplateContext,
203
+ }),
204
+ );
205
+
206
+ expect(result.kind).toBe("final");
207
+ if (result.kind === "final") {
208
+ expect(result.payload.text).toContain("openclaw models auth login --provider openai");
209
+ expect(result.payload.text).not.toContain("user@example.com");
210
+ expect(result.payload.presentation).toEqual(PROVIDER_LOGIN_PRESENTATION);
211
+ }
212
+ });
213
+
214
+ it.each([
215
+ ["openai", "/login openai"],
216
+ ["xai", "/login xai"],
217
+ ["minimax-portal", "/login minimax-portal"],
218
+ ])("keeps disabled %s OAuth profiles actionable on later turns", async (provider, command) => {
219
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
220
+ new FailoverError("All OpenAI auth profiles are unavailable", {
221
+ reason: "auth_permanent",
222
+ provider,
223
+ model: "fixture-model",
224
+ authMode: "oauth",
225
+ authProfileFailure: { allInCooldown: true },
226
+ }),
227
+ );
228
+
229
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
230
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
231
+
232
+ expect(result.kind).toBe("final");
233
+ if (result.kind === "final") {
234
+ expect(result.payload.text).toContain(command);
235
+ expect(result.payload.presentation).toEqual(providerLoginPresentation(command));
236
+ }
237
+ });
238
+
239
+ it.each([
240
+ {
241
+ label: "provider names without OAuth evidence",
242
+ error: new FailoverError("Authentication unavailable", {
243
+ reason: "auth_permanent",
244
+ provider: "some-provider",
245
+ authProfileFailure: { allInCooldown: true },
246
+ }),
247
+ },
248
+ {
249
+ label: "OpenAI API-key failures",
250
+ error: new FailoverError("invalid API key", {
251
+ reason: "auth_permanent",
252
+ provider: "openai",
253
+ model: "gpt-5.5",
254
+ authMode: "api-key",
255
+ authProfileFailure: { allInCooldown: true },
256
+ }),
257
+ },
258
+ {
259
+ label: "transient OpenAI refresh failures",
260
+ error: new OAuthRefreshFailureError({
261
+ provider: "openai",
262
+ message: "temporary upstream issue",
263
+ }),
264
+ },
265
+ ])("does not offer provider login for $label", async ({ error }) => {
266
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(error);
267
+
268
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
269
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
270
+
271
+ expect(result.kind).toBe("final");
272
+ if (result.kind === "final") {
273
+ expect(result.payload.presentation).toBeUndefined();
274
+ }
275
+ });
276
+
277
+ it("adds provider login while retaining non-OpenAI terminal guidance", async () => {
278
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
279
+ new OAuthRefreshFailureError({
280
+ provider: "anthropic",
281
+ message: "invalid_grant",
282
+ }),
283
+ );
284
+
285
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
286
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
287
+
288
+ expect(result.kind).toBe("final");
289
+ if (result.kind === "final") {
290
+ expect(result.payload.text).toBe(
291
+ "⚠️ Your model provider needs a new login. Send `/login anthropic` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `openclaw models auth login --provider anthropic` on the gateway.",
292
+ );
293
+ expect(result.payload.presentation).toEqual(providerLoginPresentation("/login anthropic"));
294
+ }
295
+ });
296
+
297
+ it("surfaces Agent SDK OAuth session expiry in Discord channels", async () => {
298
+ const error = createCliOutputFailoverError({
299
+ output: {
300
+ text: "",
301
+ errorText: "Failed to authenticate: OAuth session expired and could not be refreshed",
302
+ },
303
+ provider: "claude-cli",
304
+ model: "claude-opus-5",
305
+ });
306
+ if (!error) {
307
+ throw new Error("expected CLI output failure");
308
+ }
309
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(error);
310
+
311
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
312
+ const result = await executeAgentTurn(
313
+ createMinimalRunAgentTurnParams({
314
+ sessionCtx: {
315
+ Provider: "discord",
316
+ Surface: "discord",
317
+ ChatType: "channel",
318
+ MessageSid: "msg",
319
+ } as unknown as TemplateContext,
320
+ }),
321
+ );
322
+
323
+ expect(result.kind).toBe("final");
324
+ if (result.kind === "final") {
325
+ expect(result.payload.text).toBe(
326
+ "⚠️ Your model provider needs a new login. Send `/login` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `claude auth login && openclaw models auth login --provider anthropic --method cli` on the gateway.",
327
+ );
328
+ expect(result.payload.presentation).toEqual(providerLoginPresentation("/login"));
329
+ }
330
+ });
331
+
332
+ it("surfaces claude-cli re-auth hint from structured provider metadata when the message omits claude-cli", async () => {
333
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
334
+ new FailoverError(
335
+ "Failed to authenticate. API Error: 401 Invalid authentication credentials",
336
+ {
337
+ reason: "auth",
338
+ provider: "claude-cli",
339
+ model: "claude-sonnet-4-20250514",
340
+ status: 401,
341
+ },
342
+ ),
343
+ );
344
+
345
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
346
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
347
+
348
+ expect(result.kind).toBe("final");
349
+ if (result.kind === "final") {
350
+ expect(result.payload.text).toBe(
351
+ "⚠️ Your model provider needs a new login. Send `/login` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `claude auth login && openclaw models auth login --provider anthropic --method cli` on the gateway.",
352
+ );
353
+ }
354
+ });
355
+
356
+ it("surfaces the claude-cli re-auth hint when the CLI session is logged out", async () => {
357
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
358
+ new FailoverError("Not logged in · Please run /login", {
359
+ reason: "auth",
360
+ provider: "claude-cli",
361
+ model: "claude-sonnet-4-20250514",
362
+ status: 401,
363
+ }),
364
+ );
365
+
366
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
367
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
368
+
369
+ expect(result.kind).toBe("final");
370
+ if (result.kind === "final") {
371
+ expect(result.payload.text).toBe(
372
+ "⚠️ Your model provider needs a new login. Send `/login` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `claude auth login && openclaw models auth login --provider anthropic --method cli` on the gateway.",
373
+ );
374
+ }
375
+ });
376
+
377
+ it("surfaces direct provider auth guidance for missing API keys", async () => {
378
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
379
+ new ProviderAuthError(
380
+ "missing-provider-auth",
381
+ "openai",
382
+ 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth.',
383
+ { providerGuidance: true },
384
+ ),
385
+ );
386
+
387
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
388
+ const result = await executeAgentTurn(createRunAgentTurnParams(createFollowupRun()));
389
+
390
+ expect(result.kind).toBe("final");
391
+ if (result.kind === "final") {
392
+ expect(result.payload.text).toBe(
393
+ "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-6-astra` with the OpenAI OAuth profile, or set `OPENAI_API_KEY` for direct OpenAI API-key runs.",
394
+ );
395
+ }
396
+ });
397
+
398
+ it("surfaces typed missing API-key auth guidance without parsing the message", async () => {
399
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
400
+ new MissingProviderAuthError("openai", {
401
+ mode: "api-key",
402
+ source: "env: OPENAI_API_KEY",
403
+ }),
404
+ );
405
+
406
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
407
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
408
+
409
+ expect(result.kind).toBe("final");
410
+ if (result.kind === "final") {
411
+ expect(result.payload.text).toBe(
412
+ '⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.',
413
+ );
414
+ }
415
+ });
416
+
417
+ it("formats auth-profile failover copy from typed FailoverError metadata", async () => {
418
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
419
+ new FailoverError("Auth profile failover exhausted for provider openai", {
420
+ reason: "auth",
421
+ provider: "openai",
422
+ status: 401,
423
+ authProfileFailure: { allInCooldown: true },
424
+ cause: new Error("invalid_grant"),
425
+ }),
426
+ );
427
+
428
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
429
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
430
+
431
+ expect(result.kind).toBe("final");
432
+ if (result.kind === "final") {
433
+ expect(result.payload.text).toContain("Couldn't sign in to openai.");
434
+ expect(result.payload.text).toContain("openclaw configure");
435
+ expect(result.payload.text).toContain("(invalid_grant)");
436
+ expect(result.payload.text).not.toContain("Auth profile failover exhausted");
437
+ }
438
+ });
439
+
440
+ it("renders bounded recovery when the selected auth profile is unavailable", async () => {
441
+ state.isInternalMessageChannelMock.mockReturnValue(true);
442
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
443
+ new FailoverError('Codex app-server auth profile "openai:private" was not found', {
444
+ reason: "auth",
445
+ provider: "openai",
446
+ status: 401,
447
+ code: "selected_auth_profile_unavailable",
448
+ authProfileFailure: { allInCooldown: false },
449
+ cause: new Error("arbitrary plugin detail for openai:private"),
450
+ }),
451
+ );
452
+
453
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
454
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
455
+
456
+ expect(result.kind).toBe("final");
457
+ if (result.kind === "final") {
458
+ expect(result.payload.text).toBe(
459
+ "The selected auth profile is unavailable in this agent's OpenClaw credential store. Import or migrate that credential into the agent, select another configured profile, or run `openclaw configure`, then retry.",
460
+ );
461
+ expect(result.payload.text).not.toContain("openai:private");
462
+ expect(result.payload.text).not.toContain("arbitrary plugin detail");
463
+ expect(result.payload.text).not.toContain("/login");
464
+ expect(result.payload.presentation).toBeUndefined();
465
+ }
466
+ });
467
+
468
+ it("does not suggest re-authentication for typed format failures", async () => {
469
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
470
+ new FailoverError("Format failover exhausted for provider openai", {
471
+ reason: "format",
472
+ provider: "openai",
473
+ authProfileFailure: { allInCooldown: true },
474
+ cause: new Error("messages must alternate roles"),
475
+ }),
476
+ );
477
+
478
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
479
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
480
+
481
+ expect(result.kind).toBe("final");
482
+ if (result.kind === "final") {
483
+ expect(result.payload.text).toContain("Couldn't reach openai");
484
+ expect(result.payload.text).toContain("messages must alternate roles");
485
+ expect(result.payload.text).not.toContain("models auth login");
486
+ expect(result.payload.text).not.toContain("openclaw configure");
487
+ }
488
+ });
489
+
490
+ it("points stale openai missing-key failures at doctor repair with re-auth fallback", async () => {
491
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
492
+ new ProviderAuthError(
493
+ "missing-provider-auth",
494
+ "openai",
495
+ 'No API key found for provider "openai".',
496
+ ),
497
+ );
498
+
499
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
500
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams());
501
+
502
+ expect(result.kind).toBe("final");
503
+ if (result.kind === "final") {
504
+ expect(result.payload.text).toBe(
505
+ '⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.',
506
+ );
507
+ }
508
+ });
509
+
510
+ it("falls back to a generic provider message for unsafe missing-key provider ids", async () => {
511
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
512
+ new ProviderAuthError(
513
+ "missing-provider-auth",
514
+ "openai`\nrm -rf /",
515
+ 'No API key found for provider "openai`\nrm -rf /".',
516
+ ),
517
+ );
518
+
519
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
520
+ const result = await executeAgentTurn(createRunAgentTurnParams(createFollowupRun()));
521
+
522
+ expect(result.kind).toBe("final");
523
+ if (result.kind === "final") {
524
+ expect(result.payload.text).toBe(
525
+ "⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again.",
526
+ );
527
+ }
528
+ });
529
+
530
+ it("falls back to a generic reauth command when the provider in the OAuth error is unsafe", async () => {
531
+ state.runEmbeddedAgentMock.mockRejectedValueOnce(
532
+ new OAuthRefreshFailureError({ provider: "openai`\nrm -rf /", message: "invalid_grant" }),
533
+ );
534
+
535
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
536
+ const result = await executeAgentTurn(createRunAgentTurnParams(createFollowupRun()));
537
+
538
+ expect(result.kind).toBe("final");
539
+ if (result.kind === "final") {
540
+ expect(result.payload.text).toBe(
541
+ "⚠️ Your model provider needs a new login. Send `/login` from a private chat or Control UI session. Where shown, you can also select **Sign in**. You can also re-auth with `openclaw models auth login` on the gateway.",
542
+ );
543
+ expect(result.payload.presentation).toEqual(providerLoginPresentation("/login"));
544
+ }
545
+ });
546
+ });
src/auto-reply/reply/agent-runner-execution-cli-admission.test.ts ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { withTestRunAdmission } from "../../agents/admitted-run-context.test-support.js";
3
+ import { buildPreparedCliRunContext } from "../../agents/cli-runner.test-helpers.js";
4
+ import { buildCliRunResult } from "../../agents/cli-runner/cli-run-settlement.js";
5
+ import { executeDeps } from "../../agents/cli-runner/execute-deps.js";
6
+ import { executePreparedCliRun } from "../../agents/cli-runner/execute.js";
7
+ import { buildCliMcpGrantContext } from "../../agents/cli-runner/mcp-grant-context.js";
8
+ import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
9
+ import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../../agents/failover/user-copy.js";
10
+ import { installSessionPlacementAdmissionProvider } from "../../agents/session-placement-admission.js";
11
+ import type { SessionEntry } from "../../config/sessions.js";
12
+ import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
13
+ import {
14
+ setupAgentRunnerExecutionTestState,
15
+ getExecuteAgentTurnForTest,
16
+ createFollowupRun,
17
+ requireMockCall,
18
+ expectMockCallArgFields,
19
+ initialFallbackAttemptOptions,
20
+ fallbackAttemptOptions,
21
+ createMinimalRunAgentTurnParams,
22
+ makeTestSessionStorePath,
23
+ } from "./agent-runner-execution.test-support.js";
24
+ import type { FallbackRunnerParams } from "./agent-runner-execution.test-support.js";
25
+
26
+ const state = await setupAgentRunnerExecutionTestState();
27
+
28
+ function rejectUnexpectedCompactionSuccessor(): never {
29
+ throw new Error("Unexpected compaction successor during CLI admission test");
30
+ }
31
+
32
+ describe("executeAgentTurn: CLI admission", () => {
33
+ it.each([
34
+ "ordinary",
35
+ "heartbeat",
36
+ "preserved",
37
+ "revised",
38
+ "revision-established",
39
+ "rejected",
40
+ "rejected-clear",
41
+ ])("settles the %s reply's native binding before releasing placement", async (kind) => {
42
+ const sessionKey = "agent:main:cli-binding-settlement";
43
+ const storePath = makeTestSessionStorePath();
44
+ const entry: SessionEntry = {
45
+ sessionId: "session",
46
+ updatedAt: 1,
47
+ ...(kind === "revised" ? { lifecycleRevision: "original" } : {}),
48
+ };
49
+ const rejected = kind === "rejected" || kind === "rejected-clear";
50
+ const revisionChanged = kind === "revised" || kind === "revision-established";
51
+ const binding = { sessionId: "admitted-native-session", authProfileId: "anthropic:cli" };
52
+ const settledBinding = { ...binding, sessionId: "settled-native-session" };
53
+ await replaceSessionEntry(
54
+ { sessionKey, storePath },
55
+ {
56
+ ...entry,
57
+ cliSessionBindings: { "claude-cli": binding },
58
+ },
59
+ );
60
+ const followupRun = createFollowupRun();
61
+ followupRun.run.provider = "claude-cli";
62
+ followupRun.run.model = "claude-sonnet-4-6";
63
+ if (kind === "preserved") {
64
+ followupRun.run.inputProvenance = {
65
+ kind: "inter_session",
66
+ sourceTool: "exec_approval_followup",
67
+ };
68
+ }
69
+ state.isCliProviderMock.mockImplementation((provider) => provider === "claude-cli");
70
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => {
71
+ const first = await params.run(
72
+ "claude-cli",
73
+ "claude-sonnet-4-6",
74
+ initialFallbackAttemptOptions(params),
75
+ );
76
+ if (rejected) {
77
+ expect(first).toMatchObject({
78
+ classification: { code: "generic_external_run_failure" },
79
+ });
80
+ return {
81
+ result: await params.run("openai", "gpt-5.4", fallbackAttemptOptions(params, "format")),
82
+ provider: "openai",
83
+ model: "gpt-5.4",
84
+ attempts: [{ provider: "claude-cli", model: "claude-sonnet-4-6", reason: "format" }],
85
+ };
86
+ }
87
+ return {
88
+ result: first,
89
+ provider: "claude-cli",
90
+ model: "claude-sonnet-4-6",
91
+ attempts: [],
92
+ };
93
+ });
94
+ const candidateResult = {
95
+ payloads: [{ text: "done" }],
96
+ meta: {
97
+ agentMeta: { sessionId: settledBinding.sessionId, cliSessionBinding: settledBinding },
98
+ },
99
+ };
100
+ state.runCliAgentMock.mockResolvedValueOnce(
101
+ rejected
102
+ ? buildCliRunResult({
103
+ context: buildPreparedCliRunContext(),
104
+ output: { text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT },
105
+ effectiveCliSessionId: settledBinding.sessionId,
106
+ bindingFlushOk: kind !== "rejected-clear",
107
+ usedHistoryPrompt: false,
108
+ userTurnHandled: true,
109
+ sessionBindingDisabled: false,
110
+ preparedContextAgentMeta: {},
111
+ })
112
+ : candidateResult,
113
+ );
114
+ state.runEmbeddedAgentMock.mockResolvedValueOnce({
115
+ payloads: [{ text: "fallback done" }],
116
+ meta: {},
117
+ });
118
+ let observedBinding: unknown;
119
+ const uninstall = installSessionPlacementAdmissionProvider({
120
+ assertCompactionSuccessorAllowed: rejectUnexpectedCompactionSuccessor,
121
+ executeLocalTurn: async (_claim, runLocal) => {
122
+ if (revisionChanged) {
123
+ // Mutating the prepared object must not change the captured admission revision.
124
+ entry.lifecycleRevision = "replacement";
125
+ await replaceSessionEntry(
126
+ { sessionKey, storePath },
127
+ {
128
+ ...entry,
129
+ cliSessionBindings: { "claude-cli": binding },
130
+ },
131
+ );
132
+ }
133
+ const result = await runLocal();
134
+ observedBinding = loadSessionEntry({ sessionKey, storePath })?.cliSessionBindings?.[
135
+ "claude-cli"
136
+ ];
137
+ return result;
138
+ },
139
+ executeTurn: async (_claim, _params, runLocal) => await runLocal(),
140
+ });
141
+ try {
142
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
143
+ const result = await executeAgentTurn({
144
+ ...createMinimalRunAgentTurnParams({ followupRun }),
145
+ sessionKey,
146
+ storePath,
147
+ isHeartbeat: kind === "heartbeat",
148
+ activeSessionStore: { [sessionKey]: entry },
149
+ getActiveSessionEntry: () => entry,
150
+ });
151
+ if (revisionChanged) {
152
+ expect(result.kind).toBe("final");
153
+ expect(state.runCliAgentMock).not.toHaveBeenCalled();
154
+ expect(
155
+ loadSessionEntry({ sessionKey, storePath })?.cliSessionBindings?.["claude-cli"],
156
+ ).toEqual(binding);
157
+ return;
158
+ }
159
+ expect(result.kind).toBe("success");
160
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
161
+ cliSessionId: binding.sessionId,
162
+ cliSessionBinding: binding,
163
+ });
164
+ expect(observedBinding).toEqual(
165
+ kind === "rejected-clear"
166
+ ? undefined
167
+ : kind === "preserved" || rejected
168
+ ? binding
169
+ : settledBinding,
170
+ );
171
+ } finally {
172
+ uninstall();
173
+ }
174
+ });
175
+
176
+ it.each(["ordinary", "rejected-clear", "room-event"] as const)(
177
+ "retains the %s reply after its continuity write loses ownership",
178
+ async (kind) => {
179
+ const sessionKey = "agent:main:cli-owner-loss";
180
+ const storePath = makeTestSessionStorePath();
181
+ const binding = { sessionId: "previous-native-session" };
182
+ const entry: SessionEntry = {
183
+ sessionId: "session",
184
+ updatedAt: 1,
185
+ cliSessionBindings: { "claude-cli": binding },
186
+ };
187
+ await replaceSessionEntry({ sessionKey, storePath }, entry);
188
+ const followupRun = createFollowupRun();
189
+ followupRun.run.provider = "claude-cli";
190
+ followupRun.run.model = "claude-sonnet-4-6";
191
+ if (kind === "room-event") {
192
+ followupRun.currentInboundEventKind = "room_event";
193
+ }
194
+ state.isCliProviderMock.mockImplementation((provider) => provider === "claude-cli");
195
+ const candidate = buildCliRunResult({
196
+ context: buildPreparedCliRunContext(),
197
+ output: {
198
+ text: kind === "rejected-clear" ? GENERIC_EXTERNAL_RUN_FAILURE_TEXT : "Captured reply",
199
+ },
200
+ effectiveCliSessionId: "replacement-native-session",
201
+ bindingFlushOk: kind !== "rejected-clear",
202
+ usedHistoryPrompt: false,
203
+ userTurnHandled: true,
204
+ sessionBindingDisabled: false,
205
+ preparedContextAgentMeta: {},
206
+ });
207
+ const provider: Parameters<typeof installSessionPlacementAdmissionProvider>[0] = {
208
+ assertCompactionSuccessorAllowed: rejectUnexpectedCompactionSuccessor,
209
+ executeLocalTurn: async (_claim, runLocal) => await runLocal(),
210
+ executeTurn: async (_claim, _params, runLocal) => await runLocal(),
211
+ };
212
+ const uninstall = installSessionPlacementAdmissionProvider(provider);
213
+ let uninstallReplacement: (() => void) | undefined;
214
+ state.runCliAgentMock.mockImplementationOnce(async () => {
215
+ uninstallReplacement = installSessionPlacementAdmissionProvider({ ...provider });
216
+ return candidate;
217
+ });
218
+ state.runWithModelFallbackMock.mockImplementationOnce(
219
+ async (params: FallbackRunnerParams) => {
220
+ const result = await params.run(
221
+ "claude-cli",
222
+ "claude-sonnet-4-6",
223
+ initialFallbackAttemptOptions(params),
224
+ );
225
+ expect(result).toMatchObject({
226
+ classification: null,
227
+ result: {
228
+ payloads: expect.arrayContaining(candidate.payloads ?? []),
229
+ meta: {
230
+ replayInvalid: true,
231
+ error: {
232
+ message: expect.stringContaining("CLI session continuity could not be saved"),
233
+ fallbackSafe: false,
234
+ },
235
+ },
236
+ },
237
+ });
238
+ return { result, provider: "claude-cli", model: "claude-sonnet-4-6", attempts: [] };
239
+ },
240
+ );
241
+ try {
242
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
243
+ await executeAgentTurn({
244
+ ...createMinimalRunAgentTurnParams({ followupRun }),
245
+ sessionKey,
246
+ storePath,
247
+ activeSessionStore: { [sessionKey]: entry },
248
+ getActiveSessionEntry: () => entry,
249
+ });
250
+ expect(state.runCliAgentMock).toHaveBeenCalledOnce();
251
+ expect(state.runEmbeddedAgentMock).not.toHaveBeenCalled();
252
+ expect(
253
+ loadSessionEntry({ sessionKey, storePath })?.cliSessionBindings?.["claude-cli"],
254
+ ).toEqual(binding);
255
+ } finally {
256
+ uninstallReplacement?.();
257
+ uninstall();
258
+ }
259
+ },
260
+ );
261
+
262
+ it("carries the admitted session permission and placement into the CLI grant", async () => {
263
+ state.isCliProviderMock.mockReturnValue(true);
264
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
265
+ result: await params.run(
266
+ "claude-cli",
267
+ "claude-sonnet-4-6",
268
+ initialFallbackAttemptOptions(params),
269
+ ),
270
+ provider: "claude-cli",
271
+ model: "claude-sonnet-4-6",
272
+ attempts: [],
273
+ }));
274
+ let sessionEntry: SessionEntry = {
275
+ sessionId: "session",
276
+ updatedAt: 1,
277
+ permissionMode: "guarded",
278
+ sessionRoot: "/workspace/old",
279
+ execHost: "gateway",
280
+ cliSessionBindings: {
281
+ "claude-cli": { sessionId: "old-native-session", forceReuse: true },
282
+ },
283
+ };
284
+ const admittedSessionEntry: SessionEntry = {
285
+ sessionId: "session",
286
+ updatedAt: 2,
287
+ permissionMode: "read-only",
288
+ sessionRoot: "/workspace/project",
289
+ execHost: "node",
290
+ execNode: "node-a",
291
+ execCwd: "/workspace/project/task",
292
+ cliSessionBindings: {
293
+ "claude-cli": { sessionId: "new-native-session", forceReuse: true },
294
+ },
295
+ };
296
+ state.runCliAgentMock.mockResolvedValueOnce({ payloads: [{ text: "done" }], meta: {} });
297
+ const followupRun = createFollowupRun();
298
+ followupRun.run.provider = "claude-cli";
299
+ followupRun.run.model = "claude-sonnet-4-6";
300
+ const restoreAdmission = installSessionPlacementAdmissionProvider({
301
+ assertCompactionSuccessorAllowed: rejectUnexpectedCompactionSuccessor,
302
+ executeLocalTurn: async (_claim, runLocal) => {
303
+ sessionEntry = admittedSessionEntry;
304
+ return await runLocal();
305
+ },
306
+ executeTurn: async (_claim, _params, runLocal) => await runLocal(),
307
+ });
308
+
309
+ try {
310
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
311
+ const result = await executeAgentTurn({
312
+ ...createMinimalRunAgentTurnParams({ followupRun }),
313
+ getActiveSessionEntry: () => sessionEntry,
314
+ });
315
+
316
+ expect(result.kind).toBe("success");
317
+ const run = requireMockCall(
318
+ state.runCliAgentMock,
319
+ 0,
320
+ "CLI run params",
321
+ )[0] as RunCliAgentParams;
322
+ expect(run.sessionEntry).toBe(admittedSessionEntry);
323
+ expect(run.sessionEntry?.sessionRoot).toBe("/workspace/project");
324
+ expect(run.cliSessionId).toBe("new-native-session");
325
+ expect(run.cliSessionBinding).toMatchObject({
326
+ sessionId: "new-native-session",
327
+ forceReuse: true,
328
+ });
329
+ const observedCliSessionId = run.cliSessionBinding?.sessionId ?? run.cliSessionId;
330
+ expect(observedCliSessionId).toBe("new-native-session");
331
+ if (!observedCliSessionId) {
332
+ throw new Error("expected admitted CLI session binding");
333
+ }
334
+ expect(
335
+ buildCliMcpGrantContext({
336
+ run,
337
+ config: run.config ?? {},
338
+ requireExplicitMessageTarget: false,
339
+ agentId: "main",
340
+ modelProvider: "anthropic",
341
+ modelId: "claude-sonnet-4-6",
342
+ }).execSession,
343
+ ).toMatchObject({
344
+ permissionMode: "read-only",
345
+ execHost: "node",
346
+ execNode: "node-a",
347
+ });
348
+
349
+ const nodeInvoke = vi.fn<typeof executeDeps.invokeNodeClaudeCliRun>(async (request) => {
350
+ expect(request.nodeId).toBe("node-a");
351
+ expect(request.argv).toContain("new-native-session");
352
+ expect(request.argv).not.toContain("old-native-session");
353
+ return {
354
+ ok: true,
355
+ payloadJSON: JSON.stringify({ exitCode: 0, stderrTail: "", truncated: false }),
356
+ };
357
+ });
358
+ const restoreNodeInvoke = executeDeps.invokeNodeClaudeCliRun;
359
+ const backend = {
360
+ command: "claude",
361
+ args: ["-p"],
362
+ resumeArgs: ["--resume", "{sessionId}"],
363
+ output: "text" as const,
364
+ input: "stdin" as const,
365
+ serialize: true,
366
+ };
367
+ const prepared = buildPreparedCliRunContext({
368
+ provider: "claude-cli",
369
+ model: run.model,
370
+ runId: run.runId,
371
+ workspaceDir: run.workspaceDir,
372
+ config: run.config,
373
+ sessionEntry: run.sessionEntry,
374
+ backend,
375
+ });
376
+ prepared.params = {
377
+ ...run,
378
+ admittedRunContext: prepared.params.admittedRunContext,
379
+ skillsSnapshot: undefined,
380
+ };
381
+ prepared.cwd = run.cwd;
382
+ prepared.reusableCliSession = { mode: "reuse", sessionId: observedCliSessionId };
383
+ executeDeps.invokeNodeClaudeCliRun = nodeInvoke;
384
+ try {
385
+ await withTestRunAdmission(prepared.params, async (admittedRunContext) => {
386
+ prepared.params.admittedRunContext = admittedRunContext;
387
+ await executePreparedCliRun(prepared, observedCliSessionId);
388
+ });
389
+ } finally {
390
+ executeDeps.invokeNodeClaudeCliRun = restoreNodeInvoke;
391
+ }
392
+ expect(nodeInvoke).toHaveBeenCalledOnce();
393
+ } finally {
394
+ restoreAdmission();
395
+ }
396
+ });
397
+ });
src/auto-reply/reply/agent-runner-execution-cli-auth.test.ts ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from "node:path";
2
+ import { describe, expect, it, onTestFinished } from "vitest";
3
+ import { saveAuthProfileStore } from "../../agents/auth-profiles/store-runtime.js";
4
+ import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
5
+ import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js";
6
+ import { closeOpenClawAgentDatabaseByPath } from "../../state/openclaw-agent-db.js";
7
+ import {
8
+ createFollowupRun,
9
+ createMinimalRunAgentTurnParams,
10
+ expectMockCallArgFields,
11
+ fallbackAttemptOptions,
12
+ getExecuteAgentTurnForTest,
13
+ setupAgentRunnerExecutionTestState,
14
+ type FallbackRunnerParams,
15
+ } from "./agent-runner-execution.test-support.js";
16
+
17
+ const state = await setupAgentRunnerExecutionTestState();
18
+ const managedProfile = "claude-cli:managed";
19
+ const canonicalProfile = "anthropic:managed";
20
+ const primaryProfile = "openai:primary";
21
+ const googleProfile = "google:managed";
22
+ const nativeProfile = "anthropic:claude-cli";
23
+ const customProfile = "custom-cli:managed";
24
+ const credentials = {
25
+ [managedProfile]: { type: "token", provider: "claude-cli", token: "synthetic-managed-token" },
26
+ [canonicalProfile]: { type: "token", provider: "anthropic", token: "synthetic-canonical-token" },
27
+ [primaryProfile]: { type: "api_key", provider: "openai", key: "synthetic-primary-key" },
28
+ [googleProfile]: { type: "api_key", provider: "google", key: "synthetic-google-key" },
29
+ [nativeProfile]: { type: "token", provider: "claude-cli", token: "synthetic-native-token" },
30
+ [customProfile]: { type: "token", provider: "custom-cli", token: "synthetic-custom-token" },
31
+ } satisfies Record<string, AuthProfileCredential>;
32
+
33
+ describe("executeAgentTurn: CLI credential selection", () => {
34
+ it.each([
35
+ {
36
+ name: "selects ordered CLI credentials after an automatic cross-provider fallback",
37
+ selected: primaryProfile,
38
+ source: "auto",
39
+ primary: "openai",
40
+ provider: "claude-cli",
41
+ backend: "claude-cli",
42
+ profiles: [primaryProfile, managedProfile],
43
+ expected: managedProfile,
44
+ },
45
+ {
46
+ name: "rejects an incompatible explicit account before CLI execution",
47
+ selected: primaryProfile,
48
+ source: "user",
49
+ primary: "openai",
50
+ provider: "claude-cli",
51
+ backend: "claude-cli",
52
+ profiles: [primaryProfile, managedProfile],
53
+ error: 'cannot use auth profile "openai:primary"',
54
+ },
55
+ {
56
+ name: "forwards explicitly selected canonical credentials",
57
+ selected: canonicalProfile,
58
+ source: "user",
59
+ primary: "anthropic",
60
+ provider: "claude-cli",
61
+ backend: "claude-cli",
62
+ profiles: [canonicalProfile, managedProfile],
63
+ expected: canonicalProfile,
64
+ },
65
+ {
66
+ name: "preserves a selected native login even when a managed account exists",
67
+ selected: nativeProfile,
68
+ source: "user",
69
+ primary: "claude-cli",
70
+ provider: "claude-cli",
71
+ backend: "claude-cli",
72
+ profiles: [nativeProfile, managedProfile],
73
+ expected: undefined,
74
+ },
75
+ {
76
+ name: "uses the executing Google candidate for canonical API-key fallback",
77
+ selected: primaryProfile,
78
+ source: "auto",
79
+ primary: "openai",
80
+ provider: "google",
81
+ backend: "google-gemini-cli",
82
+ profiles: [primaryProfile, googleProfile],
83
+ expected: googleProfile,
84
+ },
85
+ {
86
+ name: "retains existing scoped forwarding for a custom CLI backend",
87
+ selected: customProfile,
88
+ source: "user",
89
+ primary: "custom-cli",
90
+ provider: "custom-cli",
91
+ backend: "custom-cli",
92
+ profiles: [customProfile],
93
+ expected: customProfile,
94
+ },
95
+ ] as const)("$name", async (testCase) => {
96
+ const followupRun = createFollowupRun();
97
+ onTestFinished(() => {
98
+ closeOpenClawAgentDatabaseByPath(
99
+ path.join(followupRun.run.agentDir, "openclaw-agent.sqlite"),
100
+ );
101
+ });
102
+ const model = "test-model";
103
+ followupRun.run.provider = testCase.primary;
104
+ followupRun.run.model = model;
105
+ followupRun.run.authProfileId = testCase.selected;
106
+ followupRun.run.authProfileIdSource = testCase.source;
107
+ followupRun.run.thinkingCatalog = [{ provider: testCase.provider, id: model, input: ["text"] }];
108
+ const profiles = Object.fromEntries(testCase.profiles.map((id) => [id, credentials[id]]));
109
+ followupRun.run.config = {
110
+ auth: {
111
+ order: Object.fromEntries(testCase.profiles.map((id) => [credentials[id].provider, [id]])),
112
+ },
113
+ agents: {
114
+ defaults: {
115
+ models: { [`${testCase.provider}/${model}`]: { agentRuntime: { id: testCase.backend } } },
116
+ },
117
+ },
118
+ };
119
+ saveAuthProfileStore({ version: 1, profiles }, followupRun.run.agentDir, {
120
+ filterExternalAuthProfiles: false,
121
+ syncExternalCli: false,
122
+ });
123
+ cliBackendsTesting.setDepsForTest({
124
+ resolveRuntimeCliBackends: () => [
125
+ {
126
+ id: "claude-cli",
127
+ modelProvider: "anthropic",
128
+ pluginId: "anthropic",
129
+ config: { command: "claude" },
130
+ },
131
+ {
132
+ id: "google-gemini-cli",
133
+ modelProvider: "google",
134
+ pluginId: "google",
135
+ config: { command: "gemini" },
136
+ },
137
+ {
138
+ id: "custom-cli",
139
+ modelProvider: "custom-cli",
140
+ pluginId: "custom",
141
+ config: { command: "custom" },
142
+ },
143
+ ],
144
+ resolvePluginSetupCliBackend: () => undefined,
145
+ });
146
+ state.isCliProviderMock.mockImplementation((provider) => provider === testCase.backend);
147
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
148
+ result: await params.run(
149
+ testCase.provider,
150
+ model,
151
+ fallbackAttemptOptions(params, "rate_limit"),
152
+ ),
153
+ provider: testCase.provider,
154
+ model,
155
+ attempts: [],
156
+ }));
157
+ state.runCliAgentMock.mockResolvedValueOnce({ payloads: [{ text: "done" }], meta: {} });
158
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
159
+ const result = executeAgentTurn(createMinimalRunAgentTurnParams({ followupRun }));
160
+ if ("error" in testCase) {
161
+ expect(await result).toMatchObject({ kind: "final", payload: { isError: true } });
162
+ expect(state.runCliAgentMock).not.toHaveBeenCalled();
163
+ return;
164
+ }
165
+ expect((await result).kind).toBe("success");
166
+ expect(state.runCliAgentMock).toHaveBeenCalledOnce();
167
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI credential handoff", {
168
+ provider: testCase.backend,
169
+ authProfileId: testCase.expected,
170
+ });
171
+ });
172
+ });
src/auto-reply/reply/agent-runner-execution-cli-commentary.test.ts ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js";
3
+ import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
4
+ import type { TemplateContext } from "../templating.js";
5
+ import type { GetReplyOptions } from "../types.js";
6
+ import {
7
+ createFollowupRun,
8
+ initialFallbackAttemptOptions,
9
+ createMockTypingSignaler,
10
+ getExecuteAgentTurnForTest,
11
+ loadActualRunCliAgentForTest,
12
+ setupAgentRunnerExecutionTestState,
13
+ } from "./agent-runner-execution.test-support.js";
14
+ import type { FallbackRunnerParams } from "./agent-runner-execution.test-support.js";
15
+
16
+ const state = await setupAgentRunnerExecutionTestState();
17
+
18
+ const scriptedCliProgram = String.raw`
19
+ let input = "";
20
+ process.stdin.setEncoding("utf8");
21
+ process.stdin.on("data", (chunk) => { input += chunk; });
22
+ process.stdin.on("end", () => {
23
+ if (!input.trim()) process.exit(2);
24
+ const events = [
25
+ { type: "init", session_id: "scripted-commentary" },
26
+ {
27
+ type: "stream_event",
28
+ event: {
29
+ type: "content_block_delta",
30
+ delta: { type: "text_delta", text: "The subprocess findings are durable." },
31
+ },
32
+ },
33
+ {
34
+ type: "stream_event",
35
+ event: {
36
+ type: "content_block_start",
37
+ index: 1,
38
+ content_block: { type: "tool_use", id: "tool-scripted", name: "Read", input: {} },
39
+ },
40
+ },
41
+ {
42
+ type: "stream_event",
43
+ event: {
44
+ type: "content_block_delta",
45
+ delta: { type: "text_delta", text: "Subprocess final answer." },
46
+ },
47
+ },
48
+ { type: "stream_event", event: { type: "message_stop" } },
49
+ { type: "result", session_id: "scripted-commentary", result: "Subprocess final answer." },
50
+ ];
51
+ process.stdout.write(events.map((event) => JSON.stringify(event)).join("\n") + "\n");
52
+ });
53
+ `;
54
+
55
+ function useClaudeCliFallback() {
56
+ state.isCliProviderMock.mockReturnValue(true);
57
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
58
+ result: await params.run(
59
+ "claude-cli",
60
+ "claude-opus-4-6",
61
+ initialFallbackAttemptOptions(params),
62
+ ),
63
+ provider: "claude-cli",
64
+ model: "claude-opus-4-6",
65
+ attempts: [],
66
+ }));
67
+ }
68
+
69
+ function useScriptedClaudeCliBackend() {
70
+ const backend = {
71
+ id: "claude-cli",
72
+ modelProvider: "anthropic",
73
+ pluginId: "anthropic",
74
+ bundleMcp: false,
75
+ contextEngineHostCapabilities: ["thread-bootstrap-projection"] as const,
76
+ config: {
77
+ command: process.execPath,
78
+ args: ["-e", scriptedCliProgram],
79
+ input: "stdin" as const,
80
+ output: "jsonl" as const,
81
+ jsonlDialect: "claude-stream-json" as const,
82
+ sessionMode: "none" as const,
83
+ systemPromptWhen: "never" as const,
84
+ },
85
+ };
86
+ cliBackendsTesting.setDepsForTest({
87
+ resolvePluginSetupCliBackend: ({ backend: id }) =>
88
+ id === backend.id ? { pluginId: backend.pluginId, backend } : undefined,
89
+ resolveRuntimeCliBackends: () => [backend],
90
+ });
91
+ state.runCliAgentMock.mockImplementationOnce(async (params: RunCliAgentParams) => {
92
+ return await (
93
+ await loadActualRunCliAgentForTest()
94
+ )(params);
95
+ });
96
+ }
97
+
98
+ function createClaudeCliFollowupRun() {
99
+ const followupRun = createFollowupRun();
100
+ followupRun.run.agentId = "agent";
101
+ followupRun.run.provider = "claude-cli";
102
+ followupRun.run.model = "claude-opus-4-6";
103
+ followupRun.run.skillsSnapshot = { prompt: "", skills: [], version: 0 };
104
+ followupRun.run.timeoutMs = 10_000;
105
+ return followupRun;
106
+ }
107
+
108
+ function createTurnParams(opts: GetReplyOptions, blockStreamingEnabled: boolean) {
109
+ return {
110
+ commandBody: "hi",
111
+ followupRun: createClaudeCliFollowupRun(),
112
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
113
+ opts,
114
+ typingSignals: createMockTypingSignaler(),
115
+ blockReplyPipeline: null,
116
+ blockStreamingEnabled,
117
+ resolvedBlockStreamingBreak: "message_end" as const,
118
+ applyReplyToMode: <T>(payload: T) => payload,
119
+ shouldEmitToolResult: () => true,
120
+ shouldEmitToolOutput: () => false,
121
+ pendingToolTasks: new Set<Promise<void>>(),
122
+ resetSessionAfterRoleOrderingConflict: async () => false,
123
+ isHeartbeat: false,
124
+ sessionKey: "main",
125
+ getActiveSessionEntry: () => undefined,
126
+ resolvedVerboseLevel: "off" as const,
127
+ };
128
+ }
129
+
130
+ describe("executeAgentTurn: CLI durable commentary", () => {
131
+ it("delivers commentary from a real JSONL CLI subprocess", async () => {
132
+ useClaudeCliFallback();
133
+ useScriptedClaudeCliBackend();
134
+ state.createBlockReplyDeliveryHandlerMock.mockImplementationOnce(
135
+ (params: { onBlockReply: NonNullable<GetReplyOptions["onBlockReply"]> }) =>
136
+ params.onBlockReply,
137
+ );
138
+ const onBlockReply = vi.fn<NonNullable<GetReplyOptions["onBlockReply"]>>(async () => undefined);
139
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
140
+ const result = await executeAgentTurn(createTurnParams({ onBlockReply }, true));
141
+
142
+ expect(state.runCliAgentMock.mock.calls[0]?.[0]).toMatchObject({ emitCommentaryText: true });
143
+ const resolveContextEngineHost = state.runEmbeddedAgentEntryMock.mock.calls[0]?.[0]?.harness
144
+ ?.resolveContextEngineHost as
145
+ | ((
146
+ provider: string,
147
+ model: string,
148
+ ) => {
149
+ id: string;
150
+ label: string;
151
+ capabilities: readonly string[];
152
+ })
153
+ | undefined;
154
+ expect(resolveContextEngineHost?.("claude-cli", "claude-opus-4-6")).toEqual({
155
+ id: "cli:claude-cli",
156
+ label: 'CLI backend "claude-cli"',
157
+ capabilities: ["thread-bootstrap-projection"],
158
+ });
159
+ expect(result.kind).toBe("success");
160
+ if (result.kind === "success") {
161
+ expect(result.runResult.payloads).toEqual([{ text: "Subprocess final answer." }]);
162
+ }
163
+ expect(onBlockReply).toHaveBeenCalledExactlyOnceWith({
164
+ text: "The subprocess findings are durable.",
165
+ });
166
+ });
167
+
168
+ it("delivers completed CLI commentary through block streaming", async () => {
169
+ useClaudeCliFallback();
170
+ state.createBlockReplyDeliveryHandlerMock.mockImplementationOnce(
171
+ (params: { onBlockReply: NonNullable<GetReplyOptions["onBlockReply"]> }) =>
172
+ params.onBlockReply,
173
+ );
174
+ state.runCliAgentMock.mockImplementationOnce(
175
+ async (params: { runId: string; emitCommentaryText?: boolean }) => {
176
+ expect(params.emitCommentaryText).toBe(true);
177
+ const agentEvents = await import("../../infra/agent-events.js");
178
+ agentEvents.emitAgentEvent({
179
+ runId: params.runId,
180
+ stream: "item",
181
+ data: {
182
+ kind: "preamble",
183
+ itemId: "commentary-durable-1",
184
+ progressText: "The durable findings live here.",
185
+ },
186
+ });
187
+ return { payloads: [{ text: "Short final wrap-up." }], meta: {} };
188
+ },
189
+ );
190
+
191
+ const onBlockReply = vi.fn<NonNullable<GetReplyOptions["onBlockReply"]>>(async () => undefined);
192
+ const onItemEvent = vi.fn<NonNullable<GetReplyOptions["onItemEvent"]>>(async () => undefined);
193
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
194
+ const result = await executeAgentTurn(
195
+ createTurnParams(
196
+ {
197
+ onBlockReply,
198
+ onItemEvent,
199
+ commentaryProgressEnabled: false,
200
+ progressPreambleEnabled: true,
201
+ },
202
+ true,
203
+ ),
204
+ );
205
+
206
+ await vi.waitFor(() => {
207
+ expect(onBlockReply).toHaveBeenCalledExactlyOnceWith({
208
+ text: "The durable findings live here.",
209
+ });
210
+ expect(onItemEvent).toHaveBeenCalledExactlyOnceWith({
211
+ itemId: "commentary-durable-1",
212
+ kind: "preamble",
213
+ progressText: "The durable findings live here.",
214
+ suppressDurableProgress: true,
215
+ });
216
+ });
217
+ expect(result.kind).toBe("success");
218
+ if (result.kind === "success") {
219
+ expect(result.runResult.payloads).toEqual([{ text: "Short final wrap-up." }]);
220
+ }
221
+ });
222
+
223
+ it("forwards channel presentation callbacks to native CLI input", async () => {
224
+ useClaudeCliFallback();
225
+ state.createBlockReplyDeliveryHandlerMock.mockImplementationOnce(
226
+ (params: { onBlockReply: NonNullable<GetReplyOptions["onBlockReply"]> }) =>
227
+ params.onBlockReply,
228
+ );
229
+ state.runCliAgentMock.mockImplementationOnce(async (params: RunCliAgentParams) => {
230
+ await params.onBlockReply?.({
231
+ text: "Which color?",
232
+ presentation: {
233
+ blocks: [
234
+ {
235
+ type: "buttons",
236
+ buttons: [
237
+ {
238
+ label: "Blue",
239
+ action: { type: "question", questionId: "question-1", optionValue: "Blue" },
240
+ },
241
+ ],
242
+ },
243
+ ],
244
+ },
245
+ });
246
+ await params.onPartialReply?.({ text: "Fallback question" });
247
+ return { payloads: [{ text: "Answered." }], meta: {} };
248
+ });
249
+
250
+ const onBlockReply = vi.fn<NonNullable<GetReplyOptions["onBlockReply"]>>(async () => undefined);
251
+ const onPartialReply = vi.fn<NonNullable<GetReplyOptions["onPartialReply"]>>(
252
+ async () => undefined,
253
+ );
254
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
255
+
256
+ await executeAgentTurn(createTurnParams({ onBlockReply, onPartialReply }, false));
257
+
258
+ expect(onBlockReply).toHaveBeenCalledWith(
259
+ expect.objectContaining({ text: "Which color?", presentation: expect.any(Object) }),
260
+ );
261
+ expect(onPartialReply).toHaveBeenCalledWith({ text: "Fallback question" });
262
+ });
263
+
264
+ it("delivers commentary payloads without block streaming", async () => {
265
+ useClaudeCliFallback();
266
+ state.createBlockReplyDeliveryHandlerMock.mockImplementationOnce(
267
+ (params: { onBlockReply: NonNullable<GetReplyOptions["onBlockReply"]> }) =>
268
+ params.onBlockReply,
269
+ );
270
+ state.runCliAgentMock.mockImplementationOnce(
271
+ async (params: { runId: string; emitCommentaryText?: boolean }) => {
272
+ expect(params.emitCommentaryText).toBe(true);
273
+ const agentEvents = await import("../../infra/agent-events.js");
274
+ agentEvents.emitAgentEvent({
275
+ runId: params.runId,
276
+ stream: "item",
277
+ data: {
278
+ kind: "preamble",
279
+ itemId: "commentary-payload-1",
280
+ progressText: "A durable commentary update.",
281
+ },
282
+ });
283
+ return { payloads: [{ text: "Final answer." }], meta: {} };
284
+ },
285
+ );
286
+
287
+ const onBlockReply = vi.fn<NonNullable<GetReplyOptions["onBlockReply"]>>(async () => undefined);
288
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
289
+ const result = await executeAgentTurn(
290
+ createTurnParams({ onBlockReply, commentaryPayloadsEnabled: true }, false),
291
+ );
292
+
293
+ await vi.waitFor(() => {
294
+ expect(onBlockReply).toHaveBeenCalledExactlyOnceWith({
295
+ text: "A durable commentary update.",
296
+ isCommentary: true,
297
+ });
298
+ });
299
+ expect(result.kind).toBe("success");
300
+ if (result.kind === "success") {
301
+ expect(result.runResult.payloads).toEqual([{ text: "Final answer." }]);
302
+ }
303
+ });
304
+ });
src/auto-reply/reply/agent-runner-execution-cli-delegation.test.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildCliMcpGrantContext } from "../../agents/cli-runner/mcp-grant-context.js";
3
+ import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
4
+ import {
5
+ createFollowupRun,
6
+ createMinimalRunAgentTurnParams,
7
+ fallbackAttemptOptions,
8
+ getExecuteAgentTurnForTest,
9
+ initialFallbackAttemptOptions,
10
+ requireMockCall,
11
+ setupAgentRunnerExecutionTestState,
12
+ } from "./agent-runner-execution.test-support.js";
13
+ import type { FallbackRunnerParams } from "./agent-runner-execution.test-support.js";
14
+
15
+ const state = await setupAgentRunnerExecutionTestState();
16
+
17
+ function resolveMockedCliGrantCapability() {
18
+ const run = requireMockCall(state.runCliAgentMock, 0, "CLI run params")[0] as RunCliAgentParams;
19
+ return buildCliMcpGrantContext({
20
+ run,
21
+ config: run.config ?? {},
22
+ requireExplicitMessageTarget: false,
23
+ agentId: "main",
24
+ modelProvider: "openai",
25
+ modelId: "gpt-5.4",
26
+ }).delegationCapability;
27
+ }
28
+
29
+ describe("executeAgentTurn: CLI delegation grants", () => {
30
+ it("keeps primary CLI completion grants unrestricted", async () => {
31
+ state.isCliProviderMock.mockReturnValue(true);
32
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
33
+ result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
34
+ provider: "codex-cli",
35
+ model: "gpt-5.4",
36
+ attempts: [],
37
+ }));
38
+ state.runCliAgentMock.mockResolvedValue({ payloads: [{ text: "done" }], meta: {} });
39
+ const followupRun = createFollowupRun();
40
+ followupRun.run.provider = "codex-cli";
41
+ followupRun.run.model = "gpt-5.4";
42
+ followupRun.run.inputProvenance = {
43
+ kind: "inter_session",
44
+ sourceTool: "subagent_announce",
45
+ };
46
+
47
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
48
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams({ followupRun }));
49
+
50
+ expect(result.kind).toBe("success");
51
+ expect(resolveMockedCliGrantCapability()).toBeUndefined();
52
+ });
53
+
54
+ it("restricts fallback CLI completion grants", async () => {
55
+ state.isCliProviderMock.mockImplementation((provider) => provider === "codex-cli");
56
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => {
57
+ await params.run("anthropic", "claude", initialFallbackAttemptOptions(params));
58
+ return {
59
+ result: await params.run("codex-cli", "gpt-5.4", fallbackAttemptOptions(params, "unknown")),
60
+ provider: "codex-cli",
61
+ model: "gpt-5.4",
62
+ attempts: [{ provider: "anthropic", model: "claude", error: "rate limit" }],
63
+ };
64
+ });
65
+ state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [], meta: {} });
66
+ state.runCliAgentMock.mockResolvedValue({ payloads: [{ text: "done" }], meta: {} });
67
+ const followupRun = createFollowupRun();
68
+ followupRun.run.inputProvenance = {
69
+ kind: "inter_session",
70
+ sourceTool: "subagent_announce",
71
+ };
72
+
73
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
74
+ const result = await executeAgentTurn(createMinimalRunAgentTurnParams({ followupRun }));
75
+
76
+ expect(result.kind).toBe("success");
77
+ expect(resolveMockedCliGrantCapability()).toBe("report_only");
78
+ });
79
+ });
src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts ADDED
@@ -0,0 +1,897 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, onTestFinished, vi } from "vitest";
2
+ import { createDeferred } from "../../../test/helpers/promise.js";
3
+ import { createCliJsonlStreamingParser } from "../../agents/cli-output-stream.js";
4
+ import type { TemplateContext } from "../templating.js";
5
+ import type { GetReplyOptions } from "../types.js";
6
+ import {
7
+ createAgentTurnExecutionDefaults,
8
+ setupAgentRunnerExecutionTestState,
9
+ getExecuteAgentTurnForTest,
10
+ createMockTypingSignaler,
11
+ createFollowupRun,
12
+ initialFallbackAttemptOptions,
13
+ runInitialFallbackAttempt,
14
+ createMinimalRunAgentTurnParams,
15
+ } from "./agent-runner-execution.test-support.js";
16
+ import type {
17
+ FallbackRunnerParams,
18
+ EmbeddedAgentParams,
19
+ } from "./agent-runner-execution.test-support.js";
20
+
21
+ const state = await setupAgentRunnerExecutionTestState();
22
+
23
+ describe("executeAgentTurn: CLI progress bridging", () => {
24
+ it("bridges CLI assistant agent events into onPartialReply for live preview (#76869)", async () => {
25
+ state.isCliProviderMock.mockReturnValue(true);
26
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
27
+ result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
28
+ provider: "claude-cli",
29
+ model: "claude-opus-4-6",
30
+ attempts: [],
31
+ }));
32
+ state.runCliAgentMock.mockImplementationOnce(
33
+ async (params: { runId: string; emitCommentaryText?: boolean }) => {
34
+ expect(params.emitCommentaryText).toBe(false);
35
+ const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
36
+ "../../infra/agent-events.js",
37
+ );
38
+ realAgentEvents.emitAgentEvent({
39
+ runId: params.runId,
40
+ stream: "assistant",
41
+ data: { text: "Hello", delta: "Hello" },
42
+ });
43
+ realAgentEvents.emitAgentEvent({
44
+ runId: params.runId,
45
+ stream: "assistant",
46
+ data: { text: "Hello world", delta: " world" },
47
+ });
48
+ return { payloads: [{ text: "Hello world" }], meta: {} };
49
+ },
50
+ );
51
+
52
+ const onPartialReply = vi.fn<NonNullable<GetReplyOptions["onPartialReply"]>>(
53
+ async (_payload) => undefined,
54
+ );
55
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
56
+ const followupRun = createFollowupRun();
57
+ followupRun.run.provider = "claude-cli";
58
+ followupRun.run.model = "claude-opus-4-6";
59
+
60
+ await executeAgentTurn({
61
+ commandBody: "hi",
62
+ followupRun,
63
+ sessionCtx: {
64
+ Provider: "telegram",
65
+ MessageSid: "msg",
66
+ } as unknown as TemplateContext,
67
+ opts: { onPartialReply },
68
+ typingSignals: createMockTypingSignaler(),
69
+ ...createAgentTurnExecutionDefaults(),
70
+ });
71
+
72
+ const partialTexts = onPartialReply.mock.calls.map((call) => call[0].text);
73
+ expect(partialTexts).toEqual(["Hello", "Hello world"]);
74
+ });
75
+
76
+ it("serializes and drains bridged CLI assistant previews before completing (#76869)", async () => {
77
+ state.isCliProviderMock.mockReturnValue(true);
78
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
79
+ result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
80
+ provider: "claude-cli",
81
+ model: "claude-opus-4-6",
82
+ attempts: [],
83
+ }));
84
+ state.runCliAgentMock.mockImplementationOnce(
85
+ async (params: { runId: string; emitCommentaryText?: boolean }) => {
86
+ expect(params.emitCommentaryText).toBe(false);
87
+ const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
88
+ "../../infra/agent-events.js",
89
+ );
90
+ realAgentEvents.emitAgentEvent({
91
+ runId: params.runId,
92
+ stream: "assistant",
93
+ data: { text: "Hello", delta: "Hello" },
94
+ });
95
+ realAgentEvents.emitAgentEvent({
96
+ runId: params.runId,
97
+ stream: "assistant",
98
+ data: { text: "Hello world", delta: " world" },
99
+ });
100
+ return { payloads: [{ text: "Hello world" }], meta: {} };
101
+ },
102
+ );
103
+
104
+ let firstPreviewStarted: (() => void) | undefined;
105
+ let releaseFirstPreview: (() => void) | undefined;
106
+ const firstPreviewPromise = new Promise<void>((resolve) => {
107
+ firstPreviewStarted = resolve;
108
+ });
109
+ const previewOrder: string[] = [];
110
+ const onPartialReply = vi.fn<NonNullable<GetReplyOptions["onPartialReply"]>>(
111
+ async (payload) => {
112
+ previewOrder.push(payload.text ?? "");
113
+ if (payload.text === "Hello") {
114
+ firstPreviewStarted?.();
115
+ await new Promise<void>((resolve) => {
116
+ releaseFirstPreview = resolve;
117
+ });
118
+ previewOrder.push("Hello released");
119
+ }
120
+ },
121
+ );
122
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
123
+ const followupRun = createFollowupRun();
124
+ followupRun.run.provider = "claude-cli";
125
+ followupRun.run.model = "claude-opus-4-6";
126
+
127
+ const runPromise = executeAgentTurn({
128
+ commandBody: "hi",
129
+ followupRun,
130
+ sessionCtx: {
131
+ Provider: "telegram",
132
+ MessageSid: "msg",
133
+ } as unknown as TemplateContext,
134
+ opts: { onPartialReply },
135
+ typingSignals: createMockTypingSignaler(),
136
+ ...createAgentTurnExecutionDefaults(),
137
+ });
138
+
139
+ await firstPreviewPromise;
140
+ await new Promise((resolve) => {
141
+ setImmediate(resolve);
142
+ });
143
+ expect(previewOrder).toEqual(["Hello"]);
144
+
145
+ releaseFirstPreview?.();
146
+ await runPromise;
147
+
148
+ expect(previewOrder).toEqual(["Hello", "Hello released", "Hello world"]);
149
+ });
150
+
151
+ it("bridges CLI tool agent events into onToolStart for live preview", async () => {
152
+ state.isCliProviderMock.mockReturnValue(true);
153
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
154
+ result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
155
+ provider: "claude-cli",
156
+ model: "claude-opus-4-6",
157
+ attempts: [],
158
+ }));
159
+ state.runCliAgentMock.mockImplementationOnce(
160
+ async (params: { runId: string; emitCommentaryText?: boolean }) => {
161
+ expect(params.emitCommentaryText).toBe(false);
162
+ const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
163
+ "../../infra/agent-events.js",
164
+ );
165
+ realAgentEvents.emitAgentEvent({
166
+ runId: params.runId,
167
+ stream: "tool",
168
+ data: {
169
+ phase: "start",
170
+ name: "Bash",
171
+ toolCallId: "toolu_01ABCD",
172
+ args: { command: "ls -la" },
173
+ },
174
+ });
175
+ realAgentEvents.emitAgentEvent({
176
+ runId: params.runId,
177
+ stream: "tool",
178
+ data: {
179
+ phase: "result",
180
+ name: "Bash",
181
+ toolCallId: "toolu_01ABCD",
182
+ isError: false,
183
+ },
184
+ });
185
+ return { payloads: [{ text: "done" }], meta: {} };
186
+ },
187
+ );
188
+
189
+ const onToolStart = vi.fn<NonNullable<GetReplyOptions["onToolStart"]>>(async () => undefined);
190
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
191
+ const followupRun = createFollowupRun();
192
+ followupRun.run.provider = "claude-cli";
193
+ followupRun.run.model = "claude-opus-4-6";
194
+
195
+ await executeAgentTurn({
196
+ commandBody: "hi",
197
+ followupRun,
198
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
199
+ opts: { onToolStart },
200
+ typingSignals: createMockTypingSignaler(),
201
+ ...createAgentTurnExecutionDefaults(),
202
+ });
203
+ await new Promise((resolve) => {
204
+ setImmediate(resolve);
205
+ });
206
+
207
+ expect(onToolStart).toHaveBeenCalledTimes(1);
208
+ const call = onToolStart.mock.calls[0]?.[0];
209
+ expect(call?.name).toBe("Bash");
210
+ expect(call?.phase).toBe("start");
211
+ expect(call?.args).toEqual({ command: "ls -la" });
212
+ });
213
+
214
+ it("starts CLI assistant progress before a later tool while typing is slow", async () => {
215
+ state.isCliProviderMock.mockReturnValue(true);
216
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
217
+ result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
218
+ provider: "claude-cli",
219
+ model: "claude-opus-4-6",
220
+ attempts: [],
221
+ }));
222
+ state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
223
+ const agentEvents = await import("../../infra/agent-events.js");
224
+ agentEvents.emitAgentEvent({
225
+ runId: params.runId,
226
+ stream: "assistant",
227
+ data: { text: "answer before tool", delta: "answer before tool" },
228
+ });
229
+ agentEvents.emitAgentEvent({
230
+ runId: params.runId,
231
+ stream: "assistant",
232
+ data: { text: "answer before tool 2", delta: " 2" },
233
+ });
234
+ agentEvents.emitAgentEvent({
235
+ runId: params.runId,
236
+ stream: "tool",
237
+ data: {
238
+ phase: "start",
239
+ name: "Bash",
240
+ toolCallId: "toolu_order",
241
+ args: { command: "echo hi" },
242
+ },
243
+ });
244
+ return { payloads: [{ text: "final" }], meta: {} };
245
+ });
246
+
247
+ let releaseTyping: (() => void) | undefined;
248
+ const typingPending = new Promise<void>((resolve) => {
249
+ releaseTyping = resolve;
250
+ });
251
+ const typingSignals = createMockTypingSignaler();
252
+ vi.mocked(typingSignals.signalTextDelta).mockReturnValue(typingPending);
253
+ const callbackOrder: string[] = [];
254
+ const toolStarted = createDeferred();
255
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
256
+ const followupRun = createFollowupRun();
257
+ followupRun.run.provider = "claude-cli";
258
+ followupRun.run.model = "claude-opus-4-6";
259
+ const runPromise = executeAgentTurn({
260
+ commandBody: "hi",
261
+ followupRun,
262
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
263
+ opts: {
264
+ preserveProgressCallbackStartOrder: true,
265
+ onPartialReply: (payload) => {
266
+ callbackOrder.push(`partial:${payload.text}`);
267
+ },
268
+ onToolStart: () => {
269
+ callbackOrder.push("tool");
270
+ toolStarted.resolve();
271
+ },
272
+ },
273
+ typingSignals,
274
+ ...createAgentTurnExecutionDefaults(),
275
+ });
276
+ onTestFinished(async () => {
277
+ releaseTyping?.();
278
+ await runPromise;
279
+ });
280
+
281
+ try {
282
+ await Promise.race([toolStarted.promise, runPromise]);
283
+ expect(callbackOrder).toEqual([
284
+ "partial:answer before tool",
285
+ "partial:answer before tool 2",
286
+ "tool",
287
+ ]);
288
+ } finally {
289
+ releaseTyping?.();
290
+ await runPromise;
291
+ }
292
+ });
293
+
294
+ it("starts CLI tool progress before later assistant text while typing is slow", async () => {
295
+ state.isCliProviderMock.mockReturnValue(true);
296
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
297
+ result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
298
+ provider: "claude-cli",
299
+ model: "claude-opus-4-6",
300
+ attempts: [],
301
+ }));
302
+ state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
303
+ const agentEvents = await import("../../infra/agent-events.js");
304
+ agentEvents.emitAgentEvent({
305
+ runId: params.runId,
306
+ stream: "tool",
307
+ data: {
308
+ phase: "start",
309
+ name: "Bash",
310
+ toolCallId: "toolu_inverse_order",
311
+ args: { command: "echo hi" },
312
+ },
313
+ });
314
+ agentEvents.emitAgentEvent({
315
+ runId: params.runId,
316
+ stream: "tool",
317
+ data: {
318
+ phase: "update",
319
+ name: "Bash",
320
+ toolCallId: "toolu_inverse_order",
321
+ args: { command: "echo hi" },
322
+ },
323
+ });
324
+ agentEvents.emitAgentEvent({
325
+ runId: params.runId,
326
+ stream: "assistant",
327
+ data: { text: "answer after tool", delta: "answer after tool" },
328
+ });
329
+ return { payloads: [{ text: "final" }], meta: {} };
330
+ });
331
+
332
+ let releaseTyping: (() => void) | undefined;
333
+ const typingPending = new Promise<void>((resolve) => {
334
+ releaseTyping = resolve;
335
+ });
336
+ const typingSignals = createMockTypingSignaler();
337
+ vi.mocked(typingSignals.signalToolStart).mockReturnValue(typingPending);
338
+ const callbackOrder: string[] = [];
339
+ const partialReplyStarted = createDeferred();
340
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
341
+ const followupRun = createFollowupRun();
342
+ followupRun.run.provider = "claude-cli";
343
+ followupRun.run.model = "claude-opus-4-6";
344
+ const runPromise = executeAgentTurn({
345
+ commandBody: "hi",
346
+ followupRun,
347
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
348
+ opts: {
349
+ preserveProgressCallbackStartOrder: true,
350
+ onPartialReply: (payload) => {
351
+ callbackOrder.push(`partial:${payload.text}`);
352
+ partialReplyStarted.resolve();
353
+ },
354
+ onToolStart: (payload) => {
355
+ callbackOrder.push(`tool:${payload.phase}`);
356
+ },
357
+ },
358
+ typingSignals,
359
+ ...createAgentTurnExecutionDefaults(),
360
+ });
361
+ onTestFinished(async () => {
362
+ releaseTyping?.();
363
+ await runPromise;
364
+ });
365
+
366
+ try {
367
+ await Promise.race([partialReplyStarted.promise, runPromise]);
368
+ expect(callbackOrder).toEqual(["tool:start", "tool:update", "partial:answer after tool"]);
369
+ } finally {
370
+ releaseTyping?.();
371
+ await runPromise;
372
+ }
373
+ });
374
+
375
+ it("bridges CLI preambles for progress headlines when commentary is disabled", async () => {
376
+ state.isCliProviderMock.mockReturnValue(true);
377
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
378
+ result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
379
+ provider: "claude-cli",
380
+ model: "claude-opus-4-6",
381
+ attempts: [],
382
+ }));
383
+ state.runCliAgentMock.mockImplementationOnce(
384
+ async (params: { runId: string; emitCommentaryText?: boolean }) => {
385
+ expect(params.emitCommentaryText).toBe(true);
386
+ const agentEvents = await import("../../infra/agent-events.js");
387
+ // Inter-tool commentary surfaces as a stream:"item", kind:"preamble" agent event.
388
+ agentEvents.emitAgentEvent({
389
+ runId: params.runId,
390
+ stream: "item",
391
+ data: {
392
+ kind: "preamble",
393
+ itemId: "commentary-1",
394
+ progressText: "Let me check the files.",
395
+ },
396
+ });
397
+ return { payloads: [{ text: "done" }], meta: {} };
398
+ },
399
+ );
400
+
401
+ const onItemEvent = vi.fn<NonNullable<GetReplyOptions["onItemEvent"]>>(async () => undefined);
402
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
403
+ const followupRun = createFollowupRun();
404
+ followupRun.run.provider = "claude-cli";
405
+ followupRun.run.model = "claude-opus-4-6";
406
+
407
+ await executeAgentTurn({
408
+ commandBody: "hi",
409
+ followupRun,
410
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
411
+ opts: {
412
+ onItemEvent,
413
+ commentaryProgressEnabled: false,
414
+ progressPreambleEnabled: true,
415
+ },
416
+ typingSignals: createMockTypingSignaler(),
417
+ ...createAgentTurnExecutionDefaults(),
418
+ });
419
+ await new Promise((resolve) => {
420
+ setImmediate(resolve);
421
+ });
422
+
423
+ expect(onItemEvent).toHaveBeenCalledTimes(1);
424
+ const call = onItemEvent.mock.calls[0]?.[0];
425
+ expect(call?.kind).toBe("preamble");
426
+ expect(call?.progressText).toBe("Let me check the files.");
427
+ expect(call?.itemId).toBe("commentary-1");
428
+ });
429
+
430
+ it("does not emit CLI preambles when both progress lanes are disabled", async () => {
431
+ state.isCliProviderMock.mockReturnValue(true);
432
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
433
+ result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
434
+ provider: "claude-cli",
435
+ model: "claude-opus-4-6",
436
+ attempts: [],
437
+ }));
438
+ state.runCliAgentMock.mockImplementationOnce(
439
+ async (params: { runId: string; emitCommentaryText?: boolean }) => {
440
+ // With no commentary lane or headline consumer, pre-tool text stays in
441
+ // the assistant stream instead of being split into progress events.
442
+ expect(params.emitCommentaryText).toBe(false);
443
+ return { payloads: [{ text: "done" }], meta: {} };
444
+ },
445
+ );
446
+
447
+ const onItemEvent = vi.fn<NonNullable<GetReplyOptions["onItemEvent"]>>();
448
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
449
+ const followupRun = createFollowupRun();
450
+ followupRun.run.provider = "claude-cli";
451
+ followupRun.run.model = "claude-opus-4-6";
452
+
453
+ await executeAgentTurn({
454
+ commandBody: "hi",
455
+ followupRun,
456
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
457
+ opts: {
458
+ onItemEvent,
459
+ commentaryProgressEnabled: false,
460
+ progressPreambleEnabled: false,
461
+ },
462
+ typingSignals: createMockTypingSignaler(),
463
+ ...createAgentTurnExecutionDefaults(),
464
+ });
465
+
466
+ expect(state.runCliAgentMock).toHaveBeenCalledTimes(1);
467
+ expect(onItemEvent).not.toHaveBeenCalled();
468
+ });
469
+
470
+ it("does not bridge CLI tool deltas when silentExpected is set", async () => {
471
+ state.isCliProviderMock.mockReturnValue(true);
472
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
473
+ result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
474
+ provider: "claude-cli",
475
+ model: "claude-opus-4-6",
476
+ attempts: [],
477
+ }));
478
+ state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
479
+ const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
480
+ "../../infra/agent-events.js",
481
+ );
482
+ realAgentEvents.emitAgentEvent({
483
+ runId: params.runId,
484
+ stream: "tool",
485
+ data: {
486
+ phase: "start",
487
+ name: "Bash",
488
+ toolCallId: "toolu_silent",
489
+ args: { command: "echo silent" },
490
+ },
491
+ });
492
+ return { payloads: [{ text: "final" }], meta: {} };
493
+ });
494
+
495
+ const onToolStart = vi.fn<NonNullable<GetReplyOptions["onToolStart"]>>(async () => undefined);
496
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
497
+ const followupRun = createFollowupRun();
498
+ followupRun.run.provider = "claude-cli";
499
+ followupRun.run.model = "claude-opus-4-6";
500
+ followupRun.run.silentExpected = true;
501
+
502
+ await executeAgentTurn({
503
+ commandBody: "hi",
504
+ followupRun,
505
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
506
+ opts: { onToolStart },
507
+ typingSignals: createMockTypingSignaler(),
508
+ ...createAgentTurnExecutionDefaults(),
509
+ });
510
+ await new Promise((resolve) => {
511
+ setImmediate(resolve);
512
+ });
513
+
514
+ expect(onToolStart).not.toHaveBeenCalled();
515
+ });
516
+
517
+ it("does not bridge CLI assistant deltas when silentExpected is set (#76869)", async () => {
518
+ state.isCliProviderMock.mockReturnValue(true);
519
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
520
+ result: await runInitialFallbackAttempt(params, "claude-cli", "claude-opus-4-6"),
521
+ provider: "claude-cli",
522
+ model: "claude-opus-4-6",
523
+ attempts: [],
524
+ }));
525
+ state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
526
+ const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
527
+ "../../infra/agent-events.js",
528
+ );
529
+ realAgentEvents.emitAgentEvent({
530
+ runId: params.runId,
531
+ stream: "assistant",
532
+ data: { text: "secret heartbeat output", delta: "secret heartbeat output" },
533
+ });
534
+ realAgentEvents.emitAgentEvent({
535
+ runId: params.runId,
536
+ stream: "assistant",
537
+ data: { text: "NO_REPLY do not preview", delta: " do not preview" },
538
+ });
539
+ return { payloads: [{ text: "final" }], meta: {} };
540
+ });
541
+
542
+ const onPartialReply = vi.fn<NonNullable<GetReplyOptions["onPartialReply"]>>(
543
+ async (_payload) => undefined,
544
+ );
545
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
546
+ const followupRun = createFollowupRun();
547
+ followupRun.run.provider = "claude-cli";
548
+ followupRun.run.model = "claude-opus-4-6";
549
+ followupRun.run.silentExpected = true;
550
+
551
+ await executeAgentTurn({
552
+ commandBody: "hi",
553
+ followupRun,
554
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
555
+ opts: { onPartialReply },
556
+ typingSignals: createMockTypingSignaler(),
557
+ ...createAgentTurnExecutionDefaults(),
558
+ });
559
+ await new Promise((resolve) => {
560
+ setImmediate(resolve);
561
+ });
562
+
563
+ expect(onPartialReply).not.toHaveBeenCalled();
564
+ });
565
+
566
+ it("bridges CLI thinking agent events into onReasoningStream with the reasoning opt-in gate", async () => {
567
+ state.isCliProviderMock.mockReturnValue(true);
568
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
569
+ result: await params.run(
570
+ "claude-cli",
571
+ "claude-opus-4-7",
572
+ initialFallbackAttemptOptions(params),
573
+ ),
574
+ provider: "claude-cli",
575
+ model: "claude-opus-4-7",
576
+ attempts: [],
577
+ }));
578
+ state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
579
+ const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
580
+ "../../infra/agent-events.js",
581
+ );
582
+ realAgentEvents.emitAgentEvent({
583
+ runId: params.runId,
584
+ stream: "thinking",
585
+ data: { text: "Thinking", delta: "Thinking", isReasoningSnapshot: true },
586
+ });
587
+ realAgentEvents.emitAgentEvent({
588
+ runId: params.runId,
589
+ stream: "thinking",
590
+ data: { text: "Thinking", delta: "", isReasoningSnapshot: true },
591
+ });
592
+ realAgentEvents.emitAgentEvent({
593
+ runId: params.runId,
594
+ stream: "thinking",
595
+ data: { text: "Thinking about it", delta: " about it", isReasoningSnapshot: true },
596
+ });
597
+ return { payloads: [{ text: "Thinking about it" }], meta: {} };
598
+ });
599
+
600
+ const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
601
+ async (_payload) => undefined,
602
+ );
603
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
604
+ const followupRun = createFollowupRun();
605
+ followupRun.run.provider = "claude-cli";
606
+ followupRun.run.model = "claude-opus-4-7";
607
+
608
+ await executeAgentTurn({
609
+ commandBody: "hi",
610
+ followupRun,
611
+ sessionCtx: {
612
+ Provider: "telegram",
613
+ MessageSid: "msg",
614
+ } as unknown as TemplateContext,
615
+ opts: { onReasoningStream },
616
+ typingSignals: createMockTypingSignaler(),
617
+ ...createAgentTurnExecutionDefaults(),
618
+ });
619
+
620
+ expect(onReasoningStream.mock.calls.map((call) => call[0])).toEqual([
621
+ {
622
+ text: "Thinking",
623
+ isReasoningSnapshot: true,
624
+ requiresReasoningProgressOptIn: true,
625
+ },
626
+ {
627
+ text: "Thinking about it",
628
+ isReasoningSnapshot: true,
629
+ requiresReasoningProgressOptIn: true,
630
+ },
631
+ ]);
632
+ });
633
+
634
+ it("bridges tagged Claude CLI reasoning separately from its visible answer", async () => {
635
+ state.isCliProviderMock.mockReturnValue(true);
636
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
637
+ result: await params.run(
638
+ "claude-cli",
639
+ "claude-opus-4-7",
640
+ initialFallbackAttemptOptions(params),
641
+ ),
642
+ provider: "claude-cli",
643
+ model: "claude-opus-4-7",
644
+ attempts: [],
645
+ }));
646
+ state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
647
+ const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
648
+ "../../infra/agent-events.js",
649
+ );
650
+ const parser = createCliJsonlStreamingParser({
651
+ backend: {
652
+ command: "local-cli",
653
+ output: "jsonl",
654
+ jsonlDialect: "claude-stream-json",
655
+ },
656
+ providerId: "claude-cli",
657
+ onAssistantDelta: (delta) =>
658
+ realAgentEvents.emitAgentEvent({
659
+ runId: params.runId,
660
+ stream: "assistant",
661
+ data: delta,
662
+ }),
663
+ onThinkingDelta: (delta) =>
664
+ realAgentEvents.emitAgentEvent({
665
+ runId: params.runId,
666
+ stream: "thinking",
667
+ data: delta,
668
+ }),
669
+ });
670
+ parser.push(
671
+ [
672
+ JSON.stringify({
673
+ type: "stream_event",
674
+ event: {
675
+ type: "content_block_delta",
676
+ delta: {
677
+ type: "text_delta",
678
+ text: "<thinking>Private analysis.</thinking>Visible answer.",
679
+ },
680
+ },
681
+ }),
682
+ JSON.stringify({
683
+ type: "result",
684
+ result: "<thinking>Private analysis.</thinking>Visible answer.",
685
+ }),
686
+ "",
687
+ ].join("\n"),
688
+ );
689
+ parser.finish();
690
+ return { payloads: [{ text: parser.getOutput()?.text ?? "" }], meta: {} };
691
+ });
692
+
693
+ const onPartialReply = vi.fn<NonNullable<GetReplyOptions["onPartialReply"]>>(
694
+ async () => undefined,
695
+ );
696
+ const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
697
+ async () => undefined,
698
+ );
699
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
700
+ const followupRun = createFollowupRun();
701
+ followupRun.run.provider = "claude-cli";
702
+ followupRun.run.model = "claude-opus-4-7";
703
+
704
+ await executeAgentTurn({
705
+ commandBody: "hi",
706
+ followupRun,
707
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
708
+ opts: { onPartialReply, onReasoningStream },
709
+ typingSignals: createMockTypingSignaler(),
710
+ ...createAgentTurnExecutionDefaults(),
711
+ });
712
+
713
+ expect(onReasoningStream.mock.calls.map(([payload]) => payload.text)).toEqual([
714
+ "Private analysis.",
715
+ ]);
716
+ expect(onPartialReply.mock.calls.map(([payload]) => payload.text)).toEqual(["Visible answer."]);
717
+ });
718
+
719
+ it("does not bridge CLI thinking events to onReasoningStream when silentExpected is set", async () => {
720
+ state.isCliProviderMock.mockReturnValue(true);
721
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
722
+ result: await params.run(
723
+ "claude-cli",
724
+ "claude-opus-4-7",
725
+ initialFallbackAttemptOptions(params),
726
+ ),
727
+ provider: "claude-cli",
728
+ model: "claude-opus-4-7",
729
+ attempts: [],
730
+ }));
731
+ state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
732
+ const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
733
+ "../../infra/agent-events.js",
734
+ );
735
+ realAgentEvents.emitAgentEvent({
736
+ runId: params.runId,
737
+ stream: "thinking",
738
+ data: { text: "heartbeat scratch text", delta: "heartbeat scratch text" },
739
+ });
740
+ realAgentEvents.emitAgentEvent({
741
+ runId: params.runId,
742
+ stream: "thinking",
743
+ data: { text: "NO_REPLY do not preview reasoning", delta: " do not preview reasoning" },
744
+ });
745
+ return { payloads: [{ text: "final" }], meta: {} };
746
+ });
747
+
748
+ const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
749
+ async (_payload) => undefined,
750
+ );
751
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
752
+ const followupRun = createFollowupRun();
753
+ followupRun.run.provider = "claude-cli";
754
+ followupRun.run.model = "claude-opus-4-7";
755
+ followupRun.run.silentExpected = true;
756
+
757
+ await executeAgentTurn({
758
+ commandBody: "hi",
759
+ followupRun,
760
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
761
+ opts: { onReasoningStream },
762
+ typingSignals: createMockTypingSignaler(),
763
+ ...createAgentTurnExecutionDefaults(),
764
+ });
765
+ await new Promise((resolve) => {
766
+ setImmediate(resolve);
767
+ });
768
+
769
+ expect(onReasoningStream).not.toHaveBeenCalled();
770
+ });
771
+
772
+ it("does not bridge non-Claude CLI assistant events to onReasoningStream", async () => {
773
+ state.isCliProviderMock.mockReturnValue(true);
774
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
775
+ result: await params.run("codex-cli", "gpt-5.5", initialFallbackAttemptOptions(params)),
776
+ provider: "codex-cli",
777
+ model: "gpt-5.5",
778
+ attempts: [],
779
+ }));
780
+ state.runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => {
781
+ const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
782
+ "../../infra/agent-events.js",
783
+ );
784
+ realAgentEvents.emitAgentEvent({
785
+ runId: params.runId,
786
+ stream: "assistant",
787
+ data: { text: "final answer", delta: "final answer" },
788
+ });
789
+ return { payloads: [{ text: "final answer" }], meta: {} };
790
+ });
791
+
792
+ const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
793
+ async (_payload) => undefined,
794
+ );
795
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
796
+ const followupRun = createFollowupRun();
797
+ followupRun.run.provider = "codex-cli";
798
+ followupRun.run.model = "gpt-5.5";
799
+
800
+ await executeAgentTurn({
801
+ commandBody: "hi",
802
+ followupRun,
803
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
804
+ opts: { onReasoningStream },
805
+ typingSignals: createMockTypingSignaler(),
806
+ ...createAgentTurnExecutionDefaults(),
807
+ });
808
+ await new Promise((resolve) => {
809
+ setImmediate(resolve);
810
+ });
811
+
812
+ expect(onReasoningStream).not.toHaveBeenCalled();
813
+ });
814
+
815
+ it("does not double-fire onReasoningStream from the bridge when the API/native runtime path is active", async () => {
816
+ state.isCliProviderMock.mockReturnValue(false);
817
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
818
+ result: await params.run(
819
+ "anthropic",
820
+ "claude-sonnet-4-7",
821
+ initialFallbackAttemptOptions(params),
822
+ ),
823
+ provider: "anthropic",
824
+ model: "claude-sonnet-4-7",
825
+ attempts: [],
826
+ }));
827
+ state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
828
+ const realAgentEvents = await vi.importActual<typeof import("../../infra/agent-events.js")>(
829
+ "../../infra/agent-events.js",
830
+ );
831
+ realAgentEvents.emitAgentEvent({
832
+ runId: "api-run",
833
+ stream: "assistant",
834
+ data: { text: "assistant text from API run", delta: "assistant text from API run" },
835
+ });
836
+ await params.onAgentEvent?.({
837
+ stream: "assistant",
838
+ data: { text: "assistant text from API run", delta: "assistant text from API run" },
839
+ });
840
+ return { payloads: [{ text: "final" }], meta: {} };
841
+ });
842
+
843
+ const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
844
+ async (_payload) => undefined,
845
+ );
846
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
847
+ const followupRun = createFollowupRun();
848
+ followupRun.run.provider = "anthropic";
849
+ followupRun.run.model = "claude-sonnet-4-7";
850
+
851
+ await executeAgentTurn({
852
+ commandBody: "hi",
853
+ followupRun,
854
+ sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext,
855
+ opts: { onReasoningStream, runId: "api-run" },
856
+ typingSignals: createMockTypingSignaler(),
857
+ ...createAgentTurnExecutionDefaults(),
858
+ });
859
+ await new Promise((resolve) => {
860
+ setImmediate(resolve);
861
+ });
862
+
863
+ expect(onReasoningStream).not.toHaveBeenCalled();
864
+ });
865
+
866
+ it("preserves embedded reasoning stream opt-in markers", async () => {
867
+ state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
868
+ await params.onReasoningStream?.({ text: "stream thought" });
869
+ await params.onReasoningStream?.({
870
+ text: "ambient thought",
871
+ requiresReasoningProgressOptIn: true,
872
+ });
873
+ return { payloads: [{ text: "final" }], meta: {} };
874
+ });
875
+
876
+ const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
877
+ async (_payload) => undefined,
878
+ );
879
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
880
+
881
+ await executeAgentTurn(
882
+ createMinimalRunAgentTurnParams({
883
+ opts: { onReasoningStream },
884
+ }),
885
+ );
886
+
887
+ expect(
888
+ onReasoningStream.mock.calls.map(([payload]) => ({
889
+ text: payload.text,
890
+ requiresReasoningProgressOptIn: payload.requiresReasoningProgressOptIn,
891
+ })),
892
+ ).toEqual([
893
+ { text: "stream thought", requiresReasoningProgressOptIn: undefined },
894
+ { text: "ambient thought", requiresReasoningProgressOptIn: true },
895
+ ]);
896
+ });
897
+ });
src/auto-reply/reply/agent-runner-execution-cli-sessions.test.ts ADDED
@@ -0,0 +1,947 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from "node:path";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { prepareCliPromptImagePayload } from "../../agents/cli-runner/helpers.js";
4
+ import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
5
+ import { detectAndLoadPromptImages } from "../../agents/embedded-agent-runner/run/images.js";
6
+ import { FailoverError } from "../../agents/failover-error.js";
7
+ import { installSessionPlacementAdmissionProvider } from "../../agents/session-placement-admission.js";
8
+ import type { SessionEntry } from "../../config/sessions.js";
9
+ import { replaceSessionEntry } from "../../config/sessions/session-accessor.js";
10
+ import { registerGeneratedMediaTaskActivity } from "../../tasks/generated-media-task-activity.js";
11
+ import { resetGeneratedMediaTaskActivityForTests } from "../../tasks/task-runtime.test-helpers.js";
12
+ import type { TemplateContext } from "../templating.js";
13
+ import { SILENT_REPLY_TOKEN } from "../tokens.js";
14
+ import {
15
+ setupAgentRunnerExecutionTestState,
16
+ getExecuteAgentTurnForTest,
17
+ createFollowupRun,
18
+ createTestUserTurnRecorder,
19
+ requireRecord,
20
+ requireMockCall,
21
+ expectMockCallArgFields,
22
+ initialFallbackAttemptOptions,
23
+ createMinimalRunAgentTurnParams,
24
+ createRunAgentTurnParams,
25
+ makeTestSessionStorePath,
26
+ } from "./agent-runner-execution.test-support.js";
27
+ import type { FallbackRunnerParams } from "./agent-runner-execution.test-support.js";
28
+
29
+ const state = await setupAgentRunnerExecutionTestState();
30
+ afterEach(resetGeneratedMediaTaskActivityForTests);
31
+
32
+ function rejectUnexpectedCompactionSuccessor(): never {
33
+ throw new Error("Unexpected compaction successor during CLI session routing test");
34
+ }
35
+
36
+ describe("executeAgentTurn: CLI session routing", () => {
37
+ it("carries prepared model and thread context facts into CLI execution", async () => {
38
+ state.isCliProviderMock.mockReturnValue(true);
39
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
40
+ result: await params.run(
41
+ "claude-cli",
42
+ "claude-sonnet-4-6",
43
+ initialFallbackAttemptOptions(params),
44
+ ),
45
+ provider: "claude-cli",
46
+ model: "claude-sonnet-4-6",
47
+ attempts: [],
48
+ }));
49
+ state.runCliAgentMock.mockResolvedValueOnce({
50
+ payloads: [{ text: "done" }],
51
+ meta: {},
52
+ });
53
+ const followupRun = createFollowupRun();
54
+ followupRun.originatingThreadId = 42;
55
+ followupRun.run.provider = "claude-cli";
56
+ followupRun.run.model = "claude-sonnet-4-6";
57
+ followupRun.run.thinkingCatalog = [
58
+ {
59
+ provider: "claude-cli",
60
+ id: "claude-sonnet-4-6",
61
+ contextWindow: 400_000,
62
+ contextTokens: 321_000,
63
+ input: ["text", "image"],
64
+ },
65
+ ];
66
+
67
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
68
+ const result = await executeAgentTurn(
69
+ createMinimalRunAgentTurnParams({
70
+ followupRun,
71
+ sessionCtx: {
72
+ Provider: "telegram",
73
+ MessageSid: "msg",
74
+ MessageThreadId: "stale-topic",
75
+ } as unknown as TemplateContext,
76
+ }),
77
+ );
78
+
79
+ expect(result.kind).toBe("success");
80
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
81
+ modelContextWindow: 400_000,
82
+ modelContextTokens: 321_000,
83
+ currentThreadTs: "42",
84
+ });
85
+ });
86
+
87
+ it("preserves queued image fields from runs created before the prepared marker", async () => {
88
+ state.isCliProviderMock.mockReturnValue(true);
89
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
90
+ result: await params.run(
91
+ "claude-cli",
92
+ "claude-opus-5",
93
+ initialFallbackAttemptOptions(params),
94
+ ),
95
+ provider: "claude-cli",
96
+ model: "claude-opus-5",
97
+ attempts: [],
98
+ }));
99
+ state.runCliAgentMock.mockResolvedValueOnce({
100
+ payloads: [{ text: "described" }],
101
+ meta: {},
102
+ });
103
+ const images = [
104
+ {
105
+ type: "image" as const,
106
+ data: "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFElEQVR4nGP4z8Dwn4GBgYGJAQoAHxcCAr7cGDwAAAAASUVORK5CYII=",
107
+ mimeType: "image/png",
108
+ },
109
+ ];
110
+ const imageOrder = ["inline" as const];
111
+ const followupRun = createFollowupRun();
112
+ followupRun.run.provider = "claude-cli";
113
+ followupRun.run.model = "claude-opus-5";
114
+ followupRun.images = images;
115
+ followupRun.imageOrder = imageOrder;
116
+
117
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
118
+ const result = await executeAgentTurn(
119
+ createMinimalRunAgentTurnParams({
120
+ followupRun,
121
+ sessionCtx: {
122
+ Provider: "telegram",
123
+ MessageSid: "msg",
124
+ } as unknown as TemplateContext,
125
+ }),
126
+ );
127
+
128
+ expect(result.kind).toBe("success");
129
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
130
+ images,
131
+ imageOrder,
132
+ });
133
+ });
134
+
135
+ it("keeps prepared current-turn images aligned with CLI media facts", async () => {
136
+ state.isCliProviderMock.mockReturnValue(true);
137
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
138
+ result: await params.run(
139
+ "claude-cli",
140
+ "claude-opus-5",
141
+ initialFallbackAttemptOptions(params),
142
+ ),
143
+ provider: "claude-cli",
144
+ model: "claude-opus-5",
145
+ attempts: [],
146
+ }));
147
+ const images = [
148
+ {
149
+ type: "image" as const,
150
+ data: "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAFElEQVR4nGP4z8Dwn4GBgYGJAQoAHxcCAr7cGDwAAAAASUVORK5CYII=",
151
+ mimeType: "image/png",
152
+ },
153
+ ];
154
+ const imageOrder = ["inline" as const];
155
+ const media = [{ path: "/openclaw-test-missing/current.png", contentType: "image/png" }];
156
+ const mediaImageLayout = {
157
+ slots: [{ kind: "inline" as const, factIndex: 0 }],
158
+ suppressedFactIndexes: [],
159
+ };
160
+ state.runCliAgentMock.mockImplementationOnce(async (params: RunCliAgentParams) => {
161
+ expect(params.modelHasVision).toBe(true);
162
+ const internalParams = params as RunCliAgentParams & {
163
+ mediaImageLayout?: typeof mediaImageLayout;
164
+ };
165
+ await expect(
166
+ prepareCliPromptImagePayload({
167
+ backend: { command: "claude" },
168
+ prompt: params.prompt,
169
+ imagePrompt: params.prompt,
170
+ workspaceDir: params.workspaceDir,
171
+ images: [...images, ...images],
172
+ imageOrder: [...imageOrder, ...imageOrder],
173
+ media,
174
+ }),
175
+ ).rejects.toThrow("failed to hydrate 1 structured image attachment");
176
+
177
+ const reconciled = await detectAndLoadPromptImages({
178
+ prompt: params.prompt,
179
+ media: params.media,
180
+ workspaceDir: params.workspaceDir,
181
+ model: { input: ["text", "image"] },
182
+ existingImages: params.images,
183
+ imageOrder: params.imageOrder,
184
+ mediaImageLayout: internalParams.mediaImageLayout,
185
+ });
186
+ expect(reconciled).toMatchObject({
187
+ failedMediaCount: 0,
188
+ images,
189
+ });
190
+ return {
191
+ payloads: [{ text: "described" }],
192
+ meta: {},
193
+ };
194
+ });
195
+ const followupRun = createFollowupRun();
196
+ followupRun.run.provider = "claude-cli";
197
+ followupRun.run.model = "claude-opus-5";
198
+ followupRun.run.thinkingCatalog = [
199
+ {
200
+ provider: "claude-cli",
201
+ id: "claude-opus-5",
202
+ input: ["text", "image"],
203
+ },
204
+ ];
205
+ const preparedFollowupRun = followupRun as typeof followupRun & {
206
+ currentTurnImagesPrepared?: true;
207
+ mediaImageLayout?: typeof mediaImageLayout;
208
+ };
209
+ preparedFollowupRun.currentTurnImagesPrepared = true;
210
+ preparedFollowupRun.mediaImageLayout = mediaImageLayout;
211
+ followupRun.images = images;
212
+ followupRun.imageOrder = imageOrder;
213
+ followupRun.media = media;
214
+
215
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
216
+ const result = await executeAgentTurn(
217
+ createMinimalRunAgentTurnParams({
218
+ followupRun,
219
+ sessionCtx: {
220
+ Provider: "telegram",
221
+ MessageSid: "msg",
222
+ media,
223
+ } as unknown as TemplateContext,
224
+ }),
225
+ );
226
+
227
+ expect(result.kind).toBe("success");
228
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
229
+ images,
230
+ imageOrder,
231
+ mediaImageLayout,
232
+ media,
233
+ });
234
+ });
235
+
236
+ it("forwards the static extra system prompt to CLI backends", async () => {
237
+ state.isCliProviderMock.mockReturnValue(true);
238
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
239
+ result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
240
+ provider: "codex-cli",
241
+ model: "gpt-5.4",
242
+ attempts: [],
243
+ }));
244
+ state.runCliAgentMock.mockResolvedValueOnce({
245
+ payloads: [{ text: "final" }],
246
+ meta: {},
247
+ });
248
+
249
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
250
+ const followupRun = createFollowupRun();
251
+ followupRun.run.agentId = "main";
252
+ followupRun.run.provider = "codex-cli";
253
+ followupRun.run.model = "gpt-5.4";
254
+ followupRun.run.extraSystemPrompt = "dynamic inbound metadata\n\nstable group prompt";
255
+ followupRun.run.extraSystemPromptStatic = "stable group prompt";
256
+ followupRun.run.senderId = "sender-static";
257
+ followupRun.run.senderName = "Sender Static";
258
+ followupRun.run.senderUsername = "sender-static-user";
259
+ followupRun.run.senderE164 = "+15550002222";
260
+ followupRun.run.execOverrides = { host: "node", node: "mac-a" };
261
+ followupRun.run.bashElevated = {
262
+ enabled: true,
263
+ allowed: true,
264
+ defaultLevel: "full",
265
+ };
266
+ followupRun.run.groupId = "group-static";
267
+ followupRun.run.groupChannel = "ops";
268
+ followupRun.run.groupSpace = "workspace-static";
269
+ followupRun.run.spawnedBy = "agent:main:telegram:group:parent";
270
+ followupRun.run.runtimePolicySessionKey = "agent:main:telegram:default:direct:sender-static";
271
+ followupRun.originatingChannel = "telegram";
272
+
273
+ const result = await executeAgentTurn(createRunAgentTurnParams(followupRun));
274
+
275
+ expect(result.kind).toBe("success");
276
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
277
+ modelProvider: "codex-cli",
278
+ extraSystemPrompt: "dynamic inbound metadata\n\nstable group prompt",
279
+ extraSystemPromptStatic: "stable group prompt",
280
+ trigger: "user",
281
+ messageChannel: "telegram",
282
+ messageProvider: "telegram",
283
+ senderId: "sender-static",
284
+ senderName: "Sender Static",
285
+ senderUsername: "sender-static-user",
286
+ senderE164: "+15550002222",
287
+ execOverrides: { host: "node", node: "mac-a" },
288
+ bashElevated: { enabled: true, allowed: true, defaultLevel: "full" },
289
+ groupId: "group-static",
290
+ groupChannel: "ops",
291
+ groupSpace: "workspace-static",
292
+ spawnedBy: "agent:main:telegram:group:parent",
293
+ runtimePolicySessionKey: "agent:main:telegram:default:direct:sender-static",
294
+ });
295
+ });
296
+
297
+ it("passes silent empty-reply policy to CLI backends for message-tool-only turns", async () => {
298
+ state.isCliProviderMock.mockReturnValue(true);
299
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
300
+ result: await params.run(
301
+ "claude-cli",
302
+ "claude-sonnet-4-6",
303
+ initialFallbackAttemptOptions(params),
304
+ ),
305
+ provider: "claude-cli",
306
+ model: "claude-sonnet-4-6",
307
+ attempts: [],
308
+ }));
309
+ state.runCliAgentMock.mockResolvedValueOnce({
310
+ payloads: [{ text: SILENT_REPLY_TOKEN }],
311
+ meta: { executionTrace: { fallbackUsed: false } },
312
+ });
313
+
314
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
315
+ const followupRun = createFollowupRun();
316
+ followupRun.run.provider = "claude-cli";
317
+ followupRun.run.model = "claude-sonnet-4-6";
318
+ followupRun.run.sourceReplyDeliveryMode = "message_tool_only";
319
+ followupRun.run.allowEmptyAssistantReplyAsSilent = true;
320
+ followupRun.originatingChannel = "telegram";
321
+
322
+ const result = await executeAgentTurn(
323
+ createMinimalRunAgentTurnParams({
324
+ followupRun,
325
+ sessionCtx: {
326
+ Provider: "telegram",
327
+ MessageSid: "msg",
328
+ ChatType: "group",
329
+ } as unknown as TemplateContext,
330
+ }),
331
+ );
332
+
333
+ expect(result.kind).toBe("success");
334
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
335
+ provider: "claude-cli",
336
+ model: "claude-sonnet-4-6",
337
+ sourceReplyDeliveryMode: "message_tool_only",
338
+ allowEmptyAssistantReplyAsSilent: true,
339
+ messageChannel: "telegram",
340
+ messageProvider: "telegram",
341
+ });
342
+ });
343
+
344
+ it("passes prepared CLI user turns to the runtime persistence boundary", async () => {
345
+ state.isCliProviderMock.mockReturnValue(true);
346
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
347
+ result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
348
+ provider: "codex-cli",
349
+ model: "gpt-5.4",
350
+ attempts: [],
351
+ }));
352
+ state.runCliAgentMock.mockResolvedValueOnce({
353
+ payloads: [{ text: "final" }],
354
+ meta: {},
355
+ });
356
+
357
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
358
+ const followupRun = createFollowupRun();
359
+ followupRun.run.provider = "codex-cli";
360
+ followupRun.run.model = "gpt-5.4";
361
+ const preparedUserTurnMessage = {
362
+ role: "user",
363
+ content: "describe this",
364
+ MediaPath: "/tmp/image.png",
365
+ MediaPaths: ["/tmp/image.png"],
366
+ MediaType: "image/png",
367
+ MediaTypes: ["image/png"],
368
+ } as never;
369
+ followupRun.userTurnTranscriptRecorder = createTestUserTurnRecorder(preparedUserTurnMessage);
370
+ const storePath = makeTestSessionStorePath();
371
+ const sessionEntry: SessionEntry = {
372
+ sessionId: "session",
373
+ sessionFile: path.join(path.dirname(storePath), "session.jsonl"),
374
+ updatedAt: 1,
375
+ };
376
+ await replaceSessionEntry({ sessionKey: "agent:main:main", storePath }, sessionEntry);
377
+ const activeSessionStore = { main: sessionEntry };
378
+
379
+ await replaceSessionEntry({ sessionKey: "main", storePath }, sessionEntry);
380
+ const result = await executeAgentTurn({
381
+ ...createMinimalRunAgentTurnParams({ followupRun }),
382
+ commandBody: "runtime prompt",
383
+ transcriptCommandBody: "display prompt",
384
+ activeSessionStore,
385
+ storePath,
386
+ getActiveSessionEntry: () => activeSessionStore.main,
387
+ });
388
+
389
+ expect(result.kind).toBe("success");
390
+ expect(state.runCliAgentMock).toHaveBeenCalledOnce();
391
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI runtime", {
392
+ sessionKey: "main",
393
+ agentId: "main",
394
+ sessionId: "session",
395
+ suppressNextUserMessagePersistence: false,
396
+ persistAssistantTranscript: true,
397
+ storePath,
398
+ sessionTarget: {
399
+ agentId: "main",
400
+ sessionId: "session",
401
+ sessionKey: "main",
402
+ storePath,
403
+ },
404
+ });
405
+ const call = requireMockCall(state.runCliAgentMock, 0, "CLI runtime");
406
+ const callParams = requireRecord(call[0], "CLI runtime");
407
+ expect(callParams.userTurnTranscriptRecorder).toEqual(expect.any(Object));
408
+ expect(requireRecord(callParams.userTurnTranscriptRecorder, "user turn recorder").message).toBe(
409
+ preparedUserTurnMessage,
410
+ );
411
+ expect(callParams.onUserMessagePersisted).toEqual(expect.any(Function));
412
+ });
413
+
414
+ it("reuses CLI sessions for room-event turns", async () => {
415
+ state.isCliProviderMock.mockReturnValue(true);
416
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
417
+ result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
418
+ provider: "codex-cli",
419
+ model: "gpt-5.4",
420
+ attempts: [],
421
+ }));
422
+ state.runCliAgentMock.mockResolvedValueOnce({
423
+ payloads: [{ text: "ambient" }],
424
+ meta: {
425
+ agentMeta: {
426
+ sessionId: "existing-cli-session",
427
+ cliSessionBinding: {
428
+ sessionId: "existing-cli-session",
429
+ authProfileId: "profile",
430
+ },
431
+ },
432
+ },
433
+ });
434
+
435
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
436
+ const followupRun = createFollowupRun();
437
+ followupRun.currentInboundEventKind = "room_event";
438
+ followupRun.run.provider = "codex-cli";
439
+ followupRun.run.model = "gpt-5.4";
440
+ const sessionEntry = {
441
+ cliSessionBindings: {
442
+ "codex-cli": { sessionId: "existing-cli-session" },
443
+ },
444
+ } as unknown as SessionEntry;
445
+ const activeSessionStore = { main: sessionEntry };
446
+
447
+ const result = await executeAgentTurn({
448
+ ...createMinimalRunAgentTurnParams({ followupRun }),
449
+ activeSessionStore,
450
+ getActiveSessionEntry: () => sessionEntry,
451
+ });
452
+
453
+ expect(result.kind).toBe("success");
454
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
455
+ currentInboundEventKind: "room_event",
456
+ persistAssistantTranscript: false,
457
+ cliSessionId: "existing-cli-session",
458
+ cliSessionBinding: {
459
+ sessionId: "existing-cli-session",
460
+ },
461
+ });
462
+ if (result.kind !== "success") {
463
+ throw new Error("expected success");
464
+ }
465
+ expect(result.runResult.meta?.agentMeta?.sessionId).toBe("existing-cli-session");
466
+ expect(result.runResult.meta?.agentMeta?.cliSessionBinding).toEqual({
467
+ sessionId: "existing-cli-session",
468
+ authProfileId: "profile",
469
+ });
470
+ });
471
+
472
+ it("keeps the first CLI session created by a room-event turn", async () => {
473
+ state.isCliProviderMock.mockReturnValue(true);
474
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
475
+ result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
476
+ provider: "codex-cli",
477
+ model: "gpt-5.4",
478
+ attempts: [],
479
+ }));
480
+ state.runCliAgentMock.mockResolvedValueOnce({
481
+ payloads: [{ text: "ambient" }],
482
+ meta: {
483
+ agentMeta: {
484
+ sessionId: "new-cli-session",
485
+ cliSessionBinding: {
486
+ sessionId: "new-cli-session",
487
+ authProfileId: "profile",
488
+ },
489
+ },
490
+ },
491
+ });
492
+
493
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
494
+ const followupRun = createFollowupRun();
495
+ followupRun.currentInboundEventKind = "room_event";
496
+ followupRun.run.provider = "codex-cli";
497
+ followupRun.run.model = "gpt-5.4";
498
+ const sessionEntry = {} as unknown as SessionEntry;
499
+
500
+ const result = await executeAgentTurn({
501
+ ...createMinimalRunAgentTurnParams({ followupRun }),
502
+ getActiveSessionEntry: () => sessionEntry,
503
+ });
504
+
505
+ expect(result.kind).toBe("success");
506
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
507
+ currentInboundEventKind: "room_event",
508
+ cliSessionId: undefined,
509
+ cliSessionBinding: undefined,
510
+ });
511
+ if (result.kind !== "success") {
512
+ throw new Error("expected success");
513
+ }
514
+ expect(result.runResult.meta?.agentMeta?.sessionId).toBe("new-cli-session");
515
+ expect(result.runResult.meta?.agentMeta?.cliSessionBinding).toEqual({
516
+ sessionId: "new-cli-session",
517
+ authProfileId: "profile",
518
+ });
519
+ });
520
+
521
+ it("drops replacement room-event CLI sessions when reuse fails", async () => {
522
+ state.isCliProviderMock.mockReturnValue(true);
523
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
524
+ result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
525
+ provider: "codex-cli",
526
+ model: "gpt-5.4",
527
+ attempts: [],
528
+ }));
529
+ state.runCliAgentMock.mockResolvedValueOnce({
530
+ payloads: [{ text: "ambient" }],
531
+ meta: {
532
+ agentMeta: {
533
+ sessionId: "transient-cli-session",
534
+ cliSessionBinding: {
535
+ sessionId: "transient-cli-session",
536
+ authProfileId: "profile",
537
+ },
538
+ clearCliSessionBinding: true,
539
+ },
540
+ },
541
+ });
542
+
543
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
544
+ const followupRun = createFollowupRun();
545
+ followupRun.currentInboundEventKind = "room_event";
546
+ followupRun.run.provider = "codex-cli";
547
+ followupRun.run.model = "gpt-5.4";
548
+ const sessionEntry = {
549
+ cliSessionBindings: {
550
+ "codex-cli": { sessionId: "existing-cli-session" },
551
+ },
552
+ } as unknown as SessionEntry;
553
+ const activeSessionStore = { main: sessionEntry };
554
+ let cleanupObservedBeforePlacementRelease = false;
555
+ const restoreAdmission = installSessionPlacementAdmissionProvider({
556
+ assertCompactionSuccessorAllowed: rejectUnexpectedCompactionSuccessor,
557
+ executeLocalTurn: async (_claim, runLocal) => {
558
+ const resultLocal = await runLocal();
559
+ expect(activeSessionStore.main.cliSessionBindings?.["codex-cli"]).toBeUndefined();
560
+ cleanupObservedBeforePlacementRelease = true;
561
+ return resultLocal;
562
+ },
563
+ executeTurn: async (_claim, _params, runLocal) => await runLocal(),
564
+ });
565
+
566
+ let result: Awaited<ReturnType<typeof executeAgentTurn>>;
567
+ try {
568
+ result = await executeAgentTurn({
569
+ ...createMinimalRunAgentTurnParams({ followupRun }),
570
+ activeSessionStore,
571
+ getActiveSessionEntry: () => sessionEntry,
572
+ });
573
+ } finally {
574
+ restoreAdmission();
575
+ }
576
+
577
+ expect(result.kind).toBe("success");
578
+ expect(cleanupObservedBeforePlacementRelease).toBe(true);
579
+ expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", {
580
+ currentInboundEventKind: "room_event",
581
+ cliSessionId: "existing-cli-session",
582
+ cliSessionBinding: {
583
+ sessionId: "existing-cli-session",
584
+ },
585
+ });
586
+ if (result.kind !== "success") {
587
+ throw new Error("expected success");
588
+ }
589
+ expect(result.runResult.meta?.agentMeta?.sessionId).toBe("");
590
+ expect(result.runResult.meta?.agentMeta?.cliSessionBinding).toBeUndefined();
591
+ expect(result.runResult.meta?.agentMeta?.clearCliSessionBinding).toBeUndefined();
592
+ expect(activeSessionStore.main.cliSessionBindings?.["codex-cli"]).toBeUndefined();
593
+ });
594
+
595
+ it("keeps room-event CLI bindings when synthetic hooks return no CLI binding", async () => {
596
+ state.isCliProviderMock.mockReturnValue(true);
597
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
598
+ result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
599
+ provider: "codex-cli",
600
+ model: "gpt-5.4",
601
+ attempts: [],
602
+ }));
603
+ state.runCliAgentMock.mockResolvedValueOnce({
604
+ payloads: [{ text: "handled" }],
605
+ meta: {
606
+ agentMeta: {
607
+ sessionId: "openclaw-session",
608
+ provider: "codex-cli",
609
+ model: "gpt-5.4",
610
+ },
611
+ },
612
+ });
613
+
614
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
615
+ const followupRun = createFollowupRun();
616
+ followupRun.currentInboundEventKind = "room_event";
617
+ followupRun.run.provider = "codex-cli";
618
+ followupRun.run.model = "gpt-5.4";
619
+ const sessionEntry = {
620
+ cliSessionBindings: {
621
+ "codex-cli": { sessionId: "existing-cli-session" },
622
+ },
623
+ } as unknown as SessionEntry;
624
+ const activeSessionStore = { main: sessionEntry };
625
+
626
+ const result = await executeAgentTurn({
627
+ ...createMinimalRunAgentTurnParams({ followupRun }),
628
+ activeSessionStore,
629
+ getActiveSessionEntry: () => sessionEntry,
630
+ });
631
+
632
+ expect(result.kind).toBe("success");
633
+ if (result.kind !== "success") {
634
+ throw new Error("expected success");
635
+ }
636
+ expect(result.runResult.meta?.agentMeta?.sessionId).toBe("");
637
+ expect(result.runResult.meta?.agentMeta?.cliSessionBinding).toBeUndefined();
638
+ expect(activeSessionStore.main.cliSessionBindings?.["codex-cli"]).toEqual({
639
+ sessionId: "existing-cli-session",
640
+ });
641
+ });
642
+
643
+ it("clears room-event CLI bindings when an unflushed replacement is dropped", async () => {
644
+ state.isCliProviderMock.mockReturnValue(true);
645
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
646
+ result: await params.run("codex-cli", "gpt-5.4", initialFallbackAttemptOptions(params)),
647
+ provider: "codex-cli",
648
+ model: "gpt-5.4",
649
+ attempts: [],
650
+ }));
651
+ state.runCliAgentMock.mockResolvedValueOnce({
652
+ payloads: [{ text: "handled" }],
653
+ meta: {
654
+ agentMeta: {
655
+ sessionId: "",
656
+ provider: "codex-cli",
657
+ model: "gpt-5.4",
658
+ clearCliSessionBinding: true,
659
+ },
660
+ },
661
+ });
662
+
663
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
664
+ const followupRun = createFollowupRun();
665
+ followupRun.currentInboundEventKind = "room_event";
666
+ followupRun.run.provider = "codex-cli";
667
+ followupRun.run.model = "gpt-5.4";
668
+ const sessionEntry = {
669
+ cliSessionBindings: {
670
+ "codex-cli": { sessionId: "existing-cli-session" },
671
+ },
672
+ } as unknown as SessionEntry;
673
+ const activeSessionStore = { main: sessionEntry };
674
+
675
+ const result = await executeAgentTurn({
676
+ ...createMinimalRunAgentTurnParams({ followupRun }),
677
+ activeSessionStore,
678
+ getActiveSessionEntry: () => sessionEntry,
679
+ });
680
+
681
+ expect(result.kind).toBe("success");
682
+ if (result.kind !== "success") {
683
+ throw new Error("expected success");
684
+ }
685
+ expect(result.runResult.meta?.agentMeta?.sessionId).toBe("");
686
+ expect(result.runResult.meta?.agentMeta?.cliSessionBinding).toBeUndefined();
687
+ expect(result.runResult.meta?.agentMeta?.clearCliSessionBinding).toBeUndefined();
688
+ expect(activeSessionStore.main.cliSessionBindings?.["codex-cli"]).toBeUndefined();
689
+ });
690
+
691
+ it("clears a fork-marked Claude CLI binding when the channel turn fails", async () => {
692
+ state.isCliProviderMock.mockReturnValue(true);
693
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
694
+ result: await params.run(
695
+ "claude-cli",
696
+ "claude-opus-4-8",
697
+ initialFallbackAttemptOptions(params),
698
+ ),
699
+ provider: "claude-cli",
700
+ model: "claude-opus-4-8",
701
+ attempts: [],
702
+ }));
703
+ state.runCliAgentMock.mockRejectedValueOnce(
704
+ new FailoverError("No conversation found", {
705
+ reason: "session_expired",
706
+ provider: "claude-cli",
707
+ model: "claude-opus-4-8",
708
+ }),
709
+ );
710
+
711
+ const followupRun = createFollowupRun();
712
+ followupRun.run.provider = "claude-cli";
713
+ followupRun.run.model = "claude-opus-4-8";
714
+ const sessionEntry = {
715
+ sessionId: "openclaw-session",
716
+ updatedAt: 1,
717
+ cliSessionBindings: {
718
+ "claude-cli": { sessionId: "stale-cli-session", forkNextResume: true },
719
+ },
720
+ cliSessionIds: { "claude-cli": "stale-cli-session" },
721
+ claudeCliSessionId: "stale-cli-session",
722
+ } as SessionEntry;
723
+ const activeSessionStore = { main: sessionEntry };
724
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
725
+
726
+ const result = await executeAgentTurn({
727
+ ...createMinimalRunAgentTurnParams({ followupRun }),
728
+ activeSessionStore,
729
+ getActiveSessionEntry: () => sessionEntry,
730
+ });
731
+
732
+ expect(result.kind).toBe("final");
733
+ expect(sessionEntry.cliSessionBindings?.["claude-cli"]).toBeUndefined();
734
+ expect(sessionEntry.cliSessionIds?.["claude-cli"]).toBeUndefined();
735
+ expect(sessionEntry.claudeCliSessionId).toBeUndefined();
736
+ });
737
+
738
+ it("preserves a reused binding after a channel turn starts detached media", async () => {
739
+ const sessionKey = "agent:main:cron:media-job:run:run-1";
740
+ state.isCliProviderMock.mockReturnValue(true);
741
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
742
+ result: await params.run(
743
+ "claude-cli",
744
+ "claude-opus-4-8",
745
+ initialFallbackAttemptOptions(params),
746
+ ),
747
+ provider: "claude-cli",
748
+ model: "claude-opus-4-8",
749
+ attempts: [],
750
+ }));
751
+ const abort = Object.assign(new Error("detached media continues"), { name: "AbortError" });
752
+ state.runCliAgentMock.mockImplementationOnce(async () => {
753
+ registerGeneratedMediaTaskActivity("tool:image_generate:run-1", sessionKey);
754
+ throw abort;
755
+ });
756
+
757
+ const followupRun = createFollowupRun();
758
+ followupRun.run.provider = "claude-cli";
759
+ followupRun.run.model = "claude-opus-4-8";
760
+ const sessionEntry = {
761
+ sessionId: "openclaw-session",
762
+ updatedAt: 1,
763
+ cliSessionBindings: { "claude-cli": { sessionId: "media-session" } },
764
+ cliSessionIds: { "claude-cli": "media-session" },
765
+ claudeCliSessionId: "media-session",
766
+ } as SessionEntry;
767
+ const activeSessionStore = { [sessionKey]: sessionEntry };
768
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
769
+
770
+ await executeAgentTurn({
771
+ ...createMinimalRunAgentTurnParams({ followupRun }),
772
+ sessionKey,
773
+ activeSessionStore,
774
+ getActiveSessionEntry: () => sessionEntry,
775
+ });
776
+
777
+ expect(sessionEntry.cliSessionBindings?.["claude-cli"]?.sessionId).toBe("media-session");
778
+ expect(sessionEntry.cliSessionIds?.["claude-cli"]).toBe("media-session");
779
+ expect(sessionEntry.claudeCliSessionId).toBe("media-session");
780
+ });
781
+
782
+ it("preserves a reused binding after an operator aborts a channel turn", async () => {
783
+ const sessionKey = "agent:main:direct:aborted-run";
784
+ state.isCliProviderMock.mockReturnValue(true);
785
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
786
+ result: await params.run(
787
+ "claude-cli",
788
+ "claude-opus-4-8",
789
+ initialFallbackAttemptOptions(params),
790
+ ),
791
+ provider: "claude-cli",
792
+ model: "claude-opus-4-8",
793
+ attempts: [],
794
+ }));
795
+ state.runCliAgentMock.mockRejectedValueOnce(
796
+ Object.assign(new Error("CLI run aborted"), { name: "AbortError" }),
797
+ );
798
+
799
+ const followupRun = createFollowupRun();
800
+ followupRun.run.provider = "claude-cli";
801
+ followupRun.run.model = "claude-opus-4-8";
802
+ const sessionEntry = {
803
+ sessionId: "openclaw-session",
804
+ updatedAt: 1,
805
+ cliSessionBindings: { "claude-cli": { sessionId: "aborted-session" } },
806
+ cliSessionIds: { "claude-cli": "aborted-session" },
807
+ claudeCliSessionId: "aborted-session",
808
+ } as SessionEntry;
809
+ const activeSessionStore = { [sessionKey]: sessionEntry };
810
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
811
+
812
+ await executeAgentTurn({
813
+ ...createMinimalRunAgentTurnParams({ followupRun }),
814
+ sessionKey,
815
+ activeSessionStore,
816
+ getActiveSessionEntry: () => sessionEntry,
817
+ });
818
+
819
+ expect(sessionEntry.cliSessionBindings?.["claude-cli"]?.sessionId).toBe("aborted-session");
820
+ expect(sessionEntry.cliSessionIds?.["claude-cli"]).toBe("aborted-session");
821
+ expect(sessionEntry.claudeCliSessionId).toBe("aborted-session");
822
+ });
823
+
824
+ it("does not attribute media from an earlier admitted turn to a queued failure", async () => {
825
+ const sessionKey = "agent:main:cron:media-job:run:run-queued";
826
+ state.isCliProviderMock.mockReturnValue(true);
827
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
828
+ result: await params.run(
829
+ "claude-cli",
830
+ "claude-opus-4-8",
831
+ initialFallbackAttemptOptions(params),
832
+ ),
833
+ provider: "claude-cli",
834
+ model: "claude-opus-4-8",
835
+ attempts: [],
836
+ }));
837
+ state.runCliAgentMock.mockRejectedValueOnce(
838
+ new FailoverError("queued session expired", {
839
+ reason: "session_expired",
840
+ provider: "claude-cli",
841
+ model: "claude-opus-4-8",
842
+ }),
843
+ );
844
+
845
+ let releaseAdmission!: () => void;
846
+ const admissionGate = new Promise<void>((resolve) => {
847
+ releaseAdmission = resolve;
848
+ });
849
+ let notifyAdmissionWait!: () => void;
850
+ const admissionWait = new Promise<void>((resolve) => {
851
+ notifyAdmissionWait = resolve;
852
+ });
853
+ const restoreAdmission = installSessionPlacementAdmissionProvider({
854
+ assertCompactionSuccessorAllowed: rejectUnexpectedCompactionSuccessor,
855
+ executeLocalTurn: async (_claim, runLocal) => {
856
+ notifyAdmissionWait();
857
+ await admissionGate;
858
+ return await runLocal();
859
+ },
860
+ executeTurn: async (_claim, _params, runLocal) => await runLocal(),
861
+ });
862
+
863
+ const followupRun = createFollowupRun();
864
+ followupRun.run.provider = "claude-cli";
865
+ followupRun.run.model = "claude-opus-4-8";
866
+ const sessionEntry = {
867
+ sessionId: "openclaw-session",
868
+ updatedAt: 1,
869
+ cliSessionBindings: { "claude-cli": { sessionId: "queued-stale-session" } },
870
+ cliSessionIds: { "claude-cli": "queued-stale-session" },
871
+ claudeCliSessionId: "queued-stale-session",
872
+ } as SessionEntry;
873
+ const activeSessionStore = { [sessionKey]: sessionEntry };
874
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
875
+
876
+ try {
877
+ const runPromise = executeAgentTurn({
878
+ ...createMinimalRunAgentTurnParams({ followupRun }),
879
+ sessionKey,
880
+ activeSessionStore,
881
+ getActiveSessionEntry: () => sessionEntry,
882
+ });
883
+ await admissionWait;
884
+ registerGeneratedMediaTaskActivity("tool:image_generate:earlier-turn", sessionKey);
885
+ releaseAdmission();
886
+ const result = await runPromise;
887
+
888
+ expect(result.kind).toBe("final");
889
+ expect(sessionEntry.cliSessionBindings?.["claude-cli"]).toBeUndefined();
890
+ expect(sessionEntry.cliSessionIds?.["claude-cli"]).toBeUndefined();
891
+ expect(sessionEntry.claudeCliSessionId).toBeUndefined();
892
+ } finally {
893
+ releaseAdmission();
894
+ restoreAdmission();
895
+ }
896
+ });
897
+
898
+ it("clears a reused binding after the CLI reports an expired session", async () => {
899
+ state.isCliProviderMock.mockReturnValue(true);
900
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
901
+ result: await params.run(
902
+ "claude-cli",
903
+ "claude-opus-4-8",
904
+ initialFallbackAttemptOptions(params),
905
+ ),
906
+ provider: "claude-cli",
907
+ model: "claude-opus-4-8",
908
+ attempts: [],
909
+ }));
910
+ state.runCliAgentMock.mockRejectedValueOnce(
911
+ new FailoverError("No conversation found with session ID stale-cli-session", {
912
+ reason: "session_expired",
913
+ provider: "claude-cli",
914
+ model: "claude-opus-4-8",
915
+ }),
916
+ );
917
+
918
+ const followupRun = createFollowupRun();
919
+ followupRun.run.provider = "claude-cli";
920
+ followupRun.run.model = "claude-opus-4-8";
921
+ followupRun.run.skillsSnapshot = { prompt: "", skills: [], version: 0 };
922
+ followupRun.run.timeoutMs = 10_000;
923
+ const sessionEntry = {
924
+ sessionId: "openclaw-session",
925
+ updatedAt: 1,
926
+ cliSessionBindings: { "claude-cli": { sessionId: "stale-cli-session" } },
927
+ cliSessionIds: { "claude-cli": "stale-cli-session" },
928
+ claudeCliSessionId: "stale-cli-session",
929
+ } as SessionEntry;
930
+ const activeSessionStore = { main: sessionEntry };
931
+ const executeAgentTurn = await getExecuteAgentTurnForTest();
932
+
933
+ const result = await executeAgentTurn({
934
+ ...createMinimalRunAgentTurnParams({ followupRun }),
935
+ activeSessionStore,
936
+ getActiveSessionEntry: () => sessionEntry,
937
+ });
938
+
939
+ expect(result.kind).toBe("final");
940
+ expect(state.runCliAgentMock.mock.calls[0]?.[0]).toMatchObject({
941
+ cliSessionId: "stale-cli-session",
942
+ });
943
+ expect(sessionEntry.cliSessionBindings?.["claude-cli"]).toBeUndefined();
944
+ expect(sessionEntry.cliSessionIds?.["claude-cli"]).toBeUndefined();
945
+ expect(sessionEntry.claudeCliSessionId).toBeUndefined();
946
+ }, 15_000);
947
+ });