Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,29 @@ describe("renderChatLines", () => {
});
});

it("skips a handoff line when the provider did not actually change", () => {
const lines = renderChatLines({
activeSession: null,
notices: [],
events: [{
sessionId: "s1",
timestamp: "2026-01-01T12:00:00.000Z",
sequence: 1,
event: {
type: "model_handoff",
fromProvider: "claude",
toProvider: "claude",
fromModelId: "anthropic/claude-opus-5",
toModelId: "anthropic/claude-sonnet-5",
},
}],
});

// Swapping Opus for Sonnet is the same agent, so "[model] Claude → Claude"
// is noise. Desktop and iOS drop the row too; the TUI must agree.
expect(lines).toHaveLength(0);
});

it("LRU-caches assistant markdown parses by message text", () => {
__clearAssistantMarkdownCacheForTests();
const text = "Paragraph text\n\n```ts\nconst value = 1;\n```";
Expand Down
3 changes: 3 additions & 0 deletions apps/ade-cli/src/tuiClient/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,9 @@ export function renderChatLines(args: {
continue;
}
if (event.type === "model_handoff") {
// Same-provider transitions are not handoffs (mirrors desktop/iOS): skip
// the line rather than print "Claude → Claude".
if (event.fromProvider === event.toProvider) continue;
const from = providerDisplayLabel(event.fromProvider, "previous model");
const to = providerDisplayLabel(event.toProvider, "new model");
lines.push({ id, tone: "notice", body: `[model] ${from} → ${to}` });
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4404,6 +4404,34 @@ describe("createAgentChatService", () => {
});
});

it("does not record a handoff when the model switch stays inside one provider", async () => {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const created = await service.createSession({
laneId: "lane-1",
provider: "claude",
model: "sonnet",
});

await service.updateSession({
sessionId: created.id,
modelId: "anthropic/claude-opus-5" as never,
});

// The chip means "a different agent picked this thread up". Opus and
// Sonnet are the same agent, so emitting a handoff here produced the
// nonsense card "Claude -> Claude" in the transcript.
const summary = await service.getSessionSummary(created.id);
expect(summary?.provider).toBe("claude");
expect(summary?.modelId).toBe("anthropic/claude-opus-5");
expect(summary?.modelHandoffHistory ?? []).toEqual([]);
expect(events.map((event) => event.event)).not.toContainEqual(
expect.objectContaining({ type: "model_handoff" }),
);
});

it("refuses a model switch onto a provider that cannot carry the injected servers", async () => {
const { service } = createService();
const created = await service.createSession({
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48748,11 +48748,19 @@ export function createAgentChatService(args: {
);
}

// A handoff chip marks a change of *agent*, not a change of model. Only
// a different top-level ADE provider group qualifies: claude -> codex is
// a handoff, Claude Opus -> Claude Fable is not. Aggregator providers
// (opencode, cursor, droid) collapse to a single group, so switching the
// model they front is also not a handoff. `modelChanged` stays broader on
// purpose — it still drives the runtime teardown and title re-adoption
// below, which any model switch needs.
const providerChanged = previousProvider !== nextProvider;
const modelChanged =
previousProvider !== nextProvider
providerChanged
|| managed.session.modelId !== descriptor.id
|| managed.session.model !== nextModel;
if (modelChanged) {
if (providerChanged) {
modelHandoff = {
fromProvider: previousProvider,
toProvider: nextProvider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,28 @@ describe("AgentChatMessageList transcript rendering", () => {
expect(divider.querySelector(".items-center.h-6")).toBeTruthy();
});

it("draws no handoff divider when the provider did not actually change", () => {
const { container } = renderMessageList([
{
sessionId: "session-1",
timestamp: "2026-03-17T10:00:00.000Z",
event: {
type: "model_handoff",
fromProvider: "claude",
toProvider: "claude",
fromModelId: "anthropic/claude-opus-5",
toModelId: "anthropic/claude-sonnet-5",
},
},
]);

expect(screen.queryByTestId("model-handoff-event")).toBeNull();
// The envelope is filtered out upstream, so no row wrapper is mounted at
// all — an empty row would still consume a `--chat-row-gap`.
const rowList = container.querySelector('[class*="--chat-row-gap"]');
expect(rowList?.children.length ?? 0).toBe(0);
});

it("draws exactly one fork-history divider between seeded history and the first live event", async () => {
renderMessageList([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2277,6 +2277,20 @@ function isAutomaticContextUsageEvent(event: { type: string; origin?: string }):
return event.type === "context_usage" && event.origin !== undefined && event.origin !== "command";
}

/**
* A same-provider transition is not a handoff. The service no longer emits one,
* but an old transcript can still carry "Claude -> Claude"; drawing a divider
* with the same logo on both sides says nothing. Filtered out alongside the
* automatic context-usage snapshots so no empty row (and its gap) is mounted.
*/
function isSameProviderModelHandoffEvent(event: {
type: string;
fromProvider?: string;
toProvider?: string;
}): boolean {
return event.type === "model_handoff" && event.fromProvider === event.toProvider;
}

function QueueRecoveryCard({
recoveryId,
messageCount,
Expand Down Expand Up @@ -2430,6 +2444,8 @@ function renderEvent(
const event = envelope.event;

if (event.type === "model_handoff") {
// Same-provider handoffs never reach here: they are dropped upstream by
// `isSameProviderModelHandoffEvent` so they do not mount an empty row.
const fromLabel = providerDisplayLabel(event.fromProvider, "Previous model");
const toLabel = providerDisplayLabel(event.toProvider, "New model");
return (
Expand Down Expand Up @@ -5642,10 +5658,15 @@ function AgentChatMessageListMain({
return byRowKey;
}, [rows]);
const allGroupedRows = useMemo(
// Drop automatic context-usage snapshots before they become flex rows: an
// empty (null-rendered) row still consumes a `--chat-row-gap` on each side,
// so leaving them in would stack blank gaps during a streaming turn.
() => groupChatTranscriptRows(rows).filter((row) => !isAutomaticContextUsageEvent(row.event)),
// Drop automatic context-usage snapshots and same-provider "handoffs"
// before they become flex rows: an empty (null-rendered) row still consumes
// a `--chat-row-gap` on each side, so leaving them in would stack blank
// gaps during a streaming turn.
() =>
groupChatTranscriptRows(rows).filter(
(row) =>
!isAutomaticContextUsageEvent(row.event) && !isSameProviderModelHandoffEvent(row.event),
),
[rows],
);
// Same lookup-map shape as turnProofByRowKey / turnEndDurationByRowKey rather
Expand Down
3 changes: 3 additions & 0 deletions apps/ios/ADE/Models/RemoteModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1859,6 +1859,9 @@ enum AgentChatNoticeKind: String, Codable, Equatable {
case hostAsleep = "host_asleep"
/// Resumed half of the same chip.
case hostAwake = "host_awake"
/// Provider handoff divider. Synthesized locally from the `model_handoff`
/// event — see `workModelHandoffNoticeDetail`.
case modelHandoff = "model_handoff"

// The host's noticeKind union (see apps/desktop/src/shared/types/chat.ts) grows
// over time. `system_notice.noticeKind` is a required, non-optional decode, so an
Expand Down
51 changes: 51 additions & 0 deletions apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1256,6 +1256,57 @@ struct WorkTurnSeparatorView: View {
}
}

/// Provider handoff divider: the transcript's "a different agent picked this
/// thread up" marker. Mirrors desktop `AgentChatMessageList` — hairline, the
/// outgoing provider's logo, a small uppercase "handoff" label, an arrow, the
/// incoming provider's logo, hairline. Takes the two providers directly; the
/// same-provider filter and the `metadata` unpack live in `eventCard` and the
/// timeline call site, not here.
struct WorkModelHandoffDivider: View {
let fromProvider: String
let toProvider: String
let accessibilityLabel: String

var body: some View {
HStack(spacing: 10) {
hairline
HStack(spacing: 8) {
providerMark(fromProvider)
Text("Handoff")
.font(.system(size: 10, weight: .semibold))
.textCase(.uppercase)
.tracking(1.4)
.foregroundStyle(ADEColor.textMuted)
Image(systemName: "arrow.right")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(ADEColor.textMuted)
providerMark(toProvider)
}
hairline
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityLabel)
}

private func providerMark(_ provider: String) -> some View {
WorkProviderBareLogo(
provider: provider,
fallbackSymbol: "terminal.fill",
tint: ADEColor.textMuted,
size: 15
)
.opacity(0.9)
}

private var hairline: some View {
Rectangle()
.fill(ADEColor.glassBorder)
.frame(height: 0.6)
}
}

struct WorkTurnEndMarkerView: View {
let marker: WorkTurnEndMarker
var toolCount: Int = 0
Expand Down
7 changes: 7 additions & 0 deletions apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,13 @@ extension WorkChatSessionView {
enabled: isLive,
onRecover: onRecoverCodexTurn
)
} else if card.kind == "modelHandoff", card.metadata.count == 2 {
WorkModelHandoffDivider(
fromProvider: card.metadata[0],
toProvider: card.metadata[1],
// The card title doubles as the divider's accessibility label.
accessibilityLabel: card.title
)
} else if card.kind == "turnDiagnostics" {
WorkTurnDiagnosticsDisclosureView(
card: card,
Expand Down
Loading
Loading