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)>>>,
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<u8>) -> 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);
}

View File

@ -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(
<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 () => {
const agent = {
launchAgentChat: vi.fn(),

View File

@ -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<string, unknown> {
@ -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<ProfileCommandDialogState | null>(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<string | null>(null);
const [error, setError] = useState<ChatErrorState | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const chatRootRef = useRef<HTMLDivElement | null>(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({
</p>
)}
{error && (
<p role="alert" className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger">
{error}
</p>
<div
role="alert"
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 && (
<div

View File

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

View File

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