sync: update from private repo (9a86f49b)
This commit is contained in:
@@ -34,6 +34,36 @@ describe('calendar tools', () => {
|
||||
it('exposes TOOL_DEFS entries', () => {
|
||||
expect(TOOL_DEFS.AddCalendarEvent?.function.name).toBe('AddCalendarEvent');
|
||||
expect(TOOL_DEFS.ListCalendarEvents?.function.name).toBe('ListCalendarEvents');
|
||||
expect(TOOL_DEFS.CreateReminder?.function.name).toBe('CreateReminder');
|
||||
expect(TOOL_DEFS.ListReminders?.function.name).toBe('ListReminders');
|
||||
expect(TOOL_DEFS.CancelReminder?.function.name).toBe('CancelReminder');
|
||||
});
|
||||
|
||||
it('creates, lists, and cancels an owner-scoped relative reminder', async () => {
|
||||
const created = await executeTool('CreateReminder', { body: '休憩する', delay_minutes: 20, title: '休憩' }, ctx());
|
||||
expect(created?.isError).toBe(false);
|
||||
const id = created!.output.match(/id=([^)]+)/)?.[1]!;
|
||||
expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain(id);
|
||||
expect((await executeTool('CancelReminder', { id }, ctx()))?.isError).toBe(false);
|
||||
expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain('ありません');
|
||||
});
|
||||
|
||||
it('rejects past or ambiguous reminder times', async () => {
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: 'not-a-time' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T09:00' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-02-30T09:00:00Z' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T24:00Z' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2026-07-10T09:00+23:59' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: '2020-01-01T00:00:00.000Z' }, ctx()))?.isError).toBe(true);
|
||||
expect((await executeTool('CreateReminder', { body: 'x', due_at: new Date(Date.now() + 60_000).toISOString(), delay_minutes: 1 }, ctx()))?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('does not expose or cancel another owner\'s reminder', async () => {
|
||||
const created = await executeTool('CreateReminder', { body: 'private', delay_minutes: 10 }, ctx());
|
||||
const id = created!.output.match(/id=([^)]+)/)?.[1]!;
|
||||
expect((await executeTool('ListReminders', {}, ctx({ ownerId: 'u2' })))?.output).toContain('ありません');
|
||||
expect((await executeTool('CancelReminder', { id }, ctx({ ownerId: 'u2' })))?.isError).toBe(true);
|
||||
expect((await executeTool('ListReminders', {}, ctx()))?.output).toContain(id);
|
||||
});
|
||||
|
||||
it('AddCalendarEvent happy path: row created with createdBy=agent + sourceTaskId', async () => {
|
||||
|
||||
@@ -6,6 +6,14 @@ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const MAX_TITLE_LEN = 200;
|
||||
const MAX_DESC_LEN = 4000;
|
||||
const MAX_REMINDER_BODY_LEN = 4000;
|
||||
const ISO_WITH_TIMEZONE_PATTERN = /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?(?:Z|[+-](?:(?:0\d|1[0-3]):[0-5]\d|14:00))$/;
|
||||
|
||||
function isValidIsoWithTimezone(value: string): boolean {
|
||||
if (!ISO_WITH_TIMEZONE_PATTERN.test(value) || Number.isNaN(Date.parse(value))) return false;
|
||||
const [year, month, day] = value.slice(0, 10).split('-').map(Number);
|
||||
return day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
let _repo: Repository | null = null;
|
||||
|
||||
@@ -51,9 +59,29 @@ const LIST_CALENDAR_EVENTS_DEF: ToolDef = {
|
||||
},
|
||||
};
|
||||
|
||||
const CREATE_REMINDER_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'CreateReminder',
|
||||
description: '指定時刻または指定分後に、実行ユーザー向けの通知を登録する。過去時刻や曖昧な時刻は登録しない。',
|
||||
parameters: { type: 'object', properties: {
|
||||
body: { type: 'string', description: '通知本文(必須)' },
|
||||
due_at: { type: 'string', description: 'UTC ISO 8601 時刻。delay_minutes と排他' },
|
||||
delay_minutes: { type: 'number', description: '現在からの分数。due_at と排他、1以上' },
|
||||
title: { type: 'string', description: '任意のタイトル' }, image_url: { type: 'string', description: '任意の HTTPS 画像 URL' },
|
||||
}, required: ['body'] },
|
||||
},
|
||||
};
|
||||
|
||||
const LIST_REMINDERS_DEF: ToolDef = { type: 'function', function: { name: 'ListReminders', description: '実行ユーザーが未配信かつ取消していないリマインダーを一覧する。', parameters: { type: 'object', properties: {}, required: [] } } };
|
||||
const CANCEL_REMINDER_DEF: ToolDef = { type: 'function', function: { name: 'CancelReminder', description: '実行ユーザーの未配信リマインダーを ID で取消する。', parameters: { type: 'object', properties: { id: { type: 'string', description: 'ListReminders で確認した ID' } }, required: ['id'] } } };
|
||||
|
||||
export const TOOL_DEFS: Record<string, ToolDef> = {
|
||||
AddCalendarEvent: ADD_CALENDAR_EVENT_DEF,
|
||||
ListCalendarEvents: LIST_CALENDAR_EVENTS_DEF,
|
||||
CreateReminder: CREATE_REMINDER_DEF,
|
||||
ListReminders: LIST_REMINDERS_DEF,
|
||||
CancelReminder: CANCEL_REMINDER_DEF,
|
||||
};
|
||||
|
||||
export async function executeTool(
|
||||
@@ -63,9 +91,57 @@ export async function executeTool(
|
||||
): Promise<ToolResult | null> {
|
||||
if (name === 'AddCalendarEvent') return executeAddCalendarEvent(input, ctx);
|
||||
if (name === 'ListCalendarEvents') return executeListCalendarEvents(input, ctx);
|
||||
if (name === 'CreateReminder') return executeCreateReminder(input, ctx);
|
||||
if (name === 'ListReminders') return executeListReminders(ctx);
|
||||
if (name === 'CancelReminder') return executeCancelReminder(input, ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireReminderOwner(ctx: ToolContext): string | null {
|
||||
return typeof ctx.ownerId === 'string' && ctx.ownerId.length > 0 ? ctx.ownerId : null;
|
||||
}
|
||||
|
||||
async function executeCreateReminder(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
if (!_repo) return { output: 'Reminder repo is not initialized', isError: true };
|
||||
const ownerId = requireReminderOwner(ctx);
|
||||
if (!ownerId) return { output: 'リマインダーにはユーザー所有者が必要です', isError: true };
|
||||
const body = input.body;
|
||||
if (typeof body !== 'string' || !body.trim() || body.length > MAX_REMINDER_BODY_LEN) return { output: `body は1〜${MAX_REMINDER_BODY_LEN}文字で指定してください`, isError: true };
|
||||
const title = input.title;
|
||||
if (title !== undefined && (typeof title !== 'string' || title.length > MAX_TITLE_LEN)) return { output: `title は最大${MAX_TITLE_LEN}文字です`, isError: true };
|
||||
const imageUrl = input.image_url;
|
||||
if (imageUrl !== undefined && (typeof imageUrl !== 'string' || !imageUrl.startsWith('https://'))) return { output: 'image_url は HTTPS URL で指定してください', isError: true };
|
||||
const hasDueAt = input.due_at !== undefined;
|
||||
const hasDelay = input.delay_minutes !== undefined;
|
||||
if (hasDueAt === hasDelay) return { output: 'due_at または delay_minutes のどちらか一方を指定してください', isError: true };
|
||||
let due: Date;
|
||||
if (hasDueAt) {
|
||||
if (typeof input.due_at !== 'string' || !isValidIsoWithTimezone(input.due_at)) return { output: 'due_at は実在する、タイムゾーン付き ISO 8601 形式で指定してください', isError: true };
|
||||
due = new Date(input.due_at);
|
||||
} else {
|
||||
if (typeof input.delay_minutes !== 'number' || !Number.isFinite(input.delay_minutes) || input.delay_minutes < 1) return { output: 'delay_minutes は1以上の分数で指定してください', isError: true };
|
||||
due = new Date(Date.now() + input.delay_minutes * 60_000);
|
||||
}
|
||||
if (due.getTime() <= Date.now()) return { output: '過去の時刻には登録できません。明示的な未来時刻を指定してください', isError: true };
|
||||
const reminder = _repo.createAgentReminder({ ownerId, spaceId: ctx.spaceId ?? null, title: typeof title === 'string' ? title : null, body: body.trim(), imageUrl: typeof imageUrl === 'string' ? imageUrl : null, dueAt: due.toISOString() });
|
||||
return { output: `リマインダーを登録しました: ${reminder.dueAt} (id=${reminder.id})`, isError: false };
|
||||
}
|
||||
|
||||
async function executeListReminders(ctx: ToolContext): Promise<ToolResult> {
|
||||
if (!_repo) return { output: 'Reminder repo is not initialized', isError: true };
|
||||
const ownerId = requireReminderOwner(ctx);
|
||||
if (!ownerId) return { output: 'リマインダーにはユーザー所有者が必要です', isError: true };
|
||||
const reminders = _repo.listAgentReminders(ownerId);
|
||||
return { output: reminders.length ? reminders.map(r => `- ${r.dueAt} 「${r.title ?? r.body}」(id=${r.id})`).join('\n') : '未配信のリマインダーはありません', isError: false };
|
||||
}
|
||||
|
||||
async function executeCancelReminder(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
if (!_repo) return { output: 'Reminder repo is not initialized', isError: true };
|
||||
const ownerId = requireReminderOwner(ctx);
|
||||
if (!ownerId || typeof input.id !== 'string' || !input.id) return { output: 'id とユーザー所有者が必要です', isError: true };
|
||||
return _repo.cancelAgentReminder(input.id, ownerId) ? { output: 'リマインダーを取消しました', isError: false } : { output: '未配信のリマインダーが見つかりません', isError: true };
|
||||
}
|
||||
|
||||
async function executeAddCalendarEvent(
|
||||
input: Record<string, unknown>,
|
||||
ctx: ToolContext,
|
||||
|
||||
Reference in New Issue
Block a user