diff --git a/.changeset/fair-languages-run.md b/.changeset/fair-languages-run.md new file mode 100644 index 00000000..fb890e3f --- /dev/null +++ b/.changeset/fair-languages-run.md @@ -0,0 +1,5 @@ +--- +"openwiki": patch +--- + +fix: run clean updates when the requested output language changes diff --git a/src/agent/index.ts b/src/agent/index.ts index 59dd3ff4..10726863 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -160,7 +160,11 @@ export async function runOpenWikiAgent( ); if (command === "update" && shouldCheckUpdateNoop(options)) { - const noopStatus = await getUpdateNoopStatus(cwd, openWikiIgnore); + const noopStatus = await getUpdateNoopStatus( + cwd, + openWikiIgnore, + options.language, + ); if (noopStatus.shouldSkip) { const message = diff --git a/src/agent/translation-middleware.ts b/src/agent/translation-middleware.ts index 574c2801..4195ba27 100644 --- a/src/agent/translation-middleware.ts +++ b/src/agent/translation-middleware.ts @@ -5,6 +5,7 @@ import type { BackendProtocolV2, FileInfo } from "deepagents"; import { createMiddleware } from "langchain"; import path from "node:path"; import { getErrorMessage } from "../diagnostics.js"; +import { getPrimaryLanguageSubtag } from "../language.js"; import { OPENWIKI_TRANSLATION_PENDING_FIELD, readFrontmatterField, @@ -102,23 +103,11 @@ export function resolveTranslationPlan( source, translateAll: requestedLanguage !== undefined && - primarySubtag(requestedLanguage) !== primarySubtag(currentWikiLanguage), + getPrimaryLanguageSubtag(requestedLanguage) !== + getPrimaryLanguageSubtag(currentWikiLanguage), }; } -/** - * Returns a language tag's primary subtag (for example `zh` for `zh-CN`), - * treating an absent wiki language as English. - */ -function primarySubtag(tag: string | undefined): string { - if (!tag) return "en"; - try { - return new Intl.Locale(tag).language; - } catch { - return tag; - } -} - /** * Creates middleware that brings every existing wiki page into the run's target * language before the agent runs. diff --git a/src/agent/utils.ts b/src/agent/utils.ts index 3b372048..42436ef0 100644 --- a/src/agent/utils.ts +++ b/src/agent/utils.ts @@ -8,7 +8,7 @@ import { isExpectedSnapshotRaceError, isFileNotFoundError, } from "../fs-errors.js"; -import { resolveLanguage } from "../language.js"; +import { getPrimaryLanguageSubtag, resolveLanguage } from "../language.js"; import { readOpenWikiOnboardingConfig, readRepositoryWikiInstructions, @@ -112,6 +112,10 @@ async function readRunWikiGoal( /** * Decides whether an `update` run can be skipped because nothing meaningful changed. * + * An explicit request whose primary language differs from the persisted wiki + * language is meaningful even on a clean tree, because the translation pass + * must run before the update agent. + * * Working-tree and committed changes that only touch `openwiki/` or paths * excluded by `openWikiIgnore` do not count as meaningful, so an ignored path * changing on its own never forces a rebuild. @@ -119,6 +123,7 @@ async function readRunWikiGoal( export async function getUpdateNoopStatus( cwd: string, openWikiIgnore = new OpenWikiIgnore([]), + requestedLanguage?: string | null, ): Promise { const lastUpdate = await readLastUpdate(cwd, "repository"); @@ -130,6 +135,15 @@ export async function getUpdateNoopStatus( return { shouldSkip: false, reason: "previous update was interrupted" }; } + const resolvedRequestedLanguage = resolveLanguage(requestedLanguage).language; + if ( + resolvedRequestedLanguage !== undefined && + getPrimaryLanguageSubtag(resolvedRequestedLanguage) !== + getPrimaryLanguageSubtag(lastUpdate.language) + ) { + return { shouldSkip: false, reason: "output language changed" }; + } + const head = await getGitHead(cwd); if (!head) { diff --git a/src/language.ts b/src/language.ts index 897df165..a4e9d784 100644 --- a/src/language.ts +++ b/src/language.ts @@ -58,3 +58,20 @@ export function resolveLanguage( warning: `Unrecognized language "${trimmed}"; generating in English. Use a BCP-47 code such as zh-CN, hi, or pt-BR.`, }; } + +/** + * Returns a language tag's primary subtag (for example `zh` for `zh-CN`), + * treating an absent tag as English. Malformed persisted values are returned as + * written so they cannot accidentally compare equal to a valid requested tag. + */ +export function getPrimaryLanguageSubtag( + tag: string | null | undefined, +): string { + if (!tag) return "en"; + + try { + return new Intl.Locale(tag).language; + } catch { + return tag; + } +} diff --git a/src/startup.ts b/src/startup.ts index 909650db..3cb22ca2 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -132,7 +132,11 @@ async function canSkipCleanUpdateBeforeCredentials( } try { - const noopStatus = await getUpdateNoopStatus(cwd); + const noopStatus = await getUpdateNoopStatus( + cwd, + undefined, + command.language, + ); return noopStatus.shouldSkip; } catch { diff --git a/test/language.test.ts b/test/language.test.ts index 3964bdf0..f18567ae 100644 --- a/test/language.test.ts +++ b/test/language.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { resolveLanguage } from "../src/language.ts"; +import { getPrimaryLanguageSubtag, resolveLanguage } from "../src/language.ts"; describe("resolveLanguage", () => { test("canonicalizes recognized BCP-47 codes", () => { @@ -31,3 +31,19 @@ describe("resolveLanguage", () => { } }); }); + +describe("getPrimaryLanguageSubtag", () => { + test("compares language variants by their primary subtag", () => { + expect(getPrimaryLanguageSubtag("en-GB")).toBe("en"); + expect(getPrimaryLanguageSubtag("zh-CN")).toBe("zh"); + }); + + test("treats an absent persisted language as English", () => { + expect(getPrimaryLanguageSubtag(undefined)).toBe("en"); + expect(getPrimaryLanguageSubtag(null)).toBe("en"); + }); + + test("preserves malformed persisted values for a safe mismatch", () => { + expect(getPrimaryLanguageSubtag("not_a_locale")).toBe("not_a_locale"); + }); +}); diff --git a/test/startup.test.ts b/test/startup.test.ts index 5a1dd16a..40546f05 100644 --- a/test/startup.test.ts +++ b/test/startup.test.ts @@ -173,6 +173,25 @@ describe("resolveStartupCommand", () => { expect(result).toBe(command); }); + test("requires credentials when a clean update requests a language change", async () => { + const repo = await createRepoWithOpenWiki(); + const head = await git(repo, ["rev-parse", "HEAD"]); + await writeLastUpdate(repo, head); + + const result = await resolveStartupCommand( + updatePrintCommand({ language: "fr" }), + { + cwd: repo, + isStdinTTY: false, + }, + ); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toContain("OPENROUTER_API_KEY is required"); + } + }); + test("still requires credentials when update --print has source changes", async () => { const repo = await createRepoWithOpenWiki(); const head = await git(repo, ["rev-parse", "HEAD"]); diff --git a/test/update-noop.test.ts b/test/update-noop.test.ts index f4dd8fa1..8939eba2 100644 --- a/test/update-noop.test.ts +++ b/test/update-noop.test.ts @@ -63,6 +63,29 @@ describe("getUpdateNoopStatus", () => { expect(status.shouldSkip).toBe(true); }); + test("does not skip a clean update that requests a different language", async () => { + const repo = await createRepoWithOpenWiki(); + const head = await git(repo, ["rev-parse", "HEAD"]); + await writeLastUpdate(repo, head, { language: "en" }); + + const status = await getUpdateNoopStatus(repo, undefined, "fr"); + + expect(status).toEqual({ + shouldSkip: false, + reason: "output language changed", + }); + }); + + test("still skips an equivalent primary-language request", async () => { + const repo = await createRepoWithOpenWiki(); + const head = await git(repo, ["rev-parse", "HEAD"]); + await writeLastUpdate(repo, head, { language: "en" }); + + const status = await getUpdateNoopStatus(repo, undefined, "en-GB"); + + expect(status.shouldSkip).toBe(true); + }); + test("detects a no-op when only the committed run metadata is dirty", async () => { // A committed wiki leaves openwiki/.last-update.json tracked, so the next // run sees it as an unstaged modification: " M openwiki/.last-update.json".