diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 8175c65c..4157938b 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -1559,11 +1559,19 @@ async fn send_launch_agent_attached( owned: &Arc>>, output: application::LaunchAgentOutput, ) -> Result<(), ErrorDto> { - if output.structured.is_some() { - return Err(ErrorDto { - code: "UNSUPPORTED".to_owned(), - message: "structured agent sessions do not stream over the PTY websocket".to_owned(), - }); + if let Some(structured) = output.structured.as_ref() { + tx.send(ServerFrame::chat_attached( + &frame.id, + structured, + output.assigned_conversation_id.as_deref(), + output.engine_session_id.as_deref(), + )) + .await + .map_err(|_| ErrorDto { + code: "WS_CLOSED".to_owned(), + message: "websocket output closed".to_owned(), + })?; + return Ok(()); } let session_id = output.session.id; let dto = TerminalSessionDto::from(output); @@ -5430,6 +5438,28 @@ impl ServerFrame { } } + fn chat_attached( + request_id: &str, + session: &application::StructuredSessionDescriptor, + assigned_conversation_id: Option<&str>, + engine_session_id: Option<&str>, + ) -> Self { + Self { + kind: "chat.attached".to_owned(), + reply_to: Some(request_id.to_owned()), + payload: json!({ + "sessionId": session.session_id.to_string(), + "session": { + "sessionId": session.session_id.to_string(), + "nodeId": session.node_id.to_string(), + }, + "cellKind": "chat", + "assignedConversationId": assigned_conversation_id, + "engineSessionId": engine_session_id, + }), + } + } + fn output(session_id: SessionId, seq: u64, bytes: Vec) -> Self { Self { kind: "terminal.output".to_owned(), @@ -7043,7 +7073,7 @@ mod tests { } #[tokio::test] - async fn websocket_launch_agent_structured_is_unsupported() { + async fn websocket_launch_agent_structured_emits_chat_attached() { let state = state(); let (tx, mut rx) = mpsc::channel(4); let owned = Arc::new(Mutex::new(Vec::new())); @@ -7073,25 +7103,25 @@ mod tests { profile: None, }; - if let Err(err) = send_launch_agent_attached(&frame, &state, &tx, &owned, output).await { - tx.send(ServerFrame::error( - Some(frame.id), - payload_session_id(&frame.payload), - err.code, - err.message, - )) + send_launch_agent_attached(&frame, &state, &tx, &owned, output) .await .unwrap(); - } - let error = recv_server_frame(&mut rx).await; + let attached = recv_server_frame(&mut rx).await; - assert_eq!(error.kind, "error"); - assert_eq!(error.reply_to.as_deref(), Some("agent-structured")); - assert_eq!(error.payload["code"], "UNSUPPORTED"); + assert_eq!(attached.kind, "chat.attached"); + assert_eq!(attached.reply_to.as_deref(), Some("agent-structured")); + assert_eq!(attached.payload["sessionId"], session_id.to_string()); assert_eq!( - error.payload["message"], - "structured agent sessions do not stream over the PTY websocket" + attached.payload["session"]["sessionId"], + session_id.to_string() ); + assert_eq!(attached.payload["session"]["nodeId"], node_id.to_string()); + assert_eq!(attached.payload["cellKind"], "chat"); + assert_eq!( + attached.payload["assignedConversationId"], + "pair-conversation" + ); + assert_eq!(attached.payload["engineSessionId"], "engine-session"); assert_eq!(state.ws_pty_bridge.active_sessions(), 0); } diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 554811d6..8166067d 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -89,6 +89,98 @@ describe("CustomAgentChatView", () => { __resetProjectCliInteractionActivityForTests(); }); + it("shows the technical open failure on mobile with retry and native TUI fallback", async () => { + const openNativeTui = vi.fn(); + const agent = { + launchAgentChat: vi + .fn() + .mockRejectedValueOnce(new Error("spawn codex ENOENT")) + .mockResolvedValueOnce({ sessionId: "chat-session-1" }), + reattachAgentChat: vi.fn(async (sessionId: string) => ({ + sessionId, + scrollback: [], + })), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + const alert = await screen.findByRole("alert"); + expect(within(alert).getByText("Le CLI custom n'a pas pu démarrer.")).toBeTruthy(); + expect(within(alert).getByText("Cause: spawn codex ENOENT")).toBeTruthy(); + expect(screen.queryByText(/Vérifiez la configuration du profil/)).toBeNull(); + + fireEvent.click(within(alert).getByRole("button", { name: "Ouvrir en TUI native" })); + expect(openNativeTui).toHaveBeenCalledTimes(1); + + fireEvent.click(within(alert).getByRole("button", { name: "Réessayer" })); + await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); + }); + + it("uses the same technical open failure on desktop", async () => { + const agent = { + launchAgentChat: vi.fn(async () => { + throw new Error("permission denied: /opt/codex"); + }), + reattachAgentChat: vi.fn(), + sendAgentChat: vi.fn(async () => {}), + cancelAgentChat: vi.fn(async () => {}), + closeAgentChat: vi.fn(async () => {}), + }; + + render( + null) }, + } as unknown as Gateways} + > + + , + ); + + const alert = await screen.findByRole("alert"); + expect(within(alert).getByText("Cause: permission denied: /opt/codex")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Ouvrir en TUI native" })).toBeNull(); + }); + it("repaints attachment labels from user prompt scrollback", async () => { const agent = { launchAgentChat: vi.fn(), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 78647140..b66f3524 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -52,6 +52,7 @@ export interface CustomAgentChatViewProps { onComposerHeightPxChange?: (heightPx: number) => void; draftScope?: ChatDraftScope | null; mobile?: boolean; + onOpenNativeTui?: () => void; onSessionId: (sessionId: string | null) => void; onConversationId: (conversationId: string | null) => void; } @@ -118,6 +119,11 @@ interface SlashCommandInvocation { arguments: unknown[]; } +interface ChatErrorState { + message: string; + openFailure: boolean; +} + function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); @@ -125,9 +131,9 @@ function describe(e: unknown): string { return String(e); } -function describeOpenFailure(e: unknown, mobile: boolean): string { - if (!mobile) return describe(e); - return "Le CLI custom n'a pas pu démarrer. Vérifiez la configuration du profil ou utilisez la TUI native."; +function describeOpenFailure(e: unknown): string { + const message = describe(e).trim(); + return message || "Cause technique non disponible."; } function instrumentationError(e: unknown): Record { @@ -734,6 +740,7 @@ export function CustomAgentChatView({ onComposerHeightPxChange, draftScope: explicitDraftScope, mobile = false, + onOpenNativeTui, onSessionId, onConversationId, }: CustomAgentChatViewProps) { @@ -770,9 +777,10 @@ export function CustomAgentChatView({ const [profileDialog, setProfileDialog] = useState(null); const [opening, setOpening] = useState(Boolean(sessionId || conversationId)); + const [openRetryNonce, setOpenRetryNonce] = useState(0); const [busy, setBusy] = useState(false); const [stopping, setStopping] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const scrollRef = useRef(null); const composerRef = useRef(null); const chatRootRef = useRef(null); @@ -1371,7 +1379,9 @@ export function CustomAgentChatView({ sessionId: externalSessionId, error: instrumentationError(e), }); - if (!cancelled) setError(describeOpenFailure(e, mobile)); + if (!cancelled) { + setError({ message: describeOpenFailure(e), openFailure: true }); + } } finally { if (!cancelled) setOpening(false); } @@ -1390,7 +1400,7 @@ export function CustomAgentChatView({ applyChatViewState, currentConversationCacheKey, conversationId, - mobile, + openRetryNonce, ]); const canSend = useMemo( @@ -1490,7 +1500,10 @@ export function CustomAgentChatView({ async function executeSlashCommand(invocation: SlashCommandInvocation) { if (!agent.executeSlashCommand) { - setError(`Commande ${invocation.name} indisponible dans ce runtime.`); + setError({ + message: `Commande ${invocation.name} indisponible dans ce runtime.`, + openFailure: false, + }); return; } setDraft(""); @@ -1546,7 +1559,7 @@ export function CustomAgentChatView({ } } catch (e) { const message = `Commande ${invocation.name} échouée: ${describe(e)}`; - setError(message); + setError({ message, openFailure: false }); updateChatViewState((prev) => ({ turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }], progressIndex: prev.progressIndex, @@ -1633,7 +1646,7 @@ export function CustomAgentChatView({ ); } } catch (e) { - setError(describe(e)); + setError({ message: describe(e), openFailure: false }); } } @@ -1704,7 +1717,7 @@ export function CustomAgentChatView({ } catch (recoveryError) { finishInteraction(); setBusy(false); - setError(describe(recoveryError)); + setError({ message: describe(recoveryError), openFailure: false }); updateChatViewState((prev) => ({ turns: [ ...prev.turns, @@ -1717,7 +1730,7 @@ export function CustomAgentChatView({ } finishInteraction(); setBusy(false); - setError(describe(e)); + setError({ message: describe(e), openFailure: false }); updateChatViewState((prev) => ({ turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }], progressIndex: prev.progressIndex, @@ -1779,7 +1792,7 @@ export function CustomAgentChatView({ }); return; } - setError(describe(e)); + setError({ message: describe(e), openFailure: false }); updateChatViewState((prev) => ({ turns: [ ...prev.turns, @@ -1821,9 +1834,40 @@ export function CustomAgentChatView({

)} {error && ( -

- {error} -

+
+ {error.openFailure ? ( +
+
Le CLI custom n'a pas pu démarrer.
+
+ Cause: {error.message} +
+
+ + {onOpenNativeTui && ( + + )} +
+
+ ) : ( + error.message + )} +
)} {profileDialog && (
requestMode("tui")} onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)} onConversationId={(cid) => void vm.setCellConversation(id, cid)} /> diff --git a/frontend/src/features/web/WebAgentCell.tsx b/frontend/src/features/web/WebAgentCell.tsx index 6f875f68..50b2e8a6 100644 --- a/frontend/src/features/web/WebAgentCell.tsx +++ b/frontend/src/features/web/WebAgentCell.tsx @@ -208,6 +208,7 @@ export function WebAgentCell({ conversationId={customConversationId} draftScope={draftScope} mobile + onOpenNativeTui={() => setMode("tui")} onSessionId={setCustomSessionId} onConversationId={setCustomConversationId} />