diff --git a/backend/src/timeflow/gateway/websocket/agent_ports.py b/backend/src/timeflow/gateway/websocket/agent_ports.py index c47e7aef..d44812c3 100644 --- a/backend/src/timeflow/gateway/websocket/agent_ports.py +++ b/backend/src/timeflow/gateway/websocket/agent_ports.py @@ -153,6 +153,11 @@ def schedules(self) -> list[dict[str, Any]] | None: """Matches a query found, when the command was a query.""" ... + @property + def occurrence_overrides(self) -> list[dict[str, Any]] | None: + """Per-occurrence exceptions (cancel/replace) a recurring-schedule mutation produced.""" + ... + class AudioReplyInfo(Protocol): """Format and purpose of a spoken reply, announced before its audio.""" diff --git a/backend/src/timeflow/gateway/websocket/handlers/agent_result.py b/backend/src/timeflow/gateway/websocket/handlers/agent_result.py index 2bcfa5ff..6b669f61 100644 --- a/backend/src/timeflow/gateway/websocket/handlers/agent_result.py +++ b/backend/src/timeflow/gateway/websocket/handlers/agent_result.py @@ -91,6 +91,7 @@ async def deliver_result(self, result: CommandOutcome, stream: StreamIdentity) - status=result.status, schedule=result.schedule, schedules=result.schedules, + occurrence_overrides=result.occurrence_overrides, ), ) await self._send(stream.session_id, message.type, message.model_dump(exclude_none=True)) diff --git a/backend/src/timeflow/gateway/websocket/messages/agent.py b/backend/src/timeflow/gateway/websocket/messages/agent.py index c7ccd654..a2c706d4 100644 --- a/backend/src/timeflow/gateway/websocket/messages/agent.py +++ b/backend/src/timeflow/gateway/websocket/messages/agent.py @@ -29,6 +29,7 @@ class VoiceCommandResultPayload(BaseModel): status: str schedule: dict[str, Any] | None = None schedules: list[dict[str, Any]] | None = None + occurrence_overrides: list[dict[str, Any]] | None = None class VoiceCommandResult(BaseModel): diff --git a/backend/src/timeflow/intelligence/ports.py b/backend/src/timeflow/intelligence/ports.py index 18d3b734..149c18de 100644 --- a/backend/src/timeflow/intelligence/ports.py +++ b/backend/src/timeflow/intelligence/ports.py @@ -108,6 +108,7 @@ class CommandResult: status: str schedule: dict[str, Any] | None = None schedules: list[dict[str, Any]] | None = None + occurrence_overrides: list[dict[str, Any]] | None = None @dataclass(frozen=True, slots=True) diff --git a/backend/src/timeflow/intelligence/realtime/agent.py b/backend/src/timeflow/intelligence/realtime/agent.py index 132eb051..5f9cdcaf 100644 --- a/backend/src/timeflow/intelligence/realtime/agent.py +++ b/backend/src/timeflow/intelligence/realtime/agent.py @@ -399,6 +399,7 @@ async def tool_requested(self, call_id: str, name: str, arguments: dict[str, Any status=str(outcome["status"]), schedule=outcome.get("schedule"), schedules=outcome.get("schedules"), + occurrence_overrides=outcome.get("occurrence_overrides"), ), self._stream, ) diff --git a/backend/src/timeflow/intelligence/realtime/schedule_tools.py b/backend/src/timeflow/intelligence/realtime/schedule_tools.py index 0b2a7db0..148c8559 100644 --- a/backend/src/timeflow/intelligence/realtime/schedule_tools.py +++ b/backend/src/timeflow/intelligence/realtime/schedule_tools.py @@ -463,6 +463,8 @@ def _business_error(error: ScheduleBusinessError) -> ToolResult: def _mutation_result(result: ScheduleMutationResult, operation: str, tz: ZoneInfo) -> ToolResult: """Convert a mutation result into model output and client outcome.""" snapshot = result.schedules[0] if result.schedules else None + schedules = [_snapshot_for_client(s) for s in result.schedules] + overrides = [_override_for_client(o) for o in result.occurrence_overrides] return ToolResult( output=json.dumps( {"status": "applied", "schedule": _for_model_dict(snapshot, tz)}, ensure_ascii=False @@ -471,6 +473,8 @@ def _mutation_result(result: ScheduleMutationResult, operation: str, tz: ZoneInf "operation": operation, "status": "applied", "schedule": _snapshot_for_client(snapshot) if snapshot else None, + "schedules": schedules or None, + "occurrence_overrides": overrides or None, }, ) @@ -500,6 +504,12 @@ def _snapshot_for_client(snapshot: Any) -> dict[str, Any]: } +def _override_for_client(override: Any) -> dict[str, Any]: + """Convert ScheduleOccurrenceOverrideSnapshot to client dict, filtering out audit fields.""" + d = asdict(override) + return {k: _json_value(v) for k, v in d.items() if k not in {"created_at", "updated_at"}} + + def _local_text(instant: datetime | None, tz: ZoneInfo) -> str: """Render a stored instant as local wall-clock text, empty for a schedule without one.""" if instant is None: diff --git a/backend/tests/intelligence/realtime/test_realtime_toolbox.py b/backend/tests/intelligence/realtime/test_realtime_toolbox.py index 73bd11f4..96493174 100644 --- a/backend/tests/intelligence/realtime/test_realtime_toolbox.py +++ b/backend/tests/intelligence/realtime/test_realtime_toolbox.py @@ -11,11 +11,13 @@ import pytest from timeflow.business.calendar import ( + OccurrenceOverrideAction, ScheduleAgentService, ScheduleBusinessError, ScheduleErrorCode, ScheduleKind, ScheduleMutationResult, + ScheduleOccurrenceOverrideSnapshot, ScheduleSearchResult, ScheduleSnapshot, ScheduleStatus, @@ -305,6 +307,78 @@ def test_a_delete_reaches_the_call_that_matches_the_kind( assert service.calls == [expected] +OVERRIDE = ScheduleOccurrenceOverrideSnapshot( + id="ovr_1", + schedule_id="sch_1", + occurrence_start=datetime(2026, 9, 8, 7, 0, tzinfo=UTC), + action=OccurrenceOverrideAction.CANCEL, + created_at=datetime(2026, 9, 7, 1, 0, tzinfo=UTC), + updated_at=datetime(2026, 9, 7, 1, 0, tzinfo=UTC), +) + + +class OverrideProducingService(RecordingService): + """A this_occurrence delete that cancels rather than replaces: no new schedule, one override.""" + + def delete_recurring_schedule(self, *, account_id: str, command: Any) -> ScheduleMutationResult: + self.calls.append("delete_recurring") + return ScheduleMutationResult(schedules=(), occurrence_overrides=(OVERRIDE,)) + + +def test_a_this_occurrence_delete_reports_the_override_it_produced() -> None: + result = run( + "schedule_delete", + { + "schedule_id": "sch_1", + "expected_revision": 1, + "schedule_kind": "recurring", + "scope": "this_occurrence", + }, + ToolBox("acc_test", OverrideProducingService()), + ) + assert result.outcome is not None + assert result.outcome["schedule"] is None + assert result.outcome["occurrence_overrides"] == [ + { + "id": "ovr_1", + "schedule_id": "sch_1", + "occurrence_start": "2026-09-08T07:00:00+00:00", + "action": "cancel", + "replacement_schedule_id": None, + } + ] + + +class MultiScheduleProducingService(RecordingService): + """A this_occurrence delete hitting an existing replace override: no new override, + but two schedules — the untouched parent and the soft-deleted replacement.""" + + def delete_recurring_schedule(self, *, account_id: str, command: Any) -> ScheduleMutationResult: + self.calls.append("delete_recurring") + replacement = replace(SNAPSHOT, id="sch_replacement", status=ScheduleStatus.DELETED) + return ScheduleMutationResult(schedules=(SNAPSHOT, replacement)) + + +def test_a_this_occurrence_delete_with_existing_replacement_reports_both_schedules() -> None: + result = run( + "schedule_delete", + { + "schedule_id": "sch_1", + "expected_revision": 1, + "schedule_kind": "recurring", + "scope": "this_occurrence", + }, + ToolBox("acc_test", MultiScheduleProducingService()), + ) + assert result.outcome is not None + # schedule(单数)保持只给第一条,向后兼容 + assert result.outcome["schedule"]["id"] == "sch_1" + # schedules(复数)必须两条都在,这是这次要修的东西——之前只有 schedules[0] 会下发, + # 软删除的 replacement 永远到不了客户端 + ids = [s["id"] for s in result.outcome["schedules"]] + assert ids == ["sch_1", "sch_replacement"] + + def test_a_delete_with_nothing_left_to_report_still_says_it_applied() -> None: result = run( "schedule_delete", diff --git a/frontend/src/contracts/conversation.ts b/frontend/src/contracts/conversation.ts index 625039d7..09690f0c 100644 --- a/frontend/src/contracts/conversation.ts +++ b/frontend/src/contracts/conversation.ts @@ -64,6 +64,14 @@ export interface VoiceAsrCompletedMessage { /** 日程快照的形状由后端 `ScheduleSnapshot` 决定;前端只透传给本地落库,不在此处强约束字段。 */ export type ScheduleSnapshotPayload = Record; +export interface OccurrenceOverridePayload { + id: string; + schedule_id: string; + occurrence_start: string; + action: 'cancel' | 'replace'; + replacement_schedule_id: string | null; +} + export interface VoiceCommandResultMessage { type: 'voice.command.result'; message_id: string; @@ -74,6 +82,7 @@ export interface VoiceCommandResultMessage { status: string; schedule?: ScheduleSnapshotPayload; schedules?: ScheduleSnapshotPayload[]; + occurrence_overrides?: OccurrenceOverridePayload[]; }; } diff --git a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts index 850b2351..8ea9beac 100644 --- a/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantContinuousConversationService.ts @@ -342,6 +342,7 @@ export class AssistantContinuousConversationService implements AssistantApplicat operation: message.payload.operation, schedule: message.payload.schedule, schedules: message.payload.schedules, + occurrence_overrides: message.payload.occurrence_overrides, status: message.payload.status, }; // 状态立刻回到 listening(麦克风还开着),不等写库;message.ack 必须等 diff --git a/frontend/src/features/assistant/application/AssistantConversationService.ts b/frontend/src/features/assistant/application/AssistantConversationService.ts index 3aa21790..7aacaf73 100644 --- a/frontend/src/features/assistant/application/AssistantConversationService.ts +++ b/frontend/src/features/assistant/application/AssistantConversationService.ts @@ -224,6 +224,7 @@ export class AssistantConversationService implements AssistantApplicationPort { operation: message.payload.operation, schedule: message.payload.schedule, schedules: message.payload.schedules, + occurrence_overrides: message.payload.occurrence_overrides, status: message.payload.status, }; // 状态立刻回到 idle,不等写库;message.ack 必须等写库成功才发(AGENTS.md §6)。 diff --git a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts index b8b432ef..9fa96c20 100644 --- a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts +++ b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts @@ -1,35 +1,64 @@ -import type { CloudScheduleRow, ScheduleLocalRepository } from '../../../schedule/data'; +import type { + CloudScheduleRow, + LocalScheduleOccurrenceOverrideRow, + ScheduleLocalRepository, +} from '../../../schedule/data'; import type { LocalScheduleWriterPort } from '../../application/interfaces/LocalScheduleWriterPort'; -import type { AppliedCommand } from '../../domain/ConversationTurn'; +import type { AppliedCommand, AppliedOccurrenceOverride } from '../../domain/ConversationTurn'; /** * `applyCloudSchedule` 是仓储里现成的 upsert,专门给"服务端权威数据落到本地"用的 * (固定写 sync_status='synced'),voice.command.result 正好是这种场景——服务端 * 已经提交过了,这里只是把它同步进本地日历读服务能看到的地方。 * - * 不按 operation 名字判断该不该写:后端 create_schedule/update_schedule/ + * 用 `operation` 显式排除 list_schedules:后端 create_schedule/update_schedule/ * delete_schedule 三个操作共用同一条 `_mutation_result` 路径 - * (backend/src/timeflow/intelligence/realtime/schedule_tools.py:384), - * `schedule` 字段结构完全一样——删除是软删除,`status` 会变成 'deleted', + * (backend/src/timeflow/intelligence/realtime/schedule_tools.py:463), + * `list_schedules`(查询,见同文件 `_find`)复用同一个 `schedules` 复数字段填 + * outcome——不能靠"字段是否存在"这种隐式判断排除它,query 结果不是已提交的写入, + * 不能当成本地权威数据 upsert。删除是软删除,`status` 会变成 'deleted', * `applyCloudSchedule` 照样按 id upsert,本地这行的 status 也跟着变成 - * 'deleted',日历读服务只认 status==='active' 的行,自然就不显示了。只要 - * `schedule` 字段存在就同步,这样天然排除 list_schedules(只有 `schedules` - * 复数字段,没有 `schedule`)。 + * 'deleted',日历读服务只认 status==='active' 的行,自然就不显示了。 * - * 已知没覆盖的情况:重复日程"仅删除这一次"且不需要截断 RRULE 时 - * (service.py 的 _delete_recurring_range 里创建 occurrence_override 那条 - * 分支),后端只把 override 记到 ScheduleMutationResult.occurrence_overrides - * 里,`voice.command.result` 完全不下发这个字段——这种情况本地日历目前没法 - * 同步,得等后端把 override 数据也传下来。 + * 重复日程"仅删除这一次"命中已存在的 replace override 时(service.py 的 + * _delete_recurring_range),不产生新 override,而是让 `schedules` 里带上被 + * 连带软删的 replacement——`schedules` 复数字段是"这次命令落地的全部 schedule + * 快照",不是只服务查询场景;`occurrence_overrides` 跟 `schedules` 各自独立, + * 一条 command.result 里两者都可能有、也可能都没有。 + * + * 一条命令可能同时产生多条 schedule 写入和多条 override 写入,合起来才代表这条 + * 语音指令完整落地,所以全部包在一个事务里:任何一次写入失败(仓储返回 false, + * 比如父日程缺失、账号不匹配)都会抛错并回滚整个事务,调用方据此不发 + * message.ack,不会向服务端谎报已落库、也不会在本地留下半吊子状态。 */ export class LocalScheduleWriter implements LocalScheduleWriterPort { public constructor(private readonly repository: ScheduleLocalRepository) {} public async applyCommandResult(accountId: string, command: AppliedCommand): Promise { - if (command.status !== 'applied' || !command.schedule) { + if (command.status !== 'applied' || command.operation === 'list_schedules') { + return; + } + const schedules = command.schedules ?? (command.schedule ? [command.schedule] : []); + if (schedules.length === 0 && !command.occurrence_overrides?.length) { return; } - await this.repository.applyCloudSchedule(toCloudScheduleRow(accountId, command.schedule)); + await this.repository.withTransaction(async (repository) => { + for (const raw of schedules) { + const applied = await repository.applyCloudSchedule(toCloudScheduleRow(accountId, raw)); + if (!applied) { + throw new Error(`Could not apply cloud schedule ${String(raw.id)}`); + } + } + for (const override of command.occurrence_overrides ?? []) { + const applied = await repository.upsertOccurrenceOverride( + accountId, + toOverrideRow(override), + ); + if (!applied) { + throw new Error(`Could not apply occurrence override ${override.id}`); + } + } + }); } } @@ -70,6 +99,16 @@ function toCloudScheduleRow(accountId: string, raw: Record): Cl }; } +function toOverrideRow(override: AppliedOccurrenceOverride): LocalScheduleOccurrenceOverrideRow { + return { + id: override.id, + schedule_id: override.schedule_id, + occurrence_start: override.occurrence_start, + action: override.action, + replacement_schedule_id: override.replacement_schedule_id, + }; +} + function requireString(value: unknown, field: string): string { if (typeof value !== 'string' || value.length === 0) { throw new TypeError(`command.result.schedule.${field} must be a non-empty string`); diff --git a/frontend/src/features/assistant/domain/ConversationTurn.ts b/frontend/src/features/assistant/domain/ConversationTurn.ts index c6632573..774742dd 100644 --- a/frontend/src/features/assistant/domain/ConversationTurn.ts +++ b/frontend/src/features/assistant/domain/ConversationTurn.ts @@ -18,9 +18,18 @@ export type ConversationTurnState = | { phase: 'paused'; conversationId: string | null } | { phase: 'error'; message: string }; +export interface AppliedOccurrenceOverride { + id: string; + schedule_id: string; + occurrence_start: string; + action: 'cancel' | 'replace'; + replacement_schedule_id: string | null; +} + export interface AppliedCommand { operation: string; status: string; schedule?: Record; schedules?: Record[]; + occurrence_overrides?: AppliedOccurrenceOverride[]; } diff --git a/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts b/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts index 7d189c2b..5d93c93f 100644 --- a/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts +++ b/frontend/src/features/schedule/data/local/scheduleLocalRepository.ts @@ -89,6 +89,20 @@ export class ScheduleLocalRepository { ); } + /** + * 一批相关写入要么全部生效、要么全部不生效——调用方在 task 里用参数给的 repository + * (绑定在事务连接上)做写入,抛错会让整个事务回滚。 + */ + public async withTransaction( + task: (repository: ScheduleLocalRepository) => Promise, + ): Promise { + let result!: T; + await this.database.withExclusiveTransactionAsync(async (transaction) => { + result = await task(new ScheduleLocalRepository(transaction)); + }); + return result; + } + /** Apply cloud-owned fields while preserving existing device runtime state. */ public async applyCloudSchedule(row: CloudScheduleRow): Promise { const result = await this.database.runAsync( diff --git a/frontend/tests/integration/localScheduleWriter.test.ts b/frontend/tests/integration/localScheduleWriter.test.ts new file mode 100644 index 00000000..4e07dd58 --- /dev/null +++ b/frontend/tests/integration/localScheduleWriter.test.ts @@ -0,0 +1,169 @@ +import initSqlJs, { type SqlJsStatic } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { LocalScheduleWriter } from '../../src/features/assistant/data/local/LocalScheduleWriter'; +import type { AppliedCommand } from '../../src/features/assistant/domain/ConversationTurn'; +import { ScheduleLocalRepository } from '../../src/features/schedule/data'; +import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; +import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; + +function appliedCommand(overrides: Partial = {}): AppliedCommand { + return { + operation: 'create_schedule', + status: 'applied', + schedule: { + id: 'schedule-a', + schedule_type: 'time', + schedule_kind: 'once', + title: 'Team sync', + is_all_day: false, + timezone: 'Asia/Shanghai', + start_time: '2026-08-12T07:00:00Z', + revision: 1, + }, + ...overrides, + }; +} + +describe('LocalScheduleWriter', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + }); + + afterEach(() => { + database.close(); + }); + + it('writes the schedule when the command carries one', async () => { + const writer = new LocalScheduleWriter(repository); + await writer.applyCommandResult('account-a', appliedCommand()); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored?.title).toBe('Team sync'); + }); + + it('rejects a schedule payload missing a required field', async () => { + const writer = new LocalScheduleWriter(repository); + const command = appliedCommand({ schedule: { id: 'schedule-a' } }); + + await expect(writer.applyCommandResult('account-a', command)).rejects.toThrow( + 'command.result.schedule.schedule_type must be a non-empty string', + ); + }); + + it('does not write when the command was not applied', async () => { + const writer = new LocalScheduleWriter(repository); + await writer.applyCommandResult('account-a', appliedCommand({ status: 'rejected' })); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored).toBeNull(); + }); + + it('writes an occurrence override even when the command carries no schedule', async () => { + const writer = new LocalScheduleWriter(repository); + // 先建好日程,delete_this_occurrence 只回一条 override,不带 schedule 快照。 + await writer.applyCommandResult('account-a', appliedCommand()); + + await writer.applyCommandResult( + 'account-a', + appliedCommand({ + operation: 'delete_schedule', + schedule: undefined, + occurrence_overrides: [ + { + id: 'override-a', + schedule_id: 'schedule-a', + occurrence_start: '2026-08-19T07:00:00Z', + action: 'cancel', + replacement_schedule_id: null, + }, + ], + }), + ); + + const overrides = await repository.listOccurrenceOverrides('account-a', 'schedule-a'); + expect(overrides).toEqual([ + { + id: 'override-a', + schedule_id: 'schedule-a', + occurrence_start: '2026-08-19T07:00:00Z', + action: 'cancel', + replacement_schedule_id: null, + }, + ]); + }); + + it('does nothing when the command has neither a schedule nor overrides', async () => { + const writer = new LocalScheduleWriter(repository); + await writer.applyCommandResult( + 'account-a', + appliedCommand({ schedule: undefined, occurrence_overrides: undefined }), + ); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored).toBeNull(); + }); + + it('writes every schedule when the command carries the plural field', async () => { + const writer = new LocalScheduleWriter(repository); + await writer.applyCommandResult( + 'account-a', + appliedCommand({ + schedule: undefined, + schedules: [ + appliedCommand().schedule!, + { ...appliedCommand().schedule!, id: 'schedule-b', title: 'Follow-up' }, + ], + }), + ); + + expect((await repository.getSchedule('account-a', 'schedule-a'))?.title).toBe('Team sync'); + expect((await repository.getSchedule('account-a', 'schedule-b'))?.title).toBe('Follow-up'); + }); + + it('does not write a list_schedules query result even if it carries schedules', async () => { + const writer = new LocalScheduleWriter(repository); + await writer.applyCommandResult( + 'account-a', + appliedCommand({ + operation: 'list_schedules', + schedule: undefined, + schedules: [appliedCommand().schedule!], + }), + ); + + expect(await repository.getSchedule('account-a', 'schedule-a')).toBeNull(); + }); + + it('rolls back the schedule write when a later override write fails', async () => { + const writer = new LocalScheduleWriter(repository); + const command = appliedCommand({ + occurrence_overrides: [ + { + id: 'override-a', + // upsertOccurrenceOverride 找不到这个 schedule_id 对应的本地日程,返回 false。 + schedule_id: 'schedule-does-not-exist', + occurrence_start: '2026-08-19T07:00:00Z', + action: 'cancel', + replacement_schedule_id: null, + }, + ], + }); + + await expect(writer.applyCommandResult('account-a', command)).rejects.toThrow( + 'Could not apply occurrence override override-a', + ); + // schedule 那一半本来会写成功,但事务应该把它也回滚掉,不留半吊子状态。 + expect(await repository.getSchedule('account-a', 'schedule-a')).toBeNull(); + }); +}); diff --git a/frontend/tests/scheduleStorageContracts.test-d.ts b/frontend/tests/scheduleStorageContracts.test-d.ts index d4f8d042..ef288104 100644 --- a/frontend/tests/scheduleStorageContracts.test-d.ts +++ b/frontend/tests/scheduleStorageContracts.test-d.ts @@ -106,6 +106,7 @@ export type LocalRepositoryOperationsContract = Assert< keyof ScheduleLocalRepository, | 'getSchedule' | 'listSchedules' + | 'withTransaction' | 'applyCloudSchedule' | 'updateReminderRuntime' | 'purgeSchedule'