fix(frontend): le changement d'onglet ne déclenche plus de reprise de la conversation active — au remontage d'une cellule avec conversationId, réattache d'abord la session structurée vivante (sessionId du cache de conversation, puis registre listLiveAgents/attachLiveAgent avec rebinding de node) via reattachStructuredSession preserveViewOnEmptyScrollback avant tout chargement d'historique durable ; le transcript persisté (conversation.readPage) n'est consulté qu'en l'absence de runtime live et la bannière « Conversation reprise » reste réservée à la reprise explicite depuis le menu Conversations — éviction du cache de session morte (chatSessionViewCache.delete) réconciliée avec le cache de conversation pour ne pas rejouer un transcript obsolète ; tests: réattach live au remontage sans readPage ni bannière (#370), retour depuis une vue non conversationnelle = replay live complet (second reattach, pas de readPage, pas de bannière) — #370 (QA verte: npm ci, npm run typecheck, npx vitest run src/features/agents/CustomAgentChatView.test.tsx src/features/layout/LayoutGrid.chat.test.tsx, git diff --check ; fichiers .ideai/* et scripts/ hors périmètre ; aucun push)
This commit is contained in:
@ -433,6 +433,73 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(agent.sendAgentChat).not.toHaveBeenCalled();
|
expect(agent.sendAgentChat).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reattaches a live conversation on remount without reading durable history (#370)", async () => {
|
||||||
|
let receive: ((chunk: ReplyChunk) => void) | undefined;
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
listLiveAgents: vi.fn(async () => [{
|
||||||
|
agentId: "agent-1",
|
||||||
|
nodeId: "node-1",
|
||||||
|
sessionId: "live-session",
|
||||||
|
kind: "structured" as const,
|
||||||
|
runtimeState: "running" as const,
|
||||||
|
}]),
|
||||||
|
reattachAgentChat: vi.fn(async (
|
||||||
|
sessionId: string,
|
||||||
|
onChunk: (chunk: ReplyChunk) => void,
|
||||||
|
) => {
|
||||||
|
receive = onChunk;
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
conversationId: "active-conversation",
|
||||||
|
scrollback: [
|
||||||
|
{ kind: "userPrompt" as const, text: "Question active" },
|
||||||
|
{ kind: "textDelta" as const, text: "Réponse en cours" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
sendAgentChat: vi.fn(async () => {}),
|
||||||
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
const conversation = {
|
||||||
|
readPage: vi.fn(async () => ({ turns: [], hasMore: false })),
|
||||||
|
};
|
||||||
|
const onSessionId = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider gateways={{
|
||||||
|
agent,
|
||||||
|
conversation,
|
||||||
|
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={null}
|
||||||
|
conversationId="active-conversation"
|
||||||
|
onSessionId={onSessionId}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Question active")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("Réponse en cours").length).toBeGreaterThan(0);
|
||||||
|
expect(conversation.readPage).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText("Conversation reprise")).toBeNull();
|
||||||
|
expect(onSessionId).toHaveBeenCalledWith("live-session");
|
||||||
|
|
||||||
|
act(() => receive?.({ kind: "final", content: "Réponse terminée" }));
|
||||||
|
expect(await screen.findByText("Réponse terminée")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Question active")).toBeTruthy();
|
||||||
|
expect(screen.getAllByText("Réponse en cours").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a durable load failure distinct and retries without clearing its conversation id", async () => {
|
it("keeps a durable load failure distinct and retries without clearing its conversation id", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
@ -1602,7 +1669,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
reattachAgentChat: vi
|
reattachAgentChat: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementationOnce(async (sessionId: string) => ({
|
.mockImplementation(async (sessionId: string) => ({
|
||||||
sessionId,
|
sessionId,
|
||||||
scrollback,
|
scrollback,
|
||||||
})),
|
})),
|
||||||
@ -1639,6 +1706,11 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(await screen.findByText("Initial reply")).toBeTruthy();
|
expect(await screen.findByText("Initial reply")).toBeTruthy();
|
||||||
first.unmount();
|
first.unmount();
|
||||||
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
|
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
|
||||||
|
scrollback = [
|
||||||
|
...scrollback,
|
||||||
|
{ kind: "userPrompt", text: "Question during graph view" },
|
||||||
|
{ kind: "final", content: "Answer restored from replay" },
|
||||||
|
];
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<DIProvider
|
<DIProvider
|
||||||
@ -1659,7 +1731,9 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(await screen.findByText("Answer restored from replay")).toBeTruthy();
|
expect(await screen.findByText("Answer restored from replay")).toBeTruthy();
|
||||||
expect(screen.getByText("Question during graph view")).toBeTruthy();
|
expect(screen.getByText("Question during graph view")).toBeTruthy();
|
||||||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||||||
expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1);
|
expect(agent.reattachAgentChat).toHaveBeenCalledTimes(2);
|
||||||
|
expect(conversation.readPage).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText("Conversation reprise")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
|
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
|
||||||
|
|||||||
@ -1885,6 +1885,7 @@ export function CustomAgentChatView({
|
|||||||
applyScrollback?: boolean;
|
applyScrollback?: boolean;
|
||||||
stableIdentity?: boolean;
|
stableIdentity?: boolean;
|
||||||
preserveRestartTurns?: boolean;
|
preserveRestartTurns?: boolean;
|
||||||
|
preserveViewOnEmptyScrollback?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable");
|
if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable");
|
||||||
@ -1921,7 +1922,10 @@ export function CustomAgentChatView({
|
|||||||
if (reattached.conversationId) {
|
if (reattached.conversationId) {
|
||||||
publishConversationId(reattached.conversationId);
|
publishConversationId(reattached.conversationId);
|
||||||
}
|
}
|
||||||
if (options.applyScrollback) {
|
if (
|
||||||
|
options.applyScrollback &&
|
||||||
|
(reattached.scrollback.length > 0 || !options.preserveViewOnEmptyScrollback)
|
||||||
|
) {
|
||||||
scrollbackChunkCountRef.current = reattached.scrollback.length;
|
scrollbackChunkCountRef.current = reattached.scrollback.length;
|
||||||
let replayed = reattached.scrollback.reduce(
|
let replayed = reattached.scrollback.reduce(
|
||||||
(state, chunk) => foldChunk(state, chunk),
|
(state, chunk) => foldChunk(state, chunk),
|
||||||
@ -2214,6 +2218,21 @@ export function CustomAgentChatView({
|
|||||||
if (!isNotFound(stableError)) throw stableError;
|
if (!isNotFound(stableError)) throw stableError;
|
||||||
}
|
}
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
chatSessionViewCache.delete(externalSessionId);
|
||||||
|
const cacheKey = conversationCacheKey(
|
||||||
|
projectId,
|
||||||
|
agentId,
|
||||||
|
activeConversationIdRef.current,
|
||||||
|
);
|
||||||
|
const cached = cacheKey ? chatConversationViewCache.get(cacheKey) : undefined;
|
||||||
|
if (cacheKey && cached?.sessionId === externalSessionId) {
|
||||||
|
chatConversationViewCache.set(cacheKey, {
|
||||||
|
turns: cached.turns,
|
||||||
|
progressIndex: cached.progressIndex,
|
||||||
|
progressScope: cached.progressScope,
|
||||||
|
chunkCount: cached.chunkCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
setCurrentSession(null);
|
setCurrentSession(null);
|
||||||
setExternalSessionId(null);
|
setExternalSessionId(null);
|
||||||
setLiveSessionAttached(false);
|
setLiveSessionAttached(false);
|
||||||
@ -2256,6 +2275,51 @@ export function CustomAgentChatView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A cell can remount while its structured runtime remains live (for
|
||||||
|
// example after switching tabs). The layout session can temporarily
|
||||||
|
// be absent, but the conversation cache and live registry still carry
|
||||||
|
// the runtime identity. Reattach that runtime before considering the
|
||||||
|
// durable transcript: a conversation id alone is not a request to
|
||||||
|
// replace an active view with saved history (#370).
|
||||||
|
const cachedSessionId = cached?.sessionId;
|
||||||
|
if (cachedSessionId) {
|
||||||
|
try {
|
||||||
|
const attachedSessionId = await reattachStructuredSession(cachedSessionId, {
|
||||||
|
applyScrollback: true,
|
||||||
|
stableIdentity: true,
|
||||||
|
preserveViewOnEmptyScrollback: true,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
publishSessionId(attachedSessionId);
|
||||||
|
setSavedConversationLoadState("idle");
|
||||||
|
setResumedConversation(false);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if (!isNotFound(e)) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = agent.listLiveAgents
|
||||||
|
? (await agent.listLiveAgents(projectId)).find(
|
||||||
|
(candidate) => candidate.agentId === agentId && candidate.kind === "structured",
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
if (live) {
|
||||||
|
const rebound =
|
||||||
|
nodeId && live.nodeId !== nodeId && agent.attachLiveAgent
|
||||||
|
? await agent.attachLiveAgent(projectId, agentId, nodeId)
|
||||||
|
: live;
|
||||||
|
const attachedSessionId = await reattachStructuredSession(rebound.sessionId, {
|
||||||
|
applyScrollback: true,
|
||||||
|
preserveViewOnEmptyScrollback: true,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
publishSessionId(attachedSessionId);
|
||||||
|
setSavedConversationLoadState("idle");
|
||||||
|
setResumedConversation(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSavedConversationLoadState("loading");
|
setSavedConversationLoadState("loading");
|
||||||
setLiveSessionAttached(false);
|
setLiveSessionAttached(false);
|
||||||
try {
|
try {
|
||||||
@ -2311,8 +2375,10 @@ export function CustomAgentChatView({
|
|||||||
publishSessionId,
|
publishSessionId,
|
||||||
applyChatViewState,
|
applyChatViewState,
|
||||||
conversation,
|
conversation,
|
||||||
|
agent,
|
||||||
projectId,
|
projectId,
|
||||||
agentId,
|
agentId,
|
||||||
|
nodeId,
|
||||||
freshOnOpen,
|
freshOnOpen,
|
||||||
openRetryNonce,
|
openRetryNonce,
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user