Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
633f08e
chore: 启动 TUI viewport 布局修复
lyfmt Jul 14, 2026
53800e3
test(tui): 固定消息输入区域高度预算
lyfmt Jul 14, 2026
fd21984
refactor(tui): 集中计算帧区域高度
lyfmt Jul 14, 2026
e659a9d
fix(tui): 用消息 viewport 约束最终帧
lyfmt Jul 14, 2026
a0666a4
feat(tui): 支持消息视口按页滚动
lyfmt Jul 14, 2026
8708d35
fix(tui): resize 时保留消息滚动状态
lyfmt Jul 14, 2026
19dfbd2
test(tui): 固定工具进度有界尾部语义
lyfmt Jul 15, 2026
d06b504
fix(tui): 限制运行中工具输出尾部
lyfmt Jul 15, 2026
f9d058e
feat(tui): 增加可合并重绘调度器
lyfmt Jul 15, 2026
514ca56
fix(tui): 合并事件触发的终端重绘
lyfmt Jul 15, 2026
98fffc3
test(tui): 增加大工具输出性能门槛
lyfmt Jul 15, 2026
e7becb5
test(tool): 固定有限单行事件摘要契约
lyfmt Jul 15, 2026
5623b3f
fix(tool): 生成有限单行生命周期摘要
lyfmt Jul 15, 2026
faac33e
fix(tool): 收紧内建与 MCP 调用展示
lyfmt Jul 15, 2026
d775614
test(tui): 固定工具折叠与展开行预算
lyfmt Jul 15, 2026
36ba1e4
fix(tui): 统一工具折叠与展开预算
lyfmt Jul 15, 2026
f1bdc84
fix(tool): 收紧剩余通用输入摘要
lyfmt Jul 15, 2026
d56c0ac
fix(tui): 窄宽度保留工具省略标记
lyfmt Jul 15, 2026
c91786b
test(ai): 复现 AssistantStart 阻断 provider 回退
lyfmt Jul 15, 2026
3e42c21
fix(ai): 仅用可见输出阻止 provider 回退
lyfmt Jul 15, 2026
8b33ca4
feat(contracts): 增加 provider fallback 生命周期事件
lyfmt Jul 15, 2026
efd2ba4
feat(ai): 发布 provider attempt 回退通知
lyfmt Jul 15, 2026
4347e5c
feat(agent): 映射 provider fallback 生命周期
lyfmt Jul 15, 2026
86beaca
feat(tui): 展示 provider fallback 状态
lyfmt Jul 15, 2026
a195eb6
docs(architecture): 记录 provider fallback 事件流
lyfmt Jul 15, 2026
cece49d
fix(ai): 保留 provider fallback 通知身份
lyfmt Jul 15, 2026
1a5b33d
test(tui): 固定恢复工具调用结果关联
lyfmt Jul 15, 2026
0e2f6af
fix(tui): 关联恢复 transcript 的工具结果
lyfmt Jul 15, 2026
f0e79a4
refactor(tui): 统一实时与恢复工具投影
lyfmt Jul 15, 2026
2fe0d58
test(tui): 验证恢复首帧工具完成状态
lyfmt Jul 15, 2026
020364b
fix(tui): isolate rendering in alternate screen
lyfmt Jul 15, 2026
0a242e3
feat(tui): partition history from live transcript
lyfmt Jul 15, 2026
c800262
fix(tui): retain bounded history projection
lyfmt Jul 15, 2026
14ecd9e
feat(tui): allocate history and live content regions
lyfmt Jul 15, 2026
a21b56a
feat(tui): render bounded history beside live output
lyfmt Jul 15, 2026
60fbf49
refactor(tui): render fixed alternate-screen frames
lyfmt Jul 15, 2026
0927a99
fix(tui): preserve bounded history across live redraws
lyfmt Jul 15, 2026
7feb32c
test(tui): verify alternate-screen history split
lyfmt Jul 15, 2026
aa3466a
docs(tui): record alternate-screen rendering invariants
lyfmt Jul 15, 2026
b5a0d19
fix(tui): reconcile terminal size before redraw
lyfmt Jul 15, 2026
1e1e14c
fix(tui): render leading streaming delta
lyfmt Jul 15, 2026
0c9dadf
fix(tui): route mouse wheel to transcript history
lyfmt Jul 15, 2026
e094b5f
test(tui): cover streaming and wheel interaction in PTY
lyfmt Jul 15, 2026
8826686
refactor(tui): run terminal session on normal screen
lyfmt Jul 15, 2026
819f93a
feat(tui): track inline terminal viewport
lyfmt Jul 15, 2026
71df9da
feat(tui): track committed transcript prefix
lyfmt Jul 15, 2026
6c02e3b
refactor(tui): split transcript from mutable surface
lyfmt Jul 15, 2026
60fc799
feat(tui): render inline viewport above terminal scrollback
lyfmt Jul 15, 2026
1287436
refactor(tui): coordinate transcript commits and inline surface
lyfmt Jul 15, 2026
5466efe
refactor(tui): return scrollback controls to terminal
lyfmt Jul 15, 2026
c76f643
test(tui): lock inline scrollback commit protocol
lyfmt Jul 15, 2026
47efc9a
fix(tui): preserve scrollback across resize and close
lyfmt Jul 16, 2026
3541623
test(tui): verify native scrollback in tmux pty
lyfmt Jul 16, 2026
67112c7
docs(tui): record inline scrollback invariants
lyfmt Jul 16, 2026
19bcdc7
feat(tui): restore startup banner artwork (task 1/4)
lyfmt Jul 17, 2026
7df215c
feat(tui): commit startup banner to native scrollback (task 2/4)
lyfmt Jul 17, 2026
20332da
fix(tui): show startup banner in inline terminal (task 3/4)
lyfmt Jul 17, 2026
5c66220
docs(tui): record startup banner invariant (task 4/4)
lyfmt Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .codex/skills/lypi-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ The root `pom.xml` defines these Maven modules:
- Transports adapt input/output and display. They should not own durable session or tool state.
- Session history is append-only JSONL; branch movement changes the leaf, not old entries.
- Permission runtime changes are represented by session entries and replayed into `SessionContext`; do not mutate historical entries to change permission state.
- Tool calls, permission decisions, retry, compaction and UI updates flow through contract events where possible.
- Tool calls, permission decisions, retry, provider fallback, compaction and UI updates flow through contract events where possible.
- Provider retry and fallback notices are stream control events: agent core maps them to lifecycle events, TUI projects transient state, and neither enters the durable transcript.
- Permission request/decision events expose approval kind, available decisions and additional permission metadata for TUI/headless rendering.
- Memory consolidation is driven by `TurnEndEvent` after the main turn completes; `DefaultTurnExecutor` must not synchronously call legacy `MemoryExtractionWorker` on the user-facing path.
- `TurnEndEvent.leafEntryId` is the stable fork point for background consolidation. Runtime listeners must use this event field instead of the mutable `SessionManagerPort.currentView().leafId()`.
Expand Down
111 changes: 111 additions & 0 deletions .codex/skills/lypi-transport-tui/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
---
name: lypi-transport-tui
description: Use when changing ly-pi terminal UI, JLine input, TUI event reduction, rendering, slash commands, overlays, permission prompts, diff display, file mentions, or Skill mentions.
---

