@aaroncql/pim-agent 0.0.1 → 0.2.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/README.md +94 -66
- package/bin/pim.ts +55 -3
- package/package.json +20 -5
- package/src/extensions/_init/index.ts +3 -2
- package/src/extensions/apply-patch/coordinator.ts +49 -0
- package/src/extensions/apply-patch/executor.ts +566 -0
- package/src/extensions/apply-patch/index.ts +74 -0
- package/src/extensions/apply-patch/matcher.ts +66 -0
- package/src/extensions/apply-patch/model.ts +34 -0
- package/src/extensions/apply-patch/parser.ts +381 -0
- package/src/extensions/apply-patch/render.ts +261 -0
- package/src/extensions/apply-patch/schema.ts +43 -0
- package/src/extensions/apply-patch/types.ts +30 -0
- package/src/extensions/bash/index.ts +3 -3
- package/src/extensions/edit/index.ts +2 -1
- package/src/extensions/glob/index.ts +3 -1
- package/src/extensions/glob/schema.ts +2 -1
- package/src/extensions/grep/index.ts +3 -1
- package/src/extensions/grep/render.ts +18 -4
- package/src/extensions/grep/schema.ts +1 -1
- package/src/extensions/read/index.ts +36 -9
- package/src/extensions/read/render.ts +31 -3
- package/src/extensions/subagent/index.ts +4 -1
- package/src/extensions/todo/index.ts +4 -3
- package/src/extensions/web-search/index.ts +2 -1
- package/src/extensions/write/index.ts +2 -1
- package/src/shared/PatchSummary.ts +82 -0
- package/src/telegram/Renderer.ts +190 -4
- package/src/extensions/bash/capture.test.ts +0 -126
- package/src/extensions/bash/format.test.ts +0 -240
- package/src/extensions/bash/run.test.ts +0 -262
- package/src/extensions/command-picker/ranker.test.ts +0 -46
- package/src/extensions/edit/edit.test.ts +0 -285
- package/src/extensions/file-picker/catalog.test.ts +0 -263
- package/src/extensions/file-picker/index.test.ts +0 -168
- package/src/extensions/file-picker/ranker.test.ts +0 -94
- package/src/extensions/footer/git.test.ts +0 -76
- package/src/extensions/footer/index.test.ts +0 -161
- package/src/extensions/footer/segments.test.ts +0 -164
- package/src/extensions/glob/glob.test.ts +0 -171
- package/src/extensions/glob/index.test.ts +0 -68
- package/src/extensions/glob/render.test.ts +0 -126
- package/src/extensions/grep/grep.test.ts +0 -387
- package/src/extensions/grep/index.test.ts +0 -68
- package/src/extensions/grep/render.test.ts +0 -269
- package/src/extensions/read/read.test.ts +0 -177
- package/src/extensions/read/render.test.ts +0 -61
- package/src/extensions/subagent/index.test.ts +0 -44
- package/src/extensions/subagent/render.test.ts +0 -292
- package/src/extensions/subagent/subagent.test.ts +0 -315
- package/src/extensions/system-prompt/prompt.test.ts +0 -64
- package/src/extensions/todo/index.test.ts +0 -244
- package/src/extensions/todo/render.test.ts +0 -180
- package/src/extensions/todo/todo.test.ts +0 -222
- package/src/extensions/tps/index.test.ts +0 -254
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
- package/src/extensions/web-fetch/fetch.test.ts +0 -244
- package/src/extensions/web-fetch/render.test.ts +0 -56
- package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
- package/src/extensions/web-search/render.test.ts +0 -21
- package/src/extensions/web-search/search.test.ts +0 -53
- package/src/extensions/working-indicator/index.test.ts +0 -21
- package/src/extensions/write/render.test.ts +0 -64
- package/src/extensions/write/write.test.ts +0 -108
- package/src/shared/DiffLines.test.ts +0 -193
- package/src/shared/DiffRenderer.test.ts +0 -206
- package/src/shared/EditMatcher.test.ts +0 -123
- package/src/shared/FileScanner.test.ts +0 -158
- package/src/shared/FuzzyMatcher.test.ts +0 -114
- package/src/shared/GitignoreFilter.test.ts +0 -64
- package/src/shared/Lines.test.ts +0 -25
- package/src/shared/McpClient.test.ts +0 -235
- package/src/shared/OutputBudget.test.ts +0 -99
- package/src/shared/Paths.test.ts +0 -51
- package/src/shared/PimSettings.test.ts +0 -90
- package/src/shared/Renderer.test.ts +0 -190
- package/src/shared/SpillCache.test.ts +0 -94
- package/src/shared/Tools.test.ts +0 -392
- package/src/telegram/Config.test.ts +0 -275
- package/src/telegram/Markdown.test.ts +0 -143
- package/src/telegram/Renderer.test.ts +0 -216
- package/src/telegram/SessionRegistry.test.ts +0 -89
- package/src/telegram/TaskScheduler.test.ts +0 -278
- package/src/telegram/TaskTool.test.ts +0 -179
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import type { Api } from "grammy";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
-
|
|
7
|
-
import { Fs } from "../shared/Fs";
|
|
8
|
-
import { type TelegramConfig } from "./Config";
|
|
9
|
-
import { SessionRegistry } from "./SessionRegistry";
|
|
10
|
-
import type { SessionSettings } from "./Session";
|
|
11
|
-
import { TaskScheduler } from "./TaskScheduler";
|
|
12
|
-
|
|
13
|
-
let tmp: string;
|
|
14
|
-
let config: TelegramConfig;
|
|
15
|
-
const stubApi = {} as Api;
|
|
16
|
-
const stubScheduler = new TaskScheduler({
|
|
17
|
-
configDir: "/tmp",
|
|
18
|
-
runTask: async () => {},
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
beforeEach(async () => {
|
|
22
|
-
tmp = await mkdtemp(join(tmpdir(), "pim-session-registry-test-"));
|
|
23
|
-
config = {
|
|
24
|
-
token: "token",
|
|
25
|
-
allow: [],
|
|
26
|
-
cwd: tmp,
|
|
27
|
-
configDir: tmp,
|
|
28
|
-
};
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
afterEach(async () => {
|
|
32
|
-
await rm(tmp, { recursive: true, force: true });
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
async function readState(): Promise<Record<string, SessionSettings>> {
|
|
36
|
-
return Fs.readJsonOrEmpty<Record<string, SessionSettings>>(
|
|
37
|
-
join(tmp, "state.json"),
|
|
38
|
-
{}
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function writeState(
|
|
43
|
-
state: Record<string, SessionSettings>
|
|
44
|
-
): Promise<void> {
|
|
45
|
-
await Fs.writeAtomic(join(tmp, "state.json"), JSON.stringify(state, null, 2));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
describe("SessionRegistry state", () => {
|
|
49
|
-
test("loads persisted state and preserves it when mutating another session", async () => {
|
|
50
|
-
await writeState({
|
|
51
|
-
"1-main": {
|
|
52
|
-
cwd: "/repo",
|
|
53
|
-
cumulativeCost: 12.5,
|
|
54
|
-
sessionPath: "/sessions/one.jsonl",
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const registry = new SessionRegistry(config, stubApi, stubScheduler);
|
|
59
|
-
await registry.init();
|
|
60
|
-
const session = registry.get({ chatId: 2, threadId: undefined });
|
|
61
|
-
await session.setThinkingLevel("off");
|
|
62
|
-
|
|
63
|
-
const loaded = await readState();
|
|
64
|
-
expect(loaded["1-main"]).toEqual({
|
|
65
|
-
cwd: "/repo",
|
|
66
|
-
cumulativeCost: 12.5,
|
|
67
|
-
sessionPath: "/sessions/one.jsonl",
|
|
68
|
-
});
|
|
69
|
-
expect(loaded["2-main"]?.thinkingLevel).toBe("off");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("does not flush state when disposed before init", async () => {
|
|
73
|
-
await writeState({
|
|
74
|
-
"1-main": {
|
|
75
|
-
cwd: "/repo",
|
|
76
|
-
cumulativeCost: 12.5,
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const registry = new SessionRegistry(config, stubApi, stubScheduler);
|
|
81
|
-
await registry.disposeAll();
|
|
82
|
-
|
|
83
|
-
const loaded = await readState();
|
|
84
|
-
expect(loaded["1-main"]).toEqual({
|
|
85
|
-
cwd: "/repo",
|
|
86
|
-
cumulativeCost: 12.5,
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
});
|
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
-
|
|
6
|
-
import type { SessionId } from "./Session";
|
|
7
|
-
import { TaskScheduler } from "./TaskScheduler";
|
|
8
|
-
import type { ScheduledTask } from "./TaskSchema";
|
|
9
|
-
import { TaskStore } from "./TaskStore";
|
|
10
|
-
|
|
11
|
-
let tmp: string;
|
|
12
|
-
const sessionId: SessionId = { chatId: 42, threadId: undefined };
|
|
13
|
-
|
|
14
|
-
beforeEach(async () => {
|
|
15
|
-
tmp = await mkdtemp(join(tmpdir(), "pim-scheduler-test-"));
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
afterEach(async () => {
|
|
19
|
-
await rm(tmp, { recursive: true, force: true });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
function makeScheduler(opts: {
|
|
23
|
-
readonly now: () => number;
|
|
24
|
-
readonly onFire?: (task: ScheduledTask) => Promise<void>;
|
|
25
|
-
}): { readonly scheduler: TaskScheduler; readonly fired: ScheduledTask[] } {
|
|
26
|
-
const fired: ScheduledTask[] = [];
|
|
27
|
-
const scheduler = new TaskScheduler({
|
|
28
|
-
configDir: tmp,
|
|
29
|
-
runTask: async (task) => {
|
|
30
|
-
fired.push(task);
|
|
31
|
-
await opts.onFire?.(task);
|
|
32
|
-
},
|
|
33
|
-
now: opts.now,
|
|
34
|
-
});
|
|
35
|
-
return { scheduler, fired };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function listTaskFiles(): Promise<string[]> {
|
|
39
|
-
try {
|
|
40
|
-
return await readdir(join(tmp, "tasks"));
|
|
41
|
-
} catch {
|
|
42
|
-
return [];
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
describe("TaskScheduler.parseDuration", () => {
|
|
47
|
-
test("parses simple units", () => {
|
|
48
|
-
expect(TaskScheduler.parseDuration("30s")).toBe(30_000);
|
|
49
|
-
expect(TaskScheduler.parseDuration("5m")).toBe(300_000);
|
|
50
|
-
expect(TaskScheduler.parseDuration("2h")).toBe(7_200_000);
|
|
51
|
-
expect(TaskScheduler.parseDuration("1d")).toBe(86_400_000);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("parses compound durations", () => {
|
|
55
|
-
expect(TaskScheduler.parseDuration("1h30m")).toBe(5_400_000);
|
|
56
|
-
expect(TaskScheduler.parseDuration("2h15m30s")).toBe(8_130_000);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("rejects garbage", () => {
|
|
60
|
-
expect(() => TaskScheduler.parseDuration("")).toThrow();
|
|
61
|
-
expect(() => TaskScheduler.parseDuration("forever")).toThrow();
|
|
62
|
-
expect(() => TaskScheduler.parseDuration("10")).toThrow();
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("TaskScheduler.tick", () => {
|
|
67
|
-
test("fires due active task and reschedules interval", async () => {
|
|
68
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
69
|
-
const { scheduler, fired } = makeScheduler({ now: () => t0 });
|
|
70
|
-
const task = await scheduler.create(sessionId, {
|
|
71
|
-
prompt: "test",
|
|
72
|
-
schedule: { type: "interval", every: "30m" },
|
|
73
|
-
});
|
|
74
|
-
expect(task.nextRun).toBe(new Date(t0 + 30 * 60_000).toISOString());
|
|
75
|
-
|
|
76
|
-
// Not yet due
|
|
77
|
-
await scheduler.tick();
|
|
78
|
-
expect(fired).toHaveLength(0);
|
|
79
|
-
|
|
80
|
-
// 30 min later, due
|
|
81
|
-
const t1 = t0 + 30 * 60_000 + 5_000;
|
|
82
|
-
const later = makeScheduler({ now: () => t1 });
|
|
83
|
-
await later.scheduler.tick();
|
|
84
|
-
expect(later.fired).toHaveLength(1);
|
|
85
|
-
expect(later.fired[0]!.id).toBe(task.id);
|
|
86
|
-
|
|
87
|
-
const reloaded = (await TaskStore.loadAll(tmp))[0]!;
|
|
88
|
-
expect(reloaded.nextRun).toBe(new Date(t1 + 30 * 60_000).toISOString());
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("skips paused tasks", async () => {
|
|
92
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
93
|
-
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
94
|
-
const task = await scheduler.create(sessionId, {
|
|
95
|
-
prompt: "test",
|
|
96
|
-
schedule: { type: "interval", every: "5m" },
|
|
97
|
-
});
|
|
98
|
-
await scheduler.setStatus(sessionId, task.id, "paused");
|
|
99
|
-
|
|
100
|
-
const t1 = t0 + 6 * 60_000;
|
|
101
|
-
const { scheduler: s2, fired } = makeScheduler({ now: () => t1 });
|
|
102
|
-
await s2.tick();
|
|
103
|
-
expect(fired).toHaveLength(0);
|
|
104
|
-
|
|
105
|
-
// File still exists
|
|
106
|
-
expect(await listTaskFiles()).toHaveLength(1);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("once task is deleted after firing", async () => {
|
|
110
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
111
|
-
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
112
|
-
const at = new Date(t0 + 60_000).toISOString();
|
|
113
|
-
await scheduler.create(sessionId, {
|
|
114
|
-
prompt: "test",
|
|
115
|
-
schedule: { type: "once", at },
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const t1 = t0 + 90_000;
|
|
119
|
-
const { scheduler: s2, fired } = makeScheduler({ now: () => t1 });
|
|
120
|
-
await s2.tick();
|
|
121
|
-
expect(fired).toHaveLength(1);
|
|
122
|
-
expect(await listTaskFiles()).toHaveLength(0);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("missed >24h is advanced silently without firing", async () => {
|
|
126
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
127
|
-
// Manually write a task whose nextRun is 26h in the past
|
|
128
|
-
const stale: ScheduledTask = {
|
|
129
|
-
id: "stale-task",
|
|
130
|
-
prompt: "test",
|
|
131
|
-
chatId: sessionId.chatId,
|
|
132
|
-
threadId: sessionId.threadId,
|
|
133
|
-
schedule: { type: "interval", every: "1h" },
|
|
134
|
-
status: "active",
|
|
135
|
-
nextRun: new Date(t0 - 26 * 3600_000).toISOString(),
|
|
136
|
-
expires: null,
|
|
137
|
-
isolatedSession: false,
|
|
138
|
-
createdAt: new Date(t0 - 30 * 3600_000).toISOString(),
|
|
139
|
-
};
|
|
140
|
-
await TaskStore.save(tmp, stale);
|
|
141
|
-
|
|
142
|
-
const { scheduler, fired } = makeScheduler({ now: () => t0 });
|
|
143
|
-
await scheduler.tick();
|
|
144
|
-
expect(fired).toHaveLength(0);
|
|
145
|
-
|
|
146
|
-
const reloaded = (await TaskStore.loadAll(tmp))[0]!;
|
|
147
|
-
expect(Date.parse(reloaded.nextRun)).toBeGreaterThan(t0);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("missed <24h fires once", async () => {
|
|
151
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
152
|
-
const stale: ScheduledTask = {
|
|
153
|
-
id: "recent-miss",
|
|
154
|
-
prompt: "test",
|
|
155
|
-
chatId: sessionId.chatId,
|
|
156
|
-
threadId: sessionId.threadId,
|
|
157
|
-
schedule: { type: "interval", every: "1h" },
|
|
158
|
-
status: "active",
|
|
159
|
-
nextRun: new Date(t0 - 2 * 3600_000).toISOString(),
|
|
160
|
-
expires: null,
|
|
161
|
-
isolatedSession: false,
|
|
162
|
-
createdAt: new Date(t0 - 5 * 3600_000).toISOString(),
|
|
163
|
-
};
|
|
164
|
-
await TaskStore.save(tmp, stale);
|
|
165
|
-
|
|
166
|
-
const { scheduler, fired } = makeScheduler({ now: () => t0 });
|
|
167
|
-
await scheduler.tick();
|
|
168
|
-
expect(fired).toHaveLength(1);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("expired task is deleted without firing", async () => {
|
|
172
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
173
|
-
const expired: ScheduledTask = {
|
|
174
|
-
id: "expired",
|
|
175
|
-
prompt: "test",
|
|
176
|
-
chatId: sessionId.chatId,
|
|
177
|
-
threadId: sessionId.threadId,
|
|
178
|
-
schedule: { type: "interval", every: "1h" },
|
|
179
|
-
status: "active",
|
|
180
|
-
nextRun: new Date(t0 - 60_000).toISOString(),
|
|
181
|
-
expires: new Date(t0 - 30_000).toISOString(),
|
|
182
|
-
isolatedSession: false,
|
|
183
|
-
createdAt: new Date(t0 - 2 * 3600_000).toISOString(),
|
|
184
|
-
};
|
|
185
|
-
await TaskStore.save(tmp, expired);
|
|
186
|
-
|
|
187
|
-
const { scheduler, fired } = makeScheduler({ now: () => t0 });
|
|
188
|
-
await scheduler.tick();
|
|
189
|
-
expect(fired).toHaveLength(0);
|
|
190
|
-
expect(await listTaskFiles()).toHaveLength(0);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("cron task next-run advances via Bun.cron.parse", async () => {
|
|
194
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
195
|
-
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
196
|
-
const task = await scheduler.create(sessionId, {
|
|
197
|
-
prompt: "test",
|
|
198
|
-
schedule: { type: "cron", expr: "0 * * * *" },
|
|
199
|
-
});
|
|
200
|
-
expect(task.nextRun).toBe(new Date(t0 + 3600_000).toISOString());
|
|
201
|
-
|
|
202
|
-
// Fire it
|
|
203
|
-
const fireTime = t0 + 3600_000 + 30_000;
|
|
204
|
-
const { scheduler: s2, fired } = makeScheduler({ now: () => fireTime });
|
|
205
|
-
await s2.tick();
|
|
206
|
-
expect(fired).toHaveLength(1);
|
|
207
|
-
|
|
208
|
-
const reloaded = (await TaskStore.loadAll(tmp))[0]!;
|
|
209
|
-
// Should advance to next top-of-hour after firing
|
|
210
|
-
expect(Date.parse(reloaded.nextRun)).toBeGreaterThan(fireTime);
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
describe("TaskScheduler.create validation", () => {
|
|
215
|
-
test("rejects 'once' with past timestamp", async () => {
|
|
216
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
217
|
-
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
218
|
-
await expect(
|
|
219
|
-
scheduler.create(sessionId, {
|
|
220
|
-
prompt: "test",
|
|
221
|
-
schedule: { type: "once", at: new Date(t0 - 1000).toISOString() },
|
|
222
|
-
})
|
|
223
|
-
).rejects.toThrow();
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
test("rejects sub-minute interval", async () => {
|
|
227
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
228
|
-
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
229
|
-
await expect(
|
|
230
|
-
scheduler.create(sessionId, {
|
|
231
|
-
prompt: "test",
|
|
232
|
-
schedule: { type: "interval", every: "30s" },
|
|
233
|
-
})
|
|
234
|
-
).rejects.toThrow();
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test("rejects expires before first nextRun", async () => {
|
|
238
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
239
|
-
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
240
|
-
await expect(
|
|
241
|
-
scheduler.create(sessionId, {
|
|
242
|
-
prompt: "test",
|
|
243
|
-
schedule: { type: "interval", every: "1h" },
|
|
244
|
-
expires: new Date(t0 + 5 * 60_000).toISOString(),
|
|
245
|
-
})
|
|
246
|
-
).rejects.toThrow();
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
describe("TaskScheduler.list", () => {
|
|
251
|
-
test("filters by chat/thread", async () => {
|
|
252
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
253
|
-
const { scheduler } = makeScheduler({ now: () => t0 });
|
|
254
|
-
await scheduler.create(
|
|
255
|
-
{ chatId: 1, threadId: undefined },
|
|
256
|
-
{ prompt: "a", schedule: { type: "interval", every: "1h" } }
|
|
257
|
-
);
|
|
258
|
-
await scheduler.create(
|
|
259
|
-
{ chatId: 1, threadId: 99 },
|
|
260
|
-
{ prompt: "b", schedule: { type: "interval", every: "1h" } }
|
|
261
|
-
);
|
|
262
|
-
await scheduler.create(
|
|
263
|
-
{ chatId: 2, threadId: undefined },
|
|
264
|
-
{ prompt: "c", schedule: { type: "interval", every: "1h" } }
|
|
265
|
-
);
|
|
266
|
-
|
|
267
|
-
const main = await scheduler.list({
|
|
268
|
-
chatId: 1,
|
|
269
|
-
threadId: undefined,
|
|
270
|
-
});
|
|
271
|
-
expect(main).toHaveLength(1);
|
|
272
|
-
expect(main[0]!.prompt).toBe("a");
|
|
273
|
-
|
|
274
|
-
const threaded = await scheduler.list({ chatId: 1, threadId: 99 });
|
|
275
|
-
expect(threaded).toHaveLength(1);
|
|
276
|
-
expect(threaded[0]!.prompt).toBe("b");
|
|
277
|
-
});
|
|
278
|
-
});
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
-
|
|
6
|
-
import type { SessionId } from "./Session";
|
|
7
|
-
import { TaskScheduler } from "./TaskScheduler";
|
|
8
|
-
import { TaskTool } from "./TaskTool";
|
|
9
|
-
import type { ScheduledTask, TaskToolInput } from "./TaskSchema";
|
|
10
|
-
|
|
11
|
-
let tmp: string;
|
|
12
|
-
const sessionId: SessionId = { chatId: 7, threadId: undefined };
|
|
13
|
-
const otherSessionId: SessionId = { chatId: 7, threadId: 99 };
|
|
14
|
-
|
|
15
|
-
beforeEach(async () => {
|
|
16
|
-
tmp = await mkdtemp(join(tmpdir(), "pim-task-tool-test-"));
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
afterEach(async () => {
|
|
20
|
-
await rm(tmp, { recursive: true, force: true });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
function makeTool(opts?: { readonly now?: () => number }): {
|
|
24
|
-
readonly run: (
|
|
25
|
-
input: TaskToolInput
|
|
26
|
-
) => Promise<{ readonly text: string; readonly details: unknown }>;
|
|
27
|
-
readonly scheduler: TaskScheduler;
|
|
28
|
-
} {
|
|
29
|
-
const scheduler = new TaskScheduler({
|
|
30
|
-
configDir: tmp,
|
|
31
|
-
runTask: async () => {},
|
|
32
|
-
now: opts?.now ?? ((): number => Date.now()),
|
|
33
|
-
});
|
|
34
|
-
const tool = TaskTool.build({ scheduler, sessionId });
|
|
35
|
-
const run = async (
|
|
36
|
-
input: TaskToolInput
|
|
37
|
-
): Promise<{ readonly text: string; readonly details: unknown }> => {
|
|
38
|
-
const result = await tool.execute(
|
|
39
|
-
"test-call-id",
|
|
40
|
-
input,
|
|
41
|
-
new AbortController().signal,
|
|
42
|
-
undefined,
|
|
43
|
-
// ExtensionContext stub — tool doesn't use it
|
|
44
|
-
{} as never
|
|
45
|
-
);
|
|
46
|
-
return {
|
|
47
|
-
text: result.content
|
|
48
|
-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
49
|
-
.map((c) => c.text)
|
|
50
|
-
.join("\n"),
|
|
51
|
-
details: result.details,
|
|
52
|
-
};
|
|
53
|
-
};
|
|
54
|
-
return { run, scheduler };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
describe("task tool: create", () => {
|
|
58
|
-
test("creates an interval task and returns id + nextRun", async () => {
|
|
59
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
60
|
-
const { run } = makeTool({ now: () => t0 });
|
|
61
|
-
const result = await run({
|
|
62
|
-
action: "create",
|
|
63
|
-
prompt: "drink water",
|
|
64
|
-
schedule: { type: "interval", every: "1h" },
|
|
65
|
-
});
|
|
66
|
-
expect(result.text).toMatch(/^Created task /);
|
|
67
|
-
const details = result.details as ScheduledTask;
|
|
68
|
-
expect(details.prompt).toBe("drink water");
|
|
69
|
-
expect(details.nextRun).toBe(new Date(t0 + 3600_000).toISOString());
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("rejects sub-minute interval", async () => {
|
|
73
|
-
const { run } = makeTool();
|
|
74
|
-
await expect(
|
|
75
|
-
run({
|
|
76
|
-
action: "create",
|
|
77
|
-
prompt: "spam",
|
|
78
|
-
schedule: { type: "interval", every: "10s" },
|
|
79
|
-
})
|
|
80
|
-
).rejects.toThrow(/at least 1 minute/);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("rejects invalid cron", async () => {
|
|
84
|
-
const { run } = makeTool();
|
|
85
|
-
await expect(
|
|
86
|
-
run({
|
|
87
|
-
action: "create",
|
|
88
|
-
prompt: "test",
|
|
89
|
-
schedule: { type: "cron", expr: "not a cron" },
|
|
90
|
-
})
|
|
91
|
-
).rejects.toThrow();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("requires prompt and schedule", async () => {
|
|
95
|
-
const { run } = makeTool();
|
|
96
|
-
await expect(run({ action: "create" })).rejects.toThrow(/prompt/);
|
|
97
|
-
await expect(run({ action: "create", prompt: "hi" })).rejects.toThrow(
|
|
98
|
-
/schedule/
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe("task tool: list/delete/pause/resume", () => {
|
|
104
|
-
test("list filters to current sessionId", async () => {
|
|
105
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
106
|
-
const { run, scheduler } = makeTool({ now: () => t0 });
|
|
107
|
-
await run({
|
|
108
|
-
action: "create",
|
|
109
|
-
prompt: "mine",
|
|
110
|
-
schedule: { type: "interval", every: "1h" },
|
|
111
|
-
});
|
|
112
|
-
await scheduler.create(otherSessionId, {
|
|
113
|
-
prompt: "not mine",
|
|
114
|
-
schedule: { type: "interval", every: "1h" },
|
|
115
|
-
});
|
|
116
|
-
const result = await run({ action: "list" });
|
|
117
|
-
const details = result.details as { readonly tasks: ScheduledTask[] };
|
|
118
|
-
expect(details.tasks).toHaveLength(1);
|
|
119
|
-
expect(details.tasks[0]!.prompt).toBe("mine");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("delete removes by id", async () => {
|
|
123
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
124
|
-
const { run } = makeTool({ now: () => t0 });
|
|
125
|
-
const created = await run({
|
|
126
|
-
action: "create",
|
|
127
|
-
prompt: "soon-gone",
|
|
128
|
-
schedule: { type: "interval", every: "1h" },
|
|
129
|
-
});
|
|
130
|
-
const id = (created.details as ScheduledTask).id;
|
|
131
|
-
await run({ action: "delete", id });
|
|
132
|
-
const list = await run({ action: "list" });
|
|
133
|
-
expect((list.details as { tasks: ScheduledTask[] }).tasks).toHaveLength(0);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("delete refuses cross-session id", async () => {
|
|
137
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
138
|
-
const { run, scheduler } = makeTool({ now: () => t0 });
|
|
139
|
-
const foreign = await scheduler.create(otherSessionId, {
|
|
140
|
-
prompt: "not yours",
|
|
141
|
-
schedule: { type: "interval", every: "1h" },
|
|
142
|
-
});
|
|
143
|
-
await expect(run({ action: "delete", id: foreign.id })).rejects.toThrow(
|
|
144
|
-
/no task/
|
|
145
|
-
);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("pause flips status; resume undoes it", async () => {
|
|
149
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
150
|
-
const { run } = makeTool({ now: () => t0 });
|
|
151
|
-
const created = await run({
|
|
152
|
-
action: "create",
|
|
153
|
-
prompt: "p",
|
|
154
|
-
schedule: { type: "interval", every: "1h" },
|
|
155
|
-
});
|
|
156
|
-
const id = (created.details as ScheduledTask).id;
|
|
157
|
-
|
|
158
|
-
const paused = await run({ action: "pause", id });
|
|
159
|
-
expect((paused.details as ScheduledTask).status).toBe("paused");
|
|
160
|
-
|
|
161
|
-
const resumed = await run({ action: "resume", id });
|
|
162
|
-
expect((resumed.details as ScheduledTask).status).toBe("active");
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe("task tool: update_prompt", () => {
|
|
167
|
-
test("changes prompt text", async () => {
|
|
168
|
-
const t0 = Date.parse("2026-05-14T12:00:00Z");
|
|
169
|
-
const { run } = makeTool({ now: () => t0 });
|
|
170
|
-
const created = await run({
|
|
171
|
-
action: "create",
|
|
172
|
-
prompt: "old",
|
|
173
|
-
schedule: { type: "interval", every: "1h" },
|
|
174
|
-
});
|
|
175
|
-
const id = (created.details as ScheduledTask).id;
|
|
176
|
-
const updated = await run({ action: "update_prompt", id, prompt: "new" });
|
|
177
|
-
expect((updated.details as ScheduledTask).prompt).toBe("new");
|
|
178
|
-
});
|
|
179
|
-
});
|