Skip to content

Commit c14064e

Browse files
feat(agent): echo initiating message_id on relay_message (#3434)
1 parent 836f029 commit c14064e

5 files changed

Lines changed: 205 additions & 6 deletions

File tree

packages/agent/src/posthog-api.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,55 @@ describe("PostHogAPIClient", () => {
7878
);
7979
});
8080

81+
it.each([
82+
[
83+
"includes message_id and text_parts when provided",
84+
["part one", "final answer"],
85+
"msg-1",
86+
{
87+
text: "final answer",
88+
text_parts: ["part one", "final answer"],
89+
message_id: "msg-1",
90+
},
91+
],
92+
[
93+
"omits optional fields when unknown",
94+
undefined,
95+
undefined,
96+
{ text: "final answer" },
97+
],
98+
])(
99+
"relay_message body %s",
100+
async (_label, textParts, messageId, expectedBody) => {
101+
const client = new PostHogAPIClient({
102+
apiUrl: "https://app.posthog.com",
103+
getApiKey: vi.fn().mockResolvedValue("token"),
104+
projectId: 7,
105+
});
106+
107+
mockFetch.mockResolvedValueOnce({
108+
ok: true,
109+
json: vi.fn().mockResolvedValue({ status: "ok" }),
110+
});
111+
112+
await client.relayMessage(
113+
"task-1",
114+
"run-1",
115+
"final answer",
116+
textParts,
117+
messageId,
118+
);
119+
120+
expect(mockFetch).toHaveBeenCalledWith(
121+
"https://app.posthog.com/api/projects/7/tasks/task-1/runs/run-1/relay_message/",
122+
expect.objectContaining({
123+
method: "POST",
124+
body: JSON.stringify(expectedBody),
125+
}),
126+
);
127+
},
128+
);
129+
81130
it("returns only the artifacts created by the current upload request", async () => {
82131
const client = new PostHogAPIClient({
83132
apiUrl: "https://app.posthog.com",

packages/agent/src/posthog-api.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,15 +230,23 @@ export class PostHogAPIClient {
230230
runId: string,
231231
text: string,
232232
textParts?: string[],
233+
messageId?: string,
233234
): Promise<void> {
234235
const teamId = this.getTeamId();
235236
// Send `text_parts` alongside the joined `text` so backends that understand
236237
// the new schema can pick just the post-last-tool-use answer, while older
237238
// backends still get the flat `text` field they already handle.
238-
const body: { text: string; text_parts?: string[] } = { text };
239+
// `message_id` correlates the relay with the user message that initiated
240+
// the turn; it is omitted when no message id is known (e.g. boot prompt).
241+
const body: { text: string; text_parts?: string[]; message_id?: string } = {
242+
text,
243+
};
239244
if (textParts && textParts.length > 0) {
240245
body.text_parts = textParts;
241246
}
247+
if (messageId) {
248+
body.message_id = messageId;
249+
}
242250
await this.apiRequest<{ status: string }>(
243251
`/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/relay_message/`,
244252
{

packages/agent/src/server/agent-server.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2306,6 +2306,111 @@ describe("AgentServer HTTP Mode", () => {
23062306
expect(body.result?.stopReason).toBe("end_turn");
23072307
expect(prompt).toHaveBeenCalledTimes(2);
23082308
}, 20000);
2309+
2310+
// Shared plumbing for the relay-echo tests: install a controllable
2311+
// prompt, stub the log writer so relayAgentResponse has an answer to
2312+
// relay, and spy on the relay_message client call.
2313+
const setupRelayEchoServer = async (
2314+
prompt: () => Promise<{ stopReason: string }>,
2315+
) => {
2316+
const s = createServer();
2317+
await s.start();
2318+
const serverInternals = s as unknown as {
2319+
session: {
2320+
clientConnection: { prompt: typeof prompt };
2321+
logWriter: {
2322+
getFullAgentResponse: (runId: string) => string | undefined;
2323+
getAgentResponseParts: (runId: string) => string[];
2324+
};
2325+
};
2326+
posthogAPI: PostHogAPIClient;
2327+
};
2328+
serverInternals.session.clientConnection.prompt = prompt;
2329+
vi.spyOn(
2330+
serverInternals.session.logWriter,
2331+
"getFullAgentResponse",
2332+
).mockReturnValue("final answer");
2333+
vi.spyOn(
2334+
serverInternals.session.logWriter,
2335+
"getAgentResponseParts",
2336+
).mockReturnValue(["final answer"]);
2337+
const relaySpy = vi
2338+
.spyOn(serverInternals.posthogAPI, "relayMessage")
2339+
.mockResolvedValue(undefined);
2340+
2341+
const token = createToken();
2342+
const send = (messageId?: string) =>
2343+
fetch(`http://localhost:${port}/command`, {
2344+
method: "POST",
2345+
headers: {
2346+
Authorization: `Bearer ${token}`,
2347+
"Content-Type": "application/json",
2348+
},
2349+
body: JSON.stringify({
2350+
jsonrpc: "2.0",
2351+
id: messageId ?? "no-id",
2352+
method: "user_message",
2353+
params: {
2354+
content: "do the thing",
2355+
...(messageId ? { messageId } : {}),
2356+
},
2357+
}),
2358+
});
2359+
2360+
return { relaySpy, send };
2361+
};
2362+
2363+
it("echoes each turn's own initiating messageId on relay_message", async () => {
2364+
const pendingTurns: Array<(result: { stopReason: string }) => void> = [];
2365+
const prompt = vi.fn(
2366+
() =>
2367+
new Promise<{ stopReason: string }>((resolve) => {
2368+
pendingTurns.push(resolve);
2369+
}),
2370+
);
2371+
const { relaySpy, send } = await setupRelayEchoServer(prompt);
2372+
2373+
// The second message lands while the first turn is still in flight;
2374+
// each relay carries its own sender's id, not the first turn's.
2375+
const first = send("m-first");
2376+
await vi.waitFor(() => expect(prompt).toHaveBeenCalledTimes(1));
2377+
const second = send("m-second");
2378+
await vi.waitFor(() => expect(prompt).toHaveBeenCalledTimes(2));
2379+
2380+
pendingTurns[0]({ stopReason: "end_turn" });
2381+
await first;
2382+
await vi.waitFor(() => expect(relaySpy).toHaveBeenCalledTimes(1));
2383+
expect(relaySpy.mock.calls[0][4]).toBe("m-first");
2384+
2385+
pendingTurns[1]({ stopReason: "end_turn" });
2386+
await second;
2387+
await vi.waitFor(() => expect(relaySpy).toHaveBeenCalledTimes(2));
2388+
expect(relaySpy.mock.calls[1][4]).toBe("m-second");
2389+
2390+
// A message without an id relays without correlation (backward
2391+
// compatible with backends that don't know message_id).
2392+
relaySpy.mockClear();
2393+
const anonymous = send(undefined);
2394+
await vi.waitFor(() => expect(prompt).toHaveBeenCalledTimes(3));
2395+
pendingTurns[2]({ stopReason: "end_turn" });
2396+
await anonymous;
2397+
await vi.waitFor(() => expect(relaySpy).toHaveBeenCalledTimes(1));
2398+
expect(relaySpy.mock.calls[0][4]).toBeUndefined();
2399+
}, 20000);
2400+
2401+
it("does not leak a failed turn's messageId into the next turn", async () => {
2402+
const prompt = vi
2403+
.fn(async () => ({ stopReason: "end_turn" }))
2404+
.mockRejectedValueOnce(new Error("sdk connection lost"));
2405+
const { relaySpy, send } = await setupRelayEchoServer(prompt);
2406+
2407+
await send("m-fail");
2408+
expect(relaySpy).not.toHaveBeenCalled();
2409+
2410+
await send("m-next");
2411+
await vi.waitFor(() => expect(relaySpy).toHaveBeenCalledTimes(1));
2412+
expect(relaySpy.mock.calls[0][4]).toBe("m-next");
2413+
}, 20000);
23092414
});
23102415

23112416
describe("404 handling", () => {

packages/agent/src/server/agent-server.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,9 +1091,11 @@ export class AgentServer {
10911091

10921092
if (result.stopReason === "end_turn") {
10931093
// Relay the response to Slack. For follow-ups this is the primary
1094-
// delivery path — the HTTP caller only handles reactions.
1095-
this.relayAgentResponse(this.session.payload).catch((err) =>
1096-
this.logger.debug("Failed to relay follow-up response", err),
1094+
// delivery path — the HTTP caller only handles reactions. Echo the
1095+
// initiating message's id so the backend can attribute the answer.
1096+
this.relayAgentResponse(this.session.payload, messageId).catch(
1097+
(err) =>
1098+
this.logger.debug("Failed to relay follow-up response", err),
10971099
);
10981100
}
10991101

@@ -3818,7 +3820,10 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
38183820
};
38193821
}
38203822

3821-
private async relayAgentResponse(payload: JwtPayload): Promise<void> {
3823+
private async relayAgentResponse(
3824+
payload: JwtPayload,
3825+
messageId?: string,
3826+
): Promise<void> {
38223827
if (!this.session) {
38233828
return;
38243829
}
@@ -3862,6 +3867,7 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
38623867
payload.run_id,
38633868
message,
38643869
messageParts,
3870+
messageId,
38653871
);
38663872
} catch (error) {
38673873
this.logger.debug("Failed to relay initial agent response to Slack", {

packages/agent/src/server/question-relay.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ interface TestableAgentServer {
2323
};
2424
questionRelayedToSlack: boolean;
2525
session: unknown;
26-
relayAgentResponse: (payload: Record<string, unknown>) => Promise<void>;
26+
relayAgentResponse: (
27+
payload: Record<string, unknown>,
28+
messageId?: string,
29+
) => Promise<void>;
2730
sendInitialTaskMessage: (payload: Record<string, unknown>) => Promise<void>;
2831
}
2932

@@ -555,6 +558,34 @@ describe("Question relay", () => {
555558
"test-run-id",
556559
"agent response",
557560
["first part", "agent response"],
561+
undefined,
562+
);
563+
});
564+
565+
it("passes the initiating message id through to relayMessage", async () => {
566+
const relaySpy = vi
567+
.spyOn(server.posthogAPI, "relayMessage")
568+
.mockResolvedValue(undefined);
569+
570+
server.session = {
571+
payload: TEST_PAYLOAD,
572+
logWriter: {
573+
flush: vi.fn().mockResolvedValue(undefined),
574+
getFullAgentResponse: vi.fn().mockReturnValue("agent response"),
575+
getAgentResponseParts: vi.fn().mockReturnValue(["agent response"]),
576+
isRegistered: vi.fn().mockReturnValue(true),
577+
},
578+
};
579+
580+
server.questionRelayedToSlack = false;
581+
await server.relayAgentResponse(TEST_PAYLOAD, "msg-123");
582+
583+
expect(relaySpy).toHaveBeenCalledWith(
584+
"test-task-id",
585+
"test-run-id",
586+
"agent response",
587+
["agent response"],
588+
"msg-123",
558589
);
559590
});
560591

0 commit comments

Comments
 (0)