Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
444992e
Add Copilot-only LSP frontmatter support
Copilot Jun 26, 2026
8444c8e
Improve LSP config diagnostics
Copilot Jun 26, 2026
0b8e753
Merge branch 'main' into copilot/add-lsp-compiler-support
pelikhan Jun 26, 2026
16f2a77
feat(jsweep): add TypeScript LSP server to enable JavaScript/TypeScri…
Copilot Jun 26, 2026
018f278
docs: add .github/aw/lsp.md instructions for LSP frontmatter configur…
Copilot Jun 26, 2026
436b56c
Merge remote-tracking branch 'origin/main' into copilot/add-lsp-compi…
Copilot Jun 26, 2026
2379f24
fix(lsp): deterministic key normalization, use ResolveEngineID, regis…
Copilot Jun 26, 2026
45a42b4
chore: recompile detection-analysis-report workflow after main merge
Copilot Jun 26, 2026
2d89153
fix(lsp): add --ignore-scripts to npm LSP install commands
Copilot Jun 27, 2026
3af6d45
feat(lsp): delegate LSP runtime setup to runtime manager for proper S…
Copilot Jun 27, 2026
553ec35
feat(lsp): align LSP npm package installation with runtime manager
Copilot Jun 27, 2026
f76dcfa
Merge remote-tracking branch 'origin/main' into copilot/add-lsp-compi…
Copilot Jun 27, 2026
af19dab
feat(lsp): mark lsp as experimental with compile-time warning, schema…
Copilot Jun 27, 2026
237eb78
Merge branch 'main' into copilot/add-lsp-compiler-support
github-actions[bot] Jun 27, 2026
4aa65ef
feat(smoke-codex): enable TypeScript LSP and add function-count LSP test
Copilot Jun 27, 2026
079a3f8
feat(lsp): pin packages to releases, add frontmatter version override
Copilot Jun 27, 2026
6fc5b52
refactor(lsp): move TypeScript LSP test from smoke-codex to smoke-cop…
Copilot Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions pkg/parser/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,29 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_ToolsEditBoolean(t
}
}

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_LSPConfig(t *testing.T) {
t.Parallel()

frontmatter := map[string]any{
"on": "push",
"engine": "copilot",
"lsp": map[string]any{
"typescript": map[string]any{
"command": "typescript-language-server",
"args": []any{"--stdio"},
"fileExtensions": map[string]any{
".ts": "typescript",
},
},
},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/lsp-config-test.md")
if err != nil {
t.Fatalf("expected valid lsp configuration to pass schema validation, got: %v", err)
}
}

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_MaxLimitsAllowExpressions(t *testing.T) {
t.Parallel()

Expand Down
35 changes: 35 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4710,6 +4710,41 @@
},
"additionalProperties": false
},
"lsp": {
"type": "object",
"description": "Top-level Language Server Protocol (LSP) configuration for Copilot CLI. Each key is a language identifier and each value defines the server command, args, and file extension mappings.",
"patternProperties": {
"^[a-zA-Z0-9_-]+$": {
"type": "object",
"properties": {
"command": {
"type": "string",
"minLength": 1,
"description": "Executable command for the language server"
},
"args": {
"type": "array",
"description": "Optional command-line arguments passed to the language server executable",
"items": {
"type": "string"
}
},
"fileExtensions": {
"type": "object",
"description": "Map of file extension to language id, for example {\".ts\": \"typescript\"}",
"minProperties": 1,
"additionalProperties": {
"type": "string",
"minLength": 1
}
}
},
"required": ["command", "fileExtensions"],
"additionalProperties": false
}
},
"additionalProperties": false
},
"cache": {
"description": "Cache configuration for workflow (uses actions/cache syntax)",
"oneOf": [
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ func (c *Compiler) validateWorkflowEngineSettings(cleanPath string, workflowData
c.validateRunInstallScripts,
c.validateEngineVersion,
c.validatePlaywrightMode,
c.validateLSPSupport,
c.validateEngineHarnessScript,
c.validateEngineDriver,
c.validateEngineMCPSessionTimeout,
Expand Down
20 changes: 20 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,26 @@ func TestValidateWorkflowEngineSettings_PreservesLegacyErrorOrder(t *testing.T)
assert.NotContains(t, err.Error(), "engine.harness")
}

func TestValidateWorkflowEngineSettings_LSPRequiresCopilot(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{
AI: "codex",
LSP: map[string]LSPServerConfig{
"go": {
Command: "gopls",
Args: []string{"serve"},
FileExtensions: map[string]string{
".go": "go",
},
},
},
}

err := compiler.validateWorkflowEngineSettings("workflow.md", workflowData)
require.Error(t, err)
assert.Contains(t, err.Error(), "workflow.md: lsp is currently only supported for engine: copilot")
}

func TestMergeRawOTLPEndpoints_DedupesAndCountsSources(t *testing.T) {
mainObs := map[string]any{
"otlp": map[string]any{
Expand Down
3 changes: 2 additions & 1 deletion pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,8 @@ type WorkflowData struct {
Container string // container setting for the main job
Services string // services setting for the main job
Tools map[string]any
ParsedTools *Tools // Structured tools configuration (NEW: parsed from Tools map)
LSP map[string]LSPServerConfig // top-level LSP server configuration for Copilot CLI
ParsedTools *Tools // Structured tools configuration (NEW: parsed from Tools map)
MarkdownContent string
AI string // "claude" or "codex" (for backwards compatibility)
EngineConfig *EngineConfig // Extended engine configuration
Expand Down
42 changes: 32 additions & 10 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,22 +47,42 @@ const customEngineCommandScriptPath = "/tmp/gh-aw/engine-command.sh"
// other generators (copilot_mcp.go, mcp_setup_generator.go) rely on it the same way.
const copilotSettingsPath = "$HOME/.copilot/settings.json"

// copilotSettingsContent is the JSON content written to the Copilot CLI settings file.
// Setting builtInAgents.rubberDuck to false disables the rubber-duck sub-agent, which
// would otherwise be invoked proactively for non-trivial tasks. In Copilot engine runs
// this adds unnecessary token overhead and latency with little benefit, since the primary
// model already has strong reasoning capabilities.
const copilotSettingsContent = `{"builtInAgents":{"rubberDuck":false}}`
// copilotSettingsDefaultContent is the default JSON content written to the Copilot CLI
// settings file when no additional settings are configured.
const copilotSettingsDefaultContent = `{"builtInAgents":{"rubberDuck":false}}`

type copilotSettings struct {
BuiltInAgents map[string]bool `json:"builtInAgents"`
LSPServers map[string]LSPServerConfig `json:"lspServers,omitempty"`
}

func buildCopilotSettingsContent(workflowData *WorkflowData) string {
settings := copilotSettings{
BuiltInAgents: map[string]bool{"rubberDuck": false},
}
if workflowData != nil {
manager := NewLSPManager(workflowData.LSP)
settings.LSPServers = manager.CopilotLSPServers()
}
settingsBytes, err := json.Marshal(settings)
if err != nil {
return copilotSettingsDefaultContent
}
return string(settingsBytes)
}

// buildCopilotSettingsSetup returns shell commands that write the Copilot CLI settings
// file before the agent runs, disabling the rubber-duck sub-agent.
func buildCopilotSettingsSetup(fixOwnershipForCustomCommand bool) string {
func buildCopilotSettingsSetup(settingsContent string, fixOwnershipForCustomCommand bool) string {
if settingsContent == "" {
settingsContent = copilotSettingsDefaultContent
}
setup := "mkdir -p \"$HOME/.copilot\"\n"
if fixOwnershipForCustomCommand {
setup += "sudo chown -R \"$(id -u):$(id -g)\" \"$HOME/.copilot\"\n"
}
return setup + fmt.Sprintf("printf '%%s' %s > \"%s\"\n",
shellEscapeArg(copilotSettingsContent), copilotSettingsPath)
shellEscapeArg(settingsContent), copilotSettingsPath)
}

// buildCopilotSettingsCleanupTrap returns a shell trap command that removes the
Expand Down Expand Up @@ -382,6 +402,8 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st
// Non-sandbox mode: pass prompt file path directly
copilotCommand = fmt.Sprintf(`%s %s --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt`, execPrefix, shellJoinArgs(copilotArgs))
}
settingsContent := buildCopilotSettingsContent(workflowData)

// Conditionally wrap with sandbox (AWF only)
var command string
if isFirewallEnabled(workflowData) {
Expand Down Expand Up @@ -451,7 +473,7 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st
// Write the Copilot settings file before AWF starts. The file is created on the
// host and AWF mounts it into the container, where the Copilot CLI reads it to
// disable the rubber-duck sub-agent.
pathSetup = buildCopilotSettingsCleanupTrap() + buildCopilotSettingsSetup(customCommandScriptSetup != "") + buildCopilotMCPConfigExport(workflowData) + pathSetup
pathSetup = buildCopilotSettingsCleanupTrap() + buildCopilotSettingsSetup(settingsContent, customCommandScriptSetup != "") + buildCopilotMCPConfigExport(workflowData) + pathSetup
// Build the list of core secret var names to hide from the agent shell tools.
// In BYOK mode COPILOT_GITHUB_TOKEN is not injected into the step env at all,
// so there is nothing to exclude. Excluding it unconditionally would produce
Expand Down Expand Up @@ -495,7 +517,7 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st
}
// Write the Copilot settings file before the agent runs to disable the rubber-duck
// sub-agent. This reduces token overhead and latency for Copilot engine runs.
preCommandSetup = buildCopilotSettingsCleanupTrap() + buildCopilotSettingsSetup(customCommandScriptSetup != "") + buildCopilotMCPConfigExport(workflowData) + preCommandSetup
preCommandSetup = buildCopilotSettingsCleanupTrap() + buildCopilotSettingsSetup(settingsContent, customCommandScriptSetup != "") + buildCopilotMCPConfigExport(workflowData) + preCommandSetup
command = fmt.Sprintf(`set -o pipefail
printf '%%s' "$(date +%%s%%3N)" > %s
touch %s
Expand Down
21 changes: 17 additions & 4 deletions pkg/workflow/copilot_engine_installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu
if len(sdkInstallStep) > 0 {
steps = append(steps, sdkInstallStep)
}
return BuildNpmEngineInstallStepsWithAWF(steps, workflowData)
return appendCopilotLSPInstallSteps(BuildNpmEngineInstallStepsWithAWF(steps, workflowData), workflowData)
}
if len(sdkInstallStep) > 0 {
copilotInstallLog.Printf("Skipping Copilot CLI installation: custom command specified (%s); keeping Copilot SDK install step", workflowData.EngineConfig.Command)
return []GitHubActionStep{sdkInstallStep}
return appendCopilotLSPInstallSteps([]GitHubActionStep{sdkInstallStep}, workflowData)
}
copilotInstallLog.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command)
return []GitHubActionStep{}
return appendCopilotLSPInstallSteps([]GitHubActionStep{}, workflowData)
}

// Copilot CLI is pinned to the default version constant.
Expand Down Expand Up @@ -131,7 +131,20 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu
}
steps := BuildNpmEngineInstallStepsWithAWF(npmSteps, workflowData)

return steps
return appendCopilotLSPInstallSteps(steps, workflowData)
}

func appendCopilotLSPInstallSteps(steps []GitHubActionStep, workflowData *WorkflowData) []GitHubActionStep {
if workflowData == nil {
return steps
}
manager := NewLSPManager(workflowData.LSP)
lspSteps := manager.GenerateInstallSteps()
if len(lspSteps) == 0 {
return steps
}
copilotInstallLog.Printf("Adding %d LSP dependency installation step(s)", len(lspSteps))
return append(steps, lspSteps...)
}

func buildCopilotSDKInstallStep(workflowData *WorkflowData) GitHubActionStep {
Expand Down
60 changes: 58 additions & 2 deletions pkg/workflow/copilot_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,8 @@ func TestCopilotEngineDisablesRubberDuck(t *testing.T) {
if !strings.Contains(stepContent, "mkdir -p \"$HOME/.copilot\"") {
t.Errorf("Expected 'mkdir -p \"$HOME/.copilot\"' in step content:\n%s", stepContent)
}
if !strings.Contains(stepContent, copilotSettingsContent) {
t.Errorf("Expected copilot settings content %q in step content:\n%s", copilotSettingsContent, stepContent)
if !strings.Contains(stepContent, copilotSettingsDefaultContent) {
t.Errorf("Expected copilot settings content %q in step content:\n%s", copilotSettingsDefaultContent, stepContent)
}
if !strings.Contains(stepContent, copilotSettingsPath) {
t.Errorf("Expected copilot settings path %q in step content:\n%s", copilotSettingsPath, stepContent)
Expand All @@ -259,6 +259,62 @@ func TestCopilotEngineDisablesRubberDuck(t *testing.T) {
}
}

func TestCopilotEngineExecutionSteps_WithLSPConfig(t *testing.T) {
engine := NewCopilotEngine()
workflowData := &WorkflowData{
Name: "test-workflow",
LSP: map[string]LSPServerConfig{
"typescript": {
Command: "typescript-language-server",
Args: []string{"--stdio"},
FileExtensions: map[string]string{
".ts": "typescript",
},
},
},
}

steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log")
if len(steps) != 1 {
t.Fatalf("Expected 1 execution step, got %d", len(steps))
}

stepContent := strings.Join([]string(steps[0]), "\n")
if !strings.Contains(stepContent, `"lspServers":{"typescript":{"command":"typescript-language-server","args":["--stdio"],"fileExtensions":{".ts":"typescript"}}}`) {
t.Fatalf("Expected lspServers config in step content, got:\n%s", stepContent)
}
}

func TestCopilotEngineInstallationSteps_WithLSPConfig(t *testing.T) {
engine := NewCopilotEngine()
workflowData := &WorkflowData{
Name: "test-workflow",
LSP: map[string]LSPServerConfig{
"python": {
Command: "pyright-langserver",
Args: []string{"--stdio"},
FileExtensions: map[string]string{
".py": "python",
},
},
},
}

steps := engine.GetInstallationSteps(workflowData)
var allLines strings.Builder
for _, step := range steps {
allLines.WriteString(strings.Join(step, "\n"))
allLines.WriteByte('\n')
}
allLinesStr := allLines.String()
if !strings.Contains(allLinesStr, "Install Python LSP dependencies") {
t.Fatalf("Expected Python LSP install step, got:\n%s", allLinesStr)
}
if !strings.Contains(allLinesStr, "npm install -g pyright") {
t.Fatalf("Expected pyright install command, got:\n%s", allLinesStr)
}
}

func TestCopilotEngineExecutionStepsWithOutput(t *testing.T) {
engine := NewCopilotEngine()
workflowData := &WorkflowData{
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/copilot_home_expansion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func TestBuildCopilotSettingsSetup_UsesHomeExpansion(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildCopilotSettingsSetup(tt.fixOwnershipForCustom)
got := buildCopilotSettingsSetup(copilotSettingsDefaultContent, tt.fixOwnershipForCustom)

// Must reference $HOME, never the literal /home/runner.
assert.Contains(t, got, `mkdir -p "$HOME/.copilot"`,
Expand Down Expand Up @@ -304,7 +304,7 @@ func TestBashIntegration_SettingsSetupAndCleanupTrap(t *testing.T) {
// The setup helper unconditionally tries to run `sudo` if the chown flag
// is true; the second variant covers the no-sudo path so the test does not
// depend on sudoers being configured.
setup := buildCopilotSettingsSetup(false)
setup := buildCopilotSettingsSetup(copilotSettingsDefaultContent, false)
trap := buildCopilotSettingsCleanupTrap()

for _, hv := range homeValuesUnderTest {
Expand Down
17 changes: 9 additions & 8 deletions pkg/workflow/frontmatter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,14 +310,15 @@ type FrontmatterConfig struct {
Labels []string `json:"labels,omitempty"`

// Configuration sections - using strongly-typed structs
Tools *ToolsConfig `json:"tools,omitempty"`
MCPServers map[string]any `json:"mcp-servers,omitempty"` // Legacy field, use Tools instead
RuntimesTyped *RuntimesConfig `json:"-"` // New typed field (not in JSON to avoid conflict)
Runtimes map[string]any `json:"runtimes,omitempty"` // Deprecated: use RuntimesTyped
Jobs map[string]any `json:"jobs,omitempty"` // Custom workflow jobs (too dynamic to type)
SafeOutputs *SafeOutputsConfig `json:"safe-outputs,omitempty"`
MCPScripts *MCPScriptsConfig `json:"mcp-scripts,omitempty"`
PermissionsTyped *PermissionsConfig `json:"-"` // New typed field (not in JSON to avoid conflict)
Tools *ToolsConfig `json:"tools,omitempty"`
LSP map[string]LSPServerConfig `json:"lsp,omitempty"`
MCPServers map[string]any `json:"mcp-servers,omitempty"` // Legacy field, use Tools instead
RuntimesTyped *RuntimesConfig `json:"-"` // New typed field (not in JSON to avoid conflict)
Runtimes map[string]any `json:"runtimes,omitempty"` // Deprecated: use RuntimesTyped
Jobs map[string]any `json:"jobs,omitempty"` // Custom workflow jobs (too dynamic to type)
SafeOutputs *SafeOutputsConfig `json:"safe-outputs,omitempty"`
MCPScripts *MCPScriptsConfig `json:"mcp-scripts,omitempty"`
PermissionsTyped *PermissionsConfig `json:"-"` // New typed field (not in JSON to avoid conflict)

// Event and trigger configuration
On map[string]any `json:"on,omitempty"` // Complex trigger config with many variants (too dynamic to type)
Expand Down
Loading
Loading