merge(workstate): conversation vide alors que Work state busy — #238 (QA verte)
This commit is contained in:
@ -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<StructuredSessions>,
|
||||
conversation_id: domain::conversation::ConversationId,
|
||||
) -> Result<Option<Arc<dyn domain::ports::AgentSession>>, 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()
|
||||
|
||||
@ -4293,17 +4293,22 @@ impl AgentSession for EchoSession {
|
||||
struct CountingFactory {
|
||||
starts: Arc<AtomicUsize>,
|
||||
next_id: Arc<Mutex<u128>>,
|
||||
session_plans: Arc<Mutex<Vec<SessionPlan>>>,
|
||||
}
|
||||
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<SessionPlan> {
|
||||
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<Arc<dyn AgentSession>, 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<SessionLimitService>,
|
||||
background_store: Arc<RecordingBackgroundStore>,
|
||||
background_runner: Arc<RecordingBackgroundRunner>,
|
||||
conversations: Arc<TestConversations>,
|
||||
}
|
||||
|
||||
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<dyn PtyPort>,
|
||||
));
|
||||
let conversations = Arc::new(TestConversations::new()) as Arc<dyn ConversationRegistry>;
|
||||
let conversations = Arc::new(TestConversations::new());
|
||||
let session_limits = Arc::new(SessionLimitService::new(
|
||||
Arc::new(FixedMillisClock(1_000)) as Arc<dyn Clock>,
|
||||
Arc::new(NoopScheduler::default()) as Arc<dyn Scheduler>,
|
||||
@ -4596,7 +4603,7 @@ fn structured_ask_fixture_with_background(
|
||||
Arc::clone(&mediator) as Arc<dyn InputMediator>,
|
||||
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
|
||||
)
|
||||
.with_conversations(conversations)
|
||||
.with_conversations(Arc::clone(&conversations) as Arc<dyn ConversationRegistry>)
|
||||
.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).
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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({
|
||||
<span>Chargement du fil…</span>
|
||||
</div>
|
||||
) : vm.turns.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun tour pour le moment.</p>
|
||||
<div className="max-w-xl text-sm text-muted">
|
||||
<p>{emptyState.message}</p>
|
||||
{emptyState.detail && (
|
||||
<p className="mt-1 text-xs text-faint">{emptyState.detail}</p>
|
||||
)}
|
||||
{emptyState.action && onOpenConversation && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
onOpenConversation(emptyState.action!.conversationId)
|
||||
}
|
||||
>
|
||||
{emptyState.action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
vm.turns.map((turn) => {
|
||||
const side = sideOf(turn.source);
|
||||
|
||||
@ -923,6 +923,7 @@ export function ProjectsView() {
|
||||
projectId={viewerConversation.projectId}
|
||||
conversationId={viewerConversation.conversationId}
|
||||
onClose={() => setViewerConversation(null)}
|
||||
onOpenConversation={openConversation}
|
||||
/>
|
||||
) : activeLayoutKind === "gitGraph" ? (
|
||||
<GitGraphView
|
||||
@ -951,6 +952,7 @@ export function ProjectsView() {
|
||||
projectId={viewerConversation.projectId}
|
||||
conversationId={viewerConversation.conversationId}
|
||||
onClose={() => setViewerConversation(null)}
|
||||
onOpenConversation={openConversation}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
||||
|
||||
@ -541,6 +541,7 @@ function WebDesktopWorkspace({
|
||||
projectId={viewerConversation.projectId}
|
||||
conversationId={viewerConversation.conversationId}
|
||||
onClose={() => setViewerConversation(null)}
|
||||
onOpenConversation={openConversation}
|
||||
/>
|
||||
) : activeLayoutKind === "gitGraph" ? (
|
||||
<GitGraphView
|
||||
|
||||
@ -592,6 +592,9 @@ function AgentRow({
|
||||
const currentTurn =
|
||||
busy || tickets.some((ticket) => 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({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!detailsOpen && activeSummary && (
|
||||
<ConversationSummaryLine
|
||||
summary={activeSummary}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailsOpen && (
|
||||
<div
|
||||
id={`work-agent-details-${agent.agentId}`}
|
||||
|
||||
Reference in New Issue
Block a user