# ly-pi Transport TUI

## Core Rule

The TUI adapts input and renders state. Durable behavior belongs in session, agent core, tool runtime, resource runtime or contracts.

## Main Responsibilities

- Open a JLine terminal transport and attach to `AgentEvent`.
- Convert semantic events into `TuiViewModel`.
- Route user input, slash commands and compact commands.
- Parse file and Skill mentions for user input.
- Render message, thinking, tool, error, permission, diff and status views.
- Forward submissions to `AgentCorePort` through `TurnRequest`.

## Key Code

- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/RuntimeTuiSubmitHandler.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptPartitioner.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptCommitLedger.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderBatch.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineViewport.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiStartupBanner.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/SlashCommandRouter.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/PermissionOverlay.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/DiffOverlay.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/SkillMentionParser.java`
- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/FileMentionPicker.java`

## Event Flow

1. `JLineTuiTransport.open()` enters normal-screen interactive modes, probes the cursor with a timeout and replays non-CPR input.
2. `RuntimeTuiSubmitHandler.submitUserInput()` routes slash commands and resolves Skill mentions.
3. Normal input becomes `TurnRequest` and executes asynchronously.
4. `TuiEventReducer.reduce()` projects message, tool, permission, retry, compact, interrupt and session events.
5. `TuiTranscriptPartitioner` splits the stable prefix from the live tail without storing durable state.
6. `TuiTranscriptCommitLedger` emits each stable block once per `(sessionId, leafId)` projection.
7. `TuiRenderer` renders new committed blocks separately from the bounded live/input/overlay/status surface.
8. On the first real-terminal frame, `InlineTerminalRenderer` prepends `TuiStartupBanner` as a one-time native-scrollback prelude.
9. `InlineTerminalRenderer` inserts committed lines above the viewport and diffs only the mutable surface in one synchronized terminal transaction.

