Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docs/general/feishu-card-ui-state-machine.md

Large diffs are not rendered by default.

30 changes: 21 additions & 9 deletions docs/general/remote-surface-state-machine.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
## 1. 背景与问题现象

在多工作区使用飞书管理远程 Codex 会话时,出现以下现象:
1. **工作区内新会话在列表中不显示**:用户在特定物理工作区(如 `/home/qagent/program/svmpy`)中新开或重命名的会话,在飞书端发送 `/use` 或 `/list` 选择该工作区时,会话候选列表完全不包含该会话,甚至显示为无可用会话。
1. **工作区内新会话在列表中不显示**:用户在特定物理工作区(如 `/home/demo/program/svmpy`)中新开或重命名的会话,在飞书端发送 `/use` 或 `/list` 选择该工作区时,会话候选列表完全不包含该会话,甚至显示为无可用会话。
2. **会话名称未识别自定义重命名**:会话在终端 CLI 或 VS Code 中使用 `/rename` 重命名为自定义名称(如 `svmnew0910`)后,飞书端卡片顶部的标题与候选条目依然展示为创建会话时的首条输入长摘要或历史旧名称(如 `svmpy · svmpy_design_20260909`),自定义重命名完全失效。
3. **工作区路径展示偏离**:飞书表面展示的会话工作区路径偶发变为系统内部状态目录 `/home/qagent/.local/state/codex-remote`。
3. **工作区路径展示偏离**:飞书表面展示的会话工作区路径偶发变为系统内部状态目录 `/home/demo/.local/state/codex-remote`。

---

Expand All @@ -20,7 +20,7 @@
### 2.1 Headless Pool 预热机制与会话工作区标记污染

- **旧架构(按需随起)**:
历史版本中,headless 实例在用户发起请求时按需直接在目标物理目录(如 `/home/qagent/program/svmpy`)下冷启动。启动时实例的 `inst.WorkspaceRoot` 与该目录天然一致,内部会话继承该实例路径也不会发生错位。
历史版本中,headless 实例在用户发起请求时按需直接在目标物理目录(如 `/home/demo/program/svmpy`)下冷启动。启动时实例的 `inst.WorkspaceRoot` 与该目录天然一致,内部会话继承该实例路径也不会发生错位。
- **新架构(Managed Headless Pool 预热)**:
最新版本引入预热池以加速响应。预热实例为了提前通用待命,启动时的默认工作目录为状态目录 `~/.local/state/codex-remote`。
- **污染链路**:
Expand All @@ -31,7 +31,7 @@
current.WorkspaceKey = state.ResolveWorkspaceKey(thread.WorkspaceKey, current.WorkspaceKey, inst.WorkspaceKey, inst.WorkspaceRoot)
```
由于 `thread.WorkspaceKey` 为空,所有会话(包括 `svmpy` 会话)全部被无差别盖戳赋予了预热实例的当前目录:`~/.local/state/codex-remote`。
4. 当该预热实例后续被飞书表面借用绑定到目标工作区(如 `/home/qagent/program/svmpy`)时,实例本身属性已切换,但内部缓存的 50 个会话身上的 `WorkspaceKey` 依然残留为 `~/.local/state/codex-remote`。
4. 当该预热实例后续被飞书表面借用绑定到目标工作区(如 `/home/demo/program/svmpy`)时,实例本身属性已切换,但内部缓存的 50 个会话身上的 `WorkspaceKey` 依然残留为 `~/.local/state/codex-remote`。
5. 飞书在构建 `/use` 与 `/list` 候选会话列表时,执行 `threadBelongsToInstanceWorkspace`:
```go
// 原实现:
Expand Down
124 changes: 124 additions & 0 deletions internal/adapter/codex/translator_collaboration_live_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package codex

import (
"bufio"
"context"
"encoding/json"
"os"
"os/exec"
"strings"
"testing"
"time"

"github.com/kxn/codex-remote-feishu/internal/core/agentproto"
)

