fix(agents): unwrap read_agent_context DTO content (#140)

Le contexte projet d'un agent s'affichait "[object Object]" et se
vidait à la réouverture du panneau : read_agent_context renvoie déjà
{content} côté backend, mais les gateways front traitaient la réponse
comme une string brute. Les deux gateways (Tauri + HTTP) unwrap
maintenant .content ; type AgentContextDocument ajouté au domaine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:35:36 +02:00
parent cf61060ffb
commit 863d9b7277
10 changed files with 135 additions and 6 deletions

View File

@ -0,0 +1,6 @@
---
issueRef: "#140"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1785760014957
---

View File

@ -0,0 +1,17 @@
---
id: "353aa2ae-cf98-4a63-b4fb-a15fdb801a0a"
number: 140
title: "[Bug] je ne peux pas editer le context projet d'un agent a la main"
status: "open"
priority: "medium"
sprint: null
links: []
agentRefs: [{"agentId":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d","role":"assigned"}]
attachments: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1785759952774
updatedAt: 1785760014957
version: 3
---
Quand je suis dans le panneau des agents, que je selectionne un agent, le context projet de l'agent s'affiche mal (il n'affiche que [object Object]) et si je l'edit, que je save, et que je le réouvre il estd e nouveau vide

View File

@ -1,3 +1,3 @@
{ {
"nextNumber": 140 "nextNumber": 141
} }

View File

@ -1807,6 +1807,21 @@
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
}, },
"updatedAt": 1785709504027 "updatedAt": 1785709504027
},
{
"issueRef": "#140",
"path": "140",
"title": "[Bug] je ne peux pas editer le context projet d'un agent a la main",
"status": "open",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
],
"createdBy": {
"kind": "user"
},
"updatedAt": 1785760014957
} }
] ]
} }

View File

@ -42,12 +42,13 @@ describe("TauriAgentGateway invoke payloads", () => {
}); });
}); });
it("list_agents / read / delete pass top-level args (no request wrapper)", async () => { it("list_agents / read / delete pass top-level args and unwrap read context DTO", async () => {
const gw = new TauriAgentGateway(); const gw = new TauriAgentGateway();
await gw.listAgents("p"); await gw.listAgents("p");
expect(invoke).toHaveBeenCalledWith("list_agents", { projectId: "p" }); expect(invoke).toHaveBeenCalledWith("list_agents", { projectId: "p" });
await gw.readContext("p", "a"); invoke.mockResolvedValueOnce({ content: "# context" });
await expect(gw.readContext("p", "a")).resolves.toBe("# context");
expect(invoke).toHaveBeenCalledWith("read_agent_context", { expect(invoke).toHaveBeenCalledWith("read_agent_context", {
projectId: "p", projectId: "p",
agentId: "a", agentId: "a",

View File

@ -16,6 +16,7 @@ import { Channel, invoke } from "@tauri-apps/api/core";
import type { import type {
Agent, Agent,
AgentContextDocument,
EffortSelection, EffortSelection,
ResumableAgent, ResumableAgent,
TerminalSession, TerminalSession,
@ -117,7 +118,10 @@ export class TauriAgentGateway implements AgentGateway {
} }
readContext(projectId: string, agentId: string): Promise<string> { readContext(projectId: string, agentId: string): Promise<string> {
return invoke<string>("read_agent_context", { projectId, agentId }); return invoke<AgentContextDocument>("read_agent_context", {
projectId,
agentId,
}).then((res) => res.content);
} }
async updateContext( async updateContext(

View File

@ -8,7 +8,7 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { HttpAgentGateway } from "./streamGateways"; import { HttpAgentGateway } from "./streamGateways";
import { HttpInvoker } from "./httpInvoker"; import { HttpInvoker, type FetchLike } from "./httpInvoker";
import { WsLiveClient, type WebSocketLike } from "./wsLiveClient"; import { WsLiveClient, type WebSocketLike } from "./wsLiveClient";
import { bytesToBase64 } from "./frames"; import { bytesToBase64 } from "./frames";
@ -45,6 +45,27 @@ function gateway(): { gw: HttpAgentGateway; sockets: FakeSocket[] } {
return { gw, sockets }; return { gw, sockets };
} }
function httpAgentGateway(
fetchImpl: FetchLike,
): { gw: HttpAgentGateway; calls: { url: string; init: unknown }[] } {
const calls: { url: string; init: unknown }[] = [];
const recordingFetch: FetchLike = async (url, init) => {
calls.push({ url, init });
return fetchImpl(url, init);
};
const ws = new WsLiveClient({
wsUrl: "wss://host",
socketFactory: () => new FakeSocket(),
});
return {
gw: new HttpAgentGateway(
new HttpInvoker({ baseUrl: "https://host", fetchImpl: recordingFetch }),
ws,
),
calls,
};
}
async function replyToLast( async function replyToLast(
socket: FakeSocket, socket: FakeSocket,
index: number, index: number,
@ -78,6 +99,26 @@ function attachedAck(
const OPTS = { cwd: "/srv/app", rows: 24, cols: 80, nodeId: "node-1" }; const OPTS = { cwd: "/srv/app", rows: 24, cols: 80, nodeId: "node-1" };
describe("HttpAgentGateway WS round-trip (B6 frames)", () => { describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
it("readContext unwraps the read_agent_context DTO content over HTTP", async () => {
const { gw, calls } = httpAgentGateway(async () => ({
ok: true,
status: 200,
json: async () => ({ content: "## Restored context" }),
text: async () => JSON.stringify({ content: "## Restored context" }),
}));
await expect(gw.readContext("proj-1", "agent-1")).resolves.toBe(
"## Restored context",
);
expect(calls).toHaveLength(1);
const init = calls[0].init as { body: string };
expect(JSON.parse(init.body)).toEqual({
command: "read_agent_context",
args: { projectId: "proj-1", agentId: "agent-1" },
});
});
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => { it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
const { gw, sockets } = gateway(); const { gw, sockets } = gateway();
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];

View File

@ -21,6 +21,7 @@
import type { import type {
Agent, Agent,
AgentContextDocument,
AppExitWorkGuardState, AppExitWorkGuardState,
DomainEvent, DomainEvent,
EffortSelection, EffortSelection,
@ -234,7 +235,9 @@ export class HttpAgentGateway implements AgentGateway {
}); });
} }
readContext(projectId: string, agentId: string): Promise<string> { readContext(projectId: string, agentId: string): Promise<string> {
return this.http.invoke<string>("read_agent_context", { projectId, agentId }); return this.http
.invoke<AgentContextDocument>("read_agent_context", { projectId, agentId })
.then((res) => res.content);
} }
async updateContext(projectId: string, agentId: string, content: string): Promise<void> { async updateContext(projectId: string, agentId: string, content: string): Promise<void> {
await this.http.invoke("update_agent_context", { request: { projectId, agentId, content } }); await this.http.invoke("update_agent_context", { request: { projectId, agentId, content } });

View File

@ -1277,6 +1277,11 @@ export interface Agent {
effort?: EffortSelection; effort?: EffortSelection;
} }
/** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */
export interface AgentContextDocument {
content: string;
}
/** /**
* A terminal/PTY session as returned by the backend (mirror of * A terminal/PTY session as returned by the backend (mirror of
* `TerminalSessionDto`, camelCase wire format). Surfaced by `changeAgentProfile` * `TerminalSessionDto`, camelCase wire format). Surfaced by `changeAgentProfile`

View File

@ -241,6 +241,43 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
}); });
}); });
it("reopening the agents panel reloads the saved context as textarea text", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, {
name: "Reopen",
profileId: "p1",
initialContent: "initial",
});
const firstRender = renderPanel(agent);
await waitForIdle();
let buttons = screen.getAllByRole("button", { name: /reopen/i });
let rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
fireEvent.click(rowBtn);
let textarea = await screen.findByLabelText("agent context");
fireEvent.change(textarea, { target: { value: "persisted after reopen" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(async () => {
const agents = await agent.listAgents(PROJECT_ID);
await expect(agent.readContext(PROJECT_ID, agents[0].id)).resolves.toBe(
"persisted after reopen",
);
});
firstRender.unmount();
renderPanel(agent);
await waitForIdle();
buttons = screen.getAllByRole("button", { name: /reopen/i });
rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
fireEvent.click(rowBtn);
textarea = await screen.findByLabelText("agent context");
expect((textarea as HTMLTextAreaElement).value).toBe("persisted after reopen");
});
it("deleting an agent removes it from the list", async () => { it("deleting an agent removes it from the list", async () => {
const agent = new MockAgentGateway(); const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, { await agent.createAgent(PROJECT_ID, {