## Invariants

- Reducers should not invent durable transcript content. They project events into display state.
- The first streaming, active, pending or running block starts the live tail; only the stable prefix can be committed.
- A stable block ID is committed at most once per projection key, including after transient stable/live regressions.
- Committed transcript is terminal-native scrollback. There is no application-side 500-line history window or scroll offset.
- The startup banner is terminal decoration, not transcript: render it once per transport before initial committed history, then preserve it across redraw, resize, projection changes and close.
- PageUp, PageDown and mouse wheel never enter an application history model or mutate the draft; scrollback remains terminal/tmux-owned.
- Historical tools always use their completed collapsed rendering. Ctrl+O affects tools in the live region only.
- The mutable surface is bounded by `terminalHeight - 1` and contains only live content, input, overlays and status.
- `TerminalSession` must not enable 1049, 1000 or 1006; close and partial failure restore raw mode, cursor and interactive modes.
- Resize probes the post-reflow cursor, preserves concurrent input, never replays committed blocks and redraws only the mutable surface.
- Finalizing a streaming block commits its final text once in the same transaction that removes it from the surface.
- Close clears the mutable surface while preserving committed transcript and the shell cursor handoff position.
- A projection-key change opens a new commit epoch without clearing earlier terminal scrollback.
- Slash commands that change durable state should route through session/runtime contracts, not UI-only variables.
- Permission prompts display decision context and should clear on decision or interrupt.
- Skill mentions are resolved from the current `SkillIndex`, then passed into `TurnRequest`.
- Long-running work should not hold the UI lock while executing core logic.

## Tests To Check

- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalCursorProbeTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineViewportTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiStartupBannerTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptCommitLedgerTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportConcurrencyTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/RuntimeTuiSubmitHandlerTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/PermissionOverlayTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/DiffOverlayTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/FileMentionPickerTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/SlashCommandRouterTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/SkillMentionParserTest.java`
- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java`
- `lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh`
- `lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh`
- `lypi-transport-tui/src/test/resources/run-tui-smoke.sh`

## Before Editing

- Decide whether the change is view projection, rendering, input handling or runtime behavior.
- If it changes data shape, update `lypi-contracts` view model records first.
- Prefer event-driven state updates over querying internals from the UI layer.
- For rendering changes, test narrow width, multiline input, streaming finalization, resize reflow and surface-height bounds.
- For terminal lifecycle changes, test partial open failure and real tmux server/client PTYs before relying on control-sequence unit tests alone.
- For slash commands, test consumed, prompt-rewrite and state-change paths.