// Opt-in: uses the local Codex login for read-only new-thread and resumed-thread model turns.
func TestLiveNativeDefaultCollaborationSettings(t *testing.T) {
if os.Getenv("CODEX_REMOTE_LIVE_SETTINGS_TEST") != "1" {
t.Skip("set CODEX_REMOTE_LIVE_SETTINGS_TEST=1 to use the local Codex runtime")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
process := exec.CommandContext(ctx, "codex", "app-server", "--stdio")
stdin, err := process.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := process.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stderr, err := os.CreateTemp(t.TempDir(), "app-server-*.log")
if err != nil {
t.Fatal(err)
}
defer stderr.Close()
process.Stderr = stderr
if err = process.Start(); err != nil {
t.Fatal(err)
}
defer func() { cancel(); _ = process.Wait() }()
send := func(v any) {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
if _, err = stdin.Write(append(data, '\n')); err != nil {
t.Fatal(err)
}
}
send(map[string]any{"id": "verify-init", "method": "initialize", "params": map[string]any{"clientInfo": map[string]any{"name": "remote-settings-verifier", "version": "1"}, "capabilities": map[string]any{"experimentalApi": true}}})
tr := NewTranslator("verify-native-default")
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 65536), 16*1024*1024)
reply := ""
completed := 0
for scanner.Scan() {
raw := append([]byte(nil), scanner.Bytes()...)
var msg map[string]any
if err = json.Unmarshal(raw, &msg); err != nil {
continue
}
if msg["error"] != nil {
t.Fatalf("real app-server rejected request %v: %v", msg["id"], msg["error"])
}
if msg["id"] == "verify-archive" {
return
}
if msg["id"] == "verify-init" {
send(map[string]any{"method": "initialized", "params": map[string]any{}})
frames, err := tr.TranslateCommand(agentproto.Command{Kind: agentproto.CommandPromptSend, Target: agentproto.Target{CWD: t.TempDir(), ExecutionMode: agentproto.PromptExecutionModeStartNew}, Prompt: agentproto.Prompt{Inputs: []agentproto.Input{{Type: agentproto.InputText, Text: "Reply with SETTINGS_OK only. Do not call any tools."}}}, Overrides: agentproto.PromptOverrides{PlanMode: "off"}})
if err != nil {
t.Fatal(err)
}
for _, frame := range frames {
if _, err = stdin.Write(frame); err != nil {
t.Fatal(err)
}
}
continue
}
if r, ok := msg["result"].(map[string]any); ok && r["model"] != nil {
t.Logf("resolved model=%v effort=%v", r["model"], r["reasoningEffort"])
}
result, err := tr.ObserveServer(raw)
if err != nil {
t.Fatal(err)
}
for _, frame := range result.OutboundToCodex {
if _, err = stdin.Write(frame); err != nil {
t.Fatal(err)
}
}
if msg["method"] == "item/completed" {
if text := lookupString(msg, "params", "item", "text"); text != "" {
reply = text
}
}
if msg["method"] == "turn/completed" {
if lookupString(msg, "params", "turn", "status") != "completed" || !strings.Contains(reply, "SETTINGS_OK") {
t.Fatalf("turn failed: status=%s reply=%q", lookupString(msg, "params", "turn", "status"), reply)
}
completed++
t.Logf("real turn %d completed: %s", completed, reply)
threadID := lookupString(msg, "params", "threadId")
if completed == 2 {
send(map[string]any{"id": "verify-archive", "method": "thread/archive", "params": map[string]any{"threadId": threadID}})
continue
}
tr = NewTranslator("verify-native-resume")
frames, err := tr.TranslateCommand(agentproto.Command{Kind: agentproto.CommandPromptSend, Target: agentproto.Target{ThreadID: threadID}, Prompt: agentproto.Prompt{Inputs: []agentproto.Input{{Type: agentproto.InputText, Text: "Reply with SETTINGS_OK only. Do not call any tools."}}}, Overrides: agentproto.PromptOverrides{PlanMode: "off"}})
if err != nil {
t.Fatal(err)
}
for _, frame := range frames {
if _, err = stdin.Write(frame); err != nil {
t.Fatal(err)
}
}
}
}
t.Fatalf("app-server ended before verification completed: scanner=%v context=%v", scanner.Err(), ctx.Err())
}
67 changes: 67 additions & 0 deletions internal/adapter/codex/translator_collaboration_settings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package codex

