From cd4394248861e7bca629926dc71a57265f19986b Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 12:08:46 +0000 Subject: [PATCH] feat(task T01): implement via codex --- docs-web/content/docs/user-dashboard-chat.mdx | 8 +- docs-web/user/dashboard/chat.md | 2 +- docs/dashboard/design-system-chat.md | 2 +- src/services/planning-agent-service.ts | 19 ++++ .../planning-execution-plan-message.ts | 96 +++++++++++++++++++ ...planning-agent-service.integration.test.ts | 94 ++++++++++++++++++ .../services/planning-agent-service.test.ts | 28 ++++++ 7 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 src/services/planning-execution-plan-message.ts diff --git a/docs-web/content/docs/user-dashboard-chat.mdx b/docs-web/content/docs/user-dashboard-chat.mdx index d8a122c9f3..21c5000818 100644 --- a/docs-web/content/docs/user-dashboard-chat.mdx +++ b/docs-web/content/docs/user-dashboard-chat.mdx @@ -65,7 +65,7 @@ For integrators, the stored metadata uses `metadata.promptSuggestions` with `lab Supported generic icon identifiers are `sparkles`, `search`, `edit`, `code`, `terminal`, `bug`, `check`, `play`, `refresh`, `settings`, `file`, `folder`, `git-branch`, `git-pull-request`, `database`, `shield`, `book-open`, `message-circle`, `list-checks`, `rocket`, `zap`, `lightbulb`, `clipboard`, `download`, `upload`, `eye`, `package`, `server`, `clock`, and `help-circle`. -Messages can also render external work references as rich cards without changing the stored transcript. The dashboard recognizes Jira issues, GitHub issues and pull requests, and GitLab issues and merge requests from explicit message metadata first, then from JSON-looking message bodies with the same fields. Recognized cards show the provider, key or number, title, status, safe external link, repository or project path, labels, assignee or author, and a short preview. Malformed JSON or unsupported providers remain ordinary markdown instead of being dropped. +Normal thread messages can also render external work references as rich cards without changing the stored transcript. The dashboard recognizes Jira issues, GitHub issues and pull requests, and GitLab issues and merge requests from explicit message metadata first, then from JSON-looking message bodies with the same fields. Recognized cards show the provider, key or number, title, status, safe external link, repository or project path, labels, assignee or author, and a short preview. Malformed JSON or unsupported providers remain ordinary markdown instead of being dropped. ## Compacting a thread @@ -89,12 +89,12 @@ The **Invocations** tab is a structured log of server-created execution invocati Use this for debugging provider runs and MCP client integrations, for example to inspect agent transcripts or see exactly what arguments your LLM is passing to tools like `manage_memory` or `manage_settings`. -Invocation transcripts use the same live sprint status card as thread messages when planning metadata links them to a sprint. This means a planning invocation and its related chat message should show consistent task progress without a separate refresh control. Parsed provider conversation turns stream into running invocation transcripts for provider-backed planning, QA review, dashboard/chat replies, CI repair, merge-conflict repair, memory remediation, setup, and task coding; text-only provider output is appended when the run completes. - -Planning and QA self-reflection messages appear as structured cards instead of raw system text. Each card shows whether the reflection passed, needed improvement, or hit an error; the final decision; the attempt number; and each criterion's 5-star rating, numeric score, threshold, rationale, and improvement instructions when available. +Invocation transcripts use the same live sprint status card as thread messages when planning metadata links them to a sprint. Completed sprint-planning invocations append a final assistant summary with `metadata.widget_metadata.type = "planning_request"`, `status = "completed"`, and `metadata.executionPlan` for that exact sprint, including the sprint id, created task ids, and planned task titles. This means a planning invocation and its related chat message should show consistent task progress without a separate refresh control. Parsed provider conversation turns stream into running invocation transcripts for provider-backed planning, QA review, dashboard/chat replies, CI repair, merge-conflict repair, memory remediation, setup, and task coding; text-only provider output is appended when the run completes. Invocation transcripts use the same external-reference cards as thread messages for recognized Jira, GitHub, and GitLab payloads, including JSON payloads that would otherwise appear as raw punctuation-heavy output. This keeps linked work readable while preserving the original backend metadata and message content. +Planning and QA self-reflection messages appear as structured cards instead of raw system text. Each card shows whether the reflection passed, needed improvement, or hit an error; the final decision; the attempt number; and each criterion's 5-star rating, numeric score, threshold, rationale, and improvement instructions when available. + ## Posting messages The composer at the bottom supports: diff --git a/docs-web/user/dashboard/chat.md b/docs-web/user/dashboard/chat.md index 73e6dfec8b..21c5000818 100644 --- a/docs-web/user/dashboard/chat.md +++ b/docs-web/user/dashboard/chat.md @@ -89,7 +89,7 @@ The **Invocations** tab is a structured log of server-created execution invocati Use this for debugging provider runs and MCP client integrations, for example to inspect agent transcripts or see exactly what arguments your LLM is passing to tools like `manage_memory` or `manage_settings`. -Invocation transcripts use the same live sprint status card as thread messages when planning metadata links them to a sprint. This means a planning invocation and its related chat message should show consistent task progress without a separate refresh control. Parsed provider conversation turns stream into running invocation transcripts for provider-backed planning, QA review, dashboard/chat replies, CI repair, merge-conflict repair, memory remediation, setup, and task coding; text-only provider output is appended when the run completes. +Invocation transcripts use the same live sprint status card as thread messages when planning metadata links them to a sprint. Completed sprint-planning invocations append a final assistant summary with `metadata.widget_metadata.type = "planning_request"`, `status = "completed"`, and `metadata.executionPlan` for that exact sprint, including the sprint id, created task ids, and planned task titles. This means a planning invocation and its related chat message should show consistent task progress without a separate refresh control. Parsed provider conversation turns stream into running invocation transcripts for provider-backed planning, QA review, dashboard/chat replies, CI repair, merge-conflict repair, memory remediation, setup, and task coding; text-only provider output is appended when the run completes. Invocation transcripts use the same external-reference cards as thread messages for recognized Jira, GitHub, and GitLab payloads, including JSON payloads that would otherwise appear as raw punctuation-heavy output. This keeps linked work readable while preserving the original backend metadata and message content. diff --git a/docs/dashboard/design-system-chat.md b/docs/dashboard/design-system-chat.md index 63bed3d3b0..68a3d2ef95 100644 --- a/docs/dashboard/design-system-chat.md +++ b/docs/dashboard/design-system-chat.md @@ -18,7 +18,7 @@ The chat and invocation design system for the Code UX dashboard defines the layo - **Tool Calls / Reasoning**: Presented as full-width, compact cards rather than standard bubbles to clearly differentiate them as structural operations or internal thoughts rather than user-facing dialogue. - **Prompt suggestion tags**: Agent replies can append optional next-step prompt tags below the normal markdown body when message metadata includes `metadata.promptSuggestions`. These tags are adjuncts to the bubble, not replacements for the transcript; the markdown reply remains visible and readable even when suggestions are present. Selecting a tag populates and focuses the composer with that prompt so the user can review or edit it before sending. Tags must never auto-send a prompt from standard thread bubbles, and invocation transcripts remain read-only even if prompt-suggestion metadata is present. - **Widgets**: specialized components (Routing, Planning, Container) embedded within the stream to provide rich status and execution context without cluttering the text transcript. They use a unified visual language (`ChatWidgetFrame`). - - **Planning status**: planning widgets prefer live project state when available. The view-model resolves a sprint from message metadata (`sprintId`, `sprintRunId`, or planning widget metadata) or from the active execution run, then combines project task records with the execution snapshot's dispatches and runtime events. The card shows sprint key/name, request/task/run materialization, a real `progressbar`, queued/completed counts, and per-task status labels. Both project task records and the execution snapshot must report loaded for the active project before the live card is rendered; otherwise the widget falls back to the generic planning card instead of rendering partial or invented progress. + - **Planning status**: planning widgets prefer live project state when available. The view-model resolves a sprint from message metadata (`sprintId`, `sprintRunId`, or planning widget metadata) or from the active execution run, then combines project task records with the execution snapshot's dispatches and runtime events. Completed sprint-planning invocations also append an assistant transcript message with `metadata.widget_metadata.type = "planning_request"`, `metadata.widget_metadata.status = "completed"`, and `metadata.executionPlan` containing the exact invocation's `projectId`, `sprintId`, sprint label, goal, created task IDs, and planned task summaries. The card shows sprint key/name, request/task/run materialization, a real `progressbar`, queued/completed counts, and per-task status labels. Both project task records and the execution snapshot must report loaded for the active project before the live card is rendered; otherwise the widget falls back to the generic planning card instead of rendering partial or invented progress. - **Reasoning turns**: internal thinking output renders as a dedicated `ReasoningWidget`, not as a generic assistant bubble and not as a tool-call widget. It keeps the text plain and whitespace-preserving, adds provider/model/timing/token context in the header, and collapses long content behind an expand/collapse button with `aria-expanded` and `aria-controls`. - **Self-reflection turns**: planning and QA reflection metadata renders as a dedicated `SelfReflectionWidget`, not as the raw system text. The widget summarizes the reflection purpose, attempt, pass/fail/error state, and final decision, then lists each criterion with a 5-star visual rating derived from the 1-10 score, the numeric score, threshold, textual pass/fail state, rationale, and improvement instructions when present. Star ratings expose semantic labels for screen readers, and pass/fail is always shown as text plus icon so it is not color-only. - **External references**: Normal thread bubbles and invocation transcript bubbles render Jira issues, GitHub issues/pull requests, and GitLab issues/merge requests as `ExternalReferenceWidget` cards when the frontend view-model recognizes explicit metadata (`widget_metadata`, `externalReference`, `linkedIssue`, or top-level provider/kind/url fields) or a JSON-looking message body with the same fields. The widget shows provider, issue key or number, title, state/status, safe `http`/`https` outbound link, repository/project path, labels, assignee/author, and a short preview. Malformed JSON or unsupported providers stay on the normal markdown path so chat storage contracts and transcripts are not mutated. diff --git a/src/services/planning-agent-service.ts b/src/services/planning-agent-service.ts index a6b3f0d870..e4f7a0131b 100644 --- a/src/services/planning-agent-service.ts +++ b/src/services/planning-agent-service.ts @@ -27,6 +27,7 @@ import { parsePlannedSprintReply, PlanningParseError } from "./planning-json-ext import { extractJsonFromText } from "../domain/llm/json-extraction.js"; import type { PlannedSprintPayload, PlannedTaskDraft } from "../contracts/project-management-types.js"; import { persistPlannedTasks } from "./planning-task-persistence.js"; +import { buildPlanningExecutionPlanMessage } from "./planning-execution-plan-message.js"; import { ProviderExecutionService, resolveEffectiveModel } from "./provider-execution-service.js"; import { StructuredAgentRequestService, type StructuredAgentRequestResult } from "./structured-agent-request-service.js"; import { ProviderInvocationCancelledError, StructuredProviderResponseService } from "./structured-provider-response-service.js"; @@ -457,6 +458,8 @@ export class PlanningAgentService { if (Object.keys(sprintUpdate).length > 0) { this.deps.projectManagementRepository.updateSprint(sprint.id, sprintUpdate); } + const finalSprintName = sprintUpdate.name || sprint.name; + const finalSprintGoal = sprintUpdate.goal || sprint.goal; const { createdTaskIds } = persistPlannedTasks( projectId, @@ -466,6 +469,22 @@ export class PlanningAgentService { { defaultAgentPresetId: manualCodingAgent?.id || null }, ); + if (invocation && isExecutionInvocationActiveForFinalize(this.deps.executionRepository, invocation.id)) { + this.deps.executionRepository?.appendExecutionInvocationMessage( + invocation.id, + buildPlanningExecutionPlanMessage({ + invocationId: invocation.id, + projectId, + sprintId, + sprintNumber: sprint.number, + sprintName: finalSprintName, + goal: finalSprintGoal, + tasks: payload.tasks, + createdTaskIds, + }), + ); + } + const titles: string[] = []; for (const t of payload.tasks) { titles.push(t.title); diff --git a/src/services/planning-execution-plan-message.ts b/src/services/planning-execution-plan-message.ts new file mode 100644 index 0000000000..5ad1b459a7 --- /dev/null +++ b/src/services/planning-execution-plan-message.ts @@ -0,0 +1,96 @@ +import type { AppendExecutionInvocationMessageInput } from "../contracts/execution-types.js"; +import type { PlannedTaskDraft, TaskExecutorType, TaskPriority } from "../contracts/project-management-types.js"; + +export interface PlanningExecutionPlanMessageInput { + invocationId: string; + projectId: string; + sprintId: string; + sprintNumber: number | null; + sprintName: string; + goal: string; + tasks: readonly PlannedTaskDraft[]; + createdTaskIds: readonly string[]; +} + +interface PlanningExecutionPlanTaskSummary { + key: string; + title: string; + description: string; + priority: TaskPriority; + executorType: TaskExecutorType; + dependsOn: string[]; +} + +interface PlanningExecutionPlanMetadata { + invocationId: string; + projectId: string; + sprintId: string; + sprintNumber: number | null; + sprintName: string; + goal: string; + taskCount: number; + createdTaskIds: string[]; + tasks: PlanningExecutionPlanTaskSummary[]; +} + +export function buildPlanningExecutionPlanMessage( + input: PlanningExecutionPlanMessageInput, +): AppendExecutionInvocationMessageInput { + const tasks = input.tasks.map((task) => ({ + key: task.key, + title: task.title, + description: task.description, + priority: task.priority || "medium", + executorType: task.executorType || "auto", + dependsOn: [...(task.dependsOn || [])], + })); + const executionPlan: PlanningExecutionPlanMetadata = { + invocationId: input.invocationId, + projectId: input.projectId, + sprintId: input.sprintId, + sprintNumber: input.sprintNumber, + sprintName: input.sprintName, + goal: input.goal, + taskCount: tasks.length, + createdTaskIds: [...input.createdTaskIds], + tasks, + }; + + return { + role: "assistant", + contentMarkdown: buildPlanningExecutionPlanMarkdown(executionPlan), + metadata: { + widget_metadata: { + type: "planning_request", + status: "completed", + projectId: input.projectId, + sprintId: input.sprintId, + sprintNumber: input.sprintNumber, + sprintName: input.sprintName, + }, + executionPlan, + }, + }; +} + +function buildPlanningExecutionPlanMarkdown(plan: PlanningExecutionPlanMetadata): string { + const sprintLabel = plan.sprintNumber === null + ? plan.sprintName + : `Sprint ${plan.sprintNumber} - ${plan.sprintName}`; + const lines = [ + `## Execution Plan: ${sprintLabel}`, + "", + `Goal: ${plan.goal}`, + "", + `Planned ${plan.taskCount} ${plan.taskCount === 1 ? "task" : "tasks"}:`, + ]; + + for (const task of plan.tasks) { + const dependencySummary = task.dependsOn.length > 0 + ? ` (depends on ${task.dependsOn.map((key) => `\`${key}\``).join(", ")})` + : ""; + lines.push(`- \`${task.key}\` - ${task.title}${dependencySummary}`); + } + + return lines.join("\n"); +} diff --git a/tests/backend/services/planning-agent-service.integration.test.ts b/tests/backend/services/planning-agent-service.integration.test.ts index c5cf6f93a5..ce77f9d11f 100644 --- a/tests/backend/services/planning-agent-service.integration.test.ts +++ b/tests/backend/services/planning-agent-service.integration.test.ts @@ -156,6 +156,16 @@ describe("PlanningAgentService Integration", () => { }; } + function findCompletedExecutionPlanMessage( + executionRepository: ExecutionRepository, + invocationId: string, + ) { + return executionRepository.listExecutionInvocationMessages(invocationId).find((message) => { + const widgetMetadata = message.metadata?.widget_metadata as Record | undefined; + return widgetMetadata?.type === "planning_request" && widgetMetadata.status === "completed"; + }); + } + function reflectionResult(score: number): string { return JSON.stringify({ criteria: [ @@ -301,6 +311,90 @@ describe("PlanningAgentService Integration", () => { expect(messages[0].contentMarkdown).toContain("Turn sprint goals into concrete executable tasks."); }); + it("persists sprint-specific execution plan metadata for separate planning invocations", async () => { + const { + projectRepository, + connectionRepository, + executionRepository, + settingsRepository, + syncService, + executionControlService, + project, + sprint: firstSprint, + } = await setupTestHarness({ + name: "First Planning Sprint", + goal: "Plan the first sprint.", + }); + const secondSprint = projectRepository.createSprint(project.id, { + name: "Second Planning Sprint", + goal: "Plan the second sprint.", + }); + + const service = new PlanningAgentService({ + projectManagementRepository: projectRepository, + connectionChatRepository: connectionRepository, + executionRepository, + settingsRepository, + agentPresetSyncService: syncService, + executionControlService: executionControlService as any, + providerRunner: createPlanningTextProviderRunner([ + JSON.stringify(planningProviderPayload("First sprint task")), + JSON.stringify(planningProviderPayload("Second sprint task")), + ]), + }); + + const firstResult = await service.planSprint(project.id, firstSprint.id, {}); + const secondResult = await service.planSprint(project.id, secondSprint.id, {}); + + const firstInvocation = executionRepository + .listExecutionInvocations({ projectId: project.id, sprintId: firstSprint.id }) + .find((record) => record.sprintId === firstSprint.id); + const secondInvocation = executionRepository + .listExecutionInvocations({ projectId: project.id, sprintId: secondSprint.id }) + .find((record) => record.sprintId === secondSprint.id); + expect(firstInvocation).toBeDefined(); + expect(secondInvocation).toBeDefined(); + + const firstMessage = findCompletedExecutionPlanMessage(executionRepository, firstInvocation!.id); + const secondMessage = findCompletedExecutionPlanMessage(executionRepository, secondInvocation!.id); + const firstPlan = firstMessage?.metadata?.executionPlan as { + projectId: string; + sprintId: string; + sprintName: string; + taskCount: number; + createdTaskIds: string[]; + tasks: Array<{ title: string }>; + } | undefined; + const secondPlan = secondMessage?.metadata?.executionPlan as { + projectId: string; + sprintId: string; + sprintName: string; + taskCount: number; + createdTaskIds: string[]; + tasks: Array<{ title: string }>; + } | undefined; + + expect(firstPlan).toMatchObject({ + projectId: project.id, + sprintId: firstSprint.id, + sprintName: "First Planning Sprint", + taskCount: 1, + createdTaskIds: firstResult.createdTaskIds, + tasks: [{ title: "First sprint task" }], + }); + expect(secondPlan).toMatchObject({ + projectId: project.id, + sprintId: secondSprint.id, + sprintName: "Second Planning Sprint", + taskCount: 1, + createdTaskIds: secondResult.createdTaskIds, + tasks: [{ title: "Second sprint task" }], + }); + expect(firstPlan?.sprintId).not.toBe(secondPlan?.sprintId); + expect(firstMessage?.contentMarkdown).toContain("- `T01` - First sprint task"); + expect(secondMessage?.contentMarkdown).toContain("- `T01` - Second sprint task"); + }); + it("auto-starts after planning when self-reflection is disabled", async () => { const { projectRepository, diff --git a/tests/backend/services/planning-agent-service.test.ts b/tests/backend/services/planning-agent-service.test.ts index 37a5d32729..d94de1d772 100644 --- a/tests/backend/services/planning-agent-service.test.ts +++ b/tests/backend/services/planning-agent-service.test.ts @@ -344,6 +344,34 @@ describe("PlanningAgentService", () => { expect(createdTasks).toHaveLength(1); expect(createdTasks[0]?.title).toBe("Plan via virtual worker"); + const planningInvocation = executionRepository + .listExecutionInvocations({ projectId: project.id }) + .find((record) => record.sprintId === sprint.id); + expect(planningInvocation).toBeDefined(); + const messages = executionRepository.listExecutionInvocationMessages(planningInvocation!.id); + const executionPlanMessage = messages.find((message) => { + const widgetMetadata = message.metadata?.widget_metadata as Record | undefined; + return widgetMetadata?.type === "planning_request" && widgetMetadata.status === "completed"; + }); + const executionPlan = executionPlanMessage?.metadata?.executionPlan as { + projectId: string; + sprintId: string; + taskCount: number; + createdTaskIds: string[]; + tasks: Array<{ key: string; title: string }>; + } | undefined; + expect(executionPlanMessage?.role).toBe("assistant"); + expect(executionPlanMessage?.contentMarkdown).toContain("## Execution Plan: Sprint 1 - Virtual Planning Sprint"); + expect(executionPlan).toMatchObject({ + projectId: project.id, + sprintId: sprint.id, + taskCount: 1, + createdTaskIds: planned.createdTaskIds, + tasks: [ + { key: "T01", title: "Plan via virtual worker" }, + ], + }); + const statsSnapshot = executionRepository.getProjectStatsSnapshot(project.id, "24h"); expect(statsSnapshot.usage.totalTokens).toBe(1_030); expect(statsSnapshot.sprints[0]).toMatchObject({