@ai-devkit/agent-manager 0.38.0 → 0.40.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__/capacity/devin.test.js +239 -0
- package/dist/__tests__/capacity/devin.test.js.map +1 -0
- 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/capacity/index.d.ts +3 -0
- package/dist/capacity/index.d.ts.map +1 -1
- package/dist/capacity/index.js +8 -0
- package/dist/capacity/index.js.map +1 -1
- package/dist/capacity/providers/devin.d.ts +13 -0
- package/dist/capacity/providers/devin.d.ts.map +1 -0
- package/dist/capacity/providers/devin.js +92 -0
- package/dist/capacity/providers/devin.js.map +1 -0
- package/dist/capacity/sources/devin.d.ts +12 -0
- package/dist/capacity/sources/devin.d.ts.map +1 -0
- package/dist/capacity/sources/devin.js +29 -0
- package/dist/capacity/sources/devin.js.map +1 -0
- 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/credentials.d.ts +10 -0
- package/dist/harnesses/devin/credentials.d.ts.map +1 -0
- package/dist/harnesses/devin/credentials.js +66 -0
- package/dist/harnesses/devin/credentials.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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.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__/capacity/devin.test.ts +210 -0
- package/src/__tests__/capacity/fixtures/devin-config.json +6 -0
- package/src/__tests__/capacity/fixtures/devin-credentials.toml +4 -0
- package/src/__tests__/capacity/fixtures/devin-quota-nonplan.json +5 -0
- package/src/__tests__/capacity/fixtures/devin-quota.json +10 -0
- 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/capacity/index.ts +12 -0
- package/src/capacity/providers/devin.ts +121 -0
- package/src/capacity/sources/devin.ts +49 -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/credentials.ts +88 -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/index.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,218 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { DevinSessionParser } from "../../../harnesses/devin/DevinSessionParser.js";
|
|
7
|
+
import { writeDatabase } from "./fixtures.js";
|
|
8
|
+
|
|
9
|
+
describe("DevinSessionParser", () => {
|
|
10
|
+
let tmpDir: string;
|
|
11
|
+
let dbPath: string;
|
|
12
|
+
let parser: DevinSessionParser;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-parser-test-"));
|
|
16
|
+
dbPath = path.join(tmpDir, "sessions.db");
|
|
17
|
+
parser = new DevinSessionParser();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function openDb(): Database.Database {
|
|
25
|
+
return new Database(dbPath, { readonly: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
it("returns user and assistant messages in node order, skipping system", () => {
|
|
29
|
+
writeDatabase(dbPath, {
|
|
30
|
+
nodes: [
|
|
31
|
+
{ sessionId: "s", nodeId: 1, role: "system", content: "preamble" },
|
|
32
|
+
{ sessionId: "s", nodeId: 2, role: "user", content: "hello", isUserInput: true },
|
|
33
|
+
{ sessionId: "s", nodeId: 3, role: "assistant", content: "hi" },
|
|
34
|
+
// Later turn roots a fresh preamble chain:
|
|
35
|
+
{ sessionId: "s", nodeId: 4, role: "system", content: "more preamble" },
|
|
36
|
+
{ sessionId: "s", nodeId: 5, role: "user", content: "again", isUserInput: true },
|
|
37
|
+
{ sessionId: "s", nodeId: 6, role: "assistant", content: "done" },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
const db = openDb();
|
|
41
|
+
try {
|
|
42
|
+
expect(parser.getConversation(db, "s")).toEqual([
|
|
43
|
+
{ role: "user", content: "hello" },
|
|
44
|
+
{ role: "assistant", content: "hi" },
|
|
45
|
+
{ role: "user", content: "again" },
|
|
46
|
+
{ role: "assistant", content: "done" },
|
|
47
|
+
]);
|
|
48
|
+
} finally {
|
|
49
|
+
db.close();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("dedupes sibling revisions by message_id keeping the highest node_id", () => {
|
|
54
|
+
writeDatabase(dbPath, {
|
|
55
|
+
nodes: [
|
|
56
|
+
{ sessionId: "s", nodeId: 1, role: "assistant", content: "draft", messageId: "m1" },
|
|
57
|
+
{ sessionId: "s", nodeId: 2, role: "assistant", content: "final", messageId: "m1" },
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
const db = openDb();
|
|
61
|
+
try {
|
|
62
|
+
expect(parser.getConversation(db, "s")).toEqual([
|
|
63
|
+
{ role: "assistant", content: "final" },
|
|
64
|
+
]);
|
|
65
|
+
} finally {
|
|
66
|
+
db.close();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("skips non-input user nodes and empty content", () => {
|
|
71
|
+
writeDatabase(dbPath, {
|
|
72
|
+
nodes: [
|
|
73
|
+
{ sessionId: "s", nodeId: 1, role: "user", content: "wrapper", isUserInput: false },
|
|
74
|
+
{ sessionId: "s", nodeId: 2, role: "user", content: " ", isUserInput: true },
|
|
75
|
+
{ sessionId: "s", nodeId: 3, role: "user", content: "real", isUserInput: true },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
const db = openDb();
|
|
79
|
+
try {
|
|
80
|
+
expect(parser.getConversation(db, "s")).toEqual([{ role: "user", content: "real" }]);
|
|
81
|
+
} finally {
|
|
82
|
+
db.close();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("includes tools and thinking only in verbose mode", () => {
|
|
87
|
+
writeDatabase(dbPath, {
|
|
88
|
+
nodes: [
|
|
89
|
+
{
|
|
90
|
+
sessionId: "s",
|
|
91
|
+
nodeId: 1,
|
|
92
|
+
role: "assistant",
|
|
93
|
+
content: "working",
|
|
94
|
+
thinking: "hmm",
|
|
95
|
+
toolCalls: [{ function: { name: "exec" } }],
|
|
96
|
+
},
|
|
97
|
+
{ sessionId: "s", nodeId: 2, role: "tool", content: "tool output", name: "exec" },
|
|
98
|
+
],
|
|
99
|
+
});
|
|
100
|
+
const db = openDb();
|
|
101
|
+
try {
|
|
102
|
+
expect(parser.getConversation(db, "s")).toEqual([
|
|
103
|
+
{ role: "assistant", content: "working" },
|
|
104
|
+
]);
|
|
105
|
+
expect(parser.getConversation(db, "s", { verbose: true })).toEqual([
|
|
106
|
+
{ role: "assistant", content: "[thinking] hmm" },
|
|
107
|
+
{ role: "assistant", content: "working" },
|
|
108
|
+
{ role: "assistant", content: "[tool: exec]" },
|
|
109
|
+
{ role: "assistant", content: "[tool: exec] tool output" },
|
|
110
|
+
]);
|
|
111
|
+
} finally {
|
|
112
|
+
db.close();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("honors tail and bounds the query window", () => {
|
|
117
|
+
writeDatabase(dbPath, {
|
|
118
|
+
nodes: Array.from({ length: 30 }, (_, i) => ({
|
|
119
|
+
sessionId: "s",
|
|
120
|
+
nodeId: i + 1,
|
|
121
|
+
role: "user" as const,
|
|
122
|
+
content: `msg-${i + 1}`,
|
|
123
|
+
isUserInput: true,
|
|
124
|
+
})),
|
|
125
|
+
});
|
|
126
|
+
const db = openDb();
|
|
127
|
+
try {
|
|
128
|
+
const convo = parser.getConversation(db, "s", { tail: 3 });
|
|
129
|
+
expect(convo).toEqual([
|
|
130
|
+
{ role: "user", content: "msg-28" },
|
|
131
|
+
{ role: "user", content: "msg-29" },
|
|
132
|
+
{ role: "user", content: "msg-30" },
|
|
133
|
+
]);
|
|
134
|
+
} finally {
|
|
135
|
+
db.close();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("skips malformed chat_message rows and missing sessions", () => {
|
|
140
|
+
writeDatabase(dbPath, {
|
|
141
|
+
nodes: [
|
|
142
|
+
{ sessionId: "s", nodeId: 1, raw: "{not json" },
|
|
143
|
+
{ sessionId: "s", nodeId: 2, role: "assistant", content: "ok" },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
const db = openDb();
|
|
147
|
+
try {
|
|
148
|
+
expect(parser.getConversation(db, "s")).toEqual([
|
|
149
|
+
{ role: "assistant", content: "ok" },
|
|
150
|
+
]);
|
|
151
|
+
expect(parser.getConversation(db, "missing")).toEqual([]);
|
|
152
|
+
} finally {
|
|
153
|
+
db.close();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("reports session stats from the frontier node and latest prompt", () => {
|
|
158
|
+
writeDatabase(dbPath, {
|
|
159
|
+
nodes: [
|
|
160
|
+
{ sessionId: "s", nodeId: 1, role: "user", content: "go", createdAt: 100 },
|
|
161
|
+
{ sessionId: "s", nodeId: 2, role: "assistant", content: "done", createdAt: 200 },
|
|
162
|
+
],
|
|
163
|
+
prompts: [
|
|
164
|
+
{ sessionId: "s", content: "first task", timestamp: 100 },
|
|
165
|
+
{ sessionId: "s", content: "/mcp", timestamp: 300 },
|
|
166
|
+
{ sessionId: "s", content: "latest task", timestamp: 200 },
|
|
167
|
+
{ sessionId: "s", content: "!shell", timestamp: 400, isShell: 1 },
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
const db = openDb();
|
|
171
|
+
try {
|
|
172
|
+
expect(parser.getSessionStats(db, "s")).toEqual({
|
|
173
|
+
lastRole: "assistant",
|
|
174
|
+
lastTimeUpdated: 200_000,
|
|
175
|
+
summary: "latest task",
|
|
176
|
+
});
|
|
177
|
+
expect(parser.getSessionStats(db, "missing")).toEqual({
|
|
178
|
+
lastRole: null,
|
|
179
|
+
lastTimeUpdated: 0,
|
|
180
|
+
summary: "",
|
|
181
|
+
});
|
|
182
|
+
} finally {
|
|
183
|
+
db.close();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("falls back to the first is_user_input node for the summary", () => {
|
|
188
|
+
writeDatabase(dbPath, {
|
|
189
|
+
nodes: [
|
|
190
|
+
{ sessionId: "s", nodeId: 1, role: "user", content: "wrapper", isUserInput: false },
|
|
191
|
+
{ sessionId: "s", nodeId: 2, role: "user", content: "real first", isUserInput: true },
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
const db = openDb();
|
|
195
|
+
try {
|
|
196
|
+
expect(parser.getFirstUserMessage(db, "s")).toBe("real first");
|
|
197
|
+
} finally {
|
|
198
|
+
db.close();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("falls back to the last is_user_input node for the working-on summary", () => {
|
|
203
|
+
writeDatabase(dbPath, {
|
|
204
|
+
nodes: [
|
|
205
|
+
{ sessionId: "s", nodeId: 1, role: "user", content: "first", isUserInput: true },
|
|
206
|
+
{ sessionId: "s", nodeId: 2, role: "user", content: "wrapper", isUserInput: false },
|
|
207
|
+
{ sessionId: "s", nodeId: 3, role: "user", content: "last real", isUserInput: true },
|
|
208
|
+
],
|
|
209
|
+
});
|
|
210
|
+
const db = openDb();
|
|
211
|
+
try {
|
|
212
|
+
expect(parser.getLastUserPrompt(db, "s")).toBe("last real");
|
|
213
|
+
expect(parser.getLastUserPrompt(db, "missing")).toBe("");
|
|
214
|
+
} finally {
|
|
215
|
+
db.close();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
export interface DevinSessionInput {
|
|
6
|
+
id: string;
|
|
7
|
+
directory: string;
|
|
8
|
+
title?: string | null;
|
|
9
|
+
timeCreated: number;
|
|
10
|
+
lastActivityAt?: number;
|
|
11
|
+
mainChainId?: number | null;
|
|
12
|
+
hidden?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DevinNodeInput {
|
|
16
|
+
nodeId: number;
|
|
17
|
+
parentNodeId?: number | null;
|
|
18
|
+
role: string;
|
|
19
|
+
content?: string;
|
|
20
|
+
messageId?: string;
|
|
21
|
+
createdAt?: number;
|
|
22
|
+
isUserInput?: boolean;
|
|
23
|
+
toolCalls?: { name?: string; function?: { name?: string } }[];
|
|
24
|
+
thinking?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
/** Raw chat_message text; when set, other fields are ignored. */
|
|
27
|
+
raw?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DevinPromptInput {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
content: string;
|
|
33
|
+
timestamp?: number;
|
|
34
|
+
isShell?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeDatabase(
|
|
38
|
+
dbPath: string,
|
|
39
|
+
data: {
|
|
40
|
+
sessions?: DevinSessionInput[];
|
|
41
|
+
nodes?: (DevinNodeInput & { sessionId: string })[];
|
|
42
|
+
prompts?: DevinPromptInput[];
|
|
43
|
+
},
|
|
44
|
+
): void {
|
|
45
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
46
|
+
const db = new Database(dbPath);
|
|
47
|
+
try {
|
|
48
|
+
db.exec(`
|
|
49
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
working_directory TEXT NOT NULL,
|
|
52
|
+
backend_type TEXT NOT NULL DEFAULT 'local',
|
|
53
|
+
model TEXT NOT NULL DEFAULT 'test-model',
|
|
54
|
+
agent_mode TEXT NOT NULL DEFAULT 'normal',
|
|
55
|
+
created_at INTEGER NOT NULL,
|
|
56
|
+
last_activity_at INTEGER NOT NULL,
|
|
57
|
+
title TEXT,
|
|
58
|
+
main_chain_id INTEGER,
|
|
59
|
+
hidden INTEGER NOT NULL DEFAULT 0
|
|
60
|
+
);
|
|
61
|
+
CREATE TABLE IF NOT EXISTS message_nodes (
|
|
62
|
+
row_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
63
|
+
session_id TEXT NOT NULL,
|
|
64
|
+
node_id INTEGER NOT NULL,
|
|
65
|
+
parent_node_id INTEGER,
|
|
66
|
+
chat_message TEXT NOT NULL,
|
|
67
|
+
created_at INTEGER NOT NULL,
|
|
68
|
+
UNIQUE(session_id, node_id)
|
|
69
|
+
);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS prompt_history (
|
|
71
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
72
|
+
content TEXT NOT NULL,
|
|
73
|
+
timestamp INTEGER NOT NULL,
|
|
74
|
+
session_id TEXT NOT NULL,
|
|
75
|
+
is_shell INTEGER NOT NULL DEFAULT 0
|
|
76
|
+
);
|
|
77
|
+
`);
|
|
78
|
+
|
|
79
|
+
const insertSession = db.prepare(`
|
|
80
|
+
INSERT INTO sessions (id, working_directory, created_at, last_activity_at, title, main_chain_id, hidden)
|
|
81
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
82
|
+
`);
|
|
83
|
+
for (const session of data.sessions ?? []) {
|
|
84
|
+
insertSession.run(
|
|
85
|
+
session.id,
|
|
86
|
+
session.directory,
|
|
87
|
+
session.timeCreated,
|
|
88
|
+
session.lastActivityAt ?? session.timeCreated,
|
|
89
|
+
session.title ?? null,
|
|
90
|
+
session.mainChainId ?? null,
|
|
91
|
+
session.hidden ?? 0,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const insertNode = db.prepare(`
|
|
96
|
+
INSERT INTO message_nodes (session_id, node_id, parent_node_id, chat_message, created_at)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?)
|
|
98
|
+
`);
|
|
99
|
+
for (const node of data.nodes ?? []) {
|
|
100
|
+
const chatMessage =
|
|
101
|
+
node.raw ??
|
|
102
|
+
JSON.stringify({
|
|
103
|
+
message_id: node.messageId ?? `msg-${node.nodeId}`,
|
|
104
|
+
role: node.role,
|
|
105
|
+
content: node.content ?? "",
|
|
106
|
+
...(node.toolCalls ? { tool_calls: node.toolCalls } : {}),
|
|
107
|
+
...(node.thinking ? { thinking: { thinking: node.thinking } } : {}),
|
|
108
|
+
...(node.name ? { name: node.name } : {}),
|
|
109
|
+
...(node.isUserInput !== undefined
|
|
110
|
+
? { metadata: { is_user_input: node.isUserInput } }
|
|
111
|
+
: {}),
|
|
112
|
+
});
|
|
113
|
+
insertNode.run(
|
|
114
|
+
node.sessionId,
|
|
115
|
+
node.nodeId,
|
|
116
|
+
node.parentNodeId ?? null,
|
|
117
|
+
chatMessage,
|
|
118
|
+
node.createdAt ?? 1000 + node.nodeId,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const insertPrompt = db.prepare(`
|
|
123
|
+
INSERT INTO prompt_history (session_id, content, timestamp, is_shell)
|
|
124
|
+
VALUES (?, ?, ?, ?)
|
|
125
|
+
`);
|
|
126
|
+
for (const prompt of data.prompts ?? []) {
|
|
127
|
+
insertPrompt.run(
|
|
128
|
+
prompt.sessionId,
|
|
129
|
+
prompt.content,
|
|
130
|
+
prompt.timestamp ?? 1000,
|
|
131
|
+
prompt.isShell ?? 0,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
db.close();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function writeLocks(locksDir: string, locks: Record<string, number | string>): void {
|
|
140
|
+
fs.mkdirSync(locksDir, { recursive: true });
|
|
141
|
+
for (const [sessionId, pid] of Object.entries(locks)) {
|
|
142
|
+
fs.writeFileSync(path.join(locksDir, `${sessionId}.lock`), String(pid));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -67,6 +67,7 @@ function fixture(overrides: Partial<AgentReadinessOptions> = {}) {
|
|
|
67
67
|
".kiro",
|
|
68
68
|
".gemini/antigravity-cli",
|
|
69
69
|
".config/opencode",
|
|
70
|
+
".config/devin",
|
|
70
71
|
".pi",
|
|
71
72
|
]) {
|
|
72
73
|
files[path.join(homeDir, directory)] = "<dir>";
|
|
@@ -100,6 +101,8 @@ function fixture(overrides: Partial<AgentReadinessOptions> = {}) {
|
|
|
100
101
|
return { stdout: "github.com\n Logged in to github.com account test-user\n", stderr: "" };
|
|
101
102
|
if (command === "opencode")
|
|
102
103
|
return { stdout: "● litellm api\n● OpenAI oauth\n", stderr: "" };
|
|
104
|
+
if (command === "devin")
|
|
105
|
+
return { stdout: "Logged in (via Devin).\n\nUser:\n Name: test-user\n", stderr: "" };
|
|
103
106
|
throw new Error(`unexpected command ${command}`);
|
|
104
107
|
}),
|
|
105
108
|
codexAuth: async () => true,
|
|
@@ -124,6 +127,7 @@ describe("agent readiness", () => {
|
|
|
124
127
|
"opencode",
|
|
125
128
|
"copilot",
|
|
126
129
|
"pi",
|
|
130
|
+
"devin",
|
|
127
131
|
]);
|
|
128
132
|
for (const [agent, report] of Object.entries(reports) as Array<
|
|
129
133
|
[ReadinessAgentType, (typeof reports)[ReadinessAgentType]]
|
|
@@ -282,4 +286,41 @@ describe("agent readiness", () => {
|
|
|
282
286
|
status: "pass",
|
|
283
287
|
});
|
|
284
288
|
});
|
|
289
|
+
|
|
290
|
+
it("checks Devin auth from devin auth status output", async () => {
|
|
291
|
+
const { options } = fixture({
|
|
292
|
+
runCommand: vi.fn(async (command) => {
|
|
293
|
+
if (command === "devin")
|
|
294
|
+
return { stdout: "Logged in (via Devin).\n\nUser:\n Name: test-user\n", stderr: "" };
|
|
295
|
+
if (command === "pi") return { stdout: "npm:@ai-devkit/pi-session-tracker\n", stderr: "" };
|
|
296
|
+
if (command === "claude") return { stdout: JSON.stringify({ loggedIn: true }), stderr: "" };
|
|
297
|
+
throw new Error(`unexpected command ${command}`);
|
|
298
|
+
}),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const report = await getAgentReadinessReport("devin", options);
|
|
302
|
+
|
|
303
|
+
expect(report.auth).toMatchObject({
|
|
304
|
+
state: "authenticated",
|
|
305
|
+
source: "devin auth status",
|
|
306
|
+
provider: null,
|
|
307
|
+
availableProviders: ["devin"],
|
|
308
|
+
status: "pass",
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("reports Devin as unauthenticated without a login line", async () => {
|
|
313
|
+
const { options } = fixture({
|
|
314
|
+
runCommand: vi.fn(async (command) => {
|
|
315
|
+
if (command === "devin") return { stdout: "Not logged in\n", stderr: "" };
|
|
316
|
+
if (command === "pi") return { stdout: "npm:@ai-devkit/pi-session-tracker\n", stderr: "" };
|
|
317
|
+
if (command === "claude") return { stdout: JSON.stringify({ loggedIn: true }), stderr: "" };
|
|
318
|
+
throw new Error(`unexpected command ${command}`);
|
|
319
|
+
}),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const report = await getAgentReadinessReport("devin", options);
|
|
323
|
+
|
|
324
|
+
expect(report.auth).toMatchObject({ state: "unauthenticated", status: "fail" });
|
|
325
|
+
});
|
|
285
326
|
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { writeFile } from "fs/promises";
|
|
3
|
+
import type { Mock } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { ghosttyBackend } from "../../terminal/backends/ghostty.js";
|
|
6
|
+
import { TerminalType, type TerminalLocation } from "../../terminal/types.js";
|
|
7
|
+
import { TtyWriter } from "../../terminal/TtyWriter.js";
|
|
8
|
+
import { getProcessCwdsAsync } from "../../utils/process.js";
|
|
9
|
+
|
|
10
|
+
vi.mock("child_process", async () => {
|
|
11
|
+
const actual = await vi.importActual<typeof import("child_process")>("child_process");
|
|
12
|
+
return { ...actual, execFile: vi.fn() };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
vi.mock("fs/promises", async () => {
|
|
16
|
+
const actual = await vi.importActual<typeof import("fs/promises")>("fs/promises");
|
|
17
|
+
return { ...actual, writeFile: vi.fn() };
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
vi.mock("../../utils/process.js", async () => {
|
|
21
|
+
const actual =
|
|
22
|
+
await vi.importActual<typeof import("../../utils/process.js")>("../../utils/process.js");
|
|
23
|
+
return { ...actual, getProcessCwdsAsync: vi.fn() };
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const mockedExecFile = execFile as unknown as Mock;
|
|
27
|
+
const mockedGetProcessCwds = getProcessCwdsAsync as unknown as Mock;
|
|
28
|
+
const mockedWriteFile = writeFile as unknown as Mock;
|
|
29
|
+
const SEP = "\x1f";
|
|
30
|
+
|
|
31
|
+
interface FakeTerminal {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
dir: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fake `ps`, process cwds and Ghostty AppleScript. `processes` rows are
|
|
39
|
+
* `pid ppid tty comm`; `cwds` maps pid to its working directory. Writing an
|
|
40
|
+
* OSC 2 title to a TTY renames the terminal listed in `ttyOwner`.
|
|
41
|
+
*/
|
|
42
|
+
function fakeSystem(opts: {
|
|
43
|
+
processes: string[];
|
|
44
|
+
cwds?: Record<number, string>;
|
|
45
|
+
terminals: FakeTerminal[];
|
|
46
|
+
ttyOwner?: Record<string, string>;
|
|
47
|
+
osascriptResult?: string;
|
|
48
|
+
}) {
|
|
49
|
+
const terminals = opts.terminals.map((t) => ({ ...t }));
|
|
50
|
+
mockedGetProcessCwds.mockImplementation(async (pids: number[]) => {
|
|
51
|
+
const cwds = new Map<number, string>();
|
|
52
|
+
for (const pid of pids) if (opts.cwds?.[pid]) cwds.set(pid, opts.cwds[pid]);
|
|
53
|
+
return cwds;
|
|
54
|
+
});
|
|
55
|
+
mockedWriteFile.mockImplementation(async (tty: string, data: string) => {
|
|
56
|
+
// eslint-disable-next-line no-control-regex
|
|
57
|
+
const title = /^\x1b\]2;(.*)\x07$/.exec(data)?.[1];
|
|
58
|
+
const owner = terminals.find((t) => t.id === opts.ttyOwner?.[tty]);
|
|
59
|
+
if (owner && title !== undefined) owner.name = title;
|
|
60
|
+
});
|
|
61
|
+
mockedExecFile.mockImplementation((...args: unknown[]) => {
|
|
62
|
+
const [cmd, argv] = args as [string, string[]];
|
|
63
|
+
const cb = args[args.length - 1] as (err: Error | null, r?: { stdout: string }) => void;
|
|
64
|
+
let stdout = "";
|
|
65
|
+
if (cmd === "ps") stdout = opts.processes.join("\n") + "\n";
|
|
66
|
+
else if (cmd === "osascript" && argv[1].includes("repeat with t in terminals")) {
|
|
67
|
+
stdout = terminals.map((t) => [t.id, t.name, t.dir].join(SEP)).join("\n") + "\n";
|
|
68
|
+
} else if (cmd === "osascript") stdout = opts.osascriptResult ?? "ok\n";
|
|
69
|
+
cb(null, { stdout });
|
|
70
|
+
return {};
|
|
71
|
+
});
|
|
72
|
+
return terminals;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const GHOSTTY = "500 1 ?? /Applications/Ghostty.app/Contents/MacOS/ghostty";
|
|
76
|
+
|
|
77
|
+
describe("Ghostty terminal backend", () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
vi.clearAllMocks();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("find", () => {
|
|
83
|
+
it("ignores TTYs when Ghostty is not running", async () => {
|
|
84
|
+
fakeSystem({ processes: ["600 1 ttys001 /usr/bin/login"], terminals: [] });
|
|
85
|
+
|
|
86
|
+
expect(await ghosttyBackend.find("/dev/ttys001")).toBeNull();
|
|
87
|
+
expect(mockedExecFile).not.toHaveBeenCalledWith(
|
|
88
|
+
"osascript",
|
|
89
|
+
expect.anything(),
|
|
90
|
+
expect.anything(),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ignores TTYs whose session was not started by Ghostty", async () => {
|
|
95
|
+
fakeSystem({
|
|
96
|
+
processes: [GHOSTTY, "600 500 ttys001 /usr/bin/login", "700 1 ttys002 /usr/bin/login"],
|
|
97
|
+
terminals: [{ id: "T1", name: "zsh", dir: "/repo" }],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(await ghosttyBackend.find("/dev/ttys002")).toBeNull();
|
|
101
|
+
expect(mockedWriteFile).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("matches by working directory when it pairs one terminal with one TTY", async () => {
|
|
105
|
+
fakeSystem({
|
|
106
|
+
processes: [
|
|
107
|
+
GHOSTTY,
|
|
108
|
+
"600 500 ttys001 /usr/bin/login",
|
|
109
|
+
"601 600 ttys001 -zsh",
|
|
110
|
+
"602 601 ttys001 claude",
|
|
111
|
+
"700 500 ttys002 /usr/bin/login",
|
|
112
|
+
"701 700 ttys002 -zsh",
|
|
113
|
+
],
|
|
114
|
+
cwds: { 601: "/repo/a", 602: "/repo/a", 701: "/repo/b" },
|
|
115
|
+
terminals: [
|
|
116
|
+
{ id: "T1", name: "claude", dir: "/repo/a" },
|
|
117
|
+
{ id: "T2", name: "zsh", dir: "/repo/b" },
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(await ghosttyBackend.find("/dev/ttys001")).toEqual({
|
|
122
|
+
type: TerminalType.GHOSTTY,
|
|
123
|
+
identifier: "T1",
|
|
124
|
+
tty: "/dev/ttys001",
|
|
125
|
+
});
|
|
126
|
+
expect(mockedWriteFile).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("uses a title marker when two terminals share the directory, then restores the title", async () => {
|
|
130
|
+
const terminals = fakeSystem({
|
|
131
|
+
processes: [
|
|
132
|
+
GHOSTTY,
|
|
133
|
+
"600 500 ttys001 /usr/bin/login",
|
|
134
|
+
"601 600 ttys001 claude",
|
|
135
|
+
"700 500 ttys002 /usr/bin/login",
|
|
136
|
+
"701 700 ttys002 codex",
|
|
137
|
+
],
|
|
138
|
+
cwds: { 601: "/repo", 701: "/repo" },
|
|
139
|
+
terminals: [
|
|
140
|
+
{ id: "T1", name: "first agent", dir: "/repo" },
|
|
141
|
+
{ id: "T2", name: "second agent", dir: "/repo" },
|
|
142
|
+
],
|
|
143
|
+
ttyOwner: { "/dev/ttys001": "T1", "/dev/ttys002": "T2" },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const location = await ghosttyBackend.find("/dev/ttys002");
|
|
147
|
+
|
|
148
|
+
expect(location?.identifier).toBe("T2");
|
|
149
|
+
const titles = mockedWriteFile.mock.calls.map(([tty, data]) => [tty, data]);
|
|
150
|
+
expect(titles).toHaveLength(2);
|
|
151
|
+
expect(titles[0][0]).toBe("/dev/ttys002");
|
|
152
|
+
// eslint-disable-next-line no-control-regex
|
|
153
|
+
expect(titles[0][1]).toMatch(/^\x1b\]2;ai-devkit-\d+-\d+\x07$/);
|
|
154
|
+
expect(titles[1]).toEqual(["/dev/ttys002", "\x1b]2;second agent\x07"]);
|
|
155
|
+
expect(terminals.find((t) => t.id === "T2")?.name).toBe("second agent");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("does not trust a unique directory match when another Ghostty TTY has a process there", async () => {
|
|
159
|
+
// T1's own working directory is unreported; T2 sits in the agent's directory.
|
|
160
|
+
fakeSystem({
|
|
161
|
+
processes: [
|
|
162
|
+
GHOSTTY,
|
|
163
|
+
"600 500 ttys001 /usr/bin/login",
|
|
164
|
+
"601 600 ttys001 claude",
|
|
165
|
+
"700 500 ttys002 /usr/bin/login",
|
|
166
|
+
"701 700 ttys002 -zsh",
|
|
167
|
+
],
|
|
168
|
+
cwds: { 601: "/repo", 701: "/repo" },
|
|
169
|
+
terminals: [
|
|
170
|
+
{ id: "T1", name: "claude", dir: "" },
|
|
171
|
+
{ id: "T2", name: "zsh", dir: "/repo" },
|
|
172
|
+
],
|
|
173
|
+
ttyOwner: { "/dev/ttys001": "T1", "/dev/ttys002": "T2" },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect((await ghosttyBackend.find("/dev/ttys001"))?.identifier).toBe("T1");
|
|
177
|
+
expect(mockedWriteFile).toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns null when no terminal shows the marker", async () => {
|
|
181
|
+
vi.useFakeTimers();
|
|
182
|
+
try {
|
|
183
|
+
fakeSystem({
|
|
184
|
+
processes: [GHOSTTY, "600 500 ttys001 /usr/bin/login"],
|
|
185
|
+
terminals: [
|
|
186
|
+
{ id: "T1", name: "a", dir: "/x" },
|
|
187
|
+
{ id: "T2", name: "b", dir: "/y" },
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const result = ghosttyBackend.find("/dev/ttys001");
|
|
192
|
+
await vi.runAllTimersAsync();
|
|
193
|
+
|
|
194
|
+
expect(await result).toBeNull();
|
|
195
|
+
} finally {
|
|
196
|
+
vi.useRealTimers();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns null instead of throwing when Ghostty is not scriptable", async () => {
|
|
201
|
+
mockedGetProcessCwds.mockResolvedValue(new Map());
|
|
202
|
+
mockedExecFile.mockImplementation((...args: unknown[]) => {
|
|
203
|
+
const [cmd] = args as [string];
|
|
204
|
+
const cb = args[args.length - 1] as (err: Error | null, r?: { stdout: string }) => void;
|
|
205
|
+
if (cmd === "osascript") cb(new Error("Not authorized to send Apple events"));
|
|
206
|
+
else cb(null, { stdout: `${GHOSTTY}\n600 500 ttys001 /usr/bin/login\n` });
|
|
207
|
+
return {};
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(await ghosttyBackend.find("/dev/ttys001")).toBeNull();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("focus, send and sendKey", () => {
|
|
215
|
+
const location: TerminalLocation = {
|
|
216
|
+
type: TerminalType.GHOSTTY,
|
|
217
|
+
identifier: "T1",
|
|
218
|
+
tty: "/dev/ttys001",
|
|
219
|
+
};
|
|
220
|
+
const scripts = () =>
|
|
221
|
+
mockedExecFile.mock.calls.filter(([cmd]) => cmd === "osascript").map(([, argv]) => argv[1]);
|
|
222
|
+
|
|
223
|
+
it("focuses the terminal by id and activates Ghostty", async () => {
|
|
224
|
+
fakeSystem({ processes: [], terminals: [] });
|
|
225
|
+
|
|
226
|
+
expect(await ghosttyBackend.focus(location)).toBe(true);
|
|
227
|
+
expect(scripts()[0]).toContain('every terminal whose id is "T1"');
|
|
228
|
+
expect(scripts()[0]).toContain("focus t");
|
|
229
|
+
expect(scripts()[0]).toContain("activate");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("reports a failed focus when the terminal is gone", async () => {
|
|
233
|
+
fakeSystem({ processes: [], terminals: [], osascriptResult: "not_found\n" });
|
|
234
|
+
|
|
235
|
+
expect(await ghosttyBackend.focus(location)).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("pastes the message with input text, then types a carriage return", async () => {
|
|
239
|
+
fakeSystem({ processes: [], terminals: [] });
|
|
240
|
+
|
|
241
|
+
await TtyWriter.send(location, 'say "hi"\nnext');
|
|
242
|
+
|
|
243
|
+
expect(scripts()).toHaveLength(2);
|
|
244
|
+
expect(scripts()[0]).toContain('input text "say \\"hi\\"\\nnext" to t');
|
|
245
|
+
expect(scripts()[1]).toContain('perform action "text:\\\\x0d" on t');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("throws when the terminal is gone", async () => {
|
|
249
|
+
fakeSystem({ processes: [], terminals: [], osascriptResult: "not_found\n" });
|
|
250
|
+
|
|
251
|
+
await expect(TtyWriter.send(location, "hello")).rejects.toThrow(
|
|
252
|
+
"Ghostty terminal not found for TTY /dev/ttys001",
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Ghostty parses `text:` with Zig escapes; AppleScript doubles each backslash.
|
|
257
|
+
it.each([
|
|
258
|
+
["2", "text:2"],
|
|
259
|
+
["\x1b", "text:\\\\x1b"],
|
|
260
|
+
["\r", "text:\\\\x0d"],
|
|
261
|
+
["\\", "text:\\\\\\\\"],
|
|
262
|
+
])("types key %j with the Ghostty action %s", async (key, action) => {
|
|
263
|
+
fakeSystem({ processes: [], terminals: [] });
|
|
264
|
+
|
|
265
|
+
await TtyWriter.sendKey(location, key);
|
|
266
|
+
|
|
267
|
+
expect(scripts()[0]).toContain(`perform action "${action}" on t`);
|
|
268
|
+
expect(scripts()[0]).not.toContain("send key");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|