merge(web): CLI custom indisponible via l'interface web — #229 (QA verte)
This commit is contained in:
17
.ideai/tickets/228/carnet.md
Normal file
17
.ideai/tickets/228/carnet.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
issueRef: "#228"
|
||||
version: 4
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedAt: 1787229737762
|
||||
---
|
||||
## 2026-08-20
|
||||
- Rebuild AppImage demandé par l'utilisateur.
|
||||
- Ticket créé et passé en `inProgress`.
|
||||
- Décision Git préparée avant tagging : branche `develop`, HEAD `1b8534f`, tag à créer après build vert `v0.18.0`.
|
||||
- Frontend build exécuté avec succès.
|
||||
- Bundle Tauri AppImage exécuté avec succès via `CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1`.
|
||||
- Artefact produit : `/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage`.
|
||||
- Vérification artefact : taille `118745592` bytes, horodatage `2026-08-20 14:41:34 +0200`.
|
||||
- Git a créé localement le tag annoté `v0.18.0` sur le commit `1b8534f` puis l'a vérifié avec `git tag --contains` et `git describe --tags`.
|
||||
- Aucun push effectué.
|
||||
- Hors périmètre volontaire : remplacement manuel de l'AppImage installée et relance d'IdeA.
|
||||
17
.ideai/tickets/228/issue.md
Normal file
17
.ideai/tickets/228/issue.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
id: "ce5b03a6-deaa-4b4d-9eff-49bff8e6a79c"
|
||||
number: 228
|
||||
title: "Rebuild AppImage Linux"
|
||||
status: "closed"
|
||||
priority: "medium"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: []
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
createdAt: 1787229516097
|
||||
updatedAt: 1787229737762
|
||||
version: 4
|
||||
---
|
||||
Reconstruction de l'AppImage IdeA depuis le project root, puis tag de version du commit construit selon la règle Main (vx sur main, vy sur develop, vz sur autres branches).
|
||||
13
.ideai/tickets/229/carnet.md
Normal file
13
.ideai/tickets/229/carnet.md
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
issueRef: "#229"
|
||||
version: 4
|
||||
updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"}
|
||||
updatedAt: 1787231430402
|
||||
---
|
||||
2026-08-20
|
||||
- Symptôme utilisateur: via l'interface web, le profil CLI custom affiche "Le CLI custom n'a pas pu démarrer. Vérifiez la configuration du profil ou utilisez la TUI native".
|
||||
- Ticket créé car aucun ticket ouvert ne correspondait.
|
||||
- UX: ne pas masquer l'erreur réelle sur web/mobile; afficher un échec d'ouverture avec détail exploitable, action Réessayer, fallback TUI secondaire.
|
||||
- Architect: root cause probable = contrat cassé côté web-server. Le frontend web lance `cellKind: chat`, mais le web-server rejette encore les sessions structurées avec `UNSUPPORTED`; le message générique frontend masque la vraie cause.
|
||||
- Git: branche locale décidée `fix/229-web-custom-cli-startup`, basée sur `develop` (tip `1b8534f`), créée sans checkout.
|
||||
- À implémenter: backend web-server pour honorer `agent.launch` structuré sur web + frontend pour exposer le diagnostic utile au lieu du message générique.
|
||||
17
.ideai/tickets/229/issue.md
Normal file
17
.ideai/tickets/229/issue.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
id: "a993f30b-b128-4b26-94ba-6301279c1bd4"
|
||||
number: 229
|
||||
title: "CLI custom indisponible via l'interface web"
|
||||
status: "closed"
|
||||
priority: "high"
|
||||
sprint: null
|
||||
links: []
|
||||
agentRefs: []
|
||||
attachments: []
|
||||
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||
updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"}
|
||||
createdAt: 1787230509856
|
||||
updatedAt: 1787231430402
|
||||
version: 4
|
||||
---
|
||||
Le profil CLI custom ne demarre pas depuis l'interface web et affiche: "le cli custom n'a pas pu demarrer. Verifiez la configuration du profil ou utilisez la TUI native". A diagnostiquer et corriger selon le cycle UX/Architect/Dev/QA.
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"nextNumber": 228
|
||||
"nextNumber": 230
|
||||
}
|
||||
@ -3056,6 +3056,34 @@
|
||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||
},
|
||||
"updatedAt": 1787227664912
|
||||
},
|
||||
{
|
||||
"issueRef": "#228",
|
||||
"path": "228",
|
||||
"title": "Rebuild AppImage Linux",
|
||||
"status": "closed",
|
||||
"priority": "medium",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||
},
|
||||
"updatedAt": 1787229737762
|
||||
},
|
||||
{
|
||||
"issueRef": "#229",
|
||||
"path": "229",
|
||||
"title": "CLI custom indisponible via l'interface web",
|
||||
"status": "closed",
|
||||
"priority": "high",
|
||||
"sprint": null,
|
||||
"assignedAgentIds": [],
|
||||
"createdBy": {
|
||||
"kind": "agent",
|
||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
||||
},
|
||||
"updatedAt": 1787231430402
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -208,6 +208,7 @@ export function WebAgentCell({
|
||||
conversationId={customConversationId}
|
||||
draftScope={draftScope}
|
||||
mobile
|
||||
onOpenNativeTui={() => setMode("tui")}
|
||||
onSessionId={setCustomSessionId}
|
||||
onConversationId={setCustomConversationId}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user