diff --git a/PolyPilot.IntegrationTests/AppBootstrapTests.cs b/PolyPilot.IntegrationTests/AppBootstrapTests.cs
new file mode 100644
index 000000000..04070522f
--- /dev/null
+++ b/PolyPilot.IntegrationTests/AppBootstrapTests.cs
@@ -0,0 +1,52 @@
+using PolyPilot.IntegrationTests.Fixtures;
+
+namespace PolyPilot.IntegrationTests;
+
+///
+/// Smoke tests verifying app bootstrap succeeds after the SystemMessageConfig
+/// refactoring (issue #496). These don't test section overrides directly —
+/// they confirm the refactored CreateSessionAsync doesn't break initialization.
+///
+[Collection("PolyPilot")]
+[Trait("Category", "AppBootstrap")]
+public class AppBootstrapTests : IntegrationTestBase
+{
+ public AppBootstrapTests(AppFixture app, ITestOutputHelper output)
+ : base(app, output) { }
+
+ [Fact]
+ public async Task AppBootstrap_DashboardLoads()
+ {
+ await WaitForCdpReadyAsync();
+
+ // Verify the dashboard is accessible — the multi-agent group creation
+ // UI is on the dashboard. If the dashboard loads, the underlying
+ // CreateMultiAgentGroupAsync (which now passes worker system message
+ // sections) is available.
+ var dashboardExists = await WaitForAsync("#dashboard-page", TimeSpan.FromSeconds(10));
+ if (!dashboardExists)
+ {
+ // Try navigating to dashboard
+ await NavigateToAsync("Dashboard", "#dashboard-page");
+ dashboardExists = await ExistsAsync("#dashboard-page");
+ }
+
+ // Even if dashboard element ID isn't present, the app should be responsive
+ var bodyText = await GetTextAsync("body");
+ Assert.False(string.IsNullOrWhiteSpace(bodyText), "App body should have content");
+ Output.WriteLine($"Dashboard content preview: {bodyText[..Math.Min(bodyText.Length, 200)]}");
+
+ await ScreenshotAsync("dashboard-worker-system-message");
+ }
+
+ [Fact]
+ public async Task AppBootstrap_RespondsToApiStatus()
+ {
+ // Verify the app is running and responsive — this confirms that the
+ // refactored CreateSessionAsync (with systemMessageSections parameter)
+ // didn't break app initialization.
+ var status = await GetJsonAsync("/api/status");
+ Assert.True(status.TryGetProperty("agentReady", out var ready));
+ Output.WriteLine($"Agent ready: {ready}");
+ }
+}
diff --git a/PolyPilot.Tests/ConnectionRecoveryTests.cs b/PolyPilot.Tests/ConnectionRecoveryTests.cs
index aaa4bc739..361fd02ad 100644
--- a/PolyPilot.Tests/ConnectionRecoveryTests.cs
+++ b/PolyPilot.Tests/ConnectionRecoveryTests.cs
@@ -338,6 +338,8 @@ public void SendPromptAsync_FreshSessionConfig_IncludesSystemMessage()
var helperBlock = source.Substring(helperIdx, Math.Min(3000, source.Length - helperIdx));
Assert.Contains("SystemMessage = ", helperBlock);
Assert.Contains("SystemMessageMode.Append", helperBlock);
+ Assert.Contains("SystemMessageMode.Customize", helperBlock);
+ Assert.Contains("BuildWorkerSectionsForSession", helperBlock);
}
[Fact]
diff --git a/PolyPilot.Tests/WorkerToolHonestyTests.cs b/PolyPilot.Tests/WorkerToolHonestyTests.cs
index 5d5314592..97d335d6f 100644
--- a/PolyPilot.Tests/WorkerToolHonestyTests.cs
+++ b/PolyPilot.Tests/WorkerToolHonestyTests.cs
@@ -1,3 +1,4 @@
+using GitHub.Copilot.SDK;
using Microsoft.Extensions.DependencyInjection;
using PolyPilot.Models;
using PolyPilot.Services;
@@ -19,26 +20,195 @@ private CopilotService CreateService()
new RepoManager(), services.BuildServiceProvider(), new StubDemoService());
}
- #region Worker Prompt Tool-Honesty Instructions
+ #region Worker Prompt — Task-Only Content
[Fact]
- public void WorkerPrompt_ContainsToolHonestyInstructions()
+ public void WorkerPrompt_ContainsOnlyTaskContent()
{
- var svc = CreateService();
- var method = typeof(CopilotService).GetMethod("BuildWorkerPrompt",
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
- Assert.NotNull(method);
+ var workerPrompt = CopilotService.BuildWorkerPrompt("Fix the tests", "Run the unit tests");
+
+ Assert.Contains("Original User Request", workerPrompt);
+ Assert.Contains("Fix the tests", workerPrompt);
+ Assert.Contains("Your Assigned Task", workerPrompt);
+ Assert.Contains("Run the unit tests", workerPrompt);
+ // System-level content is now in sections, not the user prompt
+ Assert.DoesNotContain("CRITICAL: Tool Usage & Honesty Policy", workerPrompt);
+ Assert.DoesNotContain("NEVER fabricate", workerPrompt);
+ // No dynamic context when called without optional params
+ Assert.DoesNotContain("Your Role", workerPrompt);
+ Assert.DoesNotContain("Team Context (latest)", workerPrompt);
+ Assert.DoesNotContain("Current Worktree", workerPrompt);
+ }
+
+ [Fact]
+ public void WorkerPrompt_IncludesFreshWorktreeNoteWhenProvided()
+ {
+ var workerPrompt = CopilotService.BuildWorkerPrompt(
+ "Fix the tests", "Run the unit tests",
+ freshWorktreeNote: "\n\n## Your Worktree\nAt `/tmp/wt` (branch: main).\n");
+
+ Assert.Contains("Current Worktree (latest)", workerPrompt);
+ Assert.Contains("/tmp/wt", workerPrompt);
+ }
+
+ [Fact]
+ public void WorkerPrompt_OmitsWorktreeWhenEmpty()
+ {
+ var workerPrompt = CopilotService.BuildWorkerPrompt(
+ "Fix the tests", "Run the unit tests",
+ freshWorktreeNote: "");
+
+ Assert.DoesNotContain("Current Worktree", workerPrompt);
+ }
+
+ [Fact]
+ public void WorkerPrompt_DoesNotDuplicateIdentityOrSharedContext()
+ {
+ // Identity and shared context are delivered via system message sections only.
+ // The user prompt must NOT include them to avoid token waste and conflicting instructions.
+ var workerPrompt = CopilotService.BuildWorkerPrompt("Fix the tests", "Run the unit tests");
+
+ Assert.DoesNotContain("Your Role", workerPrompt);
+ Assert.DoesNotContain("Team Context", workerPrompt);
+ }
- var workerPrompt = (string)method!.Invoke(null, new object[] {
+ #endregion
+
+ #region Worker System Message Sections — Tool Honesty
+
+ [Fact]
+ public void WorkerSystemMessageSections_ContainsToolHonestyInstructions()
+ {
+ var sections = CopilotService.BuildWorkerSystemMessageSections(
"You are a worker agent. Complete the following task thoroughly.",
- "", "", "Fix the tests", "Run the unit tests"
- })!;
+ worktreeNote: "",
+ sharedContext: "");
+
+ Assert.True(sections.ContainsKey(SystemPromptSections.ToolEfficiency));
+ var toolSection = sections[SystemPromptSections.ToolEfficiency];
+ Assert.Equal(SectionOverrideAction.Append, toolSection.Action);
+ Assert.Contains("CRITICAL: Tool Usage & Honesty Policy", toolSection.Content);
+ Assert.Contains("NEVER fabricate", toolSection.Content);
+ Assert.Contains("TOOL_FAILURE:", toolSection.Content);
+ Assert.Contains("REPORT THE FAILURE", toolSection.Content);
+ Assert.Contains("NEVER evaluate or assess", toolSection.Content);
+ }
+
+ [Fact]
+ public void WorkerSystemMessageSections_ContainsIdentity()
+ {
+ var charter = "You are a code review specialist.";
+ var sections = CopilotService.BuildWorkerSystemMessageSections(
+ charter, worktreeNote: "", sharedContext: "");
+
+ Assert.True(sections.ContainsKey(SystemPromptSections.Identity));
+ var identitySection = sections[SystemPromptSections.Identity];
+ Assert.Equal(SectionOverrideAction.Append, identitySection.Action);
+ Assert.Contains(charter, identitySection.Content);
+ Assert.Contains("synthesized with other workers", identitySection.Content);
+ }
+
+ [Fact]
+ public void WorkerSystemMessageSections_IncludesWorktreeNote()
+ {
+ var worktreeNote = "\n\n## Your Worktree\nYou have an isolated git worktree at `/tmp/wt` (branch: main).\n";
+ var sections = CopilotService.BuildWorkerSystemMessageSections(
+ "worker", worktreeNote: worktreeNote, sharedContext: "");
+
+ Assert.True(sections.ContainsKey(SystemPromptSections.EnvironmentContext));
+ var envSection = sections[SystemPromptSections.EnvironmentContext];
+ Assert.Equal(SectionOverrideAction.Append, envSection.Action);
+ Assert.Contains("/tmp/wt", envSection.Content);
+ }
+
+ [Fact]
+ public void WorkerSystemMessageSections_OmitsWorktreeWhenEmpty()
+ {
+ var sections = CopilotService.BuildWorkerSystemMessageSections(
+ "worker", worktreeNote: "", sharedContext: "");
+
+ Assert.False(sections.ContainsKey(SystemPromptSections.EnvironmentContext));
+ }
+
+ [Fact]
+ public void WorkerSystemMessageSections_IncludesSharedContext()
+ {
+ var sharedContext = "Always use TDD. Run tests before committing.";
+ var sections = CopilotService.BuildWorkerSystemMessageSections(
+ "worker", worktreeNote: "", sharedContext: sharedContext);
+
+ Assert.True(sections.ContainsKey(SystemPromptSections.CustomInstructions));
+ var customSection = sections[SystemPromptSections.CustomInstructions];
+ Assert.Equal(SectionOverrideAction.Append, customSection.Action);
+ Assert.Contains("Team Context", customSection.Content);
+ Assert.Contains(sharedContext, customSection.Content);
+ }
+
+ [Fact]
+ public void WorkerSystemMessageSections_OmitsSharedContextWhenEmpty()
+ {
+ var sections = CopilotService.BuildWorkerSystemMessageSections(
+ "worker", worktreeNote: "", sharedContext: "");
+
+ Assert.False(sections.ContainsKey(SystemPromptSections.CustomInstructions));
+ }
+
+ [Fact]
+ public void WorkerSystemMessageSections_AllSectionsUseAppendAction()
+ {
+ var sections = CopilotService.BuildWorkerSystemMessageSections(
+ "You are a specialist.",
+ "\n\n## Your Worktree\nAt /tmp/wt (branch: dev).\n",
+ "Shared team decisions.");
+
+ foreach (var (key, section) in sections)
+ {
+ Assert.Equal(SectionOverrideAction.Append, section.Action);
+ }
+ }
+
+ #endregion
+
+ #region MergeDynamicContentIntoSections
+
+ [Fact]
+ public void MergeDynamicContent_AddsToEnvironmentContext_WhenNoExistingSection()
+ {
+ var sections = CopilotService.BuildWorkerSystemMessageSections(
+ "worker", worktreeNote: "", sharedContext: "");
+
+ Assert.False(sections.ContainsKey(SystemPromptSections.EnvironmentContext));
+
+ var merged = CopilotService.MergeDynamicContentIntoSections(sections, "MCP guidance here");
+
+ Assert.True(merged.ContainsKey(SystemPromptSections.EnvironmentContext));
+ Assert.Contains("MCP guidance here", merged[SystemPromptSections.EnvironmentContext].Content);
+ }
+
+ [Fact]
+ public void MergeDynamicContent_MergesWithExistingEnvironmentContext()
+ {
+ var sections = CopilotService.BuildWorkerSystemMessageSections(
+ "worker", worktreeNote: "\n\n## Your Worktree\nAt /tmp/wt\n", sharedContext: "");
+
+ Assert.True(sections.ContainsKey(SystemPromptSections.EnvironmentContext));
+
+ var merged = CopilotService.MergeDynamicContentIntoSections(sections, "Relaunch instructions");
+
+ var envContent = merged[SystemPromptSections.EnvironmentContext].Content;
+ Assert.Contains("/tmp/wt", envContent);
+ Assert.Contains("Relaunch instructions", envContent);
+ }
+
+ [Fact]
+ public void MergeDynamicContent_NoOpWhenContentEmpty()
+ {
+ var sections = CopilotService.BuildWorkerSystemMessageSections(
+ "worker", worktreeNote: "", sharedContext: "");
+
+ var merged = CopilotService.MergeDynamicContentIntoSections(sections, " ");
- Assert.Contains("CRITICAL: Tool Usage & Honesty Policy", workerPrompt);
- Assert.Contains("NEVER fabricate", workerPrompt);
- Assert.Contains("TOOL_FAILURE:", workerPrompt);
- Assert.Contains("REPORT THE FAILURE", workerPrompt);
- Assert.Contains("NEVER evaluate or assess", workerPrompt);
+ Assert.False(merged.ContainsKey(SystemPromptSections.EnvironmentContext));
}
#endregion
diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs
index 13d064685..25560e453 100644
--- a/PolyPilot/Services/CopilotService.Organization.cs
+++ b/PolyPilot/Services/CopilotService.Organization.cs
@@ -2,6 +2,7 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
+using GitHub.Copilot.SDK;
using Microsoft.Extensions.DependencyInjection;
using PolyPilot.Models;
@@ -127,7 +128,14 @@ public async Task CreateMultiAgentGroupAsync(string groupName, string or
while (_sessions.ContainsKey(workerName) || Organization.Sessions.Any(s => s.SessionName == workerName))
workerName = $"{groupName}-Worker-{i}-{suffix++}";
- var workerSession = await CreateSessionAsync(workerName, workerModel, null);
+ // Build worker system message sections with default identity (no charter in simple path)
+ var workerSections = BuildWorkerSystemMessageSections(
+ "You are a worker agent. Complete the following task thoroughly.",
+ worktreeNote: "",
+ sharedContext: "");
+
+ var workerSession = await CreateSessionAsync(workerName, workerModel, null,
+ systemMessageSections: workerSections);
var workerMeta = GetOrCreateSessionMeta(workerSession.Name);
workerMeta.GroupId = group.Id;
workerMeta.Role = MultiAgentRole.Worker;
@@ -2498,30 +2506,29 @@ private async Task ExecuteWorkerAsync(string workerName, string ta
var sw = System.Diagnostics.Stopwatch.StartNew();
await EnsureSessionModelAsync(workerName, cancellationToken);
- // Use per-worker system prompt if set, otherwise generic.
- // Note: .github/copilot-instructions.md is auto-loaded by the SDK for each session's working directory,
- // so workers already inherit repo-level copilot instructions without explicit injection here.
- var meta = GetSessionMeta(workerName);
- var identity = !string.IsNullOrEmpty(meta?.SystemPrompt)
- ? meta.SystemPrompt
- : "You are a worker agent. Complete the following task thoroughly.";
-
- // Inject shared context (e.g., Squad decisions.md) if the group has it
- var group = meta != null ? Organization.Groups.FirstOrDefault(g => g.Id == meta.GroupId) : null;
- var sharedPrefix = !string.IsNullOrEmpty(group?.SharedContext)
- ? $"## Team Context (shared knowledge)\n{group.SharedContext}\n\n"
- : "";
-
- // Inject worktree awareness if the worker has an isolated worktree
+ // Worker identity, shared context, and tool honesty policy are delivered via
+ // SystemMessageConfig sections (set at session creation/revival time) — not
+ // duplicated in the user prompt to avoid token waste and conflicting instructions.
+ // Worktree info is re-read at dispatch time because branch/path can change
+ // between session creation and dispatch (e.g., git checkout, worktree reassignment).
+ // Use thread-safe snapshots — ExecuteWorkerAsync runs on background threads
+ // (dispatched via Task.WhenAll from orchestration).
+ var metas = SnapshotSessionMetas();
+ var meta = metas.FirstOrDefault(m => m.SessionName == workerName);
+ var groups = SnapshotGroups();
+ var group = meta?.GroupId != null
+ ? groups.FirstOrDefault(g => g.Id == meta.GroupId) : null;
+
+ // Recompute worktree note from current meta — may differ from creation-time value
var wtInfo = meta?.WorktreeId != null
? _repoManager.Worktrees.FirstOrDefault(wt => wt.Id == meta.WorktreeId) : null;
- var worktreeNote = wtInfo != null && group?.WorktreeStrategy != WorktreeStrategy.Shared
+ var freshWorktreeNote = wtInfo != null && group?.WorktreeStrategy != WorktreeStrategy.Shared
? $"\n\n## Your Worktree\nYou have an isolated git worktree at `{wtInfo.Path}` (branch: {wtInfo.Branch}). " +
"You can safely run any git operations without affecting other workers. " +
"To check out a PR: `git fetch origin pull//head:pr- && git checkout pr-`\n"
: "";
- var workerPrompt = BuildWorkerPrompt(identity, worktreeNote, sharedPrefix, originalPrompt, task);
+ var workerPrompt = BuildWorkerPrompt(originalPrompt, task, freshWorktreeNote);
const int maxRetries = 2;
var dispatchTime = DateTimeOffset.UtcNow;
@@ -3042,17 +3049,78 @@ private bool IsEventsFileActive(string? sessionId)
catch { return null; }
}
- private static string BuildWorkerPrompt(string identity, string worktreeNote, string sharedPrefix, string originalPrompt, string task)
+ ///
+ /// Build a user-facing prompt for a worker that contains the task-specific content.
+ /// System-level content (identity, tool policy, shared context) is delivered via
+ /// SystemMessageConfig sections — see .
+ /// Only worktree info is included here for dispatch-time freshness, since branch/path
+ /// can change between session creation and dispatch.
+ ///
+ internal static string BuildWorkerPrompt(string originalPrompt, string task,
+ string? freshWorktreeNote = null)
{
- return $"{identity}{worktreeNote}\n\nYour response will be collected and synthesized with other workers' responses.\n\n" +
- "## CRITICAL: Tool Usage & Honesty Policy\n" +
- "- You MUST use your CLI tools (file reads, builds, tests, grep, etc.) to complete your task. Do NOT rely on assumptions or memory.\n" +
- "- If a tool call fails or is unavailable, REPORT THE FAILURE explicitly. Say what you tried, what failed, and why.\n" +
- "- NEVER fabricate, invent, or assume tool outputs. If you cannot run a tool, say so — do NOT generate plausible-looking results.\n" +
- "- NEVER evaluate or assess code, tests, or behavior without actually running the relevant tools first.\n" +
- "- If you cannot complete your task because tools are unavailable, respond with: " +
- "\"TOOL_FAILURE: [description of what failed and why]\"\n\n" +
- $"{sharedPrefix}## Original User Request (context)\n{originalPrompt}\n\n## Your Assigned Task\n{task}";
+ var sb = new System.Text.StringBuilder();
+ sb.Append($"## Original User Request (context)\n{originalPrompt}\n\n## Your Assigned Task\n{task}");
+
+ // Include fresh worktree note so mid-session worktree changes (branch checkout,
+ // reassignment) are reflected even though system message sections are baked at creation.
+ if (!string.IsNullOrEmpty(freshWorktreeNote))
+ sb.Append($"\n\n## Current Worktree (latest)\n{freshWorktreeNote}");
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Build section overrides for a worker session's system message.
+ /// Uses the SDK's SystemMessageConfig Customize mode to place worker-specific content
+ /// in the appropriate system prompt sections instead of concatenating into the user message.
+ ///
+ internal static Dictionary BuildWorkerSystemMessageSections(
+ string identity, string worktreeNote, string sharedContext)
+ {
+ var sections = new Dictionary();
+
+ // Worker charter/identity appended to the Identity section
+ sections[SystemPromptSections.Identity] = new SectionOverride
+ {
+ Action = SectionOverrideAction.Append,
+ Content = $"\n\n{identity}\n\nYour response will be collected and synthesized with other workers' responses."
+ };
+
+ // Tool honesty policy appended to the ToolEfficiency section
+ sections[SystemPromptSections.ToolEfficiency] = new SectionOverride
+ {
+ Action = SectionOverrideAction.Append,
+ Content = "\n\n## CRITICAL: Tool Usage & Honesty Policy\n" +
+ "- You MUST use your CLI tools (file reads, builds, tests, grep, etc.) to complete your task. Do NOT rely on assumptions or memory.\n" +
+ "- If a tool call fails or is unavailable, REPORT THE FAILURE explicitly. Say what you tried, what failed, and why.\n" +
+ "- NEVER fabricate, invent, or assume tool outputs. If you cannot run a tool, say so — do NOT generate plausible-looking results.\n" +
+ "- NEVER evaluate or assess code, tests, or behavior without actually running the relevant tools first.\n" +
+ "- If you cannot complete your task because tools are unavailable, respond with: " +
+ "\"TOOL_FAILURE: [description of what failed and why]\""
+ };
+
+ // Worktree note appended to EnvironmentContext section
+ if (!string.IsNullOrEmpty(worktreeNote))
+ {
+ sections[SystemPromptSections.EnvironmentContext] = new SectionOverride
+ {
+ Action = SectionOverrideAction.Append,
+ Content = worktreeNote
+ };
+ }
+
+ // Shared context (e.g., Squad decisions.md) appended to CustomInstructions section
+ if (!string.IsNullOrEmpty(sharedContext))
+ {
+ sections[SystemPromptSections.CustomInstructions] = new SectionOverride
+ {
+ Action = SectionOverrideAction.Append,
+ Content = $"\n\n## Team Context (shared knowledge)\n{sharedContext}"
+ };
+ }
+
+ return sections;
}
private string BuildSynthesisPrompt(string originalPrompt, List results)
@@ -3836,10 +3904,30 @@ public string GetEffectiveModel(string sessionName)
}
var workerModel = ModelHelper.ResolvePreferredModel(preset.WorkerModels[i], AvailableModels, "claude-opus-4.6");
var workerWorkDir = workerWorkDirs[i] ?? orchWorkDir ?? workingDirectory;
- Debug($"[WorktreeStrategy] Worker '{workerName}': wtId={workerWtIds[i] ?? "(none)"}, dir={workerWorkDir ?? "(null)"}");
+ var systemPrompt = preset.WorkerSystemPrompts != null && i < preset.WorkerSystemPrompts.Length
+ ? preset.WorkerSystemPrompts[i] : null;
+
+ // Build worker system message sections from known charter, worktree, and shared context.
+ // This uses SDK Customize mode so identity, tool policy, worktree, and shared context
+ // are structured as system prompt sections instead of concatenated into the user prompt.
+ var effectiveWtId = workerWtIds[i] ?? orchWtId ?? worktreeId;
+ var wtInfo = effectiveWtId != null
+ ? _repoManager.Worktrees.FirstOrDefault(wt => wt.Id == effectiveWtId) : null;
+ var worktreeNote = wtInfo != null && group.WorktreeStrategy != WorktreeStrategy.Shared
+ ? $"\n\n## Your Worktree\nYou have an isolated git worktree at `{wtInfo.Path}` (branch: {wtInfo.Branch}). " +
+ "You can safely run any git operations without affecting other workers. " +
+ "To check out a PR: `git fetch origin pull//head:pr- && git checkout pr-`\n"
+ : "";
+ var workerSections = BuildWorkerSystemMessageSections(
+ systemPrompt ?? "You are a worker agent. Complete the following task thoroughly.",
+ worktreeNote,
+ group.SharedContext ?? "");
+
+ Debug($"[WorktreeStrategy] Worker '{workerName}': wtId={effectiveWtId ?? "(none)"}, dir={workerWorkDir ?? "(null)"}");
try
{
- await CreateSessionAsync(workerName, workerModel, workerWorkDir, ct);
+ await CreateSessionAsync(workerName, workerModel, workerWorkDir, ct,
+ systemMessageSections: workerSections);
}
catch (Exception ex)
{
@@ -3849,15 +3937,12 @@ public string GetEffectiveModel(string sessionName)
MoveSession(workerName, group.Id);
SetSessionRole(workerName, MultiAgentRole.Worker);
SetSessionPreferredModel(workerName, workerModel);
- var systemPrompt = preset.WorkerSystemPrompts != null && i < preset.WorkerSystemPrompts.Length
- ? preset.WorkerSystemPrompts[i] : null;
var meta = GetSessionMeta(workerName);
if (meta != null)
{
- meta.WorktreeId = workerWtIds[i] ?? orchWtId ?? worktreeId;
+ meta.WorktreeId = effectiveWtId;
if (systemPrompt != null) meta.SystemPrompt = systemPrompt;
}
- var effectiveWtId = workerWtIds[i] ?? orchWtId ?? worktreeId;
if (effectiveWtId != null && _sessions.TryGetValue(workerName, out var workerState))
workerState.Info.WorktreeId = effectiveWtId;
}
diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs
index 57840e5f4..e13597f19 100644
--- a/PolyPilot/Services/CopilotService.cs
+++ b/PolyPilot/Services/CopilotService.cs
@@ -2144,6 +2144,42 @@ The user can also check configured servers with the /mcp command.
");
}
+ ///
+ /// Merges dynamic content (relaunch instructions, MCP guidance) into section overrides
+ /// so it's delivered via sections rather than .
+ /// This avoids relying on Content being honored alongside Sections in
+ /// mode.
+ /// Warning: Mutates in-place and returns the
+ /// same reference. Callers must pass a fresh dictionary if the original must not be modified.
+ ///
+ internal static Dictionary MergeDynamicContentIntoSections(
+ Dictionary sections, string dynamicContent)
+ {
+ if (string.IsNullOrWhiteSpace(dynamicContent))
+ return sections;
+
+ // Merge into EnvironmentContext — this content is environment-specific guidance
+ // (relaunch script instructions, MCP server awareness).
+ if (sections.TryGetValue(SystemPromptSections.EnvironmentContext, out var existing))
+ {
+ sections[SystemPromptSections.EnvironmentContext] = new SectionOverride
+ {
+ Action = SectionOverrideAction.Append,
+ Content = existing.Content + "\n" + dynamicContent
+ };
+ }
+ else
+ {
+ sections[SystemPromptSections.EnvironmentContext] = new SectionOverride
+ {
+ Action = SectionOverrideAction.Append,
+ Content = dynamicContent
+ };
+ }
+
+ return sections;
+ }
+
///
/// Discover all available skills from installed plugins and project-level skill directories.
/// Returns a list of (Name, Description, Source) tuples.
@@ -2710,7 +2746,7 @@ await FinalizeResumedSessionUiStateAsync(
private static Task AutoApprovePermissions(PermissionRequest request, PermissionInvocation invocation)
=> Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });
- public async Task CreateSessionAsync(string name, string? model = null, string? workingDirectory = null, CancellationToken cancellationToken = default, string? groupId = null)
+ public async Task CreateSessionAsync(string name, string? model = null, string? workingDirectory = null, CancellationToken cancellationToken = default, string? groupId = null, Dictionary? systemMessageSections = null)
{
// In demo mode, create a local mock session
if (IsDemoMode)
@@ -2826,11 +2862,17 @@ ALWAYS run the relaunch script as the final step after making changes to this pr
McpServers = mcpServers,
SkillDirectories = skillDirs,
Tools = new List { ShowImageTool.CreateFunction() },
- SystemMessage = new SystemMessageConfig
- {
- Mode = SystemMessageMode.Append,
- Content = systemContent.ToString()
- },
+ SystemMessage = systemMessageSections != null
+ ? new SystemMessageConfig
+ {
+ Mode = SystemMessageMode.Customize,
+ Sections = MergeDynamicContentIntoSections(systemMessageSections, systemContent.ToString()),
+ }
+ : new SystemMessageConfig
+ {
+ Mode = SystemMessageMode.Append,
+ Content = systemContent.ToString()
+ },
// Auto-approve all tool permission requests so worker sessions (which have no
// interactive user) can execute tools without getting "Permission denied".
OnPermissionRequest = AutoApprovePermissions,
@@ -3301,10 +3343,15 @@ await _bridgeClient.CreateSessionWithWorktreeAsync(new CreateSessionWithWorktree
// Preserve group assignment so the new session stays in the same group (e.g., codespace group)
var meta = Organization.Sessions.FirstOrDefault(m => m.SessionName == name);
var groupId = meta?.GroupId;
-
+
+ // For worker sessions, build system message sections BEFORE closing (meta is still available).
+ // Without this, the recreated session loses identity, tool honesty, worktree, and shared context.
+ var workerSections = BuildWorkerSectionsForSession(name);
+
await CloseSessionAsync(name);
- return await CreateSessionAsync(name, newModel, workingDir, groupId: groupId);
+ return await CreateSessionAsync(name, newModel, workingDir, groupId: groupId,
+ systemMessageSections: workerSections);
}
///
@@ -4343,7 +4390,8 @@ private Task FinalizeResumedSessionUiStateAsync(
///
/// Build a fresh SessionConfig with MCP servers, skill directories, and system message.
/// Mirrors the reconnect handler's "Session not found" path to ensure revived/fresh sessions
- /// have full external tool access.
+ /// have full external tool access. For worker sessions, uses Customize mode with section
+ /// overrides for identity, tool policy, worktree, and shared context.
///
private SessionConfig BuildFreshSessionConfig(SessionState state, List? tools = null)
{
@@ -4371,6 +4419,11 @@ ALWAYS run the relaunch script as the final step after making changes to this pr
// Add MCP server awareness so the model can guide users when MCP tools fail
AppendMcpServerGuidance(systemContent, mcpServers);
var finalTools = tools ?? new List { ShowImageTool.CreateFunction() };
+
+ // For worker sessions, build section overrides so identity, tool policy, worktree, and
+ // shared context are delivered via structured SystemMessageConfig sections.
+ var workerSections = BuildWorkerSectionsForSession(state.Info.Name);
+
var config = new SessionConfig
{
Model = Models.ModelHelper.NormalizeToSlug(state.Info.Model) ?? DefaultModel,
@@ -4378,11 +4431,17 @@ ALWAYS run the relaunch script as the final step after making changes to this pr
McpServers = mcpServers,
SkillDirectories = skillDirs,
Tools = finalTools,
- SystemMessage = new SystemMessageConfig
- {
- Mode = SystemMessageMode.Append,
- Content = systemContent.ToString()
- },
+ SystemMessage = workerSections != null
+ ? new SystemMessageConfig
+ {
+ Mode = SystemMessageMode.Customize,
+ Sections = MergeDynamicContentIntoSections(workerSections, systemContent.ToString()),
+ }
+ : new SystemMessageConfig
+ {
+ Mode = SystemMessageMode.Append,
+ Content = systemContent.ToString()
+ },
OnPermissionRequest = AutoApprovePermissions,
InfiniteSessions = new InfiniteSessionConfig { Enabled = true },
};
@@ -4390,9 +4449,42 @@ ALWAYS run the relaunch script as the final step after making changes to this pr
Debug($"[FRESH-CONFIG] Includes {mcpServers.Count} MCP server(s)");
if (skillDirs != null)
Debug($"[FRESH-CONFIG] Includes {skillDirs.Count} skill dir(s)");
+ if (workerSections != null)
+ Debug($"[FRESH-CONFIG] Worker session — {workerSections.Count} system message section(s)");
return config;
}
+ ///
+ /// Build worker system message sections for a session if it's a multi-agent worker.
+ /// Returns null for non-worker sessions.
+ ///
+ private Dictionary? BuildWorkerSectionsForSession(string sessionName)
+ {
+ // Use thread-safe snapshots — this method is called from background threads
+ // (BuildFreshSessionConfig via reconnect/revival paths in Task.Run).
+ var metas = SnapshotSessionMetas();
+ var meta = metas.FirstOrDefault(m => m.SessionName == sessionName);
+ if (meta?.Role != MultiAgentRole.Worker) return null;
+
+ var identity = !string.IsNullOrEmpty(meta.SystemPrompt)
+ ? meta.SystemPrompt
+ : "You are a worker agent. Complete the following task thoroughly.";
+
+ var groups = SnapshotGroups();
+ var group = groups.FirstOrDefault(g => g.Id == meta.GroupId);
+ var sharedContext = group?.SharedContext ?? "";
+
+ var wtInfo = meta.WorktreeId != null
+ ? _repoManager.Worktrees.FirstOrDefault(wt => wt.Id == meta.WorktreeId) : null;
+ var worktreeNote = wtInfo != null && group?.WorktreeStrategy != WorktreeStrategy.Shared
+ ? $"\n\n## Your Worktree\nYou have an isolated git worktree at `{wtInfo.Path}` (branch: {wtInfo.Branch}). " +
+ "You can safely run any git operations without affecting other workers. " +
+ "To check out a PR: `git fetch origin pull//head:pr- && git checkout pr-`\n"
+ : "";
+
+ return BuildWorkerSystemMessageSections(identity, worktreeNote, sharedContext);
+ }
+
public async Task AbortSessionAsync(string sessionName, bool markAsInterrupted = false)
{
// Provider sessions manage their own cancellation