Skip to content

Commit f2d8f38

Browse files
hclhclsyssteipete
authored
fix(followup): route CLI runtime drains through CLI runner (#82847) (#82857)
* fix(followup): route CLI runtime drains through CLI runner * fix(followup): route queued CLI runtimes --------- Co-authored-by: hclsys <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 422a137 commit f2d8f38

5 files changed

Lines changed: 577 additions & 205 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
5050
### Fixes
5151

5252
- Gateway/Docker: fail closed for non-loopback gateway starts without explicit shared-secret or trusted-proxy auth, and stop the image default command from bypassing config validation. Fixes #82865. (#82866) Thanks @coygeek.
53+
- Agents/followups: route queued followup turns through CLI runtime backends instead of embedded harness lookup, preventing `claude-cli`/`google-gemini-cli` followups from failing before delivery. Fixes #82847. (#82857) Thanks @hclsys.
5354
- CLI/sessions: let `openclaw sessions cleanup --fix-missing` prune malformed rows with unresolvable transcript metadata instead of throwing. Fixes #80970. (#82745) Thanks @IWhatsskill.
5455
- Gateway/usage: refresh large session usage summaries in the background and reuse durable transcript metadata so `sessions.usage` no longer blocks Gateway requests on full transcript rescans. Fixes #82773. (#82778) Thanks @hclsys.
5556
- TUI: restore the submitted draft when chat is busy instead of clearing it or queueing another run. Fixes #45326. (#82774) Thanks @hyspacex.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { runCliAgent } from "../../agents/cli-runner.js";
2+
import type { RunCliAgentParams } from "../../agents/cli-runner/types.js";
3+
import type { EmbeddedPiRunResult } from "../../agents/pi-embedded.js";
4+
import { emitAgentEvent, onAgentEvent } from "../../infra/agent-events.js";
5+
import {
6+
normalizeLowercaseStringOrEmpty,
7+
normalizeOptionalString,
8+
} from "../../shared/string-coerce.js";
9+
10+
function shouldBridgeCliAssistantTextToReasoning(provider: string): boolean {
11+
return normalizeLowercaseStringOrEmpty(provider) === "claude-cli";
12+
}
13+
14+
function createAssistantTextBridge(params: {
15+
runId: string;
16+
suppressed?: boolean;
17+
deliver?: (text: string) => Promise<void>;
18+
}) {
19+
const deliver = params.deliver;
20+
if (!deliver) {
21+
return {
22+
unsubscribe: () => undefined,
23+
drain: async (): Promise<void> => undefined,
24+
};
25+
}
26+
let lastText: string | undefined;
27+
let unsubscribed = false;
28+
let delivery = Promise.resolve();
29+
const rawUnsubscribe = onAgentEvent((evt) => {
30+
if (evt.runId !== params.runId || evt.stream !== "assistant") {
31+
return;
32+
}
33+
if (params.suppressed) {
34+
return;
35+
}
36+
const text = typeof evt.data.text === "string" ? evt.data.text : undefined;
37+
if (text === undefined || text === lastText) {
38+
return;
39+
}
40+
lastText = text;
41+
delivery = delivery.then(() => deliver(text)).catch(() => undefined);
42+
});
43+
return {
44+
unsubscribe() {
45+
if (unsubscribed) {
46+
return;
47+
}
48+
unsubscribed = true;
49+
rawUnsubscribe();
50+
},
51+
async drain(): Promise<void> {
52+
await delivery;
53+
},
54+
};
55+
}
56+
57+
export async function runCliAgentWithLifecycle(params: {
58+
runId: string;
59+
provider: string;
60+
runParams: RunCliAgentParams;
61+
startedAt?: number;
62+
emitLifecycleStart?: boolean;
63+
emitLifecycleTerminal?: boolean;
64+
onAgentRunStart?: () => void;
65+
suppressAssistantBridge?: boolean;
66+
onAssistantText?: (text: string) => Promise<void>;
67+
onReasoningText?: (text: string) => Promise<void>;
68+
onErrorBeforeLifecycle?: (err: unknown) => Promise<void>;
69+
transformResult?: (result: EmbeddedPiRunResult) => EmbeddedPiRunResult;
70+
}): Promise<EmbeddedPiRunResult> {
71+
const startedAt = params.startedAt ?? Date.now();
72+
const emitLifecycleStart = params.emitLifecycleStart ?? true;
73+
const emitLifecycleTerminal = params.emitLifecycleTerminal ?? true;
74+
params.onAgentRunStart?.();
75+
if (emitLifecycleStart) {
76+
emitAgentEvent({
77+
runId: params.runId,
78+
stream: "lifecycle",
79+
data: {
80+
phase: "start",
81+
startedAt,
82+
},
83+
});
84+
}
85+
const assistantBridge = createAssistantTextBridge({
86+
runId: params.runId,
87+
suppressed: params.suppressAssistantBridge,
88+
deliver: params.onAssistantText,
89+
});
90+
const reasoningBridge = createAssistantTextBridge({
91+
runId: params.runId,
92+
suppressed: params.suppressAssistantBridge,
93+
deliver: shouldBridgeCliAssistantTextToReasoning(params.provider)
94+
? params.onReasoningText
95+
: undefined,
96+
});
97+
let lifecycleTerminalEmitted = false;
98+
try {
99+
const rawResult = await runCliAgent(params.runParams);
100+
const result = params.transformResult?.(rawResult) ?? rawResult;
101+
assistantBridge.unsubscribe();
102+
reasoningBridge.unsubscribe();
103+
await assistantBridge.drain();
104+
await reasoningBridge.drain();
105+
106+
const cliText = normalizeOptionalString(result.payloads?.[0]?.text);
107+
if (cliText) {
108+
emitAgentEvent({
109+
runId: params.runId,
110+
stream: "assistant",
111+
data: { text: cliText },
112+
});
113+
}
114+
115+
if (emitLifecycleTerminal) {
116+
emitAgentEvent({
117+
runId: params.runId,
118+
stream: "lifecycle",
119+
data: {
120+
phase: "end",
121+
startedAt,
122+
endedAt: Date.now(),
123+
},
124+
});
125+
lifecycleTerminalEmitted = true;
126+
}
127+
return result;
128+
} catch (err) {
129+
assistantBridge.unsubscribe();
130+
reasoningBridge.unsubscribe();
131+
await assistantBridge.drain();
132+
await reasoningBridge.drain();
133+
await params.onErrorBeforeLifecycle?.(err);
134+
if (emitLifecycleTerminal) {
135+
emitAgentEvent({
136+
runId: params.runId,
137+
stream: "lifecycle",
138+
data: {
139+
phase: "error",
140+
startedAt,
141+
endedAt: Date.now(),
142+
error: String(err),
143+
},
144+
});
145+
lifecycleTerminalEmitted = true;
146+
}
147+
throw err;
148+
} finally {
149+
assistantBridge.unsubscribe();
150+
reasoningBridge.unsubscribe();
151+
if (emitLifecycleTerminal && !lifecycleTerminalEmitted) {
152+
emitAgentEvent({
153+
runId: params.runId,
154+
stream: "lifecycle",
155+
data: {
156+
phase: "error",
157+
startedAt,
158+
endedAt: Date.now(),
159+
error: "CLI run completed without lifecycle terminal event",
160+
},
161+
});
162+
}
163+
}
164+
}

0 commit comments

Comments
 (0)