Skip to content
Open
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
35 changes: 33 additions & 2 deletions packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,37 @@ describe("hydrateSessionJsonl", () => {
};
}

it("returns the selected conversation when hydrating from logs", async () => {
const posthogAPI = {
getTaskRun: vi.fn().mockResolvedValue({ log_url: "https://logs.test" }),
fetchTaskRunLogs: vi.fn().mockResolvedValue([
entry("user_message", {
content: { type: "text", text: "previous request" },
}),
]),
} as unknown as PostHogAPIClient;
const log = { info: vi.fn(), warn: vi.fn() };

const result = await hydrateSessionJsonl({
sessionId,
cwd,
taskId: "t1",
runId: "r1",
posthogAPI,
log,
});

expect(result).toEqual({
hasSession: true,
conversation: [
{
role: "user",
content: [{ type: "text", text: "previous request" }],
},
],
});
});

it("sanitizes an existing file and skips S3 hydration", async () => {
const file = await writeSessionFile();
const { posthogAPI, log } = makeDeps();
Expand All @@ -1608,7 +1639,7 @@ describe("hydrateSessionJsonl", () => {
log,
});

expect(result).toBe(true);
expect(result).toEqual({ hasSession: true });
expect(
(posthogAPI as unknown as { getTaskRun: ReturnType<typeof vi.fn> })
.getTaskRun,
Expand All @@ -1635,7 +1666,7 @@ describe("hydrateSessionJsonl", () => {
log,
});

expect(result).toBe(true);
expect(result).toEqual({ hasSession: true });
expect(log.warn).toHaveBeenCalledWith(
"Failed to sanitize existing session JSONL",
expect.anything(),
Expand Down
21 changes: 13 additions & 8 deletions packages/agent/src/adapters/claude/session/jsonl-hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ import { isEmptyContentBlock } from "../../../utils/acp-content";
import { neutralizeUnprocessableImages } from "../image-sanitization";
import { supports1MContext } from "./models";

interface ConversationTurn {
export interface ConversationTurn {
role: "user" | "assistant";
content: ContentBlock[];
toolCalls?: ToolCallInfo[];
}

export interface HydrateSessionJsonlResult {
hasSession: boolean;
conversation?: ConversationTurn[];
}

interface ToolCallInfo {
toolCallId: string;
toolName: string;
Expand Down Expand Up @@ -681,7 +686,7 @@ export async function hydrateSessionJsonl(params: {
permissionMode?: string;
posthogAPI: PostHogAPIClient;
log: HydrationLog;
}): Promise<boolean> {
}): Promise<HydrateSessionJsonlResult> {
const { posthogAPI, log } = params;

try {
Expand All @@ -702,21 +707,21 @@ export async function hydrateSessionJsonl(params: {
error: err instanceof Error ? err.message : String(err),
});
}
return true;
return { hasSession: true };
} catch {
// File doesn't exist, proceed with hydration
}

const taskRun = await posthogAPI.getTaskRun(params.taskId, params.runId);
if (!taskRun.log_url) {
log.info("No log URL, skipping JSONL hydration");
return false;
return { hasSession: false };
}

const entries = await posthogAPI.fetchTaskRunLogs(taskRun);
if (entries.length === 0) {
log.info("No S3 log entries, skipping JSONL hydration");
return false;
return { hasSession: false };
}

const entryCounts: Record<string, number> = {};
Expand All @@ -742,7 +747,7 @@ export async function hydrateSessionJsonl(params: {

if (allTurns.length === 0) {
log.info("No conversation to hydrate, skipping JSONL hydration");
return false;
return { hasSession: false };
}

const maxTokens = supports1MContext(params.model ?? "")
Expand Down Expand Up @@ -774,12 +779,12 @@ export async function hydrateSessionJsonl(params: {
turns: conversation.length,
lines: jsonlLines.length,
});
return true;
return { hasSession: true, conversation };
} catch (err) {
log.warn("Failed to hydrate session JSONL, continuing", {
sessionId: params.sessionId,
error: err instanceof Error ? err.message : String(err),
});
return false;
return { hasSession: false };
}
}
2 changes: 1 addition & 1 deletion packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,7 @@ export class AgentServer {
}

try {
const hasSession = await hydrateSessionJsonl({
const { hasSession } = await hydrateSessionJsonl({
sessionId: priorSessionId,
cwd,
taskId: payload.task_id,
Expand Down
164 changes: 162 additions & 2 deletions packages/workspace-server/src/services/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ const mockNewSession = vi.hoisted(() =>
configOptions: [],
}),
);
const mockResumeSession = vi.hoisted(() =>
vi.fn().mockResolvedValue({ configOptions: [] }),
);
const mockPrompt = vi.hoisted(() =>
vi.fn().mockResolvedValue({ stopReason: "end_turn" }),
);

const mockAcpClient = vi.hoisted(() => ({
current: undefined as
Expand All @@ -40,7 +46,8 @@ const mockClientSideConnection = vi.hoisted(() =>
this.initialize = vi.fn().mockResolvedValue({});
this.newSession = mockNewSession;
this.loadSession = vi.fn().mockResolvedValue({ configOptions: [] });
this.resumeSession = vi.fn().mockResolvedValue({ configOptions: [] });
this.resumeSession = mockResumeSession;
this.prompt = mockPrompt;
this.setSessionConfigOption = vi.fn(
async ({ value }: { value: string }) => ({
configOptions: [
Expand Down Expand Up @@ -70,6 +77,12 @@ const mockAgentRun = vi.hoisted(() =>
),
);

const mockResumeFromLog = vi.hoisted(() => vi.fn());
const mockFormatConversationForResume = vi.hoisted(() => vi.fn());
const mockHydrateSessionJsonl = vi.hoisted(() =>
vi.fn().mockResolvedValue({ hasSession: false }),
);

const mockAgentConstructor = vi.hoisted(() =>
vi.fn().mockImplementation(function (this: Record<string, unknown>) {
this.run = mockAgentRun;
Expand Down Expand Up @@ -103,6 +116,11 @@ vi.mock("@posthog/agent/posthog-api", () => ({
getLlmGatewayUrl: vi.fn(() => "https://gateway.example.com"),
}));

vi.mock("@posthog/agent/resume", () => ({
resumeFromLog: mockResumeFromLog,
formatConversationForResume: mockFormatConversationForResume,
}));

vi.mock("@posthog/agent/gateway-models", () => ({
DEFAULT_GATEWAY_MODEL: "claude-opus-4-8",
DEFAULT_CODEX_MODEL: "gpt-5.5",
Expand All @@ -113,7 +131,7 @@ vi.mock("@posthog/agent/gateway-models", () => ({
}));

vi.mock("@posthog/agent/adapters/claude/session/jsonl-hydration", () => ({
hydrateSessionJsonl: vi.fn().mockResolvedValue(undefined),
hydrateSessionJsonl: mockHydrateSessionJsonl,
}));

vi.mock("node:fs", async (importOriginal) => {
Expand Down Expand Up @@ -334,6 +352,148 @@ describe("AgentService", () => {
});
});

describe("reconnect", () => {
it("preserves conversation context when native reconnect fails", async () => {
const apiClient = {};
mockAgentConstructor.mockImplementationOnce(function (
this: Record<string, unknown>,
) {
this.run = mockAgentRun;
this.cleanup = vi.fn().mockResolvedValue(undefined);
this.getPosthogAPI = vi.fn(() => apiClient);
this.flushAllLogs = vi.fn().mockResolvedValue(undefined);
});
mockResumeFromLog.mockResolvedValue({ conversation: [{ role: "user" }] });
mockFormatConversationForResume.mockReturnValue("User: previous request");
mockResumeSession.mockRejectedValueOnce(new Error("not found"));
await service.reconnectSession({
...baseSessionParams,
adapter: "codex",
sessionId: "old-session",
});

await service.prompt("run-1", [{ type: "text", text: "next request" }]);
await service.prompt("run-1", [{ type: "text", text: "later request" }]);

expect(mockPrompt.mock.calls[0][0].prompt).toEqual([
expect.objectContaining({
type: "text",
text: expect.stringContaining("previous request"),
}),
{ type: "text", text: "next request" },
]);
expect(mockPrompt.mock.calls[1][0].prompt).toEqual([
{ type: "text", text: "later request" },
]);
});

it("preserves conversation context when reconnect has no session ID", async () => {
mockAgentConstructor.mockImplementationOnce(function (
this: Record<string, unknown>,
) {
this.run = mockAgentRun;
this.cleanup = vi.fn().mockResolvedValue(undefined);
this.getPosthogAPI = vi.fn(() => ({}));
this.flushAllLogs = vi.fn().mockResolvedValue(undefined);
});
mockResumeFromLog.mockResolvedValue({ conversation: [{ role: "user" }] });
mockFormatConversationForResume.mockReturnValue("User: previous request");

await service.reconnectSession({
...baseSessionParams,
adapter: "codex",
});
await service.prompt("run-1", [{ type: "text", text: "next request" }]);

expect(mockPrompt.mock.calls[0][0].prompt[0].text).toContain(
"previous request",
);
});

it("reuses hydrated conversation when Claude resume fails", async () => {
mockAgentConstructor.mockImplementationOnce(function (
this: Record<string, unknown>,
) {
this.run = mockAgentRun;
this.cleanup = vi.fn().mockResolvedValue(undefined);
this.getPosthogAPI = vi.fn(() => ({}));
this.flushAllLogs = vi.fn().mockResolvedValue(undefined);
});
mockHydrateSessionJsonl.mockResolvedValueOnce({
hasSession: true,
conversation: [{ role: "user", content: [] }],
});
mockFormatConversationForResume.mockReturnValue("User: hydrated request");
mockResumeSession.mockRejectedValueOnce(new Error("not found"));

await service.reconnectSession({
...baseSessionParams,
adapter: "claude",
sessionId: "old-session",
});
await service.prompt("run-1", [{ type: "text", text: "next request" }]);

expect(mockResumeFromLog).not.toHaveBeenCalled();
expect(mockPrompt.mock.calls[0][0].prompt[0].text).toContain(
"hydrated request",
);
});

it("does not resend hydrated conversation after native resume succeeds", async () => {
mockAgentConstructor.mockImplementationOnce(function (
this: Record<string, unknown>,
) {
this.run = mockAgentRun;
this.cleanup = vi.fn().mockResolvedValue(undefined);
this.getPosthogAPI = vi.fn(() => ({}));
this.flushAllLogs = vi.fn().mockResolvedValue(undefined);
});
mockHydrateSessionJsonl.mockResolvedValueOnce({
hasSession: true,
conversation: [{ role: "user", content: [] }],
});
mockFormatConversationForResume.mockReturnValue("User: hydrated request");

await service.reconnectSession({
...baseSessionParams,
adapter: "claude",
sessionId: "old-session",
});
await service.prompt("run-1", [{ type: "text", text: "next request" }]);

expect(mockPrompt.mock.calls[0][0].prompt).toEqual([
{ type: "text", text: "next request" },
]);
});

it("retries recovered context after prompt failure", async () => {
mockAgentConstructor.mockImplementationOnce(function (
this: Record<string, unknown>,
) {
this.run = mockAgentRun;
this.cleanup = vi.fn().mockResolvedValue(undefined);
this.getPosthogAPI = vi.fn(() => ({}));
this.flushAllLogs = vi.fn().mockResolvedValue(undefined);
});
mockResumeFromLog.mockResolvedValue({ conversation: [{ role: "user" }] });
mockFormatConversationForResume.mockReturnValue("User: previous request");
mockPrompt.mockRejectedValueOnce(new Error("connection lost"));

await service.reconnectSession({
...baseSessionParams,
adapter: "codex",
});
await expect(
service.prompt("run-1", [{ type: "text", text: "first attempt" }]),
).rejects.toThrow("connection lost");
await service.prompt("run-1", [{ type: "text", text: "retry" }]);

expect(mockPrompt.mock.calls[1][0].prompt[0].text).toContain(
"previous request",
);
});
});

describe("MCP servers", () => {
it("marks desktop sessions as local even though they have a taskRunId", async () => {
await service.startSession({
Expand Down
Loading
Loading