From 0a7735f450a413883ed7ec0eef390ec53125f342 Mon Sep 17 00:00:00 2001 From: stablegenius49 <185121704+stablegenius49@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:09:03 -0700 Subject: [PATCH 1/2] Fix cron channel metadata and timezone handling --- src/agent/agent-runtime.ts | 90 ++++++++++++++++--- src/agent/prompts.ts | 2 + src/agent/runtime/attempt.ts | 1 + src/agent/runtime/types.ts | 8 ++ src/gateway/chat-assistant.ts | 3 + src/gateway/gateway-core.ts | 82 ++++++++++++----- src/gateway/schedule-intent.ts | 6 +- src/gateway/telegram-gateway.ts | 8 ++ test/agent-runtime.test.mjs | 140 +++++++++++++++++++++++++++++ test/channel-gateway-core.test.mjs | 115 ++++++++++++++++++++++++ test/chat-assistant.test.mjs | 4 +- test/schedule-intent.test.mjs | 1 + 12 files changed, 423 insertions(+), 37 deletions(-) diff --git a/src/agent/agent-runtime.ts b/src/agent/agent-runtime.ts index 3a95201..58f60f8 100644 --- a/src/agent/agent-runtime.ts +++ b/src/agent/agent-runtime.ts @@ -62,7 +62,7 @@ import { } from "./journal/task-journal-store.js"; import { runRuntimeAttempt } from "./runtime/attempt.js"; import { runRuntimeTask } from "./runtime/run.js"; -import type { RunTaskRequest } from "./runtime/types.js"; +import type { ChannelOriginContext, RunTaskRequest } from "./runtime/types.js"; import { AliyunUiAgentClient } from "./aliyun-ui-agent-client.js"; import { AliyunGuiPlusClient } from "./aliyun-gui-plus-client.js"; import { createPiSessionBridge } from "./pi-session-bridge.js"; @@ -320,6 +320,7 @@ interface PhoneAgentRunContext { launchablePackages: string[]; taskExecutionPlan: TaskExecutionPlan | null; cronTaskPlan: CronTaskPlan | null; + channelContext: ChannelOriginContext | null; runtimeModel: { id: string; provider: string; @@ -2431,7 +2432,7 @@ export class AgentRuntime { action.type === "cron_remove" || action.type === "cron_update" ) { - executionResult = this.executeCronAction(action); + executionResult = this.executeCronAction(action, ctx); } else if (action.type === "runtime_info") { executionResult = this.buildRuntimeInfoActionResult(ctx); } else { @@ -2609,6 +2610,7 @@ export class AgentRuntime { action: Extract, + ctx?: Pick, ): string { const registry = new CronRegistry(this.config); @@ -2624,6 +2626,9 @@ export class AgentRuntime { } if (action.type === "cron_update") { + const normalizedSchedule = action.schedule + ? this.normalizeCronScheduleTimezone(action.schedule, ctx?.task) + : undefined; const updated = registry.update(action.id, { name: action.name, enabled: action.enabled, @@ -2633,7 +2638,7 @@ export class AgentRuntime { task: action.task, } : undefined, - schedule: action.schedule, + schedule: normalizedSchedule, delivery: action.channel && action.to ? { @@ -2651,31 +2656,92 @@ export class AgentRuntime { : `cron_update missing id=${action.id}`; } + const inheritedChannel = action.channel ?? ctx?.channelContext?.channelType ?? null; + const inheritedPeerId = action.to ?? ctx?.channelContext?.peerId ?? null; + const inheritedSourceChannel = action.sourceChannel ?? ctx?.channelContext?.channelType ?? null; + const inheritedSourcePeerId = action.sourcePeerId ?? ctx?.channelContext?.peerId ?? null; + const inheritedCreatedBy = action.createdBy + ?? (ctx?.channelContext + ? `${ctx.channelContext.channelType}:${ctx.channelContext.senderId ?? ctx.channelContext.peerId}` + : undefined); + const normalizedSchedule = this.normalizeCronScheduleTimezone(action.schedule, ctx?.task); + const created = registry.add({ id: action.id, name: action.name, enabled: true, - schedule: action.schedule, + schedule: normalizedSchedule, payload: { kind: "agent_turn", task: action.task, }, delivery: - action.channel && action.to + inheritedChannel && inheritedPeerId ? { mode: "announce", - channel: action.channel, - to: action.to, + channel: inheritedChannel, + to: inheritedPeerId, } : null, model: action.model ?? null, promptMode: action.promptMode ?? "minimal", runOnStartup: action.runOnStartup, - createdBy: action.createdBy, - sourceChannel: action.sourceChannel, - sourcePeerId: action.sourcePeerId, + createdBy: inheritedCreatedBy, + sourceChannel: inheritedSourceChannel, + sourcePeerId: inheritedSourcePeerId, }); - return `cron_add ok id=${created.id}`; + return `cron_add ok id=${created.id} tz=${created.schedule.tz}`; + } + + private normalizeCronScheduleTimezone< + T extends { + kind: "cron" | "at" | "every"; + tz: string; + }, + >(schedule: T, sourceTask?: string): T { + if (this.sourceTaskMentionsExplicitTimezone(sourceTask ?? "")) { + return schedule; + } + const preferredTimezone = this.resolvePreferredScheduleTimezone(); + if (!preferredTimezone || schedule.tz === preferredTimezone) { + return schedule; + } + return { + ...schedule, + tz: preferredTimezone, + }; + } + + private resolvePreferredScheduleTimezone(): string { + const userPath = path.join(this.config.workspaceDir, "USER.md"); + try { + const user = fs.readFileSync(userPath, "utf-8"); + const match = user.match(/^\s*-\s*Timezone:\s*([^\r\n]+?)\s*$/im); + const configured = match?.[1]?.trim() ?? ""; + if (configured) { + return configured; + } + } catch { + // Fall back to the process timezone below. + } + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } + + private sourceTaskMentionsExplicitTimezone(task: string): boolean { + const normalized = String(task || "").trim(); + if (!normalized) { + return false; + } + return [ + /\b(?:timezone|time zone)\b/i, + /\b(?:utc|gmt)(?:\s*[+-]\s*\d{1,2}(?::?\d{2})?)?\b/i, + /\b(?:pst|pdt|est|edt|cst|cdt|mst|mdt)\b/i, + /\b(?:pacific|eastern|central|mountain)\s+time\b/i, + /\b(?:beijing|shanghai|china|tokyo|singapore|hong kong|taipei|los angeles|new york|london)\s+time\b/i, + /\b(?:asia|america|europe|australia|africa|etc)\/[A-Za-z_+-]+(?:\/[A-Za-z_+-]+)?\b/, + /(?:^|[^\d])[+-]\d{2}:\d{2}(?:$|[^\d])/, + /北京时间|上海时间|中国时间|太平洋时间|美东时间|美西时间|东京时间|新加坡时间|东八区/u, + ].some((pattern) => pattern.test(normalized)); } private executeJournalAction( @@ -3781,6 +3847,7 @@ export class AgentRuntime { availableToolNamesOverride?: string[], maxStepsOverride?: number, cronTaskPlan?: CronTaskPlan | null, + channelContext?: ChannelOriginContext | null, ): Promise { const activeToolNames = Array.isArray(availableToolNamesOverride) && availableToolNamesOverride.length > 0 ? availableToolNamesOverride @@ -3799,6 +3866,7 @@ export class AgentRuntime { taskExecutionPlan, maxStepsOverride, cronTaskPlan, + channelContext, }; return runRuntimeTask( diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index f4e15ca..b0016e5 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -229,6 +229,7 @@ export function buildSystemPrompt( "- Use request_user_input for non-sensitive short text values needed to proceed (for example vehicle plate/label).", "- Never use request_user_decision to collect credentials/OTP/payment or personal identity data.", "- Never use request_user_input to collect credentials/OTP/payment or personal identity data.", + "- For cron_add/cron_update, never infer a timezone from the user's language. If the user did not explicitly specify a timezone, use the workspace/user default timezone instead of inventing one.", "- If done, call finish with key outputs.", "", ...buildSkillsSection({ @@ -298,6 +299,7 @@ export function buildSystemPrompt( "- For memory questions about prior decisions/preferences/history, use memory_search first, then memory_get for exact lines.", "- Keep file operations inside workspace unless explicit override is provided by workspace policy.", "- Keep actions practical and reproducible.", + "- For cron_add/cron_update, never infer a timezone from the user's language. If the user did not explicitly specify a timezone, use the workspace/user default timezone instead of inventing one.", "", "## Device Ownership Model", "- Agent Phone: the Android device you control. It is a CLEAN/SHARED device with NO user personal data.", diff --git a/src/agent/runtime/attempt.ts b/src/agent/runtime/attempt.ts index a6313a4..307ad47 100644 --- a/src/agent/runtime/attempt.ts +++ b/src/agent/runtime/attempt.ts @@ -401,6 +401,7 @@ export async function runRuntimeAttempt( launchablePackages, taskExecutionPlan: request.taskExecutionPlan ?? null, cronTaskPlan: request.cronTaskPlan ?? null, + channelContext: request.channelContext ?? null, runtimeModel: profile.backend === "aliyun_ui_agent_mobile" || profile.backend === "aliyun_gui_plus" ? { id: effectiveProfile.model, diff --git a/src/agent/runtime/types.ts b/src/agent/runtime/types.ts index 277e4f5..18651a8 100644 --- a/src/agent/runtime/types.ts +++ b/src/agent/runtime/types.ts @@ -28,6 +28,12 @@ import { type AutoArtifactBuilder, type StepTrace } from "../../skills/auto-arti import type { SkillLoader } from "../../skills/skill-loader.js"; import type { SystemPromptMode } from "../prompts.js"; +export interface ChannelOriginContext { + channelType: string; + peerId: string; + senderId?: string | null; +} + export interface RunTaskRequest { task: string; modelName?: string; @@ -42,6 +48,7 @@ export interface RunTaskRequest { taskExecutionPlan?: TaskExecutionPlan | null; maxStepsOverride?: number; cronTaskPlan?: CronTaskPlan | null; + channelContext?: ChannelOriginContext | null; } export interface RunTaskAttemptOutcome { @@ -208,6 +215,7 @@ export interface PhoneAgentRunContext { launchablePackages: string[]; taskExecutionPlan: TaskExecutionPlan | null; cronTaskPlan: CronTaskPlan | null; + channelContext: ChannelOriginContext | null; runtimeModel: RuntimeModelMetadata; effectivePromptMode: SystemPromptMode; systemPrompt: string; diff --git a/src/gateway/chat-assistant.ts b/src/gateway/chat-assistant.ts index 0d54bff..426c64a 100644 --- a/src/gateway/chat-assistant.ts +++ b/src/gateway/chat-assistant.ts @@ -2441,6 +2441,7 @@ export class ChatAssistant { inputText: string, ): Promise { const locale: OnboardingLocale = inferScheduleIntentLocale(inputText); + const defaultTimezone = this.scheduleTimezoneForInput(); const recentContext = this.buildRoutingContextTranscript(chatId); const prompt = [ "Determine whether the user message asks to create a scheduled job, manage an existing scheduled job, or neither.", @@ -2464,7 +2465,9 @@ export class ChatAssistant { "13) Use kind=at only for one-shot future schedules when you can provide an RFC3339 datetime.", "14) summaryText must be short and in the user's language when route=create_schedule.", "15) If the schedule is ambiguous or any required field is missing for route=create_schedule, return route=none instead of guessing.", + "16) If the user does not explicitly specify a timezone, leave schedule.tz empty or use the default timezone below. Never infer timezone from the user's language or script.", `User locale hint: ${locale}`, + `Default timezone: ${defaultTimezone}`, this.buildExistingJobsCatalog(), recentContext ? `Recent conversation context:\n${recentContext}` : "", `User message: ${inputText}`, diff --git a/src/gateway/gateway-core.ts b/src/gateway/gateway-core.ts index 12b67a5..cb0686e 100644 --- a/src/gateway/gateway-core.ts +++ b/src/gateway/gateway-core.ts @@ -19,6 +19,7 @@ import type { import type { ChannelAdapter, ChannelRouter, + ChannelType, InboundEnvelope, SendOptions, SessionKeyResolver, @@ -1230,14 +1231,26 @@ export class GatewayCore { this.log(`task accepted channel=${envelope.channelType} sender=${envelope.senderId} model=${this.config.defaultModel}${taskDetail}`, "info", "task"); const adapter = this.router.getAdapter(envelope.channelType); + const canReply = adapter !== null && this.hasReplyTarget(envelope); + const channelContext = canReply + ? { + channelType: envelope.channelType, + peerId: envelope.peerId, + senderId: envelope.senderId, + } + : null; try { - if (adapter) await adapter.setTypingIndicator(envelope.peerId, true); + if (canReply && adapter) await adapter.setTypingIndicator(envelope.peerId, true); const enqueueProgress = (progress: AgentProgressUpdate): void => { progressWork = progressWork.then(async () => { const recentProgress = [...narrationState.recentProgress, progress].slice(-8); narrationState.allProgress = [...narrationState.allProgress, progress].slice(-16); + if (!canReply) { + narrationState.recentProgress = recentProgress; + return; + } const isFirstProgress = narrationState.lastNotifiedProgress === null && progress.step === 1; @@ -1296,6 +1309,11 @@ export class GatewayCore { undefined, async (progress) => enqueueProgress(progress), async (request) => { + if (!canReply) { + throw new Error( + "Human authorization is unavailable because this scheduled job has no reachable reply target.", + ); + } return this.humanAuth.requestAndWait( { chatId: this.peerIdNum(envelope), task, request: { ...request, timeoutSec: Math.max(30, request.timeoutSec) } }, async (opened) => { @@ -1318,45 +1336,50 @@ export class GatewayCore { ); }, runOptions?.promptMode, - adapter + canReply && adapter ? async (request) => adapter.requestUserDecision(envelope.peerId, request) : undefined, sessionKey, - adapter + canReply && adapter ? async (request) => adapter.requestUserInput(envelope.peerId, request) : undefined, - adapter + canReply && adapter ? async (request) => this.deliverChannelMedia(envelope, request, adapter) : undefined, taskExecutionPlan, runOptions?.availableToolNamesOverride, runOptions?.cronStepBudget, runOptions?.cronTaskPlan, + channelContext, ); await progressWork; this.log(`task done channel=${envelope.channelType} model=${this.config.defaultModel} ok=${result.ok} session=${result.sessionPath}`, "info", "task"); - const evidenceSnapshot = readLatestTaskJournalSnapshot(result.sessionPath); - const finalMessage = await this.chat.narrateTaskOutcome({ - task, locale, ok: result.ok, rawResult: result.message, - recentProgress: narrationState.allProgress, - evidenceSnapshot, - skillPath: result.skillPath ?? null, - scriptPath: result.scriptPath ?? null, - }); - await this.router.replyText(envelope, this.sanitizeForChat(finalMessage, 1800), { disableLinkPreview: true }); - this.chat.appendExternalTurn(this.peerIdNum(envelope), "assistant", finalMessage); + if (canReply) { + const evidenceSnapshot = readLatestTaskJournalSnapshot(result.sessionPath); + const finalMessage = await this.chat.narrateTaskOutcome({ + task, locale, ok: result.ok, rawResult: result.message, + recentProgress: narrationState.allProgress, + evidenceSnapshot, + skillPath: result.skillPath ?? null, + scriptPath: result.scriptPath ?? null, + }); + await this.router.replyText(envelope, this.sanitizeForChat(finalMessage, 1800), { disableLinkPreview: true }); + this.chat.appendExternalTurn(this.peerIdNum(envelope), "assistant", finalMessage); + } return { accepted: true, ok: result.ok, message: result.message }; } catch (error) { await progressWork.catch(() => {}); const message = `Execution interrupted: ${(error as Error).message || "Unknown error."}`; this.log(`task crash channel=${envelope.channelType} model=${this.config.defaultModel} error=${(error as Error).message}`, "error", "task"); - await this.router.replyText(envelope, this.sanitizeForChat(message, 600)); + if (canReply) { + await this.router.replyText(envelope, this.sanitizeForChat(message, 600)); + } return { accepted: true, ok: false, message }; } finally { - if (adapter) await adapter.setTypingIndicator(envelope.peerId, false).catch(() => {}); + if (canReply && adapter) await adapter.setTypingIndicator(envelope.peerId, false).catch(() => {}); void this.drainTaskQueue(); } } @@ -1385,8 +1408,9 @@ export class GatewayCore { } const adapters = this.router.getAllAdapters(); - const preferredAdapter = job.delivery?.channel - ? this.router.getAdapter(job.delivery.channel as import("../channel/types.js").ChannelType) + const replyTarget = this.resolveScheduledJobReplyTarget(job); + const preferredAdapter = replyTarget + ? this.router.getAdapter(replyTarget.channelType) : null; const adapter = preferredAdapter ?? adapters[0]; if (!adapter) { @@ -1396,11 +1420,11 @@ export class GatewayCore { const cronPlan = await this.chat.planCronTask(job.payload.task); const envelope: InboundEnvelope = { - channelType: adapter.channelType, + channelType: replyTarget?.channelType ?? adapter.channelType, senderId: "cron", senderName: "Cron", senderLanguageCode: null, - peerId: job.delivery?.to ? String(job.delivery.to) : "cron", + peerId: replyTarget?.peerId ?? "cron", peerKind: "dm", text: job.payload.task, attachments: [], @@ -1408,7 +1432,7 @@ export class GatewayCore { receivedAt: new Date().toISOString(), }; - if (job.delivery?.to) { + if (preferredAdapter && replyTarget) { const startMsg = await this.chat.narrateScheduledTaskStart(job.name, job.payload.task); await this.router.replyText(envelope, startMsg); this.chat.appendExternalTurn(this.peerIdNum(envelope), "assistant", startMsg); @@ -1743,6 +1767,22 @@ export class GatewayCore { return /[一-鿿]/u.test(task) ? "zh" : "en"; } + private hasReplyTarget(envelope: InboundEnvelope): boolean { + return envelope.peerId.trim().length > 0 && envelope.peerId !== "cron"; + } + + private resolveScheduledJobReplyTarget(job: StoredCronJob): { channelType: ChannelType; peerId: string } | null { + const channelType = job.delivery?.channel ?? job.sourceChannel; + const peerId = job.delivery?.to ? String(job.delivery.to).trim() : (job.sourcePeerId?.trim() ?? ""); + if (!channelType || !peerId) { + return null; + } + return { + channelType: channelType as ChannelType, + peerId, + }; + } + /** * ChatAssistant uses numeric chatId for history keying. * Convert peerId to a stable number hash. diff --git a/src/gateway/schedule-intent.ts b/src/gateway/schedule-intent.ts index 8ac2a07..d6de5a9 100644 --- a/src/gateway/schedule-intent.ts +++ b/src/gateway/schedule-intent.ts @@ -82,7 +82,7 @@ export function normalizeScheduleIntentDecision( schedule, delivery: null, requiresConfirmation: true, - confirmationPrompt: buildScheduleIntentConfirmationPrompt(schedule.summaryText, normalizedTask), + confirmationPrompt: buildScheduleIntentConfirmationPrompt(schedule.summaryText, schedule.tz, normalizedTask), }, }; } @@ -96,8 +96,8 @@ export function normalizeScheduleIntentCandidate( return decision?.route === "create_schedule" ? decision.intent : null; } -function buildScheduleIntentConfirmationPrompt(summaryText: string, taskText: string): string { - return `I understand this as a scheduled job: ${summaryText}, task "${taskText}". Reply "confirm" to create it or "cancel" to discard it.`; +function buildScheduleIntentConfirmationPrompt(summaryText: string, timezone: string, taskText: string): string { + return `I understand this as a scheduled job: ${summaryText} (${timezone}), task "${taskText}". Reply "confirm" to create it or "cancel" to discard it.`; } function normalizeSchedule( diff --git a/src/gateway/telegram-gateway.ts b/src/gateway/telegram-gateway.ts index e49635d..8f2a012 100644 --- a/src/gateway/telegram-gateway.ts +++ b/src/gateway/telegram-gateway.ts @@ -2388,6 +2388,13 @@ export class TelegramGateway { recentProgress: [], allProgress: [], }; + const channelContext = chatId !== null + ? { + channelType: "telegram", + peerId: String(chatId), + senderId: String(chatId), + } + : null; let progressWork: Promise = Promise.resolve(); this.log( `task accepted source=${source} chat=${chatId ?? "(none)"} task=${JSON.stringify(task)} model=${modelName ?? this.config.defaultModel}`, @@ -2597,6 +2604,7 @@ export class TelegramGateway { undefined, cronStepBudget ?? undefined, cronTaskPlan ?? undefined, + channelContext, ); await progressWork; diff --git a/test/agent-runtime.test.mjs b/test/agent-runtime.test.mjs index 139010a..e825fed 100644 --- a/test/agent-runtime.test.mjs +++ b/test/agent-runtime.test.mjs @@ -597,6 +597,146 @@ test("AgentRuntime cron_add tool creates a structured cron job", async () => { assert.equal(created.delivery.to, "12345"); }); +test("AgentRuntime cron_add inherits channel metadata from the active run context", async () => { + const runtime = setupRuntime({ + returnHomeOnTaskEnd: false, + scriptedSteps: [ + { + thought: "create cron job", + action: { + type: "cron_add", + id: "daily-slack-checkin-inherited", + name: "Daily Slack Check-in Inherited", + schedule: { + kind: "cron", + expr: "0 8 * * *", + at: null, + everyMs: null, + tz: "Asia/Shanghai", + summaryText: "Daily 08:00", + }, + task: "Open Slack and complete check-in", + promptMode: "minimal", + }, + }, + { thought: "done", action: { type: "finish", message: "task completed" } }, + ], + }); + + runtime.adb = { + queryLaunchablePackages: () => [], + captureScreenSnapshot: () => makeSnapshot(), + executeAction: async () => "ok", + }; + fs.writeFileSync( + path.join(runtime.config.workspaceDir, "USER.md"), + [ + "# USER", + "", + "## Profile", + "", + "- Timezone: America/Los_Angeles", + ].join("\n"), + "utf-8", + ); + + const result = await runtime.runTask( + "create cron job via inherited context", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ["cron_add", "finish"], + undefined, + undefined, + { channelType: "telegram", peerId: "12345", senderId: "12345" }, + ); + assert.equal(result.ok, true); + + const jobsFile = path.join(runtime.config.workspaceDir, "cron", "jobs.json"); + const saved = JSON.parse(fs.readFileSync(jobsFile, "utf-8")); + const created = saved.jobs.find((job) => job.id === "daily-slack-checkin-inherited"); + assert.equal(Boolean(created), true); + assert.deepEqual(created.delivery, { mode: "announce", channel: "telegram", to: "12345" }); + assert.equal(created.schedule.tz, "America/Los_Angeles"); + assert.equal(created.createdBy, "telegram:12345"); + assert.equal(created.sourceChannel, "telegram"); + assert.equal(created.sourcePeerId, "12345"); +}); + +test("AgentRuntime preserves an explicitly requested timezone for cron_add", async () => { + const runtime = setupRuntime({ + returnHomeOnTaskEnd: false, + scriptedSteps: [ + { + thought: "create cron job", + action: { + type: "cron_add", + id: "daily-slack-checkin-shanghai", + name: "Daily Slack Check-In Shanghai", + schedule: { + kind: "cron", + expr: "0 8 * * *", + at: null, + everyMs: null, + tz: "Asia/Shanghai", + summaryText: "Daily 08:00", + }, + task: "Open Slack and complete check-in", + promptMode: "minimal", + }, + }, + { thought: "done", action: { type: "finish", message: "task completed" } }, + ], + }); + + runtime.adb = { + queryLaunchablePackages: () => [], + captureScreenSnapshot: () => makeSnapshot(), + executeAction: async () => "ok", + }; + fs.writeFileSync( + path.join(runtime.config.workspaceDir, "USER.md"), + [ + "# USER", + "", + "## Profile", + "", + "- Timezone: America/Los_Angeles", + ].join("\n"), + "utf-8", + ); + + const result = await runtime.runTask( + "Create a cron job for 8 AM Shanghai time to open Slack and complete check-in", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ["cron_add", "finish"], + undefined, + undefined, + { channelType: "telegram", peerId: "12345", senderId: "12345" }, + ); + assert.equal(result.ok, true); + + const jobsFile = path.join(runtime.config.workspaceDir, "cron", "jobs.json"); + const saved = JSON.parse(fs.readFileSync(jobsFile, "utf-8")); + const created = saved.jobs.find((job) => job.id === "daily-slack-checkin-shanghai"); + assert.equal(Boolean(created), true); + assert.equal(created.schedule.tz, "Asia/Shanghai"); +}); + test("AgentRuntime system prompt includes task journal guidance", async () => { let capturedSystemPrompt = ""; const runtime = setupRuntime({ diff --git a/test/channel-gateway-core.test.mjs b/test/channel-gateway-core.test.mjs index 4259995..d2af97e 100644 --- a/test/channel-gateway-core.test.mjs +++ b/test/channel-gateway-core.test.mjs @@ -703,6 +703,121 @@ test("GatewayCore uses a restricted cron setup run after confirmation", async () }); }); +test("GatewayCore runs scheduled jobs headlessly when no reply target is stored", async () => { + await withTempHome("gwcore-cron-headless-", async (home) => { + const { adapter, core } = createGatewayCore(home); + + core.chat.planCronTask = async () => ({ + summary: "headless run", + steps: ["run task"], + stepBudget: 4, + completionCriteria: "task completes", + }); + core.chat.narrateScheduledTaskStart = async () => { + throw new Error("start narration should be skipped without a reply target"); + }; + core.chat.narrateTaskOutcome = async () => { + throw new Error("final narration should be skipped without a reply target"); + }; + core.agent.runTask = async () => ({ + ok: true, + message: "headless ok", + sessionPath: "/tmp/gwcore-cron-headless.jsonl", + skillPath: null, + scriptPath: null, + }); + + const result = await core.runScheduledJob({ + id: "headless-job", + name: "Headless Job", + enabled: true, + schedule: { + kind: "cron", + expr: "0 8 * * *", + at: null, + everyMs: null, + tz: "UTC", + summaryText: "Daily at 08:00 UTC", + }, + payload: { + kind: "agent_turn", + task: "Open settings app and check Wi-Fi", + }, + delivery: null, + model: null, + promptMode: "minimal", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: null, + sourceChannel: null, + sourcePeerId: null, + runOnStartup: false, + }); + + assert.deepEqual(result, { accepted: true, ok: true, message: "headless ok" }); + assert.equal(adapter.sent.length, 0); + }); +}); + +test("GatewayCore falls back to the stored source chat for cron replies when delivery is absent", async () => { + await withTempHome("gwcore-cron-source-fallback-", async (home) => { + const { adapter, core } = createGatewayCore(home); + + core.chat.planCronTask = async () => ({ + summary: "notify source chat", + steps: ["run task"], + stepBudget: 4, + completionCriteria: "task completes", + }); + core.chat.narrateScheduledTaskStart = async () => "Starting scheduled job"; + core.chat.narrateTaskOutcome = async () => "Scheduled job finished"; + core.agent.runTask = async () => ({ + ok: true, + message: "source fallback ok", + sessionPath: "/tmp/gwcore-cron-source-fallback.jsonl", + skillPath: null, + scriptPath: null, + }); + + const result = await core.runScheduledJob({ + id: "source-fallback-job", + name: "Source Fallback Job", + enabled: true, + schedule: { + kind: "cron", + expr: "0 8 * * *", + at: null, + everyMs: null, + tz: "UTC", + summaryText: "Daily at 08:00 UTC", + }, + payload: { + kind: "agent_turn", + task: "Open settings app and check Wi-Fi", + }, + delivery: null, + model: null, + promptMode: "minimal", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: "test", + sourceChannel: "telegram", + sourcePeerId: "user-1", + runOnStartup: false, + }); + + assert.deepEqual(result, { accepted: true, ok: true, message: "source fallback ok" }); + assert.equal(adapter.sent.length, 2); + assert.deepEqual( + adapter.sent.map((entry) => ({ peerId: entry.peerId, text: entry.text })), + [ + { peerId: "user-1", text: "Starting scheduled job" }, + { peerId: "user-1", text: "Scheduled job finished" }, + ], + ); + }); +}); + test("GatewayCore answers schedule-management list requests directly from the cron registry", async () => { await withTempHome("gwcore-schedule-manage-list-", async (home) => { const { adapter, core, config } = createGatewayCore(home); diff --git a/test/chat-assistant.test.mjs b/test/chat-assistant.test.mjs index 31a9fd0..faea623 100644 --- a/test/chat-assistant.test.mjs +++ b/test/chat-assistant.test.mjs @@ -228,7 +228,7 @@ test("ChatAssistant decide detects implicit Chinese schedule intent and prepares }, delivery: null, requiresConfirmation: true, - confirmationPrompt: "I understand this as a scheduled job: Every day at 08:00, task \"Open Slack and complete check-in\". Reply \"confirm\" to create it or \"cancel\" to discard it.", + confirmationPrompt: "I understand this as a scheduled job: Every day at 08:00 (Asia/Shanghai), task \"Open Slack and complete check-in\". Reply \"confirm\" to create it or \"cancel\" to discard it.", }, confidence: 0.98, reason: "schedule_model", @@ -323,7 +323,7 @@ test("ChatAssistant decide treats one-shot tomorrow phrasing as schedule intent" }, delivery: null, requiresConfirmation: true, - confirmationPrompt: "I understand this as a scheduled job: Tomorrow at 08:00, task \"Open Slack and complete check-in\". Reply \"confirm\" to create it or \"cancel\" to discard it.", + confirmationPrompt: "I understand this as a scheduled job: Tomorrow at 08:00 (Asia/Shanghai), task \"Open Slack and complete check-in\". Reply \"confirm\" to create it or \"cancel\" to discard it.", }, confidence: 0.98, reason: "schedule_model", diff --git a/test/schedule-intent.test.mjs b/test/schedule-intent.test.mjs index 1090ce1..62c5867 100644 --- a/test/schedule-intent.test.mjs +++ b/test/schedule-intent.test.mjs @@ -29,6 +29,7 @@ test("schedule intent normalization builds confirmation from model output", () = assert.equal(intent?.schedule.kind, "cron"); assert.equal(intent?.schedule.expr, "0 8 * * *"); assert.equal(intent?.schedule.summaryText, "Every day at 08:00"); + assert.match(intent?.confirmationPrompt ?? "", /Asia\/Shanghai/); assert.match(intent?.confirmationPrompt ?? "", /confirm/i); }); From 7a88b4480d50221df2193301026162fb7016268c Mon Sep 17 00:00:00 2001 From: stablegenius49 <185121704+stablegenius49@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:53:17 -0700 Subject: [PATCH 2/2] Confirm default cron timezones with the user --- src/agent/actions.ts | 8 ++++ src/agent/agent-runtime.ts | 40 +++++++---------- src/agent/prompts.ts | 8 ++-- src/agent/tools.ts | 10 +++++ src/gateway/chat-assistant.ts | 5 ++- src/gateway/gateway-core.ts | 42 +++++++++++++++++- src/gateway/schedule-intent.ts | 63 ++++++++++++++++++++------ src/types.ts | 5 +++ test/agent-runtime.test.mjs | 71 ++++++++++++++++++++++++++++++ test/channel-gateway-core.test.mjs | 63 ++++++++++++++++++++++++++ test/schedule-intent.test.mjs | 23 ++++++++++ 11 files changed, 293 insertions(+), 45 deletions(-) diff --git a/src/agent/actions.ts b/src/agent/actions.ts index 460c9a8..00c0fe2 100644 --- a/src/agent/actions.ts +++ b/src/agent/actions.ts @@ -296,6 +296,10 @@ export function normalizeAction(input: unknown): AgentAction { ? String(input.schedule.summaryText ?? "") : "", }, + timezoneSource: + input.timezoneSource === "explicit" || input.timezoneSource === "default" + ? input.timezoneSource + : undefined, task: String(input.task ?? ""), channel: toOptionalTrimmedString(input.channel), to: toOptionalTrimmedString(input.to), @@ -348,6 +352,10 @@ export function normalizeAction(input: unknown): AgentAction { summaryText: String(input.schedule.summaryText ?? ""), } : undefined, + timezoneSource: + input.timezoneSource === "explicit" || input.timezoneSource === "default" + ? input.timezoneSource + : undefined, channel: toOptionalTrimmedString(input.channel), to: toOptionalTrimmedString(input.to), model: toOptionalTrimmedString(input.model), diff --git a/src/agent/agent-runtime.ts b/src/agent/agent-runtime.ts index 58f60f8..6eafc4f 100644 --- a/src/agent/agent-runtime.ts +++ b/src/agent/agent-runtime.ts @@ -10,6 +10,7 @@ import type { ChannelMediaDeliveryResult, ChannelMediaRequest, CronTaskPlan, + CronTimezoneSource, HumanAuthDecision, HumanAuthCapability, HumanAuthRequest, @@ -2627,7 +2628,7 @@ export class AgentRuntime { if (action.type === "cron_update") { const normalizedSchedule = action.schedule - ? this.normalizeCronScheduleTimezone(action.schedule, ctx?.task) + ? this.normalizeCronScheduleTimezone(action.schedule, action.timezoneSource) : undefined; const updated = registry.update(action.id, { name: action.name, @@ -2664,7 +2665,7 @@ export class AgentRuntime { ?? (ctx?.channelContext ? `${ctx.channelContext.channelType}:${ctx.channelContext.senderId ?? ctx.channelContext.peerId}` : undefined); - const normalizedSchedule = this.normalizeCronScheduleTimezone(action.schedule, ctx?.task); + const normalizedSchedule = this.normalizeCronScheduleTimezone(action.schedule, action.timezoneSource); const created = registry.add({ id: action.id, @@ -2698,8 +2699,8 @@ export class AgentRuntime { kind: "cron" | "at" | "every"; tz: string; }, - >(schedule: T, sourceTask?: string): T { - if (this.sourceTaskMentionsExplicitTimezone(sourceTask ?? "")) { + >(schedule: T, timezoneSource?: CronTimezoneSource): T { + if (timezoneSource === "explicit") { return schedule; } const preferredTimezone = this.resolvePreferredScheduleTimezone(); @@ -2716,10 +2717,18 @@ export class AgentRuntime { const userPath = path.join(this.config.workspaceDir, "USER.md"); try { const user = fs.readFileSync(userPath, "utf-8"); - const match = user.match(/^\s*-\s*Timezone:\s*([^\r\n]+?)\s*$/im); - const configured = match?.[1]?.trim() ?? ""; + const timezoneLine = user + .split(/\r?\n/) + .find((line) => /^\s*-\s*Timezone\s*:/i.test(line)); + const configured = timezoneLine + ? timezoneLine.replace(/^\s*-\s*Timezone\s*:\s*/i, "").trim() + : ""; if (configured) { - return configured; + try { + return new Intl.DateTimeFormat("en-US", { timeZone: configured }).resolvedOptions().timeZone; + } catch { + // Fall through to the process timezone below. + } } } catch { // Fall back to the process timezone below. @@ -2727,23 +2736,6 @@ export class AgentRuntime { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } - private sourceTaskMentionsExplicitTimezone(task: string): boolean { - const normalized = String(task || "").trim(); - if (!normalized) { - return false; - } - return [ - /\b(?:timezone|time zone)\b/i, - /\b(?:utc|gmt)(?:\s*[+-]\s*\d{1,2}(?::?\d{2})?)?\b/i, - /\b(?:pst|pdt|est|edt|cst|cdt|mst|mdt)\b/i, - /\b(?:pacific|eastern|central|mountain)\s+time\b/i, - /\b(?:beijing|shanghai|china|tokyo|singapore|hong kong|taipei|los angeles|new york|london)\s+time\b/i, - /\b(?:asia|america|europe|australia|africa|etc)\/[A-Za-z_+-]+(?:\/[A-Za-z_+-]+)?\b/, - /(?:^|[^\d])[+-]\d{2}:\d{2}(?:$|[^\d])/, - /北京时间|上海时间|中国时间|太平洋时间|美东时间|美西时间|东京时间|新加坡时间|东八区/u, - ].some((pattern) => pattern.test(normalized)); - } - private executeJournalAction( action: Extract = shell: "- shell: shell(command[, reason])", batch_actions: "- batch_actions: batch_actions(actions[, reason])", run_script: "- run_script: run_script(script[, timeoutSec, reason])", - cron_add: "- cron_add: cron_add(id, name, schedule, task[, channel, to, model, promptMode, runOnStartup, createdBy, sourceChannel, sourcePeerId, reason])", + cron_add: "- cron_add: cron_add(id, name, schedule, task[, timezoneSource, channel, to, model, promptMode, runOnStartup, createdBy, sourceChannel, sourcePeerId, reason])", cron_list: "- cron_list: cron_list([reason])", cron_remove: "- cron_remove: cron_remove(id[, reason])", - cron_update: "- cron_update: cron_update(id[, name, enabled, task, schedule, channel, to, model, promptMode, runOnStartup, reason])", + cron_update: "- cron_update: cron_update(id[, name, enabled, task, schedule, timezoneSource, channel, to, model, promptMode, runOnStartup, reason])", runtime_info: "- runtime_info: runtime_info([reason])", read: "- read: read(path[, from, lines, reason])", write: "- write: write(path, content[, append, reason])", @@ -229,7 +229,7 @@ export function buildSystemPrompt( "- Use request_user_input for non-sensitive short text values needed to proceed (for example vehicle plate/label).", "- Never use request_user_decision to collect credentials/OTP/payment or personal identity data.", "- Never use request_user_input to collect credentials/OTP/payment or personal identity data.", - "- For cron_add/cron_update, never infer a timezone from the user's language. If the user did not explicitly specify a timezone, use the workspace/user default timezone instead of inventing one.", + "- For cron_add/cron_update, never infer a timezone from the user's language. Set timezoneSource=explicit only when the user explicitly named a timezone. Set timezoneSource=default when using the workspace/user default timezone. If you are not confident the default timezone is intended, ask with request_user_input before calling cron_add/cron_update.", "- If done, call finish with key outputs.", "", ...buildSkillsSection({ @@ -299,7 +299,7 @@ export function buildSystemPrompt( "- For memory questions about prior decisions/preferences/history, use memory_search first, then memory_get for exact lines.", "- Keep file operations inside workspace unless explicit override is provided by workspace policy.", "- Keep actions practical and reproducible.", - "- For cron_add/cron_update, never infer a timezone from the user's language. If the user did not explicitly specify a timezone, use the workspace/user default timezone instead of inventing one.", + "- For cron_add/cron_update, never infer a timezone from the user's language. Set timezoneSource=explicit only when the user explicitly named a timezone. Set timezoneSource=default when using the workspace/user default timezone. If you are not confident the default timezone is intended, ask with request_user_input before calling cron_add/cron_update.", "", "## Device Ownership Model", "- Agent Phone: the Android device you control. It is a CLEAN/SHARED device with NO user personal data.", diff --git a/src/agent/tools.ts b/src/agent/tools.ts index d699887..e80f7cf 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -175,11 +175,20 @@ const cronScheduleSchema = Type.Object({ summaryText: Type.String({ description: "Human-readable schedule summary." }), }); +const cronTimezoneSourceSchema = Type.Union([ + Type.Literal("explicit"), + Type.Literal("default"), +], { + description: + "Whether the timezone came explicitly from the user or was defaulted from workspace/user profile settings.", +}); + export const cronAddSchema = Type.Object({ thought: ThoughtParam, id: Type.String({ description: "Unique cron job id." }), name: Type.String({ description: "Display name for the cron job." }), schedule: cronScheduleSchema, + timezoneSource: Type.Optional(cronTimezoneSourceSchema), task: Type.String({ description: "Natural-language task to run when the job fires." }), channel: Type.Optional(Type.String({ description: "Optional delivery channel." })), to: Type.Optional(Type.String({ description: "Optional delivery target/peer id." })), @@ -210,6 +219,7 @@ export const cronUpdateSchema = Type.Object({ enabled: Type.Optional(Type.Boolean({ description: "Enable or disable the job." })), task: Type.Optional(Type.String({ description: "Updated task text." })), schedule: Type.Optional(cronScheduleSchema), + timezoneSource: Type.Optional(cronTimezoneSourceSchema), channel: Type.Optional(Type.String({ description: "Updated delivery channel." })), to: Type.Optional(Type.String({ description: "Updated delivery target." })), model: Type.Optional(Type.String({ description: "Updated model override." })), diff --git a/src/gateway/chat-assistant.ts b/src/gateway/chat-assistant.ts index 426c64a..63136f9 100644 --- a/src/gateway/chat-assistant.ts +++ b/src/gateway/chat-assistant.ts @@ -2446,7 +2446,7 @@ export class ChatAssistant { const prompt = [ "Determine whether the user message asks to create a scheduled job, manage an existing scheduled job, or neither.", "Output strict JSON only:", - '{"route":"create_schedule|manage_schedule|none","task":"","manageIntent":{"action":"list|update|remove|enable|disable|unknown","selector":{"all":true|false,"ids":[""],"nameContains":[""],"taskContains":[""],"scheduleContains":[""],"enabled":"any|enabled|disabled"},"patch":{"name":"","task":"","enabled":true|false|null,"schedule":{"kind":"cron|at|every","expr":"","at":"","everyMs":number|null,"tz":"","summaryText":""}}},"schedule":{"kind":"cron|at|every","expr":"","at":"","everyMs":number|null,"tz":"","summaryText":""},"confidence":0-1,"reason":"..."}', + '{"route":"create_schedule|manage_schedule|none","task":"","manageIntent":{"action":"list|update|remove|enable|disable|unknown","selector":{"all":true|false,"ids":[""],"nameContains":[""],"taskContains":[""],"scheduleContains":[""],"enabled":"any|enabled|disabled"},"patch":{"name":"","task":"","enabled":true|false|null,"schedule":{"kind":"cron|at|every","expr":"","at":"","everyMs":number|null,"tz":"","timezoneSource":"explicit|default","summaryText":""}}},"schedule":{"kind":"cron|at|every","expr":"","at":"","everyMs":number|null,"tz":"","timezoneSource":"explicit|default","summaryText":""},"confidence":0-1,"reason":"..."}', "Rules:", "1) Return route=create_schedule for explicit or implicit requests to create a new recurring or one-shot scheduled task/reminder.", "2) Return route=manage_schedule for requests to inspect, list, modify, rename, enable, disable, delete, or otherwise manage an existing cron job or scheduled task.", @@ -2465,7 +2465,8 @@ export class ChatAssistant { "13) Use kind=at only for one-shot future schedules when you can provide an RFC3339 datetime.", "14) summaryText must be short and in the user's language when route=create_schedule.", "15) If the schedule is ambiguous or any required field is missing for route=create_schedule, return route=none instead of guessing.", - "16) If the user does not explicitly specify a timezone, leave schedule.tz empty or use the default timezone below. Never infer timezone from the user's language or script.", + '16) If the user explicitly specifies a timezone, set schedule.timezoneSource="explicit" and set schedule.tz to that timezone.', + '17) If the user does not explicitly specify a timezone, leave schedule.tz empty or use the default timezone below, and set schedule.timezoneSource="default". Never infer timezone from the user\'s language or script.', `User locale hint: ${locale}`, `Default timezone: ${defaultTimezone}`, this.buildExistingJobsCatalog(), diff --git a/src/gateway/gateway-core.ts b/src/gateway/gateway-core.ts index cb0686e..29b77dc 100644 --- a/src/gateway/gateway-core.ts +++ b/src/gateway/gateway-core.ts @@ -34,6 +34,7 @@ import { HumanAuthBridge } from "../human-auth/bridge.js"; import { LocalHumanAuthStack } from "../human-auth/local-stack.js"; import { readLatestTaskJournalSnapshot } from "../agent/journal/task-journal-store.js"; import { ChatAssistant } from "./chat-assistant.js"; +import { buildScheduleIntentConfirmationPrompt } from "./schedule-intent.js"; import { emptyCronManagementPatch, emptyCronManagementSelector, @@ -645,6 +646,18 @@ export class GatewayCore { return null; } + private parseScheduleConfirmationTimezone(text: string): string | null { + const normalized = text.trim().replace(/^["'`]+|["'`]+$/g, ""); + if (!normalized) { + return null; + } + try { + return new Intl.DateTimeFormat("en-US", { timeZone: normalized }).resolvedOptions().timeZone; + } catch { + return null; + } + } + private slugifyScheduleTask(task: string): string { const slug = task .toLowerCase() @@ -699,6 +712,7 @@ export class GatewayCore { id: `schedule-${Date.now()}-${this.slugifyScheduleTask(intent.normalizedTask)}`, name: this.buildScheduledJobName(intent), schedule: intent.schedule, + timezoneSource: intent.timezoneSource, task: intent.normalizedTask, channel: envelope.channelType, to: envelope.peerId, @@ -1103,9 +1117,33 @@ export class GatewayCore { return false; } const action = this.readScheduleConfirmationAction(text); - const locale = this.inferLocale(envelope); + const timezoneOverride = this.parseScheduleConfirmationTimezone(text); + if (!action && timezoneOverride) { + const updatedIntent: ScheduleIntent = { + ...pending.intent, + schedule: { + ...pending.intent.schedule, + tz: timezoneOverride, + }, + timezoneSource: "explicit", + confirmationPrompt: buildScheduleIntentConfirmationPrompt( + pending.intent.schedule.summaryText, + timezoneOverride, + pending.intent.normalizedTask, + "explicit", + ), + }; + this.pendingScheduleConfirmations.set(key, { + intent: updatedIntent, + createdAtMs: Date.now(), + }); + await this.router.replyText(envelope, this.sanitizeForChat(updatedIntent.confirmationPrompt, 1800)); + return true; + } if (!action) { - const reminder = 'You have a pending scheduled job. Reply with "confirm" or "cancel" first.'; + const reminder = pending.intent.timezoneSource === "default" + ? 'You have a pending scheduled job. Reply with "confirm", "cancel", or send a timezone like "America/Los_Angeles".' + : 'You have a pending scheduled job. Reply with "confirm" or "cancel" first.'; await this.router.replyText(envelope, reminder); return true; } diff --git a/src/gateway/schedule-intent.ts b/src/gateway/schedule-intent.ts index d6de5a9..1b04483 100644 --- a/src/gateway/schedule-intent.ts +++ b/src/gateway/schedule-intent.ts @@ -1,4 +1,4 @@ -import type { ScheduleIntent } from "../types.js"; +import type { CronTimezoneSource, ScheduleIntent } from "../types.js"; import { normalizeCronManagementIntent, type CronManagementAction, @@ -21,6 +21,11 @@ interface NormalizeScheduleIntentOptions { resolveTimezone?: () => string; } +type NormalizedScheduleValue = { + schedule: ScheduleIntent["schedule"]; + timezoneSource: CronTimezoneSource; +}; + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -67,10 +72,11 @@ export function normalizeScheduleIntentDecision( } const normalizedTask = normalizeOneLine(String(candidate.task ?? "")); - const schedule = normalizeSchedule(candidate.schedule, options); - if (!normalizedTask || !schedule) { + const normalizedSchedule = normalizeSchedule(candidate.schedule, options); + if (!normalizedTask || !normalizedSchedule) { return null; } + const { schedule, timezoneSource } = normalizedSchedule; const normalizedSourceText = normalizeOneLine(sourceText); @@ -80,9 +86,15 @@ export function normalizeScheduleIntentDecision( sourceText: normalizedSourceText, normalizedTask, schedule, + timezoneSource, delivery: null, requiresConfirmation: true, - confirmationPrompt: buildScheduleIntentConfirmationPrompt(schedule.summaryText, schedule.tz, normalizedTask), + confirmationPrompt: buildScheduleIntentConfirmationPrompt( + schedule.summaryText, + schedule.tz, + normalizedTask, + timezoneSource, + ), }, }; } @@ -96,14 +108,26 @@ export function normalizeScheduleIntentCandidate( return decision?.route === "create_schedule" ? decision.intent : null; } -function buildScheduleIntentConfirmationPrompt(summaryText: string, timezone: string, taskText: string): string { +export function buildScheduleIntentConfirmationPrompt( + summaryText: string, + timezone: string, + taskText: string, + timezoneSource: CronTimezoneSource = "explicit", +): string { + if (timezoneSource === "default") { + return [ + `I understand this as a scheduled job: ${summaryText} (${timezone}), task "${taskText}".`, + "I used your default timezone because you did not specify one.", + 'Reply "confirm" to create it, "cancel" to discard it, or send a different timezone such as "Asia/Shanghai".', + ].join(" "); + } return `I understand this as a scheduled job: ${summaryText} (${timezone}), task "${taskText}". Reply "confirm" to create it or "cancel" to discard it.`; } function normalizeSchedule( value: unknown, options: NormalizeScheduleIntentOptions, -): ScheduleIntent["schedule"] | null { +): NormalizedScheduleValue | null { if (!isObject(value)) { return null; } @@ -117,7 +141,13 @@ function normalizeSchedule( const at = normalizeOptionalString(value.at); const summaryText = normalizeOneLine(String(value.summaryText ?? "")); const everyMs = toFinitePositiveNumber(value.everyMs); - const tz = normalizeOptionalString(value.tz) ?? resolveScheduleTimezone(options); + const suppliedTimezone = normalizeOptionalString(value.tz); + const tz = suppliedTimezone ?? resolveScheduleTimezone(options); + const declaredTimezoneSource = normalizeTimezoneSource(value.timezoneSource); + const timezoneSource: CronTimezoneSource = + suppliedTimezone && declaredTimezoneSource === "explicit" + ? "explicit" + : "default"; if (!summaryText) { return null; @@ -133,12 +163,15 @@ function normalizeSchedule( } return { - kind, - expr: expr ?? null, - at: at ?? null, - everyMs: everyMs ?? null, - tz, - summaryText, + schedule: { + kind, + expr: expr ?? null, + at: at ?? null, + everyMs: everyMs ?? null, + tz, + summaryText, + }, + timezoneSource, }; } @@ -154,6 +187,10 @@ function normalizeOptionalString(value: unknown): string | null { return normalized || null; } +function normalizeTimezoneSource(value: unknown): CronTimezoneSource | null { + return value === "explicit" || value === "default" ? value : null; +} + function toFinitePositiveNumber(value: unknown): number | null { if (value === undefined || value === null || value === "") { return null; diff --git a/src/types.ts b/src/types.ts index d0e25d6..3e6fafa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -507,6 +507,7 @@ export type AgentAction = id: string; name: string; schedule: CronScheduleSpec; + timezoneSource?: CronTimezoneSource; task: string; channel?: string; to?: string; @@ -527,6 +528,7 @@ export type AgentAction = enabled?: boolean; task?: string; schedule?: CronScheduleSpec; + timezoneSource?: CronTimezoneSource; channel?: string; to?: string; model?: string; @@ -702,6 +704,8 @@ export interface CronScheduleSpec { summaryText: string; } +export type CronTimezoneSource = "explicit" | "default"; + export interface CronDeliveryTarget { mode: "announce"; channel: string; @@ -712,6 +716,7 @@ export interface ScheduleIntent { sourceText: string; normalizedTask: string; schedule: CronScheduleSpec; + timezoneSource: CronTimezoneSource; delivery?: CronDeliveryTarget | null; requiresConfirmation: boolean; confirmationPrompt: string; diff --git a/test/agent-runtime.test.mjs b/test/agent-runtime.test.mjs index e825fed..92b3e7d 100644 --- a/test/agent-runtime.test.mjs +++ b/test/agent-runtime.test.mjs @@ -557,6 +557,7 @@ test("AgentRuntime cron_add tool creates a structured cron job", async () => { tz: "Asia/Shanghai", summaryText: "Daily 08:00", }, + timezoneSource: "explicit", task: "Open Slack and complete check-in", channel: "telegram", to: "12345", @@ -595,6 +596,7 @@ test("AgentRuntime cron_add tool creates a structured cron job", async () => { assert.equal(created.payload.task, "Open Slack and complete check-in"); assert.equal(created.delivery.channel, "telegram"); assert.equal(created.delivery.to, "12345"); + assert.equal(created.schedule.tz, "Asia/Shanghai"); }); test("AgentRuntime cron_add inherits channel metadata from the active run context", async () => { @@ -615,6 +617,7 @@ test("AgentRuntime cron_add inherits channel metadata from the active run contex tz: "Asia/Shanghai", summaryText: "Daily 08:00", }, + timezoneSource: "default", task: "Open Slack and complete check-in", promptMode: "minimal", }, @@ -687,6 +690,7 @@ test("AgentRuntime preserves an explicitly requested timezone for cron_add", asy tz: "Asia/Shanghai", summaryText: "Daily 08:00", }, + timezoneSource: "explicit", task: "Open Slack and complete check-in", promptMode: "minimal", }, @@ -737,6 +741,73 @@ test("AgentRuntime preserves an explicitly requested timezone for cron_add", asy assert.equal(created.schedule.tz, "Asia/Shanghai"); }); +test("AgentRuntime ignores blank USER.md timezone lines when defaulting cron timezone", async () => { + const runtime = setupRuntime({ + returnHomeOnTaskEnd: false, + scriptedSteps: [ + { + thought: "create cron job", + action: { + type: "cron_add", + id: "daily-slack-checkin-fallback", + name: "Daily Slack Check-in Fallback", + schedule: { + kind: "cron", + expr: "0 8 * * *", + at: null, + everyMs: null, + tz: "Asia/Shanghai", + summaryText: "Daily 08:00", + }, + timezoneSource: "default", + task: "Open Slack and complete check-in", + promptMode: "minimal", + }, + }, + { thought: "done", action: { type: "finish", message: "task completed" } }, + ], + }); + + runtime.adb = { + queryLaunchablePackages: () => [], + captureScreenSnapshot: () => makeSnapshot(), + executeAction: async () => "ok", + }; + fs.writeFileSync( + path.join(runtime.config.workspaceDir, "USER.md"), + [ + "# USER", + "", + "## Profile", + "", + "- Timezone:", + "- Language preference: English", + ].join("\n"), + "utf-8", + ); + + const result = await runtime.runTask( + "create cron job with fallback timezone", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ["cron_add", "finish"], + ); + assert.equal(result.ok, true); + + const jobsFile = path.join(runtime.config.workspaceDir, "cron", "jobs.json"); + const saved = JSON.parse(fs.readFileSync(jobsFile, "utf-8")); + const created = saved.jobs.find((job) => job.id === "daily-slack-checkin-fallback"); + assert.equal(Boolean(created), true); + assert.equal(created.schedule.tz, Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"); +}); + test("AgentRuntime system prompt includes task journal guidance", async () => { let capturedSystemPrompt = ""; const runtime = setupRuntime({ diff --git a/test/channel-gateway-core.test.mjs b/test/channel-gateway-core.test.mjs index d2af97e..eb60522 100644 --- a/test/channel-gateway-core.test.mjs +++ b/test/channel-gateway-core.test.mjs @@ -510,6 +510,7 @@ test("GatewayCore handlePlainMessage replies with confirmation for schedule inte tz: "Asia/Shanghai", summaryText: "Daily 08:00", }, + timezoneSource: "explicit", delivery: null, requiresConfirmation: true, confirmationPrompt: "Please confirm creating this scheduled job.", @@ -554,6 +555,7 @@ test("GatewayCore creates a structured cron job after confirmation", async () => tz: "Asia/Shanghai", summaryText: "Daily 08:00", }, + timezoneSource: "explicit", delivery: null, requiresConfirmation: true, confirmationPrompt: "Please confirm creating this scheduled job.", @@ -638,6 +640,7 @@ test("GatewayCore uses a restricted cron setup run after confirmation", async () tz: "Asia/Shanghai", summaryText: "Daily 08:00", }, + timezoneSource: "explicit", delivery: null, requiresConfirmation: true, confirmationPrompt: "Please confirm creating this scheduled job.", @@ -703,6 +706,64 @@ test("GatewayCore uses a restricted cron setup run after confirmation", async () }); }); +test("GatewayCore lets the user override a defaulted schedule timezone before confirmation", async () => { + await withTempHome("gwcore-schedule-timezone-override-", async (home) => { + const { adapter, core } = createGatewayCore(home); + let capturedTask = ""; + + core.chat.decide = async () => ({ + mode: "schedule_intent", + task: "Open Slack and complete check-in", + reply: [ + 'I understand this as a scheduled job: Daily 08:00 (America/Los_Angeles), task "Open Slack and complete check-in".', + "I used your default timezone because you did not specify one.", + 'Reply "confirm" to create it, "cancel" to discard it, or send a different timezone such as "Asia/Shanghai".', + ].join(" "), + confidence: 0.99, + reason: "schedule_intent:cron", + scheduleIntent: { + sourceText: "Every morning at 8, open Slack and complete check-in", + normalizedTask: "Open Slack and complete check-in", + schedule: { + kind: "cron", + expr: "0 8 * * *", + at: null, + everyMs: null, + tz: "America/Los_Angeles", + summaryText: "Daily 08:00", + }, + timezoneSource: "default", + delivery: null, + requiresConfirmation: true, + confirmationPrompt: [ + 'I understand this as a scheduled job: Daily 08:00 (America/Los_Angeles), task "Open Slack and complete check-in".', + "I used your default timezone because you did not specify one.", + 'Reply "confirm" to create it, "cancel" to discard it, or send a different timezone such as "Asia/Shanghai".', + ].join(" "), + }, + }); + core.agent.runTask = async (task) => { + capturedTask = task; + return { + ok: true, + message: "created", + sessionPath: "/tmp/cron-setup.jsonl", + skillPath: null, + scriptPath: null, + }; + }; + + await core.handleInbound(makeEnvelope({ text: "Every morning at 8, open Slack and complete check-in" })); + await core.handleInbound(makeEnvelope({ text: "Europe/London" })); + await core.handleInbound(makeEnvelope({ text: "confirm" })); + + assert.match(adapter.sent[1].text, /Europe\/London/); + assert.match(adapter.sent[1].text, /confirm/i); + assert.match(capturedTask, /"tz": "Europe\/London"/); + assert.match(capturedTask, /"timezoneSource": "explicit"/); + }); +}); + test("GatewayCore runs scheduled jobs headlessly when no reply target is stored", async () => { await withTempHome("gwcore-cron-headless-", async (home) => { const { adapter, core } = createGatewayCore(home); @@ -1781,6 +1842,7 @@ test("GatewayCore cancels pending schedule confirmation without creating a job", tz: "Asia/Shanghai", summaryText: "Daily 08:00", }, + timezoneSource: "explicit", delivery: null, requiresConfirmation: true, confirmationPrompt: "Please confirm creating this scheduled job.", @@ -1825,6 +1887,7 @@ test("GatewayCore blocks unrelated messages while schedule confirmation is pendi tz: "Asia/Shanghai", summaryText: "Daily 08:00", }, + timezoneSource: "explicit", delivery: null, requiresConfirmation: true, confirmationPrompt: "Please confirm creating this scheduled job.", diff --git a/test/schedule-intent.test.mjs b/test/schedule-intent.test.mjs index 62c5867..767ccb0 100644 --- a/test/schedule-intent.test.mjs +++ b/test/schedule-intent.test.mjs @@ -29,10 +29,33 @@ test("schedule intent normalization builds confirmation from model output", () = assert.equal(intent?.schedule.kind, "cron"); assert.equal(intent?.schedule.expr, "0 8 * * *"); assert.equal(intent?.schedule.summaryText, "Every day at 08:00"); + assert.equal(intent?.timezoneSource, "default"); assert.match(intent?.confirmationPrompt ?? "", /Asia\/Shanghai/); + assert.match(intent?.confirmationPrompt ?? "", /default timezone/i); assert.match(intent?.confirmationPrompt ?? "", /confirm/i); }); +test("schedule intent normalization preserves explicit timezone metadata from model output", () => { + const intent = normalizeScheduleIntentCandidate("Open Slack tomorrow at 8am Shanghai time", { + route: "create_schedule", + task: "Open Slack", + schedule: { + kind: "at", + at: "2026-03-08T08:00:00+08:00", + tz: "Asia/Shanghai", + timezoneSource: "explicit", + summaryText: "Tomorrow at 08:00", + }, + }, { + timezone: "America/Los_Angeles", + }); + + assert.ok(intent); + assert.equal(intent?.schedule.tz, "Asia/Shanghai"); + assert.equal(intent?.timezoneSource, "explicit"); + assert.doesNotMatch(intent?.confirmationPrompt ?? "", /default timezone/i); +}); + test("schedule intent normalization requires RFC3339 at value for one-shot schedules", () => { const invalid = normalizeScheduleIntentCandidate("Open Slack tomorrow at 8am", { isScheduleIntent: true,