@poncho-ai/harness 0.37.1 → 0.38.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.
@@ -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({ task: "B", scheduledAt: Date.now() + 120_000, conversationId: "c2" });
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
  });
@@ -64,6 +64,31 @@ function runEngineTests(name: string, factory: () => Promise<{ engine: StorageEn
64
64
  expect(results).toHaveLength(1);
65
65
  expect(results[0].title).toBe("alpha beta");
66
66
  });
67
+
68
+ it("persists parentConversationId atomically when provided to create", async () => {
69
+ const parent = await engine.conversations.create("o", "Parent");
70
+ const child = await engine.conversations.create("o", "Child", null, {
71
+ parentConversationId: parent.conversationId,
72
+ subagentMeta: { task: "do thing", status: "running" },
73
+ messages: [{ role: "user", content: "do thing" }],
74
+ });
75
+
76
+ // Returned object reflects init fields
77
+ expect(child.parentConversationId).toBe(parent.conversationId);
78
+ expect(child.subagentMeta?.task).toBe("do thing");
79
+ expect(child.messages).toHaveLength(1);
80
+
81
+ // listSummaries reads the dedicated column — child must be linked to parent.
82
+ const summaries = await engine.conversations.list("o");
83
+ const childSummary = summaries.find((s) => s.conversationId === child.conversationId);
84
+ expect(childSummary?.parentConversationId).toBe(parent.conversationId);
85
+
86
+ // get() rehydrates from data blob — parent + meta must round-trip.
87
+ const reloaded = await engine.conversations.get(child.conversationId);
88
+ expect(reloaded?.parentConversationId).toBe(parent.conversationId);
89
+ expect(reloaded?.subagentMeta?.task).toBe("do thing");
90
+ expect(reloaded?.messages).toHaveLength(1);
91
+ });
67
92
  });
68
93
 
69
94
  // -- Memory --