fix(chat): ignore self-emitted custom session echoes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 15:29:03 +02:00
parent 525ea94b09
commit ce9ba0dc3d
2 changed files with 92 additions and 13 deletions

View File

@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
@ -151,6 +152,63 @@ describe("CustomAgentChatView", () => {
expect(screen.queryByText(/structured session gone/)).toBeNull(); expect(screen.queryByText(/structured session gone/)).toBeNull();
}); });
it("does not reopen when the parent echoes a launched session id", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-echo",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
function EchoingParent() {
const [echoedSessionId, setEchoedSessionId] = useState<string | null>(null);
return (
<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={echoedSessionId}
conversationId="conversation-1"
onSessionId={(nextSessionId) => {
onSessionId(nextSessionId);
setEchoedSessionId(nextSessionId);
}}
onConversationId={vi.fn()}
/>
</DIProvider>
);
}
render(<EchoingParent />);
await waitFor(() =>
expect(onSessionId).toHaveBeenCalledWith("chat-session-echo"),
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(agent.launchAgentChat).toHaveBeenCalledTimes(1);
expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1);
});
it("recovers and retries the prompt when send reports NOT_FOUND", async () => { it("recovers and retries the prompt when send reports NOT_FOUND", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(async () => ({ launchAgentChat: vi.fn(async () => ({

View File

@ -131,6 +131,7 @@ export function CustomAgentChatView({
const { agent, system } = useGateways(); const { agent, system } = useGateways();
const [turns, setTurns] = useState<ChatTurn[]>([]); const [turns, setTurns] = useState<ChatTurn[]>([]);
const [currentSession, setCurrentSession] = useState(sessionId); const [currentSession, setCurrentSession] = useState(sessionId);
const [externalSessionId, setExternalSessionId] = useState(sessionId);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [attachment, setAttachment] = useState<string | null>(null); const [attachment, setAttachment] = useState<string | null>(null);
const [opening, setOpening] = useState(false); const [opening, setOpening] = useState(false);
@ -141,6 +142,7 @@ export function CustomAgentChatView({
sessionRef.current = currentSession; sessionRef.current = currentSession;
const mountedRef = useRef(false); const mountedRef = useRef(false);
const openOrAttachCountRef = useRef(0); const openOrAttachCountRef = useRef(0);
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
const onSessionIdRef = useRef(onSessionId); const onSessionIdRef = useRef(onSessionId);
onSessionIdRef.current = onSessionId; onSessionIdRef.current = onSessionId;
const onConversationIdRef = useRef(onConversationId); const onConversationIdRef = useRef(onConversationId);
@ -167,6 +169,11 @@ export function CustomAgentChatView({
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
}, []); }, []);
const publishSessionId = useCallback((nextSessionId: string | null) => {
selfEmittedSessionIdRef.current = nextSessionId;
onSessionIdRef.current(nextSessionId);
}, []);
const reattachStructuredSession = useCallback( const reattachStructuredSession = useCallback(
async (sid: string, options: { applyScrollback?: boolean } = {}) => { async (sid: string, options: { applyScrollback?: boolean } = {}) => {
if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable"); if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable");
@ -249,7 +256,7 @@ export function CustomAgentChatView({
}); });
if (!mountedRef.current) return launched.sessionId; if (!mountedRef.current) return launched.sessionId;
setCurrentSession(launched.sessionId); setCurrentSession(launched.sessionId);
onSessionIdRef.current(launched.sessionId); publishSessionId(launched.sessionId);
if (launched.assignedConversationId) { if (launched.assignedConversationId) {
onConversationIdRef.current(launched.assignedConversationId); onConversationIdRef.current(launched.assignedConversationId);
} }
@ -268,7 +275,7 @@ export function CustomAgentChatView({
retryAttachNotFound = false; retryAttachNotFound = false;
if (mountedRef.current) { if (mountedRef.current) {
setCurrentSession(null); setCurrentSession(null);
onSessionIdRef.current(null); publishSessionId(null);
} }
continue; continue;
} }
@ -289,6 +296,7 @@ export function CustomAgentChatView({
nodeId, nodeId,
conversationId, conversationId,
reattachStructuredSession, reattachStructuredSession,
publishSessionId,
], ],
); );
@ -297,6 +305,18 @@ export function CustomAgentChatView({
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
}, [turns]); }, [turns]);
useEffect(() => {
if (selfEmittedSessionIdRef.current === sessionId) {
console.debug("[ticket149] openOrAttach:self-session-echo:skip", {
timestamp: new Date().toISOString(),
sessionId,
});
selfEmittedSessionIdRef.current = undefined;
return;
}
setExternalSessionId(sessionId);
}, [sessionId]);
useEffect(() => { useEffect(() => {
if (!supported) return; if (!supported) return;
let cancelled = false; let cancelled = false;
@ -307,19 +327,19 @@ export function CustomAgentChatView({
console.debug("[ticket149] openOrAttach:start", { console.debug("[ticket149] openOrAttach:start", {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
execution, execution,
sessionId, sessionId: externalSessionId,
}); });
setOpening(true); setOpening(true);
setError(null); setError(null);
try { try {
if (sessionId) { if (externalSessionId) {
try { try {
await reattachStructuredSession(sessionId, { applyScrollback: true }); await reattachStructuredSession(externalSessionId, { applyScrollback: true });
if (cancelled) return; if (cancelled) return;
console.debug("[ticket149] openOrAttach:reattach-existing:success", { console.debug("[ticket149] openOrAttach:reattach-existing:success", {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
execution, execution,
sessionId, sessionId: externalSessionId,
}); });
return; return;
} catch (e) { } catch (e) {
@ -329,12 +349,12 @@ export function CustomAgentChatView({
if (!isNotFound(e)) throw e; if (!isNotFound(e)) throw e;
if (!cancelled) { if (!cancelled) {
setCurrentSession(null); setCurrentSession(null);
onSessionIdRef.current(null); publishSessionId(null);
} }
console.debug("[ticket149] openOrAttach:reattach-existing:not-found", { console.debug("[ticket149] openOrAttach:reattach-existing:not-found", {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
execution, execution,
sessionId, sessionId: externalSessionId,
error: instrumentationError(e), error: instrumentationError(e),
}); });
} }
@ -347,13 +367,13 @@ export function CustomAgentChatView({
console.debug("[ticket149] openOrAttach:recover:success", { console.debug("[ticket149] openOrAttach:recover:success", {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
execution, execution,
receivedSessionId: sessionId, receivedSessionId: externalSessionId,
}); });
} catch (e) { } catch (e) {
console.error("[ticket149] openOrAttach:error", { console.error("[ticket149] openOrAttach:error", {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
execution, execution,
sessionId, sessionId: externalSessionId,
error: instrumentationError(e), error: instrumentationError(e),
}); });
if (!cancelled) setError(describe(e)); if (!cancelled) setError(describe(e));
@ -368,9 +388,10 @@ export function CustomAgentChatView({
}; };
}, [ }, [
supported, supported,
sessionId, externalSessionId,
reattachStructuredSession, reattachStructuredSession,
recoverStructuredSession, recoverStructuredSession,
publishSessionId,
]); ]);
const canSend = useMemo( const canSend = useMemo(
@ -408,7 +429,7 @@ export function CustomAgentChatView({
if (isNotFound(e)) { if (isNotFound(e)) {
try { try {
setCurrentSession(null); setCurrentSession(null);
onSessionIdRef.current(null); publishSessionId(null);
const recoveredSession = await recoverStructuredSession({ const recoveredSession = await recoverStructuredSession({
applyScrollback: false, applyScrollback: false,
retryAttachNotFound: true, retryAttachNotFound: true,
@ -440,7 +461,7 @@ export function CustomAgentChatView({
} catch (e) { } catch (e) {
if (isNotFound(e)) { if (isNotFound(e)) {
setCurrentSession(null); setCurrentSession(null);
onSessionIdRef.current(null); publishSessionId(null);
setTurns((prev) => [...prev, { role: "tool", label: "Session déjà fermée." }]); setTurns((prev) => [...prev, { role: "tool", label: "Session déjà fermée." }]);
return; return;
} }