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:
@ -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 () => {
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user