fix(chat): reprise robuste au retour d'un layout non conversationnel (composer verrouillé selon l'état réel, replay complet du scrollback, plus de fausse terminaison 'no reply') (#190)

This commit is contained in:
2026-08-11 19:50:35 +02:00
parent 5470d30371
commit ddecf9e0c5
4 changed files with 252 additions and 30 deletions

View File

@ -59,6 +59,14 @@ function addPluginCommand(
});
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
describe("CustomAgentChatView", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -174,8 +182,136 @@ describe("CustomAgentChatView", () => {
expect(await screen.findByText("Background tool")).toBeTruthy();
expect(screen.getByText("Completed away")).toBeTruthy();
expect(
screen.getByText("Reprise effectuée: 2 événements rattrapés pendant l'absence."),
).toBeTruthy();
screen.queryByText(/Reprise effectuée:/),
).toBeNull();
});
it("keeps the composer locked on layout return until reattach replay settles (#190)", async () => {
let scrollback: ReplyChunk[] = [
{ kind: "userPrompt" as const, text: "Initial prompt" },
{ kind: "final" as const, content: "Initial reply" },
];
const secondReattach = deferred<{ sessionId: string; scrollback: ReplyChunk[] }>();
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi
.fn()
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback,
}))
.mockImplementationOnce(() => secondReattach.promise),
detachAgentChat: vi.fn(async () => {}),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const props = {
projectId: "project-1",
agentId: "agent-1",
agentName: "Worker",
profile,
cwd: "/repo",
nodeId: "node-1",
sessionId: "chat-session-1",
conversationId: "conversation-1",
onSessionId: vi.fn(),
onConversationId: vi.fn(),
};
const first = render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView {...props} />
</DIProvider>,
);
expect(await screen.findByText("Initial reply")).toBeTruthy();
first.unmount();
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
scrollback = [
{ kind: "userPrompt" as const, text: "Initial prompt" },
{ kind: "final" as const, content: "Initial reply" },
{ kind: "userPrompt" as const, text: "Question during graph view" },
{ kind: "textDelta" as const, text: "Partial answer while away" },
];
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView {...props} />
</DIProvider>,
);
expect(screen.getByText("Initial reply")).toBeTruthy();
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
act(() => {
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
});
expect(await screen.findByText("Question during graph view")).toBeTruthy();
expect(screen.getByText("Partial answer while away")).toBeTruthy();
expect(screen.queryByText(/Reprise effectuée:/)).toBeNull();
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
});
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [
{ kind: "userPrompt" as const, text: "resume the turn" },
{
kind: "error" as const,
message: "Le modèle n'a renvoyé aucune réponse.",
},
{ kind: "textDelta" as const, text: "Real answer " },
{ kind: "final" as const, content: "Real answer completed" },
],
})),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
expect(await screen.findByText("Real answer completed")).toBeTruthy();
expect(screen.getByText("Real answer")).toBeTruthy();
expect(screen.queryByText("Le modèle n'a renvoyé aucune réponse.")).toBeNull();
});
it("stops the live agent with cascade feedback and releases the structured session", async () => {

View File

@ -59,6 +59,7 @@ interface CachedChatView {
}
const chatSessionViewCache = new Map<string, CachedChatView>();
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
export function __clearCustomAgentChatViewCacheForTests() {
chatSessionViewCache.clear();
@ -299,17 +300,6 @@ function appendProgress(turns: ChatTurn[], progress: ReplyProgress): ChatTurn[]
return [...turns, { role: "progress", progress }];
}
function reattachNotice(missedChunks: number): ChatTurn | null {
if (missedChunks <= 0) return null;
return {
role: "tool",
label:
missedChunks === 1
? "Reprise effectuée: 1 événement rattrapé pendant l'absence."
: `Reprise effectuée: ${missedChunks} événements rattrapés pendant l'absence.`,
};
}
function mergeCompletedProgress(
running: ReplyProgress,
completed: ReplyProgress,
@ -362,6 +352,26 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
});
}
function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
return turn?.role === "error" && turn.text === EMPTY_MODEL_RESPONSE_MESSAGE;
}
function turnsIndicateBusy(turns: ChatTurn[]): boolean {
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn.role === "final" || turn.role === "error") return false;
if (turn.role === "agent" && turn.pending) return true;
if (
turn.role === "progress" &&
!turn.settled &&
progressStageBucket(turn.progress) !== "completed"
) {
return true;
}
}
return false;
}
function fileExtension(mime: string): string {
if (mime === "image/png") return "png";
if (mime === "image/jpeg") return "jpg";
@ -490,6 +500,9 @@ function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
raw.kind !== "userPrompt" &&
raw.kind !== "UserPrompt"
) {
if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") {
return foldChunk(turns.slice(0, -1), raw);
}
return turns;
}
switch (raw.kind) {
@ -556,7 +569,7 @@ export function CustomAgentChatView({
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
const [profileDialog, setProfileDialog] =
useState<ProfileCommandDialogState | null>(null);
const [opening, setOpening] = useState(false);
const [opening, setOpening] = useState(Boolean(sessionId));
const [busy, setBusy] = useState(false);
const [stopping, setStopping] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -793,14 +806,10 @@ export function CustomAgentChatView({
if (!mountedRef.current) return reattached.sessionId;
setCurrentSession(reattached.sessionId);
if (options.applyScrollback) {
const cachedCount = chatSessionViewCache.get(sid)?.chunkCount ?? 0;
scrollbackChunkCountRef.current = Math.max(
scrollbackChunkCountRef.current,
reattached.scrollback.length,
);
scrollbackChunkCountRef.current = reattached.scrollback.length;
const replayed = reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]);
const notice = reattachNotice(reattached.scrollback.length - cachedCount);
setTurns(notice ? [...replayed, notice] : replayed);
setTurns(replayed);
setBusy(turnsIndicateBusy(replayed));
}
return reattached.sessionId;
},