diff --git a/docs-web/content/docs/developer-http-api.mdx b/docs-web/content/docs/developer-http-api.mdx index 2372fe011c..fb6bd1f77d 100644 --- a/docs-web/content/docs/developer-http-api.mdx +++ b/docs-web/content/docs/developer-http-api.mdx @@ -61,7 +61,7 @@ This page lists every endpoint, grouped by domain. Path parameters use `:name` n | Method | Path | Description | | --- | --- | --- | -| `GET` | `/api/projects/:projectId/tasks?sprintId=` | List. | +| `GET` | `/api/projects/:projectId/tasks?sprintId=` | List tasks. Rated tasks include optional latest `selfReflectionRating`; unrated tasks omit it. | | `POST` | `/api/projects/:projectId/tasks` | Create. | | `PATCH` | `/api/tasks/:taskId` | Update. | | `DELETE` | `/api/tasks/:taskId` | Delete. | diff --git a/docs-web/developer/http-api.md b/docs-web/developer/http-api.md index bd190dac46..18f20804bd 100644 --- a/docs-web/developer/http-api.md +++ b/docs-web/developer/http-api.md @@ -61,7 +61,7 @@ This page lists every endpoint, grouped by domain. Path parameters use `:name` n | Method | Path | Description | | --- | --- | --- | -| `GET` | `/api/projects/:projectId/tasks?sprintId=` | List. | +| `GET` | `/api/projects/:projectId/tasks?sprintId=` | List tasks. Rated tasks include optional latest `selfReflectionRating`; unrated tasks omit it. | | `POST` | `/api/projects/:projectId/tasks` | Create. | | `PATCH` | `/api/tasks/:taskId` | Update. | | `DELETE` | `/api/tasks/:taskId` | Delete. | diff --git a/docs/architecture/project-management-implementation.md b/docs/architecture/project-management-implementation.md index a3b766355d..0ecb1dd0b6 100644 --- a/docs/architecture/project-management-implementation.md +++ b/docs/architecture/project-management-implementation.md @@ -102,6 +102,8 @@ The dashboard now has project-scoped CRUD endpoints: `GET /api/projects` is the single data source for Projects page cards. The project summary payload includes the source kind, repository metadata, local base path, creation and update timestamps, and the latest project-scoped run activity derived from `sprint_runs` and `task_runs`. +`GET /api/projects/:projectId/tasks` returns each task with optional `latestReview` and `selfReflectionRating` fields. `selfReflectionRating` is omitted for unrated tasks and, when present, is the latest persisted task-run self-reflection rating selected by captured timestamp and persisted row order. + Legacy runtime endpoints still exist for the old live runtime/status surfaces: - `GET /api/status` - `GET /api/live-activities` diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index 4f69c3e2cd..9b17f8c8f3 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -344,7 +344,7 @@ Legacy runtime: - The organic sprint bubble cells use the same live start/stop control path as the registry list, so the hover play/stop action is now functional instead of decorative - Organic sprint bubble cell shadows use the shared project-cell organic shadow underlay from `organic-cell-styles.ts`, keeping sprint and project gallery cells at the same ambient depth in light and dark themes. - Sprint cells now surface a QA-reviewed indicator with an expandable overlay section inside the created column, and allow marking sprints completed directly from the cell menu -- Task rows and Live task cards now surface task-level QA review badges from the latest task QA run, including a running indicator while QA review is in progress. +- Task rows and Live task cards now surface task-level QA review badges from the latest task QA run, including a running indicator while QA review is in progress. The same task records carry an optional latest `selfReflectionRating` payload when a completed task run persisted one; unrated tasks omit the field. - The Tasks page sprint scope selector uses a keyboard-accessible listbox pattern with selected, open, loading, and empty option state, arrow/Home/End navigation, Escape close, outside-click close, and trigger focus restoration. Task board status and priority filters keep the current cards visible during the short filter transition, then announce the settled result count through a polite live region. Task board lanes render as named regions with count summaries, drop-target feedback, reduced-motion drag-disabled copy, and status regions for loading, empty, and error states; Kanban cards expose task id/title/status/priority, dependency blockers, optimistic saving, session, preview, PR, live runtime, rerun availability, duration, QA review context, and screen-reader drag guidance in stable accessible text while keeping drag-and-drop pointer-only. Creating or editing a task opens a named editor viewbox inside the task workspace: it sits to the right of the board on wide screens, becomes the primary full-width region on narrow screens, keeps the selected sprint scope plus active board filters intact, and can persist a task-level `agentPresetId` worker-agent override. Card quick actions sit in the bottom footer below metadata/dependency indicators; fine-pointer layouts reserve the action tray and reveal it on hover or keyboard focus, while touch/coarse-pointer layouts keep actions visible. - Rendered markdown previews use near-black body, heading, list, blockquote, and table text in light mode while preserving slate/white dark-mode text and signal-colored links/code. - Live task cards now include `Edit` and `Force complete` actions: diff --git a/src/contracts/app-types.ts b/src/contracts/app-types.ts index 7a4c0aeb97..07eb4ae838 100644 --- a/src/contracts/app-types.ts +++ b/src/contracts/app-types.ts @@ -2,6 +2,7 @@ import type { InstructionTemplateId } from "../instructions/instruction-template import type { ProviderInvocationPurpose, TokenUsageSource } from "./execution-types.js"; import type { ExecutionInvocationRecord } from "./invocation-types.js"; import type { MemorySettings } from "./memory-types.js"; +import type { TaskSelfReflectionRating } from "./task-self-reflection-types.js"; export interface JulesSource { name: string; @@ -130,6 +131,7 @@ export interface Subtask { reviewer: string | null; finishedAt: string | null; }; + selfReflectionRating?: TaskSelfReflectionRating; is_merged?: boolean; merge_indicator?: SubtaskMergeIndicator; intervention_owner?: InterventionOwner; diff --git a/src/contracts/project-management-types.ts b/src/contracts/project-management-types.ts index d67be2b460..20b3aa06b9 100644 --- a/src/contracts/project-management-types.ts +++ b/src/contracts/project-management-types.ts @@ -1,5 +1,6 @@ import type { AgentRoutingMode, VirtualWorkerProvider } from "./app-types.js"; import type { ProjectSettingsOverride } from "./settings-scope-types.js"; +import type { TaskSelfReflectionRating } from "./task-self-reflection-types.js"; import type { ProjectWorkerAssignmentRecord } from "./worker-types.js"; export type ProjectStatus = "running" | "failed" | "intervention" | "idle"; @@ -285,6 +286,7 @@ export interface TaskRecord { [key: string]: any; }; latestReview?: SprintReviewSummary; + selfReflectionRating?: TaskSelfReflectionRating; mergeIndicator: string | null; sourceType: string | null; sourcePath: string | null; diff --git a/src/repositories/project-management-repository.ts b/src/repositories/project-management-repository.ts index 2b4c85b087..5f771bfd54 100644 --- a/src/repositories/project-management-repository.ts +++ b/src/repositories/project-management-repository.ts @@ -39,6 +39,7 @@ import { loadProjectSummaryAggregationMap, projectSummaryQuery, type ProjectSumm import { loadSprintSummaryAggregationMap, sprintSummaryQuery, type SprintSummaryAggregation } from "./project-management/sprint-summary-query.js"; import { validateTaskDependencies } from "./project-management/task-dependency-graph.js"; import { getHomeCodeUxPath } from "../shared/config/code-ux-paths.js"; +import { TaskSelfReflectionRatingRepository } from "./task-self-reflection-rating-repository.js"; const SELECTED_PROJECT_KEY = "selected_project_id"; const GENERATED_SPRINT_NAME_PREFIX = "Untitled sprint"; @@ -153,7 +154,8 @@ export class ProjectManagementRepository { private readonly realtimeNotifier?: DashboardRealtimeMutationNotifier, private readonly settingsRepository: SettingsRepository = new SettingsRepository(), private readonly projectWorkerAssignmentRepository: ProjectWorkerAssignmentRepository = new ProjectWorkerAssignmentRepository(storage), - private readonly logger: Logger = createLogger({ bindings: { component: "ProjectManagementRepository" } }) + private readonly logger: Logger = createLogger({ bindings: { component: "ProjectManagementRepository" } }), + private readonly taskSelfReflectionRatingRepository: TaskSelfReflectionRatingRepository = new TaskSelfReflectionRatingRepository(storage) ) { this.db = storage.getDatabase(); } @@ -1105,7 +1107,9 @@ export class ProjectManagementRepository { dependencyMap.set(row.task_id, current); } - const reviewMap = this.getLatestTaskReviewSummaryMap(rows.map((row) => row.id)); + const taskIds = rows.map((row) => row.id); + const reviewMap = this.getLatestTaskReviewSummaryMap(taskIds); + const selfReflectionRatingMap = this.taskSelfReflectionRatingRepository.getLatestByTaskIds(taskIds); return rows.map((row) => ({ id: row.id, @@ -1125,6 +1129,7 @@ export class ProjectManagementRepository { isIndependent: toBoolean(row.is_independent), isMerged: toBoolean(row.is_merged), latestReview: reviewMap.get(row.id), + selfReflectionRating: selfReflectionRatingMap.get(row.id), mergeIndicator: row.merge_indicator, sourceType: row.source_type, sourcePath: row.source_path, diff --git a/src/repositories/project-runtime/runtime-status-projection.ts b/src/repositories/project-runtime/runtime-status-projection.ts index 886d7beaed..0a1602e621 100644 --- a/src/repositories/project-runtime/runtime-status-projection.ts +++ b/src/repositories/project-runtime/runtime-status-projection.ts @@ -2,9 +2,11 @@ import { DatabaseAdapter } from "../db/database-adapter.js"; import { AppDbStorage } from "../app-db-storage.js"; import type { DashboardStatus, JulesActivity, Subtask, SubtaskStatus } from "../../contracts/app-types.js"; import type { SprintReviewSummary } from "../../contracts/project-management-types.js"; +import type { TaskSelfReflectionRating } from "../../contracts/task-self-reflection-types.js"; import { mapPlanningStatusToRuntimeStatus, toMergeIndicator } from "../../services/subtask-state-mapper.js"; import { RuntimeContextPayload } from "./runtime-context-store.js"; import { toNumber, toBoolean, parsePayloadJson } from "../repository-utils.js"; +import { TaskSelfReflectionRatingRepository } from "../task-self-reflection-rating-repository.js"; export type PlanningTaskStatus = "pending" | "in_progress" | "coding_completed" | "completed" | "QA_REVIEW_FAILED"; export type ProjectStatus = "running" | "failed" | "intervention" | "idle"; @@ -85,6 +87,7 @@ export interface MappedTask { row: TaskRow; dependsOnTaskIds: string[]; latestReview?: SprintReviewSummary; + selfReflectionRating?: TaskSelfReflectionRating; } interface RecentActivitiesCacheEntry { @@ -131,7 +134,8 @@ export class RuntimeStatusProjection { constructor( private readonly storage: AppDbStorage, - private readonly db: DatabaseAdapter + private readonly db: DatabaseAdapter, + private readonly taskSelfReflectionRatingRepository: TaskSelfReflectionRatingRepository = new TaskSelfReflectionRatingRepository(storage), ) {} buildProjectStatus( @@ -165,6 +169,7 @@ export class RuntimeStatusProjection { activities: recentActivitiesByTaskId.get(task.row.id), is_independent: toBoolean(task.row.is_independent), latestReview: task.latestReview, + selfReflectionRating: task.selfReflectionRating, is_merged: merged, merge_indicator: toMergeIndicator(task.row.merge_indicator), }; @@ -218,12 +223,15 @@ export class RuntimeStatusProjection { dependencyMap.set(row.task_id, current); } - const reviewMap = this.getLatestTaskReviewSummaryMap(taskRows.map((row) => row.id)); + const taskIds = taskRows.map((row) => row.id); + const reviewMap = this.getLatestTaskReviewSummaryMap(taskIds); + const selfReflectionRatingMap = this.taskSelfReflectionRatingRepository.getLatestByTaskIds(taskIds); return taskRows.map((row) => ({ row, dependsOnTaskIds: dependencyMap.get(row.id) || [], latestReview: reviewMap.get(row.id), + selfReflectionRating: selfReflectionRatingMap.get(row.id), })); } diff --git a/src/repositories/task-self-reflection-rating-repository.ts b/src/repositories/task-self-reflection-rating-repository.ts index 93c6943561..cb8742d853 100644 --- a/src/repositories/task-self-reflection-rating-repository.ts +++ b/src/repositories/task-self-reflection-rating-repository.ts @@ -126,7 +126,7 @@ export class TaskSelfReflectionRatingRepository { r.*, ROW_NUMBER() OVER ( PARTITION BY r.task_id - ORDER BY r.captured_at DESC, r.updated_at DESC, r.id DESC + ORDER BY r.captured_at DESC, r.rowid DESC ) AS row_number FROM task_self_reflection_ratings r WHERE r.task_id diff --git a/tests/backend/repositories/project-management-repository.test.ts b/tests/backend/repositories/project-management-repository.test.ts index f86f17826e..c4e8ec5f99 100644 --- a/tests/backend/repositories/project-management-repository.test.ts +++ b/tests/backend/repositories/project-management-repository.test.ts @@ -12,6 +12,7 @@ import { } from "../../../src/repositories/project-management-repository.js"; import { ExecutionRepository } from "../../../src/repositories/execution-repository.js"; import { SprintMarkdownService } from "../../../src/services/sprint-markdown-service.js"; +import { TaskSelfReflectionRatingRepository } from "../../../src/repositories/task-self-reflection-rating-repository.js"; const tempDirs: string[] = []; @@ -932,6 +933,85 @@ describe("ProjectManagementRepository", () => { }); }); + it("includes latest task self-reflection ratings in listTasks and omits unrated tasks", async () => { + const { storage, repository, executionRepository } = await createRepository(); + const ratingRepository = new TaskSelfReflectionRatingRepository(storage); + + const project = repository.createProject({ + name: "Task Self Reflection Project", + sourceType: "local", + sourceRef: "/tmp/task-self-reflection", + }); + const sprint = repository.createSprint(project.id, { + name: "Task Self Reflection Sprint", + goal: "Expose task self-reflection state", + }); + const ratedTask = repository.createTask(project.id, { + sprintId: sprint.id, + taskKey: "T1", + title: "Rated task", + }); + const unratedTask = repository.createTask(project.id, { + sprintId: sprint.id, + taskKey: "T2", + title: "Unrated task", + }); + const olderRun = executionRepository.createTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + provider: "codex", + state: "COMPLETED", + }); + const latestRun = executionRepository.createTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + provider: "codex", + state: "COMPLETED", + }); + + ratingRepository.upsertForTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + sourceTaskRunId: olderRun.id, + overallRating: 2, + sections: [ + { label: "Implementation", normalizedLabel: "implementation", rating: 2, note: "Earlier run" }, + ], + capturedAt: "2026-06-01T10:00:00.000Z", + }); + ratingRepository.upsertForTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + sourceTaskRunId: latestRun.id, + overallRating: 4, + sections: [ + { label: "Tests", normalizedLabel: "tests", rating: 5, note: "Covered" }, + ], + capturedAt: "2026-06-01T10:00:00.000Z", + }); + + const tasks = repository.listTasks(project.id, sprint.id); + const mappedRated = tasks.find((task) => task.id === ratedTask.id); + const mappedUnrated = tasks.find((task) => task.id === unratedTask.id); + + expect(mappedUnrated?.selfReflectionRating).toBeUndefined(); + expect(mappedRated?.selfReflectionRating).toMatchObject({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + sourceTaskRunId: latestRun.id, + overallRating: 4, + sections: [ + { label: "Tests", normalizedLabel: "tests", rating: 5, note: "Covered" }, + ], + capturedAt: "2026-06-01T10:00:00.000Z", + }); + }); + it("handles originalPrompt in sprints and supports clearing tasks", async () => { const { repository } = await createRepository(); diff --git a/tests/backend/repositories/project-runtime/runtime-status-projection.test.ts b/tests/backend/repositories/project-runtime/runtime-status-projection.test.ts index 93c2f3b6d4..1f629f168e 100644 --- a/tests/backend/repositories/project-runtime/runtime-status-projection.test.ts +++ b/tests/backend/repositories/project-runtime/runtime-status-projection.test.ts @@ -6,6 +6,7 @@ import { AppDbStorage } from "../../../../src/repositories/app-db-storage.js"; import { RuntimeStatusProjection } from "../../../../src/repositories/project-runtime/runtime-status-projection.js"; import { ProjectManagementRepository } from "../../../../src/repositories/project-management-repository.js"; import { ExecutionRepository } from "../../../../src/repositories/execution-repository.js"; +import { TaskSelfReflectionRatingRepository } from "../../../../src/repositories/task-self-reflection-rating-repository.js"; const tempDirs: string[] = []; @@ -14,6 +15,7 @@ async function createProjection(): Promise<{ projection: RuntimeStatusProjection; projectRepository: ProjectManagementRepository; executionRepository: ExecutionRepository; + ratingRepository: TaskSelfReflectionRatingRepository; }> { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "runtime-status-projection-test-")); tempDirs.push(dir); @@ -23,6 +25,7 @@ async function createProjection(): Promise<{ projection: new RuntimeStatusProjection(storage, storage.getDatabase()), projectRepository: new ProjectManagementRepository(storage), executionRepository: new ExecutionRepository(storage), + ratingRepository: new TaskSelfReflectionRatingRepository(storage), }; } @@ -222,4 +225,76 @@ describe("RuntimeStatusProjection", () => { expect(refreshedStatus.subtasks[0]?.activities?.map((activity) => activity.id)).toEqual(["act-1", "act-2"]); }); + + it("projects latest task self-reflection ratings for live status and omits unrated tasks", async () => { + const { projection, projectRepository, executionRepository, ratingRepository } = await createProjection(); + + const project = projectRepository.createProject({ name: "Proj", sourceType: "local", sourceRef: "/path" }); + const sprint = projectRepository.createSprint(project.id, { name: "Sprint 1", number: 1 }); + const ratedTask = projectRepository.createTask(project.id, { + sprintId: sprint.id, + taskKey: "T1", + title: "Rated task", + status: "completed", + }); + const unratedTask = projectRepository.createTask(project.id, { + sprintId: sprint.id, + taskKey: "T2", + title: "Unrated task", + status: "pending", + }); + const olderRun = executionRepository.createTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + provider: "codex", + state: "COMPLETED", + }); + const latestRun = executionRepository.createTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + provider: "codex", + state: "COMPLETED", + }); + + ratingRepository.upsertForTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + sourceTaskRunId: olderRun.id, + overallRating: 1, + sections: [ + { label: "Quality", normalizedLabel: "quality", rating: 1, note: "Older" }, + ], + capturedAt: "2026-06-01T10:00:00.000Z", + }); + ratingRepository.upsertForTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + sourceTaskRunId: latestRun.id, + overallRating: 5, + sections: [ + { label: "Quality", normalizedLabel: "quality", rating: 5, note: "Latest" }, + ], + capturedAt: "2026-06-01T10:05:00.000Z", + }); + + const status = projection.buildProjectStatus(project.id, sprint.id, null); + const mappedRated = status.subtasks.find((task) => task.record_id === ratedTask.id); + const mappedUnrated = status.subtasks.find((task) => task.record_id === unratedTask.id); + + expect(mappedUnrated?.selfReflectionRating).toBeUndefined(); + expect(mappedRated?.selfReflectionRating).toMatchObject({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + sourceTaskRunId: latestRun.id, + overallRating: 5, + sections: [ + { label: "Quality", normalizedLabel: "quality", rating: 5, note: "Latest" }, + ], + }); + }); }); diff --git a/tests/backend/server/dashboard-project-api.test.ts b/tests/backend/server/dashboard-project-api.test.ts index 442898fb08..408ba24c3e 100644 --- a/tests/backend/server/dashboard-project-api.test.ts +++ b/tests/backend/server/dashboard-project-api.test.ts @@ -16,6 +16,7 @@ import { AgentPresetRepository } from "../../../src/repositories/agent-preset-re import { SettingsRepository } from "../../../src/repositories/settings-repository.js"; import { WorkerEndpointRepository } from "../../../src/repositories/worker-endpoint-repository.js"; import { ProjectWorkerAssignmentRepository } from "../../../src/repositories/project-worker-assignment-repository.js"; +import { TaskSelfReflectionRatingRepository } from "../../../src/repositories/task-self-reflection-rating-repository.js"; import { SprintMarkdownService } from "../../../src/services/sprint-markdown-service.js"; import { AgentPresetSyncService } from "../../../src/services/agent-preset-sync-service.js"; import { ProjectAttentionService } from "../../../src/domain/workers/project-attention-service.js"; @@ -29,6 +30,20 @@ type TestFetchResponse = { json: () => Promise; }; +interface ApiTaskWithSelfReflection { + id: string; + selfReflectionRating?: { + sourceTaskRunId: string; + overallRating: number; + sections: Array<{ + label: string; + normalizedLabel: string; + rating: number; + note: string | null; + }>; + }; +} + const mapExecutionConnections = (connections: McpConnectionRecord[]) => ( connections.map((connection) => ({ id: connection.id, @@ -704,6 +719,94 @@ describe("dashboard project management API", () => { }); }); + it("serializes latest task self-reflection ratings in GET /api/projects/:projectId/tasks", async () => { + const { fetch, storage, executionRepository } = await createServerHandle(); + const ratingRepository = new TaskSelfReflectionRatingRepository(storage); + const baseUrl = "http://127.0.0.1"; + + const projectResponse = await fetch(`${baseUrl}/api/projects`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Task Rating API Project", sourceType: "local", sourceRef: "/tmp/task-rating-api" }), + }); + expect(projectResponse.status).toBe(201); + const project = await projectResponse.json() as { id: string }; + + const sprintResponse = await fetch(`${baseUrl}/api/projects/${project.id}/sprints`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Task Rating API Sprint", goal: "Expose ratings" }), + }); + expect(sprintResponse.status).toBe(201); + const sprint = await sprintResponse.json() as { id: string }; + + const ratedTaskResponse = await fetch(`${baseUrl}/api/projects/${project.id}/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sprintId: sprint.id, taskKey: "T1", title: "Rated task" }), + }); + expect(ratedTaskResponse.status).toBe(201); + const ratedTask = await ratedTaskResponse.json() as { id: string }; + + const unratedTaskResponse = await fetch(`${baseUrl}/api/projects/${project.id}/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sprintId: sprint.id, taskKey: "T2", title: "Unrated task" }), + }); + expect(unratedTaskResponse.status).toBe(201); + const unratedTask = await unratedTaskResponse.json() as { id: string }; + + const olderRun = executionRepository.createTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + provider: "codex", + state: "COMPLETED", + }); + const latestRun = executionRepository.createTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + provider: "codex", + state: "COMPLETED", + }); + ratingRepository.upsertForTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + sourceTaskRunId: olderRun.id, + overallRating: 2, + sections: [], + capturedAt: "2026-06-01T10:00:00.000Z", + }); + ratingRepository.upsertForTaskRun({ + projectId: project.id, + sprintId: sprint.id, + taskId: ratedTask.id, + sourceTaskRunId: latestRun.id, + overallRating: 4, + sections: [ + { label: "Testing", normalizedLabel: "testing", rating: 5, note: "Serialized" }, + ], + capturedAt: "2026-06-01T10:10:00.000Z", + }); + + const tasksResponse = await fetch(`${baseUrl}/api/projects/${project.id}/tasks?sprintId=${sprint.id}`); + expect(tasksResponse.status).toBe(200); + const tasks = await tasksResponse.json() as ApiTaskWithSelfReflection[]; + const mappedRated = tasks.find((task) => task.id === ratedTask.id); + const mappedUnrated = tasks.find((task) => task.id === unratedTask.id); + + expect(mappedUnrated?.selfReflectionRating).toBeUndefined(); + expect(mappedRated?.selfReflectionRating).toEqual(expect.objectContaining({ + sourceTaskRunId: latestRun.id, + overallRating: 4, + sections: [ + { label: "Testing", normalizedLabel: "testing", rating: 5, note: "Serialized" }, + ], + })); + }); + it("round-trips agent presets through explicit markdown pull and push API routes", async () => { const { fetch, dir, repository } = await createServerHandle(); const baseUrl = "http://127.0.0.1";