FN-8759: preserve planning interview context in created tasks
Retain complete Planning Mode decisions throughout task creation. - Add a canonical handoff formatter that embeds ordered interview Q&A without duplicates. - Apply the handoff to single-task, API, and multi-task planning creation paths. - Cover selected, custom, and commented answers; document the retained context and add a patch changeset. Files changed: .changeset/fn-8759-planning-session-qa.md | 7 ++++ docs/dashboard-guide.md | 3 +- .../planning-interview-formatters.test.ts | 45 +++++++++++++++++++++- .../planning-question-regeneration.test.ts | 35 +++++++++++++++++ .../src/__tests__/routes-planning.test.ts | 28 +++++++++++++- packages/dashboard/src/planning.ts | 42 ++++++++++++++------ .../src/routes/register-planning-subtask-routes.ts | 10 ++--- 7 files changed, 150 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-8759 Fusion-Task-Lineage: c07e1714-cd0e-4701-9448-b77db773211d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8759-planning-session-qa.md
Normal file
7
.changeset/fn-8759-planning-session-qa.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Retain complete Planning Mode interview decisions in every created task.
|
||||||
|
category: fix
|
||||||
|
dev: Centralizes Planning Mode task handoff formatting across single, CLI, and multi-task creation.
|
||||||
@@ -606,7 +606,8 @@ After five completed answers, mobile interviews show **Next question** and **Rev
|
|||||||
Choose **Validate** when the plan is ready. Validation is durable and immediately creates the task using the selected workflow and branch settings. If creation is interrupted after validation, Planning restores a create-only retry state; it never validates again or creates a second task. **Sessions** (and mobile Back) remains the escape hatch for browsing, switching, and reviewing session history, with **New session** pinned in the saved-session list. The AI never ends an interview on its own; selection questions still provide alternatives with pros and cons plus an **Other** free-text choice.
|
Choose **Validate** when the plan is ready. Validation is durable and immediately creates the task using the selected workflow and branch settings. If creation is interrupted after validation, Planning restores a create-only retry state; it never validates again or creates a second task. **Sessions** (and mobile Back) remains the escape hatch for browsing, switching, and reviewing session history, with **New session** pinned in the saved-session list. The AI never ends an interview on its own; selection questions still provide alternatives with pros and cons plus an **Other** free-text choice.
|
||||||
|
|
||||||
<!-- FNXC:PlanningMode 2026-07-20-12:00: FN-8441 separates the lean Planning Mode artifact from triage's executor specification. -->
|
<!-- FNXC:PlanningMode 2026-07-20-12:00: FN-8441 separates the lean Planning Mode artifact from triage's executor specification. -->
|
||||||
On creation, the validated running plan becomes **plan.md**: its title, description, size, suggested dependencies, and key deliverables are stored as the task description and task document `plan` (priority remains a task field, not a plan.md section). The original request that started the session is stored separately as `original-description`. The task planning agent later expands plan.md into the executor-ready **PROMPT.md**; PROMPT.md's **Original Description** preserves that original request verbatim.
|
<!-- FNXC:PlanningMode 2026-08-03-10:15: FN-8759 requires Planning Mode task handoff to retain the full ordered interview, including selected answers, custom Other text, and comments, without replacing the verbatim original request. -->
|
||||||
|
On creation, the validated running plan becomes **plan.md**: its title, description, size, suggested dependencies, and key deliverables are stored as the task description and task document `plan` (priority remains a task field, not a plan.md section). The full ordered Planning Mode interview Q&A, including selected answers, custom **Other** text, and comments, is retained with that plan for triage and executors. The original request that started the session is stored separately as `original-description`. The task planning agent later expands plan.md into the executor-ready **PROMPT.md**; PROMPT.md's **Original Description** preserves that original request verbatim.
|
||||||
|
|
||||||
- **Branch strategy** options mirror Subtask Breakdown semantics:
|
- **Branch strategy** options mirror Subtask Breakdown semantics:
|
||||||
- `Use project/default branch`
|
- `Use project/default branch`
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { PlanningQuestion } from "@fusion/core";
|
import { parsePlanningPlanMd, type PlanningQuestion, type PlanningSummary } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
formatInitialPlanRequestForAgent,
|
formatInitialPlanRequestForAgent,
|
||||||
formatInitialRunningPlanRequestForAgent,
|
formatInitialRunningPlanRequestForAgent,
|
||||||
formatInterviewQA,
|
formatInterviewQA,
|
||||||
|
formatPlanningTaskHandoff,
|
||||||
formatResponseForAgent,
|
formatResponseForAgent,
|
||||||
normalizePlanningSummaryPayload,
|
normalizePlanningSummaryPayload,
|
||||||
} from "../planning";
|
} from "../planning";
|
||||||
@@ -122,3 +123,45 @@ describe("planning interview formatter Other answers", () => {
|
|||||||
expect(prompt).toMatch(/every accumulated decision/i);
|
expect(prompt).toMatch(/every accumulated decision/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("planning task handoff formatter", () => {
|
||||||
|
const summary: PlanningSummary = {
|
||||||
|
title: "Ship planning handoff",
|
||||||
|
description: "Preserve the lean plan.",
|
||||||
|
suggestedSize: "M",
|
||||||
|
suggestedDependencies: [],
|
||||||
|
keyDeliverables: ["Persist decisions"],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("includes every ordered answer once without mutating the lean summary", () => {
|
||||||
|
const longOther = "Long operator decision ".repeat(20);
|
||||||
|
const history = [
|
||||||
|
{ question: singleSelectQuestion, response: { scope: "mvp", _other: longOther, _comment: "Keep this comment" } },
|
||||||
|
{ question: multiSelectQuestion, response: { priorities: ["speed", "quality"] } },
|
||||||
|
{ question: confirmQuestion, response: { proceed: true } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handoff = formatPlanningTaskHandoff(summary, history);
|
||||||
|
const qa = formatInterviewQA(history);
|
||||||
|
|
||||||
|
expect(handoff).toContain(qa);
|
||||||
|
expect(handoff.match(/## Planning Interview Context/g)).toHaveLength(1);
|
||||||
|
expect(handoff).toContain("MVP");
|
||||||
|
expect(handoff).toContain("Speed, Quality");
|
||||||
|
expect(handoff).toContain("Yes");
|
||||||
|
expect(handoff).toContain(longOther);
|
||||||
|
expect(handoff).toContain("Keep this comment");
|
||||||
|
expect(summary.description).toBe("Preserve the lean plan.");
|
||||||
|
expect(parsePlanningPlanMd(handoff)).toMatchObject({
|
||||||
|
title: summary.title,
|
||||||
|
description: expect.stringContaining(longOther),
|
||||||
|
});
|
||||||
|
expect(formatPlanningTaskHandoff({ ...summary, description: `${summary.description}\n\n${qa}` }, history).match(/## Planning Interview Context/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps empty interview handoffs canonical without an empty context shell", () => {
|
||||||
|
const handoff = formatPlanningTaskHandoff(summary, []);
|
||||||
|
expect(handoff).not.toContain("Planning Interview Context");
|
||||||
|
expect(parsePlanningPlanMd(handoff)).toMatchObject({ description: summary.description });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -266,8 +266,43 @@ describe("planning question regeneration instead of no-active-question errors",
|
|||||||
createTask,
|
createTask,
|
||||||
} as unknown as TaskStore;
|
} as unknown as TaskStore;
|
||||||
|
|
||||||
|
const session = await getSession(sessionId);
|
||||||
|
const longOther = "Lossless custom decision ".repeat(20);
|
||||||
|
session!.history = [
|
||||||
|
{
|
||||||
|
question: {
|
||||||
|
id: "direction",
|
||||||
|
type: "single_select",
|
||||||
|
question: "Which direction?",
|
||||||
|
options: [{ id: "safe", label: "Safe rollout" }],
|
||||||
|
},
|
||||||
|
response: { direction: "safe", _other: longOther, _comment: "Keep the audit trail" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: {
|
||||||
|
id: "priorities",
|
||||||
|
type: "multi_select",
|
||||||
|
question: "Which priorities?",
|
||||||
|
options: [{ id: "quality", label: "Quality" }, { id: "speed", label: "Speed" }],
|
||||||
|
},
|
||||||
|
response: { priorities: ["quality", "speed"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: { id: "confirm", type: "confirm", question: "Proceed?" },
|
||||||
|
response: { confirm: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const first = await createTaskFromPlanSession(sessionId, taskStore);
|
const first = await createTaskFromPlanSession(sessionId, taskStore);
|
||||||
expect(first.alreadyCreated).toBe(false);
|
expect(first.alreadyCreated).toBe(false);
|
||||||
|
const firstDescription = createTask.mock.calls[0]![0].description;
|
||||||
|
expect(firstDescription).toContain("## Planning Interview Context");
|
||||||
|
expect(firstDescription).toContain("Safe rollout");
|
||||||
|
expect(firstDescription).toContain("Quality, Speed");
|
||||||
|
expect(firstDescription).toContain("Yes");
|
||||||
|
expect(firstDescription).toContain(longOther);
|
||||||
|
expect(firstDescription).toContain("Keep the audit trail");
|
||||||
|
expect(firstDescription.match(/## Planning Interview Context/g)).toHaveLength(1);
|
||||||
expect(createTask).toHaveBeenCalledTimes(1);
|
expect(createTask).toHaveBeenCalledTimes(1);
|
||||||
expect(createTask.mock.calls[0][0].proposalClaimId).toBe(`planning-session:${sessionId}`);
|
expect(createTask.mock.calls[0][0].proposalClaimId).toBe(`planning-session:${sessionId}`);
|
||||||
expect((await getSession(sessionId))?.validated).toBe(true);
|
expect((await getSession(sessionId))?.validated).toBe(true);
|
||||||
|
|||||||
@@ -2619,6 +2619,11 @@ describe("Planning Mode Routes", () => {
|
|||||||
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
|
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
|
||||||
}), { "Content-Type": "application/json" });
|
}), { "Content-Type": "application/json" });
|
||||||
await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "Content-Type": "application/json" });
|
await REQUEST(buildApp(), "POST", `/api/planning/${sessionId}/validate`, undefined, { "Content-Type": "application/json" });
|
||||||
|
const completedSession = await planningModule.getSession(sessionId);
|
||||||
|
completedSession!.history = [{
|
||||||
|
question: { id: "handoff", type: "text", question: "What must remain durable?" },
|
||||||
|
response: "Must have login",
|
||||||
|
}];
|
||||||
|
|
||||||
// Create task from planning
|
// Create task from planning
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
@@ -2631,6 +2636,10 @@ describe("Planning Mode Routes", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(store.createTask).toHaveBeenCalled();
|
expect(store.createTask).toHaveBeenCalled();
|
||||||
|
const [createInput] = (store.createTask as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||||
|
expect(createInput.description).toContain("## Planning Interview Context");
|
||||||
|
expect(createInput.description).toContain("Must have login");
|
||||||
|
expect(createInput.description.match(/## Planning Interview Context/g)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("terminalizes a not-yet-validated session when Proceed with plan creates its task", async () => {
|
it("terminalizes a not-yet-validated session when Proceed with plan creates its task", async () => {
|
||||||
@@ -3266,6 +3275,16 @@ describe("Planning Mode Routes", () => {
|
|||||||
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||||
|
|
||||||
const planningSessionId = await createCompletedPlanningSession();
|
const planningSessionId = await createCompletedPlanningSession();
|
||||||
|
const planningSession = await planningModule.getSession(planningSessionId);
|
||||||
|
planningSession!.history = [{
|
||||||
|
question: {
|
||||||
|
id: "retention",
|
||||||
|
type: "single_select",
|
||||||
|
question: "Which retention policy?",
|
||||||
|
options: [{ id: "full", label: "Keep full interview context" }],
|
||||||
|
},
|
||||||
|
response: { retention: "full", _other: "Preserve every custom answer", _comment: "No truncation" },
|
||||||
|
}];
|
||||||
const breakdownRes = await REQUEST(
|
const breakdownRes = await REQUEST(
|
||||||
buildApp(),
|
buildApp(),
|
||||||
"POST",
|
"POST",
|
||||||
@@ -3309,9 +3328,16 @@ describe("Planning Mode Routes", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(res.body.tasks).toHaveLength(2);
|
expect(res.body.tasks).toHaveLength(2);
|
||||||
|
for (const [input] of (store.createTask as ReturnType<typeof vi.fn>).mock.calls) {
|
||||||
|
expect(input.description).toContain("## Planning Interview Context");
|
||||||
|
expect(input.description).toContain("Keep full interview context");
|
||||||
|
expect(input.description).toContain("Preserve every custom answer");
|
||||||
|
expect(input.description).toContain("No truncation");
|
||||||
|
expect(input.description.match(/## Planning Interview Context/g)).toHaveLength(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the completed planning session in history after multi-task creation", async () => {
|
it("keeps the completed planning session in history after multi-task creation", async () => {
|
||||||
// Bug C: /planning/create-tasks used cleanupSession() which deleted the
|
// Bug C: /planning/create-tasks used cleanupSession() which deleted the
|
||||||
// persisted ai_sessions row, so a session that ran to completion AND
|
// persisted ai_sessions row, so a session that ran to completion AND
|
||||||
// created tasks vanished from the saved-sessions history. It must instead
|
// created tasks vanished from the saved-sessions history. It must instead
|
||||||
|
|||||||
@@ -3874,15 +3874,12 @@ function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknow
|
|||||||
case "text":
|
case "text":
|
||||||
return typeof responseValue === "string" ? responseValue : String(responseValue ?? "");
|
return typeof responseValue === "string" ? responseValue : String(responseValue ?? "");
|
||||||
|
|
||||||
case "single_select":
|
case "single_select": {
|
||||||
if (other.length > 0) {
|
const selected = typeof responseValue === "string"
|
||||||
return `${other} (user's own answer)`;
|
? question.options?.find((candidate) => candidate.id === responseValue)?.label || responseValue
|
||||||
}
|
: String(responseValue ?? "");
|
||||||
if (typeof responseValue === "string") {
|
return [selected, other.length > 0 ? `${other} (user's own answer)` : ""].filter(Boolean).join(", ");
|
||||||
const option = question.options?.find((candidate) => candidate.id === responseValue);
|
}
|
||||||
return option?.label || responseValue;
|
|
||||||
}
|
|
||||||
return String(responseValue ?? "");
|
|
||||||
|
|
||||||
case "multi_select": {
|
case "multi_select": {
|
||||||
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
|
const selected = Array.isArray(responseValue) ? responseValue.map((id) => {
|
||||||
@@ -3898,8 +3895,10 @@ function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknow
|
|||||||
return selected.length > 0 ? selected.join(", ") : String(responseValue ?? "");
|
return selected.length > 0 ? selected.join(", ") : String(responseValue ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "confirm":
|
case "confirm": {
|
||||||
return other.length > 0 ? `${other} (user's own answer)` : responseValue === true ? "Yes" : "No";
|
const selected = responseValue === true ? "Yes" : "No";
|
||||||
|
return [selected, other.length > 0 ? `${other} (user's own answer)` : ""].filter(Boolean).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return JSON.stringify(responseValue);
|
return JSON.stringify(responseValue);
|
||||||
@@ -3932,6 +3931,25 @@ export function formatInterviewQA(
|
|||||||
return `## Planning Interview Context\n\n${entries.join("\n\n")}`;
|
return `## Planning Interview Context\n\n${entries.join("\n\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMode 2026-08-03-10:03:
|
||||||
|
Every task created from Planning Mode must retain the ordered interview decisions that shaped its
|
||||||
|
lean plan. Compose a copy for the task handoff so the authoritative running summary stays lean,
|
||||||
|
empty sessions add no shell, and replaying an already-composed child cannot duplicate Q&A.
|
||||||
|
*/
|
||||||
|
export function formatPlanningTaskHandoff(
|
||||||
|
summary: PlanningSummary,
|
||||||
|
history: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||||
|
): string {
|
||||||
|
const qaSection = formatInterviewQA(history);
|
||||||
|
const description = summary.description.trim();
|
||||||
|
const handoffDescription = qaSection && !description.includes(qaSection)
|
||||||
|
? `${description}\n\n${qaSection}`
|
||||||
|
: description;
|
||||||
|
|
||||||
|
return formatPlanningPlanMd({ ...summary, description: handoffDescription });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel and cleanup a planning session.
|
* Cancel and cleanup a planning session.
|
||||||
*/
|
*/
|
||||||
@@ -4191,7 +4209,7 @@ export async function createTaskFromPlanSession(
|
|||||||
// FNXC:PlanningMultiTask 2026-07-24-03:40: review finding — a post-insert failure (e.g. finalize) lands in the raced-insert catch; without this marker the task WE created was mislabeled alreadyCreated:true.
|
// FNXC:PlanningMultiTask 2026-07-24-03:40: review finding — a post-insert failure (e.g. finalize) lands in the raced-insert catch; without this marker the task WE created was mislabeled alreadyCreated:true.
|
||||||
let insertedTask: Task | undefined;
|
let insertedTask: Task | undefined;
|
||||||
try {
|
try {
|
||||||
const planMd = formatPlanningPlanMd(summary);
|
const planMd = formatPlanningTaskHandoff(summary, session.history);
|
||||||
const originalRequest = session.initialPlan?.trim() || summary.description.trim();
|
const originalRequest = session.initialPlan?.trim() || summary.description.trim();
|
||||||
const task = await store.createTask({
|
const task = await store.createTask({
|
||||||
title: summary.title,
|
title: summary.title,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_TASK_PRIORITY,
|
DEFAULT_TASK_PRIORITY,
|
||||||
formatPlanningPlanMd,
|
|
||||||
resolveEffectiveSettingsDetailedById,
|
resolveEffectiveSettingsDetailedById,
|
||||||
resolvePlanningSettingsModel,
|
resolvePlanningSettingsModel,
|
||||||
TASK_PRIORITIES,
|
TASK_PRIORITIES,
|
||||||
@@ -1203,6 +1202,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
releasePlanningTaskCreation,
|
releasePlanningTaskCreation,
|
||||||
validateSession,
|
validateSession,
|
||||||
planningProposalClaimId,
|
planningProposalClaimId,
|
||||||
|
formatPlanningTaskHandoff,
|
||||||
} = await import("../planning.js");
|
} = await import("../planning.js");
|
||||||
|
|
||||||
let session = await getSession(sessionId);
|
let session = await getSession(sessionId);
|
||||||
@@ -1433,7 +1433,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
FN-8441 hands the current lean plan to triage as task description plus a plan
|
FN-8441 hands the current lean plan to triage as task description plus a plan
|
||||||
document. The raw session request remains a separate original-description document.
|
document. The raw session request remains a separate original-description document.
|
||||||
*/
|
*/
|
||||||
const planMd = formatPlanningPlanMd(summary);
|
const planMd = formatPlanningTaskHandoff(summary, session?.history ?? []);
|
||||||
// Persisted legacy sessions can lack initialPlan; retain the pre-format plan body,
|
// Persisted legacy sessions can lack initialPlan; retain the pre-format plan body,
|
||||||
// never the session title, as the only fail-soft operator-request substitute.
|
// never the session title, as the only fail-soft operator-request substitute.
|
||||||
const originalRequest = typeof initialPlan === "string" && initialPlan.trim()
|
const originalRequest = typeof initialPlan === "string" && initialPlan.trim()
|
||||||
@@ -1598,7 +1598,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const { getSession, releaseSession, formatInterviewQA, mergePlanningSubtaskDrafts } = await import("../planning.js");
|
const { getSession, releaseSession, formatInterviewQA, formatPlanningTaskHandoff, mergePlanningSubtaskDrafts } = await import("../planning.js");
|
||||||
|
|
||||||
const session = await getSession(planningSessionId);
|
const session = await getSession(planningSessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -1695,13 +1695,13 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
|||||||
|
|
||||||
for (const item of mergedSubtasks) {
|
for (const item of mergedSubtasks) {
|
||||||
const itemDescription = typeof item.description === "string" ? item.description.trim() : item.title.trim();
|
const itemDescription = typeof item.description === "string" ? item.description.trim() : item.title.trim();
|
||||||
const planMd = formatPlanningPlanMd({
|
const planMd = formatPlanningTaskHandoff({
|
||||||
title: item.title.trim(),
|
title: item.title.trim(),
|
||||||
description: itemDescription,
|
description: itemDescription,
|
||||||
suggestedSize: item.suggestedSize ?? session.summary.suggestedSize,
|
suggestedSize: item.suggestedSize ?? session.summary.suggestedSize,
|
||||||
suggestedDependencies: item.dependsOn ?? [],
|
suggestedDependencies: item.dependsOn ?? [],
|
||||||
keyDeliverables: [itemDescription],
|
keyDeliverables: [itemDescription],
|
||||||
});
|
}, session.history);
|
||||||
const { workingBranch: taskBranch } = resolveEntryPointBranchAssignment({
|
const { workingBranch: taskBranch } = resolveEntryPointBranchAssignment({
|
||||||
assignmentMode: branchMode,
|
assignmentMode: branchMode,
|
||||||
resolvedBranch,
|
resolvedBranch,
|
||||||
|
|||||||
Reference in New Issue
Block a user