From 8fd0046a2ef825b9fbfbd51c6499a535afe525b6 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 14 Sep 2026 17:59:52 +0200 Subject: [PATCH 1/3] codex-agent-output-locale-contract: enforce localized public responses --- build/api/index.js | 148 ++++++++- .../api/src/data/model/application_error.d.ts | 2 +- build/cli/index.js | 301 ++++++++++++++---- build/github_action/index.js | 301 ++++++++++++++---- docs/agents/execution-contract.mdx | 9 + docs/agents/failure-policy.mdx | 1 + docs/bugbot/detection.mdx | 6 + .../development/agent-functionality-audit.mdx | 22 +- docs/development/architecture.mdx | 10 +- docs/development/testing.mdx | 7 +- docs/pull-requests/ai-description.mdx | 3 +- .../security/prompt-injection.mdx | 2 +- .../__tests__/application_error.test.ts | 2 +- .../agent_output_locale_policy.test.ts | 68 ++++ .../__tests__/agent_response_schemas.test.ts | 17 + .../policies/agent_output_locale_policy.ts | 113 +++++++ .../policies/agent_response_schemas.ts | 39 ++- .../push_single_action_contexts.test.ts | 1 + .../__tests__/check_progress_use_case.test.ts | 23 +- .../__tests__/progress_response.test.ts | 14 +- .../recommend_steps_use_case.test.ts | 45 ++- .../actions/progress_analysis_workflow.ts | 7 +- .../usecases/actions/progress_response.ts | 14 +- .../actions/recommend_steps_result_policy.ts | 30 +- .../actions/recommend_steps_workflow.ts | 4 + .../usecases/pull_request_workflow_context.ts | 3 + .../usecases/push_single_action_contexts.ts | 4 + .../bugbot_review_lifecycle.e2e.test.ts | 7 +- ...detect_potential_problems_use_case.test.ts | 33 +- .../analyze_bugbot_revision_use_case.ts | 1 + .../commit/bugbot/build_bugbot_prompt.ts | 1 + .../commit/bugbot/query_bugbot_findings.ts | 21 +- .../usecases/steps/commit/bugbot/schema.ts | 4 +- .../__tests__/agent_answer_policy.test.ts | 8 +- .../common/__tests__/think_use_case.test.ts | 26 +- .../steps/common/agent_answer_policy.ts | 16 +- .../steps/common/think_answer_workflow.ts | 17 +- .../answer_issue_help_use_case.test.ts | 21 +- .../steps/issue/answer_issue_help_workflow.ts | 9 +- ..._pull_request_description_use_case.test.ts | 25 +- ...pdate_pull_request_description_workflow.ts | 28 +- .../agent_output_locale_boundaries.test.ts | 38 +++ src/data/model/application_error.ts | 7 + src/prompts/__tests__/bugbot.test.ts | 2 + src/prompts/__tests__/check_progress.test.ts | 2 + src/prompts/__tests__/index.test.ts | 1 + src/prompts/__tests__/recommend_steps.test.ts | 5 +- src/prompts/__tests__/think.test.ts | 3 + .../update_pull_request_description.test.ts | 2 + src/prompts/answer_issue_help.ts | 4 +- src/prompts/bugbot.ts | 5 +- src/prompts/check_progress.ts | 6 +- src/prompts/recommend_steps.ts | 8 +- src/prompts/think.ts | 6 +- .../update_pull_request_description.ts | 8 +- 55 files changed, 1252 insertions(+), 258 deletions(-) create mode 100644 src/application/policies/__tests__/agent_output_locale_policy.test.ts create mode 100644 src/application/policies/agent_output_locale_policy.ts create mode 100644 src/architecture/__tests__/agent_output_locale_boundaries.test.ts diff --git a/build/api/index.js b/build/api/index.js index 4bfdbd14d..51c40ba45 100644 --- a/build/api/index.js +++ b/build/api/index.js @@ -67,6 +67,91 @@ function runAtApplicationErrorBoundary(operation) { } +/***/ }), + +/***/ 601: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY = exports.PRODUCT_FACING_AGENT_TASKS = void 0; +exports.productFacingAgentQueryOptions = productFacingAgentQueryOptions; +exports.validateAgentOutputLocale = validateAgentOutputLocale; +exports.agentOutputLocaleFailureMessage = agentOutputLocaleFailureMessage; +const locale_1 = __nccwpck_require__(5386); +exports.PRODUCT_FACING_AGENT_TASKS = [ + 'think', + 'answer-issue-help', + 'progress', + 'recommend-steps', + 'pull-request-description', + 'bugbot-review', +]; +const PRODUCT_FACING_AGENT_SCHEMA_NAMES = Object.freeze({ + think: 'think_response', + 'answer-issue-help': 'answer_issue_help_response', + progress: 'progress_response', + 'recommend-steps': 'recommend_steps_response', + 'pull-request-description': 'pull_request_description_response', + 'bugbot-review': 'bugbot_findings', +}); +exports.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY = { + type: 'string', + minLength: 1, + maxLength: 255, + description: 'The exact canonical BCP-47 locale requested in targetLocale.', +}; +/** + * Builds the only supported structured-output options for product-facing agent + * calls. Runtime assertions make an accidentally weakened schema fail before + * an agent provider is invoked. + */ +function productFacingAgentQueryOptions(task, schema) { + if (!schema.properties?.outputLocale || !schema.required?.includes('outputLocale')) { + throw new TypeError(`Product-facing agent schema for ${task} must require outputLocale.`); + } + return Object.freeze({ + expectJson: true, + schema: schema, + schemaName: PRODUCT_FACING_AGENT_SCHEMA_NAMES[task], + }); +} +/** Validates locale metadata before any model prose can reach product state. */ +function validateAgentOutputLocale(response, targetLocale) { + const expectedLocale = (0, locale_1.canonicalizeLocaleTag)(targetLocale); + if (response == null || typeof response !== 'object' || Array.isArray(response)) { + return Object.freeze({ kind: 'invalid', expectedLocale, reason: 'response-not-object' }); + } + const payload = response; + if (typeof payload.outputLocale !== 'string' || !payload.outputLocale.trim()) { + return Object.freeze({ kind: 'invalid', expectedLocale, reason: 'output-locale-missing' }); + } + let actualLocale; + try { + actualLocale = (0, locale_1.canonicalizeLocaleTag)(payload.outputLocale); + } + catch { + return Object.freeze({ + kind: 'invalid', + expectedLocale, + reason: 'output-locale-invalid', + }); + } + if (actualLocale !== expectedLocale || payload.outputLocale !== expectedLocale) { + return Object.freeze({ + kind: 'invalid', + expectedLocale, + actualLocale, + reason: 'output-locale-mismatch', + }); + } + return Object.freeze({ kind: 'valid', expectedLocale, payload: Object.freeze({ ...payload }) }); +} +function agentOutputLocaleFailureMessage(validation) { + return `Configured agent output was rejected before publication (${validation.reason}; expected ${validation.expectedLocale}).`; +} + + /***/ }), /***/ 5712: @@ -1560,7 +1645,7 @@ async function analyzeBugbotRevision(execution, context, dependencies) { dependencies.telemetry.observeContext(context, prompt); (0, logging_ports_1.logInfo)('Detecting potential problems via configured agent using canonical change context...'); const startedAt = Date.now(); - const agentResponse = await dependencies.telemetry.measure('analysis', () => (0, query_bugbot_findings_1.queryBugbotFindings)(dependencies.agent, execution.analysis.agentConfiguration, prompt)); + const agentResponse = await dependencies.telemetry.measure('analysis', () => (0, query_bugbot_findings_1.queryBugbotFindings)(dependencies.agent, execution.analysis.agentConfiguration, prompt, execution.locale.pullRequest)); dependencies.telemetry.observeResponse(agentResponse); (0, logging_ports_1.logInfo)(`Bugbot reviewer completed in ${Date.now() - startedAt}ms.`); const raw = await dependencies.telemetry.measure('normalization', () => (0, prepare_bugbot_findings_1.prepareBugbotFindings)(agentResponse, execution.ignorePatterns, execution.analysis.minimumSeverity, execution.analysis.commentLimit)); @@ -2374,6 +2459,7 @@ function buildBugbotPrompt(param, context) { reviewConversationBlock: context.reviewConversationBlock, rulesBlock: context.reviewRulesBlock, effortBlock: `**Review effort:** ${resolvedEffort}. ${resolvedEffort === 'high' ? 'Perform deeper cross-file and adversarial analysis.' : resolvedEffort === 'low' ? 'Prioritize high-signal changed-code defects and avoid speculative breadth.' : 'Balance depth, latency, and false-positive control.'}`, + targetLocale: param.locale.pullRequest, }); } function buildCoverageBlock(context) { @@ -3415,17 +3501,22 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.queryBugbotFindings = queryBugbotFindings; const agent_task_policy_1 = __nccwpck_require__(5712); const schema_1 = __nccwpck_require__(6808); -async function queryBugbotFindings(repository, configuration, prompt) { - return repository.query({ +const agent_output_locale_policy_1 = __nccwpck_require__(601); +const application_error_1 = __nccwpck_require__(5999); +async function queryBugbotFindings(repository, configuration, prompt, targetLocale) { + const response = await repository.query({ configuration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, - options: { - expectJson: true, - schema: schema_1.BUGBOT_RESPONSE_SCHEMA, - schemaName: 'bugbot_findings', - }, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('bugbot-review', schema_1.BUGBOT_RESPONSE_SCHEMA), }); + if (response == null || typeof response !== 'object' || Array.isArray(response)) + return response; + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + return validation.payload; } @@ -3636,10 +3727,12 @@ function sanitizeUserCommentForPrompt(raw) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.BUGBOT_FIX_INTENT_RESPONSE_SCHEMA = exports.BUGBOT_RESPONSE_SCHEMA = void 0; const bugbot_finding_marker_policy_1 = __nccwpck_require__(8024); +const agent_output_locale_policy_1 = __nccwpck_require__(601); /** Detection returns findings and explicit lifecycle changes for prior finding IDs. */ exports.BUGBOT_RESPONSE_SCHEMA = { type: 'object', properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, findings: { type: 'array', maxItems: 200, @@ -3698,7 +3791,7 @@ exports.BUGBOT_RESPONSE_SCHEMA = { description: 'Retained previous findings that are now fixed or obsolete; use an empty array when none are resolved.', }, }, - required: ['findings', 'resolved_findings'], + required: ['outputLocale', 'findings', 'resolved_findings'], additionalProperties: false, }; /** @@ -4378,6 +4471,12 @@ exports.APPLICATION_ERROR_METADATA = { action: 'Inspect the sanitized agent status and retry if appropriate.', retainedState: PRESERVED_STATE, }, + 'locale.output-invalid': { + kind: 'agent', retryable: true, + impact: 'Agent-generated product content was rejected before publication because its locale contract was invalid.', + action: 'Retry with a provider that supports the configured repository locale.', + retainedState: UNCHANGED_STATE, + }, 'locale.translation-failed': { kind: 'agent', retryable: true, impact: 'The request could not be safely interpreted in the configured repository language.', @@ -5351,6 +5450,8 @@ exports.getAnswerIssueHelpPrompt = getAnswerIssueHelpPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `The user has just opened a question/help issue. Provide a helpful initial response to their question or request below. Be concise and actionable. Write every human-readable sentence in {{targetLocale}} while preserving code identifiers, paths, refs, commands, and URLs verbatim. +Return a JSON object with \`outputLocale\` set exactly to \`{{targetLocale}}\` and \`answer\` containing the Markdown response. + **Answer in this single response:** Give a complete, direct answer. Do not reply that you need to explore the repository, read documentation first, or gather more information—use the project (README, docs/, code, .cursor/rules) to answer now. For "how do I…" or tutorial-style questions (e.g. how to implement or configure this project), provide concrete steps or guidance based on the project's actual documentation and structure. {{projectContextInstruction}} @@ -5358,7 +5459,7 @@ const TEMPLATE = `The user has just opened a question/help issue. Provide a help **Issue description (user's question or request):** {{description}} -Respond with a single JSON object containing an "answer" field with your reply. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; +Respond with a single JSON object containing \`outputLocale\` and \`answer\`. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; function getAnswerIssueHelpPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { description: params.description, @@ -5382,6 +5483,8 @@ exports.getBugbotPrompt = getBugbotPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are analyzing the latest code changes for potential bugs and issues. +Write every human-readable finding title, description, evidence, and suggestion in {{targetLocale}}. Preserve identifiers, code, symbols, paths, refs, commands, and URLs verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Repository context:** @@ -5415,7 +5518,7 @@ For every finding: Return every finding field required by the response schema. Use null for file, line, endLine, severity, confidence, category, evidence, suggestion, symbol, codeSnippet, or suggestedCode when that value does not safely apply. Only include files outside the ignore list. {{previousBlock}} -**Output:** Return a JSON object with "findings" (new/current problems from task 1) and "resolved_findings" (objects containing the exact prior finding id and either "fixed" or "obsolete"). Always return both arrays; use an empty array when there are no resolved findings. Never resolve an id that was not included in the previous-findings list.`; +**Output:** Return a JSON object with "outputLocale", "findings" (new/current problems from task 1), and "resolved_findings" (objects containing the exact prior finding id and either "fixed" or "obsolete"). Always return both arrays; use an empty array when there are no resolved findings. Never resolve an id that was not included in the previous-findings list.`; function getBugbotPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { ...params, @@ -5564,6 +5667,8 @@ exports.getCheckProgressPrompt = getCheckProgressPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are in the repository workspace. Assess the progress of issue #{{issueNumber}} using the full diff between the base (parent) branch and the current branch. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, percentages, and JSON keys verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Branches:** @@ -5579,7 +5684,7 @@ const TEMPLATE = `You are in the repository workspace. Assess the progress of is **Issue description:** {{issueDescription}} -Respond with a single JSON object: { "progress": , "summary": "", "remaining": "" | null }.`; +Respond with a single JSON object: { "outputLocale": "{{targetLocale}}", "progress": , "summary": "", "remaining": "" | null }.`; function getCheckProgressPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { projectContextInstruction: params.projectContextInstruction, @@ -5587,6 +5692,7 @@ function getCheckProgressPrompt(params) { baseBranch: params.baseBranch, currentBranch: params.currentBranch, issueDescription: params.issueDescription, + targetLocale: params.targetLocale, }); } @@ -5767,6 +5873,8 @@ exports.getRecommendStepsPrompt = getRecommendStepsPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `Based on the following issue description, recommend concrete steps to implement or address this issue. Order the steps logically (e.g. setup, implementation, tests, docs). Keep each step clear and actionable. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, and URLs verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Issue #{{issueNumber}} description:** @@ -5774,14 +5882,15 @@ const TEMPLATE = `Based on the following issue description, recommend concrete s {{previousRecommendation}} -Provide a complete numbered list of recommended steps in **markdown** (use headings, lists, code blocks for commands or snippets) so it is easy to read. You can add brief sub-bullets per step if needed. +Return one JSON object with \`outputLocale\`, \`status\`, and \`steps\`. When a material recommendation is needed, set \`status\` to \`recommendation\` and put a complete numbered list in Markdown in \`steps\` (headings, lists, and code blocks are allowed). You can add brief sub-bullets per step if needed. -If the current description does not require any material change to the previous recommendation, output exactly \`NO_NEW_RECOMMENDATIONS\` and nothing else. Do not use that sentinel when there is no previous recommendation.`; +If the current description does not require any material change to the previous recommendation, set \`status\` to \`unchanged\` and \`steps\` to null. Do not return \`unchanged\` when there is no previous recommendation.`; function getRecommendStepsPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { projectContextInstruction: params.projectContextInstruction, issueNumber: String(params.issueNumber), issueDescription: params.issueDescription, + targetLocale: params.targetLocale, previousRecommendation: params.previousRecommendation ? `Previous recommendation (use only to detect whether the current plan is still valid):\n\n${params.previousRecommendation}\n` : 'There is no previous recommendation for this issue.', @@ -5803,6 +5912,8 @@ exports.getThinkPrompt = getThinkPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are a helpful assistant. Answer the following question concisely in {{targetLocale}}, using the context below when relevant. Format your answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response. Preserve code identifiers, paths, refs, commands, and URLs verbatim. +Return a JSON object with \`outputLocale\` set exactly to \`{{targetLocale}}\` and \`answer\` containing the Markdown response. Every human-readable sentence in \`answer\` must use the target locale. + {{projectContextInstruction}} {{contextBlock}}Question: {{question}}`; function getThinkPrompt(params) { @@ -5810,7 +5921,7 @@ function getThinkPrompt(params) { projectContextInstruction: params.projectContextInstruction, contextBlock: params.contextBlock, question: params.question, - targetLocale: params.targetLocale ?? 'en-US', + targetLocale: params.targetLocale, }); } @@ -5829,6 +5940,8 @@ exports.getUpdatePullRequestDescriptionPrompt = getUpdatePullRequestDescriptionP const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, issue/PR references, and conventional title prefixes verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Branches:** @@ -5849,12 +5962,12 @@ const TEMPLATE = `You are in the repository workspace. Your task is to produce a - **Breaking Changes:** list any, or "None". - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. 5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. -6. **Output format:** Return only the filled template content. Do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start directly with the first heading of the template (e.g. # Summary). Do not wrap the output in code blocks. +6. **Output format:** Return one JSON object with \`outputLocale\` and \`description\`. Put only the filled template content in \`description\`; do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start \`description\` directly with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks. **Issue description:** {{issueDescription}} -Output only the filled template content (the PR description body), starting with the first heading. No preamble, no commentary.`; +Return the structured JSON response only.`; function getUpdatePullRequestDescriptionPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { projectContextInstruction: params.projectContextInstruction, @@ -5863,6 +5976,7 @@ function getUpdatePullRequestDescriptionPrompt(params) { issueNumber: String(params.issueNumber), issueDescription: params.issueDescription, relatedIssueInstruction: params.relatedIssueInstruction, + targetLocale: params.targetLocale, }); } diff --git a/build/api/src/data/model/application_error.d.ts b/build/api/src/data/model/application_error.d.ts index deef8da9b..26a532e14 100644 --- a/build/api/src/data/model/application_error.d.ts +++ b/build/api/src/data/model/application_error.d.ts @@ -1,5 +1,5 @@ export type ApplicationErrorKind = 'configuration' | 'authorization' | 'provider' | 'agent' | 'validation' | 'workflow' | 'unknown'; -export type ApplicationErrorCode = 'configuration.invalid' | 'configuration.unsupported' | 'authorization.denied' | 'authorization.credential-invalid' | 'provider.not-found' | 'provider.conflict' | 'provider.rate-limited' | 'provider.unavailable' | 'provider.contract-invalid' | 'agent.policy-rejected' | 'agent.failed' | 'locale.translation-failed' | 'validation.invalid-input' | 'workflow.invalid-event' | 'workflow.stale' | 'workflow.cancelled' | 'workflow.failed' | 'timeout' | 'unexpected'; +export type ApplicationErrorCode = 'configuration.invalid' | 'configuration.unsupported' | 'authorization.denied' | 'authorization.credential-invalid' | 'provider.not-found' | 'provider.conflict' | 'provider.rate-limited' | 'provider.unavailable' | 'provider.contract-invalid' | 'agent.policy-rejected' | 'agent.failed' | 'locale.output-invalid' | 'locale.translation-failed' | 'validation.invalid-input' | 'workflow.invalid-event' | 'workflow.stale' | 'workflow.cancelled' | 'workflow.failed' | 'timeout' | 'unexpected'; interface ApplicationErrorMetadata { readonly kind: ApplicationErrorKind; readonly retryable: boolean; diff --git a/build/cli/index.js b/build/cli/index.js index db0bf52d9..ff5eff541 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -41255,16 +41255,103 @@ function isRecord(value) { } +/***/ }), + +/***/ 30601: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY = exports.PRODUCT_FACING_AGENT_TASKS = void 0; +exports.productFacingAgentQueryOptions = productFacingAgentQueryOptions; +exports.validateAgentOutputLocale = validateAgentOutputLocale; +exports.agentOutputLocaleFailureMessage = agentOutputLocaleFailureMessage; +const locale_1 = __nccwpck_require__(15386); +exports.PRODUCT_FACING_AGENT_TASKS = [ + 'think', + 'answer-issue-help', + 'progress', + 'recommend-steps', + 'pull-request-description', + 'bugbot-review', +]; +const PRODUCT_FACING_AGENT_SCHEMA_NAMES = Object.freeze({ + think: 'think_response', + 'answer-issue-help': 'answer_issue_help_response', + progress: 'progress_response', + 'recommend-steps': 'recommend_steps_response', + 'pull-request-description': 'pull_request_description_response', + 'bugbot-review': 'bugbot_findings', +}); +exports.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY = { + type: 'string', + minLength: 1, + maxLength: 255, + description: 'The exact canonical BCP-47 locale requested in targetLocale.', +}; +/** + * Builds the only supported structured-output options for product-facing agent + * calls. Runtime assertions make an accidentally weakened schema fail before + * an agent provider is invoked. + */ +function productFacingAgentQueryOptions(task, schema) { + if (!schema.properties?.outputLocale || !schema.required?.includes('outputLocale')) { + throw new TypeError(`Product-facing agent schema for ${task} must require outputLocale.`); + } + return Object.freeze({ + expectJson: true, + schema: schema, + schemaName: PRODUCT_FACING_AGENT_SCHEMA_NAMES[task], + }); +} +/** Validates locale metadata before any model prose can reach product state. */ +function validateAgentOutputLocale(response, targetLocale) { + const expectedLocale = (0, locale_1.canonicalizeLocaleTag)(targetLocale); + if (response == null || typeof response !== 'object' || Array.isArray(response)) { + return Object.freeze({ kind: 'invalid', expectedLocale, reason: 'response-not-object' }); + } + const payload = response; + if (typeof payload.outputLocale !== 'string' || !payload.outputLocale.trim()) { + return Object.freeze({ kind: 'invalid', expectedLocale, reason: 'output-locale-missing' }); + } + let actualLocale; + try { + actualLocale = (0, locale_1.canonicalizeLocaleTag)(payload.outputLocale); + } + catch { + return Object.freeze({ + kind: 'invalid', + expectedLocale, + reason: 'output-locale-invalid', + }); + } + if (actualLocale !== expectedLocale || payload.outputLocale !== expectedLocale) { + return Object.freeze({ + kind: 'invalid', + expectedLocale, + actualLocale, + reason: 'output-locale-mismatch', + }); + } + return Object.freeze({ kind: 'valid', expectedLocale, payload: Object.freeze({ ...payload }) }); +} +function agentOutputLocaleFailureMessage(validation) { + return `Configured agent output was rejected before publication (${validation.reason}; expected ${validation.expectedLocale}).`; +} + + /***/ }), /***/ 25603: -/***/ ((__unused_webpack_module, exports) => { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; /** Shared structured-response contracts used by agent-backed application flows. */ Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.LANGUAGE_CHECK_RESPONSE_SCHEMA = exports.THINK_RESPONSE_SCHEMA = exports.TRANSLATION_RESPONSE_SCHEMA = exports.LANGUAGE_ADAPTATION_RESPONSE_SCHEMA = void 0; +exports.LANGUAGE_CHECK_RESPONSE_SCHEMA = exports.PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA = exports.RECOMMEND_STEPS_RESPONSE_SCHEMA = exports.THINK_RESPONSE_SCHEMA = exports.TRANSLATION_RESPONSE_SCHEMA = exports.LANGUAGE_ADAPTATION_RESPONSE_SCHEMA = void 0; +const agent_output_locale_policy_1 = __nccwpck_require__(30601); exports.LANGUAGE_ADAPTATION_RESPONSE_SCHEMA = { type: 'object', properties: { @@ -41303,6 +41390,7 @@ exports.TRANSLATION_RESPONSE_SCHEMA = exports.LANGUAGE_ADAPTATION_RESPONSE_SCHEM exports.THINK_RESPONSE_SCHEMA = { type: 'object', properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, answer: { type: 'string', minLength: 1, @@ -41310,7 +41398,39 @@ exports.THINK_RESPONSE_SCHEMA = { description: 'The concise answer to the user question. Required.', }, }, - required: ['answer'], + required: ['outputLocale', 'answer'], + additionalProperties: false, +}; +exports.RECOMMEND_STEPS_RESPONSE_SCHEMA = { + type: 'object', + properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, + status: { + type: 'string', + enum: ['recommendation', 'unchanged'], + description: 'Whether a recommendation is present or the previous recommendation remains valid.', + }, + steps: { + type: ['string', 'null'], + maxLength: 12000, + description: 'Markdown implementation steps for recommendation; null when status is unchanged.', + }, + }, + required: ['outputLocale', 'status', 'steps'], + additionalProperties: false, +}; +exports.PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA = { + type: 'object', + properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, + description: { + type: 'string', + minLength: 1, + maxLength: 60000, + description: 'The complete Markdown pull-request description body.', + }, + }, + required: ['outputLocale', 'description'], additionalProperties: false, }; /** @deprecated Retained for API compatibility; runtime adaptation uses one combined schema. */ @@ -47815,6 +47935,7 @@ const find_issue_branch_1 = __nccwpck_require__(38575); const progress_prerequisite_policy_1 = __nccwpck_require__(31001); const progress_response_1 = __nccwpck_require__(64264); const application_error_1 = __nccwpck_require__(75999); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); /** Loads progress context and asks the configured agent for an assessment. */ async function analyzeProgress(param, taskId, dependencies) { const issueNumber = param.issueNumber; @@ -47861,6 +47982,7 @@ async function analyzeProgress(param, taskId, dependencies) { issueDescription, baseBranch: developmentBranch, currentBranch: resolvedBranch, + targetLocale: param.targetLocale, }); (0, logging_ports_1.logDebugInfo)(`CheckProgress: prompt length=${prompt.length}, issue description length=${issueDescription.length}.`); (0, logging_ports_1.logInfo)('🤖 Analyzing progress using the configured agent...'); @@ -47869,12 +47991,10 @@ async function analyzeProgress(param, taskId, dependencies) { agentId: agent_task_policy_1.AGENT_PLAN, prompt, options: { - expectJson: true, - schema: progress_response_1.PROGRESS_RESPONSE_SCHEMA, - schemaName: 'progress_response', + ...(0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('progress', progress_response_1.PROGRESS_RESPONSE_SCHEMA), includeReasoning: param.includeReasoning, }, - })); + }), param.targetLocale); return { kind: 'ready', issueNumber, @@ -47922,25 +48042,32 @@ function validateProgressPrerequisites(input) { /***/ }), /***/ 64264: -/***/ ((__unused_webpack_module, exports) => { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.PROGRESS_RESPONSE_SCHEMA = void 0; exports.parseProgressResponse = parseProgressResponse; +const agent_output_locale_policy_1 = __nccwpck_require__(30601); +const application_error_1 = __nccwpck_require__(75999); exports.PROGRESS_RESPONSE_SCHEMA = { type: 'object', properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, progress: { type: 'number', minimum: 0, maximum: 100, description: 'Completion percentage 0-100' }, summary: { type: 'string', minLength: 1, maxLength: 8000, description: 'Short explanation of the assessment' }, remaining: { type: ['string', 'null'], maxLength: 8000, description: 'When progress < 100: what is left to do to reach 100%; otherwise null.' }, }, - required: ['progress', 'summary', 'remaining'], + required: ['outputLocale', 'progress', 'summary', 'remaining'], additionalProperties: false, }; -function parseProgressResponse(response) { - const payload = response && typeof response === 'object' ? response : {}; +function parseProgressResponse(response, targetLocale) { + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + const payload = validation.payload; const rawProgress = typeof payload.progress === 'number' ? payload.progress : 0; return { progress: Math.min(100, Math.max(0, Math.round(rawProgress))), @@ -48162,10 +48289,10 @@ exports.buildRecommendationResult = buildRecommendationResult; const result_1 = __nccwpck_require__(73817); const recommendation_policy_1 = __nccwpck_require__(39410); const logging_ports_1 = __nccwpck_require__(6152); -const copilot_interaction_policy_1 = __nccwpck_require__(90108); const application_error_1 = __nccwpck_require__(75999); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); function buildRecommendationResult(param, taskId, response, issueDescriptionFingerprint, previousRecommendation, issueNumber) { - const steps = extractRecommendationText(response); + const steps = extractRecommendationText(response, param.targetLocale); if (!steps) { const semanticError = new application_error_1.ApplicationError('agent.failed', 'The configured agent returned no recommendation.'); (0, logging_ports_1.logError)(semanticError); @@ -48182,21 +48309,13 @@ function buildRecommendationResult(param, taskId, response, issueDescriptionFing recommendationFingerprint, recommendation: (0, recommendation_policy_1.limitStoredRecommendation)(steps), }; - const stepsWithWelcome = isNewIssue(param) - ? [(0, copilot_interaction_policy_1.buildCopilotWelcomeMessage)(param.tokenUser), '## Recommended implementation steps', steps] - : ['## Recommended implementation steps', steps]; return recommendationOutcome([new result_1.Result({ id: taskId, success: true, executed: true, - stepFormat: 'markdown', - steps: stepsWithWelcome, payload: { issueNumber, recommendedSteps: steps, recommendationState }, })]); } -function isNewIssue(param) { - return param.eventName === 'issues' && param.eventAction === 'opened'; -} function skipUnchangedRecommendation(_param, previous, fingerprint, reason) { (0, logging_ports_1.logInfo)(`RecommendSteps: ${reason}; skipping recommendation comment.`); return recommendationOutcome([], { ...previous, issueDescriptionFingerprint: fingerprint }); @@ -48209,12 +48328,16 @@ function recommendationOutcome(results, recommendationState) { } : {}), }); } -function extractRecommendationText(response) { - if (typeof response === 'string') - return response.trim(); - if (!response || typeof response.steps !== 'string') +function extractRecommendationText(response, targetLocale) { + if (response == null) return ''; - return response.steps.trim(); + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + if (validation.payload.status === 'unchanged') + return recommendation_policy_1.NO_NEW_RECOMMENDATIONS; + return typeof validation.payload.steps === 'string' ? validation.payload.steps.trim() : ''; } @@ -48264,6 +48387,8 @@ const project_context_instruction_1 = __nccwpck_require__(63907); const task_emoji_1 = __nccwpck_require__(46103); const recommend_steps_result_policy_1 = __nccwpck_require__(65928); const application_error_1 = __nccwpck_require__(75999); +const agent_response_schemas_1 = __nccwpck_require__(25603); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); /** Runs the recommendation policy and agent interaction for an issue. */ async function runRecommendStepsWorkflow(param, taskId, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(taskId)} Executing ${taskId}.`); @@ -48294,6 +48419,7 @@ async function runRecommendStepsWorkflow(param, taskId, dependencies) { issueNumber: String(issueNumber), issueDescription, previousRecommendation: previousRecommendation?.recommendation, + targetLocale: param.targetLocale, }); (0, logging_ports_1.logDebugInfo)(`RecommendSteps: prompt length=${prompt.length}, issue description length=${issueDescription.length}.`); (0, logging_ports_1.logInfo)('🤖 Recommending steps using the configured agent...'); @@ -48301,6 +48427,7 @@ async function runRecommendStepsWorkflow(param, taskId, dependencies) { configuration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('recommend-steps', agent_response_schemas_1.RECOMMEND_STEPS_RESPONSE_SCHEMA), }); return (0, recommend_steps_result_policy_1.buildRecommendationResult)(param, taskId, response, issueDescriptionFingerprint, previousRecommendation, issueNumber); } @@ -50520,6 +50647,7 @@ function projectPullRequestDescriptionContext(source) { mode: source.ai.getPullRequestDescriptionMode(), membersOnly: source.ai.getAiMembersOnly(), agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('planner') }), + targetLocale: source.locale?.pullRequest ?? 'en-US', }); } @@ -50592,6 +50720,7 @@ function projectProgressContext(source) { ]), agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('findings') }), includeReasoning: source.ai.getAiIncludeReasoning(), + targetLocale: source.locale?.issue ?? 'en-US', }); } function projectRecommendStepsContext(source) { @@ -50603,6 +50732,7 @@ function projectRecommendStepsContext(source) { ...(source.tokenUser ? { tokenUser: source.tokenUser } : {}), ...(previous ? { previousRecommendation: Object.freeze({ ...previous }) } : {}), agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('planner') }), + targetLocale: source.locale?.issue ?? 'en-US', }); } function projectInactivityContext(source) { @@ -51747,7 +51877,7 @@ async function analyzeBugbotRevision(execution, context, dependencies) { dependencies.telemetry.observeContext(context, prompt); (0, logging_ports_1.logInfo)('Detecting potential problems via configured agent using canonical change context...'); const startedAt = Date.now(); - const agentResponse = await dependencies.telemetry.measure('analysis', () => (0, query_bugbot_findings_1.queryBugbotFindings)(dependencies.agent, execution.analysis.agentConfiguration, prompt)); + const agentResponse = await dependencies.telemetry.measure('analysis', () => (0, query_bugbot_findings_1.queryBugbotFindings)(dependencies.agent, execution.analysis.agentConfiguration, prompt, execution.locale.pullRequest)); dependencies.telemetry.observeResponse(agentResponse); (0, logging_ports_1.logInfo)(`Bugbot reviewer completed in ${Date.now() - startedAt}ms.`); const raw = await dependencies.telemetry.measure('normalization', () => (0, prepare_bugbot_findings_1.prepareBugbotFindings)(agentResponse, execution.ignorePatterns, execution.analysis.minimumSeverity, execution.analysis.commentLimit)); @@ -53063,6 +53193,7 @@ function buildBugbotPrompt(param, context) { reviewConversationBlock: context.reviewConversationBlock, rulesBlock: context.reviewRulesBlock, effortBlock: `**Review effort:** ${resolvedEffort}. ${resolvedEffort === 'high' ? 'Perform deeper cross-file and adversarial analysis.' : resolvedEffort === 'low' ? 'Prioritize high-signal changed-code defects and avoid speculative breadth.' : 'Balance depth, latency, and false-positive control.'}`, + targetLocale: param.locale.pullRequest, }); } function buildCoverageBlock(context) { @@ -54671,17 +54802,22 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.queryBugbotFindings = queryBugbotFindings; const agent_task_policy_1 = __nccwpck_require__(85712); const schema_1 = __nccwpck_require__(16808); -async function queryBugbotFindings(repository, configuration, prompt) { - return repository.query({ +const agent_output_locale_policy_1 = __nccwpck_require__(30601); +const application_error_1 = __nccwpck_require__(75999); +async function queryBugbotFindings(repository, configuration, prompt, targetLocale) { + const response = await repository.query({ configuration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, - options: { - expectJson: true, - schema: schema_1.BUGBOT_RESPONSE_SCHEMA, - schemaName: 'bugbot_findings', - }, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('bugbot-review', schema_1.BUGBOT_RESPONSE_SCHEMA), }); + if (response == null || typeof response !== 'object' || Array.isArray(response)) + return response; + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + return validation.payload; } @@ -54940,10 +55076,12 @@ function sanitizeUserCommentForPrompt(raw) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.BUGBOT_FIX_INTENT_RESPONSE_SCHEMA = exports.BUGBOT_RESPONSE_SCHEMA = void 0; const bugbot_finding_marker_policy_1 = __nccwpck_require__(98024); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); /** Detection returns findings and explicit lifecycle changes for prior finding IDs. */ exports.BUGBOT_RESPONSE_SCHEMA = { type: 'object', properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, findings: { type: 'array', maxItems: 200, @@ -55002,7 +55140,7 @@ exports.BUGBOT_RESPONSE_SCHEMA = { description: 'Retained previous findings that are now fixed or obsolete; use an empty array when none are resolved.', }, }, - required: ['findings', 'resolved_findings'], + required: ['outputLocale', 'findings', 'resolved_findings'], additionalProperties: false, }; /** @@ -56237,16 +56375,22 @@ async function inspectWorkspace(gitCommitPort, phase) { /***/ }), /***/ 72063: -/***/ ((__unused_webpack_module, exports) => { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.extractStructuredAnswer = extractStructuredAnswer; -function extractStructuredAnswer(response) { - if (response == null || typeof response !== 'object') +const agent_output_locale_policy_1 = __nccwpck_require__(30601); +const application_error_1 = __nccwpck_require__(75999); +function extractStructuredAnswer(response, targetLocale) { + if (response == null) return ''; - const answer = response.answer; + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + const answer = validation.payload.answer; return typeof answer === 'string' ? answer.trim() : ''; } @@ -56942,6 +57086,7 @@ const agent_answer_policy_1 = __nccwpck_require__(72063); const github_comment_publication_policy_1 = __nccwpck_require__(72712); const application_error_1 = __nccwpck_require__(75999); const comment_translation_policy_1 = __nccwpck_require__(27150); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); async function runThinkAnswerWorkflow(param, taskId, request, dependencies) { const issueDescription = await loadIssueDescription(request.issueNumberForContext, dependencies.issueDescriptionQueryPort); const contextBlock = issueDescription @@ -56954,7 +57099,7 @@ async function runThinkAnswerWorkflow(param, taskId, request, dependencies) { question: request.question, targetLocale: param.targetLocale ?? 'en-US', }); - const answer = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(await queryThinkAnswer(param, prompt, dependencies.aiRepository)); + const answer = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(await queryThinkAnswer(param, prompt, dependencies.aiRepository, param.targetLocale ?? 'en-US')); if (!answer) { (0, logging_ports_1.logError)('Configured agent returned no answer for Think.'); return [ @@ -56987,19 +57132,15 @@ async function loadIssueDescription(issueNumber, repository) { const description = await repository.getDescription(issueNumber); return description?.trim() ?? ''; } -async function queryThinkAnswer(param, prompt, repository) { +async function queryThinkAnswer(param, prompt, repository, targetLocale) { (0, logging_ports_1.logDebugInfo)(`Think: calling configured agent (prompt length=${prompt.length}).`); const response = await repository.query({ configuration: param.agentConfiguration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, - options: { - expectJson: true, - schema: agent_response_schemas_1.THINK_RESPONSE_SCHEMA, - schemaName: 'think_response', - }, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('think', agent_response_schemas_1.THINK_RESPONSE_SCHEMA), }); - const answer = (0, agent_answer_policy_1.extractStructuredAnswer)(response); + const answer = (0, agent_answer_policy_1.extractStructuredAnswer)(response, targetLocale); (0, logging_ports_1.logDebugInfo)(`Think: agent response received. Answer length=${answer.length}.`); return answer; } @@ -57398,6 +57539,7 @@ const agent_answer_policy_1 = __nccwpck_require__(72063); const github_comment_publication_policy_1 = __nccwpck_require__(72712); const copilot_interaction_policy_1 = __nccwpck_require__(90108); const application_error_1 = __nccwpck_require__(75999); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); const TASK_ID = 'AnswerIssueHelpUseCase'; /** Posts one contextual answer for a newly opened question/help issue. */ async function runAnswerIssueHelpWorkflow(param, dependencies) { @@ -57418,13 +57560,9 @@ async function runAnswerIssueHelpWorkflow(param, dependencies) { configuration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, - options: { - expectJson: true, - schema: agent_response_schemas_1.THINK_RESPONSE_SCHEMA, - schemaName: 'answer_issue_help_response', - }, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('answer-issue-help', agent_response_schemas_1.THINK_RESPONSE_SCHEMA), }); - const answer = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)((0, agent_answer_policy_1.extractStructuredAnswer)(response)); + const answer = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)((0, agent_answer_policy_1.extractStructuredAnswer)(response, param.locale)); (0, logging_ports_1.logDebugInfo)(`AnswerIssueHelp: agent response. Answer length=${answer.length}.`); if (!answer) { return [noAnswerResult()]; @@ -59176,6 +59314,8 @@ const github_comment_publication_policy_1 = __nccwpck_require__(72712); const pull_request_description_1 = __nccwpck_require__(45315); const application_error_1 = __nccwpck_require__(75999); const positive_integer_policy_1 = __nccwpck_require__(19879); +const agent_response_schemas_1 = __nccwpck_require__(25603); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); /** Generates and publishes a PR description from an immutable, capability-scoped request. */ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(taskId)} Executing ${taskId} (AI PR description).`); @@ -59219,14 +59359,16 @@ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependen relatedIssueInstruction: context.issueNumber > 0 ? `Include \`Closes #${context.issueNumber}\` and "Related to #" only if relevant.` : 'Do not add a Closes line because this pull request has no linked issue.', + targetLocale: context.targetLocale, }); (0, logging_ports_1.logDebugInfo)(`UpdatePullRequestDescription: prompt length=${prompt.length}, issue description length=${issueDescription.length}. Calling configured agent.`); const response = await dependencies.aiRepository.query({ configuration: context.agentConfiguration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('pull-request-description', agent_response_schemas_1.PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA), }); - const generatedDescription = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(extractDescription(response)); + const generatedDescription = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(extractDescription(response, context.targetLocale)); if (!generatedDescription.trim()) { return [new result_1.Result({ id: taskId, @@ -59243,7 +59385,7 @@ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependen return [new result_1.Result({ id: taskId, success: true, executed: true, steps: [] })]; } catch (cause) { - const semanticError = new application_error_1.ApplicationError('workflow.failed', 'Unable to update pull request description.', { cause }); + const semanticError = (0, application_error_1.toApplicationError)(cause, 'workflow.failed', 'Unable to update pull request description.'); (0, logging_ports_1.logError)(semanticError); return [new result_1.Result({ id: taskId, @@ -59274,12 +59416,14 @@ async function loadPullRequestDetails(context, dependencies, trigger) { ? dependencies.pullRequestDescriptionCommandPort.getDetails(context.pullRequest.number) : undefined; } -function extractDescription(response) { - if (typeof response === 'string') - return response; - if (!response) +function extractDescription(response, targetLocale) { + if (response == null) return ''; - return typeof response.description === 'string' ? response.description : ''; + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + return typeof validation.payload.description === 'string' ? validation.payload.description : ''; } function skipped(taskId, step) { return [new result_1.Result({ id: taskId, success: false, executed: false, steps: [step] })]; @@ -61872,6 +62016,12 @@ exports.APPLICATION_ERROR_METADATA = { action: 'Inspect the sanitized agent status and retry if appropriate.', retainedState: PRESERVED_STATE, }, + 'locale.output-invalid': { + kind: 'agent', retryable: true, + impact: 'Agent-generated product content was rejected before publication because its locale contract was invalid.', + action: 'Retry with a provider that supports the configured repository locale.', + retainedState: UNCHANGED_STATE, + }, 'locale.translation-failed': { kind: 'agent', retryable: true, impact: 'The request could not be safely interpreted in the configured repository language.', @@ -76319,6 +76469,8 @@ exports.getAnswerIssueHelpPrompt = getAnswerIssueHelpPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `The user has just opened a question/help issue. Provide a helpful initial response to their question or request below. Be concise and actionable. Write every human-readable sentence in {{targetLocale}} while preserving code identifiers, paths, refs, commands, and URLs verbatim. +Return a JSON object with \`outputLocale\` set exactly to \`{{targetLocale}}\` and \`answer\` containing the Markdown response. + **Answer in this single response:** Give a complete, direct answer. Do not reply that you need to explore the repository, read documentation first, or gather more information—use the project (README, docs/, code, .cursor/rules) to answer now. For "how do I…" or tutorial-style questions (e.g. how to implement or configure this project), provide concrete steps or guidance based on the project's actual documentation and structure. {{projectContextInstruction}} @@ -76326,7 +76478,7 @@ const TEMPLATE = `The user has just opened a question/help issue. Provide a help **Issue description (user's question or request):** {{description}} -Respond with a single JSON object containing an "answer" field with your reply. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; +Respond with a single JSON object containing \`outputLocale\` and \`answer\`. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; function getAnswerIssueHelpPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { description: params.description, @@ -76374,6 +76526,8 @@ exports.getBugbotPrompt = getBugbotPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are analyzing the latest code changes for potential bugs and issues. +Write every human-readable finding title, description, evidence, and suggestion in {{targetLocale}}. Preserve identifiers, code, symbols, paths, refs, commands, and URLs verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Repository context:** @@ -76407,7 +76561,7 @@ For every finding: Return every finding field required by the response schema. Use null for file, line, endLine, severity, confidence, category, evidence, suggestion, symbol, codeSnippet, or suggestedCode when that value does not safely apply. Only include files outside the ignore list. {{previousBlock}} -**Output:** Return a JSON object with "findings" (new/current problems from task 1) and "resolved_findings" (objects containing the exact prior finding id and either "fixed" or "obsolete"). Always return both arrays; use an empty array when there are no resolved findings. Never resolve an id that was not included in the previous-findings list.`; +**Output:** Return a JSON object with "outputLocale", "findings" (new/current problems from task 1), and "resolved_findings" (objects containing the exact prior finding id and either "fixed" or "obsolete"). Always return both arrays; use an empty array when there are no resolved findings. Never resolve an id that was not included in the previous-findings list.`; function getBugbotPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { ...params, @@ -76560,6 +76714,8 @@ exports.getCheckProgressPrompt = getCheckProgressPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are in the repository workspace. Assess the progress of issue #{{issueNumber}} using the full diff between the base (parent) branch and the current branch. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, percentages, and JSON keys verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Branches:** @@ -76575,7 +76731,7 @@ const TEMPLATE = `You are in the repository workspace. Assess the progress of is **Issue description:** {{issueDescription}} -Respond with a single JSON object: { "progress": , "summary": "", "remaining": "" | null }.`; +Respond with a single JSON object: { "outputLocale": "{{targetLocale}}", "progress": , "summary": "", "remaining": "" | null }.`; function getCheckProgressPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { projectContextInstruction: params.projectContextInstruction, @@ -76583,6 +76739,7 @@ function getCheckProgressPrompt(params) { baseBranch: params.baseBranch, currentBranch: params.currentBranch, issueDescription: params.issueDescription, + targetLocale: params.targetLocale, }); } @@ -76767,6 +76924,8 @@ exports.getRecommendStepsPrompt = getRecommendStepsPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `Based on the following issue description, recommend concrete steps to implement or address this issue. Order the steps logically (e.g. setup, implementation, tests, docs). Keep each step clear and actionable. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, and URLs verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Issue #{{issueNumber}} description:** @@ -76774,14 +76933,15 @@ const TEMPLATE = `Based on the following issue description, recommend concrete s {{previousRecommendation}} -Provide a complete numbered list of recommended steps in **markdown** (use headings, lists, code blocks for commands or snippets) so it is easy to read. You can add brief sub-bullets per step if needed. +Return one JSON object with \`outputLocale\`, \`status\`, and \`steps\`. When a material recommendation is needed, set \`status\` to \`recommendation\` and put a complete numbered list in Markdown in \`steps\` (headings, lists, and code blocks are allowed). You can add brief sub-bullets per step if needed. -If the current description does not require any material change to the previous recommendation, output exactly \`NO_NEW_RECOMMENDATIONS\` and nothing else. Do not use that sentinel when there is no previous recommendation.`; +If the current description does not require any material change to the previous recommendation, set \`status\` to \`unchanged\` and \`steps\` to null. Do not return \`unchanged\` when there is no previous recommendation.`; function getRecommendStepsPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { projectContextInstruction: params.projectContextInstruction, issueNumber: String(params.issueNumber), issueDescription: params.issueDescription, + targetLocale: params.targetLocale, previousRecommendation: params.previousRecommendation ? `Previous recommendation (use only to detect whether the current plan is still valid):\n\n${params.previousRecommendation}\n` : 'There is no previous recommendation for this issue.', @@ -76804,6 +76964,8 @@ exports.getThinkPrompt = getThinkPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are a helpful assistant. Answer the following question concisely in {{targetLocale}}, using the context below when relevant. Format your answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response. Preserve code identifiers, paths, refs, commands, and URLs verbatim. +Return a JSON object with \`outputLocale\` set exactly to \`{{targetLocale}}\` and \`answer\` containing the Markdown response. Every human-readable sentence in \`answer\` must use the target locale. + {{projectContextInstruction}} {{contextBlock}}Question: {{question}}`; function getThinkPrompt(params) { @@ -76811,7 +76973,7 @@ function getThinkPrompt(params) { projectContextInstruction: params.projectContextInstruction, contextBlock: params.contextBlock, question: params.question, - targetLocale: params.targetLocale ?? 'en-US', + targetLocale: params.targetLocale, }); } @@ -76831,6 +76993,8 @@ exports.getUpdatePullRequestDescriptionPrompt = getUpdatePullRequestDescriptionP const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, issue/PR references, and conventional title prefixes verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Branches:** @@ -76851,12 +77015,12 @@ const TEMPLATE = `You are in the repository workspace. Your task is to produce a - **Breaking Changes:** list any, or "None". - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. 5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. -6. **Output format:** Return only the filled template content. Do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start directly with the first heading of the template (e.g. # Summary). Do not wrap the output in code blocks. +6. **Output format:** Return one JSON object with \`outputLocale\` and \`description\`. Put only the filled template content in \`description\`; do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start \`description\` directly with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks. **Issue description:** {{issueDescription}} -Output only the filled template content (the PR description body), starting with the first heading. No preamble, no commentary.`; +Return the structured JSON response only.`; function getUpdatePullRequestDescriptionPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { projectContextInstruction: params.projectContextInstruction, @@ -76865,6 +77029,7 @@ function getUpdatePullRequestDescriptionPrompt(params) { issueNumber: String(params.issueNumber), issueDescription: params.issueDescription, relatedIssueInstruction: params.relatedIssueInstruction, + targetLocale: params.targetLocale, }); } diff --git a/build/github_action/index.js b/build/github_action/index.js index 85896e745..3a2cea603 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -41846,16 +41846,103 @@ function isRecord(value) { } +/***/ }), + +/***/ 30601: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY = exports.PRODUCT_FACING_AGENT_TASKS = void 0; +exports.productFacingAgentQueryOptions = productFacingAgentQueryOptions; +exports.validateAgentOutputLocale = validateAgentOutputLocale; +exports.agentOutputLocaleFailureMessage = agentOutputLocaleFailureMessage; +const locale_1 = __nccwpck_require__(15386); +exports.PRODUCT_FACING_AGENT_TASKS = [ + 'think', + 'answer-issue-help', + 'progress', + 'recommend-steps', + 'pull-request-description', + 'bugbot-review', +]; +const PRODUCT_FACING_AGENT_SCHEMA_NAMES = Object.freeze({ + think: 'think_response', + 'answer-issue-help': 'answer_issue_help_response', + progress: 'progress_response', + 'recommend-steps': 'recommend_steps_response', + 'pull-request-description': 'pull_request_description_response', + 'bugbot-review': 'bugbot_findings', +}); +exports.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY = { + type: 'string', + minLength: 1, + maxLength: 255, + description: 'The exact canonical BCP-47 locale requested in targetLocale.', +}; +/** + * Builds the only supported structured-output options for product-facing agent + * calls. Runtime assertions make an accidentally weakened schema fail before + * an agent provider is invoked. + */ +function productFacingAgentQueryOptions(task, schema) { + if (!schema.properties?.outputLocale || !schema.required?.includes('outputLocale')) { + throw new TypeError(`Product-facing agent schema for ${task} must require outputLocale.`); + } + return Object.freeze({ + expectJson: true, + schema: schema, + schemaName: PRODUCT_FACING_AGENT_SCHEMA_NAMES[task], + }); +} +/** Validates locale metadata before any model prose can reach product state. */ +function validateAgentOutputLocale(response, targetLocale) { + const expectedLocale = (0, locale_1.canonicalizeLocaleTag)(targetLocale); + if (response == null || typeof response !== 'object' || Array.isArray(response)) { + return Object.freeze({ kind: 'invalid', expectedLocale, reason: 'response-not-object' }); + } + const payload = response; + if (typeof payload.outputLocale !== 'string' || !payload.outputLocale.trim()) { + return Object.freeze({ kind: 'invalid', expectedLocale, reason: 'output-locale-missing' }); + } + let actualLocale; + try { + actualLocale = (0, locale_1.canonicalizeLocaleTag)(payload.outputLocale); + } + catch { + return Object.freeze({ + kind: 'invalid', + expectedLocale, + reason: 'output-locale-invalid', + }); + } + if (actualLocale !== expectedLocale || payload.outputLocale !== expectedLocale) { + return Object.freeze({ + kind: 'invalid', + expectedLocale, + actualLocale, + reason: 'output-locale-mismatch', + }); + } + return Object.freeze({ kind: 'valid', expectedLocale, payload: Object.freeze({ ...payload }) }); +} +function agentOutputLocaleFailureMessage(validation) { + return `Configured agent output was rejected before publication (${validation.reason}; expected ${validation.expectedLocale}).`; +} + + /***/ }), /***/ 25603: -/***/ ((__unused_webpack_module, exports) => { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; /** Shared structured-response contracts used by agent-backed application flows. */ Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.LANGUAGE_CHECK_RESPONSE_SCHEMA = exports.THINK_RESPONSE_SCHEMA = exports.TRANSLATION_RESPONSE_SCHEMA = exports.LANGUAGE_ADAPTATION_RESPONSE_SCHEMA = void 0; +exports.LANGUAGE_CHECK_RESPONSE_SCHEMA = exports.PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA = exports.RECOMMEND_STEPS_RESPONSE_SCHEMA = exports.THINK_RESPONSE_SCHEMA = exports.TRANSLATION_RESPONSE_SCHEMA = exports.LANGUAGE_ADAPTATION_RESPONSE_SCHEMA = void 0; +const agent_output_locale_policy_1 = __nccwpck_require__(30601); exports.LANGUAGE_ADAPTATION_RESPONSE_SCHEMA = { type: 'object', properties: { @@ -41894,6 +41981,7 @@ exports.TRANSLATION_RESPONSE_SCHEMA = exports.LANGUAGE_ADAPTATION_RESPONSE_SCHEM exports.THINK_RESPONSE_SCHEMA = { type: 'object', properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, answer: { type: 'string', minLength: 1, @@ -41901,7 +41989,39 @@ exports.THINK_RESPONSE_SCHEMA = { description: 'The concise answer to the user question. Required.', }, }, - required: ['answer'], + required: ['outputLocale', 'answer'], + additionalProperties: false, +}; +exports.RECOMMEND_STEPS_RESPONSE_SCHEMA = { + type: 'object', + properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, + status: { + type: 'string', + enum: ['recommendation', 'unchanged'], + description: 'Whether a recommendation is present or the previous recommendation remains valid.', + }, + steps: { + type: ['string', 'null'], + maxLength: 12000, + description: 'Markdown implementation steps for recommendation; null when status is unchanged.', + }, + }, + required: ['outputLocale', 'status', 'steps'], + additionalProperties: false, +}; +exports.PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA = { + type: 'object', + properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, + description: { + type: 'string', + minLength: 1, + maxLength: 60000, + description: 'The complete Markdown pull-request description body.', + }, + }, + required: ['outputLocale', 'description'], additionalProperties: false, }; /** @deprecated Retained for API compatibility; runtime adaptation uses one combined schema. */ @@ -48530,6 +48650,7 @@ const find_issue_branch_1 = __nccwpck_require__(38575); const progress_prerequisite_policy_1 = __nccwpck_require__(31001); const progress_response_1 = __nccwpck_require__(64264); const application_error_1 = __nccwpck_require__(75999); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); /** Loads progress context and asks the configured agent for an assessment. */ async function analyzeProgress(param, taskId, dependencies) { const issueNumber = param.issueNumber; @@ -48576,6 +48697,7 @@ async function analyzeProgress(param, taskId, dependencies) { issueDescription, baseBranch: developmentBranch, currentBranch: resolvedBranch, + targetLocale: param.targetLocale, }); (0, logging_ports_1.logDebugInfo)(`CheckProgress: prompt length=${prompt.length}, issue description length=${issueDescription.length}.`); (0, logging_ports_1.logInfo)('🤖 Analyzing progress using the configured agent...'); @@ -48584,12 +48706,10 @@ async function analyzeProgress(param, taskId, dependencies) { agentId: agent_task_policy_1.AGENT_PLAN, prompt, options: { - expectJson: true, - schema: progress_response_1.PROGRESS_RESPONSE_SCHEMA, - schemaName: 'progress_response', + ...(0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('progress', progress_response_1.PROGRESS_RESPONSE_SCHEMA), includeReasoning: param.includeReasoning, }, - })); + }), param.targetLocale); return { kind: 'ready', issueNumber, @@ -48637,25 +48757,32 @@ function validateProgressPrerequisites(input) { /***/ }), /***/ 64264: -/***/ ((__unused_webpack_module, exports) => { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.PROGRESS_RESPONSE_SCHEMA = void 0; exports.parseProgressResponse = parseProgressResponse; +const agent_output_locale_policy_1 = __nccwpck_require__(30601); +const application_error_1 = __nccwpck_require__(75999); exports.PROGRESS_RESPONSE_SCHEMA = { type: 'object', properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, progress: { type: 'number', minimum: 0, maximum: 100, description: 'Completion percentage 0-100' }, summary: { type: 'string', minLength: 1, maxLength: 8000, description: 'Short explanation of the assessment' }, remaining: { type: ['string', 'null'], maxLength: 8000, description: 'When progress < 100: what is left to do to reach 100%; otherwise null.' }, }, - required: ['progress', 'summary', 'remaining'], + required: ['outputLocale', 'progress', 'summary', 'remaining'], additionalProperties: false, }; -function parseProgressResponse(response) { - const payload = response && typeof response === 'object' ? response : {}; +function parseProgressResponse(response, targetLocale) { + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + const payload = validation.payload; const rawProgress = typeof payload.progress === 'number' ? payload.progress : 0; return { progress: Math.min(100, Math.max(0, Math.round(rawProgress))), @@ -48877,10 +49004,10 @@ exports.buildRecommendationResult = buildRecommendationResult; const result_1 = __nccwpck_require__(73817); const recommendation_policy_1 = __nccwpck_require__(39410); const logging_ports_1 = __nccwpck_require__(6152); -const copilot_interaction_policy_1 = __nccwpck_require__(90108); const application_error_1 = __nccwpck_require__(75999); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); function buildRecommendationResult(param, taskId, response, issueDescriptionFingerprint, previousRecommendation, issueNumber) { - const steps = extractRecommendationText(response); + const steps = extractRecommendationText(response, param.targetLocale); if (!steps) { const semanticError = new application_error_1.ApplicationError('agent.failed', 'The configured agent returned no recommendation.'); (0, logging_ports_1.logError)(semanticError); @@ -48897,21 +49024,13 @@ function buildRecommendationResult(param, taskId, response, issueDescriptionFing recommendationFingerprint, recommendation: (0, recommendation_policy_1.limitStoredRecommendation)(steps), }; - const stepsWithWelcome = isNewIssue(param) - ? [(0, copilot_interaction_policy_1.buildCopilotWelcomeMessage)(param.tokenUser), '## Recommended implementation steps', steps] - : ['## Recommended implementation steps', steps]; return recommendationOutcome([new result_1.Result({ id: taskId, success: true, executed: true, - stepFormat: 'markdown', - steps: stepsWithWelcome, payload: { issueNumber, recommendedSteps: steps, recommendationState }, })]); } -function isNewIssue(param) { - return param.eventName === 'issues' && param.eventAction === 'opened'; -} function skipUnchangedRecommendation(_param, previous, fingerprint, reason) { (0, logging_ports_1.logInfo)(`RecommendSteps: ${reason}; skipping recommendation comment.`); return recommendationOutcome([], { ...previous, issueDescriptionFingerprint: fingerprint }); @@ -48924,12 +49043,16 @@ function recommendationOutcome(results, recommendationState) { } : {}), }); } -function extractRecommendationText(response) { - if (typeof response === 'string') - return response.trim(); - if (!response || typeof response.steps !== 'string') +function extractRecommendationText(response, targetLocale) { + if (response == null) return ''; - return response.steps.trim(); + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + if (validation.payload.status === 'unchanged') + return recommendation_policy_1.NO_NEW_RECOMMENDATIONS; + return typeof validation.payload.steps === 'string' ? validation.payload.steps.trim() : ''; } @@ -48979,6 +49102,8 @@ const project_context_instruction_1 = __nccwpck_require__(63907); const task_emoji_1 = __nccwpck_require__(46103); const recommend_steps_result_policy_1 = __nccwpck_require__(65928); const application_error_1 = __nccwpck_require__(75999); +const agent_response_schemas_1 = __nccwpck_require__(25603); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); /** Runs the recommendation policy and agent interaction for an issue. */ async function runRecommendStepsWorkflow(param, taskId, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(taskId)} Executing ${taskId}.`); @@ -49009,6 +49134,7 @@ async function runRecommendStepsWorkflow(param, taskId, dependencies) { issueNumber: String(issueNumber), issueDescription, previousRecommendation: previousRecommendation?.recommendation, + targetLocale: param.targetLocale, }); (0, logging_ports_1.logDebugInfo)(`RecommendSteps: prompt length=${prompt.length}, issue description length=${issueDescription.length}.`); (0, logging_ports_1.logInfo)('🤖 Recommending steps using the configured agent...'); @@ -49016,6 +49142,7 @@ async function runRecommendStepsWorkflow(param, taskId, dependencies) { configuration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('recommend-steps', agent_response_schemas_1.RECOMMEND_STEPS_RESPONSE_SCHEMA), }); return (0, recommend_steps_result_policy_1.buildRecommendationResult)(param, taskId, response, issueDescriptionFingerprint, previousRecommendation, issueNumber); } @@ -51521,6 +51648,7 @@ function projectPullRequestDescriptionContext(source) { mode: source.ai.getPullRequestDescriptionMode(), membersOnly: source.ai.getAiMembersOnly(), agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('planner') }), + targetLocale: source.locale?.pullRequest ?? 'en-US', }); } @@ -51593,6 +51721,7 @@ function projectProgressContext(source) { ]), agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('findings') }), includeReasoning: source.ai.getAiIncludeReasoning(), + targetLocale: source.locale?.issue ?? 'en-US', }); } function projectRecommendStepsContext(source) { @@ -51604,6 +51733,7 @@ function projectRecommendStepsContext(source) { ...(source.tokenUser ? { tokenUser: source.tokenUser } : {}), ...(previous ? { previousRecommendation: Object.freeze({ ...previous }) } : {}), agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('planner') }), + targetLocale: source.locale?.issue ?? 'en-US', }); } function projectInactivityContext(source) { @@ -51961,7 +52091,7 @@ async function analyzeBugbotRevision(execution, context, dependencies) { dependencies.telemetry.observeContext(context, prompt); (0, logging_ports_1.logInfo)('Detecting potential problems via configured agent using canonical change context...'); const startedAt = Date.now(); - const agentResponse = await dependencies.telemetry.measure('analysis', () => (0, query_bugbot_findings_1.queryBugbotFindings)(dependencies.agent, execution.analysis.agentConfiguration, prompt)); + const agentResponse = await dependencies.telemetry.measure('analysis', () => (0, query_bugbot_findings_1.queryBugbotFindings)(dependencies.agent, execution.analysis.agentConfiguration, prompt, execution.locale.pullRequest)); dependencies.telemetry.observeResponse(agentResponse); (0, logging_ports_1.logInfo)(`Bugbot reviewer completed in ${Date.now() - startedAt}ms.`); const raw = await dependencies.telemetry.measure('normalization', () => (0, prepare_bugbot_findings_1.prepareBugbotFindings)(agentResponse, execution.ignorePatterns, execution.analysis.minimumSeverity, execution.analysis.commentLimit)); @@ -53277,6 +53407,7 @@ function buildBugbotPrompt(param, context) { reviewConversationBlock: context.reviewConversationBlock, rulesBlock: context.reviewRulesBlock, effortBlock: `**Review effort:** ${resolvedEffort}. ${resolvedEffort === 'high' ? 'Perform deeper cross-file and adversarial analysis.' : resolvedEffort === 'low' ? 'Prioritize high-signal changed-code defects and avoid speculative breadth.' : 'Balance depth, latency, and false-positive control.'}`, + targetLocale: param.locale.pullRequest, }); } function buildCoverageBlock(context) { @@ -54885,17 +55016,22 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.queryBugbotFindings = queryBugbotFindings; const agent_task_policy_1 = __nccwpck_require__(85712); const schema_1 = __nccwpck_require__(16808); -async function queryBugbotFindings(repository, configuration, prompt) { - return repository.query({ +const agent_output_locale_policy_1 = __nccwpck_require__(30601); +const application_error_1 = __nccwpck_require__(75999); +async function queryBugbotFindings(repository, configuration, prompt, targetLocale) { + const response = await repository.query({ configuration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, - options: { - expectJson: true, - schema: schema_1.BUGBOT_RESPONSE_SCHEMA, - schemaName: 'bugbot_findings', - }, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('bugbot-review', schema_1.BUGBOT_RESPONSE_SCHEMA), }); + if (response == null || typeof response !== 'object' || Array.isArray(response)) + return response; + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + return validation.payload; } @@ -55154,10 +55290,12 @@ function sanitizeUserCommentForPrompt(raw) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.BUGBOT_FIX_INTENT_RESPONSE_SCHEMA = exports.BUGBOT_RESPONSE_SCHEMA = void 0; const bugbot_finding_marker_policy_1 = __nccwpck_require__(98024); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); /** Detection returns findings and explicit lifecycle changes for prior finding IDs. */ exports.BUGBOT_RESPONSE_SCHEMA = { type: 'object', properties: { + outputLocale: agent_output_locale_policy_1.AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, findings: { type: 'array', maxItems: 200, @@ -55216,7 +55354,7 @@ exports.BUGBOT_RESPONSE_SCHEMA = { description: 'Retained previous findings that are now fixed or obsolete; use an empty array when none are resolved.', }, }, - required: ['findings', 'resolved_findings'], + required: ['outputLocale', 'findings', 'resolved_findings'], additionalProperties: false, }; /** @@ -56451,16 +56589,22 @@ async function inspectWorkspace(gitCommitPort, phase) { /***/ }), /***/ 72063: -/***/ ((__unused_webpack_module, exports) => { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.extractStructuredAnswer = extractStructuredAnswer; -function extractStructuredAnswer(response) { - if (response == null || typeof response !== 'object') +const agent_output_locale_policy_1 = __nccwpck_require__(30601); +const application_error_1 = __nccwpck_require__(75999); +function extractStructuredAnswer(response, targetLocale) { + if (response == null) return ''; - const answer = response.answer; + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + const answer = validation.payload.answer; return typeof answer === 'string' ? answer.trim() : ''; } @@ -57524,6 +57668,7 @@ const agent_answer_policy_1 = __nccwpck_require__(72063); const github_comment_publication_policy_1 = __nccwpck_require__(72712); const application_error_1 = __nccwpck_require__(75999); const comment_translation_policy_1 = __nccwpck_require__(27150); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); async function runThinkAnswerWorkflow(param, taskId, request, dependencies) { const issueDescription = await loadIssueDescription(request.issueNumberForContext, dependencies.issueDescriptionQueryPort); const contextBlock = issueDescription @@ -57536,7 +57681,7 @@ async function runThinkAnswerWorkflow(param, taskId, request, dependencies) { question: request.question, targetLocale: param.targetLocale ?? 'en-US', }); - const answer = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(await queryThinkAnswer(param, prompt, dependencies.aiRepository)); + const answer = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(await queryThinkAnswer(param, prompt, dependencies.aiRepository, param.targetLocale ?? 'en-US')); if (!answer) { (0, logging_ports_1.logError)('Configured agent returned no answer for Think.'); return [ @@ -57569,19 +57714,15 @@ async function loadIssueDescription(issueNumber, repository) { const description = await repository.getDescription(issueNumber); return description?.trim() ?? ''; } -async function queryThinkAnswer(param, prompt, repository) { +async function queryThinkAnswer(param, prompt, repository, targetLocale) { (0, logging_ports_1.logDebugInfo)(`Think: calling configured agent (prompt length=${prompt.length}).`); const response = await repository.query({ configuration: param.agentConfiguration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, - options: { - expectJson: true, - schema: agent_response_schemas_1.THINK_RESPONSE_SCHEMA, - schemaName: 'think_response', - }, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('think', agent_response_schemas_1.THINK_RESPONSE_SCHEMA), }); - const answer = (0, agent_answer_policy_1.extractStructuredAnswer)(response); + const answer = (0, agent_answer_policy_1.extractStructuredAnswer)(response, targetLocale); (0, logging_ports_1.logDebugInfo)(`Think: agent response received. Answer length=${answer.length}.`); return answer; } @@ -57980,6 +58121,7 @@ const agent_answer_policy_1 = __nccwpck_require__(72063); const github_comment_publication_policy_1 = __nccwpck_require__(72712); const copilot_interaction_policy_1 = __nccwpck_require__(90108); const application_error_1 = __nccwpck_require__(75999); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); const TASK_ID = 'AnswerIssueHelpUseCase'; /** Posts one contextual answer for a newly opened question/help issue. */ async function runAnswerIssueHelpWorkflow(param, dependencies) { @@ -58000,13 +58142,9 @@ async function runAnswerIssueHelpWorkflow(param, dependencies) { configuration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, - options: { - expectJson: true, - schema: agent_response_schemas_1.THINK_RESPONSE_SCHEMA, - schemaName: 'answer_issue_help_response', - }, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('answer-issue-help', agent_response_schemas_1.THINK_RESPONSE_SCHEMA), }); - const answer = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)((0, agent_answer_policy_1.extractStructuredAnswer)(response)); + const answer = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)((0, agent_answer_policy_1.extractStructuredAnswer)(response, param.locale)); (0, logging_ports_1.logDebugInfo)(`AnswerIssueHelp: agent response. Answer length=${answer.length}.`); if (!answer) { return [noAnswerResult()]; @@ -59758,6 +59896,8 @@ const github_comment_publication_policy_1 = __nccwpck_require__(72712); const pull_request_description_1 = __nccwpck_require__(45315); const application_error_1 = __nccwpck_require__(75999); const positive_integer_policy_1 = __nccwpck_require__(19879); +const agent_response_schemas_1 = __nccwpck_require__(25603); +const agent_output_locale_policy_1 = __nccwpck_require__(30601); /** Generates and publishes a PR description from an immutable, capability-scoped request. */ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependencies) { (0, logging_ports_1.logInfo)(`${(0, task_emoji_1.getTaskEmoji)(taskId)} Executing ${taskId} (AI PR description).`); @@ -59801,14 +59941,16 @@ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependen relatedIssueInstruction: context.issueNumber > 0 ? `Include \`Closes #${context.issueNumber}\` and "Related to #" only if relevant.` : 'Do not add a Closes line because this pull request has no linked issue.', + targetLocale: context.targetLocale, }); (0, logging_ports_1.logDebugInfo)(`UpdatePullRequestDescription: prompt length=${prompt.length}, issue description length=${issueDescription.length}. Calling configured agent.`); const response = await dependencies.aiRepository.query({ configuration: context.agentConfiguration, agentId: agent_task_policy_1.AGENT_PLAN, prompt, + options: (0, agent_output_locale_policy_1.productFacingAgentQueryOptions)('pull-request-description', agent_response_schemas_1.PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA), }); - const generatedDescription = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(extractDescription(response)); + const generatedDescription = (0, github_comment_publication_policy_1.sanitizeAgentMarkdown)(extractDescription(response, context.targetLocale)); if (!generatedDescription.trim()) { return [new result_1.Result({ id: taskId, @@ -59825,7 +59967,7 @@ async function runUpdatePullRequestDescriptionWorkflow(request, taskId, dependen return [new result_1.Result({ id: taskId, success: true, executed: true, steps: [] })]; } catch (cause) { - const semanticError = new application_error_1.ApplicationError('workflow.failed', 'Unable to update pull request description.', { cause }); + const semanticError = (0, application_error_1.toApplicationError)(cause, 'workflow.failed', 'Unable to update pull request description.'); (0, logging_ports_1.logError)(semanticError); return [new result_1.Result({ id: taskId, @@ -59856,12 +59998,14 @@ async function loadPullRequestDetails(context, dependencies, trigger) { ? dependencies.pullRequestDescriptionCommandPort.getDetails(context.pullRequest.number) : undefined; } -function extractDescription(response) { - if (typeof response === 'string') - return response; - if (!response) +function extractDescription(response, targetLocale) { + if (response == null) return ''; - return typeof response.description === 'string' ? response.description : ''; + const validation = (0, agent_output_locale_policy_1.validateAgentOutputLocale)(response, targetLocale); + if (validation.kind === 'invalid') { + throw new application_error_1.ApplicationError('locale.output-invalid', (0, agent_output_locale_policy_1.agentOutputLocaleFailureMessage)(validation)); + } + return typeof validation.payload.description === 'string' ? validation.payload.description : ''; } function skipped(taskId, step) { return [new result_1.Result({ id: taskId, success: false, executed: false, steps: [step] })]; @@ -60233,6 +60377,12 @@ exports.APPLICATION_ERROR_METADATA = { action: 'Inspect the sanitized agent status and retry if appropriate.', retainedState: PRESERVED_STATE, }, + 'locale.output-invalid': { + kind: 'agent', retryable: true, + impact: 'Agent-generated product content was rejected before publication because its locale contract was invalid.', + action: 'Retry with a provider that supports the configured repository locale.', + retainedState: UNCHANGED_STATE, + }, 'locale.translation-failed': { kind: 'agent', retryable: true, impact: 'The request could not be safely interpreted in the configured repository language.', @@ -74194,6 +74344,8 @@ exports.getAnswerIssueHelpPrompt = getAnswerIssueHelpPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `The user has just opened a question/help issue. Provide a helpful initial response to their question or request below. Be concise and actionable. Write every human-readable sentence in {{targetLocale}} while preserving code identifiers, paths, refs, commands, and URLs verbatim. +Return a JSON object with \`outputLocale\` set exactly to \`{{targetLocale}}\` and \`answer\` containing the Markdown response. + **Answer in this single response:** Give a complete, direct answer. Do not reply that you need to explore the repository, read documentation first, or gather more information—use the project (README, docs/, code, .cursor/rules) to answer now. For "how do I…" or tutorial-style questions (e.g. how to implement or configure this project), provide concrete steps or guidance based on the project's actual documentation and structure. {{projectContextInstruction}} @@ -74201,7 +74353,7 @@ const TEMPLATE = `The user has just opened a question/help issue. Provide a help **Issue description (user's question or request):** {{description}} -Respond with a single JSON object containing an "answer" field with your reply. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; +Respond with a single JSON object containing \`outputLocale\` and \`answer\`. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; function getAnswerIssueHelpPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { description: params.description, @@ -74249,6 +74401,8 @@ exports.getBugbotPrompt = getBugbotPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are analyzing the latest code changes for potential bugs and issues. +Write every human-readable finding title, description, evidence, and suggestion in {{targetLocale}}. Preserve identifiers, code, symbols, paths, refs, commands, and URLs verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Repository context:** @@ -74282,7 +74436,7 @@ For every finding: Return every finding field required by the response schema. Use null for file, line, endLine, severity, confidence, category, evidence, suggestion, symbol, codeSnippet, or suggestedCode when that value does not safely apply. Only include files outside the ignore list. {{previousBlock}} -**Output:** Return a JSON object with "findings" (new/current problems from task 1) and "resolved_findings" (objects containing the exact prior finding id and either "fixed" or "obsolete"). Always return both arrays; use an empty array when there are no resolved findings. Never resolve an id that was not included in the previous-findings list.`; +**Output:** Return a JSON object with "outputLocale", "findings" (new/current problems from task 1), and "resolved_findings" (objects containing the exact prior finding id and either "fixed" or "obsolete"). Always return both arrays; use an empty array when there are no resolved findings. Never resolve an id that was not included in the previous-findings list.`; function getBugbotPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { ...params, @@ -74435,6 +74589,8 @@ exports.getCheckProgressPrompt = getCheckProgressPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are in the repository workspace. Assess the progress of issue #{{issueNumber}} using the full diff between the base (parent) branch and the current branch. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, percentages, and JSON keys verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Branches:** @@ -74450,7 +74606,7 @@ const TEMPLATE = `You are in the repository workspace. Assess the progress of is **Issue description:** {{issueDescription}} -Respond with a single JSON object: { "progress": , "summary": "", "remaining": "" | null }.`; +Respond with a single JSON object: { "outputLocale": "{{targetLocale}}", "progress": , "summary": "", "remaining": "" | null }.`; function getCheckProgressPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { projectContextInstruction: params.projectContextInstruction, @@ -74458,6 +74614,7 @@ function getCheckProgressPrompt(params) { baseBranch: params.baseBranch, currentBranch: params.currentBranch, issueDescription: params.issueDescription, + targetLocale: params.targetLocale, }); } @@ -74665,6 +74822,8 @@ exports.getRecommendStepsPrompt = getRecommendStepsPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `Based on the following issue description, recommend concrete steps to implement or address this issue. Order the steps logically (e.g. setup, implementation, tests, docs). Keep each step clear and actionable. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, and URLs verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Issue #{{issueNumber}} description:** @@ -74672,14 +74831,15 @@ const TEMPLATE = `Based on the following issue description, recommend concrete s {{previousRecommendation}} -Provide a complete numbered list of recommended steps in **markdown** (use headings, lists, code blocks for commands or snippets) so it is easy to read. You can add brief sub-bullets per step if needed. +Return one JSON object with \`outputLocale\`, \`status\`, and \`steps\`. When a material recommendation is needed, set \`status\` to \`recommendation\` and put a complete numbered list in Markdown in \`steps\` (headings, lists, and code blocks are allowed). You can add brief sub-bullets per step if needed. -If the current description does not require any material change to the previous recommendation, output exactly \`NO_NEW_RECOMMENDATIONS\` and nothing else. Do not use that sentinel when there is no previous recommendation.`; +If the current description does not require any material change to the previous recommendation, set \`status\` to \`unchanged\` and \`steps\` to null. Do not return \`unchanged\` when there is no previous recommendation.`; function getRecommendStepsPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { projectContextInstruction: params.projectContextInstruction, issueNumber: String(params.issueNumber), issueDescription: params.issueDescription, + targetLocale: params.targetLocale, previousRecommendation: params.previousRecommendation ? `Previous recommendation (use only to detect whether the current plan is still valid):\n\n${params.previousRecommendation}\n` : 'There is no previous recommendation for this issue.', @@ -74702,6 +74862,8 @@ exports.getThinkPrompt = getThinkPrompt; const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are a helpful assistant. Answer the following question concisely in {{targetLocale}}, using the context below when relevant. Format your answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response. Preserve code identifiers, paths, refs, commands, and URLs verbatim. +Return a JSON object with \`outputLocale\` set exactly to \`{{targetLocale}}\` and \`answer\` containing the Markdown response. Every human-readable sentence in \`answer\` must use the target locale. + {{projectContextInstruction}} {{contextBlock}}Question: {{question}}`; function getThinkPrompt(params) { @@ -74709,7 +74871,7 @@ function getThinkPrompt(params) { projectContextInstruction: params.projectContextInstruction, contextBlock: params.contextBlock, question: params.question, - targetLocale: params.targetLocale ?? 'en-US', + targetLocale: params.targetLocale, }); } @@ -74729,6 +74891,8 @@ exports.getUpdatePullRequestDescriptionPrompt = getUpdatePullRequestDescriptionP const fill_1 = __nccwpck_require__(2559); const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, issue/PR references, and conventional title prefixes verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Branches:** @@ -74749,12 +74913,12 @@ const TEMPLATE = `You are in the repository workspace. Your task is to produce a - **Breaking Changes:** list any, or "None". - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. 5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. -6. **Output format:** Return only the filled template content. Do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start directly with the first heading of the template (e.g. # Summary). Do not wrap the output in code blocks. +6. **Output format:** Return one JSON object with \`outputLocale\` and \`description\`. Put only the filled template content in \`description\`; do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start \`description\` directly with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks. **Issue description:** {{issueDescription}} -Output only the filled template content (the PR description body), starting with the first heading. No preamble, no commentary.`; +Return the structured JSON response only.`; function getUpdatePullRequestDescriptionPrompt(params) { return (0, fill_1.fillTemplate)(TEMPLATE, { projectContextInstruction: params.projectContextInstruction, @@ -74763,6 +74927,7 @@ function getUpdatePullRequestDescriptionPrompt(params) { issueNumber: String(params.issueNumber), issueDescription: params.issueDescription, relatedIssueInstruction: params.relatedIssueInstruction, + targetLocale: params.targetLocale, }); } diff --git a/docs/agents/execution-contract.mdx b/docs/agents/execution-contract.mdx index 9edf39faf..d85d8e22e 100644 --- a/docs/agents/execution-contract.mdx +++ b/docs/agents/execution-contract.mdx @@ -33,6 +33,15 @@ All roles deny agent-tool network, approval escalation, MCP, plugins, subagents, The hard maxima are 15 minutes, 512 KiB of prompt bytes, and 4 MiB of combined process output. Structured tasks declare a JSON Schema. Before Codex starts, Copilot requires a closed object schema in which every property is required, optional values are nullable, nested objects reject additional properties, and arrays define their items. Copilot then validates the returned response locally as well. An incompatible contract, invalid or oversized output, and prose-wrapped JSON are rejected; removed response shapes have no legacy conversion path. +Every product-facing read-only task also receives the canonical effective +`targetLocale` and must return the exact same tag in required `outputLocale` +metadata. This covers Think and help answers, recommendations, progress, +pull-request descriptions, and Bugbot findings. A missing, malformed, +non-canonical, or different output locale is rejected before a comment, review, +description, label, or recommendation state can change. There is no +best-effort mixed-language publication. Technical paths, refs, commands, links, +identifiers, and structured facts remain locale-independent. + Each invocation emits bounded lifecycle observations for plan start, admitted preflight, and terminal execution. They include only provider, capability, manifest/version, workspace and output modes, artifact hashes, duration, byte diff --git a/docs/agents/failure-policy.mdx b/docs/agents/failure-policy.mdx index 7e1414388..4bb2d0118 100644 --- a/docs/agents/failure-policy.mdx +++ b/docs/agents/failure-policy.mdx @@ -25,6 +25,7 @@ The Action fails closed. It MUST stop rather than silently selecting another run | CLI timeout | Execution | Stop; do not switch provider | | Non-zero CLI exit | Execution | Stop; preserve sanitized diagnostics only | | Invalid agent response | Parsing | Stop; do not publish unsafe findings | +| Missing or mismatched `outputLocale` | Parsing | Stop before product publication or state mutation; retain existing state | | Verification command fails | Autofix | Do not commit or push | | Sensitive workspace change | Commit policy | Reject automated commit | diff --git a/docs/bugbot/detection.mdx b/docs/bugbot/detection.mdx index 0b19d990e..9a332a68d 100644 --- a/docs/bugbot/detection.mdx +++ b/docs/bugbot/detection.mdx @@ -93,6 +93,12 @@ Each child comment is attached to an exact line/range confirmed from the current The action checks the PR head before invoking the reviewer, before mutation, after finding mutations, and before projecting current state. If a newer revision owns the PR, the old run is recorded as superseded and does not overwrite current-state surfaces. +Finding titles, descriptions, evidence, and suggestions use the effective PR +locale. Paths, line numbers, symbols, code, severities, IDs, commands, and links +remain structured and unchanged. The findings response must echo the exact +canonical locale in `outputLocale`; a missing or different tag rejects the +whole analysis before any finding or review is published. + When the configured agent later reports a finding as **resolved** (e.g. after you or the bot fixed the code), the action: - **Updates** every existing stored destination so the marker shows `resolved: true`. diff --git a/docs/development/agent-functionality-audit.mdx b/docs/development/agent-functionality-audit.mdx index 92b806f53..b1a7273d6 100644 --- a/docs/development/agent-functionality-audit.mdx +++ b/docs/development/agent-functionality-audit.mdx @@ -11,14 +11,14 @@ This page is the maintained acceptance contract for Copilot's agent surface. An | Capability | Trigger | Role | Agent output | Authorized side effect | | --- | --- | --- | --- | --- | -| Initial help and assistance | Opened question/help issue | `planner` | Schema-validated answer | Sanitized issue comment | -| Recommendations | Issue lifecycle, single action, or CLI | `planner` | Markdown recommendation | Managed issue comment/state | -| Progress analysis | Push, single action, or CLI | `findings` | Schema-validated progress | Issue/PR progress state | -| PR description | PR lifecycle or `/copilot description` | `planner` | Sanitized Markdown | Managed append block by default; explicit replace mode only | -| Think, explain, diagnose, analyze, and conversational commands | Authorized issue/PR comment or CLI | `planner`, `findings`, or `reviewer` by command | Schema-validated answer | Sanitized comment | -| Test plan | `/copilot test-plan` | `tester` | Schema-validated answer | Sanitized comment | -| Translation | Explicitly addressed issue or PR comment | `findings` | Schema-validated language decision and translation | Idempotent comment update | -| Bugbot detection | Push, PR, `/copilot review`, `/copilot recheck`, single action, or CLI | `findings` or `reviewer` | Bounded findings schema | Review/comments plus persisted finding lifecycle | +| Initial help and assistance | Opened question/help issue | `planner` | Locale-tagged schema-validated answer | One sanitized issue reply | +| Recommendations | Issue lifecycle, single action, or CLI | `planner` | Locale-tagged Markdown recommendation or typed unchanged state | One durable plan card/state | +| Progress analysis | Push, single action, or CLI | `findings` | Locale-tagged progress facts and prose | One durable progress card plus issue/PR labels | +| PR description | PR lifecycle or `/copilot description` | `planner` | Locale-tagged sanitized Markdown | Managed append block or configured replace mode | +| Think, explain, diagnose, analyze, and conversational commands | Authorized issue/PR comment or CLI | `planner`, `findings`, or `reviewer` by command | Locale-tagged schema-validated answer | One sanitized reply | +| Test plan | `/copilot test-plan` | `tester` | Locale-tagged schema-validated answer | One sanitized reply | +| Translation | Explicitly addressed issue or PR comment | `findings` | One schema-validated adaptation decision | Internal localized request; source comment remains unchanged | +| Bugbot detection | Push, PR, `/copilot review`, `/copilot recheck`, single action, or CLI | `findings` or `reviewer` | Locale-tagged bounded findings schema | Canonical status/review plus persisted finding lifecycle | | Bugbot autofix | Explicit `/copilot fix` or authorized intent | `fixer` | Workspace edits | Exact-path commit and token-scoped push after verification | | General implementation | Explicit `/copilot implement`, authorized intent, or CLI | `fixer` | Workspace edits | Exact-path commit and token-scoped push after verification | @@ -30,6 +30,12 @@ available. File mutation requires an authorized actor, a clean workspace, the expected branch, a bounded exact-path change set, sensitive-path rejection, isolated verification commands, and a second postflight check before commit. +All six product-facing query families receive the effective surface locale and +must echo it exactly as `outputLocale`. The common parser rejects absent, +malformed, non-canonical, or mismatched metadata before a product-facing side +effect. Fixer responses and intent classifiers are not public prose and remain +separate contracts; trusted application code owns their eventual status UI. + ## Runtime matrix | Runtime | Authentication | Analysis boundary | Fixer boundary | Structured output | diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index 95d086a5d..c5fee224a 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -25,6 +25,14 @@ cached language-agent request. A missing or invalid dynamic slice falls back atomically to English and records only locale, source, descriptor count, and a stable reason in the Job Summary. +Product-facing agent calls cross a second closed locale boundary. The fixed +inventory is Think, initial help, recommendations, progress, pull-request +descriptions, and Bugbot review. Each call uses the common structured-query +options builder, receives `targetLocale`, requires `outputLocale`, and validates +an exact canonical match before any product mutation. An architecture test +compares the call sites with that inventory, while strict-schema tests require +the locale field in every response contract. + Language adaptation is separate from catalog resolution. Comment admission, command parsing, and authorization establish trusted command identity before at most one prose adaptation. The language port has no comment-update capability: @@ -101,7 +109,7 @@ reconciliation use cases receive narrow context contracts containing only the facts they need; they do not depend on the complete `Execution` aggregate. This keeps pure decisions independently testable and makes new event sources less likely to couple unrelated capabilities. Application failures use the -closed `ApplicationError` contract: one of 18 semantic codes, a derived +closed `ApplicationError` contract: one of 20 semantic codes, a derived category and retry decision, impact, recommended action, retained-state summary, and request correlation UUID. A raw cause is ECMAScript-private, never serialized or logged, and cannot enter `Result.errors`. CLI output, diff --git a/docs/development/testing.mdx b/docs/development/testing.mdx index f761a06c0..faddddd98 100644 --- a/docs/development/testing.mdx +++ b/docs/development/testing.mdx @@ -85,7 +85,12 @@ all-branch observer workflow is also contract-checked to remain free of agent in Architecture tests additionally verify that production imports remain acyclic, application code does not depend on concrete adapters, and pure model/policy -code remains free of runtime, provider, and logging dependencies. Refresh the +code remains free of runtime, provider, and logging dependencies. The +product-facing agent inventory test also requires exactly one guarded query for +Think, help, recommendations, progress, PR descriptions, and Bugbot, and prompt +tests require both `targetLocale` and `outputLocale`. Workflow tests prove that +a mismatched response cannot publish or mutate labels, descriptions, +recommendation state, or findings. Refresh the local topology before an architecture review with Graphify, and use the RepoWise index-only health/dead-code reports to distinguish current complexity from history-based churn signals: diff --git a/docs/pull-requests/ai-description.mdx b/docs/pull-requests/ai-description.mdx index ce9cd9229..11697fb53 100644 --- a/docs/pull-requests/ai-description.mdx +++ b/docs/pull-requests/ai-description.mdx @@ -14,7 +14,8 @@ description: How the configured agent fills your pull request template from the - Computes the **diff** between base and head (e.g. `git diff base..head`) to understand what changed. - Uses the **issue description** (when the PR branch is linked to an issue) as context; otherwise it infers intent from the PR metadata and diff. 3. The agent **fills the template** with a structured description: summary, scope of changes, technical details, how to test, breaking changes, deployment notes, etc., following the same sections and format as your template. -4. The action writes the result to the PR body (prefixed with the issue number for reference). +4. The request carries the effective PR locale (`pull-requests-locale`, otherwise `repository-locale`, otherwise `en-US`). The response must echo that exact canonical tag in `outputLocale`; a mismatch stops before the body changes. +5. The action writes the validated result to the PR body according to the configured ownership mode. No pre-computed file list or patches are sent from the action; the agent has access to the workspace and computes the diff itself, similar to the [check progress](/single-actions) flow. diff --git a/docs/security-operations/security/prompt-injection.mdx b/docs/security-operations/security/prompt-injection.mdx index 7a5b7aaba..2472d9c69 100644 --- a/docs/security-operations/security/prompt-injection.mdx +++ b/docs/security-operations/security/prompt-injection.mdx @@ -40,7 +40,7 @@ only for a structured language decision or translation. The workflow: - preserves the original comment as escaped inert text; - neutralizes mentions, slash commands, and HTML-comment markers in the translated publication; -- writes a versioned marker so a translated comment is not translated again; +- writes a versioned marker only in the bot-owned response so the adaptation context cannot re-enter command routing; - never treats translated text as an action request. ### Findings and recommendations diff --git a/src/application/errors/__tests__/application_error.test.ts b/src/application/errors/__tests__/application_error.test.ts index 2e41c5c16..ab5a1f685 100644 --- a/src/application/errors/__tests__/application_error.test.ts +++ b/src/application/errors/__tests__/application_error.test.ts @@ -12,7 +12,7 @@ describe('ApplicationError', () => { it('exposes the closed semantic contract for every error code', () => { const codes = Object.keys(APPLICATION_ERROR_METADATA) as ApplicationErrorCode[]; - expect(codes).toHaveLength(19); + expect(codes).toHaveLength(20); for (const code of codes) { const error = new ApplicationError(code, 'Safe public message.', { correlationId: CORRELATION_ID }); expect(error).toMatchObject({ diff --git a/src/application/policies/__tests__/agent_output_locale_policy.test.ts b/src/application/policies/__tests__/agent_output_locale_policy.test.ts new file mode 100644 index 000000000..d79f23833 --- /dev/null +++ b/src/application/policies/__tests__/agent_output_locale_policy.test.ts @@ -0,0 +1,68 @@ +import { + PRODUCT_FACING_AGENT_TASKS, + agentOutputLocaleFailureMessage, + productFacingAgentQueryOptions, + validateAgentOutputLocale, +} from '../agent_output_locale_policy'; + +describe('agent output locale policy', () => { + const schema = { + type: 'object', + properties: { outputLocale: { type: 'string' }, prose: { type: 'string' } }, + required: ['outputLocale', 'prose'], + additionalProperties: false, + } as const; + + it('keeps the complete product-facing task inventory closed and builds strict query options', () => { + expect(PRODUCT_FACING_AGENT_TASKS).toEqual([ + 'think', + 'answer-issue-help', + 'progress', + 'recommend-steps', + 'pull-request-description', + 'bugbot-review', + ]); + expect(PRODUCT_FACING_AGENT_TASKS.map(task => productFacingAgentQueryOptions(task, schema).schemaName)) + .toEqual([ + 'think_response', + 'answer_issue_help_response', + 'progress_response', + 'recommend_steps_response', + 'pull_request_description_response', + 'bugbot_findings', + ]); + }); + + it('rejects a schema that does not require outputLocale before provider use', () => { + expect(() => productFacingAgentQueryOptions('think', { properties: {}, required: [] })) + .toThrow('must require outputLocale'); + }); + + it('accepts exact canonical metadata and returns an immutable copy', () => { + const input = { outputLocale: 'pt-BR', prose: 'Concluído' }; + const result = validateAgentOutputLocale(input, 'pt-BR'); + expect(result).toMatchObject({ kind: 'valid', expectedLocale: 'pt-BR', payload: input }); + if (result.kind === 'valid') { + expect(result.payload).not.toBe(input); + expect(Object.isFrozen(result.payload)).toBe(true); + } + }); + + it.each([ + [undefined, 'response-not-object'], + ['text', 'response-not-object'], + [{}, 'output-locale-missing'], + [{ outputLocale: '@@' }, 'output-locale-invalid'], + [{ outputLocale: 'en_us' }, 'output-locale-mismatch'], + [{ outputLocale: ' en-US ' }, 'output-locale-mismatch'], + [{ outputLocale: 'fr-FR' }, 'output-locale-mismatch'], + ])('rejects invalid locale-tagged output %#', (response, reason) => { + const result = validateAgentOutputLocale(response, 'en-US'); + expect(result).toMatchObject({ kind: 'invalid', reason, expectedLocale: 'en-US' }); + if (result.kind === 'invalid') { + expect(agentOutputLocaleFailureMessage(result)).toContain(String(reason)); + const serialized = JSON.stringify(response); + if (serialized) expect(agentOutputLocaleFailureMessage(result)).not.toContain(serialized); + } + }); +}); diff --git a/src/application/policies/__tests__/agent_response_schemas.test.ts b/src/application/policies/__tests__/agent_response_schemas.test.ts index 933341b0f..08bacbc52 100644 --- a/src/application/policies/__tests__/agent_response_schemas.test.ts +++ b/src/application/policies/__tests__/agent_response_schemas.test.ts @@ -1,10 +1,13 @@ import { LANGUAGE_CHECK_RESPONSE_SCHEMA, + PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA, + RECOMMEND_STEPS_RESPONSE_SCHEMA, THINK_RESPONSE_SCHEMA, TRANSLATION_RESPONSE_SCHEMA, } from '../agent_response_schemas'; import { assertStrictOutputSchema } from '../agent_execution/strict_output_schema_policy'; import { PROGRESS_RESPONSE_SCHEMA } from '../../usecases/actions/progress_response'; +import { BUGBOT_RESPONSE_SCHEMA } from '../../usecases/steps/commit/bugbot/schema'; describe('production agent response schemas', () => { it.each([ @@ -12,7 +15,21 @@ describe('production agent response schemas', () => { ['language check', LANGUAGE_CHECK_RESPONSE_SCHEMA], ['think', THINK_RESPONSE_SCHEMA], ['progress', PROGRESS_RESPONSE_SCHEMA], + ['recommend steps', RECOMMEND_STEPS_RESPONSE_SCHEMA], + ['pull-request description', PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA], + ['bugbot review', BUGBOT_RESPONSE_SCHEMA], ])('%s uses the strict native structured-output contract', (_name, schema) => { expect(() => assertStrictOutputSchema(schema)).not.toThrow(); }); + + it.each([ + THINK_RESPONSE_SCHEMA, + PROGRESS_RESPONSE_SCHEMA, + RECOMMEND_STEPS_RESPONSE_SCHEMA, + PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA, + BUGBOT_RESPONSE_SCHEMA, + ])('requires exact output-locale metadata for product-facing prose', (schema) => { + expect(schema.required).toContain('outputLocale'); + expect(schema.properties.outputLocale).toMatchObject({ type: 'string', maxLength: 255 }); + }); }); diff --git a/src/application/policies/agent_output_locale_policy.ts b/src/application/policies/agent_output_locale_policy.ts new file mode 100644 index 000000000..c68d565ba --- /dev/null +++ b/src/application/policies/agent_output_locale_policy.ts @@ -0,0 +1,113 @@ +import { canonicalizeLocaleTag } from '../../domain/locale'; + +export const PRODUCT_FACING_AGENT_TASKS = [ + 'think', + 'answer-issue-help', + 'progress', + 'recommend-steps', + 'pull-request-description', + 'bugbot-review', +] as const; + +export type ProductFacingAgentTask = typeof PRODUCT_FACING_AGENT_TASKS[number]; + +const PRODUCT_FACING_AGENT_SCHEMA_NAMES: Readonly> = Object.freeze({ + think: 'think_response', + 'answer-issue-help': 'answer_issue_help_response', + progress: 'progress_response', + 'recommend-steps': 'recommend_steps_response', + 'pull-request-description': 'pull_request_description_response', + 'bugbot-review': 'bugbot_findings', +}); + +export const AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY = { + type: 'string', + minLength: 1, + maxLength: 255, + description: 'The exact canonical BCP-47 locale requested in targetLocale.', +} as const; + +export type AgentOutputLocaleFailureReason = + | 'response-not-object' + | 'output-locale-missing' + | 'output-locale-invalid' + | 'output-locale-mismatch'; + +export type AgentOutputLocaleValidation = + | { + readonly kind: 'valid'; + readonly expectedLocale: string; + readonly payload: Readonly>; + } + | { + readonly kind: 'invalid'; + readonly expectedLocale: string; + readonly reason: AgentOutputLocaleFailureReason; + readonly actualLocale?: string; + }; + +interface ProductFacingResponseSchema { + readonly properties?: Readonly>; + readonly required?: readonly string[]; +} + +/** + * Builds the only supported structured-output options for product-facing agent + * calls. Runtime assertions make an accidentally weakened schema fail before + * an agent provider is invoked. + */ +export function productFacingAgentQueryOptions( + task: ProductFacingAgentTask, + schema: ProductFacingResponseSchema, +): { + readonly expectJson: true; + readonly schema: Record; + readonly schemaName: string; +} { + if (!schema.properties?.outputLocale || !schema.required?.includes('outputLocale')) { + throw new TypeError(`Product-facing agent schema for ${task} must require outputLocale.`); + } + return Object.freeze({ + expectJson: true, + schema: schema as unknown as Record, + schemaName: PRODUCT_FACING_AGENT_SCHEMA_NAMES[task], + }); +} + +/** Validates locale metadata before any model prose can reach product state. */ +export function validateAgentOutputLocale( + response: unknown, + targetLocale: string, +): AgentOutputLocaleValidation { + const expectedLocale = canonicalizeLocaleTag(targetLocale); + if (response == null || typeof response !== 'object' || Array.isArray(response)) { + return Object.freeze({ kind: 'invalid', expectedLocale, reason: 'response-not-object' }); + } + const payload = response as Record; + if (typeof payload.outputLocale !== 'string' || !payload.outputLocale.trim()) { + return Object.freeze({ kind: 'invalid', expectedLocale, reason: 'output-locale-missing' }); + } + let actualLocale: string; + try { + actualLocale = canonicalizeLocaleTag(payload.outputLocale); + } catch { + return Object.freeze({ + kind: 'invalid', + expectedLocale, + reason: 'output-locale-invalid', + }); + } + if (actualLocale !== expectedLocale || payload.outputLocale !== expectedLocale) { + return Object.freeze({ + kind: 'invalid', + expectedLocale, + actualLocale, + reason: 'output-locale-mismatch', + }); + } + return Object.freeze({ kind: 'valid', expectedLocale, payload: Object.freeze({ ...payload }) }); +} + +export function agentOutputLocaleFailureMessage(validation: Extract): string { + return `Configured agent output was rejected before publication (${validation.reason}; expected ${validation.expectedLocale}).`; +} diff --git a/src/application/policies/agent_response_schemas.ts b/src/application/policies/agent_response_schemas.ts index 11f3dee39..5fab4e7cb 100644 --- a/src/application/policies/agent_response_schemas.ts +++ b/src/application/policies/agent_response_schemas.ts @@ -1,5 +1,7 @@ /** Shared structured-response contracts used by agent-backed application flows. */ +import { AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY } from './agent_output_locale_policy'; + export const LANGUAGE_ADAPTATION_RESPONSE_SCHEMA = { type: 'object', properties: { @@ -40,6 +42,7 @@ export const TRANSLATION_RESPONSE_SCHEMA = LANGUAGE_ADAPTATION_RESPONSE_SCHEMA; export const THINK_RESPONSE_SCHEMA = { type: 'object', properties: { + outputLocale: AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, answer: { type: 'string', minLength: 1, @@ -47,7 +50,41 @@ export const THINK_RESPONSE_SCHEMA = { description: 'The concise answer to the user question. Required.', }, }, - required: ['answer'], + required: ['outputLocale', 'answer'], + additionalProperties: false, +} as const; + +export const RECOMMEND_STEPS_RESPONSE_SCHEMA = { + type: 'object', + properties: { + outputLocale: AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, + status: { + type: 'string', + enum: ['recommendation', 'unchanged'], + description: 'Whether a recommendation is present or the previous recommendation remains valid.', + }, + steps: { + type: ['string', 'null'], + maxLength: 12_000, + description: 'Markdown implementation steps for recommendation; null when status is unchanged.', + }, + }, + required: ['outputLocale', 'status', 'steps'], + additionalProperties: false, +} as const; + +export const PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA = { + type: 'object', + properties: { + outputLocale: AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, + description: { + type: 'string', + minLength: 1, + maxLength: 60_000, + description: 'The complete Markdown pull-request description body.', + }, + }, + required: ['outputLocale', 'description'], additionalProperties: false, } as const; diff --git a/src/application/usecases/__tests__/push_single_action_contexts.test.ts b/src/application/usecases/__tests__/push_single_action_contexts.test.ts index 6d8891cd4..a5b57395e 100644 --- a/src/application/usecases/__tests__/push_single_action_contexts.test.ts +++ b/src/application/usecases/__tests__/push_single_action_contexts.test.ts @@ -193,6 +193,7 @@ describe('push and single-action context projection', () => { eventName: 'push', eventAction: '', agentConfiguration: { provider: 'codex', model: 'planner-model' }, + targetLocale: 'en-US', }); }); diff --git a/src/application/usecases/actions/__tests__/check_progress_use_case.test.ts b/src/application/usecases/actions/__tests__/check_progress_use_case.test.ts index 7092d8ed7..4d145964a 100644 --- a/src/application/usecases/actions/__tests__/check_progress_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/check_progress_use_case.test.ts @@ -8,6 +8,7 @@ import { CheckProgressUseCase } from '../check_progress_use_case'; import { Ai } from '../../../../data/model/ai'; import type { Execution } from '../../../../data/model/execution'; import { projectProgressContext } from '../../push_single_action_contexts'; +import type { AgentQueryResult } from '../../../ports/agent_query_ports'; jest.mock('../../../../utils/logger', () => ({ logInfo: jest.fn(), @@ -27,6 +28,12 @@ const mockGetOpenPullRequestNumbersByHeadBranch = jest.fn(); const mockAskAgent = jest.fn(); +async function localizedProgress(request: { configuration: unknown; agentId: string; prompt: string; options?: unknown }): Promise { + const response = await mockAskAgent(request.configuration, request.agentId, request.prompt, request.options); + return response && typeof response === 'object' && !Array.isArray(response) + ? { outputLocale: 'en-US', ...response as Record } + : response; +} function baseParam(overrides: Record = {}): Execution { const branches = { main: 'main', @@ -61,7 +68,7 @@ describe('CheckProgressUseCase', () => { { setProgressLabel: mockSetProgressLabel }, { getListOfBranches: mockGetListOfBranches }, { getOpenPullRequestNumbersByHeadBranch: mockGetOpenPullRequestNumbersByHeadBranch }, - { query: (request: { configuration: unknown; agentId: string; prompt: string; options?: unknown }) => mockAskAgent(request.configuration, request.agentId, request.prompt, request.options) }, + { query: localizedProgress }, ); invoke = (param) => useCase.invoke(projectProgressContext(param)); mockGetDescription.mockReset(); @@ -171,8 +178,20 @@ describe('CheckProgressUseCase', () => { expect(results).toHaveLength(1); expect(results[0].success).toBe(false); - expect(results[0].errors?.some((e) => String(e).includes('Progress detection returned 0%'))).toBe(true); + expect(results[0].errors[0]).toMatchObject({ code: 'locale.output-invalid' }); expect(mockAskAgent).toHaveBeenCalledTimes(1); + expect(mockSetProgressLabel).not.toHaveBeenCalled(); + }); + + it('rejects a mismatched output locale before changing labels', async () => { + mockGetDescription.mockResolvedValue('Issue body'); + mockAskAgent.mockResolvedValue({ outputLocale: 'fr-FR', progress: 80, summary: 'Terminé', remaining: null }); + + const results = await invoke(baseParam()); + + expect(results[0].errors[0]).toMatchObject({ code: 'locale.output-invalid' }); + expect(mockSetProgressLabel).not.toHaveBeenCalled(); + expect(mockSetLabels).not.toHaveBeenCalled(); }); it('returns error when progress is 0% (single call; HTTP retries are in the findings adapter)', async () => { diff --git a/src/application/usecases/actions/__tests__/progress_response.test.ts b/src/application/usecases/actions/__tests__/progress_response.test.ts index 735e35e42..0982df4cf 100644 --- a/src/application/usecases/actions/__tests__/progress_response.test.ts +++ b/src/application/usecases/actions/__tests__/progress_response.test.ts @@ -3,19 +3,17 @@ import { parseProgressResponse } from '../progress_response'; describe('parseProgressResponse', () => { it('clamps and rounds progress while trimming optional text', () => { expect(parseProgressResponse({ + outputLocale: 'en-US', progress: 101.6, summary: ' done ', reasoning: ' why ', remaining: ' next ', - })).toEqual({ progress: 100, summary: ' done ', reasoning: 'why', remaining: 'next' }); + }, 'en-US')).toEqual({ progress: 100, summary: ' done ', reasoning: 'why', remaining: 'next' }); }); - it('uses safe defaults for malformed responses', () => { - expect(parseProgressResponse(null)).toEqual({ - progress: 0, - summary: 'Unable to determine progress.', - reasoning: '', - remaining: '', - }); + it('rejects malformed or mismatched locale metadata', () => { + expect(() => parseProgressResponse(null, 'en-US')).toThrow('response-not-object'); + expect(() => parseProgressResponse({ outputLocale: 'es-ES', progress: 1 }, 'en-US')) + .toThrow('output-locale-mismatch'); }); }); diff --git a/src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts b/src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts index 1d4693696..e3a5e70c0 100644 --- a/src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts @@ -4,6 +4,7 @@ import { Config } from '../../../../data/model/config'; import { getResultPayload } from '../../../../data/model/result'; import type { Execution } from '../../../../data/model/execution'; import { projectRecommendStepsContext, type RecommendStepsOutcome } from '../../push_single_action_contexts'; +import type { AgentQueryResult } from '../../../ports/agent_query_ports'; jest.mock('../../../../utils/logger', () => ({ logInfo: jest.fn(), @@ -18,6 +19,22 @@ jest.mock('../../../../utils/task_emoji', () => ({ const mockGetDescription = jest.fn(); const mockAskAgent = jest.fn(); +async function localizedRecommendation(request: { configuration: unknown; agentId: string; prompt: string; options?: unknown }): Promise { + const response = await mockAskAgent(request.configuration, request.agentId, request.prompt, request.options); + if (typeof response === 'string') { + return response === 'NO_NEW_RECOMMENDATIONS' + ? { outputLocale: 'en-US', status: 'unchanged', steps: null } + : { outputLocale: 'en-US', status: 'recommendation', steps: response }; + } + if (!response || typeof response !== 'object' || Array.isArray(response)) return response; + const payload = response as Record; + return { + outputLocale: 'en-US', + status: typeof payload.steps === 'string' ? 'recommendation' : payload.status, + ...payload, + }; +} + function baseParam(overrides: Record = {}): Execution { return { owner: 'owner', @@ -35,7 +52,7 @@ describe('RecommendStepsUseCase', () => { let invoke: (param: Execution) => Promise; beforeEach(() => { - useCase = new RecommendStepsUseCase({ getDescription: mockGetDescription }, { query: (request: { configuration: unknown; agentId: string; prompt: string; options?: unknown }) => mockAskAgent(request.configuration, request.agentId, request.prompt, request.options) }); + useCase = new RecommendStepsUseCase({ getDescription: mockGetDescription }, { query: localizedRecommendation }); invoke = async (param) => { lastOutcome = await useCase.invoke(projectRecommendStepsContext(param)); return lastOutcome.results; @@ -76,13 +93,16 @@ describe('RecommendStepsUseCase', () => { const results = await invoke(param); expect(results).toHaveLength(1); expect(results[0].success).toBe(true); - expect(results[0].steps).toBeDefined(); - expect(results[0].stepFormat).toBe('markdown'); - expect(results[0].steps[0]).toBe('## Recommended implementation steps'); + expect(results[0].steps).toEqual([]); expect(getResultPayload(results[0].payload)?.recommendedSteps).toContain('1. Add auth module'); const prompt = mockAskAgent.mock.calls[0][2]; expect(prompt).toContain('42'); expect(prompt).toContain('Implement login feature.'); + expect(prompt).toContain('outputLocale` exactly as `en-US'); + expect(mockAskAgent.mock.calls[0][3]).toMatchObject({ + expectJson: true, + schemaName: 'recommend_steps_response', + }); }); it('returns success when AI returns object with steps', async () => { @@ -94,7 +114,7 @@ describe('RecommendStepsUseCase', () => { expect(getResultPayload(results[0].payload)?.recommendedSteps).toContain('1. Reproduce'); }); - it('includes the bot welcome in the first recommendation for a newly opened issue', async () => { + it('keeps onboarding inside the single semantic plan card instead of result steps', async () => { mockGetDescription.mockResolvedValue('Implement login feature.'); mockAskAgent.mockResolvedValue('1. Add auth module'); const param = baseParam({ @@ -106,9 +126,8 @@ describe('RecommendStepsUseCase', () => { const results = await invoke(param); - expect(results[0].steps[0]).toContain(''); - expect(results[0].steps[0]).toContain('Hi! I’m **@vypbot**'); - expect(results[0].steps).toContain('## Recommended implementation steps'); + expect(results[0].steps).toEqual([]); + expect(getResultPayload(results[0].payload)?.recommendedSteps).toBe('1. Add auth module'); }); it('removes Copilot metadata from the prompt and fingerprint input', async () => { @@ -241,4 +260,14 @@ describe('RecommendStepsUseCase', () => { expect(results[0].success).toBe(false); expect(results[0].errors[0].message).toBe('The configured agent returned no recommendation.'); }); + + it('rejects a mismatched output locale without creating recommendation state', async () => { + mockGetDescription.mockResolvedValue('Do something'); + mockAskAgent.mockResolvedValue({ outputLocale: 'fr-FR', status: 'recommendation', steps: '1. Faire' }); + + const results = await invoke(baseParam()); + + expect(results[0].errors[0]).toMatchObject({ code: 'locale.output-invalid' }); + expect(lastOutcome?.configurationPatch).toBeUndefined(); + }); }); diff --git a/src/application/usecases/actions/progress_analysis_workflow.ts b/src/application/usecases/actions/progress_analysis_workflow.ts index 4629844da..207726254 100644 --- a/src/application/usecases/actions/progress_analysis_workflow.ts +++ b/src/application/usecases/actions/progress_analysis_workflow.ts @@ -16,6 +16,7 @@ import { type ProgressAttemptResult, } from './progress_response'; import { ApplicationError, type ApplicationErrorCode } from '../../errors/application_error'; +import { productFacingAgentQueryOptions } from '../../policies/agent_output_locale_policy'; export interface ProgressAnalysisDependencies { issueDescriptionQueryPort: BoundIssueDescriptionQueryPort; @@ -97,6 +98,7 @@ export async function analyzeProgress( issueDescription, baseBranch: developmentBranch, currentBranch: resolvedBranch, + targetLocale: param.targetLocale, }); logDebugInfo( `CheckProgress: prompt length=${prompt.length}, issue description length=${issueDescription.length}.`, @@ -108,12 +110,11 @@ export async function analyzeProgress( agentId: AGENT_PLAN, prompt, options: { - expectJson: true, - schema: PROGRESS_RESPONSE_SCHEMA as unknown as Record, - schemaName: 'progress_response', + ...productFacingAgentQueryOptions('progress', PROGRESS_RESPONSE_SCHEMA), includeReasoning: param.includeReasoning, }, }), + param.targetLocale, ); return { diff --git a/src/application/usecases/actions/progress_response.ts b/src/application/usecases/actions/progress_response.ts index 46e39c648..8d5b7614c 100644 --- a/src/application/usecases/actions/progress_response.ts +++ b/src/application/usecases/actions/progress_response.ts @@ -1,11 +1,15 @@ +import { AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, agentOutputLocaleFailureMessage, validateAgentOutputLocale } from '../../policies/agent_output_locale_policy'; +import { ApplicationError } from '../../errors/application_error'; + export const PROGRESS_RESPONSE_SCHEMA = { type: 'object', properties: { + outputLocale: AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, progress: { type: 'number', minimum: 0, maximum: 100, description: 'Completion percentage 0-100' }, summary: { type: 'string', minLength: 1, maxLength: 8_000, description: 'Short explanation of the assessment' }, remaining: { type: ['string', 'null'], maxLength: 8_000, description: 'When progress < 100: what is left to do to reach 100%; otherwise null.' }, }, - required: ['progress', 'summary', 'remaining'], + required: ['outputLocale', 'progress', 'summary', 'remaining'], additionalProperties: false, } as const; @@ -16,8 +20,12 @@ export interface ProgressAttemptResult { remaining: string; } -export function parseProgressResponse(response: unknown): ProgressAttemptResult { - const payload = response && typeof response === 'object' ? response as Record : {}; +export function parseProgressResponse(response: unknown, targetLocale: string): ProgressAttemptResult { + const validation = validateAgentOutputLocale(response, targetLocale); + if (validation.kind === 'invalid') { + throw new ApplicationError('locale.output-invalid', agentOutputLocaleFailureMessage(validation)); + } + const payload = validation.payload; const rawProgress = typeof payload.progress === 'number' ? payload.progress : 0; return { progress: Math.min(100, Math.max(0, Math.round(rawProgress))), diff --git a/src/application/usecases/actions/recommend_steps_result_policy.ts b/src/application/usecases/actions/recommend_steps_result_policy.ts index abbf3057d..7bee6547b 100644 --- a/src/application/usecases/actions/recommend_steps_result_policy.ts +++ b/src/application/usecases/actions/recommend_steps_result_policy.ts @@ -1,10 +1,13 @@ import { Result } from '../../../data/model/result'; import type { RecommendationState } from '../../../data/model/recommendation_state'; -import { createRecommendationFingerprint, isNoNewRecommendation, limitStoredRecommendation } from '../../../application/policies/recommendation_policy'; +import { createRecommendationFingerprint, isNoNewRecommendation, limitStoredRecommendation, NO_NEW_RECOMMENDATIONS } from '../../../application/policies/recommendation_policy'; import { logDebugInfo, logError, logInfo } from '../../ports/logging_ports'; -import { buildCopilotWelcomeMessage } from '../../../application/policies/copilot_interaction_policy'; import { ApplicationError } from '../../errors/application_error'; import type { RecommendStepsContext, RecommendStepsOutcome } from '../push_single_action_contexts'; +import { + agentOutputLocaleFailureMessage, + validateAgentOutputLocale, +} from '../../policies/agent_output_locale_policy'; export function buildRecommendationResult( param: RecommendStepsContext, @@ -14,7 +17,7 @@ export function buildRecommendationResult( previousRecommendation: RecommendationState | undefined, issueNumber: number, ): RecommendStepsOutcome { - const steps = extractRecommendationText(response); + const steps = extractRecommendationText(response, param.targetLocale); if (!steps) { const semanticError = new ApplicationError('agent.failed', 'The configured agent returned no recommendation.'); logError(semanticError); @@ -29,23 +32,14 @@ export function buildRecommendationResult( recommendationFingerprint, recommendation: limitStoredRecommendation(steps), }; - const stepsWithWelcome = isNewIssue(param) - ? [buildCopilotWelcomeMessage(param.tokenUser), '## Recommended implementation steps', steps] - : ['## Recommended implementation steps', steps]; return recommendationOutcome([new Result({ id: taskId, success: true, executed: true, - stepFormat: 'markdown', - steps: stepsWithWelcome, payload: { issueNumber, recommendedSteps: steps, recommendationState }, })]); } -function isNewIssue(param: RecommendStepsContext): boolean { - return param.eventName === 'issues' && param.eventAction === 'opened'; -} - function skipUnchangedRecommendation(_param: RecommendStepsContext, previous: RecommendationState, fingerprint: string, reason: string): RecommendStepsOutcome { logInfo(`RecommendSteps: ${reason}; skipping recommendation comment.`); return recommendationOutcome([], { ...previous, issueDescriptionFingerprint: fingerprint }); @@ -60,8 +54,12 @@ function recommendationOutcome(results: readonly Result[], recommendationState?: }); } -function extractRecommendationText(response: string | Record | undefined): string { - if (typeof response === 'string') return response.trim(); - if (!response || typeof response.steps !== 'string') return ''; - return response.steps.trim(); +function extractRecommendationText(response: string | Record | undefined, targetLocale: string): string { + if (response == null) return ''; + const validation = validateAgentOutputLocale(response, targetLocale); + if (validation.kind === 'invalid') { + throw new ApplicationError('locale.output-invalid', agentOutputLocaleFailureMessage(validation)); + } + if (validation.payload.status === 'unchanged') return NO_NEW_RECOMMENDATIONS; + return typeof validation.payload.steps === 'string' ? validation.payload.steps.trim() : ''; } diff --git a/src/application/usecases/actions/recommend_steps_workflow.ts b/src/application/usecases/actions/recommend_steps_workflow.ts index 62c3b0391..33988ce8b 100644 --- a/src/application/usecases/actions/recommend_steps_workflow.ts +++ b/src/application/usecases/actions/recommend_steps_workflow.ts @@ -14,6 +14,8 @@ import { PROJECT_CONTEXT_INSTRUCTION } from '../../../utils/project_context_inst import { getTaskEmoji } from '../../../utils/task_emoji'; import { buildRecommendationResult } from './recommend_steps_result_policy'; import { ApplicationError, toApplicationError } from '../../errors/application_error'; +import { RECOMMEND_STEPS_RESPONSE_SCHEMA } from '../../policies/agent_response_schemas'; +import { productFacingAgentQueryOptions } from '../../policies/agent_output_locale_policy'; export interface RecommendStepsWorkflowDependencies { issueDescriptionQueryPort: BoundIssueDescriptionQueryPort; @@ -62,6 +64,7 @@ export async function runRecommendStepsWorkflow( issueNumber: String(issueNumber), issueDescription, previousRecommendation: previousRecommendation?.recommendation, + targetLocale: param.targetLocale, }); logDebugInfo( `RecommendSteps: prompt length=${prompt.length}, issue description length=${issueDescription.length}.`, @@ -72,6 +75,7 @@ export async function runRecommendStepsWorkflow( configuration, agentId: AGENT_PLAN, prompt, + options: productFacingAgentQueryOptions('recommend-steps', RECOMMEND_STEPS_RESPONSE_SCHEMA), }); return buildRecommendationResult(param, taskId, response, issueDescriptionFingerprint, previousRecommendation, issueNumber); } catch (error) { diff --git a/src/application/usecases/pull_request_workflow_context.ts b/src/application/usecases/pull_request_workflow_context.ts index 11cf10b9f..a6819b712 100644 --- a/src/application/usecases/pull_request_workflow_context.ts +++ b/src/application/usecases/pull_request_workflow_context.ts @@ -35,6 +35,7 @@ export interface PullRequestDescriptionContext { readonly mode: PullRequestDescriptionMode; readonly membersOnly: boolean; readonly agentConfiguration: Readonly; + readonly targetLocale: string; } export interface PullRequestDescriptionRequest { @@ -96,6 +97,7 @@ export interface PullRequestWorkflowContextSource { getAiMembersOnly(): boolean; getAgentConfiguration(task: 'planner'): AgentConfiguration; }; + readonly locale?: { readonly pullRequest?: string }; } export function projectPullRequestWorkflowStepContexts( @@ -155,5 +157,6 @@ export function projectPullRequestDescriptionContext( mode: source.ai.getPullRequestDescriptionMode(), membersOnly: source.ai.getAiMembersOnly(), agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('planner') }), + targetLocale: source.locale?.pullRequest ?? 'en-US', }); } diff --git a/src/application/usecases/push_single_action_contexts.ts b/src/application/usecases/push_single_action_contexts.ts index d3015742a..2eda8190c 100644 --- a/src/application/usecases/push_single_action_contexts.ts +++ b/src/application/usecases/push_single_action_contexts.ts @@ -30,6 +30,7 @@ export interface ProgressContext { readonly branchTypes: readonly string[]; readonly agentConfiguration: Readonly; readonly includeReasoning: boolean; + readonly targetLocale: string; } export interface RecommendStepsContext { @@ -39,6 +40,7 @@ export interface RecommendStepsContext { readonly tokenUser?: string; readonly previousRecommendation?: Readonly; readonly agentConfiguration: Readonly; + readonly targetLocale: string; } export interface RecommendationStatePatch { @@ -272,6 +274,7 @@ export function projectProgressContext(source: PushSingleActionContextSource): P ]), agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('findings') }), includeReasoning: source.ai.getAiIncludeReasoning(), + targetLocale: source.locale?.issue ?? 'en-US', }); } @@ -284,6 +287,7 @@ export function projectRecommendStepsContext(source: PushSingleActionContextSour ...(source.tokenUser ? { tokenUser: source.tokenUser } : {}), ...(previous ? { previousRecommendation: Object.freeze({ ...previous }) } : {}), agentConfiguration: Object.freeze({ ...source.ai.getAgentConfiguration('planner') }), + targetLocale: source.locale?.issue ?? 'en-US', }); } diff --git a/src/application/usecases/steps/commit/__tests__/bugbot_review_lifecycle.e2e.test.ts b/src/application/usecases/steps/commit/__tests__/bugbot_review_lifecycle.e2e.test.ts index 6e17fa159..a6b6e0d41 100644 --- a/src/application/usecases/steps/commit/__tests__/bugbot_review_lifecycle.e2e.test.ts +++ b/src/application/usecases/steps/commit/__tests__/bugbot_review_lifecycle.e2e.test.ts @@ -188,7 +188,10 @@ function finding(id = 'unchecked-token', file = 'src/auth.ts') { describe('Bugbot review lifecycle E2E contract', () => { it('publishes one native review and durably suppresses a manually dismissed moved finding', async () => { const provider = new InMemoryReviewProvider(); - const responses = [{ findings: [finding()] }, { findings: [finding('renamed-id', 'src/security/auth.ts')] }]; + const responses = [ + { outputLocale: 'en-US', findings: [finding()], resolved_findings: [] }, + { outputLocale: 'en-US', findings: [finding('renamed-id', 'src/security/auth.ts')], resolved_findings: [] }, + ]; const telemetry: unknown[] = []; const useCase = new DetectPotentialProblemsUseCase( { query: jest.fn(async () => responses.shift()) }, @@ -214,7 +217,7 @@ describe('Bugbot review lifecycle E2E contract', () => { it('executes analysis in dry-run mode without any provider mutation', async () => { const provider = new InMemoryReviewProvider(); const useCase = new DetectPotentialProblemsUseCase( - { query: jest.fn(async () => ({ findings: [finding()] })) }, + { query: jest.fn(async () => ({ outputLocale: 'en-US', findings: [finding()], resolved_findings: [] })) }, scmPorts(provider), ); diff --git a/src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts b/src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts index 2cee8dee0..cb2912458 100644 --- a/src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts +++ b/src/application/usecases/steps/commit/__tests__/detect_potential_problems_use_case.test.ts @@ -239,12 +239,14 @@ describe("DetectPotentialProblemsUseCase", () => { prompt: string; options?: unknown; }) => - mockAskAgent( + Promise.resolve(mockAskAgent( request.configuration, request.agentId, request.prompt, request.options, - ), + )).then((response) => response && typeof response === 'object' && !Array.isArray(response) + ? { outputLocale: 'en-US', ...response } + : response), }, { context, @@ -462,6 +464,32 @@ describe("DetectPotentialProblemsUseCase", () => { expect(mockAddComment).not.toHaveBeenCalled(); }); + it("rejects a mismatched output locale before publishing or resolving findings", async () => { + mockAskAgent.mockResolvedValue({ + outputLocale: "fr-FR", + findings: [{ + id: "src/foo.ts:10:possible-null", + title: "Déréférencement nul possible", + description: "La variable peut être nulle.", + }], + resolved_findings: ["existing-finding"], + }); + + const results = await invokeUseCase(useCase, baseParam()); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual(expect.objectContaining({ success: false, executed: true })); + expect(results[0].errors[0]).toEqual(expect.objectContaining({ + code: "locale.output-invalid", + })); + expect(mockAddComment).not.toHaveBeenCalled(); + expect(mockUpdateComment).not.toHaveBeenCalled(); + expect(mockCreateReviewWithComments).not.toHaveBeenCalled(); + expect(mockUpdatePullRequestReviewComment).not.toHaveBeenCalled(); + expect(mockResolvePullRequestReviewThread).not.toHaveBeenCalled(); + expect(mockUnresolvePullRequestReviewThread).not.toHaveBeenCalled(); + }); + it('returns success with "no new findings, no resolved" when findings and resolved_findings are empty', async () => { mockAskAgent.mockResolvedValue({ findings: [], resolved_findings: [] }); @@ -1011,6 +1039,7 @@ describe("DetectPotentialProblemsUseCase", () => { }, ); mockAskAgent.mockResolvedValue({ + outputLocale: 'en-US', findings: [ { id: "f1", diff --git a/src/application/usecases/steps/commit/bugbot/analyze_bugbot_revision_use_case.ts b/src/application/usecases/steps/commit/bugbot/analyze_bugbot_revision_use_case.ts index 0ea48d43a..ffb065b85 100644 --- a/src/application/usecases/steps/commit/bugbot/analyze_bugbot_revision_use_case.ts +++ b/src/application/usecases/steps/commit/bugbot/analyze_bugbot_revision_use_case.ts @@ -33,6 +33,7 @@ export async function analyzeBugbotRevision( dependencies.agent, execution.analysis.agentConfiguration, prompt, + execution.locale.pullRequest, ), ); dependencies.telemetry.observeResponse(agentResponse); diff --git a/src/application/usecases/steps/commit/bugbot/build_bugbot_prompt.ts b/src/application/usecases/steps/commit/bugbot/build_bugbot_prompt.ts index 035d4d0d0..bb4cecab9 100644 --- a/src/application/usecases/steps/commit/bugbot/build_bugbot_prompt.ts +++ b/src/application/usecases/steps/commit/bugbot/build_bugbot_prompt.ts @@ -62,6 +62,7 @@ export function buildBugbotPrompt(param: BugbotReviewOperationContext, context: reviewConversationBlock: context.reviewConversationBlock, rulesBlock: context.reviewRulesBlock, effortBlock: `**Review effort:** ${resolvedEffort}. ${resolvedEffort === 'high' ? 'Perform deeper cross-file and adversarial analysis.' : resolvedEffort === 'low' ? 'Prioritize high-signal changed-code defects and avoid speculative breadth.' : 'Balance depth, latency, and false-positive control.'}`, + targetLocale: param.locale.pullRequest, }); } diff --git a/src/application/usecases/steps/commit/bugbot/query_bugbot_findings.ts b/src/application/usecases/steps/commit/bugbot/query_bugbot_findings.ts index b94222b71..dd3c6c956 100644 --- a/src/application/usecases/steps/commit/bugbot/query_bugbot_findings.ts +++ b/src/application/usecases/steps/commit/bugbot/query_bugbot_findings.ts @@ -2,20 +2,29 @@ import type { AgentConfiguration } from '../../../../../domain/agent'; import type { FindingsQueryPort } from '../../../../ports/agent_findings_ports'; import { AGENT_PLAN } from '../../../../../application/policies/agent_task_policy'; import { BUGBOT_RESPONSE_SCHEMA } from './schema'; +import { + agentOutputLocaleFailureMessage, + productFacingAgentQueryOptions, + validateAgentOutputLocale, +} from '../../../../policies/agent_output_locale_policy'; +import { ApplicationError } from '../../../../errors/application_error'; export async function queryBugbotFindings( repository: FindingsQueryPort, configuration: Readonly, prompt: string, + targetLocale: string, ): Promise { - return repository.query({ + const response = await repository.query({ configuration, agentId: AGENT_PLAN, prompt, - options: { - expectJson: true, - schema: BUGBOT_RESPONSE_SCHEMA as unknown as Record, - schemaName: 'bugbot_findings', - }, + options: productFacingAgentQueryOptions('bugbot-review', BUGBOT_RESPONSE_SCHEMA), }); + if (response == null || typeof response !== 'object' || Array.isArray(response)) return response; + const validation = validateAgentOutputLocale(response, targetLocale); + if (validation.kind === 'invalid') { + throw new ApplicationError('locale.output-invalid', agentOutputLocaleFailureMessage(validation)); + } + return validation.payload; } diff --git a/src/application/usecases/steps/commit/bugbot/schema.ts b/src/application/usecases/steps/commit/bugbot/schema.ts index 419d63d5d..1ec1acf79 100644 --- a/src/application/usecases/steps/commit/bugbot/schema.ts +++ b/src/application/usecases/steps/commit/bugbot/schema.ts @@ -4,11 +4,13 @@ */ import { MAX_FINDING_ID_LENGTH } from '../../../../policies/bugbot_finding_marker_policy'; +import { AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY } from '../../../../policies/agent_output_locale_policy'; /** Detection returns findings and explicit lifecycle changes for prior finding IDs. */ export const BUGBOT_RESPONSE_SCHEMA = { type: 'object', properties: { + outputLocale: AGENT_OUTPUT_LOCALE_SCHEMA_PROPERTY, findings: { type: 'array', maxItems: 200, @@ -67,7 +69,7 @@ export const BUGBOT_RESPONSE_SCHEMA = { description: 'Retained previous findings that are now fixed or obsolete; use an empty array when none are resolved.', }, }, - required: ['findings', 'resolved_findings'], + required: ['outputLocale', 'findings', 'resolved_findings'], additionalProperties: false, } as const; diff --git a/src/application/usecases/steps/common/__tests__/agent_answer_policy.test.ts b/src/application/usecases/steps/common/__tests__/agent_answer_policy.test.ts index f206e34ea..adc9fcdf3 100644 --- a/src/application/usecases/steps/common/__tests__/agent_answer_policy.test.ts +++ b/src/application/usecases/steps/common/__tests__/agent_answer_policy.test.ts @@ -2,11 +2,13 @@ import { extractStructuredAnswer } from '../agent_answer_policy'; describe('agent answer policy', () => { it('returns a trimmed answer from a structured response', () => { - expect(extractStructuredAnswer({ answer: ' done ' })).toBe('done'); + expect(extractStructuredAnswer({ outputLocale: 'en-US', answer: ' done ' }, 'en-US')).toBe('done'); }); it('rejects malformed responses', () => { - expect(extractStructuredAnswer(undefined)).toBe(''); - expect(extractStructuredAnswer({ answer: 42 })).toBe(''); + expect(extractStructuredAnswer(undefined, 'en-US')).toBe(''); + expect(extractStructuredAnswer({ outputLocale: 'en-US', answer: 42 }, 'en-US')).toBe(''); + expect(() => extractStructuredAnswer({ outputLocale: 'fr-FR', answer: 'done' }, 'en-US')) + .toThrow('output-locale-mismatch'); }); }); diff --git a/src/application/usecases/steps/common/__tests__/think_use_case.test.ts b/src/application/usecases/steps/common/__tests__/think_use_case.test.ts index 484a64b5d..19f24350d 100644 --- a/src/application/usecases/steps/common/__tests__/think_use_case.test.ts +++ b/src/application/usecases/steps/common/__tests__/think_use_case.test.ts @@ -1,6 +1,7 @@ import { ThinkUseCase } from '../think_use_case'; import { Ai } from '../../../../../data/model/ai'; import { projectThinkContext, type ThinkContextSource } from '../think_workflow'; +import type { AgentQueryResult } from '../../../../ports/agent_query_ports'; jest.mock('../../../../../utils/logger', () => ({ logInfo: jest.fn(), @@ -12,6 +13,13 @@ const mockAskAgent = jest.fn(); const mockAddComment = jest.fn(); const mockGetDescription = jest.fn(); +async function localizedAnswer(prompt: string): Promise { + const response = await mockAskAgent.mock.results[mockAskAgent.mock.results.length - 1]?.value; + if (!response || typeof response !== 'object' || Array.isArray(response)) return response; + const outputLocale = prompt.includes('es-ES') ? 'es-ES' : 'en-US'; + return { outputLocale, ...response as Record }; +} + function baseParam(overrides: Record = {}) { return { owner: 'o', @@ -43,7 +51,10 @@ describe('ThinkUseCase', () => { useCase = new ThinkUseCase( { getDescription: mockGetDescription }, { addComment: mockAddComment }, - { query: (request: { configuration: unknown; agentId: string; prompt: string; options?: unknown }) => mockAskAgent(request.configuration, request.agentId, request.prompt, request.options) }, + { query: async (request: { configuration: unknown; agentId: string; prompt: string; options?: unknown }) => { + mockAskAgent(request.configuration, request.agentId, request.prompt, request.options); + return localizedAnswer(request.prompt); + } }, ); mockAskAgent.mockReset(); mockAddComment.mockReset(); @@ -188,6 +199,19 @@ describe('ThinkUseCase', () => { expect(results[0].executed).toBe(true); }); + it('rejects a response for another locale before posting the answer', async () => { + mockAskAgent.mockResolvedValue({ outputLocale: 'fr-FR', answer: 'Réponse.' }); + const param = baseParam({ + locale: { issue: 'en-US', pullRequest: 'en-US' }, + issue: { ...baseParam().issue, commentBody: '@bot answer this' }, + }); + + const results = await invoke(param); + + expect(results[0].errors[0]).toMatchObject({ code: 'locale.output-invalid' }); + expect(mockAddComment).not.toHaveBeenCalled(); + }); + it('returns error when OpenCode model is empty', async () => { const param = baseParam({ ai: new Ai('https://server', '', false, [], false, 'low', 20), diff --git a/src/application/usecases/steps/common/agent_answer_policy.ts b/src/application/usecases/steps/common/agent_answer_policy.ts index a76ad6c21..3aef5d2b9 100644 --- a/src/application/usecases/steps/common/agent_answer_policy.ts +++ b/src/application/usecases/steps/common/agent_answer_policy.ts @@ -1,5 +1,15 @@ -export function extractStructuredAnswer(response: unknown): string { - if (response == null || typeof response !== 'object') return ''; - const answer = (response as Record).answer; +import { + agentOutputLocaleFailureMessage, + validateAgentOutputLocale, +} from '../../../policies/agent_output_locale_policy'; +import { ApplicationError } from '../../../errors/application_error'; + +export function extractStructuredAnswer(response: unknown, targetLocale: string): string { + if (response == null) return ''; + const validation = validateAgentOutputLocale(response, targetLocale); + if (validation.kind === 'invalid') { + throw new ApplicationError('locale.output-invalid', agentOutputLocaleFailureMessage(validation)); + } + const answer = validation.payload.answer; return typeof answer === 'string' ? answer.trim() : ''; } diff --git a/src/application/usecases/steps/common/think_answer_workflow.ts b/src/application/usecases/steps/common/think_answer_workflow.ts index 4266c0f00..aae0a8828 100644 --- a/src/application/usecases/steps/common/think_answer_workflow.ts +++ b/src/application/usecases/steps/common/think_answer_workflow.ts @@ -15,6 +15,7 @@ import type { AgentConfiguration } from '../../../../data/model/agent'; import type { AgentTask } from '../../../../domain/agent'; import { appendTranslationContext } from '../../../policies/comment_translation_policy'; import type { TranslationPublication } from '../../../policies/comment_translation_policy'; +import { productFacingAgentQueryOptions } from '../../../policies/agent_output_locale_policy'; export interface ThinkAnswerDependencies { issueDescriptionQueryPort: BoundIssueDescriptionQueryPort; @@ -54,7 +55,12 @@ export async function runThinkAnswerWorkflow( question: request.question, targetLocale: param.targetLocale ?? 'en-US', }); - const answer = sanitizeAgentMarkdown(await queryThinkAnswer(param, prompt, dependencies.aiRepository)); + const answer = sanitizeAgentMarkdown(await queryThinkAnswer( + param, + prompt, + dependencies.aiRepository, + param.targetLocale ?? 'en-US', + )); if (!answer) { logError('Configured agent returned no answer for Think.'); return [ @@ -103,19 +109,16 @@ async function queryThinkAnswer( param: ThinkAnswerContext, prompt: string, repository: FindingsQueryPort, + targetLocale: string, ): Promise { logDebugInfo(`Think: calling configured agent (prompt length=${prompt.length}).`); const response = await repository.query({ configuration: param.agentConfiguration, agentId: AGENT_PLAN, prompt, - options: { - expectJson: true, - schema: THINK_RESPONSE_SCHEMA as unknown as Record, - schemaName: 'think_response', - }, + options: productFacingAgentQueryOptions('think', THINK_RESPONSE_SCHEMA), }); - const answer = extractStructuredAnswer(response); + const answer = extractStructuredAnswer(response, targetLocale); logDebugInfo(`Think: agent response received. Answer length=${answer.length}.`); return answer; } diff --git a/src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts b/src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts index d77186a5e..6779a92b9 100644 --- a/src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts +++ b/src/application/usecases/steps/issue/__tests__/answer_issue_help_use_case.test.ts @@ -1,6 +1,7 @@ import { AnswerIssueHelpUseCase } from '../answer_issue_help_use_case'; import { Ai } from '../../../../../data/model/ai'; import type { AnswerIssueHelpContext } from '../../../issue_workflow_context'; +import type { AgentQueryResult } from '../../../../ports/agent_query_ports'; jest.mock('../../../../../utils/logger', () => ({ logInfo: jest.fn(), @@ -16,6 +17,12 @@ const mockAddComment = jest.fn(); const mockAskAgent = jest.fn(); +async function localizedAnswer(prompt: string): Promise { + const response = await mockAskAgent.mock.results[mockAskAgent.mock.results.length - 1]?.value; + if (!response || typeof response !== 'object' || Array.isArray(response)) return response; + const outputLocale = prompt.includes('es-ES') ? 'es-ES' : 'en-US'; + return { outputLocale, ...response as Record }; +} function configuredAgent() { return new Ai('http://localhost:4096', 'opencode/model', false, [], false, 'low', 20) .getAgentConfiguration('planner'); @@ -38,7 +45,10 @@ describe('AnswerIssueHelpUseCase', () => { let useCase: AnswerIssueHelpUseCase; beforeEach(() => { - useCase = new AnswerIssueHelpUseCase({ addComment: mockAddComment }, { query: (request: { configuration: unknown; agentId: string; prompt: string; options?: unknown }) => mockAskAgent(request.configuration, request.agentId, request.prompt, request.options) }); + useCase = new AnswerIssueHelpUseCase({ addComment: mockAddComment }, { query: async (request: { configuration: unknown; agentId: string; prompt: string; options?: unknown }) => { + mockAskAgent(request.configuration, request.agentId, request.prompt, request.options); + return localizedAnswer(request.prompt); + } }); mockAddComment.mockReset(); mockAskAgent.mockReset(); }); @@ -174,6 +184,15 @@ describe('AnswerIssueHelpUseCase', () => { expect(publishedComment).toContain('Aquí tienes ayuda.'); }); + it('rejects a response for another locale before posting help', async () => { + mockAskAgent.mockResolvedValue({ outputLocale: 'fr-FR', answer: 'Aide.' }); + + const results = await useCase.invoke(baseParam({ locale: 'en-US' })); + + expect(results[0].errors[0]).toMatchObject({ code: 'locale.output-invalid' }); + expect(mockAddComment).not.toHaveBeenCalled(); + }); + it('returns failure when OpenCode returns no answer', async () => { mockAskAgent.mockResolvedValue(undefined); const param = baseParam(); diff --git a/src/application/usecases/steps/issue/answer_issue_help_workflow.ts b/src/application/usecases/steps/issue/answer_issue_help_workflow.ts index dde5d1cd6..87caf3188 100644 --- a/src/application/usecases/steps/issue/answer_issue_help_workflow.ts +++ b/src/application/usecases/steps/issue/answer_issue_help_workflow.ts @@ -14,6 +14,7 @@ import { sanitizeAgentMarkdown } from '../../../../application/policies/github_c import { buildCopilotWelcomeMessage } from '../../../../application/policies/copilot_interaction_policy'; import { ApplicationError, toApplicationError } from '../../../errors/application_error'; import type { AnswerIssueHelpContext } from '../../issue_workflow_context'; +import { productFacingAgentQueryOptions } from '../../../policies/agent_output_locale_policy'; export interface AnswerIssueHelpWorkflowDependencies { issueNotificationPort: BoundIssueNotificationPort; @@ -46,13 +47,9 @@ export async function runAnswerIssueHelpWorkflow( configuration, agentId: AGENT_PLAN, prompt, - options: { - expectJson: true, - schema: THINK_RESPONSE_SCHEMA as unknown as Record, - schemaName: 'answer_issue_help_response', - }, + options: productFacingAgentQueryOptions('answer-issue-help', THINK_RESPONSE_SCHEMA), }); - const answer = sanitizeAgentMarkdown(extractStructuredAnswer(response)); + const answer = sanitizeAgentMarkdown(extractStructuredAnswer(response, param.locale)); logDebugInfo(`AnswerIssueHelp: agent response. Answer length=${answer.length}.`); if (!answer) { return [noAnswerResult()]; diff --git a/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts b/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts index cdb92bf29..901c08a3d 100644 --- a/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts +++ b/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts @@ -4,6 +4,7 @@ import type { PullRequestDescriptionContext, PullRequestDescriptionRequest, } from '../../../pull_request_workflow_context'; +import type { AgentQueryResult } from '../../../../ports/agent_query_ports'; jest.mock('../../../../../utils/logger', () => ({ logInfo: jest.fn(), logDebugInfo: jest.fn(), logError: jest.fn(), @@ -15,6 +16,13 @@ const mockAskAgent = jest.fn(); const mockUpdateDescription = jest.fn(); const mockGetDetails = jest.fn(); +async function localizedDescription(value: Parameters[0]): Promise { + const response = await mockAskAgent(value); + return typeof response === 'string' + ? { outputLocale: 'en-US', description: response } + : response; +} + function context(overrides: Partial = {}): PullRequestDescriptionContext { const ai = new Ai('http://localhost:4096', 'model', false, [], false, 'low', 20); return { @@ -30,6 +38,7 @@ function context(overrides: Partial = {}): PullRe mode: 'replace', membersOnly: false, agentConfiguration: ai.getAgentConfiguration('planner'), + targetLocale: 'en-US', ...overrides, }; } @@ -50,7 +59,7 @@ describe('UpdatePullRequestDescriptionUseCase', () => { { updateDescription: mockUpdateDescription, getDetails: mockGetDetails }, { getDescription: mockGetIssueDescription }, { getAllMembers: mockGetAllMembers }, - { query: (value) => mockAskAgent(value) }, + { query: localizedDescription }, ); mockGetIssueDescription.mockResolvedValue('Issue description'); mockGetAllMembers.mockResolvedValue(['alice', 'bob']); @@ -162,4 +171,18 @@ describe('UpdatePullRequestDescriptionUseCase', () => { expect(mockUpdateDescription).not.toHaveBeenCalled(); expect(JSON.stringify(results)).not.toContain('secret diagnostic'); }); + + it('uses the PR locale contract and rejects mismatched output before updating the body', async () => { + mockAskAgent.mockResolvedValue({ outputLocale: 'fr-FR', description: '# Résumé' }); + + const results = await useCase.invoke(request({ targetLocale: 'es-ES' })); + + expect(mockAskAgent.mock.calls[0][0].prompt).toContain('outputLocale` exactly as `es-ES'); + expect(mockAskAgent.mock.calls[0][0].options).toMatchObject({ + expectJson: true, + schemaName: 'pull_request_description_response', + }); + expect(results[0].errors[0]).toMatchObject({ code: 'locale.output-invalid' }); + expect(mockUpdateDescription).not.toHaveBeenCalled(); + }); }); diff --git a/src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts b/src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts index 7c4111950..72fd28fcb 100644 --- a/src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts +++ b/src/application/usecases/steps/pull_request/update_pull_request_description_workflow.ts @@ -13,8 +13,14 @@ import { mergeManagedPullRequestDescription, shouldAutomaticallyUpdatePullRequestDescription, } from '../../../../domain/pull_request_description'; -import { ApplicationError } from '../../../errors/application_error'; +import { ApplicationError, toApplicationError } from '../../../errors/application_error'; import { parsePositiveSafeInteger } from '../../../../domain/positive_integer_policy'; +import { PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA } from '../../../policies/agent_response_schemas'; +import { + agentOutputLocaleFailureMessage, + productFacingAgentQueryOptions, + validateAgentOutputLocale, +} from '../../../policies/agent_output_locale_policy'; import type { PullRequestDescriptionRequest, PullRequestDescriptionContext, @@ -83,6 +89,7 @@ export async function runUpdatePullRequestDescriptionWorkflow( relatedIssueInstruction: context.issueNumber > 0 ? `Include \`Closes #${context.issueNumber}\` and "Related to #" only if relevant.` : 'Do not add a Closes line because this pull request has no linked issue.', + targetLocale: context.targetLocale, }); logDebugInfo( `UpdatePullRequestDescription: prompt length=${prompt.length}, issue description length=${issueDescription.length}. Calling configured agent.`, @@ -91,8 +98,12 @@ export async function runUpdatePullRequestDescriptionWorkflow( configuration: context.agentConfiguration, agentId: AGENT_PLAN, prompt, + options: productFacingAgentQueryOptions( + 'pull-request-description', + PULL_REQUEST_DESCRIPTION_RESPONSE_SCHEMA, + ), }); - const generatedDescription = sanitizeAgentMarkdown(extractDescription(response)); + const generatedDescription = sanitizeAgentMarkdown(extractDescription(response, context.targetLocale)); if (!generatedDescription.trim()) { return [new Result({ id: taskId, @@ -112,7 +123,7 @@ export async function runUpdatePullRequestDescriptionWorkflow( ); return [new Result({ id: taskId, success: true, executed: true, steps: [] })]; } catch (cause) { - const semanticError = new ApplicationError('workflow.failed', 'Unable to update pull request description.', { cause }); + const semanticError = toApplicationError(cause, 'workflow.failed', 'Unable to update pull request description.'); logError(semanticError); return [new Result({ id: taskId, @@ -153,10 +164,13 @@ async function loadPullRequestDetails( : undefined; } -function extractDescription(response: string | Record | undefined): string { - if (typeof response === 'string') return response; - if (!response) return ''; - return typeof response.description === 'string' ? response.description : ''; +function extractDescription(response: string | Record | undefined, targetLocale: string): string { + if (response == null) return ''; + const validation = validateAgentOutputLocale(response, targetLocale); + if (validation.kind === 'invalid') { + throw new ApplicationError('locale.output-invalid', agentOutputLocaleFailureMessage(validation)); + } + return typeof validation.payload.description === 'string' ? validation.payload.description : ''; } function skipped(taskId: string, step: string): Result[] { diff --git a/src/architecture/__tests__/agent_output_locale_boundaries.test.ts b/src/architecture/__tests__/agent_output_locale_boundaries.test.ts new file mode 100644 index 000000000..daadc0aee --- /dev/null +++ b/src/architecture/__tests__/agent_output_locale_boundaries.test.ts @@ -0,0 +1,38 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PRODUCT_FACING_AGENT_TASKS } from '../../application/policies/agent_output_locale_policy'; + +const root = join(__dirname, '../..'); + +function productionTypeScriptFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return entry.name === '__tests__' ? [] : productionTypeScriptFiles(path); + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') ? [path] : []; + }); +} + +describe('product-facing agent locale boundaries', () => { + it('has exactly one locale-guarded query call for every inventoried product task', () => { + const taskCalls = productionTypeScriptFiles(root).flatMap((file) => { + const source = readFileSync(file, 'utf8'); + return [...source.matchAll(/productFacingAgentQueryOptions\(\s*['"]([^'"]+)['"]/gu)] + .map((match) => match[1]); + }); + + expect(taskCalls.sort()).toEqual([...PRODUCT_FACING_AGENT_TASKS].sort()); + }); + + it.each([ + 'prompts/think.ts', + 'prompts/answer_issue_help.ts', + 'prompts/check_progress.ts', + 'prompts/recommend_steps.ts', + 'prompts/update_pull_request_description.ts', + 'prompts/bugbot.ts', + ])('%s requires targetLocale and outputLocale', (relativePath) => { + const source = readFileSync(join(root, relativePath), 'utf8'); + expect(source).toContain('targetLocale'); + expect(source).toContain('outputLocale'); + }); +}); diff --git a/src/data/model/application_error.ts b/src/data/model/application_error.ts index 7ac283fa7..030785ae4 100644 --- a/src/data/model/application_error.ts +++ b/src/data/model/application_error.ts @@ -19,6 +19,7 @@ export type ApplicationErrorCode = | 'provider.contract-invalid' | 'agent.policy-rejected' | 'agent.failed' + | 'locale.output-invalid' | 'locale.translation-failed' | 'validation.invalid-input' | 'workflow.invalid-event' @@ -106,6 +107,12 @@ export const APPLICATION_ERROR_METADATA: Readonly { ignoreBlock: '\n**Files to ignore:** *.test.ts', previousBlock: '(No previous findings.)', coverageBlock: '**Context coverage:** complete.', + targetLocale: 'de-DE', }); expect(prompt).toContain('**Context.**'); expect(prompt).toContain('org'); @@ -26,6 +27,7 @@ describe('getBugbotPrompt', () => { expect(prompt).toContain('Context coverage'); expect(prompt).toContain('findings'); expect(prompt).toContain('resolved_findings'); + expect(prompt).toContain('outputLocale` exactly as `de-DE'); expect(prompt).not.toContain('{{'); }); }); diff --git a/src/prompts/__tests__/check_progress.test.ts b/src/prompts/__tests__/check_progress.test.ts index ca268a4c2..26de11189 100644 --- a/src/prompts/__tests__/check_progress.test.ts +++ b/src/prompts/__tests__/check_progress.test.ts @@ -8,6 +8,7 @@ describe('getCheckProgressPrompt', () => { baseBranch: 'develop', currentBranch: 'feature/wip', issueDescription: 'Build the API.', + targetLocale: 'pt-BR', }); expect(prompt).toContain('**Context.**'); expect(prompt).toContain('issue #3'); @@ -16,6 +17,7 @@ describe('getCheckProgressPrompt', () => { expect(prompt).toContain('Build the API.'); expect(prompt).toContain('progress'); expect(prompt).toContain('remaining'); + expect(prompt).toContain('"outputLocale": "pt-BR"'); expect(prompt).not.toContain('{{'); }); }); diff --git a/src/prompts/__tests__/index.test.ts b/src/prompts/__tests__/index.test.ts index c1982fff6..b26792a32 100644 --- a/src/prompts/__tests__/index.test.ts +++ b/src/prompts/__tests__/index.test.ts @@ -24,6 +24,7 @@ describe('getPrompt', () => { projectContextInstruction: 'X', contextBlock: '\n\n', question: 'q', + targetLocale: 'en-US', }; const viaRegistry = getPrompt(PROMPT_NAMES.THINK, params); const viaGetter = getThinkPrompt(params); diff --git a/src/prompts/__tests__/recommend_steps.test.ts b/src/prompts/__tests__/recommend_steps.test.ts index 2e905d98a..0f5ac87b5 100644 --- a/src/prompts/__tests__/recommend_steps.test.ts +++ b/src/prompts/__tests__/recommend_steps.test.ts @@ -6,12 +6,14 @@ describe('getRecommendStepsPrompt', () => { projectContextInstruction: '**Use project.**', issueNumber: '7', issueDescription: 'Implement OAuth flow.', + targetLocale: 'fr-FR', }); expect(prompt).toContain('**Use project.**'); expect(prompt).toContain('Issue #7'); expect(prompt).toContain('Implement OAuth flow.'); expect(prompt).toContain('recommend concrete steps'); - expect(prompt).toContain('NO_NEW_RECOMMENDATIONS'); + expect(prompt).toContain('status` to `unchanged'); + expect(prompt).toContain('outputLocale` exactly as `fr-FR'); expect(prompt).not.toContain('{{'); }); @@ -21,6 +23,7 @@ describe('getRecommendStepsPrompt', () => { issueNumber: '7', issueDescription: 'Implement OAuth flow.', previousRecommendation: '1. Reuse the existing auth service.', + targetLocale: 'en-US', }); expect(prompt).toContain(''); diff --git a/src/prompts/__tests__/think.test.ts b/src/prompts/__tests__/think.test.ts index 06758ea5a..b4969abe6 100644 --- a/src/prompts/__tests__/think.test.ts +++ b/src/prompts/__tests__/think.test.ts @@ -6,6 +6,7 @@ describe('getThinkPrompt', () => { projectContextInstruction: '**Use context.**', contextBlock: '\n\nContext (issue #1):\nFix the bug.\n\n', question: 'what is 2+2?', + targetLocale: 'fr-FR', }); expect(prompt).toContain('helpful assistant'); expect(prompt).toContain('**Use context.**'); @@ -13,6 +14,7 @@ describe('getThinkPrompt', () => { expect(prompt).toContain('Fix the bug.'); expect(prompt).toContain('Question: [BEGIN_UNTRUSTED_DATA origin=prompt.question'); expect(prompt).toContain('what is 2+2?'); + expect(prompt).toContain('outputLocale` set exactly to `fr-FR'); expect(prompt).not.toContain('{{'); }); @@ -21,6 +23,7 @@ describe('getThinkPrompt', () => { projectContextInstruction: 'X', contextBlock: '\n\n', question: 'q', + targetLocale: 'en-US', }); expect(prompt).toContain('Question: [BEGIN_UNTRUSTED_DATA origin=prompt.question'); expect(prompt).toContain('\nq\n'); diff --git a/src/prompts/__tests__/update_pull_request_description.test.ts b/src/prompts/__tests__/update_pull_request_description.test.ts index de536dba5..061d63eb3 100644 --- a/src/prompts/__tests__/update_pull_request_description.test.ts +++ b/src/prompts/__tests__/update_pull_request_description.test.ts @@ -9,6 +9,7 @@ describe('getUpdatePullRequestDescriptionPrompt', () => { issueNumber: '42', issueDescription: 'Add login screen.', relatedIssueInstruction: 'Include `Closes #42` when relevant.', + targetLocale: 'es-ES', }); expect(prompt).toContain('**Use repo.**'); expect(prompt).toContain('`main`'); @@ -17,6 +18,7 @@ describe('getUpdatePullRequestDescriptionPrompt', () => { expect(prompt).toContain('Add login screen.'); expect(prompt).toContain('pull_request_template.md'); expect(prompt).toContain('git diff'); + expect(prompt).toContain('outputLocale` exactly as `es-ES'); expect(prompt).not.toContain('{{'); }); }); diff --git a/src/prompts/answer_issue_help.ts b/src/prompts/answer_issue_help.ts index c3f2bda91..69154555d 100644 --- a/src/prompts/answer_issue_help.ts +++ b/src/prompts/answer_issue_help.ts @@ -6,6 +6,8 @@ import { fillTemplate } from './fill'; const TEMPLATE = `The user has just opened a question/help issue. Provide a helpful initial response to their question or request below. Be concise and actionable. Write every human-readable sentence in {{targetLocale}} while preserving code identifiers, paths, refs, commands, and URLs verbatim. +Return a JSON object with \`outputLocale\` set exactly to \`{{targetLocale}}\` and \`answer\` containing the Markdown response. + **Answer in this single response:** Give a complete, direct answer. Do not reply that you need to explore the repository, read documentation first, or gather more information—use the project (README, docs/, code, .cursor/rules) to answer now. For "how do I…" or tutorial-style questions (e.g. how to implement or configure this project), provide concrete steps or guidance based on the project's actual documentation and structure. {{projectContextInstruction}} @@ -13,7 +15,7 @@ const TEMPLATE = `The user has just opened a question/help issue. Provide a help **Issue description (user's question or request):** {{description}} -Respond with a single JSON object containing an "answer" field with your reply. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; +Respond with a single JSON object containing \`outputLocale\` and \`answer\`. Format the answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response.`; export type AnswerIssueHelpParams = { description: string; diff --git a/src/prompts/bugbot.ts b/src/prompts/bugbot.ts index ca2cd1d85..d32a03d14 100644 --- a/src/prompts/bugbot.ts +++ b/src/prompts/bugbot.ts @@ -5,6 +5,8 @@ import { fillTemplate } from './fill'; const TEMPLATE = `You are analyzing the latest code changes for potential bugs and issues. +Write every human-readable finding title, description, evidence, and suggestion in {{targetLocale}}. Preserve identifiers, code, symbols, paths, refs, commands, and URLs verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Repository context:** @@ -38,7 +40,7 @@ For every finding: Return every finding field required by the response schema. Use null for file, line, endLine, severity, confidence, category, evidence, suggestion, symbol, codeSnippet, or suggestedCode when that value does not safely apply. Only include files outside the ignore list. {{previousBlock}} -**Output:** Return a JSON object with "findings" (new/current problems from task 1) and "resolved_findings" (objects containing the exact prior finding id and either "fixed" or "obsolete"). Always return both arrays; use an empty array when there are no resolved findings. Never resolve an id that was not included in the previous-findings list.`; +**Output:** Return a JSON object with "outputLocale", "findings" (new/current problems from task 1), and "resolved_findings" (objects containing the exact prior finding id and either "fixed" or "obsolete"). Always return both arrays; use an empty array when there are no resolved findings. Never resolve an id that was not included in the previous-findings list.`; export type BugbotParams = { projectContextInstruction: string; @@ -55,6 +57,7 @@ export type BugbotParams = { reviewConversationBlock?: string; rulesBlock?: string; effortBlock?: string; + targetLocale: string; }; export function getBugbotPrompt(params: BugbotParams): string { diff --git a/src/prompts/check_progress.ts b/src/prompts/check_progress.ts index 405a83ccf..7b91c12ea 100644 --- a/src/prompts/check_progress.ts +++ b/src/prompts/check_progress.ts @@ -5,6 +5,8 @@ import { fillTemplate } from './fill'; const TEMPLATE = `You are in the repository workspace. Assess the progress of issue #{{issueNumber}} using the full diff between the base (parent) branch and the current branch. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, percentages, and JSON keys verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Branches:** @@ -20,7 +22,7 @@ const TEMPLATE = `You are in the repository workspace. Assess the progress of is **Issue description:** {{issueDescription}} -Respond with a single JSON object: { "progress": , "summary": "", "remaining": "" | null }.`; +Respond with a single JSON object: { "outputLocale": "{{targetLocale}}", "progress": , "summary": "", "remaining": "" | null }.`; export type CheckProgressParams = { projectContextInstruction: string; @@ -28,6 +30,7 @@ export type CheckProgressParams = { baseBranch: string; currentBranch: string; issueDescription: string; + targetLocale: string; }; export function getCheckProgressPrompt(params: CheckProgressParams): string { @@ -37,5 +40,6 @@ export function getCheckProgressPrompt(params: CheckProgressParams): string { baseBranch: params.baseBranch, currentBranch: params.currentBranch, issueDescription: params.issueDescription, + targetLocale: params.targetLocale, }); } diff --git a/src/prompts/recommend_steps.ts b/src/prompts/recommend_steps.ts index f84ef36a7..59678c44e 100644 --- a/src/prompts/recommend_steps.ts +++ b/src/prompts/recommend_steps.ts @@ -5,6 +5,8 @@ import { fillTemplate } from './fill'; const TEMPLATE = `Based on the following issue description, recommend concrete steps to implement or address this issue. Order the steps logically (e.g. setup, implementation, tests, docs). Keep each step clear and actionable. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, and URLs verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Issue #{{issueNumber}} description:** @@ -12,15 +14,16 @@ const TEMPLATE = `Based on the following issue description, recommend concrete s {{previousRecommendation}} -Provide a complete numbered list of recommended steps in **markdown** (use headings, lists, code blocks for commands or snippets) so it is easy to read. You can add brief sub-bullets per step if needed. +Return one JSON object with \`outputLocale\`, \`status\`, and \`steps\`. When a material recommendation is needed, set \`status\` to \`recommendation\` and put a complete numbered list in Markdown in \`steps\` (headings, lists, and code blocks are allowed). You can add brief sub-bullets per step if needed. -If the current description does not require any material change to the previous recommendation, output exactly \`NO_NEW_RECOMMENDATIONS\` and nothing else. Do not use that sentinel when there is no previous recommendation.`; +If the current description does not require any material change to the previous recommendation, set \`status\` to \`unchanged\` and \`steps\` to null. Do not return \`unchanged\` when there is no previous recommendation.`; export type RecommendStepsParams = { projectContextInstruction: string; issueNumber: string; issueDescription: string; previousRecommendation?: string; + targetLocale: string; }; export function getRecommendStepsPrompt(params: RecommendStepsParams): string { @@ -28,6 +31,7 @@ export function getRecommendStepsPrompt(params: RecommendStepsParams): string { projectContextInstruction: params.projectContextInstruction, issueNumber: String(params.issueNumber), issueDescription: params.issueDescription, + targetLocale: params.targetLocale, previousRecommendation: params.previousRecommendation ? `Previous recommendation (use only to detect whether the current plan is still valid):\n\n${params.previousRecommendation}\n` : 'There is no previous recommendation for this issue.', diff --git a/src/prompts/think.ts b/src/prompts/think.ts index 6efec1a01..d73771db4 100644 --- a/src/prompts/think.ts +++ b/src/prompts/think.ts @@ -5,6 +5,8 @@ import { fillTemplate } from './fill'; const TEMPLATE = `You are a helpful assistant. Answer the following question concisely in {{targetLocale}}, using the context below when relevant. Format your answer in **markdown** (headings, lists, code blocks where useful) so it is easy to read. Do not include the question in your response. Preserve code identifiers, paths, refs, commands, and URLs verbatim. +Return a JSON object with \`outputLocale\` set exactly to \`{{targetLocale}}\` and \`answer\` containing the Markdown response. Every human-readable sentence in \`answer\` must use the target locale. + {{projectContextInstruction}} {{contextBlock}}Question: {{question}}`; @@ -12,7 +14,7 @@ export type ThinkParams = { projectContextInstruction: string; contextBlock: string; question: string; - targetLocale?: string; + targetLocale: string; }; export function getThinkPrompt(params: ThinkParams): string { @@ -20,6 +22,6 @@ export function getThinkPrompt(params: ThinkParams): string { projectContextInstruction: params.projectContextInstruction, contextBlock: params.contextBlock, question: params.question, - targetLocale: params.targetLocale ?? 'en-US', + targetLocale: params.targetLocale, }); } diff --git a/src/prompts/update_pull_request_description.ts b/src/prompts/update_pull_request_description.ts index fae6cf12d..a36e00c3d 100644 --- a/src/prompts/update_pull_request_description.ts +++ b/src/prompts/update_pull_request_description.ts @@ -5,6 +5,8 @@ import { fillTemplate } from './fill'; const TEMPLATE = `You are in the repository workspace. Your task is to produce a pull request description by filling the project's PR template with information from the branch diff and the issue. +Write every human-readable sentence in {{targetLocale}}. Preserve code identifiers, paths, refs, commands, URLs, issue/PR references, and conventional title prefixes verbatim. Echo \`outputLocale\` exactly as \`{{targetLocale}}\`. + {{projectContextInstruction}} **Branches:** @@ -25,12 +27,12 @@ const TEMPLATE = `You are in the repository workspace. Your task is to produce a - **Breaking Changes:** list any, or "None". - **Notes for Reviewers / Additional Context:** fill only if useful; otherwise a short placeholder or omit. 5. Do not output a single compact paragraph. Output the full filled template so the PR description is well-structured and easy to scan. Preserve the template's formatting (headings with # and ##, horizontal rules). Use checkboxes \`- [ ]\` / \`- [x]\` only where they add value; you may simplify or drop a section if it does not apply. -6. **Output format:** Return only the filled template content. Do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start directly with the first heading of the template (e.g. # Summary). Do not wrap the output in code blocks. +6. **Output format:** Return one JSON object with \`outputLocale\` and \`description\`. Put only the filled template content in \`description\`; do not add any preamble, meta-commentary, or framing phrases (e.g. "Based on my analysis...", "After reviewing the diff...", "Here is the description..."). Start \`description\` directly with the first heading of the template (e.g. # Summary). Do not wrap it in code blocks. **Issue description:** {{issueDescription}} -Output only the filled template content (the PR description body), starting with the first heading. No preamble, no commentary.`; +Return the structured JSON response only.`; export type UpdatePullRequestDescriptionParams = { projectContextInstruction: string; @@ -39,6 +41,7 @@ export type UpdatePullRequestDescriptionParams = { issueNumber: string; issueDescription: string; relatedIssueInstruction: string; + targetLocale: string; }; export function getUpdatePullRequestDescriptionPrompt(params: UpdatePullRequestDescriptionParams): string { @@ -49,5 +52,6 @@ export function getUpdatePullRequestDescriptionPrompt(params: UpdatePullRequestD issueNumber: String(params.issueNumber), issueDescription: params.issueDescription, relatedIssueInstruction: params.relatedIssueInstruction, + targetLocale: params.targetLocale, }); } From bb1bedeb0fdf49e5b5ac37ae14d581917a159927 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 14 Sep 2026 18:06:36 +0200 Subject: [PATCH 2/3] codex-agent-output-locale-contract: cover localized response fallbacks --- .../common/__tests__/think_use_case.test.ts | 23 +++++++++++++++++++ ..._pull_request_description_use_case.test.ts | 12 ++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/application/usecases/steps/common/__tests__/think_use_case.test.ts b/src/application/usecases/steps/common/__tests__/think_use_case.test.ts index 19f24350d..697686046 100644 --- a/src/application/usecases/steps/common/__tests__/think_use_case.test.ts +++ b/src/application/usecases/steps/common/__tests__/think_use_case.test.ts @@ -139,6 +139,29 @@ describe('ThinkUseCase', () => { expect(mockAskAgent).not.toHaveBeenCalled(); }); + it('defaults a legacy ready context without targetLocale to canonical English', async () => { + mockAskAgent.mockResolvedValue({ answer: 'Plan ready.' }); + mockAddComment.mockResolvedValue(undefined); + const ai = new Ai('https://opencode.example.com', 'model-x', false, [], false, 'low', 20); + + const results = await useCase.invoke({ + request: { + kind: 'ready', + commentBody: '/copilot plan rollout', + question: 'Plan rollout', + issueNumberForContext: 1, + destinationNumber: 1, + destinationType: 'issue', + }, + agentTask: 'planner', + agentConfiguration: ai.getAgentConfiguration('planner'), + }); + + expect(mockAskAgent.mock.calls[0][2]).toContain('outputLocale` set exactly to `en-US'); + expect(mockAddComment).toHaveBeenCalledWith(1, 'Plan ready.'); + expect(results[0]).toMatchObject({ success: true, executed: true }); + }); + it('returns success executed false when comment does not mention @user', async () => { const param = baseParam({ issue: { ...baseParam().issue, commentBody: 'hello world' }, diff --git a/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts b/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts index 901c08a3d..5b5077b2b 100644 --- a/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts +++ b/src/application/usecases/steps/pull_request/__tests__/update_pull_request_description_use_case.test.ts @@ -157,6 +157,18 @@ describe('UpdatePullRequestDescriptionUseCase', () => { expect(mockUpdateDescription).not.toHaveBeenCalled(); }); + it.each([ + ['null response', null], + ['missing description', { outputLocale: 'en-US' }], + ])('does not publish a %s from the agent', async (_label, response) => { + mockAskAgent.mockResolvedValue(response); + + const results = await useCase.invoke(request()); + + expect(results[0]).toMatchObject({ success: false, executed: true }); + expect(mockUpdateDescription).not.toHaveBeenCalled(); + }); + it('enforces members-only before invoking the agent', async () => { mockGetAllMembers.mockResolvedValue(['bob']); const results = await useCase.invoke(request({ membersOnly: true })); From b47ff204de541baebce4363b56bb0113c5d608e4 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Mon, 14 Sep 2026 18:10:28 +0200 Subject: [PATCH 3/3] codex-agent-output-locale-contract: reject invalid initial recommendation state --- build/cli/index.js | 18 +++++++++++++----- build/github_action/index.js | 18 +++++++++++++----- .../__tests__/recommend_steps_use_case.test.ts | 15 +++++++++++++++ .../actions/recommend_steps_result_policy.ts | 18 ++++++++++++++---- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/build/cli/index.js b/build/cli/index.js index ff5eff541..2031a8cc1 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -48294,13 +48294,14 @@ const agent_output_locale_policy_1 = __nccwpck_require__(30601); function buildRecommendationResult(param, taskId, response, issueDescriptionFingerprint, previousRecommendation, issueNumber) { const steps = extractRecommendationText(response, param.targetLocale); if (!steps) { - const semanticError = new application_error_1.ApplicationError('agent.failed', 'The configured agent returned no recommendation.'); - (0, logging_ports_1.logError)(semanticError); - return recommendationOutcome([new result_1.Result({ id: taskId, success: false, executed: true, errors: [semanticError] })]); + return recommendationFailure(taskId, 'The configured agent returned no recommendation.'); } (0, logging_ports_1.logDebugInfo)(`RecommendSteps: agent response received. Steps length=${steps.length}.`); - if (previousRecommendation && (0, recommendation_policy_1.isNoNewRecommendation)(steps)) - return skipUnchangedRecommendation(param, previousRecommendation, issueDescriptionFingerprint, 'agent found no material change'); + if ((0, recommendation_policy_1.isNoNewRecommendation)(steps)) { + return previousRecommendation + ? skipUnchangedRecommendation(param, previousRecommendation, issueDescriptionFingerprint, 'agent found no material change') + : recommendationFailure(taskId, 'The configured agent returned unchanged without a previous recommendation.'); + } const recommendationFingerprint = (0, recommendation_policy_1.createRecommendationFingerprint)(steps); if (previousRecommendation?.recommendationFingerprint === recommendationFingerprint) return skipUnchangedRecommendation(param, previousRecommendation, issueDescriptionFingerprint, 'recommendation is unchanged'); @@ -48328,6 +48329,13 @@ function recommendationOutcome(results, recommendationState) { } : {}), }); } +function recommendationFailure(taskId, message) { + const semanticError = new application_error_1.ApplicationError('agent.failed', message); + (0, logging_ports_1.logError)(semanticError); + return recommendationOutcome([ + new result_1.Result({ id: taskId, success: false, executed: true, errors: [semanticError] }), + ]); +} function extractRecommendationText(response, targetLocale) { if (response == null) return ''; diff --git a/build/github_action/index.js b/build/github_action/index.js index 3a2cea603..7781d7c6f 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -49009,13 +49009,14 @@ const agent_output_locale_policy_1 = __nccwpck_require__(30601); function buildRecommendationResult(param, taskId, response, issueDescriptionFingerprint, previousRecommendation, issueNumber) { const steps = extractRecommendationText(response, param.targetLocale); if (!steps) { - const semanticError = new application_error_1.ApplicationError('agent.failed', 'The configured agent returned no recommendation.'); - (0, logging_ports_1.logError)(semanticError); - return recommendationOutcome([new result_1.Result({ id: taskId, success: false, executed: true, errors: [semanticError] })]); + return recommendationFailure(taskId, 'The configured agent returned no recommendation.'); } (0, logging_ports_1.logDebugInfo)(`RecommendSteps: agent response received. Steps length=${steps.length}.`); - if (previousRecommendation && (0, recommendation_policy_1.isNoNewRecommendation)(steps)) - return skipUnchangedRecommendation(param, previousRecommendation, issueDescriptionFingerprint, 'agent found no material change'); + if ((0, recommendation_policy_1.isNoNewRecommendation)(steps)) { + return previousRecommendation + ? skipUnchangedRecommendation(param, previousRecommendation, issueDescriptionFingerprint, 'agent found no material change') + : recommendationFailure(taskId, 'The configured agent returned unchanged without a previous recommendation.'); + } const recommendationFingerprint = (0, recommendation_policy_1.createRecommendationFingerprint)(steps); if (previousRecommendation?.recommendationFingerprint === recommendationFingerprint) return skipUnchangedRecommendation(param, previousRecommendation, issueDescriptionFingerprint, 'recommendation is unchanged'); @@ -49043,6 +49044,13 @@ function recommendationOutcome(results, recommendationState) { } : {}), }); } +function recommendationFailure(taskId, message) { + const semanticError = new application_error_1.ApplicationError('agent.failed', message); + (0, logging_ports_1.logError)(semanticError); + return recommendationOutcome([ + new result_1.Result({ id: taskId, success: false, executed: true, errors: [semanticError] }), + ]); +} function extractRecommendationText(response, targetLocale) { if (response == null) return ''; diff --git a/src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts b/src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts index e3a5e70c0..004079928 100644 --- a/src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts +++ b/src/application/usecases/actions/__tests__/recommend_steps_use_case.test.ts @@ -203,6 +203,21 @@ describe('RecommendStepsUseCase', () => { expect(lastOutcome?.configurationPatch?.recommendationState.issueDescriptionFingerprint).not.toBe('old-description'); }); + it('rejects unchanged status when no previous recommendation exists', async () => { + mockGetDescription.mockResolvedValue('Implement login feature.'); + mockAskAgent.mockResolvedValue('NO_NEW_RECOMMENDATIONS'); + + const results = await invoke(baseParam()); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ success: false, executed: true }); + expect(results[0].errors[0]).toMatchObject({ + code: 'agent.failed', + message: 'The configured agent returned unchanged without a previous recommendation.', + }); + expect(lastOutcome?.configurationPatch).toBeUndefined(); + }); + it('does not publish a duplicate recommendation when the normalized response is unchanged', async () => { mockGetDescription.mockReset(); mockGetDescription diff --git a/src/application/usecases/actions/recommend_steps_result_policy.ts b/src/application/usecases/actions/recommend_steps_result_policy.ts index 7bee6547b..124f029b5 100644 --- a/src/application/usecases/actions/recommend_steps_result_policy.ts +++ b/src/application/usecases/actions/recommend_steps_result_policy.ts @@ -19,12 +19,14 @@ export function buildRecommendationResult( ): RecommendStepsOutcome { const steps = extractRecommendationText(response, param.targetLocale); if (!steps) { - const semanticError = new ApplicationError('agent.failed', 'The configured agent returned no recommendation.'); - logError(semanticError); - return recommendationOutcome([new Result({ id: taskId, success: false, executed: true, errors: [semanticError] })]); + return recommendationFailure(taskId, 'The configured agent returned no recommendation.'); } logDebugInfo(`RecommendSteps: agent response received. Steps length=${steps.length}.`); - if (previousRecommendation && isNoNewRecommendation(steps)) return skipUnchangedRecommendation(param, previousRecommendation, issueDescriptionFingerprint, 'agent found no material change'); + if (isNoNewRecommendation(steps)) { + return previousRecommendation + ? skipUnchangedRecommendation(param, previousRecommendation, issueDescriptionFingerprint, 'agent found no material change') + : recommendationFailure(taskId, 'The configured agent returned unchanged without a previous recommendation.'); + } const recommendationFingerprint = createRecommendationFingerprint(steps); if (previousRecommendation?.recommendationFingerprint === recommendationFingerprint) return skipUnchangedRecommendation(param, previousRecommendation, issueDescriptionFingerprint, 'recommendation is unchanged'); const recommendationState: RecommendationState = { @@ -54,6 +56,14 @@ function recommendationOutcome(results: readonly Result[], recommendationState?: }); } +function recommendationFailure(taskId: string, message: string): RecommendStepsOutcome { + const semanticError = new ApplicationError('agent.failed', message); + logError(semanticError); + return recommendationOutcome([ + new Result({ id: taskId, success: false, executed: true, errors: [semanticError] }), + ]); +} + function extractRecommendationText(response: string | Record | undefined, targetLocale: string): string { if (response == null) return ''; const validation = validateAgentOutputLocale(response, targetLocale);