fix(docs-i18n): centralize translation safeguards

This commit is contained in:
Peter Steinberger
2026-07-14 03:47:37 -04:00
parent 489690fa16
commit 1d4188138e
10 changed files with 241 additions and 28 deletions
+50
View File
@@ -9,6 +9,11 @@ import (
"slices"
"strconv"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
textpkg "github.com/yuin/goldmark/text"
)
const defaultDocChunkMaxBytes = 12000
@@ -31,6 +36,8 @@ type docChunkStructure struct {
headingLevels []int
listShapes []markdownListShape
inlineCodeSpans []string
linkDestinations []string
numericValues []string
fencedPlaceholders []string
fencedProtocolTokens []string
fencedDirectiveTokens []string
@@ -97,6 +104,12 @@ func validateDocBodyFencedLiterals(source, translated string) error {
if !slices.Equal(sourceStructure.fencedDirectiveTokens, translatedStructure.fencedDirectiveTokens) {
return fmt.Errorf("fenced directive mismatch: source=%d translated=%d", len(sourceStructure.fencedDirectiveTokens), len(translatedStructure.fencedDirectiveTokens))
}
if !sameStringMultiset(sourceStructure.linkDestinations, translatedStructure.linkDestinations) {
return fmt.Errorf("link destination mismatch: source=%d translated=%d", len(sourceStructure.linkDestinations), len(translatedStructure.linkDestinations))
}
if !sameStringMultiset(sourceStructure.numericValues, translatedStructure.numericValues) {
return fmt.Errorf("numeric value mismatch: source=%d translated=%d", len(sourceStructure.numericValues), len(translatedStructure.numericValues))
}
return nil
}
@@ -280,6 +293,12 @@ func validateDocChunkTranslation(source, translated string) error {
if !slices.Equal(sourceStructure.fencedDirectiveTokens, translatedStructure.fencedDirectiveTokens) {
return fmt.Errorf("fenced directive mismatch: source=%d translated=%d", len(sourceStructure.fencedDirectiveTokens), len(translatedStructure.fencedDirectiveTokens))
}
if !sameStringMultiset(sourceStructure.linkDestinations, translatedStructure.linkDestinations) {
return fmt.Errorf("link destination mismatch: source=%d translated=%d", len(sourceStructure.linkDestinations), len(translatedStructure.linkDestinations))
}
if !sameStringMultiset(sourceStructure.numericValues, translatedStructure.numericValues) {
return fmt.Errorf("numeric value mismatch: source=%d translated=%d", len(sourceStructure.numericValues), len(translatedStructure.numericValues))
}
if !slices.Equal(sortedKeys(sourceStructure.tagCounts), sortedKeys(translatedStructure.tagCounts)) {
return fmt.Errorf("component tag set mismatch")
}
@@ -440,12 +459,43 @@ func summarizeDocChunkStructure(text string) docChunkStructure {
headingLevels: extractMarkdownHeadingLevels(text),
listShapes: extractMarkdownListShapes(text),
inlineCodeSpans: extractMarkdownInlineCodeValues(text),
linkDestinations: extractMarkdownLinkDestinations(text),
numericValues: extractNumericValues(text),
fencedPlaceholders: fencedPlaceholders,
fencedProtocolTokens: fencedProtocolTokens,
fencedDirectiveTokens: fencedDirectiveTokens,
}
}
func extractMarkdownLinkDestinations(text string) []string {
// Validate inline Markdown destinations only. Reference-style links and GFM bare-autolink
// boundaries remain governed by the shared structural and exact-URL prompt rules.
source := []byte(normalizeDocComponentsForMarkdownParse(text))
doc := parseDocsMarkdown(source)
destinations := make([]string, 0)
_ = ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch link := node.(type) {
case *ast.Link:
if link.Reference == nil {
destinations = append(destinations, "link:"+string(link.Destination))
}
case *ast.Image:
if link.Reference == nil {
destinations = append(destinations, "image:"+string(link.Destination))
}
}
return ast.WalkContinue, nil
})
return destinations
}
func parseDocsMarkdown(source []byte) ast.Node {
return goldmark.New(goldmark.WithExtensions(extension.GFM, extension.Footnote)).Parser().Parse(textpkg.NewReader(source))
}
func countsWithoutFence(counts map[string]int) map[string]int {
filtered := map[string]int{}
for key, value := range counts {
+84 -2
View File
@@ -2325,8 +2325,8 @@ func TestProcessFileDocUsesFieldLevelFrontmatterTranslation(t *testing.T) {
if !strings.Contains(text, "在 Fly.io 上部署 OpenClaw") {
t.Fatalf("expected translated read_when entry in output:\n%s", text)
}
if !strings.Contains(text, "prompt_version: 24") {
t.Fatalf("expected prompt version 24 in output metadata:\n%s", text)
if !strings.Contains(text, "prompt_version: 25") {
t.Fatalf("expected prompt version 25 in output metadata:\n%s", text)
}
}
@@ -2375,3 +2375,85 @@ func TestProcessFileDocRejectsSuspiciousFrontmatterScalarExpansion(t *testing.T)
t.Fatalf("expected read_when translation to survive fallback:\n%s", text)
}
}
func TestValidateDocChunkTranslationRejectsChangedCompositeLiteral(t *testing.T) {
t.Parallel()
tests := [][2]string{
{"Supports 1:1 conversations.\n", "आमने-सामने की बातचीत को सपोर्ट करता है।\n"},
{"Use mask 0xFF.\n", "Use mask 0xAA.\n"},
{"Use 1e-3.\n", "Use 1e -3.\n"},
}
for _, pair := range tests {
err := validateDocChunkTranslation(pair[0], pair[1])
if err == nil || !strings.Contains(err.Error(), "numeric value mismatch") {
t.Fatalf("expected composite-literal mismatch, got %v", err)
}
}
}
func TestValidateDocBodyRejectsChangedCompositeLiteral(t *testing.T) {
t.Parallel()
err := validateDocBodyFencedLiterals("Supports 1:1 conversations.\n", "आमने-सामने की बातचीत को सपोर्ट करता है।\n")
if err == nil || !strings.Contains(err.Error(), "numeric value mismatch") {
t.Fatalf("expected final-document numeric mismatch, got %v", err)
}
}
func TestExtractNumericValuesKeepsLowAmbiguityComposites(t *testing.T) {
t.Parallel()
got := strings.Join(extractNumericValues("0xFF 0b101 0o755 1.5:1 1e-3 v1.2.3"), ",")
if want := "0xFF,0b101,0o755,1.5:1,1e-3"; got != want {
t.Fatalf("unexpected composite literals: got=%q want=%q", got, want)
}
if err := validateDocChunkTranslation("Supports 1:1 conversations.\n", "Unterstützt 1:1-Unterhaltungen.\n"); err != nil {
t.Fatalf("expected locale compound after exact ratio to pass: %v", err)
}
}
func TestValidateDocChunkTranslationRejectsDroppedDuplicateLink(t *testing.T) {
t.Parallel()
source := "Deploy on [Render](https://render.com), then open [account](https://render.com).\n"
translated := "Auf [Render](https://render.com) bereitstellen, dann das Konto öffnen.\n"
err := validateDocChunkTranslation(source, translated)
if err == nil || !strings.Contains(err.Error(), "link destination mismatch") {
t.Fatalf("expected duplicate-link mismatch, got %v", err)
}
}
func TestValidateDocBodyRejectsDroppedLinkMarkup(t *testing.T) {
t.Parallel()
err := validateDocBodyFencedLiterals("Read [guide](/setup).\n", "Lesen Sie guide](/setup).\n")
if err == nil || !strings.Contains(err.Error(), "link destination mismatch") {
t.Fatalf("expected final-document link mismatch, got %v", err)
}
}
func TestExtractMarkdownLinkDestinationsUsesParsedNodes(t *testing.T) {
t.Parallel()
source := "[docs](https://host/a_(b)) [guide](/setup \"Setup guide\") [angle](<https://host/a b>)"
got := strings.Join(extractMarkdownLinkDestinations(source), ",")
want := "link:https://host/a_(b),link:/setup,link:https://host/a b"
if got != want {
t.Fatalf("unexpected parsed destinations: got=%q want=%q", got, want)
}
if got := extractMarkdownLinkDestinations("`[inline](https://example.com)`"); len(got) != 0 {
t.Fatalf("expected code-context link to be ignored, got %q", got)
}
}
func TestValidateDocChunkTranslationChecksLinkInsideMDX(t *testing.T) {
t.Parallel()
source := "<Card>\nRead [guide](/setup).\n</Card>\n"
translated := "<Card>\nLesen Sie guide](/setup).\n</Card>\n"
err := validateDocChunkTranslation(source, translated)
if err == nil || !strings.Contains(err.Error(), "link destination mismatch") {
t.Fatalf("expected MDX-contained link mismatch, got %v", err)
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ func main() {
docsRoot = flag.String("docs", "docs", "docs root")
tmPath = flag.String("tm", "", "translation memory path")
mode = flag.String("mode", "segment", "translation mode (segment|doc)")
thinking = flag.String("thinking", "xhigh", "thinking level (low|medium|high|xhigh)")
thinking = flag.String("thinking", "xhigh", "thinking level (low|medium|high|xhigh|max)")
overwrite = flag.Bool("overwrite", false, "overwrite existing translations")
allowPartial = flag.Bool("allow-partial", false, "write successful doc-mode outputs even when another file fails")
maxFiles = flag.Int("max", 0, "max files to process (0 = all)")
+54
View File
@@ -13,6 +13,9 @@ var (
linkURLRe = regexp.MustCompile(`\[[^\]]*\]\(([^)]+)\)`)
placeholderRe = regexp.MustCompile(`__OC_I18N_\d+__`)
listMarkerRe = regexp.MustCompile(`^([ \t]*(?:>[ \t]*)*)([-+*]|[0-9]+[.)])([ \t]+)`)
// Hard validation stays limited to low-ambiguity composite literals. Plain numbers remain
// model-visible so target-language plurals and ordinals can change grammar without false failures.
numericValueRe = regexp.MustCompile(`(?:0[xX][0-9A-Za-z_]+|0[bB][0-9A-Za-z_]+|0[oO][0-9A-Za-z_]+|[0-9]+(?:\.[0-9]+)?(?::[0-9]+(?:\.[0-9]+)?)+|(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)[eE][+-]?[0-9]+)`)
)
func maskMarkdown(text string, nextPlaceholder func() string, placeholders *[]string, mapping map[string]string) string {
@@ -113,6 +116,57 @@ func maskMarkdownDocSyntax(text string, nextPlaceholder func() string, placehold
return maskByteRanges(masked, listRanges, nextPlaceholder, placeholders, mapping)
}
func extractNumericValues(text string) []string {
protocolRanges := make([][2]int, 0)
for _, span := range placeholderRe.FindAllStringIndex(text, -1) {
protocolRanges = append(protocolRanges, [2]int{span[0], span[1]})
}
values := make([]string, 0)
for _, span := range numericValueRe.FindAllStringIndex(text, -1) {
candidate := [2]int{span[0], span[1]}
if hasCompositeNumericLeadingContinuation(text, candidate[0]) || hasCompositeNumericContinuation(text, candidate[1]) || rangeOverlapsAny(candidate, protocolRanges) {
continue
}
values = append(values, text[span[0]:span[1]])
}
return values
}
func hasCompositeNumericLeadingContinuation(text string, position int) bool {
if position == 0 {
return false
}
value := text[position-1]
if value == '_' {
for position > 0 && text[position-1] == '_' {
position--
}
return position > 0 && isCompositeNumericWordByte(text[position-1])
}
return value == '.' || value == '-' || isCompositeNumericWordByte(value)
}
func hasCompositeNumericContinuation(text string, position int) bool {
if position >= len(text) {
return false
}
value := text[position]
if value == '_' {
for position < len(text) && text[position] == '_' {
position++
}
return position < len(text) && isCompositeNumericWordByte(text[position])
}
if isCompositeNumericWordByte(value) {
return true
}
return value == '.' && position+1 < len(text) && isCompositeNumericWordByte(text[position+1])
}
func isCompositeNumericWordByte(value byte) bool {
return value >= '0' && value <= '9' || value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z'
}
func markdownLiteralFenceByteRanges(text string) [][2]int {
return markdownLiteralFenceByteRangesWithMode(text, true)
}
+21 -20
View File
@@ -57,54 +57,54 @@ func translationPrompt(srcLang, tgtLang string, glossary []GlossaryEntry) string
var localeRules = map[string]string{
"zh-cn": `Locale rules:
- Write fluent Simplified Chinese using mainland technical terminology and simplified characters. Use neutral documentation tone with “你/你的”, not “您/您的”.
- Use Simplified Chinese, mainland technical terminology, and simplified characters. Use “你/你的”, not “您/您的”.
- Insert a space between Latin characters or digits and Chinese text when natural under W3C CLREQ. Use Chinese quotation marks “ and ” for Chinese prose; keep ASCII quotes in protected literals.
- Fixed terminology: “Gateway” is “Gateway 网关”; keep “Skills”, “local loopback”, and “Tailscale” in English.`,
"zh-tw": `Locale rules:
- Write fluent Traditional Chinese using Taiwan terminology and traditional characters; do not emit Simplified Chinese forms. Use neutral documentation tone with “你/你的”.
- Use Traditional Chinese, Taiwan terminology, and traditional characters; do not emit Simplified Chinese forms. Use “你/你的”.
- Insert a space between Latin characters or digits and Chinese text when natural. Use Chinese quotation marks “ and ” for Chinese prose; keep ASCII quotes in protected literals.
- Keep security concepts distinct: translate “credentials” as “認證資訊”, not “憑證”; reserve “憑證” for certificates.`,
"ja-jp": `Locale rules:
- Write fluent technical Japanese in a neutral documentation tone. Avoid excessively formal honorifics such as “〜でございます”.
- Avoid excessively formal Japanese honorifics such as “〜でございます”.
- Use Japanese quotation marks 「 and 」 for Japanese prose. Do not add or remove spacing around Latin text merely because it borders Japanese; change spacing only when Japanese grammar requires it.
- Keep “Skills”, “local loopback”, and “Tailscale” in English.`,
"es": `Locale rules:
- Write neutral international Spanish and avoid region-specific colloquialisms. Prefer impersonal documentation phrasing; do not mix “tú”, “usted”, and “vos” forms within a page.`,
- Use international Spanish, avoid region-specific colloquialisms, and prefer impersonal documentation phrasing.`,
"pt-br": `Locale rules:
- Write Brazilian Portuguese, not European Portuguese. Use neutral Brazilian technical terminology and keep forms of address consistent within a page.`,
- Use Brazilian Portuguese, not European Portuguese, and Brazilian technical terminology.`,
"ko": `Locale rules:
- Write standard Korean technical documentation in a consistent formal-polite style using 합니다/하십시오 forms. Avoid mixing speech levels within a page.`,
- Use formal-polite Korean with 합니다/하십시오 forms.`,
"de": `Locale rules:
- Use formal address consistently: “Sie/Ihr/Ihnen”. Avoid informal “du/dein/dir”.
- Use formal address: “Sie/Ihr/Ihnen”. Avoid informal “du/dein/dir”.
- Use established technical German; keep “Provider” where it is clearer than “Anbieter”, and avoid awkward mixed compounds.`,
"fr": `Locale rules:
- Write neutral technical French. Use “vous/votre” consistently and avoid informal “tu/ton”. Use established French technical terminology without forced translations of protected product terms.`,
- Use “vous/votre” and avoid informal “tu/ton”.`,
"hi": `Locale rules:
- Write standard modern Hindi in Devanagari. Use “आप/आपका” consistently and avoid unnecessary transliterated English outside protected terms.`,
- Use modern Hindi in Devanagari and “आप/आपका” for direct address.`,
"ar": `Locale rules:
- Write clear Modern Standard Arabic in a neutral technical tone. Keep prose naturally right-to-left without reordering or altering left-to-right code, commands, URLs, placeholders, or product names.`,
- Use Modern Standard Arabic. Keep prose naturally right-to-left without reordering or altering left-to-right code, commands, URLs, placeholders, or product names.`,
"it": `Locale rules:
- Write neutral technical Italian. Prefer impersonal instructional phrasing and do not mix informal “tu” with formal “Lei” within a page.`,
- Prefer impersonal Italian instructional phrasing.`,
"vi": `Locale rules:
- Write standard Vietnamese in a neutral technical tone. Use “bạn” consistently when direct address is necessary and avoid unnecessary English outside protected terms.`,
- Use “bạn” when direct address is necessary.`,
"nl": `Locale rules:
- Write standard Dutch in a concise, neutral technical tone. Use informal “je/jouw” consistently for direct address; do not switch to formal “u/uw” except inside protected literal quotations. Avoid unnecessary English outside protected terms.`,
- Use informal “je/jouw” for direct address; do not switch to formal “u/uw” except inside protected literal quotations.`,
"fa": `Locale rules:
- Write standard Iranian Persian in a neutral technical tone. Use Persian ی and ک rather than Arabic ي and ك, and use standard Persian half-spaces where required.
- Use Iranian Persian, Persian ی and ک rather than Arabic ي and ك, and standard Persian half-spaces where required.
- Keep prose naturally right-to-left without reordering or altering left-to-right code, commands, URLs, placeholders, or product names.`,
"ru": `Locale rules:
- Write standard Russian in a neutral technical style. Prefer established Russian technical terminology and avoid unnecessary English outside protected terms.
- Use established Russian technical terminology.
- Translate the generic noun “plugin” as “плагин”; inflect it for Russian case and number, and capitalize it when normal Russian syntax requires. Never force English “Plugin” into ordinary prose. Preserve it only inside protected code or identifiers, or when a higher-precedence literal label rule applies.`,
"tr": `Locale rules:
- Write standard Turkish in a concise, neutral technical tone. Preserve Turkish dotted and dotless I correctly and avoid unnecessary English outside protected terms.`,
- Preserve Turkish dotted and dotless I correctly.`,
"uk": `Locale rules:
- Write standard Ukrainian in a neutral technical style. Use established Ukrainian terminology rather than Russian calques and avoid unnecessary English outside protected terms.`,
- Use established Ukrainian terminology rather than Russian calques.`,
"id": `Locale rules:
- Write standard Indonesian in a neutral technical tone. Use “Anda” consistently when direct address is necessary and avoid unnecessary English outside protected terms.`,
- Use “Anda” when direct address is necessary.`,
"pl": `Locale rules:
- Write standard Polish in a neutral technical style. Prefer impersonal instructional constructions and avoid gendered direct address when it is not required.`,
- Prefer impersonal Polish instructional constructions and avoid gendered direct address when it is not required.`,
"th": `Locale rules:
- Write standard Thai in a neutral technical tone. Do not insert spaces between every Thai word; use spacing around Latin text, digits, and protected terms only where natural in Thai.`,
- Do not insert spaces between every Thai word; use spacing around Latin text, digits, and protected terms only where natural in Thai.`,
}
func localePromptRules(tgtLang string) string {
@@ -118,6 +118,7 @@ const documentationQualityRules = `Documentation quality rules:
- Label precedence, highest to lowest: literal third-party UI text; locale-specific fixed terminology stated in this prompt; supplied glossary mappings; normal translation. A higher rule overrides every lower rule and the general instructions to translate all prose, headings, and labels. OpenClaw-owned UI and documentation labels use the highest applicable fixed term or glossary mapping; otherwise translate them normally.
- Preserve technical meaning over literal wording. Keep authentication, authorization, credentials, tokens, passwords, secrets, identities, and accounts distinct unless the source explicitly equates them. Preserve actors, objects, temporal order, negation, conditions, scope, singular/plural meaning, and requirement strength such as “must”, “required”, “only”, and “never”.
- Preserve every factual value exactly, including numbers, units, versions, ports, limits, durations, paths, and comparison operators. Do not add explanations, infer missing facts, soften warnings, or correct the source.
- Use one locale-appropriate register within each page. Do not mix formal, informal, honorific, or speech-level forms. Prefer impersonal documentation phrasing when the locale overlay does not specify a direct-address form.
- Use one established target-language term per concept within a page. Avoid unnecessary English except for protected literals, code, URLs, glossary-preserved terms, and product names.`
const translationPromptTemplate = `You are a translation function, not a chat assistant.
+21 -2
View File
@@ -42,6 +42,7 @@ func TestTranslationPromptUsesSharedContractAndLocaleOverlayForEverySupportedLoc
"Keep authentication, authorization, credentials, tokens, passwords, secrets, identities, and accounts distinct",
"Preserve actors, objects, temporal order, negation, conditions, scope, singular/plural meaning, and requirement strength",
"Preserve every factual value exactly, including numbers, units, versions, ports, limits, durations, paths, and comparison operators",
"Use one locale-appropriate register within each page. Do not mix formal, informal, honorific, or speech-level forms",
"Preserve Markdown list nodes exactly: ordered versus unordered kind, nesting, item count, and ordered-list starting number",
"Preserve HTML/MDX tag names, attribute names, nesting, and structural attribute values exactly",
"Fenced text, transcript, output, and documentation examples are an exception to the preceding block rule",
@@ -141,10 +142,10 @@ func TestTranslationPromptAddsRepresentativeLocaleRules(t *testing.T) {
}{
{locale: "zh-CN", wants: []string{"Simplified Chinese", "你/你的", "Gateway 网关"}},
{locale: "zh-TW", wants: []string{"Traditional Chinese", "Taiwan terminology", "do not emit Simplified Chinese forms", "translate “credentials” as “認證資訊”, not “憑證”", "reserve “憑證” for certificates"}},
{locale: "ja-JP", wants: []string{"technical Japanese", "〜でございます", "「 and 」"}},
{locale: "ja-JP", wants: []string{"Japanese honorifics", "〜でございます", "「 and 」"}},
{locale: "de", wants: []string{"Sie/Ihr/Ihnen", "du/dein/dir"}},
{locale: "pt-BR", wants: []string{"Brazilian Portuguese, not European Portuguese"}},
{locale: "nl", wants: []string{"Use informal “je/jouw” consistently", "do not switch to formal “u/uw” except inside protected literal quotations"}},
{locale: "nl", wants: []string{"Use informal “je/jouw” for direct address", "do not switch to formal “u/uw” except inside protected literal quotations"}},
{locale: "fa", wants: []string{"Persian ی and ک", "right-to-left"}},
{locale: "ru", wants: []string{"generic noun “plugin” as “плагин”", "inflect it for Russian case and number", "Never force English “Plugin” into ordinary prose"}},
{locale: "uk", wants: []string{"Ukrainian terminology rather than Russian calques"}},
@@ -164,6 +165,24 @@ func TestTranslationPromptAddsRepresentativeLocaleRules(t *testing.T) {
}
}
func TestLocaleOverlaysDoNotRepeatSharedQualityRules(t *testing.T) {
t.Parallel()
for locale, rules := range localeRules {
for _, duplicate := range []string{
"neutral technical tone",
"neutral documentation tone",
"avoid unnecessary English",
"consistent within a page",
"mixing speech levels",
} {
if strings.Contains(rules, duplicate) {
t.Errorf("locale %s repeats shared rule %q", locale, duplicate)
}
}
}
}
func TestTranslationPromptLeavesUnknownLocaleWithoutInventedOverlay(t *testing.T) {
t.Parallel()
+3
View File
@@ -349,6 +349,9 @@ func normalizeThinking(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "low", "medium", "high", "xhigh":
return strings.ToLower(strings.TrimSpace(value))
case "max":
// Codex CLI 0.144.3 supports max for GPT-5.6; xhigh remains the default for older model overrides.
return "max"
default:
return "xhigh"
}
+4 -1
View File
@@ -64,10 +64,13 @@ func TestDocsI18nCommandWaitDelayUsesEnvOverride(t *testing.T) {
}
}
func TestNormalizeThinkingDefaultsToXHigh(t *testing.T) {
func TestNormalizeThinkingDefaultsToXHighAndAcceptsMax(t *testing.T) {
if got := normalizeThinking(""); got != "xhigh" {
t.Fatalf("expected xhigh default, got %q", got)
}
if got := normalizeThinking("MAX"); got != "max" {
t.Fatalf("expected max normalization, got %q", got)
}
}
func TestIsRetryableTranslateErrorRejectsDeadlineExceeded(t *testing.T) {
+1 -1
View File
@@ -12,7 +12,7 @@ import (
const (
workflowVersion = 16
promptVersion = 24
promptVersion = 25
docsI18nEngineName = "codex"
envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER"
envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL"
+2 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"strconv"
"strings"
"testing"
)
@@ -8,7 +9,7 @@ import (
func TestCacheNamespaceIncludesPromptVersion(t *testing.T) {
t.Parallel()
if want := "prompt=24"; !strings.Contains(cacheNamespace(), want) {
if want := "prompt=" + strconv.Itoa(promptVersion); !strings.Contains(cacheNamespace(), want) {
t.Fatalf("expected cache namespace to contain %q, got %q", want, cacheNamespace())
}
}