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
Original file line number Diff line number Diff line change
Expand Up @@ -541,8 +541,9 @@ export async function hydrateSessionJsonl(params: {
});

const allTurns = rebuildConversation(entries);

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

Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface ResumeState {
interrupted: boolean;
lastDevice?: DeviceInfo;
logEntryCount: number;
sessionId: string | null;
}

export interface ConversationTurn {
Expand Down Expand Up @@ -93,6 +94,7 @@ export async function resumeFromLog(
interrupted: result.data.interrupted,
lastDevice: result.data.lastDevice,
logEntryCount: result.data.logEntryCount,
sessionId: result.data.sessionId,
};
}

Expand Down
64 changes: 64 additions & 0 deletions packages/agent/src/sagas/resume-saga.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { SagaLogger } from "@posthog/shared";
import { afterEach, beforeEach, describe, expect, it, type vi } from "vitest";
import { POSTHOG_NOTIFICATIONS } from "../acp-extensions";
import type { PostHogAPIClient } from "../posthog-api";
import { ResumeSaga } from "./resume-saga";
import {
Expand All @@ -8,6 +9,7 @@ import {
createGitCheckpointNotification,
createMockApiClient,
createMockLogger,
createNotification,
createTaskRun,
createTestRepo,
createToolCall,
Expand Down Expand Up @@ -553,6 +555,68 @@ describe("ResumeSaga", () => {
});
});

describe("session id", () => {
const runStarted = (sessionId: string) =>
createNotification(POSTHOG_NOTIFICATIONS.RUN_STARTED, { sessionId });
const sdkPrefixedRunStarted = (sessionId: string) =>
createNotification(`_${POSTHOG_NOTIFICATIONS.RUN_STARTED}`, {
sessionId,
});

it.each([
{
name: "extracts the session id from the run_started notification",
entries: () => [
runStarted("session-abc"),
createUserMessage("Hello"),
createAgentChunk("Hi"),
],
expected: "session-abc",
},
{
name: "reads the sdk-prefixed run_started method too",
entries: () => [
sdkPrefixedRunStarted("session-prefixed"),
createUserMessage("Hello"),
],
expected: "session-prefixed",
},
{
name: "returns the most recent session id when several are present",
entries: () => [
runStarted("session-old"),
createUserMessage("Hello"),
runStarted("session-new"),
],
expected: "session-new",
},
{
name: "returns null when no run_started notification is present",
entries: () => [createUserMessage("Hello"), createAgentChunk("Hi")],
expected: null,
},
])("$name", async ({ entries, expected }) => {
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
createTaskRun(),
);
(
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
).mockResolvedValue(entries());

const saga = new ResumeSaga(mockLogger);
const result = await saga.run({
taskId: "task-1",
runId: "run-1",
repositoryPath: repo.path,
apiClient: mockApiClient,
});

expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.sessionId).toBe(expected);
});
});
Comment thread
tatoalo marked this conversation as resolved.

describe("log entry count", () => {
it("reports correct log entry count", async () => {
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
Expand Down
24 changes: 24 additions & 0 deletions packages/agent/src/sagas/resume-saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface ResumeOutput {
interrupted: boolean;
lastDevice?: DeviceInfo;
logEntryCount: number;
sessionId: string | null;
}

export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
Expand Down Expand Up @@ -87,9 +88,14 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
Promise.resolve(this.findLastDeviceInfo(entries)),
);

const sessionId = await this.readOnlyStep("find_session_id", () =>
Promise.resolve(this.findSessionId(entries)),
);

this.log.info("Resume state rebuilt", {
turns: conversation.length,
hasGitCheckpoint: !!latestGitCheckpoint,
hasSessionId: !!sessionId,
interrupted: false,
});

Expand All @@ -99,6 +105,7 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
interrupted: false,
lastDevice,
logEntryCount: entries.length,
sessionId,
};
}

Expand All @@ -108,9 +115,26 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
latestGitCheckpoint: null,
interrupted: false,
logEntryCount: 0,
sessionId: null,
};
}

private findSessionId(entries: StoredNotification[]): string | null {
const runStarted = POSTHOG_NOTIFICATIONS.RUN_STARTED;
for (let i = entries.length - 1; i >= 0; i--) {
const method = entries[i].notification?.method;
if (method === runStarted || method === `_${runStarted}`) {
const params = entries[i].notification?.params as
| { sessionId?: string }
| undefined;
if (typeof params?.sessionId === "string" && params.sessionId) {
return params.sessionId;
}
}
}
return null;
}

