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)
}
}