@poncho-ai/harness 0.37.2 → 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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +216 -0
- package/dist/index.d.ts +397 -34
- package/dist/index.js +2228 -130
- package/package.json +2 -2
- package/src/harness.ts +48 -72
- package/src/index.ts +1 -0
- package/src/local-tools.ts +4 -3
- package/src/mcp.ts +9 -6
- package/src/orchestrator/continuation.ts +13 -0
- package/src/orchestrator/history.ts +69 -0
- package/src/orchestrator/index.ts +54 -0
- package/src/orchestrator/orchestrator.ts +1535 -0
- package/src/orchestrator/subagents.ts +27 -0
- package/src/orchestrator/turn.ts +367 -0
- package/src/reminder-store.ts +183 -16
- package/src/reminder-tools.ts +102 -6
- package/src/state.ts +127 -3
- package/src/storage/engine.ts +29 -10
- package/src/storage/memory-engine.ts +74 -10
- package/src/storage/postgres-engine.ts +1 -0
- package/src/storage/schema.ts +21 -0
- package/src/storage/sql-dialect.ts +294 -23
- package/src/storage/sqlite-engine.ts +1 -0
- package/src/storage/store-adapters.ts +17 -11
- package/src/telemetry.ts +10 -7
- package/src/upload-store.ts +7 -4
- package/src/vfs/bash-manager.ts +16 -2
- package/src/vfs/edit-file-tool.ts +9 -8
- package/src/vfs/read-file-tool.ts +14 -15
- package/src/vfs/write-file-tool.ts +6 -5
- package/test/orchestrator.test.ts +176 -0
- package/test/reminder-store.test.ts +193 -4
- package/test/state.test.ts +21 -0
- package/test/storage-engine.test.ts +80 -0
package/src/vfs/bash-manager.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Bash } from "just-bash";
|
|
|
6
6
|
import type {
|
|
7
7
|
BashOptions,
|
|
8
8
|
CommandName,
|
|
9
|
+
IFileSystem,
|
|
9
10
|
NetworkConfig as JustBashNetworkConfig,
|
|
10
11
|
} from "just-bash";
|
|
11
12
|
import type { StorageEngine } from "../storage/engine.js";
|
|
@@ -65,6 +66,7 @@ function toBashOptions(
|
|
|
65
66
|
|
|
66
67
|
export class BashEnvironmentManager {
|
|
67
68
|
private environments = new Map<string, Bash>();
|
|
69
|
+
private filesystems = new Map<string, IFileSystem>();
|
|
68
70
|
private readonly workingDir: string | null;
|
|
69
71
|
private readonly bashOptions: Partial<BashOptions>;
|
|
70
72
|
|
|
@@ -79,11 +81,21 @@ export class BashEnvironmentManager {
|
|
|
79
81
|
this.bashOptions = toBashOptions(bashConfig, network);
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/** Return the combined IFileSystem (VFS + optional /project mount) for a tenant. */
|
|
85
|
+
getFs(tenantId: string): IFileSystem {
|
|
86
|
+
let fs = this.filesystems.get(tenantId);
|
|
87
|
+
if (!fs) {
|
|
88
|
+
const adapter = new PonchoFsAdapter(this.engine, tenantId, this.limits);
|
|
89
|
+
fs = createBashFs(adapter, this.workingDir);
|
|
90
|
+
this.filesystems.set(tenantId, fs);
|
|
91
|
+
}
|
|
92
|
+
return fs;
|
|
93
|
+
}
|
|
94
|
+
|
|
82
95
|
getOrCreate(tenantId: string): Bash {
|
|
83
96
|
let bash = this.environments.get(tenantId);
|
|
84
97
|
if (!bash) {
|
|
85
|
-
const
|
|
86
|
-
const fs = createBashFs(adapter, this.workingDir);
|
|
98
|
+
const fs = this.getFs(tenantId);
|
|
87
99
|
bash = new Bash({
|
|
88
100
|
fs,
|
|
89
101
|
cwd: "/",
|
|
@@ -112,9 +124,11 @@ export class BashEnvironmentManager {
|
|
|
112
124
|
|
|
113
125
|
destroy(tenantId: string): void {
|
|
114
126
|
this.environments.delete(tenantId);
|
|
127
|
+
this.filesystems.delete(tenantId);
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
destroyAll(): void {
|
|
118
131
|
this.environments.clear();
|
|
132
|
+
this.filesystems.clear();
|
|
119
133
|
}
|
|
120
134
|
}
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
|
|
5
5
|
import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
|
|
6
|
-
import type {
|
|
6
|
+
import type { IFileSystem } from "just-bash";
|
|
7
7
|
|
|
8
8
|
export const createEditFileTool = (
|
|
9
|
-
|
|
9
|
+
getFs: (tenantId: string) => IFileSystem,
|
|
10
10
|
): ToolDefinition => defineTool({
|
|
11
11
|
name: "edit_file",
|
|
12
12
|
description:
|
|
@@ -44,12 +44,13 @@ export const createEditFileTool = (
|
|
|
44
44
|
if (!oldStr) throw new Error("old_str must not be empty");
|
|
45
45
|
|
|
46
46
|
const tenantId = context.tenantId ?? "__default__";
|
|
47
|
-
const
|
|
48
|
-
if (!stat) throw new Error(`File not found: ${filePath}`);
|
|
49
|
-
if (stat.type === "directory") throw new Error(`${filePath} is a directory`);
|
|
47
|
+
const fs = getFs(tenantId);
|
|
50
48
|
|
|
51
|
-
|
|
52
|
-
const
|
|
49
|
+
if (!(await fs.exists(filePath))) throw new Error(`File not found: ${filePath}`);
|
|
50
|
+
const stat = await fs.stat(filePath);
|
|
51
|
+
if (stat.isDirectory) throw new Error(`${filePath} is a directory`);
|
|
52
|
+
|
|
53
|
+
const content = await fs.readFile(filePath);
|
|
53
54
|
|
|
54
55
|
const first = content.indexOf(oldStr);
|
|
55
56
|
if (first === -1) {
|
|
@@ -65,7 +66,7 @@ export const createEditFileTool = (
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
const updated = content.slice(0, first) + newStr + content.slice(first + oldStr.length);
|
|
68
|
-
await
|
|
69
|
+
await fs.writeFile(filePath, updated);
|
|
69
70
|
|
|
70
71
|
return { ok: true, path: filePath };
|
|
71
72
|
},
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// read_file tool – read files from the
|
|
3
|
-
// PDFs) as
|
|
4
|
-
// model-request time via the vfs:// scheme.
|
|
2
|
+
// read_file tool – read files from the filesystem, returning binary files
|
|
3
|
+
// (images, PDFs) as inline base64 media parts.
|
|
5
4
|
// ---------------------------------------------------------------------------
|
|
6
5
|
|
|
7
6
|
import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
|
|
8
|
-
import type {
|
|
9
|
-
import { VFS_SCHEME } from "../upload-store.js";
|
|
7
|
+
import type { IFileSystem } from "just-bash";
|
|
10
8
|
|
|
11
9
|
const MIME_MAP: Record<string, string> = {
|
|
12
10
|
".txt": "text/plain",
|
|
@@ -48,7 +46,7 @@ const isTextMime = (mime: string): boolean =>
|
|
|
48
46
|
mime === "application/x-sh";
|
|
49
47
|
|
|
50
48
|
export const createReadFileTool = (
|
|
51
|
-
|
|
49
|
+
getFs: (tenantId: string) => IFileSystem,
|
|
52
50
|
): ToolDefinition => defineTool({
|
|
53
51
|
name: "read_file",
|
|
54
52
|
description:
|
|
@@ -73,29 +71,30 @@ export const createReadFileTool = (
|
|
|
73
71
|
}
|
|
74
72
|
|
|
75
73
|
const tenantId = context.tenantId ?? "__default__";
|
|
76
|
-
const
|
|
77
|
-
|
|
74
|
+
const fs = getFs(tenantId);
|
|
75
|
+
|
|
76
|
+
if (!(await fs.exists(filePath))) {
|
|
78
77
|
throw new Error(`File not found: ${filePath}`);
|
|
79
78
|
}
|
|
80
|
-
|
|
79
|
+
const stat = await fs.stat(filePath);
|
|
80
|
+
if (stat.isDirectory) {
|
|
81
81
|
throw new Error(`${filePath} is a directory, not a file`);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
const mediaType =
|
|
84
|
+
const mediaType = mimeFromPath(filePath) ?? "application/octet-stream";
|
|
85
85
|
const filename = filePath.split("/").pop() ?? filePath;
|
|
86
86
|
|
|
87
87
|
// Text files: read and return inline
|
|
88
88
|
if (isTextMime(mediaType)) {
|
|
89
|
-
const
|
|
90
|
-
const text = Buffer.from(buf).toString("utf8");
|
|
89
|
+
const text = await fs.readFile(filePath);
|
|
91
90
|
return { filename, mediaType, content: text };
|
|
92
91
|
}
|
|
93
92
|
|
|
94
|
-
//
|
|
95
|
-
|
|
93
|
+
// Binary files (images, PDFs): read bytes and return as base64
|
|
94
|
+
const buf = await fs.readFileBuffer(filePath);
|
|
96
95
|
return {
|
|
97
96
|
type: "file",
|
|
98
|
-
data:
|
|
97
|
+
data: Buffer.from(buf).toString("base64"),
|
|
99
98
|
mediaType,
|
|
100
99
|
filename,
|
|
101
100
|
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// write_file tool – create or overwrite a file in the
|
|
2
|
+
// write_file tool – create or overwrite a file in the filesystem.
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
|
|
5
5
|
import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
|
|
6
|
-
import type {
|
|
6
|
+
import type { IFileSystem } from "just-bash";
|
|
7
7
|
|
|
8
8
|
export const createWriteFileTool = (
|
|
9
|
-
|
|
9
|
+
getFs: (tenantId: string) => IFileSystem,
|
|
10
10
|
): ToolDefinition => defineTool({
|
|
11
11
|
name: "write_file",
|
|
12
12
|
description:
|
|
@@ -35,14 +35,15 @@ export const createWriteFileTool = (
|
|
|
35
35
|
if (!filePath) throw new Error("path is required");
|
|
36
36
|
|
|
37
37
|
const tenantId = context.tenantId ?? "__default__";
|
|
38
|
+
const fs = getFs(tenantId);
|
|
38
39
|
|
|
39
40
|
// Create parent directories
|
|
40
41
|
const dir = filePath.slice(0, filePath.lastIndexOf("/"));
|
|
41
42
|
if (dir) {
|
|
42
|
-
await
|
|
43
|
+
await fs.mkdir(dir, { recursive: true });
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
await
|
|
46
|
+
await fs.writeFile(filePath, content);
|
|
46
47
|
|
|
47
48
|
return { ok: true, path: filePath };
|
|
48
49
|
},
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { AgentEvent, Message } from "@poncho-ai/sdk";
|
|
3
|
+
import {
|
|
4
|
+
loadRunHistory,
|
|
5
|
+
normalizeApprovalCheckpoint,
|
|
6
|
+
buildApprovalCheckpoints,
|
|
7
|
+
resolveRunRequest,
|
|
8
|
+
createTurnDraftState,
|
|
9
|
+
recordStandardTurnEvent,
|
|
10
|
+
executeConversationTurn,
|
|
11
|
+
} from "../src/orchestrator/index.js";
|
|
12
|
+
import type { Conversation } from "../src/state.js";
|
|
13
|
+
|
|
14
|
+
const baseMessages: Message[] = [{ role: "user", content: "hello" }];
|
|
15
|
+
|
|
16
|
+
const makeConversation = (overrides: Partial<Conversation> & Pick<Conversation, "messages">): Conversation => ({
|
|
17
|
+
conversationId: "conv_test",
|
|
18
|
+
title: "test",
|
|
19
|
+
ownerId: "local-owner",
|
|
20
|
+
tenantId: null,
|
|
21
|
+
createdAt: Date.now(),
|
|
22
|
+
updatedAt: Date.now(),
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("orchestrator helpers", () => {
|
|
27
|
+
it("falls back to user messages when harness history is unavailable", () => {
|
|
28
|
+
const conversation = makeConversation({
|
|
29
|
+
messages: baseMessages,
|
|
30
|
+
_harnessMessages: "invalid" as unknown as Message[],
|
|
31
|
+
});
|
|
32
|
+
const resolved = loadRunHistory(conversation);
|
|
33
|
+
expect(resolved.source).toBe("messages");
|
|
34
|
+
expect(resolved.shouldRebuildCanonical).toBe(true);
|
|
35
|
+
expect(resolved.messages).toEqual(baseMessages);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("prefers continuation history when requested", () => {
|
|
39
|
+
const continuation: Message[] = [{ role: "assistant", content: "next" }];
|
|
40
|
+
const conversation = makeConversation({
|
|
41
|
+
messages: baseMessages,
|
|
42
|
+
_harnessMessages: baseMessages,
|
|
43
|
+
_continuationMessages: continuation,
|
|
44
|
+
});
|
|
45
|
+
const resolved = loadRunHistory(conversation, { preferContinuation: true });
|
|
46
|
+
expect(resolved.source).toBe("continuation");
|
|
47
|
+
expect(resolved.messages).toEqual(continuation);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("normalizes legacy approval checkpoint payloads", () => {
|
|
51
|
+
const normalized = normalizeApprovalCheckpoint(
|
|
52
|
+
{
|
|
53
|
+
approvalId: "approval_1",
|
|
54
|
+
runId: "run_1",
|
|
55
|
+
tool: "read_file",
|
|
56
|
+
toolCallId: "tool_1",
|
|
57
|
+
input: {},
|
|
58
|
+
checkpointMessages: undefined as unknown as Message[],
|
|
59
|
+
baseMessageCount: -1,
|
|
60
|
+
pendingToolCalls: [
|
|
61
|
+
{ id: "t2", name: "list_directory", input: {} },
|
|
62
|
+
{ foo: "bar" } as unknown as { id: string; name: string; input: Record<string, unknown> },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
baseMessages,
|
|
66
|
+
);
|
|
67
|
+
expect(normalized.checkpointMessages).toEqual(baseMessages);
|
|
68
|
+
expect(normalized.baseMessageCount).toBe(0);
|
|
69
|
+
expect(normalized.pendingToolCalls).toEqual([{ id: "t2", name: "list_directory", input: {} }]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("builds checkpoint approvals with a canonical shape", () => {
|
|
73
|
+
const checkpoints = buildApprovalCheckpoints({
|
|
74
|
+
approvals: [
|
|
75
|
+
{
|
|
76
|
+
approvalId: "approval_1",
|
|
77
|
+
tool: "read_file",
|
|
78
|
+
toolCallId: "tool_1",
|
|
79
|
+
input: { path: "README.md" },
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
runId: "run_1",
|
|
83
|
+
checkpointMessages: baseMessages,
|
|
84
|
+
baseMessageCount: 2,
|
|
85
|
+
pendingToolCalls: [{ id: "tool_2", name: "list_directory", input: {} }],
|
|
86
|
+
});
|
|
87
|
+
expect(checkpoints).toEqual([
|
|
88
|
+
{
|
|
89
|
+
approvalId: "approval_1",
|
|
90
|
+
runId: "run_1",
|
|
91
|
+
tool: "read_file",
|
|
92
|
+
toolCallId: "tool_1",
|
|
93
|
+
input: { path: "README.md" },
|
|
94
|
+
checkpointMessages: baseMessages,
|
|
95
|
+
baseMessageCount: 2,
|
|
96
|
+
pendingToolCalls: [{ id: "tool_2", name: "list_directory", input: {} }],
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("resolves run request by preferring continuation and preserves rebuild signal", () => {
|
|
102
|
+
const continuation: Message[] = [{ role: "assistant", content: "continuation" }];
|
|
103
|
+
const conversation = makeConversation({
|
|
104
|
+
messages: baseMessages,
|
|
105
|
+
_harnessMessages: "invalid" as unknown as Message[],
|
|
106
|
+
_continuationMessages: continuation,
|
|
107
|
+
});
|
|
108
|
+
const resolved = resolveRunRequest(conversation, {
|
|
109
|
+
conversationId: "conv_1",
|
|
110
|
+
messages: baseMessages,
|
|
111
|
+
preferContinuation: true,
|
|
112
|
+
});
|
|
113
|
+
expect(resolved.source).toBe("continuation");
|
|
114
|
+
expect(resolved.messages).toEqual(continuation);
|
|
115
|
+
expect(resolved.shouldRebuildCanonical).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("records standard draft events for tool timeline and text", () => {
|
|
119
|
+
const draft = createTurnDraftState();
|
|
120
|
+
recordStandardTurnEvent(
|
|
121
|
+
draft,
|
|
122
|
+
{ type: "tool:started", tool: "read_file", input: {} } as AgentEvent,
|
|
123
|
+
);
|
|
124
|
+
recordStandardTurnEvent(
|
|
125
|
+
draft,
|
|
126
|
+
{ type: "tool:completed", tool: "read_file", duration: 42, output: {} } as AgentEvent,
|
|
127
|
+
);
|
|
128
|
+
recordStandardTurnEvent(
|
|
129
|
+
draft,
|
|
130
|
+
{ type: "model:chunk", content: "done" } as AgentEvent,
|
|
131
|
+
);
|
|
132
|
+
expect(draft.toolTimeline).toEqual(["- start `read_file`", "- done `read_file` (42ms)"]);
|
|
133
|
+
expect(draft.assistantResponse).toBe("done");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("executes a conversation turn through the shared executor", async () => {
|
|
137
|
+
const events: AgentEvent[] = [
|
|
138
|
+
{ type: "run:started", runId: "run_1", agentId: "test" } as AgentEvent,
|
|
139
|
+
{ type: "tool:started", tool: "list_directory", input: {} } as AgentEvent,
|
|
140
|
+
{ type: "model:chunk", content: "hello" } as AgentEvent,
|
|
141
|
+
{
|
|
142
|
+
type: "run:completed",
|
|
143
|
+
runId: "run_1",
|
|
144
|
+
result: {
|
|
145
|
+
status: "completed" as const,
|
|
146
|
+
response: "hello",
|
|
147
|
+
steps: 2,
|
|
148
|
+
duration: 10,
|
|
149
|
+
tokens: { input: 1, output: 1, cached: 0 },
|
|
150
|
+
continuation: false,
|
|
151
|
+
continuationMessages: baseMessages,
|
|
152
|
+
contextTokens: 321,
|
|
153
|
+
contextWindow: 1000,
|
|
154
|
+
},
|
|
155
|
+
} as AgentEvent,
|
|
156
|
+
];
|
|
157
|
+
const fakeHarness = {
|
|
158
|
+
async *runWithTelemetry() {
|
|
159
|
+
for (const event of events) yield event;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
const seenTypes: string[] = [];
|
|
163
|
+
const result = await executeConversationTurn({
|
|
164
|
+
harness: fakeHarness as never,
|
|
165
|
+
runInput: { task: "test", messages: baseMessages, conversationId: "conv_1" } as never,
|
|
166
|
+
onEvent: (event) => {
|
|
167
|
+
seenTypes.push(event.type);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
expect(result.latestRunId).toBe("run_1");
|
|
171
|
+
expect(result.runSteps).toBe(2);
|
|
172
|
+
expect(result.runContextTokens).toBe(321);
|
|
173
|
+
expect(result.draft.assistantResponse).toBe("hello");
|
|
174
|
+
expect(seenTypes).toEqual(["run:started", "tool:started", "model:chunk", "run:completed"]);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { createReminderStore } from "../src/reminder-store.js";
|
|
2
|
+
import { createReminderStore, computeNextOccurrence } from "../src/reminder-store.js";
|
|
3
3
|
import { createReminderTools } from "../src/reminder-tools.js";
|
|
4
4
|
import type { ToolContext } from "@poncho-ai/sdk";
|
|
5
|
+
import type { Reminder, Recurrence } from "../src/reminder-store.js";
|
|
5
6
|
|
|
6
7
|
describe("reminder store", () => {
|
|
7
8
|
it("creates with memory provider by default", () => {
|
|
@@ -66,6 +67,128 @@ describe("reminder store", () => {
|
|
|
66
67
|
const store = createReminderStore("agent-noexist", { provider: "memory" });
|
|
67
68
|
await expect(store.cancel("does-not-exist")).rejects.toThrow("not found");
|
|
68
69
|
});
|
|
70
|
+
|
|
71
|
+
it("creates a recurring reminder with recurrence config", async () => {
|
|
72
|
+
const store = createReminderStore("agent-recur", { provider: "memory" });
|
|
73
|
+
const reminder = await store.create({
|
|
74
|
+
task: "Daily standup",
|
|
75
|
+
scheduledAt: Date.now() + 60_000,
|
|
76
|
+
conversationId: "conv-5",
|
|
77
|
+
recurrence: { type: "daily", interval: 1 },
|
|
78
|
+
});
|
|
79
|
+
expect(reminder.recurrence).toEqual({ type: "daily", interval: 1 });
|
|
80
|
+
expect(reminder.occurrenceCount).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("updates scheduledAt and occurrenceCount", async () => {
|
|
84
|
+
const store = createReminderStore("agent-update", { provider: "memory" });
|
|
85
|
+
const reminder = await store.create({
|
|
86
|
+
task: "Weekly report",
|
|
87
|
+
scheduledAt: Date.now() + 60_000,
|
|
88
|
+
conversationId: "conv-6",
|
|
89
|
+
recurrence: { type: "weekly" },
|
|
90
|
+
});
|
|
91
|
+
const newTime = Date.now() + 7 * 24 * 60 * 60 * 1000;
|
|
92
|
+
const updated = await store.update(reminder.id, {
|
|
93
|
+
scheduledAt: newTime,
|
|
94
|
+
occurrenceCount: 1,
|
|
95
|
+
});
|
|
96
|
+
expect(updated.scheduledAt).toBe(newTime);
|
|
97
|
+
expect(updated.occurrenceCount).toBe(1);
|
|
98
|
+
expect(updated.status).toBe("pending");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("throws when updating a nonexistent reminder", async () => {
|
|
102
|
+
const store = createReminderStore("agent-update-err", { provider: "memory" });
|
|
103
|
+
await expect(store.update("nope", { scheduledAt: 123 })).rejects.toThrow("not found");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("computeNextOccurrence", () => {
|
|
108
|
+
const baseReminder = (recurrence: Recurrence, overrides?: Partial<Reminder>): Reminder => ({
|
|
109
|
+
id: "r1",
|
|
110
|
+
task: "test",
|
|
111
|
+
scheduledAt: new Date("2026-04-14T09:00:00Z").getTime(),
|
|
112
|
+
status: "pending",
|
|
113
|
+
createdAt: Date.now(),
|
|
114
|
+
conversationId: "c1",
|
|
115
|
+
recurrence,
|
|
116
|
+
occurrenceCount: 0,
|
|
117
|
+
...overrides,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns null for non-recurring reminder", () => {
|
|
121
|
+
const r = baseReminder(null as unknown as Recurrence, { recurrence: null });
|
|
122
|
+
expect(computeNextOccurrence(r)).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("computes daily recurrence", () => {
|
|
126
|
+
const r = baseReminder({ type: "daily" });
|
|
127
|
+
const next = computeNextOccurrence(r)!;
|
|
128
|
+
expect(next).toBe(r.scheduledAt + 24 * 60 * 60 * 1000);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("computes daily recurrence with interval", () => {
|
|
132
|
+
const r = baseReminder({ type: "daily", interval: 3 });
|
|
133
|
+
const next = computeNextOccurrence(r)!;
|
|
134
|
+
expect(next).toBe(r.scheduledAt + 3 * 24 * 60 * 60 * 1000);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("computes weekly recurrence", () => {
|
|
138
|
+
const r = baseReminder({ type: "weekly" });
|
|
139
|
+
const next = computeNextOccurrence(r)!;
|
|
140
|
+
expect(next).toBe(r.scheduledAt + 7 * 24 * 60 * 60 * 1000);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("computes weekly recurrence with daysOfWeek", () => {
|
|
144
|
+
// 2026-04-14 is a Tuesday (day 2)
|
|
145
|
+
const r = baseReminder({ type: "weekly", daysOfWeek: [2, 4] }); // Tue, Thu
|
|
146
|
+
const next = computeNextOccurrence(r)!;
|
|
147
|
+
// Next match after Tuesday should be Thursday (2 days later)
|
|
148
|
+
expect(next).toBe(r.scheduledAt + 2 * 24 * 60 * 60 * 1000);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("wraps weekly daysOfWeek to next week", () => {
|
|
152
|
+
// 2026-04-14 is a Tuesday (day 2). Only Monday (1) in list → next week.
|
|
153
|
+
const r = baseReminder({ type: "weekly", daysOfWeek: [1] });
|
|
154
|
+
const next = computeNextOccurrence(r)!;
|
|
155
|
+
// Next Monday is 6 days later
|
|
156
|
+
expect(next).toBe(r.scheduledAt + 6 * 24 * 60 * 60 * 1000);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("computes monthly recurrence", () => {
|
|
160
|
+
const r = baseReminder({ type: "monthly" });
|
|
161
|
+
const next = computeNextOccurrence(r)!;
|
|
162
|
+
const nextDate = new Date(next);
|
|
163
|
+
expect(nextDate.getUTCMonth()).toBe(4); // May (0-indexed)
|
|
164
|
+
expect(nextDate.getUTCDate()).toBe(14);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("computes cron recurrence", () => {
|
|
168
|
+
// Every day at 10:00 UTC
|
|
169
|
+
const r = baseReminder({ type: "cron", expression: "0 10 * * *" });
|
|
170
|
+
const next = computeNextOccurrence(r)!;
|
|
171
|
+
const nextDate = new Date(next);
|
|
172
|
+
expect(nextDate.getUTCHours()).toBe(10);
|
|
173
|
+
expect(nextDate.getUTCMinutes()).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("respects maxOccurrences", () => {
|
|
177
|
+
const r = baseReminder({ type: "daily", maxOccurrences: 3 }, { occurrenceCount: 2 });
|
|
178
|
+
expect(computeNextOccurrence(r)).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("respects endsAt", () => {
|
|
182
|
+
const endsAt = new Date("2026-04-14T10:00:00Z").getTime();
|
|
183
|
+
// Daily would go to April 15, which is after endsAt
|
|
184
|
+
const r = baseReminder({ type: "daily", endsAt });
|
|
185
|
+
expect(computeNextOccurrence(r)).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns null for cron with no expression", () => {
|
|
189
|
+
const r = baseReminder({ type: "cron" });
|
|
190
|
+
expect(computeNextOccurrence(r)).toBeNull();
|
|
191
|
+
});
|
|
69
192
|
});
|
|
70
193
|
|
|
71
194
|
describe("reminder tools", () => {
|
|
@@ -105,6 +228,44 @@ describe("reminder tools", () => {
|
|
|
105
228
|
expect(all).toHaveLength(1);
|
|
106
229
|
});
|
|
107
230
|
|
|
231
|
+
it("set_reminder creates a recurring reminder", async () => {
|
|
232
|
+
const store = createReminderStore("agent-tool-recur", { provider: "memory" });
|
|
233
|
+
const tools = createReminderTools(store);
|
|
234
|
+
const setTool = tools.find((t) => t.name === "set_reminder")!;
|
|
235
|
+
const future = new Date(Date.now() + 3600_000).toISOString();
|
|
236
|
+
const result = (await setTool.handler(
|
|
237
|
+
{
|
|
238
|
+
task: "Daily standup",
|
|
239
|
+
datetime: future,
|
|
240
|
+
recurrence: { type: "daily", interval: 1 },
|
|
241
|
+
},
|
|
242
|
+
makeContext(),
|
|
243
|
+
)) as {
|
|
244
|
+
ok: boolean;
|
|
245
|
+
reminder: { id: string; status: string; recurrence: { type: string }; occurrenceCount: number };
|
|
246
|
+
};
|
|
247
|
+
expect(result.ok).toBe(true);
|
|
248
|
+
expect(result.reminder.recurrence.type).toBe("daily");
|
|
249
|
+
expect(result.reminder.occurrenceCount).toBe(0);
|
|
250
|
+
|
|
251
|
+
const all = await store.list();
|
|
252
|
+
expect(all).toHaveLength(1);
|
|
253
|
+
expect(all[0].recurrence).toEqual({ type: "daily", interval: 1 });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("set_reminder rejects invalid recurrence type", async () => {
|
|
257
|
+
const store = createReminderStore("agent-tool-bad-recur", { provider: "memory" });
|
|
258
|
+
const tools = createReminderTools(store);
|
|
259
|
+
const setTool = tools.find((t) => t.name === "set_reminder")!;
|
|
260
|
+
const future = new Date(Date.now() + 3600_000).toISOString();
|
|
261
|
+
await expect(
|
|
262
|
+
setTool.handler(
|
|
263
|
+
{ task: "Bad", datetime: future, recurrence: { type: "hourly" } },
|
|
264
|
+
makeContext(),
|
|
265
|
+
),
|
|
266
|
+
).rejects.toThrow("Invalid recurrence type");
|
|
267
|
+
});
|
|
268
|
+
|
|
108
269
|
it("set_reminder rejects past datetimes", async () => {
|
|
109
270
|
const store = createReminderStore("agent-tool-past", { provider: "memory" });
|
|
110
271
|
const tools = createReminderTools(store);
|
|
@@ -113,18 +274,24 @@ describe("reminder tools", () => {
|
|
|
113
274
|
await expect(setTool.handler({ task: "Too late", datetime: past }, makeContext())).rejects.toThrow("future");
|
|
114
275
|
});
|
|
115
276
|
|
|
116
|
-
it("list_reminders returns all reminders", async () => {
|
|
277
|
+
it("list_reminders returns all reminders with recurrence info", async () => {
|
|
117
278
|
const store = createReminderStore("agent-tool-list", { provider: "memory" });
|
|
118
279
|
await store.create({ task: "A", scheduledAt: Date.now() + 60_000, conversationId: "c1" });
|
|
119
|
-
await store.create({
|
|
280
|
+
await store.create({
|
|
281
|
+
task: "B",
|
|
282
|
+
scheduledAt: Date.now() + 120_000,
|
|
283
|
+
conversationId: "c2",
|
|
284
|
+
recurrence: { type: "weekly", daysOfWeek: [1, 3, 5] },
|
|
285
|
+
});
|
|
120
286
|
|
|
121
287
|
const tools = createReminderTools(store);
|
|
122
288
|
const listTool = tools.find((t) => t.name === "list_reminders")!;
|
|
123
289
|
const result = (await listTool.handler({}, makeContext())) as {
|
|
124
|
-
reminders: Array<{ task: string }>;
|
|
290
|
+
reminders: Array<{ task: string; recurrence?: { type: string } }>;
|
|
125
291
|
count: number;
|
|
126
292
|
};
|
|
127
293
|
expect(result.count).toBe(2);
|
|
294
|
+
expect(result.reminders[1].recurrence?.type).toBe("weekly");
|
|
128
295
|
});
|
|
129
296
|
|
|
130
297
|
it("list_reminders filters by status", async () => {
|
|
@@ -156,4 +323,26 @@ describe("reminder tools", () => {
|
|
|
156
323
|
expect(result.ok).toBe(true);
|
|
157
324
|
expect(result.reminder.status).toBe("cancelled");
|
|
158
325
|
});
|
|
326
|
+
|
|
327
|
+
it("cancel_reminder stops recurring reminders", async () => {
|
|
328
|
+
const store = createReminderStore("agent-tool-cancel-recur", { provider: "memory" });
|
|
329
|
+
const r = await store.create({
|
|
330
|
+
task: "Stop repeating",
|
|
331
|
+
scheduledAt: Date.now() + 60_000,
|
|
332
|
+
conversationId: "c1",
|
|
333
|
+
recurrence: { type: "daily" },
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const tools = createReminderTools(store);
|
|
337
|
+
const cancelTool = tools.find((t) => t.name === "cancel_reminder")!;
|
|
338
|
+
const result = (await cancelTool.handler({ id: r.id }, makeContext())) as {
|
|
339
|
+
ok: boolean;
|
|
340
|
+
reminder: { status: string };
|
|
341
|
+
};
|
|
342
|
+
expect(result.ok).toBe(true);
|
|
343
|
+
expect(result.reminder.status).toBe("cancelled");
|
|
344
|
+
|
|
345
|
+
const all = await store.list();
|
|
346
|
+
expect(all[0].status).toBe("cancelled");
|
|
347
|
+
});
|
|
159
348
|
});
|
package/test/state.test.ts
CHANGED
|
@@ -47,4 +47,25 @@ describe("conversation store factory", () => {
|
|
|
47
47
|
const found = await store.get(created.conversationId);
|
|
48
48
|
expect(found?.title).toBe("layout");
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
it("listThreads filters to children with parentMessageId set", async () => {
|
|
52
|
+
const store = createConversationStore();
|
|
53
|
+
const parent = await store.create("o", "Parent");
|
|
54
|
+
// Subagent — parentConversationId set, no parentMessageId
|
|
55
|
+
await store.create("o", "Subagent", null, {
|
|
56
|
+
parentConversationId: parent.conversationId,
|
|
57
|
+
subagentMeta: { task: "x", status: "running" },
|
|
58
|
+
});
|
|
59
|
+
// Thread — both set
|
|
60
|
+
const thread = await store.create("o", "Thread", null, {
|
|
61
|
+
parentConversationId: parent.conversationId,
|
|
62
|
+
parentMessageId: "anchor",
|
|
63
|
+
threadMeta: { snapshotLength: 1 },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const threads = await store.listThreads(parent.conversationId);
|
|
67
|
+
expect(threads).toHaveLength(1);
|
|
68
|
+
expect(threads[0].conversationId).toBe(thread.conversationId);
|
|
69
|
+
expect(threads[0].parentMessageId).toBe("anchor");
|
|
70
|
+
});
|
|
50
71
|
});
|