@gajae-code/coding-agent 0.17.1 → 0.17.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/CHANGELOG.md +72 -0
- package/dist/types/async/job-manager.d.ts +7 -6
- package/dist/types/config/autorouting-tier-map.d.ts +6 -0
- package/dist/types/config/file-lock.d.ts +1 -0
- package/dist/types/config/model-profile-activation.d.ts +18 -3
- package/dist/types/config/model-registry.d.ts +9 -0
- package/dist/types/coordinator-mcp/server.d.ts +3 -0
- package/dist/types/modes/acp/acp-agent.d.ts +40 -1
- package/dist/types/modes/components/thinking-selector.d.ts +2 -1
- package/dist/types/sdk/acp/adapter.d.ts +27 -0
- package/dist/types/sdk/acp/final-text.d.ts +8 -0
- package/dist/types/sdk/broker/ensure.d.ts +46 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +1 -1
- package/dist/types/sdk/prompt-failure.d.ts +30 -1
- package/dist/types/sdk/prompt-status.d.ts +8 -0
- package/dist/types/sdk/router/replay-retention-gap-cache.d.ts +13 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +27 -0
- package/dist/types/thinking.d.ts +3 -1
- package/dist/types/utils/mupdf-wasm-embedded.d.ts +2 -0
- package/package.json +7 -7
- package/src/async/job-manager.ts +38 -8
- package/src/commands/sdk.ts +78 -4
- package/src/config/autorouting-tier-map.ts +2 -0
- package/src/config/file-lock.ts +52 -17
- package/src/config/model-profile-activation.ts +72 -10
- package/src/config/model-registry.ts +21 -0
- package/src/coordinator-mcp/server.ts +21 -6
- package/src/debug/report-bundle.ts +114 -24
- package/src/gjc-runtime/managed-owner-admission.ts +67 -50
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/modes/acp/acp-agent.ts +187 -14
- package/src/modes/acp/acp-event-mapper.ts +9 -0
- package/src/modes/components/model-selector.ts +31 -15
- package/src/modes/components/status-line/segments.ts +6 -1
- package/src/modes/components/thinking-selector.ts +7 -3
- package/src/modes/controllers/selector-controller.ts +16 -3
- package/src/sdk/acp/adapter.ts +63 -1
- package/src/sdk/acp/final-text.ts +19 -2
- package/src/sdk/broker/ensure.ts +97 -19
- package/src/sdk/bus/index.ts +31 -4
- package/src/sdk/host/session-runtime.test.ts +498 -7
- package/src/sdk/host/session-runtime.ts +188 -21
- package/src/sdk/prompt-deadline-manager.ts +3 -3
- package/src/sdk/prompt-failure.ts +43 -1
- package/src/sdk/prompt-status.ts +8 -0
- package/src/sdk/router/replay-retention-gap-cache.ts +27 -0
- package/src/sdk/router/session-router.ts +22 -3
- package/src/sdk/session.ts +12 -9
- package/src/sdk/transport/serve-cli.ts +105 -15
- package/src/session-import/codex.ts +12 -3
- package/src/thinking.ts +21 -2
- package/src/utils/mupdf-wasm-embedded.ts +3 -0
- package/src/utils/mupdf-wasm.ts +24 -2
|
@@ -46,14 +46,20 @@ import {
|
|
|
46
46
|
AcpSdkAdapterError,
|
|
47
47
|
acpMcpLaunchFailure,
|
|
48
48
|
} from "../../sdk/acp";
|
|
49
|
-
import { resolveAcpFinalText } from "../../sdk/acp/final-text";
|
|
49
|
+
import { hasAcpFinalTextContent, resolveAcpFinalText } from "../../sdk/acp/final-text";
|
|
50
50
|
import type { SessionLifecycleMcpServer } from "../../sdk/acp/mcp";
|
|
51
51
|
import { ensureBroker } from "../../sdk/broker/ensure";
|
|
52
52
|
import { canonicalSessionCwd } from "../../sdk/broker/session-index";
|
|
53
53
|
import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../../sdk/client";
|
|
54
54
|
import type { AbortScope } from "../../sdk/host/control/operations";
|
|
55
55
|
import { SYNTHETIC_PROVIDER_ID } from "../../sdk/model-profile-namespace";
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
failedPromptOutcome,
|
|
58
|
+
isSafePromptFailureCode,
|
|
59
|
+
isSdkPromptFailurePhase,
|
|
60
|
+
promptFailureRetryability,
|
|
61
|
+
rephaseFailedOutcome,
|
|
62
|
+
} from "../../sdk/prompt-failure";
|
|
57
63
|
import type { SdkPromptTerminalOutcome } from "../../sdk/prompt-status";
|
|
58
64
|
import { PromptActivity, type PromptWatchdogClock, systemPromptWatchdogClock } from "../../sdk/prompt-watchdog";
|
|
59
65
|
import { validateRequiredPromptText } from "../../sdk/protocol/adapter-validation";
|
|
@@ -177,6 +183,8 @@ interface PromptWaiter {
|
|
|
177
183
|
lastFrameAt: number;
|
|
178
184
|
/** Type of that prompt-owned frame, reported when the watchdog expires. */
|
|
179
185
|
lastFrameType: string;
|
|
186
|
+
/** True while the prompt is blocked in provider preflight, before the turn is dispatched. */
|
|
187
|
+
awaitingProviderPreflight: boolean;
|
|
180
188
|
/** Cancels the armed inactivity watchdog; re-armed by prompt-owned frames. */
|
|
181
189
|
cancelWatchdog?: () => void;
|
|
182
190
|
/** What the host is observably doing — a tool running, a model call unanswered — and the bound that follows from it. */
|
|
@@ -185,6 +193,12 @@ interface PromptWaiter {
|
|
|
185
193
|
observedTurnActivity: boolean;
|
|
186
194
|
/** True once a prompt-owned tool execution frame was observed; vetoes a first-turn retry that would re-run it. */
|
|
187
195
|
observedToolExecution: boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Newest `plan` update published for this prompt, retained so an abandoned turn can report
|
|
198
|
+
* what it had left to do (issue #5669). Absent means no plan was ever observed, which is
|
|
199
|
+
* evidence of nothing — distinct from an observed plan whose entries are all `completed`.
|
|
200
|
+
*/
|
|
201
|
+
planSnapshot?: AcpPlanSnapshot;
|
|
188
202
|
/** Coordinates a prompt-control rejection racing an acknowledged ACP cancellation. */
|
|
189
203
|
cancelAttempt?: Promise<boolean>;
|
|
190
204
|
/** Whether ANY overlapping cancel attempt for this prompt was acknowledged:
|
|
@@ -618,7 +632,7 @@ type SdkPromptFailedOutcome = Extract<SdkPromptTerminalOutcome, { kind: "failed"
|
|
|
618
632
|
* category — so settlement carries that classification instead of discarding it into a bare
|
|
619
633
|
* `AcpSdkAdapterError` (review P1, issue #5574).
|
|
620
634
|
*/
|
|
621
|
-
class AcpPromptFailureError extends AcpSdkAdapterError {
|
|
635
|
+
export class AcpPromptFailureError extends AcpSdkAdapterError {
|
|
622
636
|
readonly failure: SdkPromptFailedOutcome;
|
|
623
637
|
constructor(failure: SdkPromptFailedOutcome) {
|
|
624
638
|
super(failure.code, failure.message);
|
|
@@ -626,6 +640,103 @@ class AcpPromptFailureError extends AcpSdkAdapterError {
|
|
|
626
640
|
}
|
|
627
641
|
}
|
|
628
642
|
|
|
643
|
+
/**
|
|
644
|
+
* The wire projection of a prompt terminal's classification (issue #5615).
|
|
645
|
+
*
|
|
646
|
+
* `super(failure.code, failure.message)` above narrows the error to the generic
|
|
647
|
+
* `prompt_failed` plus its fixed redacted message, so the classification the
|
|
648
|
+
* terminal already computed used to die at the JSON-RPC boundary: an ACP client
|
|
649
|
+
* saw only `-32603 {code, details}` and could not tell a transient provider blip
|
|
650
|
+
* from a dead session without parsing English. These fields carry it across.
|
|
651
|
+
*
|
|
652
|
+
* `providerCode` is re-checked against the safe-token rule rather than trusted:
|
|
653
|
+
* `terminalOutcome` accepts the host's `providerCode` on a `typeof` check alone,
|
|
654
|
+
* and this is the first path that puts it on the wire, so an unbounded value is
|
|
655
|
+
* dropped instead of becoming a leak of provider text (the redaction contract in
|
|
656
|
+
* `sanitizePromptFailure`). A dropped or absent code is omitted, never nulled.
|
|
657
|
+
*/
|
|
658
|
+
function promptFailureWireData(failure: SdkPromptFailedOutcome): Record<string, string> {
|
|
659
|
+
return {
|
|
660
|
+
phase: failure.phase,
|
|
661
|
+
category: failure.category,
|
|
662
|
+
retryability: promptFailureRetryability(failure.category),
|
|
663
|
+
...(isSafePromptFailureCode(failure.providerCode) ? { providerCode: failure.providerCode } : {}),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* What an ACP `plan` update said the turn intended to do. Structural rather than the SDK's
|
|
669
|
+
* `PlanEntry` so only the fields this evidence reads are depended on.
|
|
670
|
+
*/
|
|
671
|
+
type AcpPlanSnapshot = Array<{
|
|
672
|
+
content: string;
|
|
673
|
+
status: "pending" | "in_progress" | "completed";
|
|
674
|
+
_meta?: { gjcTodoStatus?: unknown } | null;
|
|
675
|
+
}>;
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* An abandoned prompt that still carries the plan its turn was working through (issue #5669).
|
|
679
|
+
*
|
|
680
|
+
* The watchdog settles a prompt whose host stopped producing frames, and that rejection used to
|
|
681
|
+
* be a bare `AcpSdkAdapterError`: `{code, details}` and nothing else. A wrapper could not tell
|
|
682
|
+
* "the agent finished" from "the agent stopped with work left", so an abandoned turn's unverified
|
|
683
|
+
* diff was published as if it were complete. The last observed plan is the evidence that settles
|
|
684
|
+
* that question, so settlement carries it instead of dropping it at the JSON-RPC boundary.
|
|
685
|
+
*/
|
|
686
|
+
export class AcpPromptAbandonedError extends AcpSdkAdapterError {
|
|
687
|
+
readonly plan: AcpPlanSnapshot | undefined;
|
|
688
|
+
constructor(code: string, message: string, plan: AcpPlanSnapshot | undefined) {
|
|
689
|
+
super(code, message);
|
|
690
|
+
this.plan = plan;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Most pending plan entries that may reach the wire. */
|
|
695
|
+
const ACP_ABANDON_PLAN_MAX_ENTRIES = 10;
|
|
696
|
+
/** Longest one pending entry's text may be on the wire. */
|
|
697
|
+
const ACP_ABANDON_PLAN_MAX_CONTENT_CHARS = 200;
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Whether a plan entry describes work the turn never finished (issue #5669).
|
|
701
|
+
*
|
|
702
|
+
* An entry whose ACP status is `completed` can still be unfinished: ACP's `PlanEntryStatus` has no
|
|
703
|
+
* `abandoned` member, so a task dropped via `/todo drop` is projected as `completed` for the
|
|
704
|
+
* client's plan UI while the mapper keeps the internal status in `_meta.gjcTodoStatus`. Completion
|
|
705
|
+
* evidence has to read that internal truth — reading the wire status alone would report a plan of
|
|
706
|
+
* nothing but dropped tasks as a finished turn. `_meta` crosses a JSON boundary, so the internal
|
|
707
|
+
* status is compared as `unknown` rather than asserted into a type.
|
|
708
|
+
*/
|
|
709
|
+
function planEntryUnfinished(entry: AcpPlanSnapshot[number]): boolean {
|
|
710
|
+
return entry.status !== "completed" || entry._meta?.gjcTodoStatus === "abandoned";
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* The wire projection of an abandoned prompt's plan (issue #5669).
|
|
715
|
+
*
|
|
716
|
+
* Plan text is model-authored, so it is bounded the same way `promptFailureWireData` bounds a
|
|
717
|
+
* provider classifier: a capped number of entries, each capped in length, and never restated in
|
|
718
|
+
* the human message. The counts are what a wrapper actually gates on; the contents are there so
|
|
719
|
+
* the reader can name the unfinished steps without a log dive.
|
|
720
|
+
*
|
|
721
|
+
* An absent plan yields no keys at all. "No plan was ever observed" is not the same claim as
|
|
722
|
+
* "the plan was complete", and a client must be able to tell them apart, so the fields are
|
|
723
|
+
* omitted rather than nulled or reported as unknown.
|
|
724
|
+
*/
|
|
725
|
+
function promptAbandonWireData(plan: AcpPlanSnapshot | undefined): Record<string, string> {
|
|
726
|
+
if (!plan) return {};
|
|
727
|
+
const pending = plan.filter(planEntryUnfinished);
|
|
728
|
+
return {
|
|
729
|
+
planIncomplete: pending.length > 0 ? "true" : "false",
|
|
730
|
+
planPendingCount: String(pending.length),
|
|
731
|
+
planTotalCount: String(plan.length),
|
|
732
|
+
planPending: JSON.stringify(
|
|
733
|
+
pending
|
|
734
|
+
.slice(0, ACP_ABANDON_PLAN_MAX_ENTRIES)
|
|
735
|
+
.map(entry => entry.content.slice(0, ACP_ABANDON_PLAN_MAX_CONTENT_CHARS)),
|
|
736
|
+
),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
629
740
|
/**
|
|
630
741
|
* The startup-readiness failure class (issue #5574): the turn failed AFTER it started, with a
|
|
631
742
|
* bounded provider/transport classifier — the signature of a host that accepted the turn before
|
|
@@ -1251,9 +1362,21 @@ export function acpRequestFailure(error: unknown): unknown {
|
|
|
1251
1362
|
const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
|
1252
1363
|
if (typeof code !== "string") return error;
|
|
1253
1364
|
const message = error instanceof Error ? error.message : code;
|
|
1365
|
+
// A prompt terminal additionally publishes its classification (issue #5615). The
|
|
1366
|
+
// spread is empty for every other error, so their payloads are byte-identical to
|
|
1367
|
+
// before. `code`/`details` and the JSON-RPC code itself are untouched either way:
|
|
1368
|
+
// this enriches `data`, it does not restate the redacted message.
|
|
1369
|
+
const data =
|
|
1370
|
+
error instanceof AcpPromptFailureError
|
|
1371
|
+
? { code, details: message, ...promptFailureWireData(error.failure) }
|
|
1372
|
+
: { code, details: message };
|
|
1373
|
+
// An abandoned prompt additionally publishes the plan it never finished (issue #5669).
|
|
1374
|
+
// Same rule as above: `code`/`details` and the JSON-RPC code are untouched, and an abandon
|
|
1375
|
+
// that observed no plan adds no keys, so its payload stays byte-identical to before.
|
|
1376
|
+
if (error instanceof AcpPromptAbandonedError) Object.assign(data, promptAbandonWireData(error.plan));
|
|
1254
1377
|
switch (code) {
|
|
1255
1378
|
case "authentication_failed":
|
|
1256
|
-
return RequestError.authRequired(
|
|
1379
|
+
return RequestError.authRequired(data, message);
|
|
1257
1380
|
// `not_found` stays -32603 with its discriminator in `data`: ACP's
|
|
1258
1381
|
// `resourceNotFound` (-32002) is a URI-addressed resource error, and an unknown
|
|
1259
1382
|
// session id is not a resource URI. Pinned ACP core-v1 conformance also requires
|
|
@@ -1261,12 +1384,15 @@ export function acpRequestFailure(error: unknown): unknown {
|
|
|
1261
1384
|
case "invalid_input":
|
|
1262
1385
|
case "unsupported":
|
|
1263
1386
|
case "unsupported_content":
|
|
1264
|
-
return RequestError.invalidParams(
|
|
1387
|
+
return RequestError.invalidParams(data, message);
|
|
1265
1388
|
default:
|
|
1266
1389
|
// The remaining internal codes (conflict, unavailable, busy, …) have no ACP
|
|
1267
1390
|
// counterpart and stay -32603. Keep the discriminator in `data` so a client can
|
|
1268
|
-
// branch on retry/reconnect instead of parsing an English message.
|
|
1269
|
-
|
|
1391
|
+
// branch on retry/reconnect instead of parsing an English message. `prompt_failed`
|
|
1392
|
+
// and `prompt_deadline_exceeded` land here and keep -32603 by design: pinned ACP
|
|
1393
|
+
// core-v1 conformance expects -32603/-32000 for this class, so the added
|
|
1394
|
+
// classification travels in `data` rather than in a renumbered code.
|
|
1395
|
+
return RequestError.internalError(data, message);
|
|
1270
1396
|
}
|
|
1271
1397
|
}
|
|
1272
1398
|
|
|
@@ -2055,6 +2181,7 @@ export class AcpAgent implements Agent {
|
|
|
2055
2181
|
deferredActivityFrames: [],
|
|
2056
2182
|
lastFrameAt: this.#promptWatchdogClock.now(),
|
|
2057
2183
|
lastFrameType: "prompt_dispatch",
|
|
2184
|
+
awaitingProviderPreflight: true,
|
|
2058
2185
|
activity: new PromptActivity(),
|
|
2059
2186
|
observedTurnActivity: false,
|
|
2060
2187
|
observedToolExecution: false,
|
|
@@ -2066,9 +2193,24 @@ export class AcpAgent implements Agent {
|
|
|
2066
2193
|
// pending. Retain the original promise for the caller, but mark that delayed rejection
|
|
2067
2194
|
// as observed until prompt() can resume and await it.
|
|
2068
2195
|
void response.catch(() => undefined);
|
|
2196
|
+
// Silence has to be bounded from the moment the prompt owns the session: a host that
|
|
2197
|
+
// dies before it ever answers is exactly the failure that leaves the client running.
|
|
2198
|
+
// Provider preflight is part of that window — it awaits a lease the host may never win
|
|
2199
|
+
// — so the bound is armed before it, not after (issue #5658). The arm below re-arms
|
|
2200
|
+
// this same waiter once preflight clears, and #armPromptWatchdog cancels the previous
|
|
2201
|
+
// timer first, so only one timer is ever live.
|
|
2202
|
+
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2203
|
+
// The watchdog settles `response`, not this await, so a preflight that never returns
|
|
2204
|
+
// would pin session/prompt open with the caller's promise already rejected. Racing the
|
|
2205
|
+
// settlement lets the re-checks below hand that rejection back to the caller.
|
|
2206
|
+
const settlement = response.then(
|
|
2207
|
+
() => undefined,
|
|
2208
|
+
() => undefined,
|
|
2209
|
+
);
|
|
2069
2210
|
try {
|
|
2070
|
-
await record.adapter.ensureProviders();
|
|
2211
|
+
await Promise.race([record.adapter.ensureProviders(), settlement]);
|
|
2071
2212
|
} catch (error) {
|
|
2213
|
+
waiter.awaitingProviderPreflight = false;
|
|
2072
2214
|
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2073
2215
|
this.#retiredPromptAcknowledgements.delete(params.sessionId);
|
|
2074
2216
|
return await response;
|
|
@@ -2081,11 +2223,13 @@ export class AcpAgent implements Agent {
|
|
|
2081
2223
|
if (record.activePrompt === waiter) {
|
|
2082
2224
|
record.activePrompt = undefined;
|
|
2083
2225
|
record.busy = record.backgroundBusy;
|
|
2226
|
+
clearPromptWatchdog(waiter);
|
|
2084
2227
|
this.#retiredPromptAcknowledgements.delete(params.sessionId);
|
|
2085
2228
|
void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
|
|
2086
2229
|
}
|
|
2087
2230
|
throw error;
|
|
2088
2231
|
}
|
|
2232
|
+
waiter.awaitingProviderPreflight = false;
|
|
2089
2233
|
if (waiter.settled || record.activePrompt !== waiter) {
|
|
2090
2234
|
this.#retiredPromptAcknowledgements.delete(params.sessionId);
|
|
2091
2235
|
return await response;
|
|
@@ -2096,8 +2240,9 @@ export class AcpAgent implements Agent {
|
|
|
2096
2240
|
return await response;
|
|
2097
2241
|
}
|
|
2098
2242
|
|
|
2099
|
-
//
|
|
2100
|
-
//
|
|
2243
|
+
// Re-arm now that preflight is behind us: the bound armed before it already covered
|
|
2244
|
+
// this prompt's ownership window, and this call replaces that timer rather than
|
|
2245
|
+
// racing it, so the dispatched turn gets the full inactivity bound of its own.
|
|
2101
2246
|
this.#armPromptWatchdog(params.sessionId, record, waiter);
|
|
2102
2247
|
// Echo the user's own message back as `user_message_chunk`. Clients render their
|
|
2103
2248
|
// transcript from session/update, so without this a prompt's text and any attached
|
|
@@ -3257,15 +3402,32 @@ export class AcpAgent implements Agent {
|
|
|
3257
3402
|
async #expirePromptWatchdog(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
|
|
3258
3403
|
if (record.activePrompt !== waiter || waiter.settled || waiter.terminalReserved) return;
|
|
3259
3404
|
const silenceMs = Math.max(0, this.#promptWatchdogClock.now() - waiter.lastFrameAt);
|
|
3260
|
-
|
|
3405
|
+
// A prompt still in preflight has never reached the host, so "stopped producing frames"
|
|
3406
|
+
// would point the reader at the wrong phase: nothing was ever dispatched to produce them.
|
|
3407
|
+
const cause =
|
|
3408
|
+
this.#promptTransportGone(id, record) ??
|
|
3409
|
+
(waiter.awaitingProviderPreflight
|
|
3410
|
+
? "the SDK session host never finished provider preflight"
|
|
3411
|
+
: "the SDK session host stopped producing frames");
|
|
3412
|
+
// Counts only: the plan's contents are model-authored and belong on the bounded wire
|
|
3413
|
+
// projection, not in the log line (issue #5669).
|
|
3414
|
+
const plan = waiter.planSnapshot;
|
|
3261
3415
|
logger.error("acp_prompt_watchdog_expired", {
|
|
3262
3416
|
sessionId: id,
|
|
3263
3417
|
cause,
|
|
3264
3418
|
silenceMs,
|
|
3265
3419
|
lastFrameType: waiter.lastFrameType,
|
|
3420
|
+
awaitingProviderPreflight: waiter.awaitingProviderPreflight,
|
|
3266
3421
|
inactivityBoundMs: waiter.activity.inactivityBoundMs,
|
|
3267
3422
|
toolRunning: waiter.activity.running,
|
|
3268
3423
|
awaitingModel: waiter.activity.awaitingModel,
|
|
3424
|
+
...(plan
|
|
3425
|
+
? {
|
|
3426
|
+
planIncomplete: plan.some(planEntryUnfinished),
|
|
3427
|
+
planPendingCount: plan.filter(planEntryUnfinished).length,
|
|
3428
|
+
planTotalCount: plan.length,
|
|
3429
|
+
}
|
|
3430
|
+
: {}),
|
|
3269
3431
|
...(waiter.correlation.commandId ? { commandId: waiter.correlation.commandId } : {}),
|
|
3270
3432
|
...(waiter.correlation.turnId ? { turnId: waiter.correlation.turnId } : {}),
|
|
3271
3433
|
});
|
|
@@ -3273,11 +3435,12 @@ export class AcpAgent implements Agent {
|
|
|
3273
3435
|
record,
|
|
3274
3436
|
id,
|
|
3275
3437
|
waiter,
|
|
3276
|
-
new
|
|
3438
|
+
new AcpPromptAbandonedError(
|
|
3277
3439
|
"prompt_abandoned",
|
|
3278
3440
|
`ACP prompt was abandoned after ${Math.round(silenceMs / 1_000)}s of silence: ${cause}. Last frame was ` +
|
|
3279
3441
|
`"${waiter.lastFrameType}" (${describeCorrelation(waiter.correlation)}). The turn was settled so the ` +
|
|
3280
3442
|
`client stops waiting; the session still accepts the next prompt.`,
|
|
3443
|
+
plan,
|
|
3281
3444
|
),
|
|
3282
3445
|
);
|
|
3283
3446
|
}
|
|
@@ -3467,7 +3630,11 @@ export class AcpAgent implements Agent {
|
|
|
3467
3630
|
// the first-turn retry gate — which the settlement below releases — would see
|
|
3468
3631
|
// "started, no output", resubmit, and let BOTH this terminal's final text and the
|
|
3469
3632
|
// retry's answer reach ACP consumers. Record the output before settling (review P1).
|
|
3470
|
-
|
|
3633
|
+
// Whitespace-only final text carries no assistant content. `hasAcpFinalTextContent` is the
|
|
3634
|
+
// single presence predicate shared with the terminal publication gate below, so a terminal
|
|
3635
|
+
// this gate treats as "no output" can never publish a chunk alongside the retry's answer.
|
|
3636
|
+
if (typeof event.finalText === "string" && hasAcpFinalTextContent(event.finalText))
|
|
3637
|
+
record.promptObservedAssistantOutput = true;
|
|
3471
3638
|
// Failure diagnostics are useful but advisory. Settle before any mapped
|
|
3472
3639
|
// session update can await a backpressured client transport; otherwise an
|
|
3473
3640
|
// already-decided failure can still lose to the inactivity watchdog.
|
|
@@ -3534,6 +3701,12 @@ export class AcpAgent implements Agent {
|
|
|
3534
3701
|
notification.update.content.type === "text"
|
|
3535
3702
|
)
|
|
3536
3703
|
promptOwner.emittedAssistantText += notification.update.content.text;
|
|
3704
|
+
// The newest plan REPLACES the retained one: a plan update is the whole list, not a
|
|
3705
|
+
// delta, so `todo_auto_clear`'s empty `entries` correctly leaves no pending work behind
|
|
3706
|
+
// (issue #5669). Retained here rather than at frame ingress because this is where the
|
|
3707
|
+
// mapper has already decided what the client is being told the plan is.
|
|
3708
|
+
if (promptOwner && notification.update.sessionUpdate === "plan")
|
|
3709
|
+
promptOwner.planSnapshot = notification.update.entries;
|
|
3537
3710
|
// A live assistant text/thought chunk for this prompt owner has now been exposed to
|
|
3538
3711
|
// ACP consumers. Record it so the first-turn readiness retry is vetoed: a re-submit
|
|
3539
3712
|
// would deliver a second attempt's output on top of these chunks (issue #5574).
|
|
@@ -3628,7 +3801,7 @@ export class AcpAgent implements Agent {
|
|
|
3628
3801
|
if (promptOwner) this.#flushFailureDiagnostics(id, promptOwner, adapter);
|
|
3629
3802
|
let decorationStart = Promise.resolve();
|
|
3630
3803
|
const finalText = typeof event.finalText === "string" ? event.finalText : "";
|
|
3631
|
-
if (promptOwner && finalText) {
|
|
3804
|
+
if (promptOwner && hasAcpFinalTextContent(finalText)) {
|
|
3632
3805
|
const finalTextTask = (async () => {
|
|
3633
3806
|
await Bun.sleep(0);
|
|
3634
3807
|
const resolution = resolveAcpFinalText(promptOwner.emittedAssistantText, finalText);
|
|
@@ -239,6 +239,7 @@ export function mapAgentSessionEventToAcpSessionUpdates(
|
|
|
239
239
|
content: todo.content,
|
|
240
240
|
priority: "medium" as const,
|
|
241
241
|
status: mapTodoStatus(todo.status),
|
|
242
|
+
_meta: { gjcTodoStatus: todo.status },
|
|
242
243
|
}));
|
|
243
244
|
return [toSessionNotification(sessionId, { sessionUpdate: "plan", entries })];
|
|
244
245
|
}
|
|
@@ -502,6 +503,13 @@ function toSessionNotification(sessionId: string, update: SessionUpdate): Sessio
|
|
|
502
503
|
return { sessionId, update };
|
|
503
504
|
}
|
|
504
505
|
|
|
506
|
+
/**
|
|
507
|
+
* ACP's `PlanEntryStatus` has only three members, so an `abandoned` todo has to be projected as
|
|
508
|
+
* one of them and `completed` is the one that stops a client's plan UI from showing a dropped task
|
|
509
|
+
* as outstanding work. That projection is lossy, so every plan entry also carries the internal
|
|
510
|
+
* status as `_meta.gjcTodoStatus` for readers that need the truth rather than the rendering
|
|
511
|
+
* (issue #5669).
|
|
512
|
+
*/
|
|
505
513
|
const todoStatusMap: Record<TodoStatus, "pending" | "in_progress" | "completed"> = {
|
|
506
514
|
pending: "pending",
|
|
507
515
|
in_progress: "in_progress",
|
|
@@ -529,6 +537,7 @@ function mapTodoWriteResultToPlanUpdate(
|
|
|
529
537
|
content: todo.content,
|
|
530
538
|
priority: "medium" as const,
|
|
531
539
|
status: mapTodoStatus(todo.status),
|
|
540
|
+
_meta: { gjcTodoStatus: todo.status },
|
|
532
541
|
})),
|
|
533
542
|
};
|
|
534
543
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { ThinkingLevel } from "@gajae-code/agent-core";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getMiniMaxThinkingMode,
|
|
4
|
+
getSupportedEfforts,
|
|
5
|
+
type Model,
|
|
6
|
+
modelSupportsServiceTier,
|
|
7
|
+
modelsAreEqual,
|
|
8
|
+
} from "@gajae-code/ai/core";
|
|
3
9
|
import {
|
|
4
10
|
Container,
|
|
5
11
|
fuzzyFilter,
|
|
@@ -26,6 +32,7 @@ import type { BillingPath } from "../../config/billing-path";
|
|
|
26
32
|
import {
|
|
27
33
|
getProxyRoutableProviders,
|
|
28
34
|
inspectProxyProviderId,
|
|
35
|
+
isModelProfileProxyConfigured,
|
|
29
36
|
requiresQualifiedModelProfileRoleResolution,
|
|
30
37
|
resolveProxyMode,
|
|
31
38
|
rewriteSelectorForProxy,
|
|
@@ -426,6 +433,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
426
433
|
#presetScopeMenuOpen: boolean = false;
|
|
427
434
|
#presetScopeIndex: number = 0;
|
|
428
435
|
#providerAuthById = new Map<string, boolean>();
|
|
436
|
+
#credentiallessProviders = new Set<string>();
|
|
429
437
|
#bareProfileAuthByName = new Map<string, boolean>();
|
|
430
438
|
// Rebuilt with the provider and bare-selector snapshots on every catalog or credential refresh.
|
|
431
439
|
#profileAuthByName = new Map<string, boolean>();
|
|
@@ -1441,17 +1449,16 @@ export class ModelSelectorComponent extends Container {
|
|
|
1441
1449
|
const proxyProvider = tryResolveProxyProviderId(this.#settings);
|
|
1442
1450
|
if (
|
|
1443
1451
|
proxyProvider !== undefined &&
|
|
1444
|
-
!(
|
|
1452
|
+
!isModelProfileProxyConfigured(
|
|
1453
|
+
proxyProvider,
|
|
1454
|
+
this.#modelRegistry.getConfiguredProviderIds?.(),
|
|
1455
|
+
this.#credentiallessProviders.has(proxyProvider),
|
|
1456
|
+
)
|
|
1445
1457
|
)
|
|
1446
1458
|
return false;
|
|
1447
1459
|
try {
|
|
1448
1460
|
if (resolveProxyMode(this.#settings) === "always") {
|
|
1449
|
-
|
|
1450
|
-
if (
|
|
1451
|
-
proxyProvider === undefined ||
|
|
1452
|
-
this.#isProviderAuthenticated(proxyProvider) !== true ||
|
|
1453
|
-
!configuredProviders.includes(proxyProvider)
|
|
1454
|
-
)
|
|
1461
|
+
if (proxyProvider === undefined || this.#isProviderAuthenticated(proxyProvider) !== true)
|
|
1455
1462
|
return false;
|
|
1456
1463
|
}
|
|
1457
1464
|
} catch {
|
|
@@ -1530,6 +1537,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
1530
1537
|
async #refreshProviderAuth(): Promise<void> {
|
|
1531
1538
|
const refreshGeneration = ++this.#providerAuthRefreshGeneration;
|
|
1532
1539
|
this.#providerAuthById = new Map();
|
|
1540
|
+
this.#credentiallessProviders = new Set();
|
|
1533
1541
|
this.#bareProfileAuthByName = new Map();
|
|
1534
1542
|
this.#profileAuthByName = new Map();
|
|
1535
1543
|
const availableModels = this.#getProfileAvailableModels();
|
|
@@ -1586,9 +1594,9 @@ export class ModelSelectorComponent extends Container {
|
|
|
1586
1594
|
[...providers].map(async provider => {
|
|
1587
1595
|
try {
|
|
1588
1596
|
const apiKey = await this.#modelRegistry.getApiKeyForProvider(provider, this.#authSessionId);
|
|
1589
|
-
return [provider, apiKey === kNoAuth || isAuthenticated(apiKey)] as const;
|
|
1597
|
+
return [provider, apiKey === kNoAuth || isAuthenticated(apiKey), apiKey === kNoAuth] as const;
|
|
1590
1598
|
} catch {
|
|
1591
|
-
return [provider, false] as const;
|
|
1599
|
+
return [provider, false, false] as const;
|
|
1592
1600
|
}
|
|
1593
1601
|
}),
|
|
1594
1602
|
);
|
|
@@ -1598,7 +1606,10 @@ export class ModelSelectorComponent extends Container {
|
|
|
1598
1606
|
refreshGeneration !== this.#providerAuthRefreshGeneration
|
|
1599
1607
|
)
|
|
1600
1608
|
return;
|
|
1601
|
-
this.#providerAuthById = new Map(entries);
|
|
1609
|
+
this.#providerAuthById = new Map(entries.map(([provider, authenticated]) => [provider, authenticated]));
|
|
1610
|
+
this.#credentiallessProviders = new Set(
|
|
1611
|
+
entries.filter(([, , keyless]) => keyless).map(([provider]) => provider),
|
|
1612
|
+
);
|
|
1602
1613
|
const profileAuthEntries = await Promise.all(
|
|
1603
1614
|
[...this.#getPresetGroups().values()].flat().map(async profile => {
|
|
1604
1615
|
const bindings = resolveProfileBindings(profile);
|
|
@@ -1853,7 +1864,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
1853
1864
|
const modelLabel = sanitizeText(`${model.provider}/${model.id}`).replace(/\s+/g, " ").trim();
|
|
1854
1865
|
let label = modelLabel;
|
|
1855
1866
|
if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) {
|
|
1856
|
-
label += ` (${getThinkingLevelMetadata(thinkingLevel).label})`;
|
|
1867
|
+
label += ` (${getThinkingLevelMetadata(thinkingLevel, model).label})`;
|
|
1857
1868
|
}
|
|
1858
1869
|
const billing = this.#billingPathForModel(model)?.label;
|
|
1859
1870
|
if (billing) label += ` [${billing}]`;
|
|
@@ -2214,7 +2225,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
2214
2225
|
const assigned = this.#roles[role];
|
|
2215
2226
|
if (roleInfo.tag && assigned && modelsAreEqual(assigned.model, item.model)) {
|
|
2216
2227
|
const badge = makeInvertedBadge(roleInfo.tag, roleInfo.color ?? "muted");
|
|
2217
|
-
const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
|
|
2228
|
+
const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel, item.model).label;
|
|
2218
2229
|
|
|
2219
2230
|
// Subagent roles (task.agentModelOverrides) run under task.serviceTier,
|
|
2220
2231
|
// so their ⚡ uses the effective subagent tier. A non-subagent
|
|
@@ -2326,6 +2337,11 @@ export class ModelSelectorComponent extends Container {
|
|
|
2326
2337
|
#renderActionMenu(item: ModelItem | CanonicalModelItem): void {
|
|
2327
2338
|
this.#listContainer.addChild(new Spacer(1));
|
|
2328
2339
|
this.#listContainer.addChild(new Text(theme.fg("muted", ` Action for: ${item.model.id}`), 0, 0));
|
|
2340
|
+
if (getMiniMaxThinkingMode(item.model) === "always-on") {
|
|
2341
|
+
this.#listContainer.addChild(
|
|
2342
|
+
new Text(theme.fg("muted", " Reasoning: always on; effort control is unsupported"), 0, 0),
|
|
2343
|
+
);
|
|
2344
|
+
}
|
|
2329
2345
|
this.#listContainer.addChild(new Spacer(1));
|
|
2330
2346
|
const actionCount = this.#getActionCount(item.model);
|
|
2331
2347
|
for (let i = 0; i < actionCount; i++) {
|
|
@@ -2390,7 +2406,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
2390
2406
|
// Show the highlighted reasoning level — never the model id (that mislabel
|
|
2391
2407
|
// made "Reasoning for Default: gpt-5.6-luna" look like a level).
|
|
2392
2408
|
const selectedLevel = choice.levels[this.#selectedThinkingIndex];
|
|
2393
|
-
const selectedLevelLabel = selectedLevel ? getThinkingLevelMetadata(selectedLevel).label : "—";
|
|
2409
|
+
const selectedLevelLabel = selectedLevel ? getThinkingLevelMetadata(selectedLevel, choice.item.model).label : "—";
|
|
2394
2410
|
this.#listContainer.addChild(new Spacer(1));
|
|
2395
2411
|
this.#listContainer.addChild(
|
|
2396
2412
|
new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${selectedLevelLabel}`), 0, 0),
|
|
@@ -2398,7 +2414,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
2398
2414
|
this.#listContainer.addChild(new Spacer(1));
|
|
2399
2415
|
for (let i = 0; i < choice.levels.length; i++) {
|
|
2400
2416
|
const level = choice.levels[i];
|
|
2401
|
-
const metadata = getThinkingLevelMetadata(level);
|
|
2417
|
+
const metadata = getThinkingLevelMetadata(level, choice.item.model);
|
|
2402
2418
|
const prefix = i === this.#selectedThinkingIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
|
|
2403
2419
|
const label = `${metadata.label} — ${metadata.description}`;
|
|
2404
2420
|
this.#listContainer.addChild(
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as os from "node:os";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { ThinkingLevel } from "@gajae-code/agent-core";
|
|
4
|
+
import { getMiniMaxThinkingMode } from "@gajae-code/ai/model-thinking";
|
|
4
5
|
import { TERMINAL, truncateToWidth } from "@gajae-code/tui";
|
|
5
6
|
import { formatDuration, formatNumber, getProjectDir, pathIsWithin, relativePathWithinRoot } from "@gajae-code/utils";
|
|
6
7
|
import { type ThemeColor, theme } from "../../../modes/theme/theme";
|
|
8
|
+
import { getThinkingLevelMetadata } from "../../../thinking";
|
|
7
9
|
import { shortenPath } from "../../../tools/render-utils";
|
|
8
10
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
|
|
9
11
|
import { sanitizeStatusText } from "../../shared";
|
|
@@ -113,7 +115,10 @@ const modelSegment: StatusLineSegment = {
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
// Add thinking level with dot separator
|
|
116
|
-
|
|
118
|
+
const miniMaxMode = state.model && getMiniMaxThinkingMode(state.model);
|
|
119
|
+
if (opts.showThinkingLevel !== false && miniMaxMode) {
|
|
120
|
+
content += `${theme.sep.dot}${getThinkingLevelMetadata(state.thinkingLevel ?? ThinkingLevel.Off, state.model).label}`;
|
|
121
|
+
} else if (opts.showThinkingLevel !== false && state.model?.thinking) {
|
|
117
122
|
const level = state.thinkingLevel ?? ThinkingLevel.Off;
|
|
118
123
|
if (level !== ThinkingLevel.Off) {
|
|
119
124
|
const thinkingText = theme.thinking[level as keyof typeof theme.thinking];
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@gajae-code/agent-core";
|
|
2
|
+
import type { Model } from "@gajae-code/ai/types";
|
|
2
3
|
import { Container, type SelectItem, SelectList, Text } from "@gajae-code/tui";
|
|
3
4
|
import { getSelectListTheme, theme } from "../../modes/theme/theme";
|
|
4
|
-
import { getThinkingLevelMetadata
|
|
5
|
+
import { getThinkingLevelMetadata } from "../../thinking";
|
|
5
6
|
import { DynamicBorder } from "./dynamic-border";
|
|
6
7
|
|
|
7
8
|
const SCOPE_ITEMS = [
|
|
@@ -31,21 +32,24 @@ export class ThinkingSelectorComponent extends Container {
|
|
|
31
32
|
#selectedLevel: ThinkingLevelValue | undefined;
|
|
32
33
|
readonly #onSelect: (selection: ThinkingSelectorSelection) => void;
|
|
33
34
|
readonly #onCancel: () => void;
|
|
35
|
+
readonly #model: Model | undefined;
|
|
34
36
|
|
|
35
37
|
constructor(
|
|
36
38
|
currentLevel: ThinkingLevelValue | undefined,
|
|
37
39
|
availableLevels: ThinkingLevelValue[],
|
|
38
40
|
onSelect: (selection: ThinkingSelectorSelection) => void,
|
|
39
41
|
onCancel: () => void,
|
|
42
|
+
model?: Model,
|
|
40
43
|
) {
|
|
41
44
|
super();
|
|
42
45
|
|
|
43
46
|
this.#onSelect = onSelect;
|
|
44
47
|
this.#onCancel = onCancel;
|
|
48
|
+
this.#model = model;
|
|
45
49
|
|
|
46
50
|
const currentValue = currentLevel ?? ThinkingLevel.Off;
|
|
47
51
|
const thinkingLevels: SelectItem[] = availableLevels.map(level => {
|
|
48
|
-
const metadata = getThinkingLevelMetadata(level
|
|
52
|
+
const metadata = getThinkingLevelMetadata(level, model);
|
|
49
53
|
return level === currentValue
|
|
50
54
|
? { ...metadata, label: metadata.label + theme.fg("muted", " (current)") }
|
|
51
55
|
: metadata;
|
|
@@ -96,7 +100,7 @@ export class ThinkingSelectorComponent extends Container {
|
|
|
96
100
|
this.#renderLevelList();
|
|
97
101
|
};
|
|
98
102
|
|
|
99
|
-
const metadata = getThinkingLevelMetadata(level
|
|
103
|
+
const metadata = getThinkingLevelMetadata(level, this.#model);
|
|
100
104
|
this.detachAll();
|
|
101
105
|
this.#selectList = scopeList;
|
|
102
106
|
this.addChild(new DynamicBorder());
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type Api,
|
|
5
5
|
type AuthCredentialSelector,
|
|
6
6
|
type CredentialRemovalTarget,
|
|
7
|
+
getMiniMaxThinkingMode,
|
|
7
8
|
isSqliteCorruptionError,
|
|
8
9
|
isSqliteError,
|
|
9
10
|
type Model,
|
|
@@ -155,6 +156,7 @@ import {
|
|
|
155
156
|
MODEL_ONBOARDING_SETUP_COMMAND,
|
|
156
157
|
} from "../../setup/model-onboarding-guidance";
|
|
157
158
|
import { addApiCompatibleProvider, formatProviderSetupResult } from "../../setup/provider-onboarding";
|
|
159
|
+
import { getThinkingLevelMetadata } from "../../thinking";
|
|
158
160
|
import {
|
|
159
161
|
isConfigurableSearchProviderId,
|
|
160
162
|
isSearchProviderPreference,
|
|
@@ -1847,6 +1849,11 @@ export class SelectorController {
|
|
|
1847
1849
|
}
|
|
1848
1850
|
|
|
1849
1851
|
showEffortSelector(): void {
|
|
1852
|
+
const model = this.ctx.session.model;
|
|
1853
|
+
if (model && getMiniMaxThinkingMode(model) === "always-on") {
|
|
1854
|
+
this.ctx.showStatus("Reasoning is always on for MiniMax M2.x; disabling and effort control are unsupported.");
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1850
1857
|
const availableLevels = [
|
|
1851
1858
|
ThinkingLevel.Inherit,
|
|
1852
1859
|
ThinkingLevel.Off,
|
|
@@ -1871,9 +1878,14 @@ export class SelectorController {
|
|
|
1871
1878
|
|
|
1872
1879
|
const effectiveLevel = this.ctx.session.thinkingLevel ?? ThinkingLevel.Off;
|
|
1873
1880
|
const requestedLabel =
|
|
1874
|
-
level === ThinkingLevel.Inherit
|
|
1881
|
+
level === ThinkingLevel.Inherit
|
|
1882
|
+
? `${level} (configured default: ${configuredDefault})`
|
|
1883
|
+
: getThinkingLevelMetadata(level, model).label;
|
|
1884
|
+
const effectiveLabel = getThinkingLevelMetadata(effectiveLevel, model).label;
|
|
1875
1885
|
const clampedSuffix =
|
|
1876
|
-
effectiveLevel === levelToApply
|
|
1886
|
+
effectiveLevel === levelToApply
|
|
1887
|
+
? ""
|
|
1888
|
+
: ` Requested ${getThinkingLevelMetadata(levelToApply, model).label}; effective ${effectiveLabel}.`;
|
|
1877
1889
|
|
|
1878
1890
|
this.ctx.statusLine.invalidate();
|
|
1879
1891
|
this.ctx.updateEditorBorderColor();
|
|
@@ -1882,13 +1894,14 @@ export class SelectorController {
|
|
|
1882
1894
|
this.ctx.ui.requestRender();
|
|
1883
1895
|
const scopeLabel = persistDefault ? "Default reasoning effort" : "Reasoning effort";
|
|
1884
1896
|
this.ctx.showStatus(
|
|
1885
|
-
`${scopeLabel} set to ${requestedLabel}. Effective effort: ${
|
|
1897
|
+
`${scopeLabel} set to ${requestedLabel}. Effective effort: ${effectiveLabel}.${clampedSuffix}`,
|
|
1886
1898
|
);
|
|
1887
1899
|
},
|
|
1888
1900
|
() => {
|
|
1889
1901
|
done();
|
|
1890
1902
|
this.ctx.ui.requestRender();
|
|
1891
1903
|
},
|
|
1904
|
+
model,
|
|
1892
1905
|
);
|
|
1893
1906
|
return { component: selector, focus: selector };
|
|
1894
1907
|
});
|