fix(chat): le transcript s'affiche à nouveau après redémarrage automatique d'un agent — #295 (app-tauri ChatBridge: cartes successors/owners, rebind_session déplace sink+scrolloback du logical chat vers la session de remplacement et canonical_session résout les écritures avec l'ancien id sur toutes les opérations register/scrollback/detach/send_output; backend stream: ReplayOutputBridge::rekey prépare le scrollback source devant le destination et invalide les générations pré-rekey des deux pumps; commands: agent_send binde l'owner stable (projectId,agentId) au tour humain et rebind avant le flux de continuation au handoff restart; reattach_agent_chat résout le successeur du bridge puis fallback identité stable projectId/agentId explicite ou owner bindé, renvoie sessionId effectif + conversationId, NOT_FOUND sinon; web-server chat.attach même fallback stale→live par project/agent; DTO ReattachChatDto/ReattachResultDto documentent l'id effectif post-restart; frontend: ReattachAgentChatOptions, conversationId dans ChatAttachedPayload, CustomAgentChatView réattache sur agentRestartCompleted (dédupe par restartId, scrollback appliqué, tours restart préservés), retry identité stable sur NOT_FOUND dans openOrAttach avant fallback launch frais, publication sessionId effectif + conversationId, état vide «Conversation liée» quand une conversation/run/workstate lié existe; QA verte reconfirmée par Git: git diff --check, cargo fmt --all --check, app-tauri chat_bridge 34/34 dont 3 nouveaux restart_rebind + dto_chat 24/24, cargo check -p app-tauri, backend 91+7 ignored permissions socket, web-server websocket_chat_attach 3/3, npm test 77/77 CustomAgentChatView+agentGateway, npm run build; réserves non bloquantes: warnings Vite esbuild/oxc et chunk >500kB connus)
This commit is contained in:
@ -275,11 +275,14 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
async reattachAgentChat(
|
||||
sessionId: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
options: { projectId?: string; agentId?: string } = {},
|
||||
): Promise<ReattachAgentChatResult> {
|
||||
const channel = new Channel<ReplyChunk>();
|
||||
channel.onmessage = onChunk;
|
||||
return invoke<ReattachAgentChatResult>("reattach_agent_chat", {
|
||||
sessionId,
|
||||
...(options.projectId ? { projectId: options.projectId } : {}),
|
||||
...(options.agentId ? { agentId: options.agentId } : {}),
|
||||
onReply: channel,
|
||||
});
|
||||
}
|
||||
|
||||
@ -313,21 +313,30 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
||||
freshStart: false,
|
||||
});
|
||||
|
||||
const reattached = gw.reattachAgentChat("chat-1", () => {});
|
||||
const reattached = gw.reattachAgentChat("chat-1", () => {}, {
|
||||
projectId: "proj-1",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
const attach = await replyToLast(socket, 1, (id) => ({
|
||||
kind: "chat.attached",
|
||||
replyTo: id,
|
||||
payload: {
|
||||
sessionId: "chat-1",
|
||||
conversationId: "conv-replacement",
|
||||
scrollback: [{ kind: "final", content: "Recovered reply" }],
|
||||
},
|
||||
}));
|
||||
await expect(reattached).resolves.toEqual({
|
||||
sessionId: "chat-1",
|
||||
conversationId: "conv-replacement",
|
||||
scrollback: [{ kind: "final", content: "Recovered reply" }],
|
||||
});
|
||||
expect(attach.kind).toBe("chat.attach");
|
||||
expect(attach.payload).toEqual({ sessionId: "chat-1" });
|
||||
expect(attach.payload).toEqual({
|
||||
sessionId: "chat-1",
|
||||
projectId: "proj-1",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
const chunks: unknown[] = [];
|
||||
const sent = gw.sendAgentChat("chat-1", "Hello", (chunk) => chunks.push(chunk), {
|
||||
|
||||
@ -152,5 +152,7 @@ export interface ChatAttachedPayload {
|
||||
};
|
||||
cellKind?: "chat" | "pty";
|
||||
assignedConversationId?: string;
|
||||
conversationId?: string;
|
||||
engineSessionId?: string;
|
||||
scrollback?: ReplyChunk[];
|
||||
}
|
||||
|
||||
@ -379,8 +379,9 @@ export class HttpAgentGateway implements AgentGateway {
|
||||
async reattachAgentChat(
|
||||
sessionId: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
options: { projectId?: string; agentId?: string } = {},
|
||||
): Promise<ReattachAgentChatResult> {
|
||||
const ack = await this.ws.send("chat.attach", { sessionId });
|
||||
const ack = await this.ws.send("chat.attach", { sessionId, ...options });
|
||||
if (ack.kind !== "chat.attached") {
|
||||
throw {
|
||||
code: "CHAT_ATTACH_FAILED",
|
||||
@ -392,6 +393,14 @@ export class HttpAgentGateway implements AgentGateway {
|
||||
this.ws.setChatSink(attachedSessionId, onChunk);
|
||||
return {
|
||||
sessionId: attachedSessionId,
|
||||
...(payload.conversationId ?? payload.assignedConversationId ?? payload.engineSessionId
|
||||
? {
|
||||
conversationId:
|
||||
payload.conversationId ??
|
||||
payload.assignedConversationId ??
|
||||
payload.engineSessionId,
|
||||
}
|
||||
: {}),
|
||||
scrollback: payload.scrollback ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -231,6 +231,143 @@ describe("CustomAgentChatView", () => {
|
||||
expect(screen.getByText("runtime unavailable")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reconciles a restarted chat to its replacement session and keeps receiving chunks", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const liveReceivers: Array<(chunk: ReplyChunk) => void> = [];
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
async (_sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||||
liveReceivers.push(onChunk);
|
||||
return {
|
||||
sessionId: "old-session",
|
||||
conversationId: "old-conversation",
|
||||
scrollback: [{ kind: "final", content: "Avant redémarrage" }],
|
||||
};
|
||||
},
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
async (_sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||||
liveReceivers.push(onChunk);
|
||||
return {
|
||||
sessionId: "replacement-session",
|
||||
conversationId: "replacement-conversation",
|
||||
scrollback: [
|
||||
{ kind: "final", content: "Avant redémarrage" },
|
||||
{ kind: "userPrompt", text: "Continue" },
|
||||
{ kind: "textDelta", text: "Reprise conservée" },
|
||||
],
|
||||
};
|
||||
},
|
||||
),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
const onSessionId = vi.fn();
|
||||
const onConversationId = vi.fn();
|
||||
|
||||
render(
|
||||
<DIProvider gateways={{ agent, system } as unknown as Gateways}>
|
||||
<AnnouncementsProvider projectId="project-1">
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId="old-session"
|
||||
conversationId="old-conversation"
|
||||
onSessionId={onSessionId}
|
||||
onConversationId={onConversationId}
|
||||
/>
|
||||
</AnnouncementsProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Avant redémarrage")).toBeTruthy();
|
||||
act(() => {
|
||||
system.emit({
|
||||
type: "agentRestartRequested",
|
||||
agentId: "agent-1",
|
||||
restartId: "restart-295",
|
||||
reason: "reload plugin",
|
||||
});
|
||||
system.emit({
|
||||
type: "agentRestartCompleted",
|
||||
agentId: "agent-1",
|
||||
restartId: "restart-295",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledTimes(2));
|
||||
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"old-session",
|
||||
expect.any(Function),
|
||||
{ projectId: "project-1", agentId: "agent-1" },
|
||||
);
|
||||
expect(await screen.findByText("Reprise conservée")).toBeTruthy();
|
||||
expect(onSessionId).toHaveBeenCalledWith("replacement-session");
|
||||
expect(onConversationId).toHaveBeenCalledWith("replacement-conversation");
|
||||
|
||||
act(() => {
|
||||
liveReceivers[1]?.({ kind: "final", content: "Chunk après redémarrage" });
|
||||
});
|
||||
expect(await screen.findByText("Chunk après redémarrage")).toBeTruthy();
|
||||
expect(screen.queryByText(/Envoyez un message/)).toBeNull();
|
||||
});
|
||||
|
||||
it("retries a stale session attach with stable project and agent identity", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "stale session" })
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "replacement-session",
|
||||
conversationId: "replacement-conversation",
|
||||
scrollback: [{ kind: "final", content: "Transcript restauré" }],
|
||||
}),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
const onSessionId = vi.fn();
|
||||
|
||||
render(
|
||||
<DIProvider
|
||||
gateways={{ agent, system: new MockSystemGateway() } as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId="stale-session"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={onSessionId}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Transcript restauré")).toBeTruthy();
|
||||
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"stale-session",
|
||||
expect.any(Function),
|
||||
{ projectId: "project-1", agentId: "agent-1" },
|
||||
);
|
||||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||||
expect(onSessionId).toHaveBeenCalledWith("replacement-session");
|
||||
});
|
||||
|
||||
it("shows the technical open failure on mobile with retry and native TUI fallback", async () => {
|
||||
const openNativeTui = vi.fn();
|
||||
const agent = {
|
||||
@ -1344,6 +1481,7 @@ describe("CustomAgentChatView", () => {
|
||||
reattachAgentChat: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" })
|
||||
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" })
|
||||
.mockImplementationOnce(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
@ -4829,6 +4967,9 @@ describe("CustomAgentChatView", () => {
|
||||
.mockRejectedValueOnce(
|
||||
"not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6",
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
"not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6",
|
||||
)
|
||||
.mockImplementationOnce(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
@ -4869,6 +5010,12 @@ describe("CustomAgentChatView", () => {
|
||||
);
|
||||
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"69b57638-0ac7-4a79-8252-e0436a3265f6",
|
||||
expect.any(Function),
|
||||
{ projectId: "project-1", agentId: "agent-1" },
|
||||
);
|
||||
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"chat-session-2",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
@ -1015,6 +1015,7 @@ export function CustomAgentChatView({
|
||||
const mountedRef = useRef(false);
|
||||
const openOrAttachCountRef = useRef(0);
|
||||
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
|
||||
const reconciledRestartIdsRef = useRef(new Set<string>());
|
||||
const onSessionIdRef = useRef(onSessionId);
|
||||
onSessionIdRef.current = onSessionId;
|
||||
const onConversationIdRef = useRef(onConversationId);
|
||||
@ -1543,7 +1544,14 @@ export function CustomAgentChatView({
|
||||
}, []);
|
||||
|
||||
const reattachStructuredSession = useCallback(
|
||||
async (sid: string, options: { applyScrollback?: boolean } = {}) => {
|
||||
async (
|
||||
sid: string,
|
||||
options: {
|
||||
applyScrollback?: boolean;
|
||||
stableIdentity?: boolean;
|
||||
preserveRestartTurns?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
if (!agent.reattachAgentChat) throw new Error("Structured reattach unavailable");
|
||||
console.debug("[ticket149] reattachStructuredSession:start", {
|
||||
timestamp: new Date().toISOString(),
|
||||
@ -1552,7 +1560,9 @@ export function CustomAgentChatView({
|
||||
});
|
||||
let reattached: Awaited<ReturnType<NonNullable<typeof agent.reattachAgentChat>>>;
|
||||
try {
|
||||
reattached = await agent.reattachAgentChat(sid, receive);
|
||||
reattached = options.stableIdentity
|
||||
? await agent.reattachAgentChat(sid, receive, { projectId, agentId })
|
||||
: await agent.reattachAgentChat(sid, receive);
|
||||
} catch (e) {
|
||||
console.error("[ticket149] reattachStructuredSession:error", {
|
||||
timestamp: new Date().toISOString(),
|
||||
@ -1569,20 +1579,62 @@ export function CustomAgentChatView({
|
||||
});
|
||||
if (!mountedRef.current) return reattached.sessionId;
|
||||
setCurrentSession(reattached.sessionId);
|
||||
if (reattached.sessionId !== sid) {
|
||||
publishSessionId(reattached.sessionId);
|
||||
}
|
||||
if (reattached.conversationId) {
|
||||
onConversationIdRef.current(reattached.conversationId);
|
||||
}
|
||||
if (options.applyScrollback) {
|
||||
scrollbackChunkCountRef.current = reattached.scrollback.length;
|
||||
const replayed = reattached.scrollback.reduce(
|
||||
let replayed = reattached.scrollback.reduce(
|
||||
(state, chunk) => foldChunk(state, chunk),
|
||||
emptyChatViewState(),
|
||||
);
|
||||
if (options.preserveRestartTurns) {
|
||||
const restartTurns = turnsRef.current.filter((turn) => turn.role === "restart");
|
||||
if (restartTurns.length > 0) {
|
||||
replayed = { ...replayed, turns: [...replayed.turns, ...restartTurns] };
|
||||
}
|
||||
}
|
||||
applyChatViewState(replayed);
|
||||
setBusy(turnsIndicateBusy(replayed));
|
||||
}
|
||||
return reattached.sessionId;
|
||||
},
|
||||
[agent, applyChatViewState, receive],
|
||||
[agent, agentId, applyChatViewState, projectId, publishSessionId, receive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const completed = [...restartEvents]
|
||||
.reverse()
|
||||
.find((event) => event.type === "agentRestartCompleted");
|
||||
if (!completed || reconciledRestartIdsRef.current.has(completed.restartId)) return;
|
||||
const sid = sessionRef.current ?? externalSessionId;
|
||||
if (!sid) return;
|
||||
|
||||
reconciledRestartIdsRef.current.add(completed.restartId);
|
||||
let cancelled = false;
|
||||
setOpening(true);
|
||||
setError(null);
|
||||
void reattachStructuredSession(sid, {
|
||||
applyScrollback: true,
|
||||
stableIdentity: true,
|
||||
preserveRestartTurns: true,
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError({ message: describeOpenFailure(e), openFailure: true });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setOpening(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [externalSessionId, reattachStructuredSession, restartEvents]);
|
||||
|
||||
const recoverStructuredSession = useCallback(
|
||||
async (
|
||||
options: {
|
||||
@ -1749,6 +1801,23 @@ export function CustomAgentChatView({
|
||||
// the session is gone (closed/never live) and the caller falls back
|
||||
// to a fresh launch — any other error still surfaces to the user.
|
||||
if (!isNotFound(e)) throw e;
|
||||
try {
|
||||
const replacementSessionId = await reattachStructuredSession(externalSessionId, {
|
||||
applyScrollback: true,
|
||||
stableIdentity: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
publishSessionId(replacementSessionId);
|
||||
console.debug("[ticket295] openOrAttach:stable-reattach:success", {
|
||||
timestamp: new Date().toISOString(),
|
||||
execution,
|
||||
requestedSessionId: externalSessionId,
|
||||
replacementSessionId,
|
||||
});
|
||||
return;
|
||||
} catch (stableError) {
|
||||
if (!isNotFound(stableError)) throw stableError;
|
||||
}
|
||||
if (!cancelled) {
|
||||
setCurrentSession(null);
|
||||
publishSessionId(null);
|
||||
@ -1834,6 +1903,12 @@ export function CustomAgentChatView({
|
||||
!opening,
|
||||
[supported, draft, attachments.length, effectiveBusy, opening],
|
||||
);
|
||||
const hasLinkedConversation = Boolean(
|
||||
conversationId ||
|
||||
requesterDelegation.currentAgentBusy ||
|
||||
restart ||
|
||||
delegationActive,
|
||||
);
|
||||
|
||||
async function openProfileDialog() {
|
||||
const availableProfiles = (await profileGateway.listProfiles()).filter((candidate) =>
|
||||
@ -2412,6 +2487,10 @@ export function CustomAgentChatView({
|
||||
{externalSessionId ? "Reprise en cours..." : "Ouverture de la session structurée…"}
|
||||
</span>
|
||||
</div>
|
||||
) : displayTurns.length === 0 && hasLinkedConversation ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Conversation liée — récupération du transcript en cours.
|
||||
</p>
|
||||
) : displayTurns.length === 0 ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Envoyez un message pour démarrer la conversation structurée.
|
||||
|
||||
@ -204,12 +204,20 @@ export interface AgentChatHandle {
|
||||
|
||||
/** Result of re-attaching a custom chat view to a live structured session. */
|
||||
export interface ReattachAgentChatResult {
|
||||
/** Stable structured session id. */
|
||||
/** Effective live structured session id (may replace the requested id after restart). */
|
||||
sessionId: string;
|
||||
/** Effective provider conversation id, when known by the structured adapter. */
|
||||
conversationId?: string;
|
||||
/** Retained chunks already streamed for the session. */
|
||||
scrollback: ReplyChunk[];
|
||||
}
|
||||
|
||||
/** Stable owner identity used to reconcile a stale transport id after restart. */
|
||||
export interface ReattachAgentChatOptions {
|
||||
projectId?: string;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
/** Agents: create, list, read/update context, delete, launch (L6). */
|
||||
export interface AgentGateway {
|
||||
/** Lists all agents belonging to the given project. */
|
||||
@ -324,6 +332,7 @@ export interface AgentGateway {
|
||||
reattachAgentChat?(
|
||||
sessionId: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
options?: ReattachAgentChatOptions,
|
||||
): Promise<ReattachAgentChatResult>;
|
||||
/** Detaches the current custom chat view transport while keeping the session alive. */
|
||||
detachAgentChat?(sessionId: string): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user