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:
6
.ideai/tickets/140/carnet.md
Normal file
6
.ideai/tickets/140/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#140"
|
||||||
|
version: 3
|
||||||
|
updatedBy: {"kind":"user"}
|
||||||
|
updatedAt: 1785760014957
|
||||||
|
---
|
||||||
17
.ideai/tickets/140/issue.md
Normal file
17
.ideai/tickets/140/issue.md
Normal 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
|
||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nextNumber": 140
|
"nextNumber": 141
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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[] = [];
|
||||||
|
|||||||
@ -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 } });
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user