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);
|
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 () => {
|
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
|
|||||||
@ -67,15 +67,18 @@ interface CachedChatView {
|
|||||||
turns: ChatTurn[];
|
turns: ChatTurn[];
|
||||||
progressIndex: Map<string, ProgressIndexEntry>;
|
progressIndex: Map<string, ProgressIndexEntry>;
|
||||||
chunkCount: number;
|
chunkCount: number;
|
||||||
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatSessionViewCache = new Map<string, CachedChatView>();
|
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 EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
|
||||||
const COMPOSER_RESIZE_STEP = 12;
|
const COMPOSER_RESIZE_STEP = 12;
|
||||||
const FALLBACK_COMPOSER_HEIGHT = 64;
|
const FALLBACK_COMPOSER_HEIGHT = 64;
|
||||||
|
|
||||||
export function __clearCustomAgentChatViewCacheForTests() {
|
export function __clearCustomAgentChatViewCacheForTests() {
|
||||||
chatSessionViewCache.clear();
|
chatSessionViewCache.clear();
|
||||||
|
chatConversationViewCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AttachmentDraft {
|
interface AttachmentDraft {
|
||||||
@ -310,6 +313,14 @@ function emptyChatViewState(): ChatViewState {
|
|||||||
return { turns: [], progressIndex: new Map() };
|
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 {
|
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
|
||||||
const key = progressUpsertKey(progress);
|
const key = progressUpsertKey(progress);
|
||||||
const existingIndex = state.turns.findIndex(
|
const existingIndex = state.turns.findIndex(
|
||||||
@ -711,10 +722,27 @@ export function CustomAgentChatView({
|
|||||||
}: CustomAgentChatViewProps) {
|
}: CustomAgentChatViewProps) {
|
||||||
const { agent, profile: profileGateway, system } = useGateways();
|
const { agent, profile: profileGateway, system } = useGateways();
|
||||||
const pluginRuntime = usePluginRuntime();
|
const pluginRuntime = usePluginRuntime();
|
||||||
const [turns, setTurns] = useState<ChatTurn[]>(
|
const initialConversationCacheKey = conversationCacheKey(
|
||||||
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
|
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 [currentSession, setCurrentSession] = useState(sessionId);
|
||||||
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
@ -724,7 +752,7 @@ export function CustomAgentChatView({
|
|||||||
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
|
||||||
const [profileDialog, setProfileDialog] =
|
const [profileDialog, setProfileDialog] =
|
||||||
useState<ProfileCommandDialogState | null>(null);
|
useState<ProfileCommandDialogState | null>(null);
|
||||||
const [opening, setOpening] = useState(Boolean(sessionId));
|
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [stopping, setStopping] = useState(false);
|
const [stopping, setStopping] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -849,6 +877,10 @@ export function CustomAgentChatView({
|
|||||||
() => (layoutId ? { projectId, layoutId, nodeId } : null),
|
() => (layoutId ? { projectId, layoutId, nodeId } : null),
|
||||||
[layoutId, nodeId, projectId],
|
[layoutId, nodeId, projectId],
|
||||||
);
|
);
|
||||||
|
const currentConversationCacheKey = useMemo(
|
||||||
|
() => conversationCacheKey(projectId, agentId, conversationId),
|
||||||
|
[agentId, conversationId, projectId],
|
||||||
|
);
|
||||||
const displayTurns = turns;
|
const displayTurns = turns;
|
||||||
|
|
||||||
const clearPersistedDraft = useCallback(async () => {
|
const clearPersistedDraft = useCallback(async () => {
|
||||||
@ -918,13 +950,27 @@ export function CustomAgentChatView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentSession) return;
|
if (currentSession) {
|
||||||
chatSessionViewCache.set(currentSession, {
|
chatSessionViewCache.set(currentSession, {
|
||||||
turns,
|
turns,
|
||||||
progressIndex,
|
progressIndex,
|
||||||
chunkCount: scrollbackChunkCountRef.current,
|
chunkCount: scrollbackChunkCountRef.current,
|
||||||
});
|
sessionId: currentSession,
|
||||||
}, [currentSession, progressIndex, turns]);
|
});
|
||||||
|
}
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
draftSaveSeqRef.current += 1;
|
draftSaveSeqRef.current += 1;
|
||||||
@ -1216,6 +1262,15 @@ export function CustomAgentChatView({
|
|||||||
setOpening(true);
|
setOpening(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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) {
|
if (externalSessionId) {
|
||||||
const cached = chatSessionViewCache.get(externalSessionId);
|
const cached = chatSessionViewCache.get(externalSessionId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@ -1250,9 +1305,32 @@ export function CustomAgentChatView({
|
|||||||
error: instrumentationError(e),
|
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({
|
await recoverStructuredSession({
|
||||||
applyScrollback: false,
|
applyScrollback: Boolean(conversationId),
|
||||||
retryAttachNotFound: true,
|
retryAttachNotFound: true,
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@ -1285,6 +1363,8 @@ export function CustomAgentChatView({
|
|||||||
recoverStructuredSession,
|
recoverStructuredSession,
|
||||||
publishSessionId,
|
publishSessionId,
|
||||||
applyChatViewState,
|
applyChatViewState,
|
||||||
|
currentConversationCacheKey,
|
||||||
|
conversationId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canSend = useMemo(
|
const canSend = useMemo(
|
||||||
|
|||||||
@ -266,6 +266,71 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
|
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 () => {
|
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
|
||||||
const layout = new MockLayoutGateway();
|
const layout = new MockLayoutGateway();
|
||||||
const agent = new MockAgentGateway();
|
const agent = new MockAgentGateway();
|
||||||
|
|||||||
Reference in New Issue
Block a user