agent-relay-runner 0.127.6 → 0.127.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/adapter.ts
CHANGED
|
@@ -124,6 +124,11 @@ export interface ProviderPermissionPromptHandler {
|
|
|
124
124
|
buildView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt;
|
|
125
125
|
buildHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown>;
|
|
126
126
|
buildResolvedPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ProviderPermissionResolvedPrompt;
|
|
127
|
+
// #1173: for prompt kinds whose PreToolUse hook is answered immediately (deny-then-hold —
|
|
128
|
+
// see control-server.ts's AskUserQuestion branch) rather than held open for the decision,
|
|
129
|
+
// this renders the eventual "answer" decision as the normal user-prompt text to inject back
|
|
130
|
+
// into the session. Absent for prompt kinds that still ride the hook response directly.
|
|
131
|
+
buildAnswerInjectionPrompt?(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string;
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
export interface ProviderConfig {
|
|
@@ -12,6 +12,7 @@ export const claudePermissionPromptHandler: ProviderPermissionPromptHandler = {
|
|
|
12
12
|
buildView: claudePermissionApprovalView,
|
|
13
13
|
buildHookResponse: claudePermissionHookResponse,
|
|
14
14
|
buildResolvedPrompt: claudeResolvedPermissionPrompt,
|
|
15
|
+
buildAnswerInjectionPrompt: claudeAnswerInjectionPrompt,
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
|
|
@@ -155,6 +156,23 @@ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: Provi
|
|
|
155
156
|
};
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
// #1173: AskUserQuestion's PreToolUse hook is answered with `deny` immediately (see
|
|
160
|
+
// control-server.ts) so Claude flushes its buffered reasoning instead of blocking on the human.
|
|
161
|
+
// The user's actual answer arrives afterward as a normal injected prompt, not as a hook
|
|
162
|
+
// response — this renders that prompt's text from the same view/decision shape the old
|
|
163
|
+
// blocking flow used, so the model sees a clear, structured hand-off instead of silence.
|
|
164
|
+
function claudeAnswerInjectionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string {
|
|
165
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
166
|
+
const questions = Array.isArray(view.questions) ? view.questions : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
|
|
167
|
+
const rows = resolvedQuestionRows(questions, decision.answers, "(no answer given)");
|
|
168
|
+
const qa = rows.map((row) => `Q: ${row.question}\nA: ${row.answer}`).join("\n\n");
|
|
169
|
+
return [
|
|
170
|
+
"You previously called AskUserQuestion with the following question(s); it was intercepted so the user could answer directly via Agent Relay instead of through the tool call:",
|
|
171
|
+
qa,
|
|
172
|
+
"The user has now answered above. Continue the task using their answer(s) — do not call AskUserQuestion again for the same question(s).",
|
|
173
|
+
].join("\n\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
158
176
|
function claudeResolvedPermissionBody(input: { kind: string; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
|
|
159
177
|
if (input.kind === "questions") {
|
|
160
178
|
return [
|
package/src/control-server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
-
import type { Message, ReplyObligation } from "agent-relay-sdk";
|
|
2
|
+
import type { InteractivePrompt, Message, ReplyObligation } from "agent-relay-sdk";
|
|
3
3
|
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
4
4
|
import type { ProviderPermissionDecisionInput, ProviderPermissionDecisionReason, ProviderPermissionPromptHandler, ProviderPermissionResolvedPrompt, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
5
5
|
import { obligationRequiresExplicitReply } from "./reply-obligation-cache";
|
|
@@ -18,6 +18,11 @@ const PERMISSION_TIMEOUT_REASON =
|
|
|
18
18
|
"No response received within ~15 minutes (timeout, not a dismissal). Re-ask if you still need a decision.";
|
|
19
19
|
const PERMISSION_STOP_REASON = "The runner restarted while this permission request was pending; the original hook is no longer connected.";
|
|
20
20
|
const RESOLVED_APPROVAL_LIMIT = 1024;
|
|
21
|
+
// #1173: the immediate PreToolUse response for a "questions" prompt (see the deny-then-hold
|
|
22
|
+
// branch in handlePermissionRequest below). Bridges the hand-off so the model doesn't read a
|
|
23
|
+
// bare denial and re-ask — the real answer follows shortly as an injected user prompt.
|
|
24
|
+
const PENDING_QUESTION_DENY_REASON =
|
|
25
|
+
"The user is answering this question directly via Agent Relay, not through this tool call — do not re-ask it. Their answer will follow as your next message; wait for it and continue from there.";
|
|
21
26
|
|
|
22
27
|
// A hook that failed in a way it could not handle itself reports here so the
|
|
23
28
|
// failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
|
|
@@ -34,6 +39,12 @@ interface MonitorSocketData {
|
|
|
34
39
|
|
|
35
40
|
type MonitorSocket = ServerWebSocket<MonitorSocketData>;
|
|
36
41
|
|
|
42
|
+
interface PendingPermissionWaiter {
|
|
43
|
+
resolve(input: ProviderPermissionDecisionInput): void;
|
|
44
|
+
timer: Timer;
|
|
45
|
+
suppressResolvedStatus?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
export interface ControlServer {
|
|
38
49
|
port: number;
|
|
39
50
|
url: string;
|
|
@@ -52,6 +63,11 @@ interface ControlServerOptions {
|
|
|
52
63
|
onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
|
|
53
64
|
onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
|
|
54
65
|
onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
|
|
66
|
+
// #1173: a "questions" (AskUserQuestion) prompt's own PreToolUse hook is answered with `deny`
|
|
67
|
+
// immediately (see the branch in handlePermissionRequest) instead of held open for the human,
|
|
68
|
+
// so there is no live HTTP request left to answer once the decision resolves. This delivers
|
|
69
|
+
// that resolution instead: `text` is the prompt to inject as a normal user turn.
|
|
70
|
+
onAnswerInject?(input: { approvalId: string; text?: string }): Promise<void> | void;
|
|
55
71
|
// A provider user-prompt hook hands over text typed directly into the session
|
|
56
72
|
// so the runner can mirror it into dashboard chat.
|
|
57
73
|
onUserPrompt?(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void>;
|
|
@@ -78,7 +94,7 @@ export type ResolvedPermissionPrompt = ProviderPermissionResolvedPrompt;
|
|
|
78
94
|
export function startControlServer(options: ControlServerOptions): ControlServer {
|
|
79
95
|
const monitors = new Set<MonitorSocket>();
|
|
80
96
|
const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
|
|
81
|
-
const pendingPermissionRequests = new Map<string,
|
|
97
|
+
const pendingPermissionRequests = new Map<string, PendingPermissionWaiter>();
|
|
82
98
|
// approvalIds already resolved, so a duplicate decision (e.g. bus replay after a
|
|
83
99
|
// reconnect, or both the PreToolUse and PermissionRequest hooks racing one answer)
|
|
84
100
|
// is an idempotent no-op instead of a silent command failure (#693).
|
|
@@ -213,17 +229,19 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
213
229
|
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, input.approvalId);
|
|
214
230
|
recordResolvedApproval(resolvedApprovals, input.approvalId);
|
|
215
231
|
pending.resolve(withDecisionReason(input, input.decision === "deny" || input.decision === "abort" ? "dismissed" : "answered"));
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
232
|
+
if (!pending.suppressResolvedStatus) {
|
|
233
|
+
options.onStatus({
|
|
234
|
+
status: "busy",
|
|
235
|
+
reason: "provider-turn",
|
|
236
|
+
providerState: {
|
|
237
|
+
state: "active",
|
|
238
|
+
reason: "permissionResolved",
|
|
239
|
+
label: options.permissionPromptHandler?.resolvedLabel ?? "Provider approval resolved",
|
|
240
|
+
source: options.permissionPromptHandler?.source ?? options.providerSourceId ?? "provider",
|
|
241
|
+
updatedAt: Date.now(),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
}
|
|
227
245
|
return true;
|
|
228
246
|
},
|
|
229
247
|
};
|
|
@@ -292,26 +310,85 @@ function withDecisionReason(input: ProviderPermissionDecisionInput, kind: Provid
|
|
|
292
310
|
|
|
293
311
|
async function handlePermissionRequest(
|
|
294
312
|
req: Request,
|
|
295
|
-
|
|
296
|
-
|
|
313
|
+
options: ControlServerOptions,
|
|
314
|
+
pendingPermissionRequests: Map<string, PendingPermissionWaiter>,
|
|
297
315
|
): Promise<Response> {
|
|
298
316
|
const handler = options.permissionPromptHandler;
|
|
299
317
|
if (!handler) return Response.json({ ok: false, reason: "permission prompt handler unavailable" }, { status: 501 });
|
|
300
318
|
const occurredAt = Date.now();
|
|
301
319
|
const body = await req.json().catch(() => null);
|
|
302
320
|
if (!isRecord(body)) return Response.json({});
|
|
303
|
-
// #435/#1116: flush any un-mirrored narrative before the blocking form appears in
|
|
304
|
-
// chat. The adapter owns how to settle its capture source; the transport only
|
|
305
|
-
// forwards the hook payload as an anchor and stamps the returned truth value.
|
|
306
321
|
const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
|
|
307
322
|
const toolName = typeof body.tool_name === "string" ? body.tool_name : undefined;
|
|
308
323
|
const toolInput = isRecord(body.tool_input) ? body.tool_input : undefined;
|
|
324
|
+
const approvalId = crypto.randomUUID();
|
|
325
|
+
|
|
326
|
+
// #1173: a PreToolUse "questions" prompt (the provider's AskUserQuestion) used to block this hook
|
|
327
|
+
// response for up to ~15 minutes waiting on the human — which meant the turn's reasoning that
|
|
328
|
+
// led to the question stayed buffered in the provider's process (never flushed to the transcript
|
|
329
|
+
// JSONL) for exactly as long as the human took to answer, so the dashboard could only ever
|
|
330
|
+
// show the question AFTER the reasoning that motivated it. Denying the tool call immediately
|
|
331
|
+
// is the ordinary, already-supported "rejected tool call" hook contract: the provider persists the
|
|
332
|
+
// buffered turn + a rejected tool_result right away, on its own, same as any other denied tool
|
|
333
|
+
// — so the settle-poll harvest below (previously doomed to time out for this one case) now
|
|
334
|
+
// finds the freshly-flushed anchor within moments. The human's eventual answer is delivered
|
|
335
|
+
// afterward as a normal injected prompt (see the `.then` below), not as this hook's response.
|
|
336
|
+
const probeView = handler.buildView(approvalId, body, true);
|
|
337
|
+
if (body.hook_event_name === "PreToolUse" && probeView.kind === "questions" && handler.buildAnswerInjectionPrompt) {
|
|
338
|
+
const response = Response.json(handler.buildHookResponse(
|
|
339
|
+
{ approvalId, decision: "deny", reason: PENDING_QUESTION_DENY_REASON },
|
|
340
|
+
body,
|
|
341
|
+
));
|
|
342
|
+
void awaitPermissionDecision({
|
|
343
|
+
options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
|
|
344
|
+
blockedReason: "pendingQuestion",
|
|
345
|
+
recommendedAction: "Answer the question in Agent Relay.",
|
|
346
|
+
suppressResolvedStatus: true,
|
|
347
|
+
}).then(({ decision, view }) => {
|
|
348
|
+
const text = decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length
|
|
349
|
+
? handler.buildAnswerInjectionPrompt!(view, decision, body)
|
|
350
|
+
: pendingQuestionDismissalPrompt(decision);
|
|
351
|
+
return options.onAnswerInject?.({ approvalId, text });
|
|
352
|
+
}).catch(() => {});
|
|
353
|
+
return response;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const { decision } = await awaitPermissionDecision({
|
|
357
|
+
options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
|
|
358
|
+
blockedReason: "permissionRequest",
|
|
359
|
+
recommendedAction: "Review the permission request in Agent Relay.",
|
|
360
|
+
});
|
|
361
|
+
return Response.json(handler.buildHookResponse(decision, body));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Shared by both the traditional block-until-decided flow and the #1173 deny-then-hold flow:
|
|
365
|
+
// pre-flushes narrative (#435/#1116), builds+persists the interactive view, publishes the
|
|
366
|
+
// blocked providerState, and resolves once a decision arrives (or the wait times out). Callers
|
|
367
|
+
// differ only in what happens with the resolved decision (build a hook response vs. inject an
|
|
368
|
+
// answer prompt), so that part is left to them.
|
|
369
|
+
async function awaitPermissionDecision(params: {
|
|
370
|
+
options: ControlServerOptions;
|
|
371
|
+
pendingPermissionRequests: Map<string, PendingPermissionWaiter>;
|
|
372
|
+
handler: ProviderPermissionPromptHandler;
|
|
373
|
+
approvalId: string;
|
|
374
|
+
body: Record<string, unknown>;
|
|
375
|
+
occurredAt: number;
|
|
376
|
+
transcriptPath: string;
|
|
377
|
+
toolName?: string;
|
|
378
|
+
toolInput?: unknown;
|
|
379
|
+
blockedReason: string;
|
|
380
|
+
recommendedAction: string;
|
|
381
|
+
suppressResolvedStatus?: boolean;
|
|
382
|
+
}): Promise<{ decision: ProviderPermissionDecisionInput; view: InteractivePrompt }> {
|
|
383
|
+
const { options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput, blockedReason, recommendedAction, suppressResolvedStatus } = params;
|
|
384
|
+
// #435/#1116: flush any un-mirrored narrative before the blocking form appears in
|
|
385
|
+
// chat. The adapter owns how to settle its capture source; the transport only
|
|
386
|
+
// forwards the hook payload as an anchor and stamps the returned truth value.
|
|
309
387
|
let reasoningSettled = true;
|
|
310
388
|
if (transcriptPath && options.onSessionTurn) {
|
|
311
389
|
const result = await options.onSessionTurn({ transcriptPath, isPreFlush: true, toolName, toolInput }).catch(() => undefined);
|
|
312
390
|
if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
|
|
313
391
|
}
|
|
314
|
-
const approvalId = crypto.randomUUID();
|
|
315
392
|
const view = handler.buildView(approvalId, body, reasoningSettled);
|
|
316
393
|
const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
|
|
317
394
|
const hookDeadlineAt = occurredAt + waitMs;
|
|
@@ -327,9 +404,9 @@ async function handlePermissionRequest(
|
|
|
327
404
|
reason: "provider-turn",
|
|
328
405
|
providerState: {
|
|
329
406
|
state: "blocked",
|
|
330
|
-
reason:
|
|
407
|
+
reason: blockedReason,
|
|
331
408
|
label: view.title,
|
|
332
|
-
recommendedAction
|
|
409
|
+
recommendedAction,
|
|
333
410
|
source: handler.source,
|
|
334
411
|
pendingApproval: view,
|
|
335
412
|
updatedAt: Date.now(),
|
|
@@ -342,14 +419,20 @@ async function handlePermissionRequest(
|
|
|
342
419
|
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
|
|
343
420
|
resolve(withDecisionReason({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON }, "timeout"));
|
|
344
421
|
}, waitMs);
|
|
345
|
-
pendingPermissionRequests.set(approvalId, { resolve, timer });
|
|
422
|
+
pendingPermissionRequests.set(approvalId, { resolve, timer, suppressResolvedStatus });
|
|
346
423
|
});
|
|
347
424
|
|
|
348
425
|
if (decision.decisionReason?.kind !== "timeout" && options.onPermissionResolved) {
|
|
349
426
|
await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, occurredAt))).catch(() => {});
|
|
350
427
|
}
|
|
351
428
|
|
|
352
|
-
return
|
|
429
|
+
return { decision, view };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function pendingQuestionDismissalPrompt(decision: ProviderPermissionDecisionInput): string {
|
|
433
|
+
const timedOut = decision.decisionReason?.kind === "timeout";
|
|
434
|
+
const event = timedOut ? "timed out before the user answered" : "was dismissed before the user answered";
|
|
435
|
+
return `Your previous AskUserQuestion ${event}. Proceed on your best judgment, or re-ask if you still need a decision.`;
|
|
353
436
|
}
|
|
354
437
|
|
|
355
438
|
async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
|
package/src/runner-core.ts
CHANGED
|
@@ -277,6 +277,12 @@ export class AgentRunner {
|
|
|
277
277
|
// wakes one) so the blocked badge lifts on its own.
|
|
278
278
|
private rateLimitHold?: ProviderState;
|
|
279
279
|
private pendingRateLimitHold?: ProviderState;
|
|
280
|
+
// #1173: like rateLimitHold — a deny-then-hold "questions" (AskUserQuestion) prompt's own
|
|
281
|
+
// turn genuinely ends (the provider reacts to the denial and stops normally) before the human
|
|
282
|
+
// answers, so the pendingApproval can't ride the provider-turn active-work claim (it would
|
|
283
|
+
// vanish the instant that claim clears on the real idle). Persists across that idle; cleared
|
|
284
|
+
// once the answer is injected (or the prompt is dismissed/times out).
|
|
285
|
+
private pendingQuestionHold?: ProviderState;
|
|
280
286
|
// Settle-poll cadence for the pre-flush anchor (#1116) — instance fields (not just the
|
|
281
287
|
// module constants) so tests can shrink them without waiting out the real bound.
|
|
282
288
|
private settlePollIntervalMs = SETTLE_POLL_INTERVAL_MS;
|
|
@@ -429,6 +435,7 @@ export class AgentRunner {
|
|
|
429
435
|
onReplyObligations: () => Promise.resolve(this.obligationCache.get()),
|
|
430
436
|
onSessionTurn: (input) => this.publishSessionTurn(input), onPermissionResolved: (input) => this.publishSessionEvent({ from: this.agentId, to: "user", body: input.body, occurredAt: input.occurredAt, session: { type: "prompt-resolution", origin: "provider", label: input.decisionLabel, ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}), stepId: `prompt-resolution:${input.approvalId}`, promptResolution: input } }),
|
|
431
437
|
onPermissionAbandoned: (input) => this.publishPermissionAbandonedNotice(input),
|
|
438
|
+
onAnswerInject: (input) => this.injectAnswerPrompt(input),
|
|
432
439
|
onUserPrompt: (input) => this.handleUserPrompt(input),
|
|
433
440
|
onSessionBoundary: (input) => this.handleSessionBoundary(input),
|
|
434
441
|
onHookFatal: (report) => this.reportHookFatal(report),
|
|
@@ -926,6 +933,29 @@ export class AgentRunner {
|
|
|
926
933
|
return { injected: true, messageId, attachmentCount: attachments.length };
|
|
927
934
|
}
|
|
928
935
|
|
|
936
|
+
// #1173: the resolution side of the deny-then-hold AskUserQuestion flow (see control-server.ts's
|
|
937
|
+
// handlePermissionRequest). By the time this fires, the original PreToolUse hook has long since
|
|
938
|
+
// been answered with `deny` — there's no live HTTP request to respond to — so the human's answer
|
|
939
|
+
// (or dismissal) is delivered by clearing the pending-question hold and injecting a normal
|
|
940
|
+
// user prompt via the same path the dashboard chat box uses.
|
|
941
|
+
private async injectAnswerPrompt(input: { approvalId: string; text?: string }): Promise<void> {
|
|
942
|
+
const heldApprovalId = typeof this.pendingQuestionHold?.pendingApproval?.id === "string" ? this.pendingQuestionHold.pendingApproval.id : undefined;
|
|
943
|
+
if (heldApprovalId && heldApprovalId !== input.approvalId) return;
|
|
944
|
+
if (heldApprovalId === input.approvalId) {
|
|
945
|
+
this.pendingQuestionHold = undefined;
|
|
946
|
+
this.publishStatus();
|
|
947
|
+
}
|
|
948
|
+
const text = input.text ?? "The question was dismissed or timed out before the user answered. Proceed on your best judgment, or re-ask if you still need a decision.";
|
|
949
|
+
if (!this.process || !this.options.adapter.deliverInitialPrompt) {
|
|
950
|
+
this.sessionLog(`could not inject AskUserQuestion answer for ${input.approvalId}: provider process/prompt-injection unavailable`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
this.recordInjectedPrompt(text.trim());
|
|
954
|
+
await this.options.adapter.deliverInitialPrompt(this.process, text).catch((error) => {
|
|
955
|
+
this.sessionLog(`failed to inject AskUserQuestion answer for ${input.approvalId}: ${errMessage(error)}`);
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
|
|
929
959
|
private async promptInjectionText(body: string, messageId: number | undefined, attachments: Record<string, unknown>[]): Promise<string> {
|
|
930
960
|
if (!attachments.length) return body;
|
|
931
961
|
const messages = await messagesWithCachedAttachments([{
|
|
@@ -1251,10 +1281,18 @@ export class AgentRunner {
|
|
|
1251
1281
|
this.rateLimitHold = update.providerState;
|
|
1252
1282
|
if (fresh) this.publishRateLimitNotice(update.providerState);
|
|
1253
1283
|
}
|
|
1284
|
+
} else if (ps.state === "blocked" && ps.reason === "pendingQuestion") {
|
|
1285
|
+
// #1173: set directly (never deferred) — unlike the rate-limit hold, this always
|
|
1286
|
+
// arrives while the turn that asked is still busy, well before the real idle it must
|
|
1287
|
+
// survive. Any other providerState (a fresh question, a plain approval, etc.) still
|
|
1288
|
+
// supersedes it via the `else` branch below.
|
|
1289
|
+
this.providerBlocked = true;
|
|
1290
|
+
this.pendingQuestionHold = update.providerState;
|
|
1254
1291
|
} else {
|
|
1255
1292
|
this.providerBlocked = ps.state === "blocked";
|
|
1256
1293
|
this.pendingRateLimitHold = undefined;
|
|
1257
1294
|
this.rateLimitHold = undefined;
|
|
1295
|
+
this.pendingQuestionHold = undefined;
|
|
1258
1296
|
}
|
|
1259
1297
|
} else if (status === "idle") {
|
|
1260
1298
|
this.providerBlocked = false;
|
|
@@ -1313,6 +1351,12 @@ export class AgentRunner {
|
|
|
1313
1351
|
this.currentTurnStartedAt = Date.now();
|
|
1314
1352
|
this.compactionMidTurn = false;
|
|
1315
1353
|
this.sessionLog(`turn started (turn ${this.currentTurnId})`);
|
|
1354
|
+
// #1173: a genuinely new turn starting (not the mid-turn busy update that sets the
|
|
1355
|
+
// hold itself — that arrives while currentTurnId is already set) means the human moved
|
|
1356
|
+
// on without using the form (a fresh message, a self-resume, etc.). The normal
|
|
1357
|
+
// answer/dismiss path already cleared this; this is the fallback for every other way
|
|
1358
|
+
// forward progress can happen, so a stale question never lingers past its own turn.
|
|
1359
|
+
this.pendingQuestionHold = undefined;
|
|
1316
1360
|
}
|
|
1317
1361
|
id = this.currentTurnId;
|
|
1318
1362
|
this.busyReconciler.arm();
|
|
@@ -1904,7 +1948,7 @@ export class AgentRunner {
|
|
|
1904
1948
|
const staleProviderTurnBusy = this.isStaleProviderTurnOnlyBusy(status, activeWork, busyReasons, liveness);
|
|
1905
1949
|
const effectiveStatus = staleProviderTurnBusy ? "idle" : status;
|
|
1906
1950
|
const agentStatus = runnerAgentStatus(effectiveStatus);
|
|
1907
|
-
const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? (staleProviderTurnBusy ? null : providerStateFromActiveWork(activeWork));
|
|
1951
|
+
const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? this.pendingQuestionHold ?? (staleProviderTurnBusy ? null : providerStateFromActiveWork(activeWork));
|
|
1908
1952
|
this.bus.setSemanticStatus(effectiveStatus === "offline" || effectiveStatus === "error" ? "idle" : effectiveStatus);
|
|
1909
1953
|
this.bus.status({
|
|
1910
1954
|
agentStatus,
|