After using this Skill, reverse-check that the change did not move business state into TUI classes.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import cn.lypi.contracts.model.AssistantError;
import cn.lypi.contracts.model.AssistantStart;
import cn.lypi.contracts.model.AssistantStreamEvent;
import cn.lypi.contracts.model.ProviderFallbackNotice;
import cn.lypi.contracts.model.ProviderRetryNotice;
import cn.lypi.contracts.model.TextDelta;
import cn.lypi.contracts.model.ThinkingDelta;
Expand Down Expand Up @@ -59,6 +60,8 @@ public void accept(AssistantStreamEvent event) {
}
case ProviderRetryNotice ignored -> {
}
case ProviderFallbackNotice ignored -> {
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
import cn.lypi.contracts.context.ToolCallContentBlock;
import cn.lypi.contracts.event.ErrorEvent;
import cn.lypi.contracts.event.TurnStartEvent;
import cn.lypi.contracts.model.AssistantDone;
import cn.lypi.contracts.model.AssistantEventStream;
import cn.lypi.contracts.model.AssistantError;
import cn.lypi.contracts.model.AssistantStart;
import cn.lypi.contracts.model.AssistantStreamEvent;
import cn.lypi.contracts.model.ProviderFallbackNotice;
import cn.lypi.contracts.model.ProviderRetryNotice;
import cn.lypi.contracts.model.TextDelta;
import cn.lypi.contracts.model.ThinkingDelta;
Expand Down Expand Up @@ -262,7 +264,7 @@ private AgentMessage runModel(TurnRequest request, ContextSnapshot context) {
String sessionId = request.sessionId();
final boolean[] assistantStarted = {false};
final MessageKind[] startedKind = {MessageKind.TEXT};
Optional<ProviderRetryNotice> pendingRetry = Optional.empty();
ProviderAttemptLifecycles providerLifecycles = new ProviderAttemptLifecycles(sessionId);
ProviderConversationStateHolder providerConversationState = new ProviderConversationStateHolder();
try (AssistantEventStream stream = ports.aiProvider().stream(
context,
Expand All @@ -271,18 +273,21 @@ private AgentMessage runModel(TurnRequest request, ContextSnapshot context) {
request.abortSignal()
)) {
for (AssistantStreamEvent event : stream) {
if (event instanceof ProviderRetryNotice notice) {
pendingRetry.ifPresent(previous -> eventPublisher.publishRetryEnd(request.sessionId(), previous, false));
eventPublisher.publishRetryStart(request.sessionId(), notice);
pendingRetry = Optional.of(notice);
if (event instanceof ProviderFallbackNotice notice) {
providerLifecycles.startFallback(notice);
continue;
}
if (pendingRetry.isPresent()) {
ProviderRetryNotice notice = pendingRetry.get();
eventPublisher.publishRetryEnd(request.sessionId(), notice, !(event instanceof cn.lypi.contracts.model.AssistantError));
pendingRetry = Optional.empty();
if (event instanceof ProviderRetryNotice notice) {
providerLifecycles.startRetry(notice);
continue;
}
accumulator.accept(event);
boolean abortRequested = request.abortSignal().aborted();
if (abortRequested || event instanceof AssistantError) {
providerLifecycles.close(false);
} else if (completesProviderLifecycleSuccessfully(event)) {
providerLifecycles.close(true);
}
if (event instanceof TextDelta delta) {
String messageId = currentAssistantId(accumulator);
ensureAssistantMessageStart(sessionId, messageId, MessageKind.TEXT, assistantStarted, startedKind);
Expand Down Expand Up @@ -339,23 +344,21 @@ private AgentMessage runModel(TurnRequest request, ContextSnapshot context) {
Map.of("errorId", error.errorId())
));
}
if (request.abortSignal().aborted()) {
pendingRetry.ifPresent(notice -> eventPublisher.publishRetryEnd(request.sessionId(), notice, false));
pendingRetry = Optional.empty();
if (abortRequested) {
break;
}
}
providerConversationState.value = stream.result().providerConversationState();
} catch (RuntimeException failure) {
pendingRetry.ifPresent(notice -> eventPublisher.publishRetryEnd(sessionId, notice, false));
providerLifecycles.close(false);
accumulator.messageId()
.ifPresent(messageId -> {
ensureAssistantMessageStart(sessionId, messageId, MessageKind.TEXT, assistantStarted, startedKind);
eventPublisher.publishAssistantMessageEnd(sessionId, messageId, startedKind[0]);
});
throw failure;
}
pendingRetry.ifPresent(notice -> eventPublisher.publishRetryEnd(request.sessionId(), notice, false));
providerLifecycles.close(false);

AgentMessage message = accumulator.toMessage(
ids.newMessageId(),
Expand All @@ -366,6 +369,16 @@ private AgentMessage runModel(TurnRequest request, ContextSnapshot context) {
return message;
}

private boolean completesProviderLifecycleSuccessfully(AssistantStreamEvent event) {
return switch (event) {
case TextDelta delta -> delta.text() != null && !delta.text().isEmpty();
case ThinkingDelta delta -> delta.text() != null && !delta.text().isEmpty();
case ToolCallDelta ignored -> true;
case AssistantDone ignored -> true;
default -> false;
};
}

private List<ToolResult<?>> executeTools(
String sessionId,
String turnId,
Expand Down Expand Up @@ -437,4 +450,42 @@ private void ensureAssistantMessageStart(
private static final class ProviderConversationStateHolder {
private Optional<cn.lypi.contracts.model.ProviderConversationState> value = Optional.empty();
}

private final class ProviderAttemptLifecycles {
private final String sessionId;
private Optional<ProviderRetryNotice> pendingRetry = Optional.empty();
private Optional<ProviderFallbackNotice> pendingFallback = Optional.empty();

private ProviderAttemptLifecycles(String sessionId) {
this.sessionId = sessionId;
}

private void startRetry(ProviderRetryNotice notice) {
closeRetry(false);
eventPublisher.publishRetryStart(sessionId, notice);
pendingRetry = Optional.of(notice);
}

private void startFallback(ProviderFallbackNotice notice) {
closeRetry(false);
closeFallback(false);
eventPublisher.publishProviderFallbackStart(sessionId, notice);
pendingFallback = Optional.of(notice);
}

private void close(boolean success) {
closeRetry(success);
closeFallback(success);
}

private void closeRetry(boolean success) {
pendingRetry.ifPresent(notice -> eventPublisher.publishRetryEnd(sessionId, notice, success));
pendingRetry = Optional.empty();
}

private void closeFallback(boolean success) {
pendingFallback.ifPresent(notice -> eventPublisher.publishProviderFallbackEnd(sessionId, notice, success));
pendingFallback = Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
import cn.lypi.contracts.event.MessageDeltaEvent;
import cn.lypi.contracts.event.MessageEndEvent;
import cn.lypi.contracts.event.MessageStartEvent;
import cn.lypi.contracts.event.ProviderFallbackEndEvent;
import cn.lypi.contracts.event.ProviderFallbackStartEvent;
import cn.lypi.contracts.event.RetryEndEvent;
import cn.lypi.contracts.event.RetryStartEvent;
import cn.lypi.contracts.event.TurnEndEvent;
import cn.lypi.contracts.model.ProviderFallbackNotice;
import cn.lypi.contracts.model.ProviderRetryNotice;
import cn.lypi.contracts.model.ToolCallDelta;
import java.time.Clock;
Expand Down Expand Up @@ -69,6 +72,25 @@ void publishRetryEnd(String sessionId, ProviderRetryNotice notice, boolean succe
));
}

void publishProviderFallbackStart(String sessionId, ProviderFallbackNotice notice) {
eventBus.publish(new ProviderFallbackStartEvent(
sessionId,
notice.fromMode(),
notice.toMode(),
notice.reason(),
clock.instant()
));
}

void publishProviderFallbackEnd(String sessionId, ProviderFallbackNotice notice, boolean success) {
eventBus.publish(new ProviderFallbackEndEvent(
sessionId,
notice.toMode(),
success,
clock.instant()
));
}

void publishAssistantMessageStart(String sessionId, String messageId, MessageKind kind) {
eventBus.publish(new MessageStartEvent(
sessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import cn.lypi.contracts.context.MessageKind;
import cn.lypi.contracts.model.AssistantDone;
import cn.lypi.contracts.model.AssistantStart;
import cn.lypi.contracts.model.ProviderFallbackNotice;
import cn.lypi.contracts.model.TextDelta;
import cn.lypi.contracts.model.ThinkingDelta;
import cn.lypi.contracts.model.TokenUsage;
Expand Down Expand Up @@ -118,4 +119,32 @@ void returnsPartialAssistantWhenAbortedBeforeDone() {
assertThat(message.content().getFirst().text()).isEqualTo("partial");
assertThat(message.stopReason()).contains("aborted");
}

@Test
void ignoresProviderFallbackNoticeWhenBuildingAssistantMessage() {
AssistantStreamAccumulator accumulator = new AssistantStreamAccumulator(clock);

accumulator.accept(new AssistantStart("msg-a"));
accumulator.accept(new ProviderFallbackNotice(
"openai",
1,
2,
"responses/websocket",
"responses/sse",
"fallback_candidate",
"provider.fallback_candidate",
"WebSocket handshake failed"
));
accumulator.accept(new TextDelta("fallback ok"));
accumulator.accept(new AssistantDone(Optional.empty(), Optional.of("end_turn")));

AgentMessage message = accumulator.toMessage("fallback", false);

assertThat(message.id()).isEqualTo("msg-a");
assertThat(message.content()).singleElement().satisfies(block -> {
assertThat(block.kind()).isEqualTo(ContentBlockKind.TEXT);
assertThat(block.text()).isEqualTo("fallback ok");
});
assertThat(message.stopReason()).contains("end_turn");
}
}
Loading
Loading