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
25 changes: 25 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,31 @@ describe("formatRelativePastTime", () => {
});

describe("renderChatLines", () => {
it("renders model handoffs as directional notice lines", () => {
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: "codex",
fromModelId: "anthropic/claude-sonnet-5",
toModelId: "openai/gpt-5.4",
},
}],
});

expect(lines).toHaveLength(1);
expect(lines[0]).toMatchObject({
tone: "notice",
body: "[model] Claude → Codex",
});
});

it("LRU-caches assistant markdown parses by message text", () => {
__clearAssistantMarkdownCacheForTests();
const text = "Paragraph text\n\n```ts\nconst value = 1;\n```";
Expand Down
7 changes: 7 additions & 0 deletions apps/ade-cli/src/tuiClient/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isHostSleepNoticeEvent,
} from "../../../desktop/src/shared/hostSleepNotice";
import { approvalRequestKind, isQuestionKind } from "../../../desktop/src/shared/pendingInputAnswers";
import { providerDisplayLabel } from "../../../desktop/src/shared/pendingInputLabels";
import { renderAdeCardBody } from "./adeCardFormat";
import { highlightCode, type HighlightedToken } from "./highlightCache";
import { glyphFor } from "./theme";
Expand Down Expand Up @@ -791,6 +792,12 @@ export function renderChatLines(args: {
}
continue;
}
if (event.type === "model_handoff") {
const from = providerDisplayLabel(event.fromProvider, "previous model");
const to = providerDisplayLabel(event.toProvider, "new model");
lines.push({ id, tone: "notice", body: `[model] ${from} → ${to}` });
continue;
}
if (event.type === "text") {
// Codex subagent child content is namespaced `codex-subagent:` and belongs
// in the subagent transcript, not the parent chat — mirror desktop, which
Expand Down
174 changes: 171 additions & 3 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const mockState = vi.hoisted(() => ({
questionReject: ReturnType<typeof vi.fn>;
permissionReply: ReturnType<typeof vi.fn>;
}>(),
openCodePromptAsyncBarrier: null as Promise<void> | null,
openCodeTitleForNextPrompt: null as string | null,
openCodeQuestionForNextPrompt: null as null | {
id: string;
Expand Down Expand Up @@ -430,6 +431,9 @@ vi.mock("../opencode/openCodeRuntime", async () => {
// arrives alongside sessionID and directory.
promptAsync: vi.fn(async (params: any = {}) => {
state.promptBodies.push(params ?? {});
if (mockState.openCodePromptAsyncBarrier) {
await mockState.openCodePromptAsyncBarrier;
}
void (async () => {
if (mockState.openCodeTitleForNextPrompt) {
pushEvent({
Expand Down Expand Up @@ -593,19 +597,39 @@ vi.mock("../opencode/openCodeRuntime", async () => {
client,
};
}),
openCodeEventStream: vi.fn(async ({ client }: { client: { __sessionId?: string } }) => {
openCodeEventStream: vi.fn(async ({
client,
signal,
}: {
client: { __sessionId?: string };
signal?: AbortSignal;
}) => {
const state = client.__sessionId ? mockState.openCodeSessions.get(client.__sessionId) : undefined;
if (!state) {
return (async function* () {})();
}
return (async function* () {
while (true) {
if (signal?.aborted) return;
if (state.events.length > 0) {
yield state.events.shift();
continue;
}
if (state.aborted) return;
await new Promise<void>((resolve) => state.waiters.push(resolve));
await new Promise<void>((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
const finish = () => {
signal?.removeEventListener("abort", finish);
const index = state.waiters.indexOf(finish);
if (index >= 0) state.waiters.splice(index, 1);
resolve();
};
signal?.addEventListener("abort", finish, { once: true });
state.waiters.push(finish);
});
}
})();
}),
Expand Down Expand Up @@ -2136,6 +2160,7 @@ beforeEach(() => {
mockState.openCodeSessionCounter = 0;
mockState.openCodeForkCalls = [];
mockState.openCodeSessions.clear();
mockState.openCodePromptAsyncBarrier = null;
mockState.openCodeTitleForNextPrompt = null;
mockState.openCodeQuestionForNextPrompt = null;
mockState.droidSessionCounter = 0;
Expand Down Expand Up @@ -4263,7 +4288,10 @@ describe("createAgentChatService", () => {
});

it("recomputes the MCP report when a model switch crosses providers", async () => {
const { service } = createService();
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const created = await service.createSession({
laneId: "lane-1",
provider: "claude",
Expand All @@ -4285,6 +4313,26 @@ describe("createAgentChatService", () => {
delivered: true,
});
expect(summary?.mcpCapability?.residual).toBeTruthy();
expect(summary?.modelHandoffHistory).toEqual([
expect.objectContaining({
fromProvider: "claude",
toProvider: "codex",
fromModelId: expect.any(String),
toModelId: expect.any(String),
}),
]);
expect(events.map((event) => event.event)).toContainEqual(
expect.objectContaining({
type: "model_handoff",
fromProvider: "claude",
toProvider: "codex",
}),
);

const { service: restarted } = createService();
await expect(restarted.getSessionSummary(created.id)).resolves.toMatchObject({
modelHandoffHistory: summary?.modelHandoffHistory,
});
});

it("refuses a model switch onto a provider that cannot carry the injected servers", async () => {
Expand Down Expand Up @@ -40894,6 +40942,126 @@ describe("createAgentChatService", () => {
await sendPromise.catch(() => undefined);
});

it("renders OpenCode follow-up text whose message.updated arrives before promptAsync settles", async () => {
// The SSE is live-only. Awaiting promptAsync before draining it used to
// drop the role announcement on a fast follow-up; the role gate then
// swallowed every assistant part while session.idle still completed.
let pulling = false;
const liveQueue: any[] = [];
let liveWaiter: (() => void) | null = null;
const wakeLive = () => {
const waiter = liveWaiter;
liveWaiter = null;
waiter?.();
};
const pushLive = (...nextEvents: any[]) => {
if (!pulling) return;
liveQueue.push(...nextEvents);
wakeLive();
};

vi.mocked(streamText).mockReturnValue({
fullStream: (async function* () {})(),
} as any);
vi.mocked(openCodeEventStream).mockImplementationOnce((async () => {
return (async function* () {
pulling = true;
wakeLive();
while (true) {
if (liveQueue.length > 0) {
yield liveQueue.shift();
continue;
}
await new Promise<void>((resolve) => {
liveWaiter = resolve;
if (liveQueue.length > 0) {
liveWaiter = null;
resolve();
}
});
}
})();
}) as unknown as typeof openCodeEventStream);

let releasePrompt!: () => void;
mockState.openCodePromptAsyncBarrier = new Promise<void>((resolve) => {
releasePrompt = resolve;
});

const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "opencode",
model: "opencode/openai/gpt-5.4",
modelId: "opencode/openai/gpt-5.4",
});
const sendPromise = service.sendMessage({
sessionId: session.id,
text: "What model are you now?",
});

await waitForEvent(
events,
(event): event is AgentChatEventEnvelope =>
event.event.type === "status" && event.event.turnStatus === "started",
);
await vi.waitFor(() => {
expect(pulling).toBe(true);
});
expect(mockState.openCodeSessions.values().next().value?.promptBodies.length ?? 0).toBe(1);

const sessionID = [...mockState.openCodeSessions.keys()][0]!;
pushLive(
{
type: "message.updated",
properties: { info: { id: "msg-fast-1", role: "assistant", sessionID } },
},
{
type: "message.part.updated",
properties: {
part: {
id: "text-fast-1",
type: "text",
text: "Still receiving messages.",
messageID: "msg-fast-1",
sessionID,
},
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "finish-fast-1",
sessionID,
type: "step-finish",
tokens: { input: 20, output: 8, cache: { read: 0, write: 0 } },
},
},
},
{
type: "session.idle",
properties: { sessionID },
},
);

await waitForEvent(
events,
(event): event is AgentChatEventEnvelope =>
event.event.type === "text" && event.event.text.includes("Still receiving messages."),
);

releasePrompt();
await waitForEvent(
events,
(event): event is AgentChatEventEnvelope => event.event.type === "done",
);
await sendPromise;
});

it("fails a cleanly ended OpenCode event stream and clears active child sessions", async () => {
const events: AgentChatEventEnvelope[] = [];
let releaseStream!: () => void;
Expand Down
Loading
Loading