diff --git a/scripts/docs-i18n/doc_chunked_raw.go b/scripts/docs-i18n/doc_chunked_raw.go index 07a4e4669b0..310c646610d 100644 --- a/scripts/docs-i18n/doc_chunked_raw.go +++ b/scripts/docs-i18n/doc_chunked_raw.go @@ -187,6 +187,9 @@ func validateDocChunkTranslation(source, translated string) error { if hasUnexpectedTopLevelProtocolWrapper(source, translated) { return fmt.Errorf("protocol token leaked: top-level wrapper") } + if err := validateNoTranslationTranscriptArtifacts(source, translated); err != nil { + return err + } sourceLower := strings.ToLower(source) translatedLower := strings.ToLower(translated) for _, token := range docsProtocolTokens { diff --git a/scripts/docs-i18n/doc_mode_test.go b/scripts/docs-i18n/doc_mode_test.go index db2b14f6f12..9d43aca43fc 100644 --- a/scripts/docs-i18n/doc_mode_test.go +++ b/scripts/docs-i18n/doc_mode_test.go @@ -460,6 +460,21 @@ func TestValidateDocChunkTranslationRejectsProtocolTokenLeakage(t *testing.T) { } } +func TestValidateDocChunkTranslationRejectsTranscriptArtifact(t *testing.T) { + t.Parallel() + + source := "Regular paragraph.\n\n" + translated := `Regular paragraph. assistant to=functions.read commentary {"path":"/home/runner/work/docs/docs/source/AGENTS.md"} code` + + err := validateDocChunkTranslation(source, translated) + if err == nil { + t.Fatal("expected transcript artifact to be rejected") + } + if !strings.Contains(err.Error(), "agent transcript artifact") { + t.Fatalf("expected transcript artifact error, got %v", err) + } +} + func TestValidateDocChunkTranslationRejectsTopLevelBodyWrapperLeakEvenWhenSourceMentionsBodyTag(t *testing.T) { t.Parallel() diff --git a/scripts/docs-i18n/go.mod b/scripts/docs-i18n/go.mod index 18827aea02c..d824c884394 100644 --- a/scripts/docs-i18n/go.mod +++ b/scripts/docs-i18n/go.mod @@ -1,10 +1,9 @@ module github.com/openclaw/openclaw/scripts/docs-i18n -go 1.24.0 +go 1.25.0 require ( - github.com/joshp123/pi-golang v0.0.4 - github.com/yuin/goldmark v1.7.8 - golang.org/x/net v0.50.0 + github.com/yuin/goldmark v1.8.2 + golang.org/x/net v0.53.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/scripts/docs-i18n/go.sum b/scripts/docs-i18n/go.sum index b23f1a74b6b..df5815b70c6 100644 --- a/scripts/docs-i18n/go.sum +++ b/scripts/docs-i18n/go.sum @@ -1,9 +1,7 @@ -github.com/joshp123/pi-golang v0.0.4 h1:82HISyKNN8bIl2lvAd65462LVCQIsjhaUFQxyQgg5Xk= -github.com/joshp123/pi-golang v0.0.4/go.mod h1:9mHEQkeJELYzubXU3b86/T8yedI/iAOKx0Tz0c41qes= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/scripts/docs-i18n/main.go b/scripts/docs-i18n/main.go index 1c2c09baef1..7c5fe38ca6a 100644 --- a/scripts/docs-i18n/main.go +++ b/scripts/docs-i18n/main.go @@ -67,7 +67,7 @@ func main() { maxFiles: *maxFiles, parallel: *parallel, }, files, func(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (docsTranslator, error) { - return NewPiTranslator(srcLang, tgtLang, glossary, thinking) + return NewCodexTranslator(srcLang, tgtLang, glossary, thinking) }); err != nil { fatal(err) } diff --git a/scripts/docs-i18n/main_test.go b/scripts/docs-i18n/main_test.go index 3f464e4301f..5da9f53e94c 100644 --- a/scripts/docs-i18n/main_test.go +++ b/scripts/docs-i18n/main_test.go @@ -37,6 +37,18 @@ func (invalidFrontmatterTranslator) TranslateRaw(_ context.Context, text, _, _ s func (invalidFrontmatterTranslator) Close() {} +type transcriptFrontmatterTranslator struct{} + +func (transcriptFrontmatterTranslator) Translate(_ context.Context, text, _, _ string) (string, error) { + return text + ` analysis to=functions.read {"path":"/home/runner/work/docs/docs/source/.agents/skills/openclaw-pr-maintainer/SKILL.md"} code`, nil +} + +func (transcriptFrontmatterTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) { + return text, nil +} + +func (transcriptFrontmatterTranslator) Close() {} + func TestRunDocsI18NRewritesFinalLocalizedPageLinks(t *testing.T) { t.Parallel() @@ -106,3 +118,45 @@ func TestTranslateSnippetDoesNotCacheFallbackToSource(t *testing.T) { t.Fatalf("expected fallback translation not to be cached") } } + +func TestTranslateSnippetRejectsTranscriptArtifact(t *testing.T) { + t.Parallel() + + tm := &TranslationMemory{entries: map[string]TMEntry{}} + source := "Working with reactions across channels" + + translated, err := translateSnippet(context.Background(), transcriptFrontmatterTranslator{}, tm, "tools/reactions.md:frontmatter:read_when:0", source, "en", "th") + if err != nil { + t.Fatalf("translateSnippet returned error: %v", err) + } + if translated != source { + t.Fatalf("expected fallback to source text, got %q", translated) + } + + cacheKey := cacheKey(cacheNamespace(), "en", "th", "tools/reactions.md:frontmatter:read_when:0", hashText(source)) + if _, ok := tm.Get(cacheKey); ok { + t.Fatalf("expected fallback translation not to be cached") + } +} + +func TestValidateNoTranslationTranscriptArtifacts(t *testing.T) { + t.Parallel() + + tests := []string{ + `表情回应 analysis to=functions.read {"path":"/home/runner/work/docs/docs/source/.agents/skills/openclaw-qa-testing/SKILL.md"} code`, + `กำลังทำงานกับ reactions to=functions.read commentary  ̄第四色json 皇平台`, + `คุณต้องการแผนที่เอกสาร analysis to=final code omitted`, + `Potrzebujesz listy funkcji TUI force_parallel: false} code`, + `กำลังตัดสินใจว่าจะกำหนดค่าผู้ให้บริการสื่อรายใด 全民彩票 casino`, + } + for _, translated := range tests { + if err := validateNoTranslationTranscriptArtifacts("Working with reactions across channels", translated); err == nil { + t.Fatalf("expected artifact to be rejected: %q", translated) + } + } + + source := "Document `functions.read` examples exactly." + if err := validateNoTranslationTranscriptArtifacts(source, "Document `functions.read` examples exactly."); err != nil { + t.Fatalf("expected source-owned token to be allowed: %v", err) + } +} diff --git a/scripts/docs-i18n/pi_command.go b/scripts/docs-i18n/pi_command.go deleted file mode 100644 index 27c3e13a748..00000000000 --- a/scripts/docs-i18n/pi_command.go +++ /dev/null @@ -1,130 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "time" -) - -const ( - envDocsPiExecutable = "OPENCLAW_DOCS_I18N_PI_EXECUTABLE" - envDocsPiArgs = "OPENCLAW_DOCS_I18N_PI_ARGS" - envDocsPiPackageVersion = "OPENCLAW_DOCS_I18N_PI_PACKAGE_VERSION" - envDocsPiOmitProvider = "OPENCLAW_DOCS_I18N_PI_OMIT_PROVIDER" - defaultPiPackageVersion = "0.58.3" -) - -type docsPiCommand struct { - Executable string - Args []string -} - -var ( - materializedPiRuntimeMu sync.Mutex - materializedPiRuntimeCommand docsPiCommand - materializedPiRuntimeErr error -) - -func resolveDocsPiCommand(ctx context.Context) (docsPiCommand, error) { - if executable := strings.TrimSpace(os.Getenv(envDocsPiExecutable)); executable != "" { - return docsPiCommand{ - Executable: executable, - Args: strings.Fields(os.Getenv(envDocsPiArgs)), - }, nil - } - - piPath, err := exec.LookPath("pi") - if err == nil && !shouldMaterializePiRuntime(piPath) { - return docsPiCommand{Executable: piPath}, nil - } - - return ensureMaterializedPiRuntime(ctx) -} - -func shouldMaterializePiRuntime(piPath string) bool { - realPath, err := filepath.EvalSymlinks(piPath) - if err != nil { - realPath = piPath - } - return strings.Contains(filepath.ToSlash(realPath), "/Projects/pi-mono/") -} - -func ensureMaterializedPiRuntime(ctx context.Context) (docsPiCommand, error) { - materializedPiRuntimeMu.Lock() - defer materializedPiRuntimeMu.Unlock() - - if materializedPiRuntimeErr == nil && materializedPiRuntimeCommand.Executable != "" { - return materializedPiRuntimeCommand, nil - } - - runtimeDir, err := getMaterializedPiRuntimeDir() - if err != nil { - materializedPiRuntimeErr = err - return docsPiCommand{}, err - } - cliPath := filepath.Join(runtimeDir, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js") - if _, err := os.Stat(cliPath); errors.Is(err, os.ErrNotExist) { - installCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - - if err := os.MkdirAll(runtimeDir, 0o755); err != nil { - materializedPiRuntimeErr = err - return docsPiCommand{}, err - } - - packageVersion := getMaterializedPiPackageVersion() - install := exec.CommandContext( - installCtx, - "npm", - "install", - "--silent", - "--no-audit", - "--no-fund", - fmt.Sprintf("@mariozechner/pi-coding-agent@%s", packageVersion), - ) - install.Dir = runtimeDir - install.Env = os.Environ() - output, err := install.CombinedOutput() - if err != nil { - materializedPiRuntimeErr = fmt.Errorf("materialize pi runtime: %w (%s)", err, strings.TrimSpace(string(output))) - return docsPiCommand{}, materializedPiRuntimeErr - } - } - - materializedPiRuntimeCommand = docsPiCommand{ - Executable: "node", - Args: []string{cliPath}, - } - materializedPiRuntimeErr = nil - return materializedPiRuntimeCommand, nil -} - -func getMaterializedPiRuntimeDir() (string, error) { - cacheDir, err := os.UserCacheDir() - if err != nil { - cacheDir = os.TempDir() - } - return filepath.Join(cacheDir, "openclaw", "docs-i18n", "pi-runtime", getMaterializedPiPackageVersion()), nil -} - -func getMaterializedPiPackageVersion() string { - if version := strings.TrimSpace(os.Getenv(envDocsPiPackageVersion)); version != "" { - return version - } - return defaultPiPackageVersion -} - -func docsPiOmitProvider() bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv(envDocsPiOmitProvider))) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} diff --git a/scripts/docs-i18n/pi_rpc_client.go b/scripts/docs-i18n/pi_rpc_client.go deleted file mode 100644 index 25566e51290..00000000000 --- a/scripts/docs-i18n/pi_rpc_client.go +++ /dev/null @@ -1,351 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "syscall" - "time" -) - -type docsPiClientOptions struct { - SystemPrompt string - Thinking string -} - -type docsPiClient struct { - process *exec.Cmd - stdin io.WriteCloser - stderr bytes.Buffer - events chan piEvent - promptLock sync.Mutex - closeOnce sync.Once - closed chan struct{} - requestID uint64 -} - -type piEvent struct { - Type string - Raw json.RawMessage -} - -type agentEndPayload struct { - Type string `json:"type,omitempty"` - Messages []agentMessage `json:"messages"` -} - -type rpcResponse struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - Command string `json:"command,omitempty"` - Success bool `json:"success"` - Error string `json:"error,omitempty"` -} - -type agentMessage struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - StopReason string `json:"stopReason,omitempty"` - ErrorMessage string `json:"errorMessage,omitempty"` -} - -type contentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` -} - -func startDocsPiClient(ctx context.Context, options docsPiClientOptions) (*docsPiClient, error) { - command, err := resolveDocsPiCommand(ctx) - if err != nil { - return nil, err - } - - args := append([]string{}, command.Args...) - args = append(args, "--mode", "rpc") - if provider := docsPiProviderArg(); provider != "" && !docsPiOmitProvider() { - args = append(args, "--provider", provider) - } - args = append(args, - "--model", docsPiModelRef(), - "--thinking", options.Thinking, - "--no-session", - ) - if strings.TrimSpace(options.SystemPrompt) != "" { - args = append(args, "--system-prompt", options.SystemPrompt) - } - - process := exec.Command(command.Executable, args...) - agentDir, err := resolveDocsPiAgentDir() - if err != nil { - return nil, err - } - process.Env = append(os.Environ(), fmt.Sprintf("PI_CODING_AGENT_DIR=%s", agentDir)) - stdin, err := process.StdinPipe() - if err != nil { - return nil, err - } - stdout, err := process.StdoutPipe() - if err != nil { - return nil, err - } - stderr, err := process.StderrPipe() - if err != nil { - return nil, err - } - - client := &docsPiClient{ - process: process, - stdin: stdin, - events: make(chan piEvent, 256), - closed: make(chan struct{}), - } - - if err := process.Start(); err != nil { - return nil, err - } - - go client.captureStderr(stderr) - go client.readStdout(stdout) - - return client, nil -} - -func (client *docsPiClient) Prompt(ctx context.Context, message string) (string, error) { - client.promptLock.Lock() - defer client.promptLock.Unlock() - - command := map[string]string{ - "type": "prompt", - "id": fmt.Sprintf("req-%d", atomic.AddUint64(&client.requestID, 1)), - "message": message, - } - payload, err := json.Marshal(command) - if err != nil { - return "", err - } - - if _, err := client.stdin.Write(append(payload, '\n')); err != nil { - return "", err - } - - for { - select { - case <-ctx.Done(): - return "", ctx.Err() - case <-client.closed: - return "", errors.New("pi process closed") - case event, ok := <-client.events: - if !ok { - return "", errors.New("pi event stream closed") - } - if event.Type == "response" { - response, err := decodeRpcResponse(event.Raw) - if err != nil { - return "", err - } - if !response.Success { - if strings.TrimSpace(response.Error) == "" { - return "", errors.New("pi prompt failed") - } - return "", errors.New(strings.TrimSpace(response.Error)) - } - continue - } - if event.Type == "agent_end" { - return extractTranslationResult(event.Raw) - } - } - } -} - -func (client *docsPiClient) Stderr() string { - return client.stderr.String() -} - -func (client *docsPiClient) Close() error { - client.closeOnce.Do(func() { - close(client.closed) - if client.stdin != nil { - _ = client.stdin.Close() - } - if client.process != nil && client.process.Process != nil { - _ = client.process.Process.Signal(syscall.SIGTERM) - } - - done := make(chan struct{}) - go func() { - if client.process != nil { - _ = client.process.Wait() - } - close(done) - }() - - select { - case <-done: - case <-time.After(2 * time.Second): - if client.process != nil && client.process.Process != nil { - _ = client.process.Process.Kill() - } - } - }) - return nil -} - -func (client *docsPiClient) captureStderr(stderr io.Reader) { - _, _ = io.Copy(&client.stderr, stderr) -} - -func (client *docsPiClient) readStdout(stdout io.Reader) { - defer close(client.events) - - reader := bufio.NewReader(stdout) - for { - line, err := reader.ReadBytes('\n') - line = bytes.TrimSpace(line) - if len(line) > 0 { - var envelope struct { - Type string `json:"type"` - } - if json.Unmarshal(line, &envelope) == nil && envelope.Type != "" { - select { - case client.events <- piEvent{Type: envelope.Type, Raw: append([]byte{}, line...)}: - case <-client.closed: - return - } - } - } - if err != nil { - return - } - } -} - -func extractTranslationResult(raw json.RawMessage) (string, error) { - var payload agentEndPayload - if err := json.Unmarshal(raw, &payload); err != nil { - return "", err - } - for index := len(payload.Messages) - 1; index >= 0; index-- { - message := payload.Messages[index] - if message.Role != "assistant" { - continue - } - if message.ErrorMessage != "" || isTerminalPiStopReason(message.StopReason) { - text, _ := extractContentText(message.Content) - return "", formatPiAgentError(message, text) - } - text, err := extractContentText(message.Content) - if err != nil { - return "", err - } - return text, nil - } - return "", errors.New("assistant message not found") -} - -func isTerminalPiStopReason(stopReason string) bool { - switch strings.ToLower(strings.TrimSpace(stopReason)) { - case "error", "terminated", "cancelled", "canceled", "aborted": - return true - default: - return false - } -} - -func formatPiAgentError(message agentMessage, assistantText string) error { - parts := []string{} - if msg := strings.TrimSpace(message.ErrorMessage); msg != "" { - parts = append(parts, msg) - } - if stop := strings.TrimSpace(message.StopReason); stop != "" { - parts = append(parts, "stopReason="+stop) - } - if preview := previewPiAssistantText(assistantText); preview != "" { - parts = append(parts, "assistant="+preview) - } - if len(parts) == 0 { - parts = append(parts, "unknown error") - } - return fmt.Errorf("pi error: %s", strings.Join(parts, "; ")) -} - -func previewPiAssistantText(text string) string { - trimmed := strings.TrimSpace(text) - if trimmed == "" { - return "" - } - trimmed = strings.ReplaceAll(trimmed, "\n", " ") - trimmed = strings.Join(strings.Fields(trimmed), " ") - const limit = 160 - if len(trimmed) <= limit { - return trimmed - } - return trimmed[:limit] + "..." -} - -func extractContentText(content json.RawMessage) (string, error) { - trimmed := strings.TrimSpace(string(content)) - if trimmed == "" { - return "", nil - } - if strings.HasPrefix(trimmed, "\"") { - var text string - if err := json.Unmarshal(content, &text); err != nil { - return "", err - } - return text, nil - } - - var blocks []contentBlock - if err := json.Unmarshal(content, &blocks); err != nil { - return "", err - } - - var parts []string - for _, block := range blocks { - if block.Type == "text" && block.Text != "" { - parts = append(parts, block.Text) - } - } - return strings.Join(parts, ""), nil -} - -func decodeRpcResponse(raw json.RawMessage) (rpcResponse, error) { - var response rpcResponse - if err := json.Unmarshal(raw, &response); err != nil { - return rpcResponse{}, err - } - return response, nil -} - -func getDocsPiAgentDir() (string, error) { - cacheDir, err := os.UserCacheDir() - if err != nil { - cacheDir = os.TempDir() - } - dir := filepath.Join(cacheDir, "openclaw", "docs-i18n", "agent") - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", err - } - return dir, nil -} - -func resolveDocsPiAgentDir() (string, error) { - if override := strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")); override != "" { - if err := os.MkdirAll(override, 0o700); err != nil { - return "", err - } - return override, nil - } - return getDocsPiAgentDir() -} diff --git a/scripts/docs-i18n/pi_rpc_client_test.go b/scripts/docs-i18n/pi_rpc_client_test.go deleted file mode 100644 index 7a6e7df60f7..00000000000 --- a/scripts/docs-i18n/pi_rpc_client_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package main - -import ( - "strings" - "testing" -) - -func TestExtractTranslationResultIncludesStopReasonAndPreview(t *testing.T) { - t.Parallel() - - raw := []byte(`{ - "type":"agent_end", - "messages":[ - { - "role":"assistant", - "stopReason":"terminated", - "content":[ - {"type":"text","text":"provider disconnected while streaming the translation chunk"} - ] - } - ] - }`) - - _, err := extractTranslationResult(raw) - if err == nil { - t.Fatal("expected error") - } - message := err.Error() - for _, want := range []string{ - "pi error:", - "stopReason=terminated", - "assistant=provider disconnected while streaming the translation chunk", - } { - if !strings.Contains(message, want) { - t.Fatalf("expected %q in error, got %q", want, message) - } - } -} - -func TestPreviewPiAssistantTextTruncatesAndFlattensWhitespace(t *testing.T) { - t.Parallel() - - input := "line one\n\nline two\tline three " + strings.Repeat("x", 200) - preview := previewPiAssistantText(input) - if strings.Contains(preview, "\n") { - t.Fatalf("expected flattened whitespace, got %q", preview) - } - if !strings.HasPrefix(preview, "line one line two line three ") { - t.Fatalf("unexpected preview prefix: %q", preview) - } - if !strings.HasSuffix(preview, "...") { - t.Fatalf("expected truncation suffix, got %q", preview) - } -} - -func TestExtractTranslationResultReturnsPiErrorBeforeDecodingStructuredErrorContent(t *testing.T) { - t.Parallel() - - raw := []byte(`{ - "type":"agent_end", - "messages":[ - { - "role":"assistant", - "stopReason":"terminated", - "content":{"type":"error","message":"provider disconnected"} - } - ] - }`) - - _, err := extractTranslationResult(raw) - if err == nil { - t.Fatal("expected error") - } - message := err.Error() - if !strings.Contains(message, "pi error:") { - t.Fatalf("expected normalized pi error, got %q", message) - } - if !strings.Contains(message, "stopReason=terminated") { - t.Fatalf("expected stopReason in error, got %q", message) - } - if strings.Contains(message, "cannot unmarshal") { - t.Fatalf("expected terminal pi error before decode failure, got %q", message) - } -} diff --git a/scripts/docs-i18n/process.go b/scripts/docs-i18n/process.go index 60188a0e142..28e735037ad 100644 --- a/scripts/docs-i18n/process.go +++ b/scripts/docs-i18n/process.go @@ -65,8 +65,8 @@ func processFile(ctx context.Context, translator docsTranslator, tm *Translation TextHash: seg.TextHash, Text: seg.Text, Translated: translated, - Provider: docsPiProvider(), - Model: docsPiModel(), + Provider: docsI18nProvider(), + Model: docsI18nModel(), SrcLang: srcLang, TgtLang: tgtLang, UpdatedAt: time.Now().UTC().Format(time.RFC3339), @@ -122,8 +122,8 @@ func encodeFrontMatter(frontData map[string]any, relPath string, source []byte) frontData["x-i18n"] = map[string]any{ "source_path": relPath, "source_hash": hashBytes(source), - "provider": docsPiProvider(), - "model": docsPiModel(), + "provider": docsI18nProvider(), + "model": docsI18nModel(), "workflow": workflowVersion, "generated_at": time.Now().UTC().Format(time.RFC3339), } @@ -229,8 +229,8 @@ func translateSnippet(ctx context.Context, translator docsTranslator, tm *Transl TextHash: textHash, Text: textValue, Translated: translated, - Provider: docsPiProvider(), - Model: docsPiModel(), + Provider: docsI18nProvider(), + Model: docsI18nModel(), SrcLang: srcLang, TgtLang: tgtLang, UpdatedAt: time.Now().UTC().Format(time.RFC3339), @@ -250,6 +250,9 @@ func validateFrontmatterScalarTranslation(source, translated string) error { if strings.Contains(lower, "") || strings.Contains(lower, "") || strings.Contains(lower, "") || strings.Contains(lower, "") { return fmt.Errorf("tagged document wrapper detected") } + if err := validateNoTranslationTranscriptArtifacts(source, trimmed); err != nil { + return err + } if strings.Contains(trimmed, "[[[FM_") { return fmt.Errorf("frontmatter marker leaked into scalar translation") } diff --git a/scripts/docs-i18n/translator.go b/scripts/docs-i18n/translator.go index 5ec5b0692e1..c9bb9807d98 100644 --- a/scripts/docs-i18n/translator.go +++ b/scripts/docs-i18n/translator.go @@ -1,26 +1,34 @@ package main import ( + "bytes" "context" "errors" "fmt" "os" + "os/exec" "strings" "time" ) const ( - translateMaxAttempts = 3 - translateBaseDelay = 15 * time.Second - defaultPromptTimeout = 2 * time.Minute - envDocsI18nPromptTimeout = "OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT" + translateMaxAttempts = 3 + translateBaseDelay = 15 * time.Second + defaultPromptTimeout = 2 * time.Minute + envDocsI18nPromptTimeout = "OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT" + envDocsI18nCodexExecutable = "OPENCLAW_DOCS_I18N_CODEX_EXECUTABLE" ) var errEmptyTranslation = errors.New("empty translation") -type PiTranslator struct { - client docsPiPromptClient - clientFactory docsPiClientFactory +var translateRetryDelay = func(attempt int) time.Duration { + return translateBaseDelay * time.Duration(attempt) +} + +type CodexTranslator struct { + systemPrompt string + thinking string + runPrompt codexPromptRunner } type docsTranslator interface { @@ -31,40 +39,32 @@ type docsTranslator interface { type docsTranslatorFactory func(string, string, []GlossaryEntry, string) (docsTranslator, error) -type docsPiPromptClient interface { - promptRunner - Close() error +type codexPromptRunner func(context.Context, codexPromptRequest) (string, error) + +type codexPromptRequest struct { + SystemPrompt string + Message string + Model string + Thinking string } -type docsPiClientFactory func(context.Context) (docsPiPromptClient, error) - -func NewPiTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*PiTranslator, error) { - options := docsPiClientOptions{ - SystemPrompt: translationPrompt(srcLang, tgtLang, glossary), - Thinking: normalizeThinking(thinking), - } - clientFactory := func(ctx context.Context) (docsPiPromptClient, error) { - return startDocsPiClient(ctx, options) - } - client, err := clientFactory(context.Background()) - if err != nil { - return nil, err - } - return &PiTranslator{client: client, clientFactory: clientFactory}, nil +func NewCodexTranslator(srcLang, tgtLang string, glossary []GlossaryEntry, thinking string) (*CodexTranslator, error) { + return &CodexTranslator{ + systemPrompt: translationPrompt(srcLang, tgtLang, glossary), + thinking: normalizeThinking(thinking), + runPrompt: runCodexExecPrompt, + }, nil } -func (t *PiTranslator) Translate(ctx context.Context, text, srcLang, tgtLang string) (string, error) { +func (t *CodexTranslator) Translate(ctx context.Context, text, srcLang, tgtLang string) (string, error) { return t.translate(ctx, text, t.translateMasked) } -func (t *PiTranslator) TranslateRaw(ctx context.Context, text, srcLang, tgtLang string) (string, error) { +func (t *CodexTranslator) TranslateRaw(ctx context.Context, text, srcLang, tgtLang string) (string, error) { return t.translate(ctx, text, t.translateRaw) } -func (t *PiTranslator) translate(ctx context.Context, text string, run func(context.Context, string) (string, error)) (string, error) { - if t.client == nil { - return "", errors.New("pi client unavailable") - } +func (t *CodexTranslator) translate(ctx context.Context, text string, run func(context.Context, string) (string, error)) (string, error) { prefix, core, suffix := splitWhitespace(text) if core == "" { return text, nil @@ -78,7 +78,7 @@ func (t *PiTranslator) translate(ctx context.Context, text string, run func(cont return prefix + translated + suffix, nil } -func (t *PiTranslator) translateWithRetry(ctx context.Context, run func(context.Context) (string, error)) (string, error) { +func (t *CodexTranslator) translateWithRetry(ctx context.Context, run func(context.Context) (string, error)) (string, error) { var lastErr error for attempt := 0; attempt < translateMaxAttempts; attempt++ { translated, err := run(ctx) @@ -90,13 +90,7 @@ func (t *PiTranslator) translateWithRetry(ctx context.Context, run func(context. } lastErr = err if attempt+1 < translateMaxAttempts { - if shouldRestartPiClientForError(err) { - if err := t.restartClient(ctx); err != nil { - return "", fmt.Errorf("%w (pi client restart failed: %v)", lastErr, err) - } - continue - } - delay := translateBaseDelay * time.Duration(attempt+1) + delay := translateRetryDelay(attempt + 1) if err := sleepWithContext(ctx, delay); err != nil { return "", err } @@ -105,12 +99,12 @@ func (t *PiTranslator) translateWithRetry(ctx context.Context, run func(context. return "", lastErr } -func (t *PiTranslator) translateMasked(ctx context.Context, core string) (string, error) { +func (t *CodexTranslator) translateMasked(ctx context.Context, core string) (string, error) { state := NewPlaceholderState(core) placeholders := make([]string, 0, 8) mapping := map[string]string{} masked := maskMarkdown(core, state.Next, &placeholders, mapping) - resText, err := runPrompt(ctx, t.client, masked) + resText, err := t.prompt(ctx, masked) if err != nil { return "", err } @@ -124,8 +118,8 @@ func (t *PiTranslator) translateMasked(ctx context.Context, core string) (string return unmaskMarkdown(translated, placeholders, mapping), nil } -func (t *PiTranslator) translateRaw(ctx context.Context, core string) (string, error) { - resText, err := runPrompt(ctx, t.client, core) +func (t *CodexTranslator) translateRaw(ctx context.Context, core string) (string, error) { + resText, err := t.prompt(ctx, core) if err != nil { return "", err } @@ -136,6 +130,20 @@ func (t *PiTranslator) translateRaw(ctx context.Context, core string) (string, e return translated, nil } +func (t *CodexTranslator) prompt(ctx context.Context, message string) (string, error) { + if t.runPrompt == nil { + return "", errors.New("codex prompt runner unavailable") + } + promptCtx, cancel := context.WithTimeout(ctx, docsI18nPromptTimeout()) + defer cancel() + return t.runPrompt(promptCtx, codexPromptRequest{ + SystemPrompt: t.systemPrompt, + Message: message, + Model: docsI18nModel(), + Thinking: t.thinking, + }) +} + func isRetryableTranslateError(err error) bool { if err == nil { return false @@ -147,44 +155,87 @@ func isRetryableTranslateError(err error) bool { return true } message := strings.ToLower(err.Error()) - if strings.Contains(message, "authentication failed") { + if strings.Contains(message, "authentication failed") || strings.Contains(message, "invalid_api_key") || strings.Contains(message, "api key") { return false } return strings.Contains(message, "placeholder missing") || strings.Contains(message, "rate limit") || strings.Contains(message, "429") || - shouldRestartPiClientForError(err) + strings.Contains(message, "500") || + strings.Contains(message, "502") || + strings.Contains(message, "503") || + strings.Contains(message, "504") || + strings.Contains(message, "temporarily unavailable") || + strings.Contains(message, "connection reset") || + strings.Contains(message, "stream") } -func shouldRestartPiClientForError(err error) bool { - if err == nil { - return false - } - message := strings.ToLower(err.Error()) - return strings.Contains(message, "pi error: terminated") || - strings.Contains(message, "stopreason=cancelled") || - strings.Contains(message, "stopreason=canceled") || - strings.Contains(message, "stopreason=aborted") || - strings.Contains(message, "stopreason=terminated") || - strings.Contains(message, "stopreason=error") || - strings.Contains(message, "pi process closed") || - strings.Contains(message, "pi event stream closed") -} - -func (t *PiTranslator) restartClient(ctx context.Context) error { - if t.clientFactory == nil { - return errors.New("pi client restart unavailable") - } - if t.client != nil { - _ = t.client.Close() - t.client = nil - } - client, err := t.clientFactory(ctx) +func runCodexExecPrompt(ctx context.Context, req codexPromptRequest) (string, error) { + outputFile, err := os.CreateTemp("", "openclaw-docs-i18n-codex-*.txt") if err != nil { - return err + return "", err } - t.client = client - return nil + outputPath := outputFile.Name() + _ = outputFile.Close() + defer os.Remove(outputPath) + + args := []string{ + "exec", + "--model", req.Model, + "-c", fmt.Sprintf("model_reasoning_effort=%q", normalizeThinking(req.Thinking)), + "--sandbox", "read-only", + "--ignore-rules", + "--skip-git-repo-check", + "--output-last-message", outputPath, + "-", + } + command := exec.CommandContext(ctx, docsCodexExecutable(), args...) + command.Stdin = strings.NewReader(buildCodexTranslationPrompt(req.SystemPrompt, req.Message)) + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + return "", fmt.Errorf("codex exec failed: %w (%s)", err, previewCommandOutput(stdout.String(), stderr.String())) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + return "", err + } + translated := strings.TrimSpace(string(data)) + if translated == "" { + return "", errEmptyTranslation + } + return translated, nil +} + +func docsCodexExecutable() string { + if executable := strings.TrimSpace(os.Getenv(envDocsI18nCodexExecutable)); executable != "" { + return executable + } + return "codex" +} + +func buildCodexTranslationPrompt(systemPrompt, message string) string { + return strings.TrimSpace(systemPrompt) + "\n\n" + + "Translate the exact input below. Return only the translated text, with no code fences, no tool calls, no reasoning, and no commentary.\n\n" + + "\n" + + message + + "\n\n" +} + +func previewCommandOutput(stdout, stderr string) string { + combined := strings.TrimSpace(strings.Join([]string{stdout, stderr}, "\n")) + if combined == "" { + return "no output" + } + combined = strings.Join(strings.Fields(combined), " ") + const limit = 500 + if len(combined) <= limit { + return combined + } + return combined[:limit] + "..." } func sleepWithContext(ctx context.Context, delay time.Duration) error { @@ -198,42 +249,11 @@ func sleepWithContext(ctx context.Context, delay time.Duration) error { } } -func (t *PiTranslator) Close() { - if t.client != nil { - _ = t.client.Close() - } -} - -type promptRunner interface { - Prompt(context.Context, string) (string, error) - Stderr() string -} - -func runPrompt(ctx context.Context, client promptRunner, message string) (string, error) { - promptCtx, cancel := context.WithTimeout(ctx, docsI18nPromptTimeout()) - defer cancel() - - result, err := client.Prompt(promptCtx, message) - if err != nil { - return "", decoratePromptError(err, client.Stderr()) - } - return result, nil -} - -func decoratePromptError(err error, stderr string) error { - if err == nil { - return nil - } - trimmed := strings.TrimSpace(stderr) - if trimmed == "" { - return err - } - return fmt.Errorf("%w (pi stderr: %s)", err, trimmed) -} +func (t *CodexTranslator) Close() {} func normalizeThinking(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { - case "low", "high": + case "low", "medium", "high", "xhigh": return strings.ToLower(strings.TrimSpace(value)) default: return "high" diff --git a/scripts/docs-i18n/translator_test.go b/scripts/docs-i18n/translator_test.go index ce63d7d4815..0bd66afd5f2 100644 --- a/scripts/docs-i18n/translator_test.go +++ b/scripts/docs-i18n/translator_test.go @@ -10,59 +10,33 @@ import ( "time" ) -type fakePromptRunner struct { - prompt func(context.Context, string) (string, error) - stderr string -} - -func (runner fakePromptRunner) Prompt(ctx context.Context, message string) (string, error) { - return runner.prompt(ctx, message) -} - -func (runner fakePromptRunner) Stderr() string { - return runner.stderr -} - -type fakePiPromptClient struct { - prompt func(context.Context, string) (string, error) - stderr string - closed bool -} - -func (client *fakePiPromptClient) Prompt(ctx context.Context, message string) (string, error) { - return client.prompt(ctx, message) -} - -func (client *fakePiPromptClient) Stderr() string { - return client.stderr -} - -func (client *fakePiPromptClient) Close() error { - client.closed = true - return nil -} - -func TestRunPromptAddsTimeout(t *testing.T) { - t.Parallel() - +func TestCodexTranslatorAddsTimeout(t *testing.T) { var deadline time.Time - client := fakePromptRunner{ - prompt: func(ctx context.Context, message string) (string, error) { + translator := &CodexTranslator{ + systemPrompt: "Translate from English to Chinese.", + thinking: "high", + runPrompt: func(ctx context.Context, req codexPromptRequest) (string, error) { var ok bool deadline, ok = ctx.Deadline() if !ok { t.Fatal("expected prompt deadline") } - if message != "Translate me" { - t.Fatalf("unexpected message %q", message) + if req.Message != "Translate me" { + t.Fatalf("unexpected message %q", req.Message) + } + if req.Model != defaultOpenAIModel { + t.Fatalf("unexpected model %q", req.Model) + } + if req.Thinking != "high" { + t.Fatalf("unexpected thinking %q", req.Thinking) } return "translated", nil }, } - got, err := runPrompt(context.Background(), client, "Translate me") + got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN") if err != nil { - t.Fatalf("runPrompt returned error: %v", err) + t.Fatalf("TranslateRaw returned error: %v", err) } if got != "translated" { t.Fatalf("unexpected translation %q", got) @@ -96,157 +70,43 @@ func TestIsRetryableTranslateErrorRejectsAuthenticationFailures(t *testing.T) { if isRetryableTranslateError(errors.New(`Authentication failed for "openai"`)) { t.Fatal("auth failures should not retry") } -} - -func TestIsRetryableTranslateErrorRetriesPiTermination(t *testing.T) { - t.Parallel() - - if !isRetryableTranslateError(errors.New("pi error: terminated; stopReason=error; assistant=partial output")) { - t.Fatal("terminated pi session should retry") + if isRetryableTranslateError(errors.New("invalid_api_key")) { + t.Fatal("API key failures should not retry") } } -func TestIsRetryableTranslateErrorRetriesTerminatedStopReason(t *testing.T) { - t.Parallel() - - if !isRetryableTranslateError(errors.New("pi error: stopReason=terminated; assistant=partial output")) { - t.Fatal("terminated stopReason should retry") - } -} - -func TestIsRetryableTranslateErrorRetriesCanceledStopReasons(t *testing.T) { +func TestIsRetryableTranslateErrorRetriesTransientCodexFailures(t *testing.T) { t.Parallel() for _, message := range []string{ - "pi error: stopReason=cancelled; assistant=partial output", - "pi error: stopReason=canceled; assistant=partial output", - "pi error: stopReason=aborted; assistant=partial output", + "codex exec failed: rate limit 429", + "codex exec failed: stream disconnected", + "codex exec failed: 503 temporarily unavailable", } { if !isRetryableTranslateError(errors.New(message)) { - t.Fatalf("expected retryable stop reason for %q", message) + t.Fatalf("expected retryable error for %q", message) } } } -func TestRunPromptIncludesStderr(t *testing.T) { - t.Parallel() +func TestCodexTranslatorRetriesTransientFailure(t *testing.T) { + previousDelay := translateRetryDelay + translateRetryDelay = func(int) time.Duration { return 0 } + defer func() { translateRetryDelay = previousDelay }() - rootErr := errors.New("context deadline exceeded") - client := fakePromptRunner{ - prompt: func(context.Context, string) (string, error) { - return "", rootErr + attempts := 0 + translator := &CodexTranslator{ + systemPrompt: "Translate from English to Chinese.", + thinking: "high", + runPrompt: func(context.Context, codexPromptRequest) (string, error) { + attempts++ + if attempts == 1 { + return "", errors.New("codex exec failed: stream disconnected") + } + return "translated", nil }, - stderr: "boom", } - _, err := runPrompt(context.Background(), client, "Translate me") - if err == nil { - t.Fatal("expected error") - } - if !errors.Is(err, rootErr) { - t.Fatalf("expected wrapped root error, got %v", err) - } - if !strings.Contains(err.Error(), "pi stderr: boom") { - t.Fatalf("expected stderr in error, got %v", err) - } -} - -func TestDecoratePromptErrorLeavesCleanErrorsAlone(t *testing.T) { - t.Parallel() - - rootErr := errors.New("plain failure") - got := decoratePromptError(rootErr, " ") - if !errors.Is(got, rootErr) { - t.Fatalf("expected original error, got %v", got) - } - if got.Error() != rootErr.Error() { - t.Fatalf("expected unchanged message, got %v", got) - } -} - -func TestResolveDocsPiCommandUsesOverrideEnv(t *testing.T) { - t.Setenv(envDocsPiExecutable, "/tmp/custom-pi") - t.Setenv(envDocsPiArgs, "--mode rpc --foo bar") - - command, err := resolveDocsPiCommand(context.Background()) - if err != nil { - t.Fatalf("resolveDocsPiCommand returned error: %v", err) - } - - if command.Executable != "/tmp/custom-pi" { - t.Fatalf("unexpected executable %q", command.Executable) - } - if strings.Join(command.Args, " ") != "--mode rpc --foo bar" { - t.Fatalf("unexpected args %v", command.Args) - } -} - -func TestDocsPiModelRefUsesProviderPrefixWhenProviderFlagIsOmitted(t *testing.T) { - t.Setenv(envDocsI18nProvider, "openai") - t.Setenv(envDocsI18nModel, "gpt-5.5") - t.Setenv(envDocsPiOmitProvider, "1") - - if got := docsPiProviderArg(); got != "" { - t.Fatalf("expected empty provider arg when omit-provider is enabled, got %q", got) - } - if got := docsPiModelRef(); got != "openai/gpt-5.5" { - t.Fatalf("expected provider-qualified model ref, got %q", got) - } -} - -func TestShouldMaterializePiRuntimeForPiMonoWrapper(t *testing.T) { - t.Parallel() - - root := t.TempDir() - sourceDir := filepath.Join(root, "Projects", "pi-mono", "packages", "coding-agent", "dist") - binDir := filepath.Join(root, "bin") - if err := os.MkdirAll(sourceDir, 0o755); err != nil { - t.Fatalf("mkdir source dir: %v", err) - } - if err := os.MkdirAll(binDir, 0o755); err != nil { - t.Fatalf("mkdir bin dir: %v", err) - } - - target := filepath.Join(sourceDir, "cli.js") - if err := os.WriteFile(target, []byte("console.log('pi');\n"), 0o644); err != nil { - t.Fatalf("write target: %v", err) - } - link := filepath.Join(binDir, "pi") - if err := os.Symlink(target, link); err != nil { - t.Fatalf("symlink: %v", err) - } - - if !shouldMaterializePiRuntime(link) { - t.Fatal("expected pi-mono wrapper to materialize runtime") - } -} - -func TestPiTranslatorRestartsClientAfterPiTermination(t *testing.T) { - t.Parallel() - - clients := []*fakePiPromptClient{} - factoryCalls := 0 - factory := func(context.Context) (docsPiPromptClient, error) { - factoryCalls++ - index := factoryCalls - client := &fakePiPromptClient{ - prompt: func(context.Context, string) (string, error) { - if index == 1 { - return "", errors.New("pi error: terminated; stopReason=error; assistant=partial output") - } - return "translated", nil - }, - } - clients = append(clients, client) - return client, nil - } - - client, err := factory(context.Background()) - if err != nil { - t.Fatalf("factory failed: %v", err) - } - translator := &PiTranslator{client: client, clientFactory: factory} - got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN") if err != nil { t.Fatalf("TranslateRaw returned error: %v", err) @@ -254,107 +114,71 @@ func TestPiTranslatorRestartsClientAfterPiTermination(t *testing.T) { if got != "translated" { t.Fatalf("unexpected translation %q", got) } - if factoryCalls != 2 { - t.Fatalf("expected factory to run twice, got %d", factoryCalls) - } - if len(clients) != 2 { - t.Fatalf("expected 2 clients, got %d", len(clients)) - } - if !clients[0].closed { - t.Fatal("expected first client to close before retry") - } - if clients[1].closed { - t.Fatal("expected replacement client to remain open") + if attempts != 2 { + t.Fatalf("expected 2 attempts, got %d", attempts) } } -func TestPiTranslatorRestartsClientAfterTerminatedStopReason(t *testing.T) { - t.Parallel() +func TestBuildCodexTranslationPromptIncludesGuardrailsAndInput(t *testing.T) { + prompt := buildCodexTranslationPrompt("System prompt.", "Hello\nworld") - clients := []*fakePiPromptClient{} - factoryCalls := 0 - factory := func(context.Context) (docsPiPromptClient, error) { - factoryCalls++ - index := factoryCalls - client := &fakePiPromptClient{ - prompt: func(context.Context, string) (string, error) { - if index == 1 { - return "", errors.New("pi error: stopReason=terminated; assistant=partial output") - } - return "translated", nil - }, + for _, want := range []string{ + "System prompt.", + "Return only the translated text", + "", + "Hello\nworld", + "", + } { + if !strings.Contains(prompt, want) { + t.Fatalf("expected %q in prompt:\n%s", want, prompt) } - clients = append(clients, client) - return client, nil - } - - client, err := factory(context.Background()) - if err != nil { - t.Fatalf("factory failed: %v", err) - } - translator := &PiTranslator{client: client, clientFactory: factory} - - got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN") - if err != nil { - t.Fatalf("TranslateRaw returned error: %v", err) - } - if got != "translated" { - t.Fatalf("unexpected translation %q", got) - } - if factoryCalls != 2 { - t.Fatalf("expected factory to run twice, got %d", factoryCalls) - } - if len(clients) != 2 { - t.Fatalf("expected 2 clients, got %d", len(clients)) - } - if !clients[0].closed { - t.Fatal("expected first client to close before retry") - } - if clients[1].closed { - t.Fatal("expected replacement client to remain open") } } -func TestPiTranslatorRestartsClientAfterCanceledStopReason(t *testing.T) { - t.Parallel() - - clients := []*fakePiPromptClient{} - factoryCalls := 0 - factory := func(context.Context) (docsPiPromptClient, error) { - factoryCalls++ - index := factoryCalls - client := &fakePiPromptClient{ - prompt: func(context.Context, string) (string, error) { - if index == 1 { - return "", errors.New("pi error: stopReason=aborted; assistant=partial output") - } - return "translated", nil - }, - } - clients = append(clients, client) - return client, nil +func TestRunCodexExecPromptUsesOutputLastMessage(t *testing.T) { + dir := t.TempDir() + fakeCodex := filepath.Join(dir, "codex") + if err := os.WriteFile(fakeCodex, []byte(`#!/bin/sh +set -eu +out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--output-last-message" ]; then + shift + out="$1" + fi + shift || true +done +cat >/dev/null +printf 'translated from codex\n' > "$out" +`), 0o755); err != nil { + t.Fatalf("write fake codex: %v", err) } + t.Setenv(envDocsI18nCodexExecutable, fakeCodex) - client, err := factory(context.Background()) + got, err := runCodexExecPrompt(context.Background(), codexPromptRequest{ + SystemPrompt: "Translate.", + Message: "Hello", + Model: "gpt-5.5", + Thinking: "high", + }) if err != nil { - t.Fatalf("factory failed: %v", err) + t.Fatalf("runCodexExecPrompt returned error: %v", err) } - translator := &PiTranslator{client: client, clientFactory: factory} - - got, err := translator.TranslateRaw(context.Background(), "Translate me", "en", "zh-CN") - if err != nil { - t.Fatalf("TranslateRaw returned error: %v", err) - } - if got != "translated" { - t.Fatalf("unexpected translation %q", got) - } - if factoryCalls != 2 { - t.Fatalf("expected factory to run twice, got %d", factoryCalls) - } - if !clients[0].closed { - t.Fatal("expected first client to close before retry") - } - if clients[1].closed { - t.Fatal("expected replacement client to remain open") + if got != "translated from codex" { + t.Fatalf("unexpected output %q", got) + } +} + +func TestPreviewCommandOutputFlattensAndTruncates(t *testing.T) { + input := "line one\n\nline two\tline three " + strings.Repeat("x", 600) + preview := previewCommandOutput(input, "") + if strings.Contains(preview, "\n") { + t.Fatalf("expected flattened whitespace, got %q", preview) + } + if !strings.HasPrefix(preview, "line one line two line three ") { + t.Fatalf("unexpected preview prefix: %q", preview) + } + if !strings.HasSuffix(preview, "...") { + t.Fatalf("expected truncation suffix, got %q", preview) } } diff --git a/scripts/docs-i18n/util.go b/scripts/docs-i18n/util.go index 4e564e9ff17..71eba5d15ba 100644 --- a/scripts/docs-i18n/util.go +++ b/scripts/docs-i18n/util.go @@ -6,27 +6,29 @@ import ( "fmt" "io" "os" + "regexp" "strings" ) const ( workflowVersion = 15 - docsI18nEngineName = "pi" + docsI18nEngineName = "codex" envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER" envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL" defaultOpenAIModel = "gpt-5.5" - defaultAnthropicModel = "claude-opus-4-6" defaultFallbackProvider = "openai" defaultFallbackModelName = defaultOpenAIModel ) +var translationTranscriptArtifactRE = regexp.MustCompile(`(?i)(?:\b(?:analysis|commentary|final|assistant|user)\s+to\s*=\s*(?:functions\.[a-z0-9_-]+|[a-z_]+)|\bto\s*=\s*(?:functions\.[a-z0-9_-]+|analysis|commentary|final)\b|\bfunctions\.[a-z0-9_-]+\b|/home/runner/work/|\.agents/skills/|\bforce_parallel\s*:|\bcode\s+omitted\b|\bomitted\s+reasoning\b|全民彩票|娱乐平台开户|娱乐平台|皇平台|彩票平台|一本道|毛片|高清视频免费|不卡免费播放)`) + func cacheNamespace() string { return fmt.Sprintf( "wf=%d|engine=%s|provider=%s|model=%s", workflowVersion, docsI18nEngineName, - docsPiProvider(), - docsPiModel(), + docsI18nProvider(), + docsI18nModel(), ) } @@ -51,89 +53,18 @@ func normalizeText(text string) string { return strings.Join(strings.Fields(strings.TrimSpace(text)), " ") } -func docsPiProvider() string { - if value := strings.TrimSpace(os.Getenv(envDocsI18nProvider)); value != "" { +func docsI18nProvider() string { + if value := strings.TrimSpace(os.Getenv(envDocsI18nProvider)); strings.EqualFold(value, "openai") { return value } - if strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "" { - return "openai" - } - if strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) != "" { - return "anthropic" - } return defaultFallbackProvider } -func docsPiModel() string { +func docsI18nModel() string { if value := strings.TrimSpace(os.Getenv(envDocsI18nModel)); value != "" { return value } - switch docsPiProvider() { - case "anthropic": - return defaultAnthropicModel - case "openai": - return defaultOpenAIModel - default: - return defaultFallbackModelName - } -} - -func docsPiProviderArg() string { - provider := docsPiProvider() - if provider == "" { - return "" - } - if docsPiOmitProvider() { - return "" - } - if strings.Contains(docsPiModel(), "/") { - return "" - } - if hasDocsPiAgentDirOverride() { - return "" - } - if !isBuiltInPiProvider(provider) { - return "" - } - return provider -} - -func docsPiModelRef() string { - model := docsPiModel() - if model == "" { - return "" - } - if strings.Contains(model, "/") { - return model - } - if docsPiOmitProvider() { - provider := docsPiProvider() - if provider == "" { - return model - } - return provider + "/" + model - } - if docsPiProviderArg() != "" { - return model - } - provider := docsPiProvider() - if provider == "" { - return model - } - return provider + "/" + model -} - -func isBuiltInPiProvider(provider string) bool { - switch strings.ToLower(strings.TrimSpace(provider)) { - case "anthropic", "openai": - return true - default: - return false - } -} - -func hasDocsPiAgentDirOverride() bool { - return strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")) != "" + return defaultFallbackModelName } func segmentID(relPath, textHash string) string { @@ -168,6 +99,21 @@ func isWhitespace(b byte) bool { } } +func validateNoTranslationTranscriptArtifacts(source, translated string) error { + sourceLower := strings.ToLower(source) + for _, match := range translationTranscriptArtifactRE.FindAllString(translated, -1) { + match = strings.TrimSpace(match) + if match == "" { + continue + } + if strings.Contains(sourceLower, strings.ToLower(match)) { + continue + } + return fmt.Errorf("agent transcript artifact leaked into translation: %q", match) + } + return nil +} + func fatal(err error) { if err == nil { return diff --git a/scripts/docs-i18n/util_test.go b/scripts/docs-i18n/util_test.go index 30dcb14a07d..1cf9723094b 100644 --- a/scripts/docs-i18n/util_test.go +++ b/scripts/docs-i18n/util_test.go @@ -2,49 +2,27 @@ package main import "testing" -func TestDocsPiProviderPrefersExplicitOverride(t *testing.T) { +func TestDocsI18nProviderUsesOpenAI(t *testing.T) { t.Setenv(envDocsI18nProvider, "anthropic") - t.Setenv("OPENAI_API_KEY", "openai-key") t.Setenv("ANTHROPIC_API_KEY", "anthropic-key") - if got := docsPiProvider(); got != "anthropic" { - t.Fatalf("expected anthropic override, got %q", got) + if got := docsI18nProvider(); got != "openai" { + t.Fatalf("expected OpenAI provider, got %q", got) } } -func TestDocsPiProviderPrefersOpenAIEnvWhenAvailable(t *testing.T) { - t.Setenv(envDocsI18nProvider, "") - t.Setenv("OPENAI_API_KEY", "openai-key") - t.Setenv("ANTHROPIC_API_KEY", "anthropic-key") - - if got := docsPiProvider(); got != "openai" { - t.Fatalf("expected openai provider, got %q", got) - } -} - -func TestDocsPiModelUsesProviderDefault(t *testing.T) { - t.Setenv(envDocsI18nProvider, "anthropic") +func TestDocsI18nModelKeepsOpenAIDefaultAtGPT55(t *testing.T) { t.Setenv(envDocsI18nModel, "") - if got := docsPiModel(); got != defaultAnthropicModel { - t.Fatalf("expected anthropic default model, got %q", got) - } -} - -func TestDocsPiModelKeepsOpenAIDefaultAtGPT54(t *testing.T) { - t.Setenv(envDocsI18nProvider, "openai") - t.Setenv(envDocsI18nModel, "") - - if got := docsPiModel(); got != defaultOpenAIModel { + if got := docsI18nModel(); got != defaultOpenAIModel { t.Fatalf("expected OpenAI default model %q, got %q", defaultOpenAIModel, got) } } -func TestDocsPiModelPrefersExplicitOverride(t *testing.T) { - t.Setenv(envDocsI18nProvider, "openai") - t.Setenv(envDocsI18nModel, "gpt-5.2") +func TestDocsI18nModelPrefersExplicitOverride(t *testing.T) { + t.Setenv(envDocsI18nModel, "__test_model_override__") - if got := docsPiModel(); got != "gpt-5.2" { + if got := docsI18nModel(); got != "__test_model_override__" { t.Fatalf("expected explicit model override, got %q", got) } }