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:
@ -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();
|
||||
|
||||
Reference in New Issue
Block a user