import (
"encoding/json"
"testing"

"github.com/kxn/codex-remote-feishu/internal/core/agentproto"
)

func TestNativeDefaultCollaborationSettingsUseResolvedThreadModel(t *testing.T) {
for _, plan := range []string{"off", "on"} {
for _, resume := range []bool{false, true} {
name := plan + "/start"
if resume {
name = plan + "/resume"
}
t.Run(name, func(t *testing.T) {
tr := NewTranslator("test")
cmd := agentproto.Command{Kind: agentproto.CommandPromptSend, Target: agentproto.Target{CWD: t.TempDir()}, Prompt: agentproto.Prompt{Inputs: []agentproto.Input{{Type: agentproto.InputText, Text: "hello"}}}, Overrides: agentproto.PromptOverrides{PlanMode: plan}, CodexResume: &agentproto.CodexResumePolicy{Mode: agentproto.CodexResumeApplyTargetProfile, ModelProviderID: "openai", ModelMode: agentproto.CodexThreadValueDefault, ReasoningMode: agentproto.CodexThreadValueDefault}}
if resume {
cmd.Target.ThreadID = "thread-1"
}
frames, err := tr.TranslateCommand(cmd)
if err != nil || len(frames) != 1 {
t.Fatalf("prepare: %v", err)
}
var request map[string]any
if err = json.Unmarshal(frames[0], &request); err != nil {
t.Fatal(err)
}
response, _ := json.Marshal(map[string]any{"id": request["id"], "result": map[string]any{"thread": map[string]any{"id": "thread-1"}, "model": "actual-native-model", "modelProvider": "openai", "reasoningEffort": "high"}})
result, err := tr.ObserveServer(response)
if err != nil || len(result.OutboundToCodex) != 1 {
t.Fatalf("follow-up: %v %#v", err, result)
}
var turn map[string]any
if err = json.Unmarshal(result.OutboundToCodex[0], &turn); err != nil {
t.Fatal(err)
}
params := turn["params"].(map[string]any)
collaboration, _ := params["collaborationMode"].(map[string]any)
settings, _ := collaboration["settings"].(map[string]any)
mode := "default"
if plan == "on" {
mode = "plan"
}
if collaboration["mode"] != mode || settings["model"] != "actual-native-model" || settings["reasoning_effort"] != "high" {
t.Fatalf("invalid collaboration settings: %#v", collaboration)
}
})
}
}
}