private findLatestGitCheckpoint(
entries: StoredNotification[],
): GitCheckpointEvent | null {
Expand Down
137 changes: 135 additions & 2 deletions packages/agent/src/server/agent-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import jwt from "jsonwebtoken";
import { type SetupServerApi, setupServer } from "msw/node";
import {
Expand All @@ -10,9 +12,18 @@ import {
it,
vi,
} from "vitest";
import { createTestRepo, type TestRepo } from "../test/fixtures/api";
import { getSessionJsonlPath } from "../adapters/claude/session/jsonl-hydration";
import type { PermissionMode } from "../execution-mode";
import type { PostHogAPIClient } from "../posthog-api";
import type { ResumeState } from "../resume";
import {
createMockApiClient,
createTaskRun,
createTestRepo,
type TestRepo,
} from "../test/fixtures/api";
import { createPostHogHandlers } from "../test/mocks/msw-handlers";
import type { TaskRun } from "../types";
import type { StoredEntry, TaskRun } from "../types";
import {
AgentServer,
isTurnCompleteNotification,
Expand Down Expand Up @@ -217,6 +228,18 @@ interface TestableServer {
): { claudeCode: { options: Record<string, unknown> } } | undefined;
}

interface NativeResumeTestServer {
resumeState: ResumeState | null;
prepareNativeResume(
payload: JwtPayload,
posthogAPI: PostHogAPIClient,
preTaskRun: TaskRun | null,
runtimeAdapter: "claude" | "codex",
cwd: string,
permissionMode: PermissionMode,
): Promise<{ sessionId: string; warm: boolean } | null>;
}

let nextTestPort = 20000;

function getNextTestPort(): number {
Expand Down Expand Up @@ -272,6 +295,21 @@ function createTestJwt(
);
}

function sessionUpdateEntry(
sessionUpdate: string,
extra: Record<string, unknown> = {},
): StoredEntry {
return {
type: "notification",
timestamp: new Date().toISOString(),
notification: {
jsonrpc: "2.0",
method: "session/update",
params: { update: { sessionUpdate, ...extra } },
},
};
}

// Test RSA key pair (2048-bit, for testing only)
const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDqh94SYMFsvG4C
Expand Down Expand Up @@ -1204,6 +1242,101 @@ describe("AgentServer HTTP Mode", () => {
});
});

describe("native resume", () => {
it("hydrates cold sessions from S3 logs instead of cached resume conversation", async () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = join(repo.path, ".claude-test");

try {
const s = createServer() as unknown as NativeResumeTestServer;
s.resumeState = {
conversation: [
{
role: "user",
content: [{ type: "text", text: "continue" }],
},
{
role: "assistant",
content: [{ type: "text", text: "visible answer only" }],
},
],
latestGitCheckpoint: null,
interrupted: false,
logEntryCount: 3,
sessionId: "prior-session",
};

const posthogAPI = createMockApiClient();
(posthogAPI.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
createTaskRun({ id: "previous-run", log_url: "s3://logs" }),
);
(
posthogAPI.fetchTaskRunLogs as ReturnType<typeof vi.fn>
).mockResolvedValue([
sessionUpdateEntry("user_message", {
content: { type: "text", text: "continue" },
}),
sessionUpdateEntry("agent_thought_chunk", {
content: {
type: "thinking",
thinking: "preserve extended thinking",
},
}),
sessionUpdateEntry("agent_message", {
content: { type: "text", text: "visible answer" },
}),
]);

const result = await s.prepareNativeResume(
{
task_id: "test-task-id",
run_id: "test-run-id",
team_id: 1,
user_id: 1,
distinct_id: "test-distinct-id",
mode: "interactive",
},
posthogAPI,
createTaskRun({
id: "test-run-id",
state: { resume_from_run_id: "previous-run" },
}),
"claude",
repo.path,
"bypassPermissions",
);

expect(result).toEqual({ sessionId: "prior-session", warm: false });
expect(posthogAPI.fetchTaskRunLogs).toHaveBeenCalledTimes(1);

const jsonl = await readFile(
getSessionJsonlPath("prior-session", repo.path),
"utf-8",
);
const blocks = jsonl
.trim()
.split("\n")
.flatMap((line) => {
const parsed = JSON.parse(line) as {
message?: { content?: unknown[] };
};
return parsed.message?.content ?? [];
});

expect(blocks).toContainEqual({
type: "thinking",
thinking: "preserve extended thinking",
});
} finally {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
}
}
});
});

describe("PR attribution", () => {
const PR_URL = "https://github.com/PostHog/posthog.com/pull/17764";
const payload: JwtPayload = {
Expand Down
Loading
Loading