From 9bf7ea65651d3342264d028227b0e5a88bd032e7 Mon Sep 17 00:00:00 2001 From: Kaito Date: Fri, 15 May 2026 22:37:11 +0700 Subject: [PATCH] fix(api): preserve stored summary freshness --- agent-skill/SKILL.md | 4 +- apps/web/app/api/agent/tasks/route.ts | 2 +- apps/web/app/api/openapi/route.ts | 2 +- apps/web/lib/api/task-read-markers.test.ts | 44 ++++++++++++++++++++++ apps/web/lib/api/task-read-markers.ts | 9 ++--- package.json | 1 + 6 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 apps/web/lib/api/task-read-markers.test.ts diff --git a/agent-skill/SKILL.md b/agent-skill/SKILL.md index 781da3a..d2de5d2 100644 --- a/agent-skill/SKILL.md +++ b/agent-skill/SKILL.md @@ -51,7 +51,7 @@ Use `Content-Type: application/json` on requests with JSON bodies. Tasks can include coordination fields in addition to the work instructions: - `note`: optional string or `null`. Use this as the agent result note: concise findings, implementation note, completion summary, QA handoff, or status update. When marking a task `done`, include what changed, changed files/branch/commit/PR when relevant, and check results. Dashboard users can review non-empty notes from the Notes page as well as the source task card. -- `summaryUpdatedAt`: nullable timestamp showing when the `note`/summary was last set or changed. Treat it as freshness metadata for review/read-marker decisions. +- `summaryUpdatedAt`: nullable stored timestamp showing when the `note`/summary content was last set or changed. It is `null` when `note` is `null`, remains unchanged when a note is omitted or submitted unchanged, updates when note content changes to a non-empty value, and clears when a note is blanked/cleared. Treat it as freshness metadata for review/read-marker decisions; use `taskUpdated*` fields and AuditLog entries for actor auditability. - `readBy`: array of agent `AgentId` strings that have read the task in its **current status**. The underlying read tracking is per task, per agent, and per status. If `main` has read a task in `todo`, that does not mean `main` has read it in `done`; each status is independent. - `blockingReason`: optional string or `null`, used when `status` is `blocked`. - `dependencyIds`: array of dependency task database UUIDs returned on task responses. @@ -603,7 +603,7 @@ Body fields: - `name`: optional non-empty string; trimmed. - `job`: optional non-empty string; trimmed. - `status`: optional; one of `todo`, `inprogress`, `done`, `blocked`. -- `note`: optional string or `null`; trimmed; blank or `null` is stored as `null`. Changed notes update `summaryUpdatedAt`; unchanged or omitted notes preserve it. +- `note`: optional string or `null`; trimmed; blank or `null` is stored as `null` and clears `summaryUpdatedAt`. Changed non-empty notes update `summaryUpdatedAt`; unchanged or omitted notes preserve it. - `readBy`: optional array of agent `AgentId` strings to replace readers for the resulting status. Omit when changing `status` or `note` unless intentionally marking the result already reviewed. - `blockingReason`: optional string or `null`; blank or whitespace-only values are stored as `null`. - At least one field is required. diff --git a/apps/web/app/api/agent/tasks/route.ts b/apps/web/app/api/agent/tasks/route.ts index 4445079..8b23b33 100644 --- a/apps/web/app/api/agent/tasks/route.ts +++ b/apps/web/app/api/agent/tasks/route.ts @@ -174,7 +174,7 @@ export async function GET(request: NextRequest) { * note: * type: string * nullable: true - * description: Result note or done summary. Trimmed; blank values are stored as null and leave summaryUpdatedAt null. + * description: Result note or done summary. Trimmed; non-empty values set summaryUpdatedAt and blank values are stored as null with summaryUpdatedAt null. * readBy: * type: array * items: diff --git a/apps/web/app/api/openapi/route.ts b/apps/web/app/api/openapi/route.ts index a28335d..18c76ca 100644 --- a/apps/web/app/api/openapi/route.ts +++ b/apps/web/app/api/openapi/route.ts @@ -136,7 +136,7 @@ const openApiDocument = swaggerJsdoc({ format: "date-time", nullable: true, description: - "Timestamp for the latest stored note/summary change. Null when note is null; unchanged when note is omitted or submitted unchanged; updated when note content changes; cleared when note is blanked.", + "Stored timestamp for the latest note/summary content change. Null when note is null or when no summary timestamp has been stored; unchanged when note is omitted or submitted unchanged; updated when note content changes; cleared when note is blanked. Actor auditability comes from taskUpdated* fields and AuditLog entries.", }, readBy: { type: "array", diff --git a/apps/web/lib/api/task-read-markers.test.ts b/apps/web/lib/api/task-read-markers.test.ts new file mode 100644 index 0000000..e9f81ae --- /dev/null +++ b/apps/web/lib/api/task-read-markers.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { Status } from "@/generated/prisma/enums" +import { getStoredSummaryUpdatedAt, serializeTaskReadMarkers } from "./task-read-markers" + +const markerAgent = { AgentId: "main" } + +describe("Agent API task serialization", () => { + it("returns stored summaryUpdatedAt for noted tasks without taskUpdatedAt fallback", () => { + const taskUpdatedAt = new Date("2026-05-15T10:00:00.000Z") + + assert.equal( + getStoredSummaryUpdatedAt({ note: "summary", summaryUpdatedAt: null }), + null + ) + + const serialized = serializeTaskReadMarkers({ + id: "task-1", + name: "Task", + job: "Do work", + status: Status.done, + note: "summary", + summaryUpdatedAt: null, + taskUpdatedAt, + readMarkers: [ + { status: Status.done, readAt: taskUpdatedAt, agent: markerAgent }, + { status: Status.todo, readAt: taskUpdatedAt, agent: { AgentId: "kaito" } }, + ], + }) + + assert.equal(serialized.summaryUpdatedAt, null) + assert.deepEqual(serialized.readBy, ["main"]) + }) + + it("clears summaryUpdatedAt when the note is absent", () => { + const staleSummaryTimestamp = new Date("2026-05-15T10:00:00.000Z") + + assert.equal( + getStoredSummaryUpdatedAt({ note: null, summaryUpdatedAt: staleSummaryTimestamp }), + null + ) + }) +}) diff --git a/apps/web/lib/api/task-read-markers.ts b/apps/web/lib/api/task-read-markers.ts index 67f760f..ad17373 100644 --- a/apps/web/lib/api/task-read-markers.ts +++ b/apps/web/lib/api/task-read-markers.ts @@ -4,7 +4,6 @@ import { Status } from "@/generated/prisma/enums" type TaskSummaryPayload = { note?: string | null summaryUpdatedAt?: Date | null - taskUpdatedAt?: Date } type ReadMarker = { @@ -23,15 +22,13 @@ export function serializeTaskReadMarkers< return { ...serializedTask, - summaryUpdatedAt: getSummaryUpdatedAt(serializedTask), + summaryUpdatedAt: getStoredSummaryUpdatedAt(serializedTask), readBy: readMarkers .filter((marker) => marker.status === task.status) .map((marker) => marker.agent.AgentId), } } -function getSummaryUpdatedAt(task: TaskSummaryPayload) { - if (!task.note) return task.summaryUpdatedAt ?? null - - return task.summaryUpdatedAt ?? task.taskUpdatedAt ?? null +export function getStoredSummaryUpdatedAt(task: TaskSummaryPayload) { + return task.note ? (task.summaryUpdatedAt ?? null) : null } diff --git a/package.json b/package.json index ca5e085..6db889d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "prisma:studio": "prisma studio", "typecheck": "corepack pnpm prisma:generate && corepack pnpm --filter @agentbridge/web typecheck && corepack pnpm --filter agentbridge-ai typecheck", "typecheck:web": "corepack pnpm prisma:generate && corepack pnpm --filter @agentbridge/web typecheck", + "test:api": "tsx --tsconfig apps/web/tsconfig.json --test apps/web/lib/api/*.test.ts", "test:task-freshness": "tsx --tsconfig apps/web/tsconfig.json --test apps/web/lib/api/task-freshness.test.ts", "cli:dev": "corepack pnpm --filter agentbridge-ai dev", "cli:build": "corepack pnpm --filter agentbridge-ai build",