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
5 changes: 5 additions & 0 deletions .changeset/fair-languages-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openwiki": patch
---

fix: run clean updates when the requested output language changes
6 changes: 5 additions & 1 deletion src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
17 changes: 3 additions & 14 deletions src/agent/translation-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion src/agent/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -112,13 +112,18 @@ 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.
*/
export async function getUpdateNoopStatus(
cwd: string,
openWikiIgnore = new OpenWikiIgnore([]),
requestedLanguage?: string | null,
): Promise<UpdateNoopStatus> {
const lastUpdate = await readLastUpdate(cwd, "repository");

Expand All @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions src/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
6 changes: 5 additions & 1 deletion src/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 17 additions & 1 deletion test/language.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
19 changes: 19 additions & 0 deletions test/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
23 changes: 23 additions & 0 deletions test/update-noop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down