func TestCollaborationModeRejectsUnknownModelWithoutClaimingTurn(t *testing.T) {
tr := NewTranslator("test")
if _, err := tr.ObserveClient([]byte(`{"method":"turn/start","params":{"threadId":"thread-1"}}`)); err != nil {
t.Fatal(err)
}
frames, err := tr.TranslateCommand(agentproto.Command{Kind: agentproto.CommandPromptSend, Origin: agentproto.Origin{Surface: "surface-1"}, Target: agentproto.Target{ThreadID: "thread-1"}, Overrides: agentproto.PromptOverrides{PlanMode: "on"}})
if err == nil || len(frames) != 0 {
t.Fatalf("unknown model produced an upstream request: frames=%d err=%v", len(frames), err)
}
if _, exists := tr.pendingRemoteTurnByThread["thread-1"]; exists {
t.Fatal("rejected request retained remote turn ownership")
}
}
5 changes: 4 additions & 1 deletion internal/adapter/codex/translator_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,6 @@ func (t *Translator) buildThreadStartParamsWithPolicy(cwd string, overrides agen

func (t *Translator) directTurnStart(threadID string, command agentproto.Command, newThread bool) ([]byte, string, error) {
delete(t.pendingLocalTurnByThread, threadID)
t.pendingRemoteTurnByThread[threadID] = choose(command.Origin.Surface, command.Origin.ChatID)
template := t.selectTurnTemplate(threadID, newThread)
template["threadId"] = threadID
template["input"] = t.buildInputs(command.Prompt.Inputs)
Expand All @@ -503,6 +502,10 @@ func (t *Translator) directTurnStart(threadID string, command agentproto.Command
setDefault(template, "attachments", []any{})
applyCodexResumePolicyToTurnStart(template, command.CodexResume)
applyPromptOverridesToTurnStart(template, command.Overrides)
if err := t.completeCollaborationSettings(threadID, template); err != nil {
return nil, "", err
}
t.pendingRemoteTurnByThread[threadID] = choose(command.Origin.Surface, command.Origin.ChatID)
t.recordCodexPolicyForThread(threadID, command.CodexResume)
requestID := t.NextRequest("turn-start")
payload := map[string]any{
Expand Down
34 changes: 34 additions & 0 deletions internal/adapter/codex/translator_config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package codex

import (
"fmt"
"strings"

"github.com/kxn/codex-remote-feishu/internal/core/agentproto"
Expand Down Expand Up @@ -218,6 +219,39 @@ func applyCodexResumePolicyToTurnStart(template map[string]any, policy *agentpro
}
}

func (t *Translator) completeCollaborationSettings(threadID string, params map[string]any) error {
mode := lookupMapFromAny(params["collaborationMode"])
if len(mode) == 0 {
return nil
}
settings := lookupMapFromAny(mode["settings"])
observed := t.observedThreads[threadID]
model := choose(xutil.LookupStringFromAny(settings["model"]), observed.Model)
if model == "" {
return fmt.Errorf("Codex 未返回当前会话模型,无法设置协作模式;请重新连接会话后重试")
}
settings["model"] = model
if _, exists := settings["reasoning_effort"]; !exists {
effort := xutil.LookupStringFromAny(params["effort"])
if effort == "" && model == observed.Model {
effort = observed.ReasoningEffort
}
settings["reasoning_effort"] = nil
if effort != "" {
settings["reasoning_effort"] = effort
}
}
if _, exists := settings["developer_instructions"]; !exists {
settings["developer_instructions"] = nil
}
if value := xutil.LookupStringFromAny(mode["mode"]); value == "" || value == "custom" {
mode["mode"] = "default"
}
mode["settings"] = settings
params["collaborationMode"] = mode
return nil
}

func normalizeObservedPlanMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "plan":
Expand Down
3 changes: 3 additions & 0 deletions internal/adapter/codex/translator_observe_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ func (t *Translator) ObserveServer(raw []byte) (Result, error) {

if id, ok := message["id"]; ok {
requestID := fmt.Sprint(id)
if threadID := lookupString(message, "result", "thread", "id"); threadID != "" {
t.mergeObservedThread(threadID, lookupString(message, "result", "modelProvider"), lookupString(message, "result", "model"), lookupString(message, "result", "reasoningEffort"))
}
if result, handled := t.observeModelListResponse(requestID, message); handled {
return result, nil
}
Expand Down
8 changes: 8 additions & 0 deletions internal/adapter/codex/translator_overrides_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ func TestTranslatePromptSendPlanModeOnMapsToUpstreamPlanMode(t *testing.T) {
t.Fatalf("observe current thread turn start: %v", err)
}

if _, err := tr.ObserveServer([]byte(`{"method":"thread/settings/updated","params":{"threadId":"thread-1","settings":{"model":"native-default-model","reasoningEffort":"high"}}}`)); err != nil {
t.Fatal(err)
}

commands, err := tr.TranslateCommand(agentproto.Command{
Kind: agentproto.CommandPromptSend,
Origin: agentproto.Origin{ChatID: "surface-1"},
Expand Down Expand Up @@ -207,6 +211,10 @@ func TestTranslatePromptSendPlanModeOffMapsToUpstreamDefaultMode(t *testing.T) {
t.Fatalf("observe current thread turn start: %v", err)
}

if _, err := tr.ObserveServer([]byte(`{"method":"thread/settings/updated","params":{"threadId":"thread-1","settings":{"model":"native-default-model","reasoningEffort":"high"}}}`)); err != nil {
t.Fatal(err)
}

commands, err := tr.TranslateCommand(agentproto.Command{
Kind: agentproto.CommandPromptSend,
Origin: agentproto.Origin{ChatID: "surface-1"},
Expand Down
2 changes: 2 additions & 0 deletions internal/app/daemon/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ func (a *App) registerAPIRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/admin/runtime-status", a.requireAdmin(a.handleRuntimeStatus))
mux.HandleFunc("GET /api/admin/config", a.requireAdmin(a.handleAdminConfig))
mux.HandleFunc("GET /api/admin/codex/profiles", a.requireAdmin(a.handleCodexProfilesList))
mux.HandleFunc("GET /api/admin/codex/default-model", a.requireAdmin(a.handleCodexRemoteDefaultGet))
mux.HandleFunc("PUT /api/admin/codex/default-model", a.requireAdmin(a.handleCodexRemoteDefaultPut))
mux.HandleFunc("POST /api/admin/codex/profiles", a.requireAdmin(a.handleCodexProfileCreate))
mux.HandleFunc("PUT /api/admin/codex/profiles/{id}", a.requireAdmin(a.handleCodexProfileUpdate))
mux.HandleFunc("DELETE /api/admin/codex/profiles/{id}", a.requireAdmin(a.handleCodexProfileDelete))
Expand Down
54 changes: 54 additions & 0 deletions internal/app/daemon/admin_codex_default.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package daemon

import (
"net/http"
"strings"

"github.com/kxn/codex-remote-feishu/internal/config"
)

type codexRemoteDefaultResponse struct {
Model string `json:"model"`
ReasoningEffort string `json:"reasoningEffort"`
}

func (a *App) handleCodexRemoteDefaultGet(w http.ResponseWriter, _ *http.Request) {
loaded, err := a.loadAdminConfig()
if err != nil {
writeAPIError(w, http.StatusInternalServerError, apiError{Code: "config_unavailable", Message: "failed to load config", Details: err.Error()})
return
}
writeJSON(w, http.StatusOK, codexRemoteDefaultResponse{Model: loaded.Config.Codex.DefaultModel, ReasoningEffort: loaded.Config.Codex.DefaultReasoningEffort})
}

func (a *App) handleCodexRemoteDefaultPut(w http.ResponseWriter, r *http.Request) {
var value codexRemoteDefaultResponse
if err := decodeJSONBody(r, &value); err != nil {
writeAPIError(w, http.StatusBadRequest, apiError{Code: "invalid_request", Message: "failed to decode default model", Details: err.Error()})
return
}
value.Model, value.ReasoningEffort = strings.TrimSpace(value.Model), strings.TrimSpace(value.ReasoningEffort)
if err := config.ValidateCodexRemoteDefault(value.Model, value.ReasoningEffort); err != nil {
writeAPIError(w, http.StatusBadRequest, apiError{Code: "codex_default_invalid", Message: err.Error()})
return
}
a.adminConfigMu.Lock()
loaded, err := a.loadAdminConfig()
if err != nil {
a.adminConfigMu.Unlock()
writeAPIError(w, http.StatusInternalServerError, apiError{Code: "config_unavailable", Message: "failed to load config", Details: err.Error()})
return
}
loaded.Config.Codex.DefaultModel = value.Model
loaded.Config.Codex.DefaultReasoningEffort = value.ReasoningEffort
if err := config.WriteAppConfig(loaded.Path, loaded.Config); err != nil {
a.adminConfigMu.Unlock()
writeAPIError(w, http.StatusInternalServerError, apiError{Code: "config_write_failed", Message: "failed to save default model", Details: err.Error()})
return
}
a.mu.Lock()
a.service.SetCodexRemoteDefault(value.Model, value.ReasoningEffort)
a.mu.Unlock()
a.adminConfigMu.Unlock()
writeJSON(w, http.StatusOK, value)
}
Loading