Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 10 additions & 1 deletion setup/js/allowed_issue_fields.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,17 @@ function validateAllowedIssueFields(issueFields, allowedFields) {
if (!Array.isArray(issueFields) || issueFields.length === 0) {
return;
}
if (!Array.isArray(allowedFields) || allowedFields.length === 0) {
return;
}
const allowedFieldSet = new Set(allowedFields.map(f => f.toLowerCase()));
if (allowedFieldSet.has("*")) {
return;
}
for (const field of issueFields) {
validateAllowedIssueFieldName(field.name, allowedFields);
if (!allowedFieldSet.has(field.name.toLowerCase())) {
throw new Error(`${ERR_VALIDATION}: issue field "${field.name}" is not in the allowed-fields list: ${allowedFields.join(", ")}`);
}
}
}

Expand Down
17 changes: 4 additions & 13 deletions setup/js/check_command_position.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs");
const { writeDenialSummary } = require("./pre_activation_summary.cjs");
const { matchesCommandName, resolveMatchedCommand } = require("./slash_command_matcher.cjs");

/**
* Check if command is the first word in the triggering text
Expand Down Expand Up @@ -90,7 +91,7 @@ async function main() {
}

if (inboundCommandName) {
if (commands.includes(inboundCommandName)) {
if (commands.some(command => matchesCommandName(command, inboundCommandName))) {
core.info(`✓ command_name '${inboundCommandName}' resolved from workflow_dispatch aw_context`);
core.setOutput("command_position_ok", "true");
core.setOutput("matched_command", inboundCommandName);
Expand All @@ -116,24 +117,14 @@ async function main() {
return;
}

// Normalize whitespace and get the first word
// Normalize whitespace and resolve the matched slash command at the start of the text.
const trimmedText = text.trim();
const matchedCommand = resolveMatchedCommand(trimmedText, commands);
const firstWord = trimmedText.split(/\s+/)[0];

core.info(`Checking command position. First word in text: ${firstWord}`);
core.info(`Looking for commands: ${commands.map(c => `/${c}`).join(", ")}`);

// Check if any of the commands match
let matchedCommand = null;
for (const command of commands) {
const expectedCommand = `/${command}`;

if (firstWord === expectedCommand) {
matchedCommand = command;
break;
}
}

if (matchedCommand) {
core.info(`✓ Command '/${matchedCommand}' matched at the start of the text`);
core.setOutput("command_position_ok", "true");
Expand Down
11 changes: 6 additions & 5 deletions setup/js/copilot_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@
require("./shim.cjs");

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { renderTemplateFromFile } = require("./messages_core.cjs");
const { getPromptPath, renderTemplateFromFile } = require("./messages_core.cjs");
const { runProcess, formatDuration, sleep, isCopilotSDKEnabled, buildCopilotSDKEnv } = require("./process_runner.cjs");
const { buildCopilotSDKServerArgs, getCopilotSDKServerPort, startCopilotSDKServer, stopCopilotSDKServer, waitForCopilotSDKServer } = require("./copilot_sdk_sidecar.cjs");
const {
Expand Down Expand Up @@ -81,7 +80,7 @@ const PROMPT_FILE_INLINE_THRESHOLD_LABEL = "100KB";
const MAX_ENV_VAR_PREVIEW_LENGTH = 120;
const OUTPUT_TAIL_MAX_CHARS = 600;
const OUTPUT_TAIL_MAX_LINES = 12;
const COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_PATH = path.join(__dirname, "../md/copilot_requests_proxy_auth_403.md");
const COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_NAME = "copilot_requests_proxy_auth_403.md";
// Pattern to detect transient CAPIError 400 in copilot output
const CAPI_ERROR_400_PATTERN = /CAPIError:\s*400/;

Expand Down Expand Up @@ -420,9 +419,10 @@ function envFlagEnabled(value) {
* Build a more actionable Copilot auth diagnostic when a 401/403 came from the gh-aw API proxy.
* @param {string} output
* @param {NodeJS.ProcessEnv} [env]
* @param {{ renderTemplateFromFile?: typeof renderTemplateFromFile }} [options]
* @returns {string}
*/
function buildCopilotProxyAuthFailureDiagnostic(output, env = process.env) {
function buildCopilotProxyAuthFailureDiagnostic(output, env = process.env, options = {}) {
const authFailure = parseProviderAuthFailure(output);
if (!authFailure || !isLikelyAWFAPIProxyURL(authFailure.providerUrl)) {
return "";
Expand All @@ -431,7 +431,8 @@ function buildCopilotProxyAuthFailureDiagnostic(output, env = process.env) {
const selectedModel = typeof env.COPILOT_MODEL === "string" && env.COPILOT_MODEL.trim() ? env.COPILOT_MODEL.trim() : "(unset)";
const stage = detectCopilotAuthFailureStage(output);
if (authFailure.statusCode === "403" && envFlagEnabled(env.S2STOKENS)) {
return renderTemplateFromFile(COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_PATH, {
const render = options.renderTemplateFromFile || renderTemplateFromFile;
return render(getPromptPath(COPILOT_REQUESTS_PROXY_AUTH_403_TEMPLATE_NAME), {
selected_model: selectedModel,
stage,
});
Expand Down
47 changes: 35 additions & 12 deletions setup/js/copilot_sdk_session.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*
* Event mapping:
* SDK "user.message" → JSONL "user.message"
* SDK "tool.execution_start" → JSONL "tool.execution_start" (toolName, mcpServerName)
* SDK "tool.execution_start" → JSONL "tool.execution_start" (toolName, mcpServerName, command?)
* SDK "tool.execution_complete" → JSONL "tool.execution_complete" (toolName, mcpServerName, success, result)
* SDK "assistant.message" → JSONL "assistant.message" (content)
*
Expand All @@ -29,13 +29,20 @@ const fs = require("fs");
const path = require("path");
const os = require("os");
const { buildCopilotSDKPermissionHandler, getEnvPositiveIntOrDefault, parseMaxToolDenialsLimit, MAX_TOOL_DENIALS_DEFAULT } = require("./copilot_sdk_permissions.cjs");
const { extractShellCommandFromToolData } = require("./tool_call_details.cjs");

// Default timeout for a single sendAndWait call: 10 minutes.
// This is intentionally generous — the headless Copilot CLI has its own internal
// timeouts for individual tool calls and model inference.
// Override via the COPILOT_SDK_SEND_TIMEOUT_MS environment variable.
const SDK_SEND_TIMEOUT_MS_DEFAULT = 10 * 60 * 1000;

// Pattern matching the SDK idle-timeout error emitted when sendAndWait reaches its
// deadline waiting for the session.idle event. This matches the message format
// "Timeout after <N>ms waiting for session.idle" produced by the Copilot SDK.
// Keep in sync with SDK_SESSION_IDLE_TIMEOUT_PATTERN in copilot_harness.cjs.
const SDK_IDLE_TIMEOUT_PATTERN = /Timeout after \d+ms waiting for session\.idle/;

/**
* Extract the prompt text from a resolved args array.
* Looks for the first occurrence of "-p <value>" or "--prompt <value>".
Expand Down Expand Up @@ -141,6 +148,13 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c
let toolDenialCount = 0;
let catastrophicToolDenialsError = null;
let catastrophicToolDenialsTriggered = false;
/**
* Map from toolCallId → {toolName, mcpServerName} for enriching tool.execution_complete
* events and for tracking in-flight tool calls when the idle-timeout fires.
* Declared at function scope so the catch block can check pendingToolCalls.size.
* @type {Map<string, {toolName: string, mcpServerName: string}>}
*/
const pendingToolCalls = new Map();

/**
* Best-effort write of a driver-level event to events.jsonl and stderr.
Expand Down Expand Up @@ -214,13 +228,6 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c
const stream = eventsStream;
log(`serialising SDK events to ${eventsPath}`);

/**
* Map from toolCallId → {toolName, mcpServerName} so that tool.execution_complete
* events (which carry no mcpServerName) can be enriched from the matching start event.
* @type {Map<string, {toolName: string, mcpServerName: string}>}
*/
const pendingToolCalls = new Map();

/**
* Write one JSONL entry to the events file and stderr.
* Uses the event's own ISO-8601 timestamp when available.
Expand Down Expand Up @@ -250,10 +257,12 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c
const toolName = event.data?.toolName ?? "unknown";
const mcpServerName = event.data?.mcpServerName ?? "";
const toolCallId = event.data?.toolCallId;
const command = extractShellCommandFromToolData(event.data);
if (toolCallId) {
pendingToolCalls.set(toolCallId, { toolName, mcpServerName });
}
writeEvent("tool.execution_start", { toolName, mcpServerName }, event.timestamp);
const eventData = command ? { toolName, mcpServerName, command } : { toolName, mcpServerName };
writeEvent("tool.execution_start", eventData, event.timestamp);
break;
}

Expand Down Expand Up @@ -316,10 +325,24 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c
const durationMs = Date.now() - startTime;
const failure = catastrophicToolDenialsError ?? (err instanceof Error ? err : new Error(String(err)));
log(`error: ${failure.message}`);

// When sendAndWait times out waiting for session.idle but the agent produced
// output and all tracked tool calls have already completed, the session work is
// done — the SDK simply failed to emit the idle signal. Treat it as a successful
// run so the harness does not classify it as a failure or waste retry attempts.
const isIdleTimeout = !catastrophicToolDenialsError && SDK_IDLE_TIMEOUT_PATTERN.test(failure.message);
if (isIdleTimeout && hasOutput && pendingToolCalls.size === 0) {
log(`warning: SDK idle-timeout with collected output and no pending tool calls — treating as completed`);
log(`session completed: hasOutput=${hasOutput} durationMs=${durationMs}`);
return { exitCode: 0, output, hasOutput, durationMs };
}

// Preserve any output collected before the error so the harness can use it
// for retry decisions and diagnostics.
return {
exitCode: 1,
output: failure.message,
hasOutput: false,
output: hasOutput ? output : failure.message,
hasOutput,
durationMs,
};
} finally {
Expand All @@ -345,4 +368,4 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c
}
}

module.exports = { SDK_SEND_TIMEOUT_MS_DEFAULT, extractPromptFromArgs, runWithCopilotSDK };
module.exports = { SDK_SEND_TIMEOUT_MS_DEFAULT, SDK_IDLE_TIMEOUT_PATTERN, extractPromptFromArgs, runWithCopilotSDK };
Loading
Loading