claude-code-acp-ts 0.53.0 → 0.57.0-beta.2
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/dist/acp-agent.d.ts +146 -2
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +866 -149
- package/dist/elicitation.d.ts +51 -3
- package/dist/elicitation.d.ts.map +1 -1
- package/dist/elicitation.js +117 -37
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +17 -0
- package/package.json +10 -10
package/dist/acp-agent.js
CHANGED
|
@@ -7,7 +7,7 @@ import * as os from "node:os";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import packageJson from "../package.json" with { type: "json" };
|
|
10
|
-
import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, mcpElicitationToCreateRequest, } from "./elicitation.js";
|
|
10
|
+
import { applyAskElicitationResponse, askUserQuestionsToCreateRequest, createElicitationResponseToElicitResult, extractAskUserQuestions, extractRefusalFallbackPrompt, mcpElicitationToCreateRequest, REFUSAL_FALLBACK_DIALOG_KIND, refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js";
|
|
11
11
|
import { SettingsManager } from "./settings.js";
|
|
12
12
|
import { applyTaskCreate, applyTaskUpdate, createPostToolUseHook, createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, taskStateToPlanEntries, toolInfoFromToolUse, toolUpdateFromDiffToolResponse, toolUpdateFromToolResult, } from "./tools.js";
|
|
13
13
|
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
|
@@ -42,6 +42,12 @@ const DEFAULT_CONTEXT_WINDOW = 200000;
|
|
|
42
42
|
* "obviously stuck" ceiling, not a guess at interrupt latency, so it can't
|
|
43
43
|
* pre-empt a slow-but-healthy interrupt. */
|
|
44
44
|
const DEFAULT_FORCE_CANCEL_GRACE_MS = 30_000;
|
|
45
|
+
/** Error surfaced when the SDK declares a turn over (`session_state_changed:
|
|
46
|
+
* idle`, its authoritative turn-over signal) without ever emitting the turn's
|
|
47
|
+
* `result` — a model stream that dropped mid-turn, or an async agent that
|
|
48
|
+
* completed/stalled without the host turn resolving (issue #825). */
|
|
49
|
+
const TURN_NO_RESULT_MESSAGE = "The turn ended without a result: the agent went idle while this prompt was still in flight " +
|
|
50
|
+
"(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
|
|
45
51
|
/** Compute a stable fingerprint of the session-defining params so we can
|
|
46
52
|
* detect when a loadSession/resumeSession call requires tearing down and
|
|
47
53
|
* recreating the underlying Query process. MCP servers are sorted by name
|
|
@@ -185,6 +191,9 @@ export function isLocalCommandMetadata(content) {
|
|
|
185
191
|
const PERMISSION_MODE_ALIASES = {
|
|
186
192
|
auto: "auto",
|
|
187
193
|
default: "default",
|
|
194
|
+
// Claude Code 2.1.200 renamed the "default" mode to "Manual" and accepts
|
|
195
|
+
// `"defaultMode": "manual"` in settings.json; honor the same alias here.
|
|
196
|
+
manual: "default",
|
|
188
197
|
acceptedits: "acceptEdits",
|
|
189
198
|
dontask: "dontAsk",
|
|
190
199
|
plan: "plan",
|
|
@@ -660,6 +669,20 @@ export class ClaudeAcpAgent {
|
|
|
660
669
|
// Stop reason accumulated for the active turn (result subtype, refusal,
|
|
661
670
|
// max_tokens, …). Reset per turn; read when the turn settles at idle.
|
|
662
671
|
let stopReason = "end_turn";
|
|
672
|
+
// How many trailing `session_state_changed: idle` messages are already
|
|
673
|
+
// accounted for: every user-turn result that terminates a turn (settle,
|
|
674
|
+
// reject, or orphan skip) is followed by one, as is a cancelled turn
|
|
675
|
+
// settled by the next turn's echo hand-off. The idle handler absorbs owed
|
|
676
|
+
// idles; an idle that arrives when NONE is owed while the active turn is
|
|
677
|
+
// still unsettled means the SDK ended the turn without ever emitting its
|
|
678
|
+
// result, so the turn will never settle on its own (issue #825).
|
|
679
|
+
// Stream-level debt, deliberately NOT reset per turn: a lagged idle can
|
|
680
|
+
// arrive after the next turn has already activated (issue #773), and the
|
|
681
|
+
// debt is what attributes it to the turn that owed it. Over-counting (an
|
|
682
|
+
// idle the SDK never emits, e.g. CLI binaries without session-state
|
|
683
|
+
// events — issue #497) is benign: the counter just absorbs one future
|
|
684
|
+
// idle, and detection degrades to the status quo rather than misfiring.
|
|
685
|
+
let owedTrailingIdles = 0;
|
|
663
686
|
const resetTurnScratch = () => {
|
|
664
687
|
lastAssistantTotalUsage = null;
|
|
665
688
|
lastAssistantUsage = null;
|
|
@@ -780,9 +803,19 @@ export class ClaudeAcpAgent {
|
|
|
780
803
|
// settle "cancelled" even when query.next() is wedged (issue #680). Re-armed
|
|
781
804
|
// after each fire so the consumer keeps serving later turns.
|
|
782
805
|
let cancelController = session.cancelController;
|
|
806
|
+
// The in-flight query.next(), kept across abort wake-ups that don't
|
|
807
|
+
// consume a message, so no yielded message is ever dropped — async
|
|
808
|
+
// generators serialize next() calls, so racing a SECOND next() while one
|
|
809
|
+
// is pending would make the abandoned one swallow a message (e.g. a
|
|
810
|
+
// force-cancelled turn's late result, whose orphan accounting below
|
|
811
|
+
// depends on actually seeing it).
|
|
812
|
+
let pendingNext = null;
|
|
783
813
|
try {
|
|
784
814
|
while (true) {
|
|
785
|
-
|
|
815
|
+
pendingNext ??= session.query
|
|
816
|
+
.next()
|
|
817
|
+
.then((result) => ({ kind: "message", result }));
|
|
818
|
+
const nextMessage = pendingNext;
|
|
786
819
|
// Fresh abort listener per iteration, removed when next() wins, so a
|
|
787
820
|
// long-lived session doesn't accumulate listeners on one signal.
|
|
788
821
|
let onAbort;
|
|
@@ -790,25 +823,36 @@ export class ClaudeAcpAgent {
|
|
|
790
823
|
onAbort = () => resolve("abort");
|
|
791
824
|
cancelController.signal.addEventListener("abort", onAbort, { once: true });
|
|
792
825
|
});
|
|
793
|
-
const raced = await Promise.race([
|
|
794
|
-
nextMessage.then((result) => ({ kind: "message", result })),
|
|
795
|
-
abortRace,
|
|
796
|
-
]);
|
|
826
|
+
const raced = await Promise.race([nextMessage, abortRace]);
|
|
797
827
|
cancelController.signal.removeEventListener("abort", onAbort);
|
|
798
828
|
if (raced === "abort") {
|
|
799
|
-
// cancel()/teardown woke us
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
829
|
+
// cancel()/teardown woke us: settle the active turn "cancelled" per
|
|
830
|
+
// the ACP contract. The SDK never acknowledged this turn (that's why
|
|
831
|
+
// the force-cancel backstop fired), so if it later recovers from the
|
|
832
|
+
// wedge it will still emit the turn's result — with no live turn to
|
|
833
|
+
// match — followed by its trailing idle. Pre-count it as an orphan
|
|
834
|
+
// so that late result is skipped (not promoted onto the next queued
|
|
835
|
+
// prompt) and its trailer is recorded as owed, not read as the next
|
|
836
|
+
// turn being abandoned. Stale counts self-heal: activation resets
|
|
837
|
+
// them (see activateTurn).
|
|
838
|
+
if (session.activeTurn && !session.activeTurn.settled) {
|
|
839
|
+
session.pendingOrphanResults = (session.pendingOrphanResults ?? 0) + 1;
|
|
840
|
+
}
|
|
841
|
+
settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
|
|
842
|
+
// If the session is being torn down, abandon the in-flight next()
|
|
843
|
+
// (swallowing any later rejection so it can't surface as unhandled)
|
|
844
|
+
// and stop; otherwise re-arm and keep consuming — `pendingNext`
|
|
845
|
+
// stays in flight so its eventual message is processed, not dropped.
|
|
805
846
|
if (!this.sessions[params.sessionId]) {
|
|
847
|
+
void nextMessage.catch(() => { });
|
|
806
848
|
return;
|
|
807
849
|
}
|
|
808
850
|
cancelController = new AbortController();
|
|
809
851
|
session.cancelController = cancelController;
|
|
810
852
|
continue;
|
|
811
853
|
}
|
|
854
|
+
// A message arrived: this next() is consumed; arm a fresh one next pass.
|
|
855
|
+
pendingNext = null;
|
|
812
856
|
const { value: message, done } = raced.result;
|
|
813
857
|
if (done || !message) {
|
|
814
858
|
// The stream ended. Settle the in-flight turns FIRST, then release the
|
|
@@ -819,9 +863,10 @@ export class ClaudeAcpAgent {
|
|
|
819
863
|
//
|
|
820
864
|
// Settle the turn that was in flight so its prompt() doesn't hang:
|
|
821
865
|
// cancelled if a cancel is pending, otherwise the accumulated outcome.
|
|
822
|
-
settleActive(
|
|
823
|
-
|
|
824
|
-
|
|
866
|
+
settleActive({
|
|
867
|
+
stopReason: session.cancelled ? "cancelled" : stopReason,
|
|
868
|
+
usage: sessionUsage(session),
|
|
869
|
+
});
|
|
825
870
|
// Queued turns the SDK never started never ran, so reject them rather
|
|
826
871
|
// than reporting a success (end_turn) — or a misleading "cancelled" —
|
|
827
872
|
// for a prompt that produced no output. (A cancel already settled the
|
|
@@ -852,6 +897,10 @@ export class ClaudeAcpAgent {
|
|
|
852
897
|
case "system":
|
|
853
898
|
switch (message.subtype) {
|
|
854
899
|
case "init":
|
|
900
|
+
// A fresh `system`/init (e.g. after reinitialize) can carry an
|
|
901
|
+
// updated Fast mode state; reconcile it with what we seeded at
|
|
902
|
+
// session creation.
|
|
903
|
+
await this.syncFastModeState(message.session_id, session, message.fast_mode_state);
|
|
855
904
|
break;
|
|
856
905
|
case "status": {
|
|
857
906
|
if (message.status === "compacting") {
|
|
@@ -937,19 +986,60 @@ export class ClaudeAcpAgent {
|
|
|
937
986
|
}
|
|
938
987
|
case "session_state_changed": {
|
|
939
988
|
if (message.state === "idle") {
|
|
940
|
-
// A non-cancelled turn
|
|
941
|
-
// `result` (issue #773),
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
//
|
|
951
|
-
|
|
952
|
-
|
|
989
|
+
// A non-cancelled turn normally settled at its terminal
|
|
990
|
+
// `result` already (issue #773), and that result recorded an
|
|
991
|
+
// owed trailing idle — absorbed here via the decrement. We
|
|
992
|
+
// must NOT settle `activeTurn` on an owed idle: `idle`
|
|
993
|
+
// carries no turn identity, and it can lag (the SDK flushes
|
|
994
|
+
// held-back results / drains background agents first), so by
|
|
995
|
+
// the time it arrives the SDK may have echoed the NEXT turn
|
|
996
|
+
// and activated it — settling now would resolve that new
|
|
997
|
+
// turn prematurely with end_turn and ~zero usage, dropping
|
|
998
|
+
// its real result. A cancelled turn relies on `idle`: its
|
|
999
|
+
// `result` is dropped at the `session.cancelled` guard, so
|
|
1000
|
+
// it never settles at a result and must settle here.
|
|
1001
|
+
//
|
|
1002
|
+
// An idle that is NOT owed while the active turn is still
|
|
1003
|
+
// unsettled is the issue #825 signature: `idle` is the SDK's
|
|
1004
|
+
// authoritative turn-over signal (it fires after held-back
|
|
1005
|
+
// results flush and background agents drain), so a turn that
|
|
1006
|
+
// reaches it without a result will never get one — the model
|
|
1007
|
+
// stream dropped mid-turn, or an async agent
|
|
1008
|
+
// completed/stalled without the host turn resolving. Fail
|
|
1009
|
+
// the turn NOW so its session/prompt gets a terminal
|
|
1010
|
+
// response, instead of leaving it hanging until the next
|
|
1011
|
+
// prompt drains the wreckage.
|
|
1012
|
+
// A cancelled turn still consumed tokens: its dropped result
|
|
1013
|
+
// already fed the accumulator (the usage tally at the result
|
|
1014
|
+
// handler runs before the `session.cancelled` guard), so
|
|
1015
|
+
// report it — clients metering spend would otherwise lose
|
|
1016
|
+
// the interrupted turn's tokens entirely (issue #844). Zero
|
|
1017
|
+
// when the cancel pre-empted the result (wedge/force-cancel).
|
|
1018
|
+
if (session.cancelled && session.activeTurn && !session.activeTurn.settled) {
|
|
1019
|
+
settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
|
|
1020
|
+
}
|
|
1021
|
+
else if (owedTrailingIdles > 0) {
|
|
1022
|
+
// Absorb a settled turn's trailing idle. Also covers a
|
|
1023
|
+
// cancel that landed between a turn's counted result and
|
|
1024
|
+
// this lagged idle (no active turn to settle): the idle
|
|
1025
|
+
// still belongs to that settled turn, and skipping the
|
|
1026
|
+
// decrement would leak the debt permanently.
|
|
1027
|
+
owedTrailingIdles--;
|
|
1028
|
+
}
|
|
1029
|
+
else if (!session.cancelled &&
|
|
1030
|
+
session.activeTurn &&
|
|
1031
|
+
!session.activeTurn.settled) {
|
|
1032
|
+
// Deliberately only the ACTIVE turn: a queued turn that
|
|
1033
|
+
// was never echoed is NOT failed here, because an idle
|
|
1034
|
+
// can legitimately precede the SDK picking up freshly
|
|
1035
|
+
// pushed input (the idle was emitted before the SDK read
|
|
1036
|
+
// it) — failing the queue head on that race would reject
|
|
1037
|
+
// a prompt the SDK is about to run. A turn abandoned
|
|
1038
|
+
// before its echo therefore still hangs until cancel or
|
|
1039
|
+
// the next prompt; only a timer could tell those apart.
|
|
1040
|
+
this.logger.error(`Session ${params.sessionId}: SDK went idle without emitting a result ` +
|
|
1041
|
+
`for the active turn; failing the in-flight prompt (issue #825)`);
|
|
1042
|
+
failActive(RequestError.internalError(errorKindData("no_result"), TURN_NO_RESULT_MESSAGE));
|
|
953
1043
|
}
|
|
954
1044
|
// The SDK generates the session title in a background task and
|
|
955
1045
|
// persists it to the session file; `idle` is the turn-over
|
|
@@ -1105,9 +1195,65 @@ export class ClaudeAcpAgent {
|
|
|
1105
1195
|
case "notification":
|
|
1106
1196
|
case "api_retry":
|
|
1107
1197
|
case "thinking_tokens":
|
|
1108
|
-
case "model_refusal_fallback":
|
|
1109
1198
|
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
|
1110
1199
|
break;
|
|
1200
|
+
case "model_refusal_fallback": {
|
|
1201
|
+
// The SDK retried a refused turn on the fallback model and made
|
|
1202
|
+
// the swap persistent for the session. Without a notice the
|
|
1203
|
+
// user just sees regenerated output; without the state sync the
|
|
1204
|
+
// client's model picker (and the model-dependent options
|
|
1205
|
+
// rebuilt from it) keeps advertising a model the session is no
|
|
1206
|
+
// longer running.
|
|
1207
|
+
//
|
|
1208
|
+
// Current CLIs only emit direction "retry" (persistent swap).
|
|
1209
|
+
// "revert"/"sticky" are retained in the SDK enum for older
|
|
1210
|
+
// CLIs, where "revert" marked a turn-only fallback — for that
|
|
1211
|
+
// direction the session stays on the original model, so skip
|
|
1212
|
+
// the persistent-swap claim and the state sync.
|
|
1213
|
+
const persistent = message.direction !== "revert";
|
|
1214
|
+
const category = message.api_refusal_category
|
|
1215
|
+
? ` (${message.api_refusal_category})`
|
|
1216
|
+
: "";
|
|
1217
|
+
const explanation = message.api_refusal_explanation
|
|
1218
|
+
? `\n\n${message.api_refusal_explanation}`
|
|
1219
|
+
: "";
|
|
1220
|
+
const outcome = persistent
|
|
1221
|
+
? `The session will continue on ${message.fallback_model}.`
|
|
1222
|
+
: `The session stays on ${message.original_model}.`;
|
|
1223
|
+
await this.client.sessionUpdate({
|
|
1224
|
+
sessionId: message.session_id,
|
|
1225
|
+
update: {
|
|
1226
|
+
sessionUpdate: "agent_message_chunk",
|
|
1227
|
+
content: {
|
|
1228
|
+
type: "text",
|
|
1229
|
+
text: `**Model fallback:** ${message.original_model} declined this request${category}; retried with ${message.fallback_model}. ${outcome}${explanation}`,
|
|
1230
|
+
},
|
|
1231
|
+
},
|
|
1232
|
+
});
|
|
1233
|
+
if (persistent) {
|
|
1234
|
+
await this.syncModelAfterRefusalFallback(params.sessionId, session, message.fallback_model);
|
|
1235
|
+
}
|
|
1236
|
+
break;
|
|
1237
|
+
}
|
|
1238
|
+
case "model_refusal_no_fallback":
|
|
1239
|
+
// The refusal ends the turn as an error; the terminal `result`
|
|
1240
|
+
// handler settles it with ACP's `refusal` stop reason and
|
|
1241
|
+
// streams `lastRefusalExplanation`. The assistant frame's
|
|
1242
|
+
// stop_details is the primary source for that explanation —
|
|
1243
|
+
// this structured banner is the backup source when the frame
|
|
1244
|
+
// carried none (older CLIs, gateways that drop stop_details).
|
|
1245
|
+
//
|
|
1246
|
+
// `refused_user_message_uuid` is explicitly null when the
|
|
1247
|
+
// refused turn was not human-authored (a background
|
|
1248
|
+
// task-notification followup or auto-continuation) — don't
|
|
1249
|
+
// let those pollute the user turn's explanation. `undefined`
|
|
1250
|
+
// (older CLIs that omit the field) can't be attributed either
|
|
1251
|
+
// way, so keep seeding — the same exposure the assistant-frame
|
|
1252
|
+
// capture already has.
|
|
1253
|
+
if (!lastRefusalExplanation && message.refused_user_message_uuid !== null) {
|
|
1254
|
+
lastRefusalExplanation = message.api_refusal_explanation ?? message.content;
|
|
1255
|
+
}
|
|
1256
|
+
break;
|
|
1111
1257
|
default:
|
|
1112
1258
|
unreachable(message, this.logger);
|
|
1113
1259
|
break;
|
|
@@ -1119,6 +1265,13 @@ export class ClaudeAcpAgent {
|
|
|
1119
1265
|
// They should not influence the user-turn lifecycle (stop reason,
|
|
1120
1266
|
// slash-command output forwarding) but their cost is real.
|
|
1121
1267
|
const isTaskNotification = message.origin?.kind === "task-notification";
|
|
1268
|
+
// Reconcile the Fast mode toggle with the SDK's reported state.
|
|
1269
|
+
// Gated to user-driven turns like every other side effect below; a
|
|
1270
|
+
// background followup's state lands on the next user turn's result.
|
|
1271
|
+
// Runs even when the turn errors or was cancelled.
|
|
1272
|
+
if (!isTaskNotification) {
|
|
1273
|
+
await this.syncFastModeState(params.sessionId, session, message.fast_mode_state);
|
|
1274
|
+
}
|
|
1122
1275
|
// A user-turn result needs an active turn so its stop reason is
|
|
1123
1276
|
// attributed and the turn settles at idle. Local-only commands carry
|
|
1124
1277
|
// no user-message echo to promote them, so do it here from the head.
|
|
@@ -1127,6 +1280,23 @@ export class ClaudeAcpAgent {
|
|
|
1127
1280
|
if (!isTaskNotification) {
|
|
1128
1281
|
ensureActiveTurn();
|
|
1129
1282
|
}
|
|
1283
|
+
// Every user-turn result terminates a turn (settle, reject, or
|
|
1284
|
+
// orphan skip) and the SDK follows it with a trailing
|
|
1285
|
+
// `session_state_changed: idle` — record the debt so the idle
|
|
1286
|
+
// handler absorbs that idle rather than reading it as a turn the
|
|
1287
|
+
// SDK abandoned (issue #825). One exclusion: the cancelled ACTIVE
|
|
1288
|
+
// turn's own result. It is dropped at the `session.cancelled`
|
|
1289
|
+
// guard, and either the idle itself settles the turn (consuming
|
|
1290
|
+
// the trailer) or the next echo's hand-off does (which records
|
|
1291
|
+
// the debt there instead) — counting here too would double it.
|
|
1292
|
+
// Results skipped while cancelled with NO active turn — orphaned
|
|
1293
|
+
// queued turns the SDK still ran, or a force-cancelled turn's
|
|
1294
|
+
// late result after the backstop settled it — get no such settle,
|
|
1295
|
+
// so their trailers must be counted here or they'd later be read
|
|
1296
|
+
// as the next healthy turn being abandoned and false-fail it.
|
|
1297
|
+
if (!isTaskNotification && (!session.cancelled || !session.activeTurn)) {
|
|
1298
|
+
owedTrailingIdles++;
|
|
1299
|
+
}
|
|
1130
1300
|
// Accumulate usage into the user turn's tally. Skip task-notification
|
|
1131
1301
|
// followups: their cost is real but is reported separately via the
|
|
1132
1302
|
// usage_update below, and `session.accumulatedUsage` is only reset on
|
|
@@ -1305,7 +1475,7 @@ export class ClaudeAcpAgent {
|
|
|
1305
1475
|
// create a block the consolidated handler's `text.length > 0`
|
|
1306
1476
|
// guard can never consume, stalling the diff cursor and
|
|
1307
1477
|
// re-emitting the next block as a duplicate.
|
|
1308
|
-
if (chunk
|
|
1478
|
+
if (chunk?.text) {
|
|
1309
1479
|
const index = message.event.index;
|
|
1310
1480
|
const last = streamedBlocks[streamedBlocks.length - 1];
|
|
1311
1481
|
if (last && last.index === index && last.type === chunk.type) {
|
|
@@ -1367,6 +1537,7 @@ export class ClaudeAcpAgent {
|
|
|
1367
1537
|
clientCapabilities: this.clientCapabilities,
|
|
1368
1538
|
cwd: session.cwd,
|
|
1369
1539
|
taskState: session.taskState,
|
|
1540
|
+
emittedToolCalls: session.emittedToolCalls,
|
|
1370
1541
|
messageId: currentStreamMessageId,
|
|
1371
1542
|
})) {
|
|
1372
1543
|
await this.client.sessionUpdate(notification);
|
|
@@ -1402,9 +1573,20 @@ export class ClaudeAcpAgent {
|
|
|
1402
1573
|
// "cancelled" per the ACP contract rather than "end_turn" —
|
|
1403
1574
|
// otherwise a cancel followed quickly by the next prompt
|
|
1404
1575
|
// would report the cancelled turn as a normal completion.
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1576
|
+
if (session.cancelled) {
|
|
1577
|
+
// The cancelled turn settles here, but the trailing idle
|
|
1578
|
+
// its interrupt produces is still in flight — record the
|
|
1579
|
+
// debt so that lagged idle is absorbed rather than read
|
|
1580
|
+
// as the freshly-activated turn ending without a result
|
|
1581
|
+
// (which would false-fail a healthy turn — issue #825).
|
|
1582
|
+
owedTrailingIdles++;
|
|
1583
|
+
// Before activateTurn resets the accumulator, so the
|
|
1584
|
+
// usage still belongs to the cancelled turn.
|
|
1585
|
+
settleActive({ stopReason: "cancelled", usage: sessionUsage(session) });
|
|
1586
|
+
}
|
|
1587
|
+
else {
|
|
1588
|
+
settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
|
|
1589
|
+
}
|
|
1408
1590
|
}
|
|
1409
1591
|
activateTurn(queued);
|
|
1410
1592
|
}
|
|
@@ -1432,7 +1614,13 @@ export class ClaudeAcpAgent {
|
|
|
1432
1614
|
lastAssistantError = message.error;
|
|
1433
1615
|
}
|
|
1434
1616
|
if (message.message.stop_reason === "refusal") {
|
|
1435
|
-
|
|
1617
|
+
// Keep any explanation already seeded by a
|
|
1618
|
+
// `model_refusal_no_fallback` banner — the banner/frame
|
|
1619
|
+
// ordering is CLI-dependent, and a frame whose stop_details
|
|
1620
|
+
// was dropped (the case the banner backup exists for) must
|
|
1621
|
+
// not clobber the seed back to null.
|
|
1622
|
+
lastRefusalExplanation =
|
|
1623
|
+
message.message.stop_details?.explanation ?? lastRefusalExplanation;
|
|
1436
1624
|
}
|
|
1437
1625
|
}
|
|
1438
1626
|
// Strip <command-*>/<local-command-stdout> markers and render any
|
|
@@ -1562,6 +1750,7 @@ export class ClaudeAcpAgent {
|
|
|
1562
1750
|
parentToolUseId: message.parent_tool_use_id,
|
|
1563
1751
|
cwd: session.cwd,
|
|
1564
1752
|
taskState: session.taskState,
|
|
1753
|
+
emittedToolCalls: session.emittedToolCalls,
|
|
1565
1754
|
messageId: messageIdForGrouping(message),
|
|
1566
1755
|
})) {
|
|
1567
1756
|
await this.client.sessionUpdate(notification);
|
|
@@ -1599,9 +1788,16 @@ export class ClaudeAcpAgent {
|
|
|
1599
1788
|
}
|
|
1600
1789
|
break;
|
|
1601
1790
|
}
|
|
1791
|
+
// `conversation_reset` (from `/clear`, plan-mode exit, fresh-session
|
|
1792
|
+
// flows) is safe to drop: turn lifecycle here is driven by
|
|
1793
|
+
// results/idle, and the client owns its own transcript view.
|
|
1794
|
+
// `control_request_progress` only reports on side_question control
|
|
1795
|
+
// requests, which this adapter never issues.
|
|
1602
1796
|
case "tool_use_summary":
|
|
1603
1797
|
case "auth_status":
|
|
1604
1798
|
case "prompt_suggestion":
|
|
1799
|
+
case "conversation_reset":
|
|
1800
|
+
case "control_request_progress":
|
|
1605
1801
|
break;
|
|
1606
1802
|
default:
|
|
1607
1803
|
unreachable(message);
|
|
@@ -1663,6 +1859,8 @@ export class ClaudeAcpAgent {
|
|
|
1663
1859
|
for (const turn of session.turnQueue) {
|
|
1664
1860
|
if (turn !== session.activeTurn && !turn.settled) {
|
|
1665
1861
|
turn.settled = true;
|
|
1862
|
+
// Deliberately no `usage`: a queued turn never ran, so the session
|
|
1863
|
+
// accumulator (the active turn's tally) is not its spend.
|
|
1666
1864
|
turn.resolve({ stopReason: "cancelled" });
|
|
1667
1865
|
orphaned++;
|
|
1668
1866
|
}
|
|
@@ -1786,7 +1984,7 @@ export class ClaudeAcpAgent {
|
|
|
1786
1984
|
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1787
1985
|
}
|
|
1788
1986
|
await this.applySessionMode(params.sessionId, params.modeId);
|
|
1789
|
-
await this.updateConfigOption(params.sessionId,
|
|
1987
|
+
await this.updateConfigOption(params.sessionId, MODE_CONFIG_ID, params.modeId);
|
|
1790
1988
|
return {};
|
|
1791
1989
|
}
|
|
1792
1990
|
async setSessionConfigOption(params) {
|
|
@@ -1801,29 +1999,54 @@ export class ClaudeAcpAgent {
|
|
|
1801
1999
|
if (session.queryClosed) {
|
|
1802
2000
|
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
|
1803
2001
|
}
|
|
1804
|
-
if (typeof params.value !== "string") {
|
|
1805
|
-
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
|
1806
|
-
}
|
|
1807
2002
|
const option = session.configOptions.find((o) => o.id === params.configId);
|
|
1808
2003
|
if (!option) {
|
|
1809
2004
|
throw new Error(`Unknown config option: ${params.configId}`);
|
|
1810
2005
|
}
|
|
2006
|
+
// Fast mode carries a boolean value (for Clients that opted into boolean
|
|
2007
|
+
// config options) or the "on"/"off" select fallback, so it bypasses the
|
|
2008
|
+
// string-only validation the select-style options below rely on.
|
|
2009
|
+
if (params.configId === FAST_MODE_CONFIG_ID) {
|
|
2010
|
+
await this.applyFastMode(session, resolveFastModeEnabled(params));
|
|
2011
|
+
return { configOptions: session.configOptions };
|
|
2012
|
+
}
|
|
2013
|
+
if (typeof params.value !== "string") {
|
|
2014
|
+
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
|
2015
|
+
}
|
|
1811
2016
|
const allValues = "options" in option && Array.isArray(option.options)
|
|
1812
2017
|
? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
|
|
1813
2018
|
: [];
|
|
1814
2019
|
let validValue = allValues.find((o) => o.value === params.value);
|
|
2020
|
+
// The option's reported currentValue is always a valid target, even when
|
|
2021
|
+
// it has no options entry: a session running an out-of-picker model
|
|
2022
|
+
// (resumed onto an allowlist-excluded model, or a refusal fallback)
|
|
2023
|
+
// reports a currentValue that isn't selectable, and a client
|
|
2024
|
+
// round-tripping it must not get "Invalid value". It flows through the
|
|
2025
|
+
// normal apply path below — re-asserting an already-current value is
|
|
2026
|
+
// harmless and can repair SDK drift.
|
|
2027
|
+
if (!validValue && option.currentValue === params.value) {
|
|
2028
|
+
validValue = { value: params.value, name: params.value };
|
|
2029
|
+
}
|
|
1815
2030
|
// For model options, fall back to resolveModelPreference when the exact
|
|
1816
2031
|
// value doesn't match. This lets callers use human-friendly aliases like
|
|
1817
2032
|
// "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
2033
|
+
// Resolve against session.modelInfos first: those entries carry
|
|
2034
|
+
// `resolvedModel`, so a full model id (in either hint spelling) lands on
|
|
2035
|
+
// the right row via the exact tier instead of a fuzzier one picking a
|
|
2036
|
+
// same-family sibling from a different context lane. The options-derived
|
|
2037
|
+
// list (which never carries `resolvedModel`) remains as a fallback for
|
|
2038
|
+
// resolutions that don't map back onto a selectable option (e.g. a fuzzy
|
|
2039
|
+
// hit on an out-of-picker verbatim entry).
|
|
2040
|
+
if (!validValue && params.configId === MODEL_CONFIG_ID) {
|
|
2041
|
+
const toOptionValue = (resolved) => resolved ? allValues.find((o) => o.value === resolved.value) : undefined;
|
|
2042
|
+
validValue = toOptionValue(resolveModelPreference(session.modelInfos, params.value));
|
|
2043
|
+
if (!validValue) {
|
|
2044
|
+
const optionInfos = allValues.map((o) => ({
|
|
2045
|
+
value: o.value,
|
|
2046
|
+
displayName: o.name,
|
|
2047
|
+
description: o.description ?? "",
|
|
2048
|
+
}));
|
|
2049
|
+
validValue = toOptionValue(resolveModelPreference(optionInfos, params.value));
|
|
1827
2050
|
}
|
|
1828
2051
|
}
|
|
1829
2052
|
if (!validValue) {
|
|
@@ -1832,7 +2055,7 @@ export class ClaudeAcpAgent {
|
|
|
1832
2055
|
// Use the canonical option value so downstream code always receives the
|
|
1833
2056
|
// model ID rather than the caller-supplied alias.
|
|
1834
2057
|
const resolvedValue = validValue.value;
|
|
1835
|
-
if (params.configId ===
|
|
2058
|
+
if (params.configId === MODE_CONFIG_ID) {
|
|
1836
2059
|
await this.applySessionMode(params.sessionId, resolvedValue);
|
|
1837
2060
|
await this.client.sessionUpdate({
|
|
1838
2061
|
sessionId: params.sessionId,
|
|
@@ -1842,7 +2065,7 @@ export class ClaudeAcpAgent {
|
|
|
1842
2065
|
},
|
|
1843
2066
|
});
|
|
1844
2067
|
}
|
|
1845
|
-
else if (params.configId ===
|
|
2068
|
+
else if (params.configId === MODEL_CONFIG_ID) {
|
|
1846
2069
|
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
|
1847
2070
|
}
|
|
1848
2071
|
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
|
|
@@ -1937,7 +2160,14 @@ export class ClaudeAcpAgent {
|
|
|
1937
2160
|
* outcome or a `requestCancelled` rejection). Either way we surface the same
|
|
1938
2161
|
* "Tool use aborted" the callers already expect, so a cancelled dialog no
|
|
1939
2162
|
* longer leaves the `await` hanging. */
|
|
1940
|
-
async requestPermissionFromClient(params, signal) {
|
|
2163
|
+
async requestPermissionFromClient(params, toolName, signal) {
|
|
2164
|
+
// The SDK may invoke `canUseTool` (and therefore this permission request)
|
|
2165
|
+
// before the assistant message's tool_use block streams to us. Some ACP clients
|
|
2166
|
+
// expect the `tool_call` a permission request references to already exist,
|
|
2167
|
+
// so emit it now if it hasn't been sent yet. The streamed tool_use chunk
|
|
2168
|
+
// later refines it with a `tool_call_update` rather than emitting a
|
|
2169
|
+
// duplicate (see `emittedToolCalls` in `toAcpNotifications`).
|
|
2170
|
+
await this.ensureToolCallEmitted(params.sessionId, toolName, params.toolCall.toolCallId, params.toolCall.rawInput);
|
|
1941
2171
|
try {
|
|
1942
2172
|
return await this.client.requestPermission(params, signal);
|
|
1943
2173
|
}
|
|
@@ -1948,6 +2178,28 @@ export class ClaudeAcpAgent {
|
|
|
1948
2178
|
throw error;
|
|
1949
2179
|
}
|
|
1950
2180
|
}
|
|
2181
|
+
/** Emit the `tool_call` a permission request references if it hasn't been sent
|
|
2182
|
+
* yet, so the client has the tool call before being asked to approve it. The
|
|
2183
|
+
* matching streamed tool_use chunk later refines it with a `tool_call_update`
|
|
2184
|
+
* instead of emitting a duplicate (see `emittedToolCalls`). Built via the same
|
|
2185
|
+
* `toolCallNotification` helper as the streamed path so the two are identical.
|
|
2186
|
+
* Tools the stream renders as a plan (TodoWrite) or suppresses (Task*) are
|
|
2187
|
+
* skipped so a permission prompt for them never surfaces a stray tool_call. */
|
|
2188
|
+
async ensureToolCallEmitted(sessionId, toolName, toolCallId, toolInput) {
|
|
2189
|
+
const session = this.sessions[sessionId];
|
|
2190
|
+
if (!session || !shouldEmitToolCall(toolName)) {
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
if (session.emittedToolCalls.has(toolCallId)) {
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
session.emittedToolCalls.add(toolCallId);
|
|
2197
|
+
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
2198
|
+
await this.client.sessionUpdate({
|
|
2199
|
+
sessionId,
|
|
2200
|
+
update: toolCallNotification({ id: toolCallId, name: toolName, input: toolInput }, toolInput, supportsTerminalOutput, session.cwd),
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
1951
2203
|
canUseTool(sessionId) {
|
|
1952
2204
|
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
|
1953
2205
|
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
|
|
@@ -1964,6 +2216,9 @@ export class ClaudeAcpAgent {
|
|
|
1964
2216
|
// than the interactive dialog). Present it as an ACP form elicitation and
|
|
1965
2217
|
// feed the answers back as updatedInput for the tool's own call() to read.
|
|
1966
2218
|
if (toolName === "AskUserQuestion" && this.clientCapabilities?.elicitation?.form) {
|
|
2219
|
+
// Like permission requests, the elicitation references this toolUseID, so
|
|
2220
|
+
// make sure the tool_call has surfaced to the client before we send it.
|
|
2221
|
+
await this.ensureToolCallEmitted(sessionId, toolName, toolUseID, toolInput);
|
|
1967
2222
|
return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal);
|
|
1968
2223
|
}
|
|
1969
2224
|
if (toolName === "ExitPlanMode") {
|
|
@@ -1998,7 +2253,7 @@ export class ClaudeAcpAgent {
|
|
|
1998
2253
|
rawInput: toolInput,
|
|
1999
2254
|
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
2000
2255
|
},
|
|
2001
|
-
}, signal);
|
|
2256
|
+
}, toolName, signal);
|
|
2002
2257
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
2003
2258
|
throw new Error("Tool use aborted");
|
|
2004
2259
|
}
|
|
@@ -2016,7 +2271,7 @@ export class ClaudeAcpAgent {
|
|
|
2016
2271
|
currentModeId: selectedMode,
|
|
2017
2272
|
},
|
|
2018
2273
|
});
|
|
2019
|
-
await this.updateConfigOption(sessionId,
|
|
2274
|
+
await this.updateConfigOption(sessionId, MODE_CONFIG_ID, selectedMode);
|
|
2020
2275
|
return {
|
|
2021
2276
|
behavior: "allow",
|
|
2022
2277
|
updatedInput: toolInput,
|
|
@@ -2057,7 +2312,7 @@ export class ClaudeAcpAgent {
|
|
|
2057
2312
|
rawInput: toolInput,
|
|
2058
2313
|
...toolInfoFromToolUse({ name: toolName, input: toolInput, id: toolUseID }, supportsTerminalOutput, session?.cwd),
|
|
2059
2314
|
},
|
|
2060
|
-
}, signal);
|
|
2315
|
+
}, toolName, signal);
|
|
2061
2316
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
|
2062
2317
|
throw new Error("Tool use aborted");
|
|
2063
2318
|
}
|
|
@@ -2157,6 +2412,44 @@ export class ClaudeAcpAgent {
|
|
|
2157
2412
|
}
|
|
2158
2413
|
return { behavior: "allow", updatedInput: outcome.updatedInput };
|
|
2159
2414
|
}
|
|
2415
|
+
/**
|
|
2416
|
+
* Handle `request_user_dialog` control requests — blocking dialogs the CLI
|
|
2417
|
+
* asks the host to render. Only kinds declared in `supportedDialogKinds`
|
|
2418
|
+
* are ever emitted; everything unexpected is answered `cancelled` (the
|
|
2419
|
+
* required answer for unrecognized kinds), which applies the dialog's
|
|
2420
|
+
* default behavior CLI-side. Today the only declared kind is the
|
|
2421
|
+
* refusal-fallback consent prompt, rendered as an ACP form elicitation.
|
|
2422
|
+
*/
|
|
2423
|
+
handleUserDialog(sessionId) {
|
|
2424
|
+
return async (request, { signal }) => {
|
|
2425
|
+
if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) {
|
|
2426
|
+
return { behavior: "cancelled" };
|
|
2427
|
+
}
|
|
2428
|
+
const prompt = extractRefusalFallbackPrompt(request.payload);
|
|
2429
|
+
if (!prompt) {
|
|
2430
|
+
this.logger.error(`refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`);
|
|
2431
|
+
return { behavior: "cancelled" };
|
|
2432
|
+
}
|
|
2433
|
+
let response;
|
|
2434
|
+
try {
|
|
2435
|
+
response = await this.client.unstable_createElicitation(refusalFallbackToCreateRequest(prompt, sessionId), signal);
|
|
2436
|
+
}
|
|
2437
|
+
catch (error) {
|
|
2438
|
+
// A cancellation we requested (signal aborted) is expected teardown;
|
|
2439
|
+
// anything else is a client failure. Either way the safe answer is
|
|
2440
|
+
// `cancelled` — the CLI applies the dialog's default (keep the
|
|
2441
|
+
// refusal) rather than switching models without consent.
|
|
2442
|
+
if (!signal.aborted) {
|
|
2443
|
+
this.logger.error(`Failed to present refusal fallback elicitation: ${error}`);
|
|
2444
|
+
}
|
|
2445
|
+
return { behavior: "cancelled" };
|
|
2446
|
+
}
|
|
2447
|
+
if (signal.aborted) {
|
|
2448
|
+
return { behavior: "cancelled" };
|
|
2449
|
+
}
|
|
2450
|
+
return { behavior: "completed", result: refusalFallbackResultFromResponse(response) };
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2160
2453
|
async sendAvailableCommandsUpdate(sessionId) {
|
|
2161
2454
|
const session = this.sessions[sessionId];
|
|
2162
2455
|
if (!session)
|
|
@@ -2184,11 +2477,11 @@ export class ClaudeAcpAgent {
|
|
|
2184
2477
|
});
|
|
2185
2478
|
}
|
|
2186
2479
|
async applyConfigOptionValue(sessionId, session, configId, value) {
|
|
2187
|
-
if (configId ===
|
|
2480
|
+
if (configId === MODE_CONFIG_ID) {
|
|
2188
2481
|
session.modes = { ...session.modes, currentModeId: value };
|
|
2189
2482
|
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
2190
2483
|
}
|
|
2191
|
-
else if (configId ===
|
|
2484
|
+
else if (configId === MODEL_CONFIG_ID) {
|
|
2192
2485
|
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
|
|
2193
2486
|
// modes below; its `displayName`/`description` also let us infer the
|
|
2194
2487
|
// context window for semantic aliases (e.g. `default`) whose ID alone
|
|
@@ -2204,8 +2497,16 @@ export class ClaudeAcpAgent {
|
|
|
2204
2497
|
}
|
|
2205
2498
|
session.models = { ...session.models, currentModelId: value };
|
|
2206
2499
|
// Recompute availableModes for the new model and clamp the current
|
|
2207
|
-
// mode if the SDK no longer offers it (today: "auto" on Haiku).
|
|
2208
|
-
|
|
2500
|
+
// mode if the SDK no longer offers it (today: "auto" on Haiku). An
|
|
2501
|
+
// unknown model (an SDK-initiated refusal fallback to a model outside
|
|
2502
|
+
// the user's `availableModels` allowlist — user-driven switches are
|
|
2503
|
+
// validated against the options first) tells us nothing about its
|
|
2504
|
+
// capabilities, so keep the current modes rather than spuriously
|
|
2505
|
+
// downgrading (e.g. kicking the user out of "auto" for a model that
|
|
2506
|
+
// does support it).
|
|
2507
|
+
const newAvailableModes = newModelInfo
|
|
2508
|
+
? buildAvailableModes(newModelInfo)
|
|
2509
|
+
: session.modes.availableModes;
|
|
2209
2510
|
// Capture BEFORE mutating session.modes so the log message reflects
|
|
2210
2511
|
// the invalidated mode rather than "default".
|
|
2211
2512
|
const previousModeId = session.modes.currentModeId;
|
|
@@ -2231,11 +2532,18 @@ export class ClaudeAcpAgent {
|
|
|
2231
2532
|
session.modes = { ...session.modes, availableModes: newAvailableModes };
|
|
2232
2533
|
}
|
|
2233
2534
|
// Rebuild config options since effort levels depend on the selected model
|
|
2234
|
-
const effortOpt = session.configOptions.find((o) => o.id ===
|
|
2535
|
+
const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
|
|
2235
2536
|
const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
|
2236
|
-
session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort, session.agents, session.currentAgent
|
|
2537
|
+
session.configOptions = buildConfigOptions(session.modes, session.models, session.modelInfos, currentEffort, session.agents, session.currentAgent, {
|
|
2538
|
+
// The toggle follows the newly selected model: it disappears when the
|
|
2539
|
+
// model lacks fast support and reappears (with the retained user
|
|
2540
|
+
// intent) when a supporting model is selected again.
|
|
2541
|
+
supported: newModelInfo?.supportsFastMode ?? false,
|
|
2542
|
+
enabled: session.fastModeEnabled,
|
|
2543
|
+
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
|
2544
|
+
});
|
|
2237
2545
|
// Sync effort with the SDK if it changed after the model switch
|
|
2238
|
-
const newEffortOpt = session.configOptions.find((o) => o.id ===
|
|
2546
|
+
const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
|
|
2239
2547
|
const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
|
2240
2548
|
if (newEffort !== currentEffort) {
|
|
2241
2549
|
await session.query.applyFlagSettings({
|
|
@@ -2258,7 +2566,7 @@ export class ClaudeAcpAgent {
|
|
|
2258
2566
|
});
|
|
2259
2567
|
}
|
|
2260
2568
|
}
|
|
2261
|
-
else if (configId ===
|
|
2569
|
+
else if (configId === AGENT_CONFIG_ID) {
|
|
2262
2570
|
// Live agent switch — no subprocess restart needed. Apply the SDK flag
|
|
2263
2571
|
// first so a rejected control request leaves both `currentAgent` and the
|
|
2264
2572
|
// config option untouched (no UI/SDK desync). Passing `null` clears the
|
|
@@ -2272,13 +2580,102 @@ export class ClaudeAcpAgent {
|
|
|
2272
2580
|
}
|
|
2273
2581
|
else {
|
|
2274
2582
|
session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o);
|
|
2275
|
-
if (configId ===
|
|
2583
|
+
if (configId === EFFORT_CONFIG_ID) {
|
|
2276
2584
|
await session.query.applyFlagSettings({
|
|
2277
2585
|
effortLevel: toSdkEffortLevel(value),
|
|
2278
2586
|
});
|
|
2279
2587
|
}
|
|
2280
2588
|
}
|
|
2281
2589
|
}
|
|
2590
|
+
/** Reconcile adapter model state after the SDK persistently swapped the
|
|
2591
|
+
* session's model out from under us (refusal fallback). The SDK already
|
|
2592
|
+
* made the switch, so this must NOT call `query.setModel` — it only
|
|
2593
|
+
* updates our bookkeeping (currentModelId, context window, mode clamping,
|
|
2594
|
+
* effort/Fast-mode options) via the same `applyConfigOptionValue` path a
|
|
2595
|
+
* user-driven model change takes, then notifies the client. */
|
|
2596
|
+
async syncModelAfterRefusalFallback(sessionId, session, fallbackModel) {
|
|
2597
|
+
// Map the SDK-reported model onto one of the session's model options
|
|
2598
|
+
// (handles display names and `resolvedModel` ids). The fallback model may
|
|
2599
|
+
// not be among the options — e.g. excluded by the user's
|
|
2600
|
+
// `availableModels` allowlist — in which case we track the raw id: the
|
|
2601
|
+
// picker shows no selection, but the model-dependent bookkeeping and any
|
|
2602
|
+
// later `setModel` round-trip stay truthful to what the SDK is running.
|
|
2603
|
+
const resolved = resolveModelPreference(session.modelInfos, fallbackModel);
|
|
2604
|
+
const value = resolved?.value ?? fallbackModel;
|
|
2605
|
+
if (session.models.currentModelId === value)
|
|
2606
|
+
return;
|
|
2607
|
+
try {
|
|
2608
|
+
await this.updateConfigOption(sessionId, MODEL_CONFIG_ID, value);
|
|
2609
|
+
}
|
|
2610
|
+
catch (err) {
|
|
2611
|
+
// This runs on the consumer loop: a throw here tears down the query
|
|
2612
|
+
// stream (failAllTurns + closeQueryStream) and bricks the session —
|
|
2613
|
+
// far worse than stale bookkeeping. The user-driven RPC path lets the
|
|
2614
|
+
// same errors propagate to fail just that request; here we log and
|
|
2615
|
+
// move on, matching the setPermissionMode containment inside
|
|
2616
|
+
// applyConfigOptionValue.
|
|
2617
|
+
this.logger.error(`Failed to reconcile model state after refusal fallback to "${fallbackModel}":`, err);
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
/** Replace the Fast mode option in `session.configOptions` so it reflects
|
|
2621
|
+
* `enabled` (and the client's current boolean-capability). A no-op when the
|
|
2622
|
+
* option isn't present, so callers must confirm the current model surfaces
|
|
2623
|
+
* it first. */
|
|
2624
|
+
refreshFastModeOption(session, enabled) {
|
|
2625
|
+
const refreshed = createFastModeConfigOption(enabled, clientSupportsBooleanConfigOptions(this.clientCapabilities));
|
|
2626
|
+
session.configOptions = session.configOptions.map((o) => o.id === FAST_MODE_CONFIG_ID ? refreshed : o);
|
|
2627
|
+
}
|
|
2628
|
+
/** Toggle Fast mode for a session: push the SDK flag, record the user's
|
|
2629
|
+
* intent, and refresh the Fast mode config option in place. Only reached
|
|
2630
|
+
* once the option exists (i.e. the current model supports fast mode), so the
|
|
2631
|
+
* option is guaranteed to be present in `configOptions`. */
|
|
2632
|
+
async applyFastMode(session, enabled) {
|
|
2633
|
+
// Apply the SDK flag first so a rejected control request leaves both the
|
|
2634
|
+
// session state and the config option untouched (no UI/SDK desync).
|
|
2635
|
+
await session.query.applyFlagSettings({ fastMode: enabled });
|
|
2636
|
+
session.fastModeEnabled = enabled;
|
|
2637
|
+
this.refreshFastModeOption(session, enabled);
|
|
2638
|
+
}
|
|
2639
|
+
/** Reconcile the session's Fast mode toggle with an SDK-reported
|
|
2640
|
+
* `fast_mode_state` (delivered on `system`/init and on user-turn `result`s).
|
|
2641
|
+
* The SDK can flip fast mode independently of the user — e.g. back to `on`
|
|
2642
|
+
* once a rate-limit `cooldown` clears — so we mirror definitive on/off
|
|
2643
|
+
* changes into the config option and notify the client.
|
|
2644
|
+
*
|
|
2645
|
+
* Guards, in order:
|
|
2646
|
+
* - absent state: nothing to reconcile.
|
|
2647
|
+
* - no Fast mode option: the current model doesn't support fast mode, so the
|
|
2648
|
+
* reported state reflects capability, not the user's intent. Leave the
|
|
2649
|
+
* retained setting untouched so it's correct when a supporting model is
|
|
2650
|
+
* reselected (the source of the earlier intent-clobber bug was mutating it
|
|
2651
|
+
* here).
|
|
2652
|
+
* - `cooldown`: a transient suspension of an already-enabled fast mode.
|
|
2653
|
+
* Leave the toggle as-is rather than flapping it — and never let a stray
|
|
2654
|
+
* cooldown spuriously enable a toggle the user has off. */
|
|
2655
|
+
async syncFastModeState(sessionId, session, state) {
|
|
2656
|
+
if (state === undefined) {
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
if (!session.configOptions.some((o) => o.id === FAST_MODE_CONFIG_ID)) {
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
if (state === "cooldown") {
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
const enabled = state === "on";
|
|
2666
|
+
if (enabled === session.fastModeEnabled) {
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
session.fastModeEnabled = enabled;
|
|
2670
|
+
this.refreshFastModeOption(session, enabled);
|
|
2671
|
+
await this.client.sessionUpdate({
|
|
2672
|
+
sessionId,
|
|
2673
|
+
update: {
|
|
2674
|
+
sessionUpdate: "config_option_update",
|
|
2675
|
+
configOptions: session.configOptions,
|
|
2676
|
+
},
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2282
2679
|
async getOrCreateSession(params) {
|
|
2283
2680
|
const existingSession = this.sessions[params.sessionId];
|
|
2284
2681
|
if (existingSession) {
|
|
@@ -2464,6 +2861,18 @@ export class ClaudeAcpAgent {
|
|
|
2464
2861
|
...(elicitationSupport.form || elicitationSupport.url
|
|
2465
2862
|
? { onElicitation: this.handleMcpElicitation(sessionId, elicitationSupport) }
|
|
2466
2863
|
: {}),
|
|
2864
|
+
// Render the CLI's refusal-fallback consent prompt ("<model> declined —
|
|
2865
|
+
// retry with <fallback>?") as an ACP form elicitation. Declaring the
|
|
2866
|
+
// kind is the opt-in: the CLI never emits an undeclared dialog, and the
|
|
2867
|
+
// flow instead degrades to the classic refusal error ending the turn.
|
|
2868
|
+
// Gated on form elicitation since that's the only ACP surface that can
|
|
2869
|
+
// present a choice outside a tool call.
|
|
2870
|
+
...(elicitationSupport.form
|
|
2871
|
+
? {
|
|
2872
|
+
onUserDialog: this.handleUserDialog(sessionId),
|
|
2873
|
+
supportedDialogKinds: [REFUSAL_FALLBACK_DIALOG_KIND],
|
|
2874
|
+
}
|
|
2875
|
+
: {}),
|
|
2467
2876
|
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
|
|
2468
2877
|
extraArgs: {
|
|
2469
2878
|
...userProvidedOptions?.extraArgs,
|
|
@@ -2486,7 +2895,7 @@ export class ClaudeAcpAgent {
|
|
|
2486
2895
|
currentModeId: "plan",
|
|
2487
2896
|
},
|
|
2488
2897
|
});
|
|
2489
|
-
await this.updateConfigOption(sessionId,
|
|
2898
|
+
await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
|
|
2490
2899
|
},
|
|
2491
2900
|
}),
|
|
2492
2901
|
],
|
|
@@ -2579,13 +2988,46 @@ export class ClaudeAcpAgent {
|
|
|
2579
2988
|
// configOptions, the current-model resolver, and the stored modelInfos
|
|
2580
2989
|
// consistent with what the user configured.
|
|
2581
2990
|
const settingsAvailableModels = settingsManager.getSettings().availableModels;
|
|
2991
|
+
const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
|
|
2582
2992
|
const allowedModels = Array.isArray(settingsAvailableModels)
|
|
2583
|
-
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels)
|
|
2993
|
+
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels, settingsModelOverrides)
|
|
2584
2994
|
: initializationResult.models;
|
|
2585
|
-
const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger);
|
|
2995
|
+
const models = await getAvailableModels(q, allowedModels, initializationResult.models, settingsManager, this.logger, creationOpts.resume !== undefined);
|
|
2586
2996
|
// Gate `auto` (and future model-specific modes) on the resolved model's
|
|
2587
2997
|
// `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
|
|
2588
|
-
|
|
2998
|
+
// A resumed session can be running a model outside the `availableModels`
|
|
2999
|
+
// allowlist (currentModelId is then the verbatim live id, see
|
|
3000
|
+
// `matchResumedModel`); its capabilities are still known to the SDK's
|
|
3001
|
+
// unfiltered list, so fall back to that before treating the model as
|
|
3002
|
+
// unknown — otherwise auto mode would be spuriously clamped and the
|
|
3003
|
+
// Fast-mode/Effort options hidden for a model that supports them.
|
|
3004
|
+
const allowlistedModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
|
|
3005
|
+
const fallbackModelInfo = allowlistedModelInfo
|
|
3006
|
+
? undefined
|
|
3007
|
+
: (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined);
|
|
3008
|
+
const currentModelInfo = allowlistedModelInfo ?? fallbackModelInfo;
|
|
3009
|
+
// Register the fallback-resolved capabilities under the verbatim live id
|
|
3010
|
+
// so every modelInfos consumer (buildConfigOptions' effort lookup, later
|
|
3011
|
+
// rebuilds via session.modelInfos) agrees with the gating below. The
|
|
3012
|
+
// picker options themselves come from `models.availableModels`, so this
|
|
3013
|
+
// adds no selectable entry. The spread keeps every capability flag
|
|
3014
|
+
// (current and future); the identity fields are overridden because the
|
|
3015
|
+
// fuzzy-matched sibling's resolvedModel/displayName/description can
|
|
3016
|
+
// describe a different context lane and would poison later resolvedModel
|
|
3017
|
+
// matching (syncModelAfterRefusalFallback) and context-window inference
|
|
3018
|
+
// (applyConfigOptionValue) if they traveled under this id.
|
|
3019
|
+
const modelInfos = fallbackModelInfo
|
|
3020
|
+
? [
|
|
3021
|
+
...allowedModels,
|
|
3022
|
+
{
|
|
3023
|
+
...fallbackModelInfo,
|
|
3024
|
+
value: models.currentModelId,
|
|
3025
|
+
displayName: models.currentModelId,
|
|
3026
|
+
description: "",
|
|
3027
|
+
resolvedModel: undefined,
|
|
3028
|
+
},
|
|
3029
|
+
]
|
|
3030
|
+
: allowedModels;
|
|
2589
3031
|
const availableModes = buildAvailableModes(currentModelInfo);
|
|
2590
3032
|
// Clamp `permissionMode` if the resolved session does not offer it. The
|
|
2591
3033
|
// common case is `permissions.defaultMode: "auto"` resolving to a model
|
|
@@ -2628,9 +3070,20 @@ export class ClaudeAcpAgent {
|
|
|
2628
3070
|
const currentAgent = requestedAgent && agents.some((a) => a.name === requestedAgent)
|
|
2629
3071
|
? requestedAgent
|
|
2630
3072
|
: DEFAULT_AGENT_ID;
|
|
2631
|
-
|
|
3073
|
+
// Seed Fast mode from the SDK's reported state so the UI reflects reality
|
|
3074
|
+
// (the CLI may start a session with fast mode already on, or force it off
|
|
3075
|
+
// when `fastModePerSessionOptIn` is set). The toggle is only surfaced while
|
|
3076
|
+
// the resolved model advertises `supportsFastMode`.
|
|
3077
|
+
const fastModeEnabled = initializationResult.fast_mode_state !== undefined &&
|
|
3078
|
+
fastModeStateEnabled(initializationResult.fast_mode_state);
|
|
3079
|
+
const fastMode = {
|
|
3080
|
+
supported: currentModelInfo?.supportsFastMode ?? false,
|
|
3081
|
+
enabled: fastModeEnabled,
|
|
3082
|
+
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
|
3083
|
+
};
|
|
3084
|
+
const configOptions = buildConfigOptions(modes, models, modelInfos, settingsManager.getSettings().effortLevel, agents, currentAgent, fastMode);
|
|
2632
3085
|
// Apply the initial effort level to the SDK so it matches the UI default
|
|
2633
|
-
const initialEffort = configOptions.find((o) => o.id ===
|
|
3086
|
+
const initialEffort = configOptions.find((o) => o.id === EFFORT_CONFIG_ID);
|
|
2634
3087
|
if (initialEffort &&
|
|
2635
3088
|
typeof initialEffort.currentValue === "string" &&
|
|
2636
3089
|
initialEffort.currentValue !== "default") {
|
|
@@ -2653,15 +3106,23 @@ export class ClaudeAcpAgent {
|
|
|
2653
3106
|
},
|
|
2654
3107
|
modes,
|
|
2655
3108
|
models,
|
|
2656
|
-
modelInfos
|
|
3109
|
+
modelInfos,
|
|
2657
3110
|
configOptions,
|
|
2658
3111
|
agents,
|
|
2659
3112
|
currentAgent,
|
|
3113
|
+
fastModeEnabled,
|
|
2660
3114
|
abortController,
|
|
2661
3115
|
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
|
2662
|
-
contextWindowSize:
|
|
3116
|
+
contextWindowSize:
|
|
3117
|
+
// Deliberately keyed to the allowlisted entry: a fallback-resolved
|
|
3118
|
+
// sibling's displayName/description can describe a different context
|
|
3119
|
+
// lane than the verbatim live id (e.g. an "opus[1m]" row matched for
|
|
3120
|
+
// a bare 200k id), so on the fallback path only the id itself is a
|
|
3121
|
+
// trustworthy window signal.
|
|
3122
|
+
inferContextWindowFromModel(models.currentModelId, allowlistedModelInfo?.displayName, allowlistedModelInfo?.description) ?? DEFAULT_CONTEXT_WINDOW,
|
|
2663
3123
|
taskState,
|
|
2664
3124
|
toolUseCache: {},
|
|
3125
|
+
emittedToolCalls: new Set(),
|
|
2665
3126
|
messageIdToUuid: new Map(),
|
|
2666
3127
|
};
|
|
2667
3128
|
return {
|
|
@@ -2704,9 +3165,9 @@ function totalTokens(usage) {
|
|
|
2704
3165
|
}
|
|
2705
3166
|
/**
|
|
2706
3167
|
* Build the `data` payload attached to a `RequestError.internalError` when we
|
|
2707
|
-
* have a categorical error from the Claude SDK
|
|
2708
|
-
* categorical error is available, matching
|
|
2709
|
-
* `undefined` to `RequestError.internalError`.
|
|
3168
|
+
* have a categorical error — from the Claude SDK, or one of the adapter's own
|
|
3169
|
+
* kinds. Returns `undefined` when no categorical error is available, matching
|
|
3170
|
+
* the previous behavior of passing `undefined` to `RequestError.internalError`.
|
|
2710
3171
|
*
|
|
2711
3172
|
* The `errorKind` field is a convention for ACP clients to dispatch on
|
|
2712
3173
|
* without having to pattern-match the human-readable message text. Clients
|
|
@@ -2769,8 +3230,10 @@ function buildAvailableModes(modelInfo) {
|
|
|
2769
3230
|
});
|
|
2770
3231
|
}
|
|
2771
3232
|
modes.push({
|
|
3233
|
+
// Claude Code 2.1.200 renamed this mode to "Manual" across its surfaces;
|
|
3234
|
+
// the wire id stays "default" ("manual" is only an accepted input alias).
|
|
2772
3235
|
id: "default",
|
|
2773
|
-
name: "
|
|
3236
|
+
name: "Manual",
|
|
2774
3237
|
description: "Standard behavior, prompts for dangerous operations",
|
|
2775
3238
|
}, {
|
|
2776
3239
|
id: "acceptEdits",
|
|
@@ -2835,10 +3298,78 @@ export async function discoverCustomAgents(q) {
|
|
|
2835
3298
|
return [];
|
|
2836
3299
|
}
|
|
2837
3300
|
}
|
|
2838
|
-
|
|
3301
|
+
/** Stable ids for the session config options surfaced via `configOptions`.
|
|
3302
|
+
* Centralized so the option declarations in `buildConfigOptions` and the
|
|
3303
|
+
* handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
|
|
3304
|
+
* same identifiers and can't drift apart. */
|
|
3305
|
+
export const MODE_CONFIG_ID = "mode";
|
|
3306
|
+
export const MODEL_CONFIG_ID = "model";
|
|
3307
|
+
export const EFFORT_CONFIG_ID = "effort";
|
|
3308
|
+
export const AGENT_CONFIG_ID = "agent";
|
|
3309
|
+
export const FAST_MODE_CONFIG_ID = "fast";
|
|
3310
|
+
/** Select-fallback values used when the client has not opted into boolean
|
|
3311
|
+
* config options (see {@link createFastModeConfigOption}). */
|
|
3312
|
+
export const FAST_MODE_ON = "on";
|
|
3313
|
+
export const FAST_MODE_OFF = "off";
|
|
3314
|
+
const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
|
|
3315
|
+
/** Map the SDK's tri-state `fast_mode_state` onto the boolean config toggle.
|
|
3316
|
+
* `cooldown` (fast mode temporarily suspended after a rate limit, per the SDK
|
|
3317
|
+
* docs) keeps the toggle on so it reflects the user's intent — only an
|
|
3318
|
+
* explicit `off` clears it. */
|
|
3319
|
+
export function fastModeStateEnabled(state) {
|
|
3320
|
+
return state !== "off";
|
|
3321
|
+
}
|
|
3322
|
+
/** Whether the Client advertised support for boolean session config options
|
|
3323
|
+
* (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
|
|
3324
|
+
* config options to Clients that opt in; otherwise we fall back to a `select`.
|
|
3325
|
+
* See https://agentclientprotocol.com/rfds/boolean-config-option. */
|
|
3326
|
+
export function clientSupportsBooleanConfigOptions(clientCapabilities) {
|
|
3327
|
+
return clientCapabilities?.session?.configOptions?.boolean != null;
|
|
3328
|
+
}
|
|
3329
|
+
/** Build the Fast mode config option. When the Client supports boolean config
|
|
3330
|
+
* options we expose a native `type: "boolean"` toggle; otherwise we degrade to
|
|
3331
|
+
* a two-value `select` ("on"/"off") so older Clients still get a usable
|
|
3332
|
+
* control. */
|
|
3333
|
+
export function createFastModeConfigOption(enabled, useBooleanOption) {
|
|
3334
|
+
const base = {
|
|
3335
|
+
id: FAST_MODE_CONFIG_ID,
|
|
3336
|
+
name: "Fast mode",
|
|
3337
|
+
description: FAST_MODE_DESCRIPTION,
|
|
3338
|
+
category: "model_config",
|
|
3339
|
+
};
|
|
3340
|
+
if (useBooleanOption) {
|
|
3341
|
+
return { ...base, type: "boolean", currentValue: enabled };
|
|
3342
|
+
}
|
|
3343
|
+
return {
|
|
3344
|
+
...base,
|
|
3345
|
+
type: "select",
|
|
3346
|
+
currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
|
|
3347
|
+
options: [
|
|
3348
|
+
{ value: FAST_MODE_ON, name: "On" },
|
|
3349
|
+
{ value: FAST_MODE_OFF, name: "Off" },
|
|
3350
|
+
],
|
|
3351
|
+
};
|
|
3352
|
+
}
|
|
3353
|
+
/** Resolve the requested Fast mode value from a `session/set_config_option`
|
|
3354
|
+
* request. Accepts a native boolean (boolean-capable Clients) or the
|
|
3355
|
+
* "on"/"off" select-fallback strings. */
|
|
3356
|
+
export function resolveFastModeEnabled(params) {
|
|
3357
|
+
const value = params.value;
|
|
3358
|
+
if (typeof value === "boolean") {
|
|
3359
|
+
return value;
|
|
3360
|
+
}
|
|
3361
|
+
if (value === FAST_MODE_ON) {
|
|
3362
|
+
return true;
|
|
3363
|
+
}
|
|
3364
|
+
if (value === FAST_MODE_OFF) {
|
|
3365
|
+
return false;
|
|
3366
|
+
}
|
|
3367
|
+
throw new Error(`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`);
|
|
3368
|
+
}
|
|
3369
|
+
export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel, agents = [], currentAgent = DEFAULT_AGENT_ID, fastMode) {
|
|
2839
3370
|
const options = [
|
|
2840
3371
|
{
|
|
2841
|
-
id:
|
|
3372
|
+
id: MODE_CONFIG_ID,
|
|
2842
3373
|
name: "Mode",
|
|
2843
3374
|
description: "Session permission mode",
|
|
2844
3375
|
category: "mode",
|
|
@@ -2851,7 +3382,7 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
|
|
|
2851
3382
|
})),
|
|
2852
3383
|
},
|
|
2853
3384
|
{
|
|
2854
|
-
id:
|
|
3385
|
+
id: MODEL_CONFIG_ID,
|
|
2855
3386
|
name: "Model",
|
|
2856
3387
|
description: "AI model to use",
|
|
2857
3388
|
category: "model",
|
|
@@ -2883,7 +3414,7 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
|
|
|
2883
3414
|
const includes = (l) => l === "default" || supportedLevels.includes(l);
|
|
2884
3415
|
const validEffort = currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
|
|
2885
3416
|
options.push({
|
|
2886
|
-
id:
|
|
3417
|
+
id: EFFORT_CONFIG_ID,
|
|
2887
3418
|
name: "Effort",
|
|
2888
3419
|
description: "Available effort levels for this model",
|
|
2889
3420
|
category: "thought_level",
|
|
@@ -2892,13 +3423,19 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
|
|
|
2892
3423
|
options: effortOptions,
|
|
2893
3424
|
});
|
|
2894
3425
|
}
|
|
3426
|
+
// Surface the Fast mode toggle only when the current model supports it. The
|
|
3427
|
+
// option renders as a native boolean toggle for Clients that opted in, and a
|
|
3428
|
+
// two-value select otherwise.
|
|
3429
|
+
if (fastMode?.supported) {
|
|
3430
|
+
options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption));
|
|
3431
|
+
}
|
|
2895
3432
|
// Only surface the Agent picker when there's a real choice — i.e. the user
|
|
2896
3433
|
// has configured at least one custom agent (built-ins are filtered out in
|
|
2897
3434
|
// discoverCustomAgents). With none configured, "Default" would be the only
|
|
2898
3435
|
// entry, so we omit the option entirely.
|
|
2899
3436
|
if (agents.length > 0) {
|
|
2900
3437
|
options.push({
|
|
2901
|
-
id:
|
|
3438
|
+
id: AGENT_CONFIG_ID,
|
|
2902
3439
|
name: "Agent",
|
|
2903
3440
|
description: "Main-thread agent persona",
|
|
2904
3441
|
type: "select",
|
|
@@ -2918,13 +3455,35 @@ export function buildConfigOptions(modes, models, modelInfos, currentEffortLevel
|
|
|
2918
3455
|
// Claude Code CLI persists display strings like "opus[1m]" in settings,
|
|
2919
3456
|
// but the SDK model list uses IDs like "claude-opus-4-6-1m".
|
|
2920
3457
|
const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
|
|
2921
|
-
//
|
|
2922
|
-
//
|
|
2923
|
-
|
|
2924
|
-
|
|
3458
|
+
// The id-suffix spelling of a context hint ("-1m" in "claude-opus-4-6-1m");
|
|
3459
|
+
// shared by the strip and canonicalize helpers below so the two can't drift.
|
|
3460
|
+
const CONTEXT_HINT_SUFFIX_PATTERN = /-(\d+m)$/i;
|
|
3461
|
+
/** Remove context-window hints — the display form "[1m]" and the SDK id
|
|
3462
|
+
* suffix form "-1m" — from a model string. Those digits describe context
|
|
3463
|
+
* size, not model identity or generation version. */
|
|
3464
|
+
function stripContextHints(s) {
|
|
3465
|
+
return s.replace(/\[\d+m\]/gi, "").replace(CONTEXT_HINT_SUFFIX_PATTERN, "");
|
|
3466
|
+
}
|
|
3467
|
+
/** Canonicalize a model id for exact comparison: trimmed, lowercased, with
|
|
3468
|
+
* the id-suffix hint spelling unified to the bracket form ("-1m" → "[1m]").
|
|
3469
|
+
* The hint itself is kept — bare and 1M ids must stay distinct. */
|
|
3470
|
+
function canonicalizeModelId(s) {
|
|
3471
|
+
return s.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
|
|
3472
|
+
}
|
|
3473
|
+
/** The context hint a model string carries ("1m" for either spelling), or
|
|
3474
|
+
* null for a bare id. */
|
|
3475
|
+
function contextHintOf(s) {
|
|
3476
|
+
return canonicalizeModelId(s).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
|
|
3477
|
+
}
|
|
3478
|
+
// Captures a model family version: `4-6`/`4.7` for dated generations, or a
|
|
3479
|
+
// bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned
|
|
3480
|
+
// `claude-opus-4-6` from matching the `opus` alias once it points at 4.7.
|
|
3481
|
+
const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)(?:[-.](\d+))?\b/;
|
|
2925
3482
|
function extractModelFamilyVersion(s) {
|
|
2926
|
-
const match = s.match(MODEL_FAMILY_VERSION_PATTERN);
|
|
2927
|
-
|
|
3483
|
+
const match = stripContextHints(s).match(MODEL_FAMILY_VERSION_PATTERN);
|
|
3484
|
+
if (!match)
|
|
3485
|
+
return null;
|
|
3486
|
+
return match[2] ? `${match[1]}.${match[2]}` : match[1];
|
|
2928
3487
|
}
|
|
2929
3488
|
function modelVersionsCompatible(preference, candidate) {
|
|
2930
3489
|
const preferred = extractModelFamilyVersion(preference);
|
|
@@ -2969,21 +3528,43 @@ function scoreModelMatch(model, tokens, contextHint) {
|
|
|
2969
3528
|
return 0;
|
|
2970
3529
|
return score;
|
|
2971
3530
|
}
|
|
2972
|
-
function resolveModelPreference(models, preference) {
|
|
3531
|
+
export function resolveModelPreference(models, preference) {
|
|
2973
3532
|
const trimmed = preference.trim();
|
|
2974
3533
|
if (!trimmed)
|
|
2975
3534
|
return null;
|
|
2976
3535
|
const lower = trimmed.toLowerCase();
|
|
2977
|
-
// Exact match on value or display name
|
|
3536
|
+
// Exact match on value or display name. Values compare on the canonical
|
|
3537
|
+
// hint spelling so "opus-1m" hits an "opus[1m]" row (and vice versa).
|
|
3538
|
+
const canonicalPreference = canonicalizeModelId(trimmed);
|
|
2978
3539
|
const directMatch = models.find((model) => model.value === trimmed ||
|
|
2979
|
-
model.value
|
|
3540
|
+
canonicalizeModelId(model.value) === canonicalPreference ||
|
|
2980
3541
|
model.displayName.toLowerCase() === lower);
|
|
2981
3542
|
if (directMatch)
|
|
2982
3543
|
return directMatch;
|
|
2983
|
-
//
|
|
3544
|
+
// Exact match on the alias's canonical resolved id (e.g. a pinned
|
|
3545
|
+
// "claude-sonnet-5" against the "sonnet" row's `resolvedModel`). SDK-
|
|
3546
|
+
// reported and unambiguous, so it's tried before the fuzzier tiers below.
|
|
3547
|
+
// Compared on the canonical hint spelling so a "-1m"-suffix pin matches a
|
|
3548
|
+
// "[1m]"-spelled resolvedModel instead of falling into the substring tier
|
|
3549
|
+
// (which would land on the bare 200k sibling). "default" is skipped first
|
|
3550
|
+
// since it shares a resolvedModel with whichever alias the CLI currently
|
|
3551
|
+
// recommends — a specific pin should land on that named alias, not
|
|
3552
|
+
// "default".
|
|
3553
|
+
const matchesResolved = (model) => model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
|
|
3554
|
+
const resolvedMatch = models.find((model) => model.value !== "default" && matchesResolved(model)) ??
|
|
3555
|
+
models.find(matchesResolved);
|
|
3556
|
+
if (resolvedMatch)
|
|
3557
|
+
return resolvedMatch;
|
|
3558
|
+
// Substring match. Skips candidates whose context hint disagrees with the
|
|
3559
|
+
// preference's — a bare row must not absorb a 1M-hinted preference (nor
|
|
3560
|
+
// vice versa); such pairs fall through to the tokenized tier, which
|
|
3561
|
+
// weighs hints in its scoring and still finds the best same-family row.
|
|
3562
|
+
const preferenceHint = contextHintOf(trimmed);
|
|
2984
3563
|
const includesMatch = models.find((model) => {
|
|
2985
3564
|
if (!modelVersionsCompatible(trimmed, model))
|
|
2986
3565
|
return false;
|
|
3566
|
+
if (contextHintOf(model.value) !== preferenceHint)
|
|
3567
|
+
return false;
|
|
2987
3568
|
const value = model.value.toLowerCase();
|
|
2988
3569
|
const display = model.displayName.toLowerCase();
|
|
2989
3570
|
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
|
@@ -3007,6 +3588,51 @@ function resolveModelPreference(models, preference) {
|
|
|
3007
3588
|
}
|
|
3008
3589
|
return bestMatch;
|
|
3009
3590
|
}
|
|
3591
|
+
/** Map the live model reported by a resumed session onto the picker's model
|
|
3592
|
+
* list. The CLI restores a resumed session's model from the transcript's
|
|
3593
|
+
* last assistant message, which records the concrete API id (e.g.
|
|
3594
|
+
* "claude-opus-4-6") with any "[1m]" context hint dropped. Tiers, in order:
|
|
3595
|
+
* 1. Exact match with the Default entry's resolution — when a named alias
|
|
3596
|
+
* shares Default's resolvedModel verbatim, the live id can't tell the
|
|
3597
|
+
* two apart, and a never-customized session should stay on Default.
|
|
3598
|
+
* 2. Exact resolvedModel match on a named row. Checked before the
|
|
3599
|
+
* hint-stripped Default comparison so a live "claude-sonnet-5[1m]" lands
|
|
3600
|
+
* on the "sonnet[1m]" row rather than a Default that resolves to the
|
|
3601
|
+
* bare "claude-sonnet-5" — the two rows differ in context window, which
|
|
3602
|
+
* drives `contextWindowSize` and capability gating downstream.
|
|
3603
|
+
* 3. Hint-stripped match with Default's resolution — a session that never
|
|
3604
|
+
* left the default resumes as the bare transcript id, and shouldn't show
|
|
3605
|
+
* a concrete picker entry.
|
|
3606
|
+
* 4. `resolveModelPreference` over the picker entries.
|
|
3607
|
+
* 5. A model with no picker counterpart (e.g. excluded by an
|
|
3608
|
+
* `availableModels` allowlist) is tracked verbatim, mirroring
|
|
3609
|
+
* `syncModelAfterRefusalFallback`: the picker shows no selection, but the
|
|
3610
|
+
* model-dependent bookkeeping stays truthful to what the SDK is running. */
|
|
3611
|
+
export function matchResumedModel(models, liveModel) {
|
|
3612
|
+
const live = canonicalizeModelId(liveModel);
|
|
3613
|
+
const defaultEntry = models.find((m) => m.value === "default");
|
|
3614
|
+
const defaultResolved = defaultEntry?.resolvedModel
|
|
3615
|
+
? canonicalizeModelId(defaultEntry.resolvedModel)
|
|
3616
|
+
: undefined;
|
|
3617
|
+
if (defaultEntry && defaultResolved === live) {
|
|
3618
|
+
return defaultEntry;
|
|
3619
|
+
}
|
|
3620
|
+
// No default-row exclusion needed: a default row matching `live` exactly
|
|
3621
|
+
// already returned at the tier above.
|
|
3622
|
+
const exactMatch = models.find((m) => m.resolvedModel && canonicalizeModelId(m.resolvedModel) === live);
|
|
3623
|
+
if (exactMatch)
|
|
3624
|
+
return exactMatch;
|
|
3625
|
+
if (defaultEntry &&
|
|
3626
|
+
defaultResolved &&
|
|
3627
|
+
stripContextHints(defaultResolved) === stripContextHints(live)) {
|
|
3628
|
+
return defaultEntry;
|
|
3629
|
+
}
|
|
3630
|
+
return (resolveModelPreference(models, liveModel) ?? {
|
|
3631
|
+
value: liveModel,
|
|
3632
|
+
displayName: liveModel,
|
|
3633
|
+
description: "",
|
|
3634
|
+
});
|
|
3635
|
+
}
|
|
3010
3636
|
function resolveSettingsModel(models, settingsModel, logger) {
|
|
3011
3637
|
if (settingsModel === undefined) {
|
|
3012
3638
|
return null;
|
|
@@ -3034,7 +3660,7 @@ function resolveSettingsModel(models, settingsModel, logger) {
|
|
|
3034
3660
|
* - The Default option is unaffected by `availableModels` — it always remains
|
|
3035
3661
|
* available, even when the allowlist is `[]`.
|
|
3036
3662
|
*/
|
|
3037
|
-
function applyAvailableModelsAllowlist(sdkModels, allowlist) {
|
|
3663
|
+
export function applyAvailableModelsAllowlist(sdkModels, allowlist, settingsModelOverrides) {
|
|
3038
3664
|
// Default is always preserved per the docs. Synthesize one if the SDK
|
|
3039
3665
|
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
|
|
3040
3666
|
// `models[0]` as a fallback) still has something to work with.
|
|
@@ -3046,18 +3672,29 @@ function applyAvailableModelsAllowlist(sdkModels, allowlist) {
|
|
|
3046
3672
|
const result = [defaultModel];
|
|
3047
3673
|
const seen = new Set([defaultModel.value]);
|
|
3048
3674
|
const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
|
|
3675
|
+
// Bedrock/Vertex deployments enforce short aliases (e.g. "claude-opus-4-6")
|
|
3676
|
+
// in availableModels but require provider-specific IDs at the API. We still
|
|
3677
|
+
// resolve `sdkMatch` against the alias (`trimmed`) — that's what the
|
|
3678
|
+
// matching heuristics above are built for, and override targets (ARNs,
|
|
3679
|
+
// opaque provider IDs) often won't textually resemble anything in
|
|
3680
|
+
// `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the
|
|
3681
|
+
// override target, so it's what `setModel` ends up passing to the API.
|
|
3049
3682
|
for (const entry of allowlist) {
|
|
3050
3683
|
const trimmed = entry.trim();
|
|
3051
3684
|
if (!trimmed || seen.has(trimmed))
|
|
3052
3685
|
continue;
|
|
3686
|
+
const overridden = settingsModelOverrides?.[trimmed];
|
|
3687
|
+
const effective = overridden ?? trimmed;
|
|
3688
|
+
if (seen.has(effective))
|
|
3689
|
+
continue;
|
|
3053
3690
|
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
|
|
3054
3691
|
if (sdkMatch) {
|
|
3055
|
-
result.push({ ...sdkMatch, value:
|
|
3692
|
+
result.push({ ...sdkMatch, value: effective });
|
|
3056
3693
|
}
|
|
3057
3694
|
else {
|
|
3058
|
-
result.push({ value:
|
|
3695
|
+
result.push({ value: effective, displayName: trimmed, description: "" });
|
|
3059
3696
|
}
|
|
3060
|
-
seen.add(
|
|
3697
|
+
seen.add(effective);
|
|
3061
3698
|
}
|
|
3062
3699
|
// The custom model option (ANTHROPIC_CUSTOM_MODEL_OPTION) is exempt from the
|
|
3063
3700
|
// allowlist, the same way Default is. Per the model-config docs it adds an
|
|
@@ -3075,14 +3712,30 @@ function applyAvailableModelsAllowlist(sdkModels, allowlist) {
|
|
|
3075
3712
|
}
|
|
3076
3713
|
return result;
|
|
3077
3714
|
}
|
|
3078
|
-
|
|
3715
|
+
/** Read the model a resumed session is actually running (via the
|
|
3716
|
+
* `getContextUsage` control request — the same source `/context` prints) and
|
|
3717
|
+
* map it onto the picker. Best-effort: a control-request failure is logged
|
|
3718
|
+
* and returns null so callers keep their current choice; failing the whole
|
|
3719
|
+
* session/load over an unreadable report would be worse. */
|
|
3720
|
+
async function readResumedLiveModel(query, models, logger) {
|
|
3721
|
+
try {
|
|
3722
|
+
const liveModel = (await query.getContextUsage()).model;
|
|
3723
|
+
return liveModel ? matchResumedModel(models, liveModel) : null;
|
|
3724
|
+
}
|
|
3725
|
+
catch (error) {
|
|
3726
|
+
logger.error("Failed to read the resumed session's live model:", error);
|
|
3727
|
+
return null;
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
async function getAvailableModels(query, models, sdkModels, settingsManager, logger, isResumedSession) {
|
|
3079
3731
|
const settings = settingsManager.getSettings();
|
|
3080
3732
|
let currentModel = models[0];
|
|
3081
3733
|
let resolvedFromInput;
|
|
3082
3734
|
// Model priority (highest to lowest):
|
|
3083
3735
|
// 1. ANTHROPIC_MODEL environment variable
|
|
3084
3736
|
// 2. settings.model (user configuration)
|
|
3085
|
-
// 3.
|
|
3737
|
+
// 3. the resumed session's live model (resumed sessions only)
|
|
3738
|
+
// 4. models[0] (default first model)
|
|
3086
3739
|
if (process.env.ANTHROPIC_MODEL) {
|
|
3087
3740
|
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
|
|
3088
3741
|
if (match) {
|
|
@@ -3097,21 +3750,49 @@ async function getAvailableModels(query, models, sdkModels, settingsManager, log
|
|
|
3097
3750
|
resolvedFromInput = settings.model;
|
|
3098
3751
|
}
|
|
3099
3752
|
}
|
|
3753
|
+
// A resumed session restores the model it was previously running (the CLI
|
|
3754
|
+
// re-reads it from the transcript), so without an env/settings override the
|
|
3755
|
+
// freshly-computed default above can disagree with what the session actually
|
|
3756
|
+
// runs — session/load then reports a model the session isn't using (issue
|
|
3757
|
+
// #845). Ask the CLI for the live model and reflect it. No `setModel` here:
|
|
3758
|
+
// the SDK is already running this model, and pushing a picker alias back
|
|
3759
|
+
// (e.g. "opus[1m]") could change the live model rather than describe it.
|
|
3760
|
+
if (resolvedFromInput === undefined && isResumedSession) {
|
|
3761
|
+
currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
|
|
3762
|
+
}
|
|
3100
3763
|
// Skip the setModel round-trip when we can prove the SDK has already landed
|
|
3101
3764
|
// on the same model. Two cases qualify:
|
|
3102
|
-
// (a) No override applied — currentModel
|
|
3103
|
-
//
|
|
3765
|
+
// (a) No override applied — currentModel is the SDK's own default (or, on
|
|
3766
|
+
// resume, the live model read back from the SDK above); nothing to sync.
|
|
3104
3767
|
// (b) The resolver returned the user's input verbatim AND that value exists
|
|
3105
3768
|
// in the SDK's original model list — meaning no fuzzy match or
|
|
3106
3769
|
// allowlist rewrite was involved, and the SDK (which reads the same
|
|
3107
3770
|
// ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
|
|
3771
|
+
// This only holds for fresh sessions: a resumed session lands on the
|
|
3772
|
+
// transcript's model regardless of env/settings, so the override must
|
|
3773
|
+
// be re-asserted to keep the reported model truthful.
|
|
3108
3774
|
// Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
|
|
3109
3775
|
// setModel call so we don't drift from the user's intended pin.
|
|
3110
3776
|
const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
|
|
3111
3777
|
const skipSetModel = resolvedFromInput === undefined ||
|
|
3112
|
-
(currentModel.value === resolvedFromInput && sdkSawSameValue);
|
|
3778
|
+
(!isResumedSession && currentModel.value === resolvedFromInput && sdkSawSameValue);
|
|
3113
3779
|
if (!skipSetModel) {
|
|
3114
|
-
|
|
3780
|
+
try {
|
|
3781
|
+
await query.setModel(currentModel.value);
|
|
3782
|
+
}
|
|
3783
|
+
catch (error) {
|
|
3784
|
+
// On a fresh session the pin is a defining option — fail loudly. A
|
|
3785
|
+
// resumed session already runs fine on the transcript's model, so
|
|
3786
|
+
// failing the whole session/load over the re-assert would be worse
|
|
3787
|
+
// than loading with the pin unapplied (mirrors the setPermissionMode
|
|
3788
|
+
// containment in createSession). The SDK then stayed on the
|
|
3789
|
+
// transcript's model, so read that back rather than reporting the
|
|
3790
|
+
// pin the session isn't running.
|
|
3791
|
+
if (!isResumedSession)
|
|
3792
|
+
throw error;
|
|
3793
|
+
logger.error(`Failed to re-assert model "${currentModel.value}" on resume:`, error);
|
|
3794
|
+
currentModel = (await readResumedLiveModel(query, models, logger)) ?? currentModel;
|
|
3795
|
+
}
|
|
3115
3796
|
}
|
|
3116
3797
|
return {
|
|
3117
3798
|
availableModels: models.map((model) => ({
|
|
@@ -3284,6 +3965,53 @@ function applyMessageId(update, messageId) {
|
|
|
3284
3965
|
update.messageId = messageId;
|
|
3285
3966
|
}
|
|
3286
3967
|
}
|
|
3968
|
+
/** Built-in tools that drive the task list (headless/SDK sessions use these
|
|
3969
|
+
* instead of TodoWrite). Their tool_use/tool_result are surfaced as `plan`
|
|
3970
|
+
* snapshots rather than as tool_calls. */
|
|
3971
|
+
function isTaskTool(toolName) {
|
|
3972
|
+
return (toolName === "TaskCreate" ||
|
|
3973
|
+
toolName === "TaskUpdate" ||
|
|
3974
|
+
toolName === "TaskList" ||
|
|
3975
|
+
toolName === "TaskGet");
|
|
3976
|
+
}
|
|
3977
|
+
/** Whether a tool's tool_use surfaces to the client as a standalone
|
|
3978
|
+
* `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
|
|
3979
|
+
* suppressed (their plan snapshot is emitted at tool_result time), so neither
|
|
3980
|
+
* produces a tool_call. */
|
|
3981
|
+
function shouldEmitToolCall(toolName) {
|
|
3982
|
+
return toolName !== "TodoWrite" && !isTaskTool(toolName);
|
|
3983
|
+
}
|
|
3984
|
+
/** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
|
|
3985
|
+
* notification for a tool_use. Shared by every site that surfaces a tool call:
|
|
3986
|
+
* the streamed tool_use path (first encounter → tool_call, later encounter →
|
|
3987
|
+
* refine) and the permission flow (`ensureToolCallEmitted`), so they can't
|
|
3988
|
+
* drift. The initial `tool_call` carries `status: "pending"` and, for Bash, the
|
|
3989
|
+
* `terminal_info` _meta that the later `terminal_output`/`terminal_exit`
|
|
3990
|
+
* updates key off of; a refining `tool_call_update` carries neither. */
|
|
3991
|
+
function toolCallNotification(toolUse, rawInput, supportsTerminalOutput, cwd, refine = false) {
|
|
3992
|
+
if (refine) {
|
|
3993
|
+
return {
|
|
3994
|
+
_meta: { claudeCode: { toolName: toolUse.name } },
|
|
3995
|
+
toolCallId: toolUse.id,
|
|
3996
|
+
sessionUpdate: "tool_call_update",
|
|
3997
|
+
rawInput,
|
|
3998
|
+
...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
|
|
3999
|
+
};
|
|
4000
|
+
}
|
|
4001
|
+
return {
|
|
4002
|
+
_meta: {
|
|
4003
|
+
claudeCode: { toolName: toolUse.name },
|
|
4004
|
+
...(toolUse.name === "Bash" && supportsTerminalOutput
|
|
4005
|
+
? { terminal_info: { terminal_id: toolUse.id } }
|
|
4006
|
+
: {}),
|
|
4007
|
+
},
|
|
4008
|
+
toolCallId: toolUse.id,
|
|
4009
|
+
sessionUpdate: "tool_call",
|
|
4010
|
+
rawInput,
|
|
4011
|
+
status: "pending",
|
|
4012
|
+
...toolInfoFromToolUse(toolUse, supportsTerminalOutput, cwd),
|
|
4013
|
+
};
|
|
4014
|
+
}
|
|
3287
4015
|
/**
|
|
3288
4016
|
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
|
3289
4017
|
* Only handles text, image, and thinking chunks for now.
|
|
@@ -3293,6 +4021,9 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
3293
4021
|
const registerHooks = options?.registerHooks !== false;
|
|
3294
4022
|
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
|
|
3295
4023
|
if (typeof content === "string") {
|
|
4024
|
+
if (content.length === 0) {
|
|
4025
|
+
return [];
|
|
4026
|
+
}
|
|
3296
4027
|
const update = {
|
|
3297
4028
|
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
3298
4029
|
content: {
|
|
@@ -3318,15 +4049,18 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
3318
4049
|
let update = null;
|
|
3319
4050
|
switch (chunk.type) {
|
|
3320
4051
|
case "text":
|
|
3321
|
-
case "text_delta":
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
4052
|
+
case "text_delta": {
|
|
4053
|
+
if (chunk.text) {
|
|
4054
|
+
update = {
|
|
4055
|
+
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
4056
|
+
content: {
|
|
4057
|
+
type: "text",
|
|
4058
|
+
text: chunk.text,
|
|
4059
|
+
},
|
|
4060
|
+
};
|
|
4061
|
+
}
|
|
3329
4062
|
break;
|
|
4063
|
+
}
|
|
3330
4064
|
case "image":
|
|
3331
4065
|
update = {
|
|
3332
4066
|
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
|
@@ -3339,10 +4073,10 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
3339
4073
|
};
|
|
3340
4074
|
break;
|
|
3341
4075
|
case "thinking":
|
|
3342
|
-
case "thinking_delta":
|
|
4076
|
+
case "thinking_delta": {
|
|
3343
4077
|
// Recent models default `thinking.display` to "omitted", which streams
|
|
3344
4078
|
// signature-only thinking blocks whose text is empty.
|
|
3345
|
-
if (chunk.thinking
|
|
4079
|
+
if (chunk.thinking) {
|
|
3346
4080
|
update = {
|
|
3347
4081
|
sessionUpdate: "agent_thought_chunk",
|
|
3348
4082
|
content: {
|
|
@@ -3352,6 +4086,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
3352
4086
|
};
|
|
3353
4087
|
}
|
|
3354
4088
|
break;
|
|
4089
|
+
}
|
|
3355
4090
|
case "tool_use":
|
|
3356
4091
|
case "server_tool_use":
|
|
3357
4092
|
case "mcp_tool_use": {
|
|
@@ -3366,10 +4101,7 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
3366
4101
|
};
|
|
3367
4102
|
}
|
|
3368
4103
|
}
|
|
3369
|
-
else if (chunk.name
|
|
3370
|
-
chunk.name === "TaskUpdate" ||
|
|
3371
|
-
chunk.name === "TaskList" ||
|
|
3372
|
-
chunk.name === "TaskGet") {
|
|
4104
|
+
else if (isTaskTool(chunk.name)) {
|
|
3373
4105
|
// Task* tool_use is suppressed; the plan update is emitted at
|
|
3374
4106
|
// tool_result time once we have the task ID (for TaskCreate) and
|
|
3375
4107
|
// confirmation that the change took effect.
|
|
@@ -3421,40 +4153,25 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
3421
4153
|
catch {
|
|
3422
4154
|
// ignore if we can't turn it to JSON
|
|
3423
4155
|
}
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
};
|
|
4156
|
+
// Emit a `tool_call` only the first time this id surfaces to the
|
|
4157
|
+
// client; afterwards refine it with a `tool_call_update`. The first
|
|
4158
|
+
// surface may be this stream chunk OR an earlier permission request
|
|
4159
|
+
// (see `ensureToolCallEmitted`), so emission is tracked separately
|
|
4160
|
+
// from `toolUseCache`. Without an `emittedToolCalls` set we fall back
|
|
4161
|
+
// to cache presence — the historical streaming-only behavior.
|
|
4162
|
+
const emittedToolCalls = options?.emittedToolCalls;
|
|
4163
|
+
const alreadyEmitted = emittedToolCalls ? emittedToolCalls.has(chunk.id) : alreadyCached;
|
|
4164
|
+
emittedToolCalls?.add(chunk.id);
|
|
4165
|
+
if (alreadyEmitted) {
|
|
4166
|
+
// Already surfaced (full assistant message after streaming, or a
|
|
4167
|
+
// permission request emitted it first) — refine with a
|
|
4168
|
+
// tool_call_update rather than emitting a duplicate tool_call.
|
|
4169
|
+
update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd, true);
|
|
3439
4170
|
}
|
|
3440
4171
|
else {
|
|
3441
|
-
// First
|
|
3442
|
-
//
|
|
3443
|
-
update =
|
|
3444
|
-
_meta: {
|
|
3445
|
-
claudeCode: {
|
|
3446
|
-
toolName: chunk.name,
|
|
3447
|
-
},
|
|
3448
|
-
...(chunk.name === "Bash" && supportsTerminalOutput
|
|
3449
|
-
? { terminal_info: { terminal_id: chunk.id } }
|
|
3450
|
-
: {}),
|
|
3451
|
-
},
|
|
3452
|
-
toolCallId: chunk.id,
|
|
3453
|
-
sessionUpdate: "tool_call",
|
|
3454
|
-
rawInput,
|
|
3455
|
-
status: "pending",
|
|
3456
|
-
...toolInfoFromToolUse(chunk, supportsTerminalOutput, options?.cwd),
|
|
3457
|
-
};
|
|
4172
|
+
// First surface (streaming content_block_start or replay) — send as
|
|
4173
|
+
// tool_call (with terminal_info for Bash).
|
|
4174
|
+
update = toolCallNotification(chunk, rawInput, supportsTerminalOutput, options?.cwd);
|
|
3458
4175
|
}
|
|
3459
4176
|
}
|
|
3460
4177
|
break;
|
|
@@ -3467,15 +4184,13 @@ export function toAcpNotifications(content, role, sessionId, toolUseCache, clien
|
|
|
3467
4184
|
case "bash_code_execution_tool_result":
|
|
3468
4185
|
case "text_editor_code_execution_tool_result":
|
|
3469
4186
|
case "mcp_tool_result": {
|
|
4187
|
+
options?.emittedToolCalls?.delete(chunk.tool_use_id);
|
|
3470
4188
|
const toolUse = toolUseCache[chunk.tool_use_id];
|
|
3471
4189
|
if (!toolUse) {
|
|
3472
4190
|
logger.error(`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`);
|
|
3473
4191
|
break;
|
|
3474
4192
|
}
|
|
3475
|
-
if (toolUse.name
|
|
3476
|
-
toolUse.name === "TaskUpdate" ||
|
|
3477
|
-
toolUse.name === "TaskList" ||
|
|
3478
|
-
toolUse.name === "TaskGet") {
|
|
4193
|
+
if (isTaskTool(toolUse.name)) {
|
|
3479
4194
|
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
|
|
3480
4195
|
// TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
|
|
3481
4196
|
// and TaskGet are read-only so we just suppress their tool_call /
|
|
@@ -3582,6 +4297,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
|
|
|
3582
4297
|
parentToolUseId: message.parent_tool_use_id,
|
|
3583
4298
|
cwd: options?.cwd,
|
|
3584
4299
|
taskState: options?.taskState,
|
|
4300
|
+
emittedToolCalls: options?.emittedToolCalls,
|
|
3585
4301
|
messageId: options?.messageId,
|
|
3586
4302
|
});
|
|
3587
4303
|
case "content_block_delta":
|
|
@@ -3590,6 +4306,7 @@ export function streamEventToAcpNotifications(message, sessionId, toolUseCache,
|
|
|
3590
4306
|
parentToolUseId: message.parent_tool_use_id,
|
|
3591
4307
|
cwd: options?.cwd,
|
|
3592
4308
|
taskState: options?.taskState,
|
|
4309
|
+
emittedToolCalls: options?.emittedToolCalls,
|
|
3593
4310
|
messageId: options?.messageId,
|
|
3594
4311
|
});
|
|
3595
4312
|
// No content. `ping` is a Messages-API keep-alive event that the SDK's
|