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