Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/pi-extension/server/agent-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
const KNOWN_JOB_FIELDS = new Set([
"provider", "command", "label",
"engine", "model", "reasoningEffort", "effort", "thinking", "fastMode",
"reviewProfileId", "repairOf",
"responseLanguage", "reviewProfileId", "repairOf",
]);
if (body && typeof body === "object") {
const unknown = Object.keys(body).filter((k) => !KNOWN_JOB_FIELDS.has(k));
Expand Down Expand Up @@ -631,6 +631,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
if (typeof body.effort === "string") config.effort = body.effort;
if (typeof body.thinking === "string") config.thinking = body.thinking;
if (body.fastMode === true) config.fastMode = true;
if (typeof body.responseLanguage === "string") config.responseLanguage = body.responseLanguage;
if (typeof body.reviewProfileId === "string") config.reviewProfileId = body.reviewProfileId;
if (typeof body.repairOf === "string") config.repairOf = body.repairOf;
const built = await options.buildCommand(provider, Object.keys(config).length > 0 ? config : undefined);
Expand Down
8 changes: 6 additions & 2 deletions apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ import {
parseCodexOutput,
transformReviewFindings,
} from "../generated/codex-review.js";
import { buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "../generated/agent-review-message.js";
import { agentJobLanguageInstruction, buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "../generated/agent-review-message.js";
import {
composeClaudeReviewPrompt,
buildClaudeCommand,
Expand Down Expand Up @@ -992,13 +992,17 @@ export async function startReviewServer(options: {
// prompt; strip the default framing prose from the user message so only the
// git/PR context remains. The default review keeps today's message verbatim.
const isCustomReview = reviewProfile.source === "user";
const userMessage = workspacePrompt
const baseUserMessage = workspacePrompt
? buildAgentReviewUserMessageForTarget({
kind: "workspace",
patch: currentPatch,
workspace: workspacePrompt,
}, isCustomReview)
: buildAgentReviewUserMessage(currentPatch, currentDiffType as DiffType, userMessageOptions, prMeta, isCustomReview);
const languageInstruction = agentJobLanguageInstruction(config?.responseLanguage);
const userMessage = languageInstruction
? `${languageInstruction}\n\n${baseUserMessage}`
: baseUserMessage;
const jobLabel = workspacePrompt ? "Workspace Review" : "Code Review";

if (provider === "codex") {
Expand Down
21 changes: 21 additions & 0 deletions packages/ai/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,27 @@ describe("Context builders", () => {
expect(prompt).toMatch(/messages/i);
expect(prompt).toMatch(/inspect/i);
});

test("responseLanguage appends a language instruction in every mode", () => {
const modes: AIContext[] = [
{ mode: "plan-review", plan: { plan: "# Plan" }, responseLanguage: "Korean" },
{ mode: "code-review", review: { patch: "+x" }, responseLanguage: "Korean" },
{ mode: "annotate", annotate: { content: "# Doc", filePath: "/x.md" }, responseLanguage: "Korean" },
];
for (const ctx of modes) {
expect(buildSystemPrompt(ctx)).toContain("Always respond in Korean");
expect(buildForkPreamble(ctx)).toContain("Always respond in Korean");
}
});

test("no language instruction when responseLanguage is absent or blank", () => {
const plain: AIContext = { mode: "plan-review", plan: { plan: "# Plan" } };
const blank: AIContext = { mode: "plan-review", plan: { plan: "# Plan" }, responseLanguage: " " };
for (const ctx of [plain, blank]) {
expect(buildSystemPrompt(ctx)).not.toContain("Always respond in");
expect(buildForkPreamble(ctx)).not.toContain("Always respond in");
}
});
});

// ---------------------------------------------------------------------------
Expand Down
27 changes: 23 additions & 4 deletions packages/ai/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,20 @@ import type { AIContext } from "./types.ts";
* - That it's operating inside Plannotator (not a general coding session)
*/
export function buildSystemPrompt(ctx: AIContext): string {
let base: string;
switch (ctx.mode) {
case "plan-review":
return buildPlanReviewPrompt(ctx);
base = buildPlanReviewPrompt(ctx);
break;
case "code-review":
return buildCodeReviewPrompt();
base = buildCodeReviewPrompt();
break;
case "annotate":
return buildAnnotatePrompt(ctx);
base = buildAnnotatePrompt(ctx);
break;
}
const lang = languageInstruction(ctx);
return lang ? `${base}\n\n${lang}` : base;
}

/**
Expand All @@ -44,9 +50,12 @@ export function buildForkPreamble(ctx: AIContext): string {
"The user is now reviewing your work in Plannotator and has a question.",
"Answer the user's message directly and concisely based on the conversation " +
"history and the context below. Do not re-review or summarize the work unless they ask.",
"",
];

const lang = languageInstruction(ctx);
if (lang) lines.push(lang);
lines.push("");

switch (ctx.mode) {
case "plan-review": {
lines.push("## Current Plan Under Review");
Expand Down Expand Up @@ -151,6 +160,16 @@ const ANSWER_DIRECTLY =
"The material below is context for what the user is looking at — do NOT review, summarize, or critique it unless the user's message asks you to. " +
"Only investigate further (read files, run git) if the user's question actually requires it.";

/** Instruction appended when the user picked a response language in settings. */
function languageInstruction(ctx: AIContext): string | null {
const lang = ctx.responseLanguage?.trim();
if (!lang) return null;
return (
`IMPORTANT: Always respond in ${lang}, regardless of the language of the user's message ` +
"or the material under review. Keep code, file paths, and technical identifiers as-is."
);
}

function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return `${text.slice(0, max)}\n\n... [truncated for context window]`;
Expand Down
8 changes: 5 additions & 3 deletions packages/core/ai-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,10 @@ export interface AnnotateContext {

/**
* Union of mode-specific contexts, discriminated by `mode`.
* `responseLanguage` is the language the AI should respond in
* (English name, e.g. "Korean"); omit for auto.
*/
export type AIContext =
| { mode: "plan-review"; plan: PlanContext; parent?: ParentSession }
| { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession }
| { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession };
| { mode: "plan-review"; plan: PlanContext; parent?: ParentSession; responseLanguage?: string }
| { mode: "code-review"; review: CodeReviewContext; parent?: ParentSession; responseLanguage?: string }
| { mode: "annotate"; annotate: AnnotateContext; parent?: ParentSession; responseLanguage?: string };
3 changes: 2 additions & 1 deletion packages/server/agent-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
const KNOWN_JOB_FIELDS = new Set([
"provider", "command", "label",
"engine", "model", "reasoningEffort", "effort", "thinking", "fastMode",
"reviewProfileId", "repairOf",
"responseLanguage", "reviewProfileId", "repairOf",
]);
if (body && typeof body === "object") {
const unknown = Object.keys(body).filter((k) => !KNOWN_JOB_FIELDS.has(k));
Expand Down Expand Up @@ -674,6 +674,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJob
if (typeof body.effort === "string") config.effort = body.effort;
if (typeof body.thinking === "string") config.thinking = body.thinking;
if (body.fastMode === true) config.fastMode = true;
if (typeof body.responseLanguage === "string") config.responseLanguage = body.responseLanguage;
if (typeof body.reviewProfileId === "string") config.reviewProfileId = body.reviewProfileId;
if (typeof body.repairOf === "string") config.repairOf = body.repairOf;
const built = await options.buildCommand(provider, Object.keys(config).length > 0 ? config : undefined);
Expand Down
14 changes: 14 additions & 0 deletions packages/server/agent-review-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ export type AgentReviewTarget =
workspace: WorkspaceReviewPromptContext;
};

/**
* Instruction appended to agent-job prompts (review / guide / tour) when the
* user picked a response language in settings. `responseLanguage` comes from
* the client job-launch body, so it's validated here.
*/
export function agentJobLanguageInstruction(responseLanguage: unknown): string | null {
const lang = typeof responseLanguage === "string" ? responseLanguage.trim() : "";
if (!lang) return null;
return (
`IMPORTANT: Write all natural-language output (titles, overviews, summaries, findings, comments) in ${lang}. ` +
"Keep file paths, code identifiers, and JSON/schema keys as-is."
);
}

export interface LocalDiffInstruction {
target: string;
inspect: string;
Expand Down
26 changes: 25 additions & 1 deletion packages/server/guide/guide-review.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test";
import { repairGuideJsonText, validateGuideOutput, parseGuideStreamOutput } from "./guide-review";
import { repairGuideJsonText, validateGuideOutput, parseGuideStreamOutput, createGuideSession } from "./guide-review";

// Pins the behaviors the PR-993 review rounds fixed. This module previously
// had NO direct coverage — the repair ladder and validation are pure logic
Expand Down Expand Up @@ -150,3 +150,27 @@ describe("parseGuideStreamOutput", () => {
expect(parseGuideStreamOutput("")).toBeNull();
});
});

describe("guide buildCommand response language", () => {
const baseOpts = {
cwd: "/tmp",
patch: "diff --git a/src/a.ts b/src/a.ts\n+x",
diffType: "uncommitted" as const,
changedFiles: [{ path: "src/a.ts", additions: 1, deletions: 0 }],
};

it("prepends the language instruction to the user message when responseLanguage is set", async () => {
const session = createGuideSession();
const built = await session.buildCommand({
...baseOpts,
config: { engine: "claude", responseLanguage: "Korean" },
});
expect(built.prompt).toContain("in Korean");
});

it("leaves the prompt unchanged when responseLanguage is absent", async () => {
const session = createGuideSession();
const built = await session.buildCommand({ ...baseOpts, config: { engine: "claude" } });
expect(built.prompt).not.toContain("in Korean");
});
});
8 changes: 6 additions & 2 deletions packages/server/guide/guide-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mkdir, writeFile, readFile, unlink } from "node:fs/promises";
import { getPlannotatorDataDir } from "@plannotator/shared/data-dir";
import type { DiffType } from "../vcs";
import type { PRMetadata } from "../pr";
import { buildWorkspacePromptContextLines, getLocalDiffInstruction, type WorkspaceReviewPromptContext } from "../agent-review-message";
import { agentJobLanguageInstruction, buildWorkspacePromptContextLines, getLocalDiffInstruction, type WorkspaceReviewPromptContext } from "../agent-review-message";
import {
MARKER_ENGINES,
makeMarkerNonce,
Expand Down Expand Up @@ -1142,7 +1142,11 @@ export function createGuideSession(): GuideSession {
return { command, stdinPrompt, prompt: repairPrompt, cwd, label: "Guide Repair", captureStdout: true, engine: "claude", model, effort: "low" };
}

const userMessage = buildGuideUserMessage(patch, diffType, options, prMetadata, changedFiles);
const languageInstruction = agentJobLanguageInstruction(config?.responseLanguage);
const baseUserMessage = buildGuideUserMessage(patch, diffType, options, prMetadata, changedFiles);
const userMessage = languageInstruction
? `${languageInstruction}\n\n${baseUserMessage}`
: baseUserMessage;

// Marker engines (Cursor, OpenCode, Pi) — none has a schema flag, so the
// guide contract's marker-delimited JSON block (composeGuideMarkerPrompt)
Expand Down
8 changes: 6 additions & 2 deletions packages/server/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import {
parseCodexOutput,
transformReviewFindings,
} from "./codex-review";
import { buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "./agent-review-message";
import { agentJobLanguageInstruction, buildAgentReviewUserMessage, buildAgentReviewUserMessageForTarget, type WorkspaceReviewPromptContext } from "./agent-review-message";
import {
composeClaudeReviewPrompt,
buildClaudeCommand,
Expand Down Expand Up @@ -958,13 +958,17 @@ export async function startReviewServer(
// prompt; strip the default framing prose from the user message so only the
// git/PR context remains. The default review keeps today's message verbatim.
const isCustomReview = reviewProfile.source === "user";
const userMessage = workspacePrompt
const baseUserMessage = workspacePrompt
? buildAgentReviewUserMessageForTarget({
kind: "workspace",
patch: launchPatch,
workspace: workspacePrompt,
}, isCustomReview)
: buildAgentReviewUserMessage(launchPatch, launchDiffType as DiffType, userMessageOptions, launchMetadata, isCustomReview);
const languageInstruction = agentJobLanguageInstruction(config?.responseLanguage);
const userMessage = languageInstruction
? `${languageInstruction}\n\n${baseUserMessage}`
: baseUserMessage;
const jobLabel = workspacePrompt ? "Workspace Review" : "Code Review";

if (provider === "codex") {
Expand Down
8 changes: 6 additions & 2 deletions packages/server/tour/tour-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mkdir, writeFile, readFile, unlink } from "node:fs/promises";
import { getPlannotatorDataDir } from "@plannotator/shared/data-dir";
import type { DiffType } from "../vcs";
import type { PRMetadata } from "../pr";
import { buildWorkspacePromptContextLines, getLocalDiffInstruction, type WorkspaceReviewPromptContext } from "../agent-review-message";
import { agentJobLanguageInstruction, buildWorkspacePromptContextLines, getLocalDiffInstruction, type WorkspaceReviewPromptContext } from "../agent-review-message";
import type {
CodeTourOutput,
TourDiffAnchor,
Expand Down Expand Up @@ -566,7 +566,11 @@ export function createTourSession(): TourSession {
const reasoningEffort = typeof config?.reasoningEffort === "string" && config.reasoningEffort ? config.reasoningEffort : undefined;
const effort = typeof config?.effort === "string" && config.effort ? config.effort : undefined;
const fastMode = config?.fastMode === true;
const userMessage = buildTourUserMessage(patch, diffType, options, prMetadata);
const languageInstruction = agentJobLanguageInstruction(config?.responseLanguage);
const baseUserMessage = buildTourUserMessage(patch, diffType, options, prMetadata);
const userMessage = languageInstruction
? `${languageInstruction}\n\n${baseUserMessage}`
: baseUserMessage;
const prompt = TOUR_REVIEW_PROMPT + "\n\n---\n\n" + userMessage;

if (engine === "codex") {
Expand Down
58 changes: 58 additions & 0 deletions packages/ui/components/AISettingsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type React from 'react';
import { getProviderMeta } from './ProviderIcons';
import {
AI_RESPONSE_LANGUAGES,
getAIProviderSettings,
getAIResponseLanguage,
resolveAIModelForProvider,
resolveAIProviderSelection,
saveAIProviderSelection,
savePreferredModel,
setAIResponseLanguage,
type AIProviderOption,
} from '../utils/aiProvider';
import { useState } from 'react';
Expand Down Expand Up @@ -47,6 +50,59 @@ export const AISettingsTab: React.FC<AISettingsTabProps> = ({
setPreferredModels(prev => ({ ...prev, [providerId]: modelId }));
};

const [responseLanguage, setResponseLanguage] = useState<string>(() => getAIResponseLanguage() ?? '');
const isPresetLanguage = (value: string) =>
value === '' || AI_RESPONSE_LANGUAGES.some(l => l.value === value);
const [customLanguage, setCustomLanguage] = useState<boolean>(() => !isPresetLanguage(getAIResponseLanguage() ?? ''));

const handleLanguageSelect = (value: string) => {
if (value === '__custom__') {
setCustomLanguage(true);
return;
}
setCustomLanguage(false);
setResponseLanguage(value);
setAIResponseLanguage(value || null);
};

const handleCustomLanguageChange = (value: string) => {
setResponseLanguage(value);
setAIResponseLanguage(value.trim() || null);
};

const languageSection = (
<>
<div className="pt-2">
<div className="text-sm font-medium">Response Language</div>
<div className="text-xs text-muted-foreground">
Language the AI should respond in. Applies from your next question.
</div>
</div>
<div className="space-y-2">
<select
value={customLanguage ? '__custom__' : responseLanguage}
onChange={(e) => handleLanguageSelect(e.target.value)}
className="w-full text-xs bg-muted rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
>
<option value="">Auto (default)</option>
{AI_RESPONSE_LANGUAGES.map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
<option value="__custom__">Custom…</option>
</select>
{customLanguage && (
<input
type="text"
value={responseLanguage}
onChange={(e) => handleCustomLanguageChange(e.target.value)}
placeholder='Language name in English, e.g. "Brazilian Portuguese"'
className="w-full text-xs bg-muted rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
)}
</div>
</>
);

if (providers.length === 0) {
return (
<>
Expand Down Expand Up @@ -140,6 +196,8 @@ export const AISettingsTab: React.FC<AISettingsTabProps> = ({
Providers are detected from installed CLI tools. No API keys are managed by Plannotator — you must be authenticated with each CLI independently.{' '}
<a href="https://plannotator.ai/docs/guides/ai-features/" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Learn more</a>
</div>

{languageSection}
</>
);
};
Loading