Skip to content

Commit 14d36c4

Browse files
authored
Merge pull request #724 from code-yeongyu/fix/widget-only-server-fallback
fix(tui): show server fallback abort once
2 parents b4a300f + d2e9078 commit 14d36c4

5 files changed

Lines changed: 237 additions & 141 deletions

File tree

packages/coding-agent/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212

1313
### Fixed
1414

15+
- Fixed client-policy server fallback aborts rendering both a refusal-shaped assistant `Error:` row and the dedicated
16+
fallback notice box. Diagnosed aborts now leave the notice box as the single visible explanation while preserving
17+
the original message diagnostics and incremental render-cache updates
18+
([#724](https://github.com/code-yeongyu/senpi/pull/724)).
19+
1520
### Removed
1621

1722
## [2026.8.5-2] - 2026-08-05

packages/coding-agent/src/modes/interactive/changes.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
# changes
22

3+
## Server fallback abort uses one TUI notice (2026-08-05)
4+
5+
### What changed
6+
7+
- `components/assistant-render-descriptors.ts` now owns assistant transcript descriptor construction and no longer
8+
emits the provider's refusal-shaped `Error:` row when the message carries the `server_fallback_aborted` diagnostic.
9+
The dedicated interactive warning widget remains the single visible explanation and still reports the server
10+
transition plus configured-chain behavior.
11+
- `components/assistant-message.ts` delegates descriptor construction to that focused module, keeping both renderer
12+
files below the repository's 250 pure-LOC ceiling.
13+
- Assistant render signatures now include diagnostics, so a diagnostic added to the active message removes any stale
14+
error descriptor during incremental rendering.
15+
- `../../../test/assistant-message-incremental-render.test.ts` covers both initial diagnosed rendering and same-message
16+
diagnostic updates.
17+
18+
### Why
19+
20+
- The provider error and the dedicated fallback widget described the same client-policy abort back to back, making one
21+
fallback transition look like two separate failures.
22+
23+
### Why extension system couldn't handle this
24+
25+
- Assistant stop-reason descriptors and their incremental render cache are private built-in TUI behavior. An extension
26+
cannot suppress one diagnostic-specific error row while preserving the session message and dedicated warning event.
27+
28+
### Expected merge conflict zones
29+
30+
- LOW: `components/assistant-message.ts` descriptor integration and `components/assistant-render-descriptors.ts`
31+
error-tail construction.
32+
333
## Fallback transitions render as shared notice boxes (2026-08-04)
434

535
### What changed

packages/coding-agent/src/modes/interactive/components/assistant-message.ts

Lines changed: 11 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,19 @@
11
import type { AssistantMessage } from "@earendil-works/pi-ai";
22
import { type Component, Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
33
import type { MarkdownTransformer } from "../../../core/extensions/types.ts";
4-
import { formatDuration } from "../../../utils/duration.ts";
5-
import { formatProviderNativeBody, formatProviderNativeSummary } from "../../provider-native-rendering.ts";
64
import { getMarkdownTheme, theme } from "../theme/theme.ts";
5+
import { type AssistantRenderDescriptor, createAssistantRenderDescriptors } from "./assistant-render-descriptors.ts";
76
import { createMarkdownTransform } from "./markdown-transform.ts";
87
import { createBoundedRenderSignature } from "./render-signature.ts";
98

109
const OSC133_ZONE_START = "\x1b]133;A\x07";
1110
const OSC133_ZONE_END = "\x1b]133;B\x07";
1211
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
1312

14-
type MarkdownDescriptorKind = "text-md" | "thinking-md";
15-
type TextDescriptorKind = "thinking-label" | "provider-native-summary" | "provider-native-body" | "error-text";
16-
type RenderDescriptorKind = "spacer" | MarkdownDescriptorKind | TextDescriptorKind;
17-
type RenderDescriptor = { readonly kind: RenderDescriptorKind; readonly text: string };
18-
19-
const SPACER_DESCRIPTOR = { kind: "spacer", text: "" } as const satisfies RenderDescriptor;
20-
2113
function assertNever(value: never): never {
2214
throw new TypeError(`Unexpected assistant render variant: ${String(value)}`);
2315
}
2416

25-
function isVisibleContent(content: AssistantMessage["content"][number], providerNativeVisible: boolean): boolean {
26-
switch (content.type) {
27-
case "text":
28-
return Boolean(content.text.trim());
29-
case "thinking":
30-
return Boolean(content.thinking.trim());
31-
case "providerNative":
32-
return providerNativeVisible;
33-
case "toolCall":
34-
return false;
35-
default:
36-
return assertNever(content);
37-
}
38-
}
39-
4017
export class AssistantMessageComponent extends Container {
4118
private renderCache?: { readonly lines: string[]; readonly signature: string; readonly width: number };
4219
private contentContainer: Container;
@@ -47,7 +24,7 @@ export class AssistantMessageComponent extends Container {
4724
private markdownTransformers: readonly MarkdownTransformer[];
4825
private lastMessage?: AssistantMessage;
4926
private lastMessageSignature?: string;
50-
private renderDescriptors: readonly RenderDescriptor[] = [];
27+
private renderDescriptors: readonly AssistantRenderDescriptor[] = [];
5128
private hasToolCalls = false;
5229
private expanded = false;
5330
private isStreaming = false;
@@ -137,122 +114,16 @@ export class AssistantMessageComponent extends Container {
137114
this.renderCache = undefined;
138115
if (streamingChanged) this.renderDescriptors = [];
139116
this.hasToolCalls = message.content.some((content) => content.type === "toolCall");
140-
const descriptors = this.createRenderDescriptors(message);
117+
const descriptors = createAssistantRenderDescriptors(message, {
118+
expanded: this.expanded,
119+
hiddenThinkingLabel: this.hiddenThinkingLabel,
120+
hideThinkingBlock: this.hideThinkingBlock,
121+
hasToolCalls: this.hasToolCalls,
122+
});
141123
this.reconcileRenderDescriptors(descriptors);
142124
}
143125

144-
private createRenderDescriptors(message: AssistantMessage): readonly RenderDescriptor[] {
145-
const descriptors: RenderDescriptor[] = [];
146-
if (message.content.some((content) => isVisibleContent(content, true))) descriptors.push(SPACER_DESCRIPTOR);
147-
for (let i = 0; i < message.content.length; i++) {
148-
const content = message.content[i];
149-
switch (content.type) {
150-
case "text": {
151-
const text = content.text.trim();
152-
if (text) descriptors.push({ kind: "text-md", text });
153-
break;
154-
}
155-
case "thinking": {
156-
const thinkingBlocks: string[] = [];
157-
let hasTiming = false;
158-
let isDone = true;
159-
let minStart = Number.POSITIVE_INFINITY;
160-
let maxEnd = Number.NEGATIVE_INFINITY;
161-
for (; i < message.content.length; i++) {
162-
const thinkingContent = message.content[i];
163-
if (thinkingContent.type !== "thinking") break;
164-
const startedAt = thinkingContent.startedAt;
165-
if (startedAt !== undefined) {
166-
hasTiming = true;
167-
minStart = Math.min(minStart, startedAt);
168-
const endedAt = thinkingContent.endedAt;
169-
if (endedAt === undefined) {
170-
isDone = false;
171-
} else {
172-
maxEnd = Math.max(maxEnd, endedAt);
173-
}
174-
}
175-
const thinking = thinkingContent.thinking.trim();
176-
if (thinking) thinkingBlocks.push(thinking);
177-
}
178-
i--;
179-
if (thinkingBlocks.length === 0) break;
180-
if (!hasTiming) {
181-
const text = this.hideThinkingBlock
182-
? theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel))
183-
: thinkingBlocks.join("\n\n");
184-
descriptors.push({ kind: this.hideThinkingBlock ? "thinking-label" : "thinking-md", text });
185-
} else {
186-
const label = isDone
187-
? theme.italic(
188-
theme.fg("thinkingText", `Thought: ${formatDuration(Math.max(0, maxEnd - minStart))}`),
189-
)
190-
: theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel));
191-
if (this.hideThinkingBlock) {
192-
descriptors.push({ kind: "thinking-label", text: label });
193-
} else {
194-
descriptors.push(
195-
{ kind: "thinking-label", text: label },
196-
{ kind: "thinking-md", text: thinkingBlocks.join("\n\n") },
197-
);
198-
}
199-
}
200-
if (message.content.slice(i + 1).some((following) => isVisibleContent(following, false)))
201-
descriptors.push(SPACER_DESCRIPTOR);
202-
break;
203-
}
204-
case "providerNative":
205-
descriptors.push(
206-
{
207-
kind: "provider-native-summary",
208-
text: theme.fg("muted", formatProviderNativeSummary(message, content, this.expanded)),
209-
},
210-
{
211-
kind: "provider-native-body",
212-
text: theme.fg("dim", formatProviderNativeBody(content, this.expanded)),
213-
},
214-
);
215-
if (message.content.slice(i + 1).some((following) => isVisibleContent(following, true)))
216-
descriptors.push(SPACER_DESCRIPTOR);
217-
break;
218-
case "toolCall":
219-
break;
220-
default:
221-
assertNever(content);
222-
}
223-
}
224-
const addError = (text: string): void => {
225-
descriptors.push(SPACER_DESCRIPTOR, { kind: "error-text", text: theme.fg("error", text) });
226-
};
227-
switch (message.stopReason) {
228-
case "length":
229-
addError(
230-
"Error: Model stopped because it reached the maximum output token limit. The response may be incomplete.",
231-
);
232-
break;
233-
case "aborted": {
234-
if (this.hasToolCalls) break;
235-
const abortMessage =
236-
message.errorMessage && message.errorMessage !== "Request was aborted"
237-
? message.errorMessage
238-
: "Operation aborted";
239-
addError(abortMessage);
240-
break;
241-
}
242-
case "error":
243-
if (!this.hasToolCalls) addError(`Error: ${message.errorMessage || "Unknown error"}`);
244-
break;
245-
case "pending":
246-
case "stop":
247-
case "toolUse":
248-
break;
249-
default:
250-
assertNever(message.stopReason);
251-
}
252-
return descriptors;
253-
}
254-
255-
private reconcileRenderDescriptors(descriptors: readonly RenderDescriptor[]): void {
126+
private reconcileRenderDescriptors(descriptors: readonly AssistantRenderDescriptor[]): void {
256127
let divergentIndex = 0;
257128
const sharedLength = Math.min(this.renderDescriptors.length, descriptors.length);
258129
while (divergentIndex < sharedLength) {
@@ -273,7 +144,7 @@ export class AssistantMessageComponent extends Container {
273144
this.renderDescriptors = descriptors;
274145
}
275146

276-
private createRenderChild(descriptor: RenderDescriptor): Component {
147+
private createRenderChild(descriptor: AssistantRenderDescriptor): Component {
277148
switch (descriptor.kind) {
278149
case "spacer":
279150
return new Spacer(1);
@@ -312,7 +183,7 @@ export class AssistantMessageComponent extends Container {
312183
content: message.content,
313184
hiddenThinkingLabel: this.hiddenThinkingLabel,
314185
hideThinkingBlock: this.hideThinkingBlock,
315-
errorMessage: message.errorMessage,
186+
errorState: [message.diagnostics, message.errorMessage],
316187
stopReason: message.stopReason,
317188
});
318189
}

0 commit comments

Comments
 (0)