@oh-my-pi/pi-coding-agent 16.3.5 → 16.3.6
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 +18 -0
- package/dist/cli.js +3388 -3374
- package/dist/types/edit/file-snapshot-store.d.ts +4 -2
- package/dist/types/edit/renderer.d.ts +0 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
- package/dist/types/extensibility/shared-events.d.ts +8 -1
- package/dist/types/modes/components/assistant-message.d.ts +15 -10
- package/dist/types/modes/components/bash-execution.d.ts +6 -0
- package/dist/types/modes/components/eval-execution.d.ts +6 -0
- package/dist/types/modes/components/tool-execution.d.ts +3 -13
- package/dist/types/modes/components/transcript-container.d.ts +26 -28
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +15 -6
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/tools/bash.d.ts +0 -2
- package/dist/types/tools/browser/tab-supervisor.d.ts +10 -0
- package/dist/types/tools/eval-render.d.ts +0 -2
- package/dist/types/tools/renderers.d.ts +0 -20
- package/dist/types/tools/ssh.d.ts +0 -2
- package/dist/types/tools/write.d.ts +1 -1
- package/package.json +12 -12
- package/src/edit/file-snapshot-store.ts +5 -2
- package/src/edit/renderer.ts +0 -5
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/shared-events.ts +9 -1
- package/src/modes/components/assistant-message.ts +134 -82
- package/src/modes/components/bash-execution.ts +9 -0
- package/src/modes/components/chat-transcript-builder.ts +8 -4
- package/src/modes/components/eval-execution.ts +9 -0
- package/src/modes/components/tool-execution.ts +4 -50
- package/src/modes/components/transcript-container.ts +82 -432
- package/src/modes/components/tree-selector.ts +9 -3
- package/src/modes/controllers/command-controller.ts +0 -3
- package/src/modes/controllers/event-controller.ts +74 -14
- package/src/modes/controllers/extension-ui-controller.ts +0 -1
- package/src/modes/controllers/input-controller.ts +4 -10
- package/src/modes/interactive-mode.ts +12 -8
- package/src/modes/utils/transcript-render-helpers.ts +40 -13
- package/src/modes/utils/ui-helpers.ts +12 -7
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +148 -1
- package/src/session/session-context.ts +7 -0
- package/src/tools/bash.ts +0 -4
- package/src/tools/browser/tab-supervisor.ts +47 -3
- package/src/tools/eval-render.ts +0 -20
- package/src/tools/read.ts +63 -20
- package/src/tools/renderers.ts +0 -20
- package/src/tools/ssh.ts +0 -16
- package/src/tools/write.ts +13 -6
|
@@ -1029,9 +1029,6 @@ export class CommandController {
|
|
|
1029
1029
|
return;
|
|
1030
1030
|
}
|
|
1031
1031
|
const name = this.ctx.sessionManager.getSessionName()!;
|
|
1032
|
-
setSessionTerminalTitle(name, this.ctx.sessionManager.getCwd());
|
|
1033
|
-
this.ctx.statusLine.invalidate();
|
|
1034
|
-
this.ctx.updateEditorBorderColor();
|
|
1035
1032
|
this.ctx.showStatus(`Session renamed to "${name}".`);
|
|
1036
1033
|
} catch (err) {
|
|
1037
1034
|
this.ctx.showError(`Rename failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -84,6 +84,8 @@ export class EventController {
|
|
|
84
84
|
// restored when the banner clears at the next `agent_start` (see
|
|
85
85
|
// #handleMessageEnd / #handleAgentStart).
|
|
86
86
|
#pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
|
|
87
|
+
#retrySupersededAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
88
|
+
#retrySupersededAssistantQueue: AssistantMessageComponent[] = [];
|
|
87
89
|
#idleCompactionTimer?: NodeJS.Timeout;
|
|
88
90
|
#idleRecapTimer?: NodeJS.Timeout;
|
|
89
91
|
// In-flight ephemeral recap turn; aborted by #cancelIdleRecap when any
|
|
@@ -325,6 +327,42 @@ export class EventController {
|
|
|
325
327
|
this.#terminalProgressActive = false;
|
|
326
328
|
}
|
|
327
329
|
|
|
330
|
+
#trackRetrySupersededAssistantComponent(component: AssistantMessageComponent | undefined): void {
|
|
331
|
+
if (!component) return;
|
|
332
|
+
const persistenceKey = component.messagePersistenceKey();
|
|
333
|
+
if (persistenceKey) this.#retrySupersededAssistantComponents.set(persistenceKey, component);
|
|
334
|
+
if (!this.#retrySupersededAssistantQueue.includes(component)) {
|
|
335
|
+
this.#retrySupersededAssistantQueue.push(component);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#takeRetrySupersededAssistantComponent(persistenceKey: string | undefined): AssistantMessageComponent | undefined {
|
|
340
|
+
if (persistenceKey) {
|
|
341
|
+
const component = this.#retrySupersededAssistantComponents.get(persistenceKey);
|
|
342
|
+
if (component) {
|
|
343
|
+
this.#retrySupersededAssistantComponents.delete(persistenceKey);
|
|
344
|
+
this.#retrySupersededAssistantQueue = this.#retrySupersededAssistantQueue.filter(
|
|
345
|
+
item => item !== component,
|
|
346
|
+
);
|
|
347
|
+
return component;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
while (this.#retrySupersededAssistantQueue.length > 0) {
|
|
351
|
+
const component = this.#retrySupersededAssistantQueue.shift();
|
|
352
|
+
if (!component) continue;
|
|
353
|
+
const key = component.messagePersistenceKey();
|
|
354
|
+
if (key && this.#retrySupersededAssistantComponents.get(key) !== component) continue;
|
|
355
|
+
if (key) this.#retrySupersededAssistantComponents.delete(key);
|
|
356
|
+
return component;
|
|
357
|
+
}
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#clearRetrySupersededAssistantComponents(): void {
|
|
362
|
+
this.#retrySupersededAssistantComponents.clear();
|
|
363
|
+
this.#retrySupersededAssistantQueue = [];
|
|
364
|
+
}
|
|
365
|
+
|
|
328
366
|
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
|
|
329
367
|
this.#lastIntent = undefined;
|
|
330
368
|
this.#readToolCallArgs.clear();
|
|
@@ -468,7 +506,7 @@ export class EventController {
|
|
|
468
506
|
if (!components) return;
|
|
469
507
|
let removed = false;
|
|
470
508
|
for (const component of components) {
|
|
471
|
-
if (!this.ctx.chatContainer.
|
|
509
|
+
if (!this.ctx.chatContainer.isBlockUncommitted(component)) continue;
|
|
472
510
|
this.ctx.chatContainer.removeChild(component);
|
|
473
511
|
removed = true;
|
|
474
512
|
}
|
|
@@ -493,16 +531,20 @@ export class EventController {
|
|
|
493
531
|
* Resolve the pending displaceable poll block before the next block lands.
|
|
494
532
|
* A follow-up `job` call displaces it — the stale "waiting on N jobs" frame
|
|
495
533
|
* is removed so repeated polls read as one persistent poll — while anything
|
|
496
|
-
* else seals it in place as final history. Removal is
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
534
|
+
* else seals it in place as final history. Removal is gated on none of the
|
|
535
|
+
* block's rows having entered native scrollback: rows already on the tape
|
|
536
|
+
* are immutable visual history, so a scrolled-off poll seals instead of
|
|
537
|
+
* being retracted.
|
|
500
538
|
*/
|
|
501
539
|
#resolveDisplaceablePoll(nextToolName?: string): void {
|
|
502
540
|
const previous = this.#displaceablePollComponent;
|
|
503
541
|
if (!previous) return;
|
|
504
542
|
this.#displaceablePollComponent = undefined;
|
|
505
|
-
if (
|
|
543
|
+
if (
|
|
544
|
+
nextToolName === "job" &&
|
|
545
|
+
previous.isDisplaceableBlock() &&
|
|
546
|
+
this.ctx.chatContainer.isBlockUncommitted(previous)
|
|
547
|
+
) {
|
|
506
548
|
this.ctx.chatContainer.removeChild(previous);
|
|
507
549
|
}
|
|
508
550
|
// Sealing stops the waiting-poll spinner and freezes the block (for a
|
|
@@ -520,7 +562,9 @@ export class EventController {
|
|
|
520
562
|
}
|
|
521
563
|
if (previous.canBeDisplacedBy(nextToolName)) {
|
|
522
564
|
this.#displaceableTodoComponent = undefined;
|
|
523
|
-
this.ctx.chatContainer.
|
|
565
|
+
if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
|
|
566
|
+
this.ctx.chatContainer.removeChild(previous);
|
|
567
|
+
}
|
|
524
568
|
previous.seal();
|
|
525
569
|
this.ctx.ui.requestRender();
|
|
526
570
|
return;
|
|
@@ -980,7 +1024,9 @@ export class EventController {
|
|
|
980
1024
|
const previous = this.#displaceableTodoComponent;
|
|
981
1025
|
if (previous && previous !== component && previous.isDisplaceableBlock()) {
|
|
982
1026
|
this.#displaceableTodoComponent = undefined;
|
|
983
|
-
this.ctx.chatContainer.
|
|
1027
|
+
if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
|
|
1028
|
+
this.ctx.chatContainer.removeChild(previous);
|
|
1029
|
+
}
|
|
984
1030
|
previous.seal();
|
|
985
1031
|
}
|
|
986
1032
|
this.#displaceableTodoComponent = component;
|
|
@@ -1219,6 +1265,7 @@ export class EventController {
|
|
|
1219
1265
|
}
|
|
1220
1266
|
|
|
1221
1267
|
async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
|
|
1268
|
+
this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent);
|
|
1222
1269
|
this.#stopWorkingLoader();
|
|
1223
1270
|
this.ctx.statusContainer.clear();
|
|
1224
1271
|
if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) {
|
|
@@ -1246,7 +1293,21 @@ export class EventController {
|
|
|
1246
1293
|
this.ctx.retryLoader = undefined;
|
|
1247
1294
|
this.ctx.statusContainer.clear();
|
|
1248
1295
|
}
|
|
1249
|
-
if (
|
|
1296
|
+
if (event.success) {
|
|
1297
|
+
let appliedRecovered = false;
|
|
1298
|
+
for (const recovered of event.recoveredErrors ?? []) {
|
|
1299
|
+
const component = this.#takeRetrySupersededAssistantComponent(recovered.persistenceKey);
|
|
1300
|
+
if (!component) continue;
|
|
1301
|
+
component.applyRetryRecovery(recovered.retryRecovery);
|
|
1302
|
+
if (this.#pinnedErrorComponent === component) this.#pinnedErrorComponent = undefined;
|
|
1303
|
+
appliedRecovered = true;
|
|
1304
|
+
}
|
|
1305
|
+
if (appliedRecovered || (event.recoveredErrors?.length ?? 0) > 0) {
|
|
1306
|
+
this.ctx.clearPinnedError();
|
|
1307
|
+
}
|
|
1308
|
+
this.#clearRetrySupersededAssistantComponents();
|
|
1309
|
+
} else {
|
|
1310
|
+
this.#clearRetrySupersededAssistantComponents();
|
|
1250
1311
|
this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
|
|
1251
1312
|
}
|
|
1252
1313
|
this.#ensureWorkingLoaderWhileStreaming();
|
|
@@ -1268,15 +1329,14 @@ export class EventController {
|
|
|
1268
1329
|
async #handleTtsrTriggered(event: Extract<AgentSessionEvent, { type: "ttsr_triggered" }>): Promise<void> {
|
|
1269
1330
|
// Consecutive notifications (e.g. per-tool matches from one assistant
|
|
1270
1331
|
// message) merge into the previous block instead of stacking. Mutating an
|
|
1271
|
-
// existing block is only safe while
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
// live block), so the grown block still repaints.
|
|
1332
|
+
// existing block is only safe while none of its rows have entered native
|
|
1333
|
+
// scrollback — committed rows are immutable visual history and a grown
|
|
1334
|
+
// block would shift them.
|
|
1275
1335
|
const previous = this.#lastTtsrNotification;
|
|
1276
1336
|
if (
|
|
1277
1337
|
previous &&
|
|
1278
1338
|
this.ctx.chatContainer.children.at(-1) === previous &&
|
|
1279
|
-
this.ctx.chatContainer.
|
|
1339
|
+
this.ctx.chatContainer.isBlockUncommitted(previous)
|
|
1280
1340
|
) {
|
|
1281
1341
|
previous.addRules(event.rules);
|
|
1282
1342
|
this.ctx.ui.requestRender();
|
|
@@ -880,7 +880,6 @@ export class ExtensionUiController {
|
|
|
880
880
|
|
|
881
881
|
async #updateSessionName(name: string): Promise<void> {
|
|
882
882
|
await this.ctx.sessionManager.setSessionName(name, "user");
|
|
883
|
-
setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
|
|
884
883
|
}
|
|
885
884
|
|
|
886
885
|
#sendExtensionUserMessage: SendUserMessageHandler = (content, options) => {
|
|
@@ -33,7 +33,7 @@ import { EnhancedPasteController } from "../../utils/enhanced-paste";
|
|
|
33
33
|
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
|
34
34
|
import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
|
|
35
35
|
import { resizeImage } from "../../utils/image-resize";
|
|
36
|
-
import { generateSessionTitle
|
|
36
|
+
import { generateSessionTitle } from "../../utils/title-generator";
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Slash commands that may carry secrets in their arguments should never be
|
|
@@ -843,16 +843,10 @@ export class InputController {
|
|
|
843
843
|
)
|
|
844
844
|
.then(async title => {
|
|
845
845
|
// Re-check: a concurrent attempt for an earlier message may have
|
|
846
|
-
// already named the session. Don't clobber it.
|
|
846
|
+
// already named the session. Don't clobber it. Terminal title and
|
|
847
|
+
// accent updates fire from the onSessionNameChanged listener.
|
|
847
848
|
if (title && !this.ctx.sessionManager.getSessionName()) {
|
|
848
|
-
|
|
849
|
-
if (applied) {
|
|
850
|
-
setSessionTerminalTitle(
|
|
851
|
-
this.ctx.sessionManager.getSessionName()!,
|
|
852
|
-
this.ctx.sessionManager.getCwd(),
|
|
853
|
-
);
|
|
854
|
-
this.ctx.updateEditorBorderColor();
|
|
855
|
-
}
|
|
849
|
+
await this.ctx.sessionManager.setSessionName(title, "auto");
|
|
856
850
|
}
|
|
857
851
|
})
|
|
858
852
|
.catch(err => {
|
|
@@ -930,6 +930,17 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
930
930
|
pushTerminalTitle();
|
|
931
931
|
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
|
|
932
932
|
this.updateEditorBorderColor();
|
|
933
|
+
// Single side-effect point for title changes: every setSessionName caller
|
|
934
|
+
// (first-input titling, /rename, extension renames, plan seeding, replan
|
|
935
|
+
// refresh) gets the terminal title + accent updates from here. Registered
|
|
936
|
+
// before initHooksAndCustomTools/#reconcileModeFromSession/#enterPlanMode —
|
|
937
|
+
// all of which can reach setSessionName during init.
|
|
938
|
+
this.#eventBusUnsubscribers.push(
|
|
939
|
+
this.sessionManager.onSessionNameChanged(() => {
|
|
940
|
+
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
|
|
941
|
+
this.#handleSessionAccentInputsChanged();
|
|
942
|
+
}),
|
|
943
|
+
);
|
|
933
944
|
this.#syncEditorMaxHeight();
|
|
934
945
|
this.isInitialized = true;
|
|
935
946
|
this.ui.requestRender(true);
|
|
@@ -986,9 +997,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
986
997
|
this.session.subscribe(event => {
|
|
987
998
|
void this.#handleGoalSessionEvent(event);
|
|
988
999
|
}),
|
|
989
|
-
this.sessionManager.onSessionNameChanged(() => {
|
|
990
|
-
this.#handleSessionAccentInputsChanged();
|
|
991
|
-
}),
|
|
992
1000
|
onStatusLineSessionAccentChanged(() => {
|
|
993
1001
|
this.#syncStatusLineSettings();
|
|
994
1002
|
this.#handleSessionAccentInputsChanged();
|
|
@@ -2658,11 +2666,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2658
2666
|
// when the user has already chosen a name (preserveContext paths).
|
|
2659
2667
|
const seededName = humanizePlanTitle(options.title);
|
|
2660
2668
|
if (seededName && !this.sessionManager.getSessionName()) {
|
|
2661
|
-
|
|
2662
|
-
if (applied) {
|
|
2663
|
-
setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
|
|
2664
|
-
this.updateEditorBorderColor();
|
|
2665
|
-
}
|
|
2669
|
+
await this.sessionManager.setSessionName(seededName, "auto");
|
|
2666
2670
|
}
|
|
2667
2671
|
|
|
2668
2672
|
// markPlanReferenceSent fires only on the dispatch path so the synthetic
|
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
8
8
|
import { type Component, Text } from "@oh-my-pi/pi-tui";
|
|
9
9
|
import { formatBytes, formatDuration } from "@oh-my-pi/pi-utils";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type CustomMessage,
|
|
12
|
+
type FileMentionMessage,
|
|
13
|
+
resolveAbortLabel,
|
|
14
|
+
shouldRenderAbortReason,
|
|
15
|
+
} from "../../session/messages";
|
|
11
16
|
import { createIrcMessageCard } from "../../tools/irc";
|
|
17
|
+
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
|
12
18
|
import { canonicalizeMessage } from "../../utils/thinking-display";
|
|
13
19
|
import { TranscriptBlock } from "../components/transcript-container";
|
|
14
20
|
import { theme } from "../theme/theme";
|
|
@@ -138,20 +144,41 @@ export function normalizeToolArgs(args: unknown): Record<string, unknown> {
|
|
|
138
144
|
return args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
|
|
139
145
|
}
|
|
140
146
|
|
|
147
|
+
export type AssistantErrorPresentation =
|
|
148
|
+
| { kind: "none" }
|
|
149
|
+
| { kind: "full"; text: string; isError: true }
|
|
150
|
+
| { kind: "compact-recovered"; text: string; isError: false };
|
|
151
|
+
|
|
152
|
+
function sanitizeRecoveredRetryNote(note: string): string {
|
|
153
|
+
const normalized = replaceTabs(note).replace(/\s+/g, " ").trim();
|
|
154
|
+
return truncateToWidth(normalized || "retried", TRUNCATE_LENGTHS.CONTENT);
|
|
155
|
+
}
|
|
156
|
+
|
|
141
157
|
/**
|
|
142
|
-
* Resolve the
|
|
143
|
-
* Silent aborts yield no label.
|
|
158
|
+
* Resolve the turn-ending assistant error presentation, if any.
|
|
159
|
+
* Silent and user-interrupt aborts yield no label. Recovered auto-retry errors
|
|
160
|
+
* collapse to a single non-error note; terminal errors keep the full red presentation.
|
|
144
161
|
*/
|
|
145
|
-
export function
|
|
162
|
+
export function resolveAssistantErrorPresentation(
|
|
146
163
|
message: AssistantAgentMessage,
|
|
147
164
|
retryAttempt = 0,
|
|
148
|
-
):
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
165
|
+
): AssistantErrorPresentation {
|
|
166
|
+
if (message.retryRecovery?.status === "recovered") {
|
|
167
|
+
return {
|
|
168
|
+
kind: "compact-recovered",
|
|
169
|
+
text: sanitizeRecoveredRetryNote(message.retryRecovery.note),
|
|
170
|
+
isError: false,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (message.stopReason === "aborted") {
|
|
174
|
+
if (!shouldRenderAbortReason(message)) return { kind: "none" };
|
|
175
|
+
return { kind: "full", text: resolveAbortLabel(message, retryAttempt), isError: true };
|
|
176
|
+
}
|
|
177
|
+
if (message.stopReason === "error") {
|
|
178
|
+
return { kind: "full", text: message.errorMessage || "Error", isError: true };
|
|
179
|
+
}
|
|
180
|
+
if (message.errorMessage && shouldRenderAbortReason(message)) {
|
|
181
|
+
return { kind: "full", text: message.errorMessage, isError: true };
|
|
182
|
+
}
|
|
183
|
+
return { kind: "none" };
|
|
157
184
|
}
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
buildFileMentionBlock,
|
|
55
55
|
buildIrcMessageCard,
|
|
56
56
|
normalizeToolArgs,
|
|
57
|
-
|
|
57
|
+
resolveAssistantErrorPresentation,
|
|
58
58
|
} from "./transcript-render-helpers";
|
|
59
59
|
|
|
60
60
|
type TextBlock = { type: "text"; text: string };
|
|
@@ -317,7 +317,11 @@ export class UiHelpers {
|
|
|
317
317
|
const previous = waitingPoll;
|
|
318
318
|
if (!previous) return;
|
|
319
319
|
waitingPoll = null;
|
|
320
|
-
if (
|
|
320
|
+
if (
|
|
321
|
+
nextToolName === "job" &&
|
|
322
|
+
previous.isDisplaceableBlock() &&
|
|
323
|
+
this.ctx.chatContainer.isBlockUncommitted(previous)
|
|
324
|
+
) {
|
|
321
325
|
this.ctx.chatContainer.removeChild(previous);
|
|
322
326
|
}
|
|
323
327
|
// Sealing freezes the block and stops the waiting-poll spinner that
|
|
@@ -334,7 +338,9 @@ export class UiHelpers {
|
|
|
334
338
|
}
|
|
335
339
|
if (previous.canBeDisplacedBy(nextToolName)) {
|
|
336
340
|
todoSnapshot = null;
|
|
337
|
-
this.ctx.chatContainer.
|
|
341
|
+
if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
|
|
342
|
+
this.ctx.chatContainer.removeChild(previous);
|
|
343
|
+
}
|
|
338
344
|
previous.seal();
|
|
339
345
|
return;
|
|
340
346
|
}
|
|
@@ -372,10 +378,9 @@ export class UiHelpers {
|
|
|
372
378
|
readGroup?.seal();
|
|
373
379
|
readGroup = null;
|
|
374
380
|
}
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
);
|
|
381
|
+
const errorPresentation = resolveAssistantErrorPresentation(message, this.ctx.viewSession.retryAttempt);
|
|
382
|
+
const hasErrorStop = errorPresentation.kind === "full";
|
|
383
|
+
const errorMessage = hasErrorStop ? errorPresentation.text : null;
|
|
379
384
|
|
|
380
385
|
// Render tool call components
|
|
381
386
|
for (const content of message.content) {
|
package/src/sdk.ts
CHANGED
|
@@ -81,6 +81,8 @@ import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/to
|
|
|
81
81
|
import type {
|
|
82
82
|
AssistantMessage,
|
|
83
83
|
AssistantMessageEvent,
|
|
84
|
+
AssistantRetryRecovery,
|
|
85
|
+
AssistantRetryRecoveryKind,
|
|
84
86
|
Context,
|
|
85
87
|
ImageContent,
|
|
86
88
|
Message,
|
|
@@ -219,6 +221,7 @@ import { createExtensionModelQuery } from "../extensibility/extensions/model-api
|
|
|
219
221
|
import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
|
|
220
222
|
import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
|
|
221
223
|
import type { HookCommandContext } from "../extensibility/hooks/types";
|
|
224
|
+
import type { RecoveredRetryError } from "../extensibility/shared-events";
|
|
222
225
|
import type { Skill, SkillWarning } from "../extensibility/skills";
|
|
223
226
|
import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
|
|
224
227
|
import { GoalRuntime } from "../goals/runtime";
|
|
@@ -497,7 +500,13 @@ export type AgentSessionEvent =
|
|
|
497
500
|
errorMessage: string;
|
|
498
501
|
errorId?: number;
|
|
499
502
|
}
|
|
500
|
-
| {
|
|
503
|
+
| {
|
|
504
|
+
type: "auto_retry_end";
|
|
505
|
+
success: boolean;
|
|
506
|
+
attempt: number;
|
|
507
|
+
finalError?: string;
|
|
508
|
+
recoveredErrors?: RecoveredRetryError[];
|
|
509
|
+
}
|
|
501
510
|
| { type: "retry_fallback_applied"; from: string; to: string; role: string }
|
|
502
511
|
| { type: "retry_fallback_succeeded"; model: string; role: string }
|
|
503
512
|
| { type: "ttsr_triggered"; rules: Rule[] }
|
|
@@ -1425,6 +1434,13 @@ type MessageEndPersistenceSlot = {
|
|
|
1425
1434
|
persist: (persistMessage: () => void) => Promise<void>;
|
|
1426
1435
|
release: () => void;
|
|
1427
1436
|
};
|
|
1437
|
+
type PendingRecoveredRetryError = {
|
|
1438
|
+
entryId: string;
|
|
1439
|
+
persistenceKey: string;
|
|
1440
|
+
recovery: AssistantRetryRecoveryKind;
|
|
1441
|
+
attempt: number;
|
|
1442
|
+
note: string;
|
|
1443
|
+
};
|
|
1428
1444
|
|
|
1429
1445
|
type PostPromptSkipReason = "aborted" | "stale-generation";
|
|
1430
1446
|
|
|
@@ -1581,6 +1597,7 @@ export class AgentSession {
|
|
|
1581
1597
|
#retryPromise: Promise<void> | undefined = undefined;
|
|
1582
1598
|
#retryResolve: (() => void) | undefined = undefined;
|
|
1583
1599
|
#activeRetryFallback: ActiveRetryFallbackState | undefined = undefined;
|
|
1600
|
+
#pendingRecoveredRetryErrors: PendingRecoveredRetryError[] = [];
|
|
1584
1601
|
// Todo completion reminder state
|
|
1585
1602
|
#todoReminderCount = 0;
|
|
1586
1603
|
/**
|
|
@@ -3663,11 +3680,14 @@ export class AgentSession {
|
|
|
3663
3680
|
role: this.#activeRetryFallback.role,
|
|
3664
3681
|
});
|
|
3665
3682
|
}
|
|
3683
|
+
const recoveredErrors = await this.#markPendingRecoveredRetryErrors(assistantMsg);
|
|
3666
3684
|
await this.#emitSessionEvent({
|
|
3667
3685
|
type: "auto_retry_end",
|
|
3668
3686
|
success: true,
|
|
3669
3687
|
attempt: this.#retryAttempt,
|
|
3688
|
+
recoveredErrors,
|
|
3670
3689
|
});
|
|
3690
|
+
this.#clearPendingRecoveredRetryErrors();
|
|
3671
3691
|
this.#retryAttempt = 0;
|
|
3672
3692
|
}
|
|
3673
3693
|
if (assistantMsg.provider === "opencode-go") {
|
|
@@ -5337,6 +5357,7 @@ export class AgentSession {
|
|
|
5337
5357
|
success: event.success,
|
|
5338
5358
|
attempt: event.attempt,
|
|
5339
5359
|
finalError: event.finalError,
|
|
5360
|
+
recoveredErrors: event.recoveredErrors,
|
|
5340
5361
|
});
|
|
5341
5362
|
} else if (event.type === "ttsr_triggered") {
|
|
5342
5363
|
await this.#extensionRunner.emit({ type: "ttsr_triggered", rules: event.rules });
|
|
@@ -10386,6 +10407,126 @@ export class AgentSession {
|
|
|
10386
10407
|
return undefined;
|
|
10387
10408
|
}
|
|
10388
10409
|
|
|
10410
|
+
#clearPendingRecoveredRetryErrors(): void {
|
|
10411
|
+
this.#pendingRecoveredRetryErrors = [];
|
|
10412
|
+
}
|
|
10413
|
+
|
|
10414
|
+
#retryRecoveryKind(
|
|
10415
|
+
id: number,
|
|
10416
|
+
switchedCredential: boolean,
|
|
10417
|
+
switchedModel: boolean,
|
|
10418
|
+
delayMs: number,
|
|
10419
|
+
): AssistantRetryRecoveryKind {
|
|
10420
|
+
if (switchedCredential) return "credential";
|
|
10421
|
+
if (switchedModel) return "model";
|
|
10422
|
+
if (AIError.is(id, AIError.Flag.UsageLimit) && delayMs > 0) return "wait";
|
|
10423
|
+
return "plain";
|
|
10424
|
+
}
|
|
10425
|
+
|
|
10426
|
+
#retryRecoveryNote(recovery: AssistantRetryRecoveryKind, rateLimited: boolean): string {
|
|
10427
|
+
const parts: string[] = [];
|
|
10428
|
+
if (rateLimited) {
|
|
10429
|
+
parts.push("rate-limited");
|
|
10430
|
+
} else if (recovery === "plain") {
|
|
10431
|
+
parts.push("error");
|
|
10432
|
+
}
|
|
10433
|
+
if (recovery === "credential") {
|
|
10434
|
+
parts.push("switched account");
|
|
10435
|
+
} else if (recovery === "model") {
|
|
10436
|
+
parts.push("switched model");
|
|
10437
|
+
} else if (recovery === "wait") {
|
|
10438
|
+
parts.push("waited");
|
|
10439
|
+
}
|
|
10440
|
+
parts.push("retried");
|
|
10441
|
+
return parts.join("; ");
|
|
10442
|
+
}
|
|
10443
|
+
|
|
10444
|
+
async #recordPendingRecoveredRetryError(
|
|
10445
|
+
message: AssistantMessage,
|
|
10446
|
+
id: number,
|
|
10447
|
+
options: { switchedCredential: boolean; switchedModel: boolean; delayMs: number },
|
|
10448
|
+
): Promise<void> {
|
|
10449
|
+
await this.#waitForSessionMessagePersistence(message);
|
|
10450
|
+
const persistenceKey = sessionMessagePersistenceKey(message);
|
|
10451
|
+
if (!persistenceKey) return;
|
|
10452
|
+
let branchEntry: SessionEntry | undefined;
|
|
10453
|
+
for (const entry of this.sessionManager.getBranch().slice().reverse()) {
|
|
10454
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
10455
|
+
if (sessionMessagePersistenceKey(entry.message) !== persistenceKey) continue;
|
|
10456
|
+
if (!sameMessageContent(entry.message, message) && !this.#isSameAssistantMessage(entry.message, message)) {
|
|
10457
|
+
continue;
|
|
10458
|
+
}
|
|
10459
|
+
branchEntry = entry;
|
|
10460
|
+
break;
|
|
10461
|
+
}
|
|
10462
|
+
if (!branchEntry) return;
|
|
10463
|
+
if (this.#pendingRecoveredRetryErrors.some(error => error.entryId === branchEntry.id)) return;
|
|
10464
|
+
const rateLimited = AIError.is(id, AIError.Flag.UsageLimit);
|
|
10465
|
+
const recovery = this.#retryRecoveryKind(id, options.switchedCredential, options.switchedModel, options.delayMs);
|
|
10466
|
+
const note = this.#retryRecoveryNote(recovery, rateLimited);
|
|
10467
|
+
this.#pendingRecoveredRetryErrors.push({
|
|
10468
|
+
entryId: branchEntry.id,
|
|
10469
|
+
persistenceKey,
|
|
10470
|
+
recovery,
|
|
10471
|
+
attempt: this.#retryAttempt,
|
|
10472
|
+
note,
|
|
10473
|
+
});
|
|
10474
|
+
}
|
|
10475
|
+
|
|
10476
|
+
async #markPendingRecoveredRetryErrors(supersedingMessage: AssistantMessage): Promise<RecoveredRetryError[]> {
|
|
10477
|
+
if (this.#pendingRecoveredRetryErrors.length === 0) return [];
|
|
10478
|
+
const branch = this.sessionManager.getBranch();
|
|
10479
|
+
const branchById = new Map<string, SessionEntry>();
|
|
10480
|
+
for (const entry of branch) {
|
|
10481
|
+
branchById.set(entry.id, entry);
|
|
10482
|
+
}
|
|
10483
|
+
const recoveredAt = new Date().toISOString();
|
|
10484
|
+
const supersededBy: AssistantRetryRecovery["supersededBy"] = {
|
|
10485
|
+
timestamp: supersedingMessage.timestamp,
|
|
10486
|
+
provider: supersedingMessage.provider,
|
|
10487
|
+
model: supersedingMessage.model,
|
|
10488
|
+
};
|
|
10489
|
+
if (supersedingMessage.responseId) {
|
|
10490
|
+
supersededBy.responseId = supersedingMessage.responseId;
|
|
10491
|
+
}
|
|
10492
|
+
const recoveredErrors: RecoveredRetryError[] = [];
|
|
10493
|
+
for (const pending of this.#pendingRecoveredRetryErrors) {
|
|
10494
|
+
let entry = branchById.get(pending.entryId);
|
|
10495
|
+
if (entry?.type !== "message" || entry.message.role !== "assistant") {
|
|
10496
|
+
entry = branch
|
|
10497
|
+
.slice()
|
|
10498
|
+
.reverse()
|
|
10499
|
+
.find(
|
|
10500
|
+
candidate =>
|
|
10501
|
+
candidate.type === "message" &&
|
|
10502
|
+
candidate.message.role === "assistant" &&
|
|
10503
|
+
sessionMessagePersistenceKey(candidate.message) === pending.persistenceKey,
|
|
10504
|
+
);
|
|
10505
|
+
}
|
|
10506
|
+
if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
|
|
10507
|
+
const retryRecovery: AssistantRetryRecovery = {
|
|
10508
|
+
kind: "auto-retry",
|
|
10509
|
+
status: "recovered",
|
|
10510
|
+
attempt: pending.attempt,
|
|
10511
|
+
recoveredAt,
|
|
10512
|
+
recovery: pending.recovery,
|
|
10513
|
+
note: pending.note,
|
|
10514
|
+
supersededBy,
|
|
10515
|
+
};
|
|
10516
|
+
entry.message.retryRecovery = retryRecovery;
|
|
10517
|
+
recoveredErrors.push({
|
|
10518
|
+
entryId: entry.id,
|
|
10519
|
+
persistenceKey: pending.persistenceKey,
|
|
10520
|
+
note: retryRecovery.note,
|
|
10521
|
+
retryRecovery,
|
|
10522
|
+
});
|
|
10523
|
+
}
|
|
10524
|
+
if (recoveredErrors.length > 0) {
|
|
10525
|
+
await this.sessionManager.rewriteEntries();
|
|
10526
|
+
}
|
|
10527
|
+
return recoveredErrors;
|
|
10528
|
+
}
|
|
10529
|
+
|
|
10389
10530
|
async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
|
|
10390
10531
|
if (!this.#isEmptyAssistantStop(assistantMessage)) {
|
|
10391
10532
|
this.#emptyStopRetryCount = 0;
|
|
@@ -10406,6 +10547,7 @@ export class AgentSession {
|
|
|
10406
10547
|
attempt: this.#retryAttempt,
|
|
10407
10548
|
finalError: "Assistant returned empty stop after retry cap",
|
|
10408
10549
|
});
|
|
10550
|
+
this.#clearPendingRecoveredRetryErrors();
|
|
10409
10551
|
this.#retryAttempt = 0;
|
|
10410
10552
|
}
|
|
10411
10553
|
this.#resolveRetry();
|
|
@@ -13265,6 +13407,7 @@ export class AgentSession {
|
|
|
13265
13407
|
attempt: this.#retryAttempt - 1,
|
|
13266
13408
|
finalError: message.errorMessage,
|
|
13267
13409
|
});
|
|
13410
|
+
this.#clearPendingRecoveredRetryErrors();
|
|
13268
13411
|
this.#retryAttempt = 0;
|
|
13269
13412
|
this.#resolveRetry(); // Resolve so waitForRetry() completes
|
|
13270
13413
|
return false;
|
|
@@ -13384,10 +13527,13 @@ export class AgentSession {
|
|
|
13384
13527
|
attempt,
|
|
13385
13528
|
finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${maxDelayMs}ms). Original error: ${errorMessage}`,
|
|
13386
13529
|
});
|
|
13530
|
+
this.#clearPendingRecoveredRetryErrors();
|
|
13387
13531
|
this.#resolveRetry();
|
|
13388
13532
|
return false;
|
|
13389
13533
|
}
|
|
13390
13534
|
|
|
13535
|
+
await this.#recordPendingRecoveredRetryError(message, id, { switchedCredential, switchedModel, delayMs });
|
|
13536
|
+
|
|
13391
13537
|
await this.#emitSessionEvent({
|
|
13392
13538
|
type: "auto_retry_start",
|
|
13393
13539
|
attempt: this.#retryAttempt,
|
|
@@ -13425,6 +13571,7 @@ export class AgentSession {
|
|
|
13425
13571
|
attempt,
|
|
13426
13572
|
finalError: "Retry cancelled",
|
|
13427
13573
|
});
|
|
13574
|
+
this.#clearPendingRecoveredRetryErrors();
|
|
13428
13575
|
this.#resolveRetry();
|
|
13429
13576
|
return false;
|
|
13430
13577
|
}
|
|
@@ -251,6 +251,13 @@ export function buildSessionContext(
|
|
|
251
251
|
const appendMessage = (entry: SessionEntry) => {
|
|
252
252
|
handleEntryResetTracking(entry);
|
|
253
253
|
if (entry.type === "message") {
|
|
254
|
+
if (
|
|
255
|
+
!options?.transcript &&
|
|
256
|
+
entry.message.role === "assistant" &&
|
|
257
|
+
entry.message.retryRecovery?.status === "recovered"
|
|
258
|
+
) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
254
261
|
pushMessage(entry.message);
|
|
255
262
|
} else if (entry.type === "custom_message") {
|
|
256
263
|
pushMessage(
|
package/src/tools/bash.ts
CHANGED
|
@@ -1409,10 +1409,6 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
|
|
|
1409
1409
|
},
|
|
1410
1410
|
mergeCallAndResult: true,
|
|
1411
1411
|
inline: true,
|
|
1412
|
-
// Collapsed pending preview caps the command to a viewport-sized tail
|
|
1413
|
-
// window that shifts while args stream. Expanded output is top-anchored
|
|
1414
|
-
// enough for the transcript to commit its settled prefix.
|
|
1415
|
-
provisionalPendingPreview: "collapsed",
|
|
1416
1412
|
};
|
|
1417
1413
|
}
|
|
1418
1414
|
|