@ai-devkit/agent-manager 0.33.1 → 0.34.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/codex.test.js +2 -1
- package/dist/__tests__/capacity/codex.test.js.map +1 -1
- package/dist/__tests__/capacity/index.test.js +6 -3
- package/dist/__tests__/capacity/index.test.js.map +1 -1
- package/dist/__tests__/capacity/zai.test.js +213 -0
- package/dist/__tests__/capacity/zai.test.js.map +1 -0
- package/dist/__tests__/providers/opencode/OpenCodeAdapter.test.js +189 -0
- package/dist/__tests__/providers/opencode/OpenCodeAdapter.test.js.map +1 -0
- package/dist/__tests__/providers/opencode/OpenCodeAgentMapper.test.js +89 -0
- package/dist/__tests__/providers/opencode/OpenCodeAgentMapper.test.js.map +1 -0
- package/dist/__tests__/providers/opencode/OpenCodeSessionLocator.test.js +94 -0
- package/dist/__tests__/providers/opencode/OpenCodeSessionLocator.test.js.map +1 -0
- package/dist/__tests__/providers/opencode/OpenCodeSessionParser.test.js +147 -0
- package/dist/__tests__/providers/opencode/OpenCodeSessionParser.test.js.map +1 -0
- package/dist/adapters/index.d.ts +1 -1
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +1 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/capacity/codex.d.ts +1 -0
- package/dist/capacity/codex.d.ts.map +1 -1
- package/dist/capacity/codex.js +9 -4
- package/dist/capacity/codex.js.map +1 -1
- package/dist/capacity/index.d.ts +8 -0
- package/dist/capacity/index.d.ts.map +1 -1
- package/dist/capacity/index.js +10 -9
- package/dist/capacity/index.js.map +1 -1
- package/dist/capacity/types.d.ts +8 -0
- package/dist/capacity/types.d.ts.map +1 -1
- package/dist/capacity/types.js.map +1 -1
- package/dist/capacity/zai.d.ts +14 -0
- package/dist/capacity/zai.d.ts.map +1 -0
- package/dist/capacity/zai.js +203 -0
- package/dist/capacity/zai.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/{adapters → providers/opencode}/OpenCodeAdapter.d.ts +5 -12
- package/dist/providers/opencode/OpenCodeAdapter.d.ts.map +1 -0
- package/dist/providers/opencode/OpenCodeAdapter.js +86 -0
- package/dist/providers/opencode/OpenCodeAdapter.js.map +1 -0
- package/dist/providers/opencode/OpenCodeAgentMapper.d.ts +11 -0
- package/dist/providers/opencode/OpenCodeAgentMapper.d.ts.map +1 -0
- package/dist/providers/opencode/OpenCodeAgentMapper.js +50 -0
- package/dist/providers/opencode/OpenCodeAgentMapper.js.map +1 -0
- package/dist/providers/opencode/OpenCodeSessionLocator.d.ts +21 -0
- package/dist/providers/opencode/OpenCodeSessionLocator.d.ts.map +1 -0
- package/dist/providers/opencode/OpenCodeSessionLocator.js +99 -0
- package/dist/providers/opencode/OpenCodeSessionLocator.js.map +1 -0
- package/dist/providers/opencode/OpenCodeSessionParser.d.ts +18 -0
- package/dist/providers/opencode/OpenCodeSessionParser.d.ts.map +1 -0
- package/dist/providers/opencode/OpenCodeSessionParser.js +104 -0
- package/dist/providers/opencode/OpenCodeSessionParser.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/capacity/codex.test.ts +94 -26
- package/src/__tests__/capacity/fixtures/zai-auth.json +6 -0
- package/src/__tests__/capacity/fixtures/zai-quota-invalid.json +7 -0
- package/src/__tests__/capacity/fixtures/zai-quota.json +38 -0
- package/src/__tests__/capacity/index.test.ts +10 -3
- package/src/__tests__/capacity/zai.test.ts +186 -0
- package/src/__tests__/providers/opencode/OpenCodeAdapter.test.ts +197 -0
- package/src/__tests__/providers/opencode/OpenCodeAgentMapper.test.ts +83 -0
- package/src/__tests__/providers/opencode/OpenCodeSessionLocator.test.ts +84 -0
- package/src/__tests__/providers/opencode/OpenCodeSessionParser.test.ts +148 -0
- package/src/adapters/index.ts +1 -1
- package/src/capacity/codex.ts +96 -28
- package/src/capacity/index.ts +29 -11
- package/src/capacity/types.ts +8 -0
- package/src/capacity/zai.ts +237 -0
- package/src/index.ts +8 -3
- package/src/providers/opencode/OpenCodeAdapter.ts +112 -0
- package/src/providers/opencode/OpenCodeAgentMapper.ts +61 -0
- package/src/providers/opencode/OpenCodeSessionLocator.ts +123 -0
- package/src/providers/opencode/OpenCodeSessionParser.ts +145 -0
- package/dist/__tests__/adapters/OpenCodeAdapter.test.js +0 -636
- package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +0 -1
- package/dist/adapters/OpenCodeAdapter.d.ts.map +0 -1
- package/dist/adapters/OpenCodeAdapter.js +0 -289
- package/dist/adapters/OpenCodeAdapter.js.map +0 -1
- package/src/__tests__/adapters/OpenCodeAdapter.test.ts +0 -544
- package/src/adapters/OpenCodeAdapter.ts +0 -373
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"success": true,
|
|
3
|
+
"code": 200,
|
|
4
|
+
"data": {
|
|
5
|
+
"limits": [
|
|
6
|
+
{
|
|
7
|
+
"type": "TOKENS_LIMIT",
|
|
8
|
+
"unit": 3,
|
|
9
|
+
"number": 5,
|
|
10
|
+
"percentage": 99,
|
|
11
|
+
"usage": 1000,
|
|
12
|
+
"currentValue": 200,
|
|
13
|
+
"remaining": 700,
|
|
14
|
+
"nextResetTime": 1787220000000
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "CREDIT_LIMIT",
|
|
18
|
+
"unit": 6,
|
|
19
|
+
"number": 1,
|
|
20
|
+
"percentage": 5,
|
|
21
|
+
"usage": 200,
|
|
22
|
+
"currentValue": 80,
|
|
23
|
+
"remaining": 150,
|
|
24
|
+
"nextResetTime": 1787824800000
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"type": "TIME_LIMIT",
|
|
28
|
+
"unit": 5,
|
|
29
|
+
"number": 1,
|
|
30
|
+
"percentage": 25,
|
|
31
|
+
"usage": 100,
|
|
32
|
+
"currentValue": 20,
|
|
33
|
+
"remaining": 90,
|
|
34
|
+
"nextResetTime": 1789812000000
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -6,7 +6,8 @@ const checkedAt = "2026-08-09T10:00:00.000Z";
|
|
|
6
6
|
describe("getCodexCapacityReport", () => {
|
|
7
7
|
it("checks Codex installation before probing", async () => {
|
|
8
8
|
const probe = vi.fn(async (context) => ({
|
|
9
|
-
|
|
9
|
+
harness: "codex",
|
|
10
|
+
provider: "openai",
|
|
10
11
|
generatedAt: context.checkedAt,
|
|
11
12
|
authenticated: true,
|
|
12
13
|
available: "yes" as const,
|
|
@@ -24,7 +25,12 @@ describe("getCodexCapacityReport", () => {
|
|
|
24
25
|
});
|
|
25
26
|
|
|
26
27
|
expect(probe).toHaveBeenCalledWith({ installed: true, checkedAt });
|
|
27
|
-
expect(report).toMatchObject({
|
|
28
|
+
expect(report).toMatchObject({
|
|
29
|
+
harness: "codex",
|
|
30
|
+
provider: "openai",
|
|
31
|
+
generatedAt: checkedAt,
|
|
32
|
+
available: "yes",
|
|
33
|
+
});
|
|
28
34
|
});
|
|
29
35
|
|
|
30
36
|
it("redacts unexpected probe failures into a stable unknown result", async () => {
|
|
@@ -37,7 +43,8 @@ describe("getCodexCapacityReport", () => {
|
|
|
37
43
|
});
|
|
38
44
|
|
|
39
45
|
expect(report).toMatchObject({
|
|
40
|
-
|
|
46
|
+
harness: "codex",
|
|
47
|
+
provider: "openai",
|
|
41
48
|
available: "unknown",
|
|
42
49
|
authenticated: null,
|
|
43
50
|
windows: [],
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
parseZaiQuota,
|
|
6
|
+
probeZaiCapacity,
|
|
7
|
+
resolveZaiApiKey,
|
|
8
|
+
} from "../../capacity/zai.js";
|
|
9
|
+
|
|
10
|
+
const checkedAt = "2026-08-20T10:00:00.000Z";
|
|
11
|
+
const fixture = (name: string) =>
|
|
12
|
+
readFile(
|
|
13
|
+
fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)),
|
|
14
|
+
"utf8",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe("z.ai credential resolution", () => {
|
|
18
|
+
it("prefers Z_AI_API_KEY without reading Pi auth", async () => {
|
|
19
|
+
const readAuth = vi.fn(async () => fixture("zai-auth.json"));
|
|
20
|
+
await expect(
|
|
21
|
+
resolveZaiApiKey({
|
|
22
|
+
env: { Z_AI_API_KEY: "env-zai-test-key" },
|
|
23
|
+
readFile: readAuth,
|
|
24
|
+
}),
|
|
25
|
+
).resolves.toBe("env-zai-test-key");
|
|
26
|
+
expect(readAuth).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("reads the Pi zai api_key credential from ~/.pi/agent/auth.json", async () => {
|
|
30
|
+
const readAuth = vi.fn(async () => fixture("zai-auth.json"));
|
|
31
|
+
await expect(
|
|
32
|
+
resolveZaiApiKey({ env: { HOME: "/users/test" }, readFile: readAuth }),
|
|
33
|
+
).resolves.toBe("pi-zai-test-key");
|
|
34
|
+
expect(readAuth).toHaveBeenCalledWith(
|
|
35
|
+
"/users/test/.pi/agent/auth.json",
|
|
36
|
+
"utf8",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it.each([
|
|
41
|
+
[
|
|
42
|
+
"missing",
|
|
43
|
+
async () =>
|
|
44
|
+
Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" })),
|
|
45
|
+
],
|
|
46
|
+
["invalid JSON", async () => "{"],
|
|
47
|
+
[
|
|
48
|
+
"wrong credential type",
|
|
49
|
+
async () => JSON.stringify({ zai: { type: "oauth", key: "x" } }),
|
|
50
|
+
],
|
|
51
|
+
["missing key", async () => JSON.stringify({ zai: { type: "api_key" } })],
|
|
52
|
+
])(
|
|
53
|
+
"fails clearly for %s credentials without exposing file content",
|
|
54
|
+
async (_name, readAuth) => {
|
|
55
|
+
await expect(
|
|
56
|
+
resolveZaiApiKey({ env: { HOME: "/users/test" }, readFile: readAuth }),
|
|
57
|
+
).rejects.toThrow(/z\.ai API key|z\.ai Pi auth file/);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("z.ai quota mapping", () => {
|
|
63
|
+
it("maps token, credit, and monthly MCP windows with recomputed usage", async () => {
|
|
64
|
+
const report = parseZaiQuota(
|
|
65
|
+
JSON.parse(await fixture("zai-quota.json")),
|
|
66
|
+
checkedAt,
|
|
67
|
+
);
|
|
68
|
+
expect(report).toMatchObject({
|
|
69
|
+
harness: "pi",
|
|
70
|
+
provider: "zai",
|
|
71
|
+
generatedAt: checkedAt,
|
|
72
|
+
authenticated: true,
|
|
73
|
+
available: "yes",
|
|
74
|
+
creditsRemaining: null,
|
|
75
|
+
});
|
|
76
|
+
expect(report.windows).toEqual([
|
|
77
|
+
{
|
|
78
|
+
id: "zai:tokens:1",
|
|
79
|
+
label: "Tokens · 5 hours",
|
|
80
|
+
limitType: "TOKENS_LIMIT",
|
|
81
|
+
durationMinutes: 300,
|
|
82
|
+
usedPercent: 30,
|
|
83
|
+
resetsAt: "2026-08-20T10:00:00.000Z",
|
|
84
|
+
total: 1000,
|
|
85
|
+
current: 200,
|
|
86
|
+
remaining: 700,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "zai:credit:2",
|
|
90
|
+
label: "Credit · 1 week",
|
|
91
|
+
limitType: "CREDIT_LIMIT",
|
|
92
|
+
durationMinutes: 10080,
|
|
93
|
+
usedPercent: 40,
|
|
94
|
+
resetsAt: "2026-08-27T10:00:00.000Z",
|
|
95
|
+
total: 200,
|
|
96
|
+
current: 80,
|
|
97
|
+
remaining: 150,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "zai:time:3",
|
|
101
|
+
label: "MCP · monthly",
|
|
102
|
+
limitType: "TIME_LIMIT",
|
|
103
|
+
durationMinutes: 43200,
|
|
104
|
+
usedPercent: 20,
|
|
105
|
+
resetsAt: "2026-09-19T10:00:00.000Z",
|
|
106
|
+
total: 100,
|
|
107
|
+
current: 20,
|
|
108
|
+
remaining: 90,
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("uses and clamps the server percentage when quota amounts are unavailable", () => {
|
|
114
|
+
const report = parseZaiQuota(
|
|
115
|
+
{
|
|
116
|
+
success: true,
|
|
117
|
+
code: 200,
|
|
118
|
+
data: {
|
|
119
|
+
limits: [
|
|
120
|
+
{ type: "TOKENS_LIMIT", unit: 1, number: 1, percentage: 120 },
|
|
121
|
+
{ type: "CREDIT_LIMIT", unit: 5, number: 30, percentage: -5 },
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
checkedAt,
|
|
126
|
+
);
|
|
127
|
+
expect(report.windows.map((window) => window.usedPercent)).toEqual([
|
|
128
|
+
100, 0,
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it.each([
|
|
133
|
+
[
|
|
134
|
+
"invalid fixture",
|
|
135
|
+
async () => JSON.parse(await fixture("zai-quota-invalid.json")),
|
|
136
|
+
],
|
|
137
|
+
[
|
|
138
|
+
"unsuccessful envelope",
|
|
139
|
+
async () => ({ success: false, code: 401, data: { limits: [] } }),
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
"invalid entry",
|
|
143
|
+
async () => ({ success: true, code: 200, data: { limits: [{}] } }),
|
|
144
|
+
],
|
|
145
|
+
])("rejects %s", async (_name, input) => {
|
|
146
|
+
const value = await input();
|
|
147
|
+
expect(() => parseZaiQuota(value, checkedAt)).toThrow(/z\.ai quota/);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("z.ai request", () => {
|
|
152
|
+
it("GETs the global quota endpoint with only Bearer authentication", async () => {
|
|
153
|
+
const body = await fixture("zai-quota.json");
|
|
154
|
+
const fetch = vi.fn(async () => new Response(body, { status: 200 }));
|
|
155
|
+
const report = await probeZaiCapacity({
|
|
156
|
+
checkedAt,
|
|
157
|
+
env: { Z_AI_API_KEY: "request-test-key" },
|
|
158
|
+
fetch,
|
|
159
|
+
});
|
|
160
|
+
expect(fetch).toHaveBeenCalledWith(
|
|
161
|
+
"https://api.z.ai/api/monitor/usage/quota/limit",
|
|
162
|
+
expect.objectContaining({
|
|
163
|
+
method: "GET",
|
|
164
|
+
headers: { Authorization: "Bearer request-test-key" },
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
expect(JSON.stringify(report)).not.toContain("request-test-key");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("rejects non-200 and malformed JSON responses with sanitized errors", async () => {
|
|
171
|
+
await expect(
|
|
172
|
+
probeZaiCapacity({
|
|
173
|
+
checkedAt,
|
|
174
|
+
env: { Z_AI_API_KEY: "never-expose-this-key" },
|
|
175
|
+
fetch: vi.fn(async () => new Response("private body", { status: 503 })),
|
|
176
|
+
}),
|
|
177
|
+
).rejects.toThrow("z.ai quota request failed: HTTP 503");
|
|
178
|
+
await expect(
|
|
179
|
+
probeZaiCapacity({
|
|
180
|
+
checkedAt,
|
|
181
|
+
env: { Z_AI_API_KEY: "never-expose-this-key" },
|
|
182
|
+
fetch: vi.fn(async () => new Response("not-json", { status: 200 })),
|
|
183
|
+
}),
|
|
184
|
+
).rejects.toThrow("z.ai quota response is not valid JSON");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
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 { AgentStatus, type ProcessInfo } from "../../../adapters/AgentAdapter.js";
|
|
7
|
+
import { OpenCodeAdapter } from "../../../providers/opencode/OpenCodeAdapter.js";
|
|
8
|
+
|
|
9
|
+
describe("OpenCodeAdapter", () => {
|
|
10
|
+
let adapter: OpenCodeAdapter;
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let dbPath: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-adapter-test-"));
|
|
16
|
+
dbPath = path.join(tmpDir, "opencode.db");
|
|
17
|
+
adapter = new OpenCodeAdapter(dbPath);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
adapter.close();
|
|
22
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("detects opencode executables", () => {
|
|
26
|
+
expect(adapter.canHandle({ pid: 1, command: "opencode", cwd: "/repo" })).toBe(true);
|
|
27
|
+
expect(adapter.canHandle({ pid: 2, command: "/usr/bin/opencode.exe", cwd: "/repo" })).toBe(
|
|
28
|
+
true,
|
|
29
|
+
);
|
|
30
|
+
expect(adapter.canHandle({ pid: 3, command: "node /repo/opencode-plugin.js" })).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns process-only agents when the database is unavailable", async () => {
|
|
34
|
+
const agents = await adapter.detectAgents({
|
|
35
|
+
processes: [{ pid: 100, command: "opencode", cwd: "/repo", tty: "ttys001" }],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(agents).toEqual([
|
|
39
|
+
expect.objectContaining({
|
|
40
|
+
type: "opencode",
|
|
41
|
+
status: AgentStatus.RUNNING,
|
|
42
|
+
pid: 100,
|
|
43
|
+
projectPath: "/repo",
|
|
44
|
+
sessionId: "pid-100",
|
|
45
|
+
summary: "OpenCode process running",
|
|
46
|
+
}),
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("maps matching OpenCode sessions to agents", async () => {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
writeDatabase(dbPath, {
|
|
53
|
+
sessions: [{ id: "sess-001", directory: "/repo", timeCreated: now - 60_000 }],
|
|
54
|
+
messages: [
|
|
55
|
+
{
|
|
56
|
+
id: "msg-user",
|
|
57
|
+
sessionId: "sess-001",
|
|
58
|
+
role: "user",
|
|
59
|
+
timeCreated: now - 50_000,
|
|
60
|
+
timeUpdated: now - 50_000,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "msg-assistant",
|
|
64
|
+
sessionId: "sess-001",
|
|
65
|
+
role: "assistant",
|
|
66
|
+
timeCreated: now - 30_000,
|
|
67
|
+
timeUpdated: now - 30_000,
|
|
68
|
+
completed: now - 20_000,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
parts: [
|
|
72
|
+
{
|
|
73
|
+
messageId: "msg-user",
|
|
74
|
+
sessionId: "sess-001",
|
|
75
|
+
type: "text",
|
|
76
|
+
text: "Refactor this module",
|
|
77
|
+
timeCreated: now - 50_000,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const process: ProcessInfo = { pid: 200, command: "opencode", cwd: "/repo", tty: "ttys002" };
|
|
83
|
+
const [agent] = await adapter.detectAgents({ processes: [process] });
|
|
84
|
+
|
|
85
|
+
expect(agent).toMatchObject({
|
|
86
|
+
type: "opencode",
|
|
87
|
+
status: AgentStatus.WAITING,
|
|
88
|
+
pid: 200,
|
|
89
|
+
projectPath: "/repo",
|
|
90
|
+
sessionId: "sess-001",
|
|
91
|
+
summary: "Refactor this module",
|
|
92
|
+
sessionFilePath: `${dbPath}::sess-001`,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns conversation messages for encoded OpenCode session refs", () => {
|
|
97
|
+
writeDatabase(dbPath, {
|
|
98
|
+
sessions: [{ id: "sess-abc", directory: "/repo", timeCreated: Date.now() }],
|
|
99
|
+
messages: [
|
|
100
|
+
{
|
|
101
|
+
id: "msg-user",
|
|
102
|
+
sessionId: "sess-abc",
|
|
103
|
+
role: "user",
|
|
104
|
+
timeCreated: 1000,
|
|
105
|
+
timeUpdated: 1000,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
parts: [
|
|
109
|
+
{
|
|
110
|
+
messageId: "msg-user",
|
|
111
|
+
sessionId: "sess-abc",
|
|
112
|
+
type: "text",
|
|
113
|
+
text: "Hello agent",
|
|
114
|
+
timeCreated: 1000,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(adapter.getConversation(`${dbPath}::sess-abc`)).toEqual([
|
|
120
|
+
{ role: "user", content: "Hello agent" },
|
|
121
|
+
]);
|
|
122
|
+
expect(adapter.getConversation("/not-an-opencode-ref")).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function writeDatabase(
|
|
127
|
+
filePath: string,
|
|
128
|
+
data: {
|
|
129
|
+
sessions: Array<{ id: string; directory: string; timeCreated: number }>;
|
|
130
|
+
messages?: Array<{
|
|
131
|
+
id: string;
|
|
132
|
+
sessionId: string;
|
|
133
|
+
role: string;
|
|
134
|
+
timeCreated: number;
|
|
135
|
+
timeUpdated: number;
|
|
136
|
+
completed?: number;
|
|
137
|
+
}>;
|
|
138
|
+
parts?: Array<{
|
|
139
|
+
messageId: string;
|
|
140
|
+
sessionId: string;
|
|
141
|
+
type: string;
|
|
142
|
+
text?: string;
|
|
143
|
+
reasoning?: string;
|
|
144
|
+
tool?: string;
|
|
145
|
+
timeCreated: number;
|
|
146
|
+
}>;
|
|
147
|
+
},
|
|
148
|
+
): void {
|
|
149
|
+
const db = new Database(filePath);
|
|
150
|
+
db.exec(`
|
|
151
|
+
CREATE TABLE session (id TEXT, directory TEXT, time_created INTEGER);
|
|
152
|
+
CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER);
|
|
153
|
+
CREATE TABLE part (message_id TEXT, session_id TEXT, data TEXT, time_created INTEGER);
|
|
154
|
+
`);
|
|
155
|
+
|
|
156
|
+
const insertSession = db.prepare(
|
|
157
|
+
"INSERT INTO session (id, directory, time_created) VALUES (?, ?, ?)",
|
|
158
|
+
);
|
|
159
|
+
for (const session of data.sessions) {
|
|
160
|
+
insertSession.run(session.id, session.directory, session.timeCreated);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const insertMessage = db.prepare(
|
|
164
|
+
"INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
|
|
165
|
+
);
|
|
166
|
+
for (const message of data.messages ?? []) {
|
|
167
|
+
insertMessage.run(
|
|
168
|
+
message.id,
|
|
169
|
+
message.sessionId,
|
|
170
|
+
JSON.stringify({
|
|
171
|
+
role: message.role,
|
|
172
|
+
time: message.completed === undefined ? {} : { completed: message.completed },
|
|
173
|
+
}),
|
|
174
|
+
message.timeCreated,
|
|
175
|
+
message.timeUpdated,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const insertPart = db.prepare(
|
|
180
|
+
"INSERT INTO part (message_id, session_id, data, time_created) VALUES (?, ?, ?, ?)",
|
|
181
|
+
);
|
|
182
|
+
for (const part of data.parts ?? []) {
|
|
183
|
+
insertPart.run(
|
|
184
|
+
part.messageId,
|
|
185
|
+
part.sessionId,
|
|
186
|
+
JSON.stringify({
|
|
187
|
+
type: part.type,
|
|
188
|
+
text: part.text,
|
|
189
|
+
reasoning: part.reasoning,
|
|
190
|
+
tool: part.tool,
|
|
191
|
+
}),
|
|
192
|
+
part.timeCreated,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
db.close();
|
|
197
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { AgentStatus } from "../../../adapters/AgentAdapter.js";
|
|
3
|
+
import { OpenCodeAgentMapper } from "../../../providers/opencode/OpenCodeAgentMapper.js";
|
|
4
|
+
|
|
5
|
+
describe("OpenCodeAgentMapper", () => {
|
|
6
|
+
const mapper = new OpenCodeAgentMapper("/tmp/opencode.db");
|
|
7
|
+
|
|
8
|
+
it("maps completed assistant sessions as waiting", () => {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
|
|
11
|
+
const agent = mapper.mapSessionToAgent(
|
|
12
|
+
{ sessionId: "sess-1", directory: "/repo", timeCreated: now - 60_000 },
|
|
13
|
+
{
|
|
14
|
+
lastRole: "assistant",
|
|
15
|
+
lastTimeUpdated: now - 30_000,
|
|
16
|
+
lastAssistantCompleted: true,
|
|
17
|
+
lastAssistantErrored: false,
|
|
18
|
+
summary: "Ship the feature",
|
|
19
|
+
},
|
|
20
|
+
{ pid: 123, command: "opencode", cwd: "/repo" },
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
expect(agent).toMatchObject({
|
|
24
|
+
type: "opencode",
|
|
25
|
+
status: AgentStatus.WAITING,
|
|
26
|
+
summary: "Ship the feature",
|
|
27
|
+
pid: 123,
|
|
28
|
+
projectPath: "/repo",
|
|
29
|
+
sessionId: "sess-1",
|
|
30
|
+
sessionFilePath: "/tmp/opencode.db::sess-1",
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps incomplete assistant sessions running even when they are quiet briefly", () => {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
|
|
37
|
+
const agent = mapper.mapSessionToAgent(
|
|
38
|
+
{ sessionId: "sess-2", directory: "/repo", timeCreated: now - 120_000 },
|
|
39
|
+
{
|
|
40
|
+
lastRole: "assistant",
|
|
41
|
+
lastTimeUpdated: now - 120_000,
|
|
42
|
+
lastAssistantCompleted: false,
|
|
43
|
+
lastAssistantErrored: false,
|
|
44
|
+
summary: "",
|
|
45
|
+
},
|
|
46
|
+
{ pid: 124, command: "opencode", cwd: "/repo" },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(agent.status).toBe(AgentStatus.RUNNING);
|
|
50
|
+
expect(agent.summary).toBe("OpenCode session active");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("marks stale sessions idle", () => {
|
|
54
|
+
const staleTime = Date.now() - 10 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
const agent = mapper.mapSessionToAgent(
|
|
57
|
+
{ sessionId: "sess-3", directory: "/old", timeCreated: staleTime },
|
|
58
|
+
{
|
|
59
|
+
lastRole: "assistant",
|
|
60
|
+
lastTimeUpdated: staleTime,
|
|
61
|
+
lastAssistantCompleted: true,
|
|
62
|
+
lastAssistantErrored: false,
|
|
63
|
+
summary: "",
|
|
64
|
+
},
|
|
65
|
+
{ pid: 125, command: "opencode", cwd: "/old" },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(agent.status).toBe(AgentStatus.IDLE);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("maps unmatched processes as running process-only agents", () => {
|
|
72
|
+
const agent = mapper.mapProcessOnlyAgent({ pid: 500, command: "opencode", cwd: "/repo" });
|
|
73
|
+
|
|
74
|
+
expect(agent).toMatchObject({
|
|
75
|
+
type: "opencode",
|
|
76
|
+
status: AgentStatus.RUNNING,
|
|
77
|
+
summary: "OpenCode process running",
|
|
78
|
+
pid: 500,
|
|
79
|
+
projectPath: "/repo",
|
|
80
|
+
sessionId: "pid-500",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
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 { OpenCodeSessionLocator } from "../../../providers/opencode/OpenCodeSessionLocator.js";
|
|
7
|
+
import { OpenCodeSessionParser } from "../../../providers/opencode/OpenCodeSessionParser.js";
|
|
8
|
+
|
|
9
|
+
describe("OpenCodeSessionLocator", () => {
|
|
10
|
+
let tmpDir: string;
|
|
11
|
+
let dbPath: string;
|
|
12
|
+
let locator: OpenCodeSessionLocator;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-locator-test-"));
|
|
16
|
+
dbPath = path.join(tmpDir, "opencode.db");
|
|
17
|
+
locator = new OpenCodeSessionLocator(dbPath, new OpenCodeSessionParser());
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
locator.close();
|
|
22
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns null when the database file does not exist", () => {
|
|
26
|
+
expect(locator.openDb()).toBeNull();
|
|
27
|
+
expect(locator.listSessions()).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("finds the newest session for a directory", () => {
|
|
31
|
+
writeDatabase(dbPath, [
|
|
32
|
+
{ id: "old", directory: "/repo", timeCreated: 1000 },
|
|
33
|
+
{ id: "new", directory: "/repo", timeCreated: 2000 },
|
|
34
|
+
{ id: "other", directory: "/other", timeCreated: 3000 },
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const db = locator.openDb();
|
|
38
|
+
expect(db).not.toBeNull();
|
|
39
|
+
expect(locator.findSessionForDirectory(db!, "/repo")).toEqual({
|
|
40
|
+
sessionId: "new",
|
|
41
|
+
directory: "/repo",
|
|
42
|
+
timeCreated: 2000,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("lists sessions and filters by cwd", () => {
|
|
47
|
+
writeDatabase(dbPath, [
|
|
48
|
+
{ id: "sess-a", directory: "/repo-a", timeCreated: 1000 },
|
|
49
|
+
{ id: "sess-b", directory: "/repo-b", timeCreated: 2000 },
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const sessions = locator.listSessions({ cwd: "/repo-a" });
|
|
53
|
+
|
|
54
|
+
expect(sessions).toEqual([
|
|
55
|
+
expect.objectContaining({
|
|
56
|
+
type: "opencode",
|
|
57
|
+
sessionId: "sess-a",
|
|
58
|
+
cwd: "/repo-a",
|
|
59
|
+
sessionFilePath: `${dbPath}::sess-a`,
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function writeDatabase(
|
|
66
|
+
filePath: string,
|
|
67
|
+
sessions: Array<{ id: string; directory: string; timeCreated: number }>,
|
|
68
|
+
): void {
|
|
69
|
+
const db = new Database(filePath);
|
|
70
|
+
db.exec(`
|
|
71
|
+
CREATE TABLE session (id TEXT, directory TEXT, time_created INTEGER);
|
|
72
|
+
CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER, time_updated INTEGER);
|
|
73
|
+
CREATE TABLE part (message_id TEXT, session_id TEXT, data TEXT, time_created INTEGER);
|
|
74
|
+
`);
|
|
75
|
+
|
|
76
|
+
const insertSession = db.prepare(
|
|
77
|
+
"INSERT INTO session (id, directory, time_created) VALUES (?, ?, ?)",
|
|
78
|
+
);
|
|
79
|
+
for (const session of sessions) {
|
|
80
|
+
insertSession.run(session.id, session.directory, session.timeCreated);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
db.close();
|
|
84
|
+
}
|