@posthog/agent 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +221 -219
- package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +21 -0
- package/dist/adapters/claude/conversion/tool-use-to-acp.js +547 -0
- package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -0
- package/dist/adapters/claude/permissions/permission-options.d.ts +13 -0
- package/dist/adapters/claude/permissions/permission-options.js +117 -0
- package/dist/adapters/claude/permissions/permission-options.js.map +1 -0
- package/dist/adapters/claude/questions/utils.d.ts +132 -0
- package/dist/adapters/claude/questions/utils.js +63 -0
- package/dist/adapters/claude/questions/utils.js.map +1 -0
- package/dist/adapters/claude/tools.d.ts +18 -0
- package/dist/adapters/claude/tools.js +95 -0
- package/dist/adapters/claude/tools.js.map +1 -0
- package/dist/agent-DBQY1BfC.d.ts +123 -0
- package/dist/agent.d.ts +5 -0
- package/dist/agent.js +3656 -0
- package/dist/agent.js.map +1 -0
- package/dist/claude-cli/cli.js +3695 -2746
- package/dist/claude-cli/vendor/ripgrep/COPYING +3 -0
- package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/arm64-darwin/ripgrep.node +0 -0
- package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/arm64-linux/ripgrep.node +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-darwin/ripgrep.node +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-linux/ripgrep.node +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
- package/dist/claude-cli/vendor/ripgrep/x64-win32/ripgrep.node +0 -0
- package/dist/gateway-models.d.ts +24 -0
- package/dist/gateway-models.js +93 -0
- package/dist/gateway-models.js.map +1 -0
- package/dist/index.d.ts +170 -1157
- package/dist/index.js +3252 -5074
- package/dist/index.js.map +1 -1
- package/dist/logger-DDBiMOOD.d.ts +24 -0
- package/dist/posthog-api.d.ts +40 -0
- package/dist/posthog-api.js +175 -0
- package/dist/posthog-api.js.map +1 -0
- package/dist/server/agent-server.d.ts +41 -0
- package/dist/server/agent-server.js +4451 -0
- package/dist/server/agent-server.js.map +1 -0
- package/dist/server/bin.d.ts +1 -0
- package/dist/server/bin.js +4507 -0
- package/dist/server/bin.js.map +1 -0
- package/dist/types.d.ts +129 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +66 -14
- package/src/acp-extensions.ts +98 -16
- package/src/adapters/acp-connection.ts +494 -0
- package/src/adapters/base-acp-agent.ts +150 -0
- package/src/adapters/claude/claude-agent.ts +596 -0
- package/src/adapters/claude/conversion/acp-to-sdk.ts +102 -0
- package/src/adapters/claude/conversion/sdk-to-acp.ts +571 -0
- package/src/adapters/claude/conversion/tool-use-to-acp.ts +618 -0
- package/src/adapters/claude/hooks.ts +64 -0
- package/src/adapters/claude/mcp/tool-metadata.ts +102 -0
- package/src/adapters/claude/permissions/permission-handlers.ts +433 -0
- package/src/adapters/claude/permissions/permission-options.ts +103 -0
- package/src/adapters/claude/plan/utils.ts +56 -0
- package/src/adapters/claude/questions/utils.ts +92 -0
- package/src/adapters/claude/session/commands.ts +38 -0
- package/src/adapters/claude/session/mcp-config.ts +37 -0
- package/src/adapters/claude/session/models.ts +12 -0
- package/src/adapters/claude/session/options.ts +236 -0
- package/src/adapters/claude/tool-meta.ts +143 -0
- package/src/adapters/claude/tools.ts +53 -688
- package/src/adapters/claude/types.ts +61 -0
- package/src/adapters/codex/spawn.ts +130 -0
- package/src/agent.ts +96 -587
- package/src/execution-mode.ts +43 -0
- package/src/gateway-models.ts +135 -0
- package/src/index.ts +79 -0
- package/src/otel-log-writer.test.ts +105 -0
- package/src/otel-log-writer.ts +94 -0
- package/src/posthog-api.ts +75 -235
- package/src/resume.ts +115 -0
- package/src/sagas/apply-snapshot-saga.test.ts +690 -0
- package/src/sagas/apply-snapshot-saga.ts +88 -0
- package/src/sagas/capture-tree-saga.test.ts +892 -0
- package/src/sagas/capture-tree-saga.ts +141 -0
- package/src/sagas/resume-saga.test.ts +558 -0
- package/src/sagas/resume-saga.ts +332 -0
- package/src/sagas/test-fixtures.ts +250 -0
- package/src/server/agent-server.test.ts +220 -0
- package/src/server/agent-server.ts +748 -0
- package/src/server/bin.ts +88 -0
- package/src/server/jwt.ts +65 -0
- package/src/server/schemas.ts +47 -0
- package/src/server/types.ts +13 -0
- package/src/server/utils/retry.test.ts +122 -0
- package/src/server/utils/retry.ts +61 -0
- package/src/server/utils/sse-parser.test.ts +93 -0
- package/src/server/utils/sse-parser.ts +46 -0
- package/src/session-log-writer.test.ts +140 -0
- package/src/session-log-writer.ts +137 -0
- package/src/test/assertions.ts +114 -0
- package/src/test/controllers/sse-controller.ts +107 -0
- package/src/test/fixtures/api.ts +111 -0
- package/src/test/fixtures/config.ts +33 -0
- package/src/test/fixtures/notifications.ts +92 -0
- package/src/test/mocks/claude-sdk.ts +251 -0
- package/src/test/mocks/msw-handlers.ts +48 -0
- package/src/test/setup.ts +114 -0
- package/src/test/wait.ts +41 -0
- package/src/tree-tracker.ts +173 -0
- package/src/types.ts +54 -137
- package/src/utils/acp-content.ts +58 -0
- package/src/utils/async-mutex.test.ts +104 -0
- package/src/utils/async-mutex.ts +31 -0
- package/src/utils/common.ts +15 -0
- package/src/utils/gateway.ts +9 -6
- package/src/utils/logger.ts +0 -30
- package/src/utils/streams.ts +220 -0
- package/CLAUDE.md +0 -331
- package/src/adapters/claude/claude.ts +0 -1947
- package/src/adapters/claude/mcp-server.ts +0 -810
- package/src/adapters/claude/utils.ts +0 -267
- package/src/adapters/connection.ts +0 -95
- package/src/file-manager.ts +0 -273
- package/src/git-manager.ts +0 -577
- package/src/schemas.ts +0 -241
- package/src/session-store.ts +0 -259
- package/src/task-manager.ts +0 -163
- package/src/todo-manager.ts +0 -180
- package/src/tools/registry.ts +0 -134
- package/src/tools/types.ts +0 -133
- package/src/utils/tapped-stream.ts +0 -60
- package/src/worktree-manager.ts +0 -974
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Saga } from "@posthog/shared";
|
|
5
|
+
import { CaptureTreeSaga as GitCaptureTreeSaga } from "@twig/git/sagas/tree";
|
|
6
|
+
import type { PostHogAPIClient } from "../posthog-api.js";
|
|
7
|
+
import type { TreeSnapshot } from "../types.js";
|
|
8
|
+
|
|
9
|
+
export interface CaptureTreeInput {
|
|
10
|
+
repositoryPath: string;
|
|
11
|
+
taskId: string;
|
|
12
|
+
runId: string;
|
|
13
|
+
apiClient?: PostHogAPIClient;
|
|
14
|
+
lastTreeHash: string | null;
|
|
15
|
+
interrupted?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CaptureTreeOutput {
|
|
19
|
+
snapshot: TreeSnapshot | null;
|
|
20
|
+
newTreeHash: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class CaptureTreeSaga extends Saga<CaptureTreeInput, CaptureTreeOutput> {
|
|
24
|
+
protected async execute(input: CaptureTreeInput): Promise<CaptureTreeOutput> {
|
|
25
|
+
const {
|
|
26
|
+
repositoryPath,
|
|
27
|
+
lastTreeHash,
|
|
28
|
+
interrupted,
|
|
29
|
+
apiClient,
|
|
30
|
+
taskId,
|
|
31
|
+
runId,
|
|
32
|
+
} = input;
|
|
33
|
+
const tmpDir = join(repositoryPath, ".posthog", "tmp");
|
|
34
|
+
|
|
35
|
+
if (existsSync(join(repositoryPath, ".gitmodules"))) {
|
|
36
|
+
this.log.warn(
|
|
37
|
+
"Repository has submodules - snapshot may not capture submodule state",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const shouldArchive = !!apiClient;
|
|
42
|
+
const archivePath = shouldArchive
|
|
43
|
+
? join(tmpDir, `tree-${Date.now()}.tar.gz`)
|
|
44
|
+
: undefined;
|
|
45
|
+
|
|
46
|
+
const gitCaptureSaga = new GitCaptureTreeSaga(this.log);
|
|
47
|
+
const captureResult = await gitCaptureSaga.run({
|
|
48
|
+
baseDir: repositoryPath,
|
|
49
|
+
lastTreeHash,
|
|
50
|
+
archivePath,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (!captureResult.success) {
|
|
54
|
+
throw new Error(`Failed to capture tree: ${captureResult.error}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const {
|
|
58
|
+
snapshot: gitSnapshot,
|
|
59
|
+
archivePath: createdArchivePath,
|
|
60
|
+
changed,
|
|
61
|
+
} = captureResult.data;
|
|
62
|
+
|
|
63
|
+
if (!changed || !gitSnapshot) {
|
|
64
|
+
this.log.debug("No changes since last capture", { lastTreeHash });
|
|
65
|
+
return { snapshot: null, newTreeHash: lastTreeHash };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let archiveUrl: string | undefined;
|
|
69
|
+
if (apiClient && createdArchivePath) {
|
|
70
|
+
try {
|
|
71
|
+
archiveUrl = await this.uploadArchive(
|
|
72
|
+
createdArchivePath,
|
|
73
|
+
gitSnapshot.treeHash,
|
|
74
|
+
apiClient,
|
|
75
|
+
taskId,
|
|
76
|
+
runId,
|
|
77
|
+
);
|
|
78
|
+
} finally {
|
|
79
|
+
await rm(createdArchivePath, { force: true }).catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const snapshot: TreeSnapshot = {
|
|
84
|
+
treeHash: gitSnapshot.treeHash,
|
|
85
|
+
baseCommit: gitSnapshot.baseCommit,
|
|
86
|
+
changes: gitSnapshot.changes,
|
|
87
|
+
timestamp: gitSnapshot.timestamp,
|
|
88
|
+
interrupted,
|
|
89
|
+
archiveUrl,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
this.log.info("Tree captured", {
|
|
93
|
+
treeHash: snapshot.treeHash,
|
|
94
|
+
changes: snapshot.changes.length,
|
|
95
|
+
interrupted,
|
|
96
|
+
archiveUrl,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return { snapshot, newTreeHash: snapshot.treeHash };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async uploadArchive(
|
|
103
|
+
archivePath: string,
|
|
104
|
+
treeHash: string,
|
|
105
|
+
apiClient: PostHogAPIClient,
|
|
106
|
+
taskId: string,
|
|
107
|
+
runId: string,
|
|
108
|
+
): Promise<string | undefined> {
|
|
109
|
+
const archiveUrl = await this.step({
|
|
110
|
+
name: "upload_archive",
|
|
111
|
+
execute: async () => {
|
|
112
|
+
const archiveContent = await readFile(archivePath);
|
|
113
|
+
const base64Content = archiveContent.toString("base64");
|
|
114
|
+
|
|
115
|
+
const artifacts = await apiClient.uploadTaskArtifacts(taskId, runId, [
|
|
116
|
+
{
|
|
117
|
+
name: `trees/${treeHash}.tar.gz`,
|
|
118
|
+
type: "tree_snapshot",
|
|
119
|
+
content: base64Content,
|
|
120
|
+
content_type: "application/gzip",
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
if (artifacts.length > 0 && artifacts[0].storage_path) {
|
|
125
|
+
this.log.info("Tree archive uploaded", {
|
|
126
|
+
storagePath: artifacts[0].storage_path,
|
|
127
|
+
treeHash,
|
|
128
|
+
});
|
|
129
|
+
return artifacts[0].storage_path;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return undefined;
|
|
133
|
+
},
|
|
134
|
+
rollback: async () => {
|
|
135
|
+
await rm(archivePath, { force: true }).catch(() => {});
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return archiveUrl;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import type { SagaLogger } from "@posthog/shared";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, type vi } from "vitest";
|
|
3
|
+
import { POSTHOG_NOTIFICATIONS } from "../acp-extensions.js";
|
|
4
|
+
import type { PostHogAPIClient } from "../posthog-api.js";
|
|
5
|
+
import { ResumeSaga } from "./resume-saga.js";
|
|
6
|
+
import {
|
|
7
|
+
createAgentChunk,
|
|
8
|
+
createArchiveBuffer,
|
|
9
|
+
createMockApiClient,
|
|
10
|
+
createMockLogger,
|
|
11
|
+
createNotification,
|
|
12
|
+
createTaskRun,
|
|
13
|
+
createTestRepo,
|
|
14
|
+
createToolCall,
|
|
15
|
+
createToolResult,
|
|
16
|
+
createTreeSnapshotNotification,
|
|
17
|
+
createUserMessage,
|
|
18
|
+
type TestRepo,
|
|
19
|
+
} from "./test-fixtures.js";
|
|
20
|
+
|
|
21
|
+
describe("ResumeSaga", () => {
|
|
22
|
+
let repo: TestRepo;
|
|
23
|
+
let mockLogger: SagaLogger;
|
|
24
|
+
let mockApiClient: PostHogAPIClient;
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
repo = await createTestRepo("resume-saga");
|
|
28
|
+
mockLogger = createMockLogger();
|
|
29
|
+
mockApiClient = createMockApiClient();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
await repo.cleanup();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("empty state handling", () => {
|
|
37
|
+
it("returns empty result when task run has no log URL", async () => {
|
|
38
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
39
|
+
createTaskRun({ log_url: "" }),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const saga = new ResumeSaga(mockLogger);
|
|
43
|
+
const result = await saga.run({
|
|
44
|
+
taskId: "task-1",
|
|
45
|
+
runId: "run-1",
|
|
46
|
+
repositoryPath: repo.path,
|
|
47
|
+
apiClient: mockApiClient,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result.success).toBe(true);
|
|
51
|
+
if (result.success) {
|
|
52
|
+
expect(result.data.conversation).toHaveLength(0);
|
|
53
|
+
expect(result.data.latestSnapshot).toBeNull();
|
|
54
|
+
expect(result.data.snapshotApplied).toBe(false);
|
|
55
|
+
expect(result.data.logEntryCount).toBe(0);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns empty result when log has no entries", async () => {
|
|
60
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
61
|
+
createTaskRun(),
|
|
62
|
+
);
|
|
63
|
+
(
|
|
64
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
65
|
+
).mockResolvedValue([]);
|
|
66
|
+
|
|
67
|
+
const saga = new ResumeSaga(mockLogger);
|
|
68
|
+
const result = await saga.run({
|
|
69
|
+
taskId: "task-1",
|
|
70
|
+
runId: "run-1",
|
|
71
|
+
repositoryPath: repo.path,
|
|
72
|
+
apiClient: mockApiClient,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(result.success).toBe(true);
|
|
76
|
+
if (result.success) {
|
|
77
|
+
expect(result.data.conversation).toHaveLength(0);
|
|
78
|
+
expect(result.data.logEntryCount).toBe(0);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("conversation rebuilding", () => {
|
|
84
|
+
it("rebuilds user and assistant turns", async () => {
|
|
85
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
86
|
+
createTaskRun(),
|
|
87
|
+
);
|
|
88
|
+
(
|
|
89
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
90
|
+
).mockResolvedValue([
|
|
91
|
+
createUserMessage("Hello"),
|
|
92
|
+
createAgentChunk("Hi there!"),
|
|
93
|
+
createUserMessage("Help me"),
|
|
94
|
+
createAgentChunk("Sure, "),
|
|
95
|
+
createAgentChunk("I can help."),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const saga = new ResumeSaga(mockLogger);
|
|
99
|
+
const result = await saga.run({
|
|
100
|
+
taskId: "task-1",
|
|
101
|
+
runId: "run-1",
|
|
102
|
+
repositoryPath: repo.path,
|
|
103
|
+
apiClient: mockApiClient,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result.success).toBe(true);
|
|
107
|
+
if (!result.success) return;
|
|
108
|
+
|
|
109
|
+
expect(result.data.conversation).toHaveLength(4);
|
|
110
|
+
expect(result.data.conversation[0].role).toBe("user");
|
|
111
|
+
expect(result.data.conversation[1].role).toBe("assistant");
|
|
112
|
+
expect(result.data.conversation[2].role).toBe("user");
|
|
113
|
+
expect(result.data.conversation[3].role).toBe("assistant");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("merges consecutive text chunks", async () => {
|
|
117
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
118
|
+
createTaskRun(),
|
|
119
|
+
);
|
|
120
|
+
(
|
|
121
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
122
|
+
).mockResolvedValue([
|
|
123
|
+
createAgentChunk("Hello "),
|
|
124
|
+
createAgentChunk("world"),
|
|
125
|
+
createAgentChunk("!"),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const saga = new ResumeSaga(mockLogger);
|
|
129
|
+
const result = await saga.run({
|
|
130
|
+
taskId: "task-1",
|
|
131
|
+
runId: "run-1",
|
|
132
|
+
repositoryPath: repo.path,
|
|
133
|
+
apiClient: mockApiClient,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result.success).toBe(true);
|
|
137
|
+
if (!result.success) return;
|
|
138
|
+
|
|
139
|
+
expect(result.data.conversation).toHaveLength(1);
|
|
140
|
+
const content = result.data.conversation[0].content[0];
|
|
141
|
+
expect(content).toEqual({ type: "text", text: "Hello world!" });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("tracks tool calls with results", async () => {
|
|
145
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
146
|
+
createTaskRun(),
|
|
147
|
+
);
|
|
148
|
+
(
|
|
149
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
150
|
+
).mockResolvedValue([
|
|
151
|
+
createToolCall("call-1", "ReadFile", { path: "/test.ts" }),
|
|
152
|
+
createToolResult("call-1", "file contents here"),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const saga = new ResumeSaga(mockLogger);
|
|
156
|
+
const result = await saga.run({
|
|
157
|
+
taskId: "task-1",
|
|
158
|
+
runId: "run-1",
|
|
159
|
+
repositoryPath: repo.path,
|
|
160
|
+
apiClient: mockApiClient,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(result.success).toBe(true);
|
|
164
|
+
if (!result.success) return;
|
|
165
|
+
|
|
166
|
+
expect(result.data.conversation).toHaveLength(1);
|
|
167
|
+
const turn = result.data.conversation[0];
|
|
168
|
+
expect(turn.toolCalls).toHaveLength(1);
|
|
169
|
+
expect(turn.toolCalls?.[0]).toMatchObject({
|
|
170
|
+
toolCallId: "call-1",
|
|
171
|
+
toolName: "ReadFile",
|
|
172
|
+
input: { path: "/test.ts" },
|
|
173
|
+
result: "file contents here",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("handles multiple tool calls in sequence", async () => {
|
|
178
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
179
|
+
createTaskRun(),
|
|
180
|
+
);
|
|
181
|
+
(
|
|
182
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
183
|
+
).mockResolvedValue([
|
|
184
|
+
createToolCall("call-1", "ReadFile", { path: "/a.ts" }),
|
|
185
|
+
createToolResult("call-1", "content a"),
|
|
186
|
+
createToolCall("call-2", "WriteFile", {
|
|
187
|
+
path: "/b.ts",
|
|
188
|
+
content: "new",
|
|
189
|
+
}),
|
|
190
|
+
createToolResult("call-2", "written"),
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
const saga = new ResumeSaga(mockLogger);
|
|
194
|
+
const result = await saga.run({
|
|
195
|
+
taskId: "task-1",
|
|
196
|
+
runId: "run-1",
|
|
197
|
+
repositoryPath: repo.path,
|
|
198
|
+
apiClient: mockApiClient,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(result.success).toBe(true);
|
|
202
|
+
if (!result.success) return;
|
|
203
|
+
|
|
204
|
+
expect(result.data.conversation[0].toolCalls).toHaveLength(2);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("handles orphaned tool calls (no result due to interruption)", async () => {
|
|
208
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
209
|
+
createTaskRun(),
|
|
210
|
+
);
|
|
211
|
+
(
|
|
212
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
213
|
+
).mockResolvedValue([
|
|
214
|
+
createAgentChunk("Let me read the file"),
|
|
215
|
+
createToolCall("call-1", "ReadFile", { path: "/test.ts" }),
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
const saga = new ResumeSaga(mockLogger);
|
|
219
|
+
const result = await saga.run({
|
|
220
|
+
taskId: "task-1",
|
|
221
|
+
runId: "run-1",
|
|
222
|
+
repositoryPath: repo.path,
|
|
223
|
+
apiClient: mockApiClient,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(result.success).toBe(true);
|
|
227
|
+
if (!result.success) return;
|
|
228
|
+
|
|
229
|
+
expect(result.data.conversation).toHaveLength(1);
|
|
230
|
+
const turn = result.data.conversation[0];
|
|
231
|
+
expect(turn.toolCalls).toHaveLength(1);
|
|
232
|
+
expect(turn.toolCalls?.[0]).toMatchObject({
|
|
233
|
+
toolCallId: "call-1",
|
|
234
|
+
toolName: "ReadFile",
|
|
235
|
+
input: { path: "/test.ts" },
|
|
236
|
+
});
|
|
237
|
+
expect(turn.toolCalls?.[0].result).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("handles multiple orphaned tool calls", async () => {
|
|
241
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
242
|
+
createTaskRun(),
|
|
243
|
+
);
|
|
244
|
+
(
|
|
245
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
246
|
+
).mockResolvedValue([
|
|
247
|
+
createToolCall("call-1", "ReadFile", { path: "/a.ts" }),
|
|
248
|
+
createToolResult("call-1", "content a"),
|
|
249
|
+
createToolCall("call-2", "WriteFile", {
|
|
250
|
+
path: "/b.ts",
|
|
251
|
+
content: "new",
|
|
252
|
+
}),
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
const saga = new ResumeSaga(mockLogger);
|
|
256
|
+
const result = await saga.run({
|
|
257
|
+
taskId: "task-1",
|
|
258
|
+
runId: "run-1",
|
|
259
|
+
repositoryPath: repo.path,
|
|
260
|
+
apiClient: mockApiClient,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(result.success).toBe(true);
|
|
264
|
+
if (!result.success) return;
|
|
265
|
+
|
|
266
|
+
const toolCalls = result.data.conversation[0].toolCalls ?? [];
|
|
267
|
+
expect(toolCalls).toHaveLength(2);
|
|
268
|
+
expect(toolCalls[0].result).toBe("content a");
|
|
269
|
+
expect(toolCalls[1].result).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("snapshot finding", () => {
|
|
274
|
+
it("finds latest tree snapshot", async () => {
|
|
275
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
276
|
+
createTaskRun(),
|
|
277
|
+
);
|
|
278
|
+
(
|
|
279
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
280
|
+
).mockResolvedValue([
|
|
281
|
+
createTreeSnapshotNotification("hash-1"),
|
|
282
|
+
createUserMessage("continue"),
|
|
283
|
+
createTreeSnapshotNotification("hash-2", "gs://bucket/hash-2.tar.gz"),
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
const saga = new ResumeSaga(mockLogger);
|
|
287
|
+
const result = await saga.run({
|
|
288
|
+
taskId: "task-1",
|
|
289
|
+
runId: "run-1",
|
|
290
|
+
repositoryPath: repo.path,
|
|
291
|
+
apiClient: mockApiClient,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(result.success).toBe(true);
|
|
295
|
+
if (!result.success) return;
|
|
296
|
+
|
|
297
|
+
expect(result.data.latestSnapshot?.treeHash).toBe("hash-2");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("finds snapshot with SDK double-underscore prefix", async () => {
|
|
301
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
302
|
+
createTaskRun(),
|
|
303
|
+
);
|
|
304
|
+
(
|
|
305
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
306
|
+
).mockResolvedValue([
|
|
307
|
+
createNotification(`_${POSTHOG_NOTIFICATIONS.TREE_SNAPSHOT}`, {
|
|
308
|
+
treeHash: "sdk-prefixed-hash",
|
|
309
|
+
baseCommit: "abc",
|
|
310
|
+
changes: [],
|
|
311
|
+
timestamp: new Date().toISOString(),
|
|
312
|
+
}),
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
const saga = new ResumeSaga(mockLogger);
|
|
316
|
+
const result = await saga.run({
|
|
317
|
+
taskId: "task-1",
|
|
318
|
+
runId: "run-1",
|
|
319
|
+
repositoryPath: repo.path,
|
|
320
|
+
apiClient: mockApiClient,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(result.success).toBe(true);
|
|
324
|
+
if (!result.success) return;
|
|
325
|
+
|
|
326
|
+
expect(result.data.latestSnapshot?.treeHash).toBe("sdk-prefixed-hash");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("returns interrupted flag from snapshot", async () => {
|
|
330
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
331
|
+
createTaskRun(),
|
|
332
|
+
);
|
|
333
|
+
(
|
|
334
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
335
|
+
).mockResolvedValue([
|
|
336
|
+
createTreeSnapshotNotification("hash-1", "gs://bucket/file.tar.gz", {
|
|
337
|
+
interrupted: true,
|
|
338
|
+
}),
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
const saga = new ResumeSaga(mockLogger);
|
|
342
|
+
const result = await saga.run({
|
|
343
|
+
taskId: "task-1",
|
|
344
|
+
runId: "run-1",
|
|
345
|
+
repositoryPath: repo.path,
|
|
346
|
+
apiClient: mockApiClient,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(result.success).toBe(true);
|
|
350
|
+
if (!result.success) return;
|
|
351
|
+
|
|
352
|
+
expect(result.data.interrupted).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("snapshot application", () => {
|
|
357
|
+
it("applies snapshot when archive URL present", async () => {
|
|
358
|
+
const baseCommit = await repo.git(["rev-parse", "HEAD"]);
|
|
359
|
+
|
|
360
|
+
const archive = await createArchiveBuffer([
|
|
361
|
+
{ path: "restored.ts", content: "restored content" },
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
365
|
+
createTaskRun(),
|
|
366
|
+
);
|
|
367
|
+
(
|
|
368
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
369
|
+
).mockResolvedValue([
|
|
370
|
+
createNotification(POSTHOG_NOTIFICATIONS.TREE_SNAPSHOT, {
|
|
371
|
+
treeHash: "hash-1",
|
|
372
|
+
baseCommit,
|
|
373
|
+
archiveUrl: "gs://bucket/hash-1.tar.gz",
|
|
374
|
+
changes: [{ path: "restored.ts", status: "A" }],
|
|
375
|
+
timestamp: new Date().toISOString(),
|
|
376
|
+
}),
|
|
377
|
+
]);
|
|
378
|
+
(
|
|
379
|
+
mockApiClient.downloadArtifact as ReturnType<typeof vi.fn>
|
|
380
|
+
).mockResolvedValue(archive);
|
|
381
|
+
|
|
382
|
+
const saga = new ResumeSaga(mockLogger);
|
|
383
|
+
const result = await saga.run({
|
|
384
|
+
taskId: "task-1",
|
|
385
|
+
runId: "run-1",
|
|
386
|
+
repositoryPath: repo.path,
|
|
387
|
+
apiClient: mockApiClient,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(result.success).toBe(true);
|
|
391
|
+
if (!result.success) return;
|
|
392
|
+
|
|
393
|
+
expect(result.data.snapshotApplied).toBe(true);
|
|
394
|
+
|
|
395
|
+
const content = await repo.readFile("restored.ts");
|
|
396
|
+
expect(content).toBe("restored content");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("continues without snapshot when no archive URL", async () => {
|
|
400
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
401
|
+
createTaskRun(),
|
|
402
|
+
);
|
|
403
|
+
(
|
|
404
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
405
|
+
).mockResolvedValue([
|
|
406
|
+
createTreeSnapshotNotification("hash-1"),
|
|
407
|
+
createUserMessage("hello"),
|
|
408
|
+
]);
|
|
409
|
+
|
|
410
|
+
const saga = new ResumeSaga(mockLogger);
|
|
411
|
+
const result = await saga.run({
|
|
412
|
+
taskId: "task-1",
|
|
413
|
+
runId: "run-1",
|
|
414
|
+
repositoryPath: repo.path,
|
|
415
|
+
apiClient: mockApiClient,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(result.success).toBe(true);
|
|
419
|
+
if (!result.success) return;
|
|
420
|
+
|
|
421
|
+
expect(result.data.snapshotApplied).toBe(false);
|
|
422
|
+
expect(result.data.latestSnapshot).not.toBeNull();
|
|
423
|
+
expect(result.data.conversation).toHaveLength(1);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("continues without snapshot when apply fails", async () => {
|
|
427
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
428
|
+
createTaskRun(),
|
|
429
|
+
);
|
|
430
|
+
(
|
|
431
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
432
|
+
).mockResolvedValue([
|
|
433
|
+
createTreeSnapshotNotification("hash-1", "gs://bucket/hash-1.tar.gz"),
|
|
434
|
+
createUserMessage("hello"),
|
|
435
|
+
]);
|
|
436
|
+
(
|
|
437
|
+
mockApiClient.downloadArtifact as ReturnType<typeof vi.fn>
|
|
438
|
+
).mockRejectedValue(new Error("Download failed"));
|
|
439
|
+
|
|
440
|
+
const saga = new ResumeSaga(mockLogger);
|
|
441
|
+
const result = await saga.run({
|
|
442
|
+
taskId: "task-1",
|
|
443
|
+
runId: "run-1",
|
|
444
|
+
repositoryPath: repo.path,
|
|
445
|
+
apiClient: mockApiClient,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
expect(result.success).toBe(true);
|
|
449
|
+
if (!result.success) return;
|
|
450
|
+
|
|
451
|
+
expect(result.data.snapshotApplied).toBe(false);
|
|
452
|
+
expect(result.data.conversation).toHaveLength(1);
|
|
453
|
+
expect(mockLogger.warn).toHaveBeenCalled();
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe("device info", () => {
|
|
458
|
+
it("extracts device info from log entries", async () => {
|
|
459
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
460
|
+
createTaskRun(),
|
|
461
|
+
);
|
|
462
|
+
(
|
|
463
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
464
|
+
).mockResolvedValue([
|
|
465
|
+
createTreeSnapshotNotification("hash-1", undefined, {
|
|
466
|
+
device: { type: "local" },
|
|
467
|
+
}),
|
|
468
|
+
createTreeSnapshotNotification("hash-2", undefined, {
|
|
469
|
+
device: { type: "cloud" },
|
|
470
|
+
}),
|
|
471
|
+
]);
|
|
472
|
+
|
|
473
|
+
const saga = new ResumeSaga(mockLogger);
|
|
474
|
+
const result = await saga.run({
|
|
475
|
+
taskId: "task-1",
|
|
476
|
+
runId: "run-1",
|
|
477
|
+
repositoryPath: repo.path,
|
|
478
|
+
apiClient: mockApiClient,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
expect(result.success).toBe(true);
|
|
482
|
+
if (!result.success) return;
|
|
483
|
+
|
|
484
|
+
expect(result.data.lastDevice).toEqual({ type: "cloud" });
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
describe("failure handling", () => {
|
|
489
|
+
it("fails when getTaskRun throws", async () => {
|
|
490
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
491
|
+
new Error("API error"),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const saga = new ResumeSaga(mockLogger);
|
|
495
|
+
const result = await saga.run({
|
|
496
|
+
taskId: "task-1",
|
|
497
|
+
runId: "run-1",
|
|
498
|
+
repositoryPath: repo.path,
|
|
499
|
+
apiClient: mockApiClient,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(result.success).toBe(false);
|
|
503
|
+
if (!result.success) {
|
|
504
|
+
expect(result.error).toContain("API error");
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("fails when fetchTaskRunLogs throws", async () => {
|
|
509
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
510
|
+
createTaskRun(),
|
|
511
|
+
);
|
|
512
|
+
(
|
|
513
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
514
|
+
).mockRejectedValue(new Error("Log fetch failed"));
|
|
515
|
+
|
|
516
|
+
const saga = new ResumeSaga(mockLogger);
|
|
517
|
+
const result = await saga.run({
|
|
518
|
+
taskId: "task-1",
|
|
519
|
+
runId: "run-1",
|
|
520
|
+
repositoryPath: repo.path,
|
|
521
|
+
apiClient: mockApiClient,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
expect(result.success).toBe(false);
|
|
525
|
+
if (!result.success) {
|
|
526
|
+
expect(result.error).toContain("Log fetch failed");
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("log entry count", () => {
|
|
532
|
+
it("reports correct log entry count", async () => {
|
|
533
|
+
(mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
534
|
+
createTaskRun(),
|
|
535
|
+
);
|
|
536
|
+
(
|
|
537
|
+
mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
|
|
538
|
+
).mockResolvedValue([
|
|
539
|
+
createUserMessage("one"),
|
|
540
|
+
createAgentChunk("two"),
|
|
541
|
+
createUserMessage("three"),
|
|
542
|
+
]);
|
|
543
|
+
|
|
544
|
+
const saga = new ResumeSaga(mockLogger);
|
|
545
|
+
const result = await saga.run({
|
|
546
|
+
taskId: "task-1",
|
|
547
|
+
runId: "run-1",
|
|
548
|
+
repositoryPath: repo.path,
|
|
549
|
+
apiClient: mockApiClient,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
expect(result.success).toBe(true);
|
|
553
|
+
if (result.success) {
|
|
554
|
+
expect(result.data.logEntryCount).toBe(3);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
});
|