From 54495261578608335ee0f3e5af56f1ab74dcbceb Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 22 Aug 2026 11:30:35 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(orchestrator):=20le=20lancement=20d'une?= =?UTF-8?q?=20cible=20structur=C3=A9e=20froide=20lie=20la=20session=20au?= =?UTF-8?q?=20fil=20A-B=20r=C3=A9solu=20par=20ask=5Fagent=20=E2=80=94=20#2?= =?UTF-8?q?38?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure_structured_session reçoit désormais le conversation_id déjà résolu et le transmet au plan de spawn au lieu de retomber sur le fallback User-B. WorkState et la session lancée pointent ainsi sur le même fil. Test de régression ask_cold_structured_agent_requester_launches_on_resolved_pair_thread. --- .../application/src/orchestrator/service.rs | 11 ++- .../application/tests/orchestrator_service.rs | 71 ++++++++++++++++++- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 986d93a9..5e01904c 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -1862,7 +1862,13 @@ impl OrchestratorService { ) })?; let session = self - .ensure_structured_session(project, agent_id, &agent.profile_id, structured) + .ensure_structured_session( + project, + agent_id, + &agent.profile_id, + structured, + conversation_id, + ) .await? .ok_or_else(|| { AppError::Invalid(format!( @@ -2558,6 +2564,7 @@ impl OrchestratorService { agent_id: AgentId, profile_id: &ProfileId, structured: &Arc, + conversation_id: domain::conversation::ConversationId, ) -> Result>, AppError> { // Cible déjà chaude : route directe (aucun lancement). if let Some(session) = structured.session_for_agent_in_project(project.id, &agent_id) { @@ -2589,7 +2596,7 @@ impl OrchestratorService { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, node_id: None, - conversation_id: None, + conversation_id: Some(conversation_id.to_string()), mcp_runtime: self .mcp_runtime_provider .as_ref() diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 4f07ea65..f116a378 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -4293,17 +4293,22 @@ impl AgentSession for EchoSession { struct CountingFactory { starts: Arc, next_id: Arc>, + session_plans: Arc>>, } impl CountingFactory { fn new() -> Self { Self { starts: Arc::new(AtomicUsize::new(0)), next_id: Arc::new(Mutex::new(9000)), + session_plans: Arc::new(Mutex::new(Vec::new())), } } fn start_count(&self) -> usize { self.starts.load(Ordering::SeqCst) } + fn session_plans(&self) -> Vec { + self.session_plans.lock().unwrap().clone() + } } #[async_trait] impl AgentSessionFactory for CountingFactory { @@ -4315,13 +4320,14 @@ impl AgentSessionFactory for CountingFactory { _profile: &AgentProfile, _ctx: &PreparedContext, _cwd: &ProjectPath, - _session: &SessionPlan, + session: &SessionPlan, _requester: Option<&str>, _env: &[(String, String)], _sandbox: Option<&domain::sandbox::SandboxPlan>, _structured_policy: Option<&domain::ports::StructuredProviderLaunchPolicy>, ) -> Result, AgentSessionError> { self.starts.fetch_add(1, Ordering::SeqCst); + self.session_plans.lock().unwrap().push(session.clone()); let id = { let mut n = self.next_id.lock().unwrap(); let id = SessionId::from_uuid(Uuid::from_u128(*n)); @@ -4500,6 +4506,7 @@ struct StructuredAskFixture { session_limits: Arc, background_store: Arc, background_runner: Arc, + conversations: Arc, } fn structured_ask_fixture(contexts: FakeContexts) -> StructuredAskFixture { @@ -4574,7 +4581,7 @@ fn structured_ask_fixture_with_background( Arc::clone(&mailbox), Arc::new(pty.clone()) as Arc, )); - let conversations = Arc::new(TestConversations::new()) as Arc; + let conversations = Arc::new(TestConversations::new()); let session_limits = Arc::new(SessionLimitService::new( Arc::new(FixedMillisClock(1_000)) as Arc, Arc::new(NoopScheduler::default()) as Arc, @@ -4596,7 +4603,7 @@ fn structured_ask_fixture_with_background( Arc::clone(&mediator) as Arc, Arc::clone(&mailbox) as Arc, ) - .with_conversations(conversations) + .with_conversations(Arc::clone(&conversations) as Arc) .with_events(Arc::new(bus.clone())) .with_session_limits(Arc::clone(&session_limits)) // Même registre que le launcher : `ask_agent` y relit la session fraîchement @@ -4626,6 +4633,7 @@ fn structured_ask_fixture_with_background( session_limits, background_store, background_runner, + conversations, } } @@ -4677,6 +4685,63 @@ async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() { ); } +/// Régression #238 : quand une cible structurée froide est lancée pour un ask A→B, +/// le launcher doit recevoir l'id du fil A↔B déjà résolu par `ask_agent`. Sans cela, +/// `launch_structured` retombe sur le fallback User↔B : WorkState pointe alors vers le +/// ticket/log A↔B tandis que la session/vue fraîchement lancée est liée à User↔B. +#[tokio::test] +async fn ask_cold_structured_agent_requester_launches_on_resolved_pair_thread() { + let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); + let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona")); + + let requester = aid(7); + let requester_uuid = uuid::Uuid::from_u128(7); + let ask_json = format!( + r#"{{ "type":"agent.message", "requestedBy":"{requester_uuid}", "targetAgent":"architect", "task":"Analyse §17" }}"# + ); + + let out = fx + .service + .dispatch(&project(), cmd(&ask_json)) + .await + .expect("ask ok"); + + assert_eq!(out.reply.as_deref(), Some("structured: Analyse §17")); + assert_eq!( + fx.factory.start_count(), + 1, + "the cold structured target is launched exactly once" + ); + + let expected = fx + .conversations + .resolve( + project_id(), + domain::ConversationParty::agent(requester), + domain::ConversationParty::agent(aid(1)), + ) + .id; + let user_target = fx + .conversations + .resolve( + project_id(), + domain::ConversationParty::User, + domain::ConversationParty::agent(aid(1)), + ) + .id; + assert_ne!( + expected, user_target, + "the regression guard must distinguish agent-requester and user threads" + ); + assert_eq!( + fx.factory.session_plans(), + vec![SessionPlan::Resume { + conversation_id: expected.to_string() + }], + "cold launch must reuse the conversation id resolved by ask_agent" + ); +} + /// Réutilisation : une **deuxième** délégation vers la **même** cible (désormais chaude) /// ne relance **pas** de session — elle réutilise celle insérée au premier `ask`. Prouve /// que le `session_for_agent` court-circuite l'auto-lancement (idempotence du chemin). From da911d5a9bcd14f746fdde398d5a2b19b6b1c21b Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 22 Aug 2026 11:30:38 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(chat):=20=C3=A9tats=20vides=20explicite?= =?UTF-8?q?s=20et=20reroutage=20vers=20le=20fil=20actif=20quand=20Work=20s?= =?UTF-8?q?tate=20signale=20un=20agent=20busy=20=E2=80=94=20#238?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConversationViewer remplace « Aucun tour pour le moment » par un diagnostic differentié (agent actif sans tour affichable, transcript non synchronisé, activité sur un autre fil) avec action « Ouvrir le fil actif ». Le panneau Work state affiche le résumé du fil du ticket actif et ProjectsView/WebWorkspace câblent onOpenConversation. --- .../conversations/ConversationViewer.test.tsx | 112 +++++++++++++ .../conversations/ConversationViewer.tsx | 147 +++++++++++++++++- .../src/features/projects/ProjectsView.tsx | 2 + frontend/src/features/web/WebWorkspace.tsx | 1 + .../workstate/ProjectWorkStatePanel.tsx | 10 ++ 5 files changed, 270 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/conversations/ConversationViewer.test.tsx b/frontend/src/features/conversations/ConversationViewer.test.tsx index e3ae617f..ef387de3 100644 --- a/frontend/src/features/conversations/ConversationViewer.test.tsx +++ b/frontend/src/features/conversations/ConversationViewer.test.tsx @@ -41,6 +41,7 @@ function renderViewer(opts: { conversation: ConversationGateway; workState?: MockWorkStateGateway; onClose?: () => void; + onOpenConversation?: (conversationId: string) => void; }) { const workState = opts.workState ?? new MockWorkStateGateway(); const gateways = { @@ -59,6 +60,7 @@ function renderViewer(opts: { projectId={P} conversationId={C} onClose={onClose} + onOpenConversation={opts.onOpenConversation} />, ), ), @@ -132,6 +134,116 @@ describe("ConversationViewer", () => { expect(await screen.findByText("Aucun tour pour le moment.")).toBeTruthy(); }); + it("distinguishes an active current thread whose transcript has no visible turns yet", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(P, { + agents: [ + { + agentId: "architect", + name: "Architect", + profileId: "codex", + busy: { state: "busy", ticket: "ticket-1", sinceMs: 10 }, + tickets: [ + { + ticketId: "ticket-1", + conversationId: C, + position: 0, + status: "inProgress", + source: { kind: "agent", agentId: "main" }, + requesterLabel: "Main", + taskPreview: "Diagnostiquer le bug", + taskLen: 20, + }, + ], + }, + ], + }); + renderViewer({ conversation: seeded([]), workState }); + + expect( + await screen.findByText("Agent actif, aucun message visible pour le moment."), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Ouvrir le fil actif" })).toBeNull(); + }); + + it("offers to open the active canonical thread when another conversation is busy", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(P, { + agents: [ + { + agentId: "architect", + name: "Architect", + profileId: "codex", + busy: { state: "busy", ticket: "ticket-1", sinceMs: 10 }, + tickets: [ + { + ticketId: "ticket-1", + conversationId: "conv-active", + position: 0, + status: "inProgress", + source: { kind: "agent", agentId: "main" }, + requesterLabel: "Main", + taskPreview: "Conversation active", + taskLen: 19, + }, + ], + }, + ], + }); + const onOpenConversation = vi.fn(); + renderViewer({ + conversation: seeded([]), + workState, + onOpenConversation, + }); + + expect(await screen.findByText("Aucun tour sur ce fil.")).toBeTruthy(); + expect(screen.getByText(/L'activité en cours semble être sur un autre fil/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Ouvrir le fil actif" })); + expect(onOpenConversation).toHaveBeenCalledWith("conv-active"); + }); + + it("distinguishes a work-state summary that has not reached transcript pagination yet", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(P, { + agents: [], + conversations: [ + { + conversationId: C, + status: "partial", + objectivePreview: "Analyser la désynchronisation", + summaryPreview: null, + summaryLen: 0, + upTo: null, + recentTurns: [], + }, + ], + }); + renderViewer({ conversation: seeded([]), workState }); + + expect( + await screen.findByText("Activité détectée, transcript pas encore synchronisé."), + ).toBeTruthy(); + }); + + it("distinguishes a busy agent with no visible thread binding yet", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(P, { + agents: [ + { + agentId: "architect", + name: "Architect", + profileId: "codex", + busy: { state: "busy", ticket: "ticket-1", sinceMs: 10 }, + tickets: [], + }, + ], + }); + renderViewer({ conversation: seeded([]), workState }); + + expect(await screen.findByText("Agent actif sans message visible.")).toBeTruthy(); + }); + it("shows the loading state while the first page is in flight", async () => { const gated: ConversationGateway = { readPage: () => new Promise(() => {}) }; renderViewer({ conversation: gated }); diff --git a/frontend/src/features/conversations/ConversationViewer.tsx b/frontend/src/features/conversations/ConversationViewer.tsx index 7068b6c7..ffd54f01 100644 --- a/frontend/src/features/conversations/ConversationViewer.tsx +++ b/frontend/src/features/conversations/ConversationViewer.tsx @@ -10,7 +10,14 @@ import { useMemo, useState } from "react"; -import type { TurnSource, TurnView } from "@/domain"; +import type { + AgentWorkState, + BackgroundCompletion, + ConversationWorkSummary, + ProjectWorkState, + TurnSource, + TurnView, +} from "@/domain"; import { Button, Spinner, cn } from "@/shared"; import { useProjectWorkState } from "@/features/workstate"; import { useConversationThread } from "./useConversationThread"; @@ -19,6 +26,7 @@ export interface ConversationViewerProps { projectId: string; conversationId: string; onClose: () => void; + onOpenConversation?: (conversationId: string) => void; } function shortId(id: string): string { @@ -50,6 +58,118 @@ function roleLabel(role: TurnView["role"]): string { } } +interface ConversationActivity { + conversationId: string; + label: string; + priority: number; +} + +interface EmptyThreadState { + message: string; + detail?: string; + action?: { + conversationId: string; + label: string; + }; +} + +function taskIsActive(status: BackgroundCompletion["status"]): boolean { + return status === "running" || status === "pending" || status === "cancelling"; +} + +function agentLabel(agent: AgentWorkState): string { + return agent.name.trim() || `Agent ${shortId(agent.agentId)}`; +} + +function summaryHasVisibleContent(summary: ConversationWorkSummary): boolean { + return Boolean( + summary.objectivePreview || + summary.summaryPreview || + (summary.recentTurns ?? []).length > 0, + ); +} + +function activeConversations(state: ProjectWorkState | null): ConversationActivity[] { + const out: ConversationActivity[] = []; + for (const agent of state?.agents ?? []) { + for (const ticket of agent.tickets ?? []) { + out.push({ + conversationId: ticket.conversationId, + label: `${agentLabel(agent)} · ${ticket.taskPreview || "ticket actif"}`, + priority: ticket.status === "inProgress" ? 0 : 1, + }); + } + for (const task of agent.backgroundTasks ?? []) { + if (!task.conversationId || !taskIsActive(task.status)) continue; + out.push({ + conversationId: task.conversationId, + label: `${agentLabel(agent)} · ${task.kind}`, + priority: 2, + }); + } + } + return out.sort( + (a, b) => + a.priority - b.priority || + a.conversationId.localeCompare(b.conversationId), + ); +} + +function emptyThreadState( + conversationId: string, + state: ProjectWorkState | null, +): EmptyThreadState { + const summaries = state?.conversations ?? []; + const currentSummary = summaries.find( + (summary) => summary.conversationId === conversationId, + ); + const activities = activeConversations(state); + const currentActivity = activities.find( + (activity) => activity.conversationId === conversationId, + ); + if (currentActivity) { + return { + message: "Agent actif, aucun message visible pour le moment.", + detail: + "Le fil est bien celui de l'activité en cours, mais le transcript n'a pas encore livré de tour affichable.", + }; + } + if (currentSummary && summaryHasVisibleContent(currentSummary)) { + return { + message: "Activité détectée, transcript pas encore synchronisé.", + detail: + "Le résumé Work state connaît ce fil, mais la lecture paginée ne renvoie encore aucun tour.", + }; + } + + const otherActivity = activities.find( + (activity) => activity.conversationId !== conversationId, + ); + if (otherActivity) { + return { + message: "Aucun tour sur ce fil.", + detail: `L'activité en cours semble être sur un autre fil: ${otherActivity.label}.`, + action: { + conversationId: otherActivity.conversationId, + label: "Ouvrir le fil actif", + }, + }; + } + + const busyAgent = (state?.agents ?? []).find( + (agent) => agent.busy?.state === "busy", + ); + if (busyAgent) { + return { + message: "Agent actif sans message visible.", + detail: + "Work state signale une activité, mais aucun ticket ou fil actif n'est encore associé au snapshot courant.", + }; + } + + return { message: "Aucun tour pour le moment." }; +} + /** One collapsible tool-activity turn (dimmed, folded by default). */ function ToolTurn({ turn, sideClass }: { turn: TurnView; sideClass: string }) { const [open, setOpen] = useState(false); @@ -87,6 +207,7 @@ export function ConversationViewer({ projectId, conversationId, onClose, + onOpenConversation, }: ConversationViewerProps) { const vm = useConversationThread(projectId, conversationId); // Reuse the already-loaded work-state inventory to resolve agent names. @@ -126,6 +247,11 @@ export function ConversationViewer({ return "Conversation"; }, [parties]); + const emptyState = useMemo( + () => emptyThreadState(conversationId, work.state), + [conversationId, work.state], + ); + function sideOf(source: TurnSource): "left" | "right" { return parties.meta.get(partyKey(source))?.side ?? "left"; } @@ -191,7 +317,24 @@ export function ConversationViewer({ Chargement du fil… ) : vm.turns.length === 0 ? ( -

Aucun tour pour le moment.

+
+

{emptyState.message}

+ {emptyState.detail && ( +

{emptyState.detail}

+ )} + {emptyState.action && onOpenConversation && ( + + )} +
) : ( vm.turns.map((turn) => { const side = sideOf(turn.source); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index cd3cf14d..992fb02f 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -923,6 +923,7 @@ export function ProjectsView() { projectId={viewerConversation.projectId} conversationId={viewerConversation.conversationId} onClose={() => setViewerConversation(null)} + onOpenConversation={openConversation} /> ) : activeLayoutKind === "gitGraph" ? ( setViewerConversation(null)} + onOpenConversation={openConversation} /> ) : (
diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index d9d1c213..d4b16587 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -541,6 +541,7 @@ function WebDesktopWorkspace({ projectId={viewerConversation.projectId} conversationId={viewerConversation.conversationId} onClose={() => setViewerConversation(null)} + onOpenConversation={openConversation} /> ) : activeLayoutKind === "gitGraph" ? ( ticket.status === "inProgress"); const activeTicket = tickets.find((ticket) => ticket.status === "inProgress"); + const activeSummary = activeTicket + ? conversations.get(activeTicket.conversationId) + : undefined; const statusLabel = agentStatusLabel(live, busy); const taskSummary = backgroundTaskSummary(backgroundTasks); const activityLine = @@ -696,6 +699,13 @@ function AgentRow({
+ {!detailsOpen && activeSummary && ( + + )} + {detailsOpen && (