fix(chat): ignore self-emitted custom session echoes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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 () => ({
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user