Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/agent/src/posthog-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,21 @@ export class PostHogAPIClient {
taskId: string,
runId: string,
text: string,
textParts?: string[],
): Promise<void> {
const teamId = this.getTeamId();
// Send `text_parts` alongside the joined `text` so backends that understand
// the new schema can pick just the post-last-tool-use answer, while older
// backends still get the flat `text` field they already handle.
const body: { text: string; text_parts?: string[] } = { text };
if (textParts && textParts.length > 0) {
body.text_parts = textParts;
}
await this.apiRequest<{ status: string }>(
`/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/relay_message/`,
{
method: "POST",
body: JSON.stringify({ text }),
body: JSON.stringify(body),
},
);
}
Expand Down
9 changes: 9 additions & 0 deletions packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3023,11 +3023,20 @@ ${signedCommitInstructions}
return;
}

// Ordered assistant text blocks (one per message between tool calls).
// The backend picks the last entry — the post-last-tool-use answer — so
// Slack no longer sees the "Let me check…" narration. `message` stays as
// the joined fallback for backends that don't understand `text_parts`.
const messageParts = this.session.logWriter.getAgentResponseParts(
payload.run_id,
);
Comment on lines +3030 to +3032

@tatoalo tatoalo Jul 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we gate this to slack only? this would run across cloud runs right? 🤔

if this relay is still gated all good

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, the relay endpoint is gated only for slack aka if no slack thread task mapping exists, we skip.


try {
await this.posthogAPI.relayMessage(
payload.task_id,
payload.run_id,
message,
messageParts,
);
} catch (error) {
this.logger.debug("Failed to relay initial agent response to Slack", {
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/src/server/question-relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,9 @@ describe("Question relay", () => {
logWriter: {
flush: vi.fn().mockResolvedValue(undefined),
getFullAgentResponse: vi.fn().mockReturnValue("agent response"),
getAgentResponseParts: vi
.fn()
.mockReturnValue(["first part", "agent response"]),
isRegistered: vi.fn().mockReturnValue(true),
},
};
Expand All @@ -482,6 +485,7 @@ describe("Question relay", () => {
"test-task-id",
"test-run-id",
"agent response",
["first part", "agent response"],
);
});

Expand Down
42 changes: 42 additions & 0 deletions packages/agent/src/session-log-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,48 @@ describe("SessionLogWriter", () => {
expect(response).toBe("first message\n\nsecond message");
});

it("getAgentResponseParts returns each turn message as a separate entry", async () => {
const sessionId = "s1";
logWriter.register(sessionId, { taskId: "t1", runId: sessionId });

logWriter.appendRawLine(
sessionId,
makeSessionUpdate("agent_message_chunk", {
content: { type: "text", text: "I'll pull DAU." },
}),
);
logWriter.appendRawLine(
sessionId,
makeSessionUpdate("tool_call", { toolCallId: "tc1" }),
);
logWriter.appendRawLine(
sessionId,
makeSessionUpdate("agent_message", {
content: { type: "text", text: "Here's your answer." },
}),
);

// getFullAgentResponse still joins for backends without text_parts support.
expect(logWriter.getFullAgentResponse(sessionId)).toBe(
"I'll pull DAU.\n\nHere's your answer.",
);
// getAgentResponseParts keeps the split — the Slack relay picks the last.
expect(logWriter.getAgentResponseParts(sessionId)).toEqual([
"I'll pull DAU.",
"Here's your answer.",
]);
});

it("getAgentResponseParts returns undefined for an empty/unregistered turn", () => {
expect(
logWriter.getAgentResponseParts("never-registered"),
).toBeUndefined();

const sessionId = "empty";
logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
expect(logWriter.getAgentResponseParts(sessionId)).toBeUndefined();
});

it("persisted log does not contain stale entries when chunks are superseded", async () => {
const sessionId = "s1";
logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
Expand Down
25 changes: 25 additions & 0 deletions packages/agent/src/session-log-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,31 @@ export class SessionLogWriter {
return session.currentTurnMessages.join("\n\n");
}

/**
* Returns the ordered assistant text blocks for the current turn — one entry
* per message between tool calls. The last entry is the text after the final
* tool_use (the actual answer to the user).
*
* The Slack relay uses this so the backend can post only the last block
* instead of every interim "Let me check…" narration.
*/
getAgentResponseParts(sessionId: string): string[] | undefined {
const session = this.sessions.get(sessionId);
if (!session || session.currentTurnMessages.length === 0) return undefined;

if (session.chunkBuffer) {
this.logger.warn(
"getAgentResponseParts called with non-empty chunk buffer",
{
sessionId,
bufferedLength: session.chunkBuffer.text.length,
},
);
}

return [...session.currentTurnMessages];
}

resetTurnMessages(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
Expand Down
Loading