@ai-devkit/agent-manager 0.38.0 → 0.39.0
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/dist/__tests__/harnesses/devin/DevinAdapter.test.js +597 -0
- package/dist/__tests__/harnesses/devin/DevinAdapter.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/DevinAgentMapper.test.js +91 -0
- package/dist/__tests__/harnesses/devin/DevinAgentMapper.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/DevinSessionLocator.test.js +192 -0
- package/dist/__tests__/harnesses/devin/DevinSessionLocator.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/DevinSessionParser.test.js +407 -0
- package/dist/__tests__/harnesses/devin/DevinSessionParser.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/fixtures.js +95 -0
- package/dist/__tests__/harnesses/devin/fixtures.js.map +1 -0
- package/dist/__tests__/harnesses/readiness/AgentReadiness.test.js +64 -1
- package/dist/__tests__/harnesses/readiness/AgentReadiness.test.js.map +1 -1
- package/dist/__tests__/terminal/GhosttyBackend.test.js +334 -0
- package/dist/__tests__/terminal/GhosttyBackend.test.js.map +1 -0
- package/dist/adapters/AgentAdapter.d.ts +1 -1
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/AgentAdapter.js +2 -1
- package/dist/adapters/AgentAdapter.js.map +1 -1
- package/dist/harnesses/devin/DevinAdapter.d.ts +39 -0
- package/dist/harnesses/devin/DevinAdapter.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinAdapter.js +177 -0
- package/dist/harnesses/devin/DevinAdapter.js.map +1 -0
- package/dist/harnesses/devin/DevinAgentMapper.d.ts +11 -0
- package/dist/harnesses/devin/DevinAgentMapper.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinAgentMapper.js +35 -0
- package/dist/harnesses/devin/DevinAgentMapper.js.map +1 -0
- package/dist/harnesses/devin/DevinSessionLocator.d.ts +46 -0
- package/dist/harnesses/devin/DevinSessionLocator.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinSessionLocator.js +176 -0
- package/dist/harnesses/devin/DevinSessionLocator.js.map +1 -0
- package/dist/harnesses/devin/DevinSessionParser.d.ts +29 -0
- package/dist/harnesses/devin/DevinSessionParser.d.ts.map +1 -0
- package/dist/harnesses/devin/DevinSessionParser.js +166 -0
- package/dist/harnesses/devin/DevinSessionParser.js.map +1 -0
- package/dist/harnesses/devin/readiness.d.ts +3 -0
- package/dist/harnesses/devin/readiness.d.ts.map +1 -0
- package/dist/harnesses/devin/readiness.js +39 -0
- package/dist/harnesses/devin/readiness.js.map +1 -0
- package/dist/harnesses/index.d.ts.map +1 -1
- package/dist/harnesses/index.js +3 -1
- package/dist/harnesses/index.js.map +1 -1
- package/dist/harnesses/readiness/AgentReadiness.d.ts.map +1 -1
- package/dist/harnesses/readiness/AgentReadiness.js +3 -1
- package/dist/harnesses/readiness/AgentReadiness.js.map +1 -1
- package/dist/harnesses/readiness/types.d.ts +1 -1
- package/dist/harnesses/readiness/types.d.ts.map +1 -1
- package/dist/harnesses/runtimeProfiles.d.ts.map +1 -1
- package/dist/harnesses/runtimeProfiles.js +2 -1
- package/dist/harnesses/runtimeProfiles.js.map +1 -1
- package/dist/terminal/TtyWriter.d.ts.map +1 -1
- package/dist/terminal/TtyWriter.js +4 -3
- package/dist/terminal/TtyWriter.js.map +1 -1
- package/dist/terminal/backends/ghostty.d.ts +3 -0
- package/dist/terminal/backends/ghostty.d.ts.map +1 -0
- package/dist/terminal/backends/ghostty.js +161 -0
- package/dist/terminal/backends/ghostty.js.map +1 -0
- package/dist/terminal/backends/index.d.ts.map +1 -1
- package/dist/terminal/backends/index.js +2 -0
- package/dist/terminal/backends/index.js.map +1 -1
- package/dist/terminal/backends/shared.d.ts +2 -0
- package/dist/terminal/backends/shared.d.ts.map +1 -1
- package/dist/terminal/backends/shared.js +4 -1
- package/dist/terminal/backends/shared.js.map +1 -1
- package/dist/terminal/types.d.ts +1 -0
- package/dist/terminal/types.d.ts.map +1 -1
- package/dist/terminal/types.js +1 -0
- package/dist/terminal/types.js.map +1 -1
- package/dist/utils/process.d.ts +2 -0
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +3 -0
- package/dist/utils/process.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/harnesses/devin/DevinAdapter.test.ts +292 -0
- package/src/__tests__/harnesses/devin/DevinAgentMapper.test.ts +88 -0
- package/src/__tests__/harnesses/devin/DevinSessionLocator.test.ts +139 -0
- package/src/__tests__/harnesses/devin/DevinSessionParser.test.ts +218 -0
- package/src/__tests__/harnesses/devin/fixtures.ts +144 -0
- package/src/__tests__/harnesses/readiness/AgentReadiness.test.ts +41 -0
- package/src/__tests__/terminal/GhosttyBackend.test.ts +271 -0
- package/src/adapters/AgentAdapter.ts +1 -0
- package/src/harnesses/devin/DevinAdapter.ts +224 -0
- package/src/harnesses/devin/DevinAgentMapper.ts +43 -0
- package/src/harnesses/devin/DevinSessionLocator.ts +230 -0
- package/src/harnesses/devin/DevinSessionParser.ts +236 -0
- package/src/harnesses/devin/readiness.ts +36 -0
- package/src/harnesses/index.ts +2 -0
- package/src/harnesses/readiness/AgentReadiness.ts +2 -0
- package/src/harnesses/runtimeProfiles.ts +1 -0
- package/src/terminal/TtyWriter.ts +5 -5
- package/src/terminal/backends/ghostty.ts +180 -0
- package/src/terminal/backends/index.ts +2 -0
- package/src/terminal/backends/shared.ts +6 -1
- package/src/terminal/types.ts +1 -0
- package/src/utils/process.ts +5 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { AgentStatus, type ProcessInfo } from "../../../adapters/AgentAdapter.js";
|
|
6
|
+
import { DevinAdapter } from "../../../harnesses/devin/DevinAdapter.js";
|
|
7
|
+
import { writeDatabase, writeLocks } from "./fixtures.js";
|
|
8
|
+
|
|
9
|
+
describe("DevinAdapter", () => {
|
|
10
|
+
let adapter: DevinAdapter;
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let dbPath: string;
|
|
13
|
+
let locksDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-adapter-test-"));
|
|
17
|
+
dbPath = path.join(tmpDir, "sessions.db");
|
|
18
|
+
locksDir = path.join(tmpDir, "session_locks");
|
|
19
|
+
adapter = new DevinAdapter(dbPath, locksDir);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
adapter.close();
|
|
24
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("accepts agent invocations and rejects utility subcommands", () => {
|
|
28
|
+
const cases: [string, boolean][] = [
|
|
29
|
+
["devin", true],
|
|
30
|
+
["/usr/local/bin/devin", true],
|
|
31
|
+
["devin -r brawny-shirt", true],
|
|
32
|
+
["devin --resume brawny-shirt --model x", true],
|
|
33
|
+
["devin -p fix the bug", true],
|
|
34
|
+
["devin -p update the schema", true],
|
|
35
|
+
["devin --print list files", true],
|
|
36
|
+
["devin -r", true],
|
|
37
|
+
["devin --resume", true],
|
|
38
|
+
["devin --cloud", true],
|
|
39
|
+
["devin acp", true],
|
|
40
|
+
["/Users/x/.local/bin/devin acp --cloud", true],
|
|
41
|
+
["devin -- list the files", true],
|
|
42
|
+
["devin list", false],
|
|
43
|
+
["devin ls", false],
|
|
44
|
+
["devin doctor", false],
|
|
45
|
+
["devin auth status", false],
|
|
46
|
+
["devin ssh sess-1", false],
|
|
47
|
+
["devin forward 8080", false],
|
|
48
|
+
["devin models", false],
|
|
49
|
+
["devin skills list", false],
|
|
50
|
+
["devin rules", false],
|
|
51
|
+
["devin mcp add x", false],
|
|
52
|
+
["devin plugins list", false],
|
|
53
|
+
["devin cloud ls", false],
|
|
54
|
+
["devin desktop /repo", false],
|
|
55
|
+
["devin update", false],
|
|
56
|
+
["devin version", false],
|
|
57
|
+
["devin migrate", false],
|
|
58
|
+
["devin sandbox run", false],
|
|
59
|
+
["devin setup", false],
|
|
60
|
+
["devin uninstall", false],
|
|
61
|
+
["devin rm sess-1", false],
|
|
62
|
+
["devin help", false],
|
|
63
|
+
["devin --model opus list", false],
|
|
64
|
+
["devin --model=opus list", false],
|
|
65
|
+
["devin --config /tmp/c.json update", false],
|
|
66
|
+
["devin --prompt-file /tmp/p.txt rm", false],
|
|
67
|
+
["node devin.js", false],
|
|
68
|
+
["devinition", false],
|
|
69
|
+
];
|
|
70
|
+
for (const [command, expected] of cases) {
|
|
71
|
+
expect(adapter.canHandle({ pid: 1, command, cwd: "/repo", tty: "ttys001" })).toBe(
|
|
72
|
+
expected,
|
|
73
|
+
command,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns process-only agents when no database or lock exists", async () => {
|
|
79
|
+
const agents = await adapter.detectAgents({
|
|
80
|
+
processes: [{ pid: 100, command: "devin", cwd: "/repo", tty: "ttys001" }],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(agents).toEqual([
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
type: "devin",
|
|
86
|
+
status: AgentStatus.RUNNING,
|
|
87
|
+
pid: 100,
|
|
88
|
+
sessionId: "pid-100",
|
|
89
|
+
summary: "Devin process running",
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("matches a TUI to its session through the acp backend's lock", async () => {
|
|
95
|
+
const now = Math.floor(Date.now() / 1000);
|
|
96
|
+
writeDatabase(dbPath, {
|
|
97
|
+
sessions: [{ id: "brawny-shirt", directory: "/repo", timeCreated: now - 100 }],
|
|
98
|
+
nodes: [
|
|
99
|
+
{
|
|
100
|
+
sessionId: "brawny-shirt",
|
|
101
|
+
nodeId: 1,
|
|
102
|
+
role: "user",
|
|
103
|
+
content: "fix the tests",
|
|
104
|
+
isUserInput: true,
|
|
105
|
+
createdAt: now - 90,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
sessionId: "brawny-shirt",
|
|
109
|
+
nodeId: 2,
|
|
110
|
+
role: "assistant",
|
|
111
|
+
content: "on it",
|
|
112
|
+
createdAt: now - 60,
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
prompts: [{ sessionId: "brawny-shirt", content: "fix the tests", timestamp: now - 90 }],
|
|
116
|
+
});
|
|
117
|
+
writeLocks(locksDir, { "brawny-shirt": 201 });
|
|
118
|
+
|
|
119
|
+
const tui: ProcessInfo = { pid: 200, command: "devin", cwd: "/repo", tty: "ttys001" };
|
|
120
|
+
const acp: ProcessInfo = {
|
|
121
|
+
pid: 201,
|
|
122
|
+
ppid: 200,
|
|
123
|
+
command: "/usr/local/bin/devin acp",
|
|
124
|
+
cwd: "/repo",
|
|
125
|
+
tty: "??",
|
|
126
|
+
};
|
|
127
|
+
const agents = await adapter.detectAgents({ processes: [tui, acp] });
|
|
128
|
+
|
|
129
|
+
expect(agents).toHaveLength(1);
|
|
130
|
+
expect(agents[0]).toMatchObject({
|
|
131
|
+
type: "devin",
|
|
132
|
+
status: AgentStatus.WAITING,
|
|
133
|
+
pid: 200,
|
|
134
|
+
sessionId: "brawny-shirt",
|
|
135
|
+
summary: "fix the tests",
|
|
136
|
+
sessionFilePath: `${dbPath}::brawny-shirt`,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("lists a standalone devin acp process as an agent via its own lock", async () => {
|
|
141
|
+
const now = Math.floor(Date.now() / 1000);
|
|
142
|
+
writeDatabase(dbPath, {
|
|
143
|
+
sessions: [{ id: "merry-frog", directory: "/repo", timeCreated: now - 50 }],
|
|
144
|
+
nodes: [
|
|
145
|
+
{
|
|
146
|
+
sessionId: "merry-frog",
|
|
147
|
+
nodeId: 1,
|
|
148
|
+
role: "tool",
|
|
149
|
+
content: "running",
|
|
150
|
+
createdAt: now - 10,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
writeLocks(locksDir, { "merry-frog": 300 });
|
|
155
|
+
|
|
156
|
+
const acp: ProcessInfo = {
|
|
157
|
+
pid: 300,
|
|
158
|
+
ppid: 999,
|
|
159
|
+
command: "devin acp",
|
|
160
|
+
cwd: "/repo",
|
|
161
|
+
tty: "??",
|
|
162
|
+
};
|
|
163
|
+
const agents = await adapter.detectAgents({ processes: [acp] });
|
|
164
|
+
|
|
165
|
+
expect(agents).toHaveLength(1);
|
|
166
|
+
expect(agents[0]).toMatchObject({
|
|
167
|
+
type: "devin",
|
|
168
|
+
status: AgentStatus.RUNNING,
|
|
169
|
+
pid: 300,
|
|
170
|
+
sessionId: "merry-frog",
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("ignores stale locks whose holder is not a live devin process", async () => {
|
|
175
|
+
const now = Math.floor(Date.now() / 1000);
|
|
176
|
+
writeDatabase(dbPath, {
|
|
177
|
+
sessions: [{ id: "dead-session", directory: "/repo", timeCreated: now - 500 }],
|
|
178
|
+
});
|
|
179
|
+
writeLocks(locksDir, { "dead-session": 4242 });
|
|
180
|
+
|
|
181
|
+
const agents = await adapter.detectAgents({
|
|
182
|
+
processes: [{ pid: 100, command: "devin", cwd: "/repo", tty: "ttys001" }],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// No lock match; cwd fallback still finds the session row.
|
|
186
|
+
expect(agents).toHaveLength(1);
|
|
187
|
+
expect(agents[0]).toMatchObject({ type: "devin", sessionId: "dead-session", pid: 100 });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("ignores a lock when its pid is reused by a non-devin process", async () => {
|
|
191
|
+
const now = Math.floor(Date.now() / 1000);
|
|
192
|
+
writeDatabase(dbPath, {
|
|
193
|
+
sessions: [{ id: "reused-pid", directory: "/other", timeCreated: now - 500 }],
|
|
194
|
+
});
|
|
195
|
+
writeLocks(locksDir, { "reused-pid": 777 });
|
|
196
|
+
|
|
197
|
+
const processes: ProcessInfo[] = [
|
|
198
|
+
{ pid: 777, command: "redis-server", cwd: "/", tty: "??" },
|
|
199
|
+
{ pid: 100, command: "devin", cwd: "/repo", tty: "ttys001" },
|
|
200
|
+
];
|
|
201
|
+
const agents = await adapter.detectAgents({ processes });
|
|
202
|
+
|
|
203
|
+
expect(agents).toHaveLength(1);
|
|
204
|
+
expect(agents[0]).toMatchObject({ sessionId: "pid-100" });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("disambiguates two TUIs in the same directory via their locks", async () => {
|
|
208
|
+
const now = Math.floor(Date.now() / 1000);
|
|
209
|
+
writeDatabase(dbPath, {
|
|
210
|
+
sessions: [
|
|
211
|
+
{ id: "first", directory: "/repo", timeCreated: now - 200 },
|
|
212
|
+
{ id: "second", directory: "/repo", timeCreated: now - 100 },
|
|
213
|
+
],
|
|
214
|
+
nodes: [
|
|
215
|
+
{ sessionId: "first", nodeId: 1, role: "assistant", content: "a", createdAt: now - 50 },
|
|
216
|
+
{ sessionId: "second", nodeId: 1, role: "assistant", content: "b", createdAt: now - 40 },
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
writeLocks(locksDir, { first: 401, second: 501 });
|
|
220
|
+
|
|
221
|
+
const processes: ProcessInfo[] = [
|
|
222
|
+
{ pid: 400, command: "devin", cwd: "/repo", tty: "ttys001" },
|
|
223
|
+
{ pid: 401, ppid: 400, command: "devin acp", cwd: "/repo", tty: "??" },
|
|
224
|
+
{ pid: 500, command: "devin", cwd: "/repo", tty: "ttys002" },
|
|
225
|
+
{ pid: 501, ppid: 500, command: "devin acp", cwd: "/repo", tty: "??" },
|
|
226
|
+
];
|
|
227
|
+
const agents = await adapter.detectAgents({ processes });
|
|
228
|
+
|
|
229
|
+
expect(agents).toHaveLength(2);
|
|
230
|
+
expect(agents.find((a) => a.pid === 400)?.sessionId).toBe("first");
|
|
231
|
+
expect(agents.find((a) => a.pid === 500)?.sessionId).toBe("second");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("falls back to working_directory matching without a lock", async () => {
|
|
235
|
+
const now = Math.floor(Date.now() / 1000);
|
|
236
|
+
writeDatabase(dbPath, {
|
|
237
|
+
sessions: [
|
|
238
|
+
{ id: "older", directory: "/repo", timeCreated: now - 300, lastActivityAt: now - 300 },
|
|
239
|
+
{ id: "newer", directory: "/repo", timeCreated: now - 200, lastActivityAt: now - 60 },
|
|
240
|
+
{ id: "hidden-sess", directory: "/repo", timeCreated: now - 10, hidden: 1 },
|
|
241
|
+
],
|
|
242
|
+
nodes: [
|
|
243
|
+
{ sessionId: "newer", nodeId: 1, role: "user", content: "hi", createdAt: now - 60 },
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const agents = await adapter.detectAgents({
|
|
248
|
+
processes: [{ pid: 100, command: "devin -p work", cwd: "/repo", tty: "ttys001" }],
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(agents).toHaveLength(1);
|
|
252
|
+
expect(agents[0]).toMatchObject({ sessionId: "newer", type: "devin" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("survives an unreadable database", async () => {
|
|
256
|
+
fs.writeFileSync(dbPath, "not sqlite at all");
|
|
257
|
+
const agents = await adapter.detectAgents({
|
|
258
|
+
processes: [{ pid: 100, command: "devin", cwd: "/repo", tty: "ttys001" }],
|
|
259
|
+
});
|
|
260
|
+
expect(agents).toEqual([expect.objectContaining({ sessionId: "pid-100" })]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("returns conversation messages for encoded session refs", () => {
|
|
264
|
+
writeDatabase(dbPath, {
|
|
265
|
+
sessions: [{ id: "sess-1", directory: "/repo", timeCreated: 1000 }],
|
|
266
|
+
nodes: [
|
|
267
|
+
{ sessionId: "sess-1", nodeId: 1, role: "user", content: "hello", isUserInput: true },
|
|
268
|
+
{ sessionId: "sess-1", nodeId: 2, role: "assistant", content: "hi there" },
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(adapter.getConversation(`${dbPath}::sess-1`)).toEqual([
|
|
273
|
+
{ role: "user", content: "hello" },
|
|
274
|
+
{ role: "assistant", content: "hi there" },
|
|
275
|
+
]);
|
|
276
|
+
expect(adapter.getConversation("/not-a-ref")).toEqual([]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("finds sessions by exact slug", async () => {
|
|
280
|
+
writeDatabase(dbPath, {
|
|
281
|
+
sessions: [
|
|
282
|
+
{ id: "wanted", directory: "/repo", timeCreated: 1000 },
|
|
283
|
+
{ id: "other", directory: "/other", timeCreated: 999 },
|
|
284
|
+
],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await expect(adapter.findSessionsById("wanted")).resolves.toEqual([
|
|
288
|
+
expect.objectContaining({ type: "devin", sessionId: "wanted" }),
|
|
289
|
+
]);
|
|
290
|
+
await expect(adapter.findSessionsById("missing")).resolves.toEqual([]);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { AgentStatus, type ProcessInfo } from "../../../adapters/AgentAdapter.js";
|
|
3
|
+
import { DevinAgentMapper } from "../../../harnesses/devin/DevinAgentMapper.js";
|
|
4
|
+
import type { DevinSession } from "../../../harnesses/devin/DevinSessionLocator.js";
|
|
5
|
+
|
|
6
|
+
const DB_PATH = "/data/devin/cli/sessions.db";
|
|
7
|
+
|
|
8
|
+
function session(overrides: Partial<DevinSession> = {}): DevinSession {
|
|
9
|
+
return {
|
|
10
|
+
sessionId: "brawny-shirt",
|
|
11
|
+
directory: "/repo",
|
|
12
|
+
title: null,
|
|
13
|
+
timeCreated: Date.now() - 60_000,
|
|
14
|
+
lastActivityAt: Date.now() - 30_000,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function proc(overrides: Partial<ProcessInfo> = {}): ProcessInfo {
|
|
20
|
+
return { pid: 42, command: "devin", cwd: "/repo", tty: "ttys001", ...overrides };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("DevinAgentMapper", () => {
|
|
24
|
+
const mapper = new DevinAgentMapper(DB_PATH);
|
|
25
|
+
|
|
26
|
+
it("maps a session to a waiting agent when the frontier node is assistant", () => {
|
|
27
|
+
const agent = mapper.mapSessionToAgent(
|
|
28
|
+
session(),
|
|
29
|
+
{ lastRole: "assistant", lastTimeUpdated: Date.now() - 1000, summary: "fix tests" },
|
|
30
|
+
proc(),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(agent).toMatchObject({
|
|
34
|
+
type: "devin",
|
|
35
|
+
status: AgentStatus.WAITING,
|
|
36
|
+
pid: 42,
|
|
37
|
+
projectPath: "/repo",
|
|
38
|
+
sessionId: "brawny-shirt",
|
|
39
|
+
summary: "fix tests",
|
|
40
|
+
sessionFilePath: `${DB_PATH}::brawny-shirt`,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("maps running status for user/tool frontier nodes", () => {
|
|
45
|
+
for (const lastRole of ["user", "tool", null]) {
|
|
46
|
+
const agent = mapper.mapSessionToAgent(
|
|
47
|
+
session(),
|
|
48
|
+
{ lastRole, lastTimeUpdated: Date.now() - 1000, summary: "" },
|
|
49
|
+
proc(),
|
|
50
|
+
);
|
|
51
|
+
expect(agent.status).toBe(AgentStatus.RUNNING);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("marks stale sessions idle", () => {
|
|
56
|
+
const agent = mapper.mapSessionToAgent(
|
|
57
|
+
session(),
|
|
58
|
+
{
|
|
59
|
+
lastRole: "assistant",
|
|
60
|
+
lastTimeUpdated: Date.now() - 10 * 60 * 1000,
|
|
61
|
+
summary: "",
|
|
62
|
+
},
|
|
63
|
+
proc(),
|
|
64
|
+
);
|
|
65
|
+
expect(agent.status).toBe(AgentStatus.IDLE);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to session title then directory for summary and project path", () => {
|
|
69
|
+
const agent = mapper.mapSessionToAgent(
|
|
70
|
+
session({ title: "Hello Greeting Session" }),
|
|
71
|
+
{ lastRole: "assistant", lastTimeUpdated: 0, summary: "" },
|
|
72
|
+
proc({ cwd: "/elsewhere" }),
|
|
73
|
+
);
|
|
74
|
+
expect(agent.summary).toBe("Hello Greeting Session");
|
|
75
|
+
expect(agent.projectPath).toBe("/repo");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("maps a process-only agent", () => {
|
|
79
|
+
const agent = mapper.mapProcessOnlyAgent(proc({ pid: 7 }));
|
|
80
|
+
expect(agent).toMatchObject({
|
|
81
|
+
type: "devin",
|
|
82
|
+
status: AgentStatus.RUNNING,
|
|
83
|
+
pid: 7,
|
|
84
|
+
sessionId: "pid-7",
|
|
85
|
+
summary: "Devin process running",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { DevinSessionLocator } from "../../../harnesses/devin/DevinSessionLocator.js";
|
|
6
|
+
import { writeDatabase, writeLocks } from "./fixtures.js";
|
|
7
|
+
|
|
8
|
+
describe("DevinSessionLocator", () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let dbPath: string;
|
|
11
|
+
let locksDir: string;
|
|
12
|
+
let locator: DevinSessionLocator;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-locator-test-"));
|
|
16
|
+
dbPath = path.join(tmpDir, "sessions.db");
|
|
17
|
+
locksDir = path.join(tmpDir, "session_locks");
|
|
18
|
+
locator = new DevinSessionLocator(dbPath, locksDir);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
locator.close();
|
|
23
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("lists active locks and skips malformed entries", () => {
|
|
27
|
+
writeLocks(locksDir, { "alpha-session": 100, "beta-session": "200" });
|
|
28
|
+
fs.writeFileSync(path.join(locksDir, "broken.lock"), "not-a-pid");
|
|
29
|
+
fs.writeFileSync(path.join(locksDir, "empty.lock"), "");
|
|
30
|
+
fs.writeFileSync(path.join(locksDir, ".lock"), "300");
|
|
31
|
+
fs.writeFileSync(path.join(locksDir, "not-a-lock.txt"), "400");
|
|
32
|
+
|
|
33
|
+
expect(locator.listActiveLocks()).toEqual([
|
|
34
|
+
{ sessionId: "alpha-session", pid: 100 },
|
|
35
|
+
{ sessionId: "beta-session", pid: 200 },
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns an empty lock list when the directory is missing", () => {
|
|
40
|
+
expect(locator.listActiveLocks()).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("finds the most recent visible session for a directory", () => {
|
|
44
|
+
writeDatabase(dbPath, {
|
|
45
|
+
sessions: [
|
|
46
|
+
{ id: "older", directory: "/repo", timeCreated: 100, lastActivityAt: 100 },
|
|
47
|
+
{ id: "newer", directory: "/repo", timeCreated: 200, lastActivityAt: 300 },
|
|
48
|
+
{ id: "hidden", directory: "/repo", timeCreated: 400, lastActivityAt: 400, hidden: 1 },
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const db = locator.openDb()!;
|
|
53
|
+
expect(locator.findSessionForDirectory(db, "/repo")?.sessionId).toBe("newer");
|
|
54
|
+
expect(locator.findSessionForDirectory(db, "/missing")).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("finds a session by id for the lock join", () => {
|
|
58
|
+
writeDatabase(dbPath, {
|
|
59
|
+
sessions: [
|
|
60
|
+
{ id: "by-id", directory: "/repo", timeCreated: 100, title: "Title" },
|
|
61
|
+
{ id: "gone", directory: "/repo", timeCreated: 100, hidden: 1 },
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const db = locator.openDb()!;
|
|
66
|
+
const session = locator.findSessionById(db, "by-id");
|
|
67
|
+
expect(session).toMatchObject({
|
|
68
|
+
sessionId: "by-id",
|
|
69
|
+
directory: "/repo",
|
|
70
|
+
title: "Title",
|
|
71
|
+
timeCreated: 100_000,
|
|
72
|
+
});
|
|
73
|
+
expect(locator.findSessionById(db, "gone")).toBeNull();
|
|
74
|
+
expect(locator.findSessionById(db, "missing")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("lists sessions with cwd filtering, hidden skip, and prompt summary", () => {
|
|
78
|
+
writeDatabase(dbPath, {
|
|
79
|
+
sessions: [
|
|
80
|
+
{ id: "a", directory: "/repo", timeCreated: 100, lastActivityAt: 150 },
|
|
81
|
+
{ id: "b", directory: "/other", timeCreated: 200, lastActivityAt: 250 },
|
|
82
|
+
{ id: "c", directory: "/repo", timeCreated: 300, hidden: 1 },
|
|
83
|
+
],
|
|
84
|
+
prompts: [
|
|
85
|
+
{ sessionId: "a", content: "first prompt", timestamp: 110 },
|
|
86
|
+
{ sessionId: "a", content: "!shell cmd", timestamp: 105, isShell: 1 },
|
|
87
|
+
{ sessionId: "b", content: "other prompt", timestamp: 210 },
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const all = locator.listSessions();
|
|
92
|
+
expect(all.map((s) => s.sessionId)).toEqual(["b", "a"]);
|
|
93
|
+
|
|
94
|
+
const filtered = locator.listSessions({ cwd: "/repo" });
|
|
95
|
+
expect(filtered).toEqual([
|
|
96
|
+
expect.objectContaining({
|
|
97
|
+
type: "devin",
|
|
98
|
+
sessionId: "a",
|
|
99
|
+
cwd: "/repo",
|
|
100
|
+
firstUserMessage: "first prompt",
|
|
101
|
+
lastActive: new Date(150_000),
|
|
102
|
+
startedAt: new Date(100_000),
|
|
103
|
+
sessionFilePath: `${dbPath}::a`,
|
|
104
|
+
}),
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("resolves the db path from XDG_DATA_HOME or ~/.local/share", () => {
|
|
109
|
+
const prevXdg = process.env.XDG_DATA_HOME;
|
|
110
|
+
const prevHome = process.env.HOME;
|
|
111
|
+
try {
|
|
112
|
+
process.env.XDG_DATA_HOME = "/xdg";
|
|
113
|
+
expect(DevinSessionLocator.resolveDbPath()).toBe(
|
|
114
|
+
path.join("/xdg", "devin", "cli", "sessions.db"),
|
|
115
|
+
);
|
|
116
|
+
delete process.env.XDG_DATA_HOME;
|
|
117
|
+
process.env.HOME = "/home/u";
|
|
118
|
+
expect(DevinSessionLocator.resolveDbPath()).toBe(
|
|
119
|
+
path.join("/home/u", ".local", "share", "devin", "cli", "sessions.db"),
|
|
120
|
+
);
|
|
121
|
+
} finally {
|
|
122
|
+
if (prevXdg === undefined) delete process.env.XDG_DATA_HOME;
|
|
123
|
+
else process.env.XDG_DATA_HOME = prevXdg;
|
|
124
|
+
if (prevHome === undefined) delete process.env.HOME;
|
|
125
|
+
else process.env.HOME = prevHome;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns empty results when the database is missing or corrupt", () => {
|
|
130
|
+
expect(locator.openDb()).toBeNull();
|
|
131
|
+
expect(locator.listSessions()).toEqual([]);
|
|
132
|
+
expect(locator.findSessionsById("x")).toEqual([]);
|
|
133
|
+
|
|
134
|
+
fs.writeFileSync(dbPath, "garbage");
|
|
135
|
+
const fresh = new DevinSessionLocator(dbPath, locksDir);
|
|
136
|
+
expect(fresh.listSessions()).toEqual([]);
|
|
137
|
+
fresh.close();
|
|
138
|
+
});
|
|
139
|
+
});
|