fix(domain,application,infrastructure,app-tauri,frontend): conversation_id redevient un pivot logique IdeA/log/handoff, reprise provider réservée à la sélection explicite — lifecycle: LaunchAgentInput::conversation_id documenté comme sélection explicite (Some = reprise native si disponible sinon handoff portable, None = conversation provider neuve), plus de copie implicite du pivot logique de la leaf au reboot ; fallback silencieux: provider_resume_fallback résolu via resolve_handoff et câblé jusqu'aux adaptateurs Claude/Codex/OpenCode via AgentSessionFactory::create resume_fallback_preamble + with_resume_fallback_preamble — le préambule reste dormant si la session native répond et n'est injecté que si la session provider s'avère introuvable/périmée (fake stale-then-fresh en test, relance fraîche sans erreur utilisateur) ; app-tauri launch_agent: contrat documenté, l'activation ordinaire d'une cellule doit envoyer None même si la leaf conserve un pivot logique d'avant reboot ; frontend CustomAgentChatView: freshOnOpen réinitialise l'état conversation complet (scrollback, view state, conversationId publié, bannière reprise, pagination historique) quand le runtime structuré n'existe plus au remontage — pas de fausse « Conversation reprise » après reboot ; tests: structured_launch_d3 étendu, structured_session_d0, agent_lifecycle structured, CustomAgentChatView freshOnOpen — #372 (QA verte: cargo check --tests -p domain -p application -p infrastructure, cargo test -p application --test structured_launch_d3, cargo test -p domain --test structured_session_d0, cargo test -p application --test agent_lifecycle structured, npm test -- CustomAgentChatView, npm run typecheck, git diff --check ; fichiers .ideai/* et scripts/ hors périmètre ; aucun push)

This commit is contained in:
2026-09-17 19:43:14 +02:00
parent 0914a77d9f
commit 07488769d3
19 changed files with 483 additions and 57 deletions

View File

@ -6751,6 +6751,79 @@ describe("CustomAgentChatView", () => {
));
});
it("starts fresh after reboot when both persisted session and conversation are stale (#372)", async () => {
const conversation = {
readPage: vi.fn(),
};
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "fresh-session",
assignedConversationId: "fresh-conversation",
})),
reattachAgentChat: vi.fn()
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "session not found" })
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "session not found" })
.mockResolvedValueOnce({ sessionId: "fresh-session", scrollback: [] }),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => ({
sessionId: "fresh-session",
outcome: "cancelled" as const,
})),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
const onConversationId = vi.fn();
render(
<DIProvider gateways={{
agent,
conversation,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}>
<CustomAgentChatView
freshOnOpen
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="stale-session"
conversationId="stale-conversation"
onSessionId={onSessionId}
onConversationId={onConversationId}
/>
</DIProvider>,
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", {
cwd: "/repo",
rows: 24,
cols: 80,
conversationId: undefined,
freshStart: true,
nodeId: "node-1",
});
expect(conversation.readPage).not.toHaveBeenCalled();
expect(screen.queryByText("Conversation reprise")).toBeNull();
expect(screen.queryByText("session not found")).toBeNull();
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onConversationId).toHaveBeenCalledWith(null);
expect(onConversationId).toHaveBeenCalledWith("fresh-conversation");
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Ready now" } });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(false),
);
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
"fresh-session",
"Ready now",
expect.any(Function),
));
});
it("uses /clean for a new conversation and disables conversation changes during a turn", async () => {
let finishSend: (() => void) | undefined;
const agent = {

View File

@ -2238,6 +2238,20 @@ export function CustomAgentChatView({
setExternalSessionId(null);
setLiveSessionAttached(false);
publishSessionId(null);
if (freshOnOpen) {
// After an application reboot, the leaf can still contain both
// ids even though the structured runtime no longer exists. A
// normal cell opening must not turn that stale runtime pivot
// into an implicit conversation resume (#372).
scrollbackChunkCountRef.current = 0;
applyChatViewState(emptyChatViewState());
publishConversationId(null);
setSavedConversationLoadState("idle");
setResumedConversation(false);
setHistoryHasMore(false);
setHistoryNextAnchor(undefined);
setHistoryError(null);
}
}
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
timestamp: new Date().toISOString(),
@ -2397,6 +2411,7 @@ export function CustomAgentChatView({
reattachStructuredSession,
recoverStructuredSession,
publishSessionId,
publishConversationId,
applyChatViewState,
conversation,
agent,