fix(web): émet chat.attached pour les sessions agents structurées au lieu de UNSUPPORTED — #229

This commit is contained in:
2026-08-20 15:10:41 +02:00
parent 1b8534fbf9
commit 023d483ba2
5 changed files with 203 additions and 35 deletions

View File

@ -1559,11 +1559,19 @@ async fn send_launch_agent_attached(
owned: &Arc<Mutex<Vec<(SessionId, u64)>>>, owned: &Arc<Mutex<Vec<(SessionId, u64)>>>,
output: application::LaunchAgentOutput, output: application::LaunchAgentOutput,
) -> Result<(), ErrorDto> { ) -> Result<(), ErrorDto> {
if output.structured.is_some() { if let Some(structured) = output.structured.as_ref() {
return Err(ErrorDto { tx.send(ServerFrame::chat_attached(
code: "UNSUPPORTED".to_owned(), &frame.id,
message: "structured agent sessions do not stream over the PTY websocket".to_owned(), 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 session_id = output.session.id;
let dto = TerminalSessionDto::from(output); 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<u8>) -> Self { fn output(session_id: SessionId, seq: u64, bytes: Vec<u8>) -> Self {
Self { Self {
kind: "terminal.output".to_owned(), kind: "terminal.output".to_owned(),
@ -7043,7 +7073,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn websocket_launch_agent_structured_is_unsupported() { async fn websocket_launch_agent_structured_emits_chat_attached() {
let state = state(); let state = state();
let (tx, mut rx) = mpsc::channel(4); let (tx, mut rx) = mpsc::channel(4);
let owned = Arc::new(Mutex::new(Vec::new())); let owned = Arc::new(Mutex::new(Vec::new()));
@ -7073,25 +7103,25 @@ mod tests {
profile: None, profile: None,
}; };
if let Err(err) = send_launch_agent_attached(&frame, &state, &tx, &owned, output).await { send_launch_agent_attached(&frame, &state, &tx, &owned, output)
tx.send(ServerFrame::error(
Some(frame.id),
payload_session_id(&frame.payload),
err.code,
err.message,
))
.await .await
.unwrap(); .unwrap();
} let attached = recv_server_frame(&mut rx).await;
let error = recv_server_frame(&mut rx).await;
assert_eq!(error.kind, "error"); assert_eq!(attached.kind, "chat.attached");
assert_eq!(error.reply_to.as_deref(), Some("agent-structured")); assert_eq!(attached.reply_to.as_deref(), Some("agent-structured"));
assert_eq!(error.payload["code"], "UNSUPPORTED"); assert_eq!(attached.payload["sessionId"], session_id.to_string());
assert_eq!( assert_eq!(
error.payload["message"], attached.payload["session"]["sessionId"],
"structured agent sessions do not stream over the PTY websocket" 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); assert_eq!(state.ws_pty_bridge.active_sessions(), 0);
} }

View File

@ -89,6 +89,98 @@ describe("CustomAgentChatView", () => {
__resetProjectCliInteractionActivityForTests(); __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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId={null}
mobile
onOpenNativeTui={openNativeTui}
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId={null}
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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 () => { it("repaints attachment labels from user prompt scrollback", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(), launchAgentChat: vi.fn(),

View File

@ -52,6 +52,7 @@ export interface CustomAgentChatViewProps {
onComposerHeightPxChange?: (heightPx: number) => void; onComposerHeightPxChange?: (heightPx: number) => void;
draftScope?: ChatDraftScope | null; draftScope?: ChatDraftScope | null;
mobile?: boolean; mobile?: boolean;
onOpenNativeTui?: () => void;
onSessionId: (sessionId: string | null) => void; onSessionId: (sessionId: string | null) => void;
onConversationId: (conversationId: string | null) => void; onConversationId: (conversationId: string | null) => void;
} }
@ -118,6 +119,11 @@ interface SlashCommandInvocation {
arguments: unknown[]; arguments: unknown[];
} }
interface ChatErrorState {
message: string;
openFailure: boolean;
}
function describe(e: unknown): string { function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) { if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message); return String((e as GatewayError).message);
@ -125,9 +131,9 @@ function describe(e: unknown): string {
return String(e); return String(e);
} }
function describeOpenFailure(e: unknown, mobile: boolean): string { function describeOpenFailure(e: unknown): string {
if (!mobile) return describe(e); const message = describe(e).trim();
return "Le CLI custom n'a pas pu démarrer. Vérifiez la configuration du profil ou utilisez la TUI native."; return message || "Cause technique non disponible.";
} }
function instrumentationError(e: unknown): Record<string, unknown> { function instrumentationError(e: unknown): Record<string, unknown> {
@ -734,6 +740,7 @@ export function CustomAgentChatView({
onComposerHeightPxChange, onComposerHeightPxChange,
draftScope: explicitDraftScope, draftScope: explicitDraftScope,
mobile = false, mobile = false,
onOpenNativeTui,
onSessionId, onSessionId,
onConversationId, onConversationId,
}: CustomAgentChatViewProps) { }: CustomAgentChatViewProps) {
@ -770,9 +777,10 @@ export function CustomAgentChatView({
const [profileDialog, setProfileDialog] = const [profileDialog, setProfileDialog] =
useState<ProfileCommandDialogState | null>(null); useState<ProfileCommandDialogState | null>(null);
const [opening, setOpening] = useState(Boolean(sessionId || conversationId)); const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
const [openRetryNonce, setOpenRetryNonce] = useState(0);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [stopping, setStopping] = useState(false); const [stopping, setStopping] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<ChatErrorState | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null); const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(null); const chatRootRef = useRef<HTMLDivElement | null>(null);
@ -1371,7 +1379,9 @@ export function CustomAgentChatView({
sessionId: externalSessionId, sessionId: externalSessionId,
error: instrumentationError(e), error: instrumentationError(e),
}); });
if (!cancelled) setError(describeOpenFailure(e, mobile)); if (!cancelled) {
setError({ message: describeOpenFailure(e), openFailure: true });
}
} finally { } finally {
if (!cancelled) setOpening(false); if (!cancelled) setOpening(false);
} }
@ -1390,7 +1400,7 @@ export function CustomAgentChatView({
applyChatViewState, applyChatViewState,
currentConversationCacheKey, currentConversationCacheKey,
conversationId, conversationId,
mobile, openRetryNonce,
]); ]);
const canSend = useMemo( const canSend = useMemo(
@ -1490,7 +1500,10 @@ export function CustomAgentChatView({
async function executeSlashCommand(invocation: SlashCommandInvocation) { async function executeSlashCommand(invocation: SlashCommandInvocation) {
if (!agent.executeSlashCommand) { if (!agent.executeSlashCommand) {
setError(`Commande ${invocation.name} indisponible dans ce runtime.`); setError({
message: `Commande ${invocation.name} indisponible dans ce runtime.`,
openFailure: false,
});
return; return;
} }
setDraft(""); setDraft("");
@ -1546,7 +1559,7 @@ export function CustomAgentChatView({
} }
} catch (e) { } catch (e) {
const message = `Commande ${invocation.name} échouée: ${describe(e)}`; const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
setError(message); setError({ message, openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }], turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }],
progressIndex: prev.progressIndex, progressIndex: prev.progressIndex,
@ -1633,7 +1646,7 @@ export function CustomAgentChatView({
); );
} }
} catch (e) { } catch (e) {
setError(describe(e)); setError({ message: describe(e), openFailure: false });
} }
} }
@ -1704,7 +1717,7 @@ export function CustomAgentChatView({
} catch (recoveryError) { } catch (recoveryError) {
finishInteraction(); finishInteraction();
setBusy(false); setBusy(false);
setError(describe(recoveryError)); setError({ message: describe(recoveryError), openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [ turns: [
...prev.turns, ...prev.turns,
@ -1717,7 +1730,7 @@ export function CustomAgentChatView({
} }
finishInteraction(); finishInteraction();
setBusy(false); setBusy(false);
setError(describe(e)); setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }], turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }],
progressIndex: prev.progressIndex, progressIndex: prev.progressIndex,
@ -1779,7 +1792,7 @@ export function CustomAgentChatView({
}); });
return; return;
} }
setError(describe(e)); setError({ message: describe(e), openFailure: false });
updateChatViewState((prev) => ({ updateChatViewState((prev) => ({
turns: [ turns: [
...prev.turns, ...prev.turns,
@ -1821,9 +1834,40 @@ export function CustomAgentChatView({
</p> </p>
)} )}
{error && ( {error && (
<p role="alert" className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"> <div
{error} role="alert"
</p> className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"
>
{error.openFailure ? (
<div className="flex min-w-0 flex-col gap-2">
<div className="font-medium">Le CLI custom n'a pas pu démarrer.</div>
<div className="break-words">
Cause: {error.message}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="primary"
disabled={opening}
onClick={() => setOpenRetryNonce((value) => value + 1)}
>
Réessayer
</Button>
{onOpenNativeTui && (
<Button
size="sm"
variant="secondary"
onClick={onOpenNativeTui}
>
Ouvrir en TUI native
</Button>
)}
</div>
</div>
) : (
error.message
)}
</div>
)} )}
{profileDialog && ( {profileDialog && (
<div <div

View File

@ -1501,6 +1501,7 @@ function LeafView({
composerHeightPx={customChatComposerHeight} composerHeightPx={customChatComposerHeight}
composerDefaultHeightPx={CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT} composerDefaultHeightPx={CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT}
onComposerHeightPxChange={setCustomChatComposerHeight} onComposerHeightPxChange={setCustomChatComposerHeight}
onOpenNativeTui={() => requestMode("tui")}
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)} onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)} onConversationId={(cid) => void vm.setCellConversation(id, cid)}
/> />

View File

@ -208,6 +208,7 @@ export function WebAgentCell({
conversationId={customConversationId} conversationId={customConversationId}
draftScope={draftScope} draftScope={draftScope}
mobile mobile
onOpenNativeTui={() => setMode("tui")}
onSessionId={setCustomSessionId} onSessionId={setCustomSessionId}
onConversationId={setCustomConversationId} onConversationId={setCustomConversationId}
/> />