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:
2026-09-09 18:44:01 +02:00
parent b1c173f7d0
commit 1b1476de52
14 changed files with 666 additions and 37 deletions

View File

@ -1827,15 +1827,40 @@ async fn ws_attach_chat(
tx: &mpsc::Sender<ServerFrame>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
) -> Result<(), ErrorDto> {
let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?;
let session = state
.app
.structured_sessions
.session(&sid)
.ok_or_else(|| ErrorDto {
code: "NOT_FOUND".to_owned(),
message: "structured chat session not found".to_owned(),
})?;
let requested_sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?;
let (sid, session) =
if let Some(session) = state.app.structured_sessions.session(&requested_sid) {
(requested_sid, session)
} else {
let fallback = frame
.payload
.get("projectId")
.and_then(Value::as_str)
.zip(frame.payload.get("agentId").and_then(Value::as_str));
let (project_id, agent_id) = fallback.ok_or_else(|| ErrorDto {
code: "NOT_FOUND".to_owned(),
message: "structured chat session not found".to_owned(),
})?;
let project_id = parse_project_id(project_id)?;
let agent_id = parse_agent_id(agent_id)?;
let sid = state
.app
.structured_sessions
.session_id_for_agent_in_project(project_id, &agent_id)
.ok_or_else(|| ErrorDto {
code: "NOT_FOUND".to_owned(),
message: "structured chat session not found".to_owned(),
})?;
let session = state
.app
.structured_sessions
.session(&sid)
.ok_or_else(|| ErrorDto {
code: "NOT_FOUND".to_owned(),
message: "structured chat session not found".to_owned(),
})?;
(sid, session)
};
tx.send(ServerFrame::chat_attached(
&frame.id,
&application::StructuredSessionDescriptor {
@ -7721,7 +7746,11 @@ mod tests {
ws_frame(
"chat-attach",
"chat.attach",
json!({ "sessionId": session_id.to_string() }),
json!({
"sessionId": session_id.to_string(),
"projectId": "not-parsed-for-a-live-session",
"agentId": "not-parsed-for-a-live-session",
}),
),
&state,
&tx,
@ -7742,6 +7771,94 @@ mod tests {
assert_eq!(state.ws_chat_bridge.active_sessions(), 1);
}
#[tokio::test]
async fn websocket_chat_attach_recovers_stale_session_from_live_project_agent() {
let state = state();
let stale_session_id = SessionId::new_random();
let live_session_id = SessionId::new_random();
let project_id = ProjectId::from_uuid(Uuid::new_v4());
let agent_id = AgentId::from_uuid(Uuid::new_v4());
let node_id = NodeId::from_uuid(Uuid::new_v4());
let session = Arc::new(FakeChatSession::new(
live_session_id,
vec![ReplyEvent::Final {
content: "unused".to_owned(),
}],
));
state.app.structured_sessions.insert_in_project(
project_id,
Arc::clone(&session) as Arc<dyn AgentSession>,
agent_id,
node_id,
);
let (tx, mut rx) = mpsc::channel(8);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"chat-attach-fallback",
"chat.attach",
json!({
"sessionId": stale_session_id.to_string(),
"projectId": project_id.to_string(),
"agentId": agent_id.to_string(),
}),
),
&state,
&tx,
&owned,
)
.await;
let attached = recv_server_frame(&mut rx).await;
assert_eq!(attached.kind, "chat.attached");
assert_eq!(attached.payload["sessionId"], live_session_id.to_string());
assert_eq!(
attached.payload["session"]["sessionId"],
live_session_id.to_string()
);
assert!(state.ws_chat_bridge.send_output(
&live_session_id,
json!({ "kind": "textDelta", "text": "after restart" })
));
let output = recv_server_frame(&mut rx).await;
assert_eq!(output.kind, "chat.output");
assert_eq!(output.payload["sessionId"], live_session_id.to_string());
assert_eq!(output.payload["chunk"]["text"], "after restart");
assert!(matches!(
owned.lock().unwrap().as_slice(),
[WsAttachment::Chat { session, .. }] if *session == live_session_id
));
}
#[tokio::test]
async fn websocket_chat_attach_stale_session_without_fallback_stays_not_found() {
let state = state();
let stale_session_id = SessionId::new_random();
let (tx, mut rx) = mpsc::channel(4);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"chat-attach-missing",
"chat.attach",
json!({ "sessionId": stale_session_id.to_string() }),
),
&state,
&tx,
&owned,
)
.await;
let error = recv_server_frame(&mut rx).await;
assert_eq!(error.kind, "error");
assert_eq!(error.reply_to.as_deref(), Some("chat-attach-missing"));
assert_eq!(error.payload["sessionId"], stale_session_id.to_string());
assert_eq!(error.payload["code"], "NOT_FOUND");
assert_eq!(state.ws_chat_bridge.active_sessions(), 0);
assert!(owned.lock().unwrap().is_empty());
}
#[tokio::test]
async fn websocket_chat_send_acknowledges_first_chunk_and_streams_rest() {
let state = state();