fix(cli): restaure la conversation au retour de layout via cache conversationId+sessionId — #217
This commit is contained in:
@ -522,6 +522,85 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("restores visible conversation from conversationId cache when layout returns before sessionId", 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" as string | null,
|
||||
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: "final" as const, content: "Answer restored from replay" },
|
||||
];
|
||||
|
||||
render(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
agent,
|
||||
system: { pickFile: vi.fn(async () => null) },
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView {...props} sessionId={null} />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Initial reply")).toBeTruthy();
|
||||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true);
|
||||
|
||||
act(() => {
|
||||
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Answer restored from replay")).toBeTruthy();
|
||||
expect(screen.getByText("Question during graph view")).toBeTruthy();
|
||||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
|
||||
@ -67,15 +67,18 @@ interface CachedChatView {
|
||||
turns: ChatTurn[];
|
||||
progressIndex: Map<string, ProgressIndexEntry>;
|
||||
chunkCount: number;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
const chatSessionViewCache = new Map<string, CachedChatView>();
|
||||
const chatConversationViewCache = new Map<string, CachedChatView>();
|
||||
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
|
||||
const COMPOSER_RESIZE_STEP = 12;
|
||||
const FALLBACK_COMPOSER_HEIGHT = 64;
|
||||
|
||||
export function __clearCustomAgentChatViewCacheForTests() {
|
||||
chatSessionViewCache.clear();
|
||||
chatConversationViewCache.clear();
|
||||
}
|
||||
|
||||
interface AttachmentDraft {
|
||||
@ -310,6 +313,14 @@ function emptyChatViewState(): ChatViewState {
|
||||
return { turns: [], progressIndex: new Map() };
|
||||
}
|
||||
|
||||
function conversationCacheKey(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
conversationId: string | null,
|
||||
): string | null {
|
||||
return conversationId ? `${projectId}::${agentId}::${conversationId}` : null;
|
||||
}
|
||||
|
||||
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
|
||||
const key = progressUpsertKey(progress);
|
||||
const existingIndex = state.turns.findIndex(
|
||||
@ -711,10 +722,27 @@ export function CustomAgentChatView({
|
||||
}: CustomAgentChatViewProps) {
|
||||
const { agent, profile: profileGateway, system } = useGateways();
|
||||
const pluginRuntime = usePluginRuntime();
|
||||
const [turns, setTurns] = useState<ChatTurn[]>(
|
||||
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
|
||||
const initialConversationCacheKey = conversationCacheKey(
|
||||
projectId,
|
||||
agentId,
|
||||
conversationId,
|
||||
);
|
||||
const [turns, setTurns] = useState<ChatTurn[]>(
|
||||
() =>
|
||||
sessionId
|
||||
? (chatSessionViewCache.get(sessionId)?.turns ?? [])
|
||||
: initialConversationCacheKey
|
||||
? (chatConversationViewCache.get(initialConversationCacheKey)?.turns ?? [])
|
||||
: [],
|
||||
);
|
||||
const [progressIndex, setProgressIndex] = useState<Map<string, ProgressIndexEntry>>(
|
||||
() =>
|
||||
sessionId
|
||||
? (chatSessionViewCache.get(sessionId)?.progressIndex ?? new Map())
|
||||
: initialConversationCacheKey
|
||||
? (chatConversationViewCache.get(initialConversationCacheKey)?.progressIndex ?? new Map())
|
||||
: new Map(),
|
||||
);
|
||||
const [progressIndex, setProgressIndex] = useState<Map<string, ProgressIndexEntry>>(new Map());
|
||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
||||
const [draft, setDraft] = useState("");
|
||||
@ -724,7 +752,7 @@ export function CustomAgentChatView({
|
||||
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
||||
const [profileDialog, setProfileDialog] =
|
||||
useState<ProfileCommandDialogState | null>(null);
|
||||
const [opening, setOpening] = useState(Boolean(sessionId));
|
||||
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -849,6 +877,10 @@ export function CustomAgentChatView({
|
||||
() => (layoutId ? { projectId, layoutId, nodeId } : null),
|
||||
[layoutId, nodeId, projectId],
|
||||
);
|
||||
const currentConversationCacheKey = useMemo(
|
||||
() => conversationCacheKey(projectId, agentId, conversationId),
|
||||
[agentId, conversationId, projectId],
|
||||
);
|
||||
const displayTurns = turns;
|
||||
|
||||
const clearPersistedDraft = useCallback(async () => {
|
||||
@ -918,13 +950,27 @@ export function CustomAgentChatView({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSession) return;
|
||||
chatSessionViewCache.set(currentSession, {
|
||||
turns,
|
||||
progressIndex,
|
||||
chunkCount: scrollbackChunkCountRef.current,
|
||||
});
|
||||
}, [currentSession, progressIndex, turns]);
|
||||
if (currentSession) {
|
||||
chatSessionViewCache.set(currentSession, {
|
||||
turns,
|
||||
progressIndex,
|
||||
chunkCount: scrollbackChunkCountRef.current,
|
||||
sessionId: currentSession,
|
||||
});
|
||||
}
|
||||
if (currentConversationCacheKey) {
|
||||
const previous = chatConversationViewCache.get(currentConversationCacheKey);
|
||||
const cachedSessionId = currentSession ?? previous?.sessionId;
|
||||
if (cachedSessionId || turns.length > 0 || progressIndex.size > 0) {
|
||||
chatConversationViewCache.set(currentConversationCacheKey, {
|
||||
turns,
|
||||
progressIndex,
|
||||
chunkCount: scrollbackChunkCountRef.current,
|
||||
...(cachedSessionId ? { sessionId: cachedSessionId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentConversationCacheKey, currentSession, progressIndex, turns]);
|
||||
|
||||
useEffect(() => {
|
||||
draftSaveSeqRef.current += 1;
|
||||
@ -1216,6 +1262,15 @@ export function CustomAgentChatView({
|
||||
setOpening(true);
|
||||
setError(null);
|
||||
try {
|
||||
const localSession = sessionRef.current;
|
||||
if (!externalSessionId && localSession) {
|
||||
console.debug("[ticket149] openOrAttach:local-session-present:skip", {
|
||||
timestamp: new Date().toISOString(),
|
||||
execution,
|
||||
currentSession: localSession,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (externalSessionId) {
|
||||
const cached = chatSessionViewCache.get(externalSessionId);
|
||||
if (cached) {
|
||||
@ -1250,9 +1305,32 @@ export function CustomAgentChatView({
|
||||
error: instrumentationError(e),
|
||||
});
|
||||
}
|
||||
} else if (currentConversationCacheKey) {
|
||||
const cached = chatConversationViewCache.get(currentConversationCacheKey);
|
||||
if (cached) {
|
||||
scrollbackChunkCountRef.current = cached.chunkCount;
|
||||
applyChatViewState({
|
||||
turns: cached.turns,
|
||||
progressIndex: cached.progressIndex ?? new Map(),
|
||||
});
|
||||
if (cached.sessionId) {
|
||||
const reattachedSessionId = await reattachStructuredSession(cached.sessionId, {
|
||||
applyScrollback: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
publishSessionId(reattachedSessionId);
|
||||
console.debug("[ticket149] openOrAttach:reattach-conversation-cache:success", {
|
||||
timestamp: new Date().toISOString(),
|
||||
execution,
|
||||
sessionId: reattachedSessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await recoverStructuredSession({
|
||||
applyScrollback: false,
|
||||
applyScrollback: Boolean(conversationId),
|
||||
retryAttachNotFound: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
@ -1285,6 +1363,8 @@ export function CustomAgentChatView({
|
||||
recoverStructuredSession,
|
||||
publishSessionId,
|
||||
applyChatViewState,
|
||||
currentConversationCacheKey,
|
||||
conversationId,
|
||||
]);
|
||||
|
||||
const canSend = useMemo(
|
||||
|
||||
@ -266,6 +266,71 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
|
||||
});
|
||||
|
||||
it("restores a custom CLI transcript when returning with a conversation id but no session (#217)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const profileGateway = new MockProfileGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const system = new MockSystemGateway();
|
||||
await profileGateway.configureProfiles([structuredProfile]);
|
||||
const created = await agent.createAgent("p1", {
|
||||
name: "Worker",
|
||||
profileId: structuredProfile.id,
|
||||
});
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: created.id,
|
||||
});
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellConversation",
|
||||
target: leafId,
|
||||
conversationId: "conversation-known",
|
||||
});
|
||||
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
|
||||
const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({
|
||||
sessionId: "chat-resumed",
|
||||
cellKind: "chat" as const,
|
||||
});
|
||||
const reattachChat = vi.spyOn(agent, "reattachAgentChat").mockResolvedValue({
|
||||
sessionId: "chat-resumed",
|
||||
scrollback: [
|
||||
{ kind: "userPrompt", text: "known prompt" },
|
||||
{ kind: "final", content: "known answer" },
|
||||
],
|
||||
});
|
||||
|
||||
renderGrid({
|
||||
layout,
|
||||
agent,
|
||||
profile: profileGateway,
|
||||
terminal,
|
||||
system,
|
||||
} as unknown as Gateways);
|
||||
|
||||
expect(await screen.findByText("known answer")).toBeTruthy();
|
||||
expect(screen.getByText("known prompt")).toBeTruthy();
|
||||
expect(launchChat).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
created.id,
|
||||
expect.objectContaining({
|
||||
nodeId: leafId,
|
||||
conversationId: "conversation-known",
|
||||
}),
|
||||
);
|
||||
expect(reattachChat).toHaveBeenCalledWith("chat-resumed", expect.any(Function));
|
||||
await waitFor(async () => {
|
||||
const updated = await layout.loadLayout("p1");
|
||||
const leaf = leaves(updated)[0];
|
||||
expect(leaf.conversationId).toBe("conversation-known");
|
||||
expect(leaf.session).toBe("chat-resumed");
|
||||
});
|
||||
expect(screen.queryByText("Envoyez un message pour démarrer la conversation structurée."))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
|
||||
Reference in New Issue
Block a user