From b056a36c6847ce199c3128343a7c7ca1163504d4 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Sat, 5 Sep 2026 13:39:33 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20Codex=20=E5=AF=B9=E6=8E=A5=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E9=AA=A8=E6=9E=B6=EF=BC=8C=E4=BE=9B=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- AGENTS.md | 1 + docs/architecture.md | 11 ++++ server/pkg/codex/approval.go | 65 ++++++++++++++++++++++ server/pkg/codex/attachment.go | 26 +++++++++ server/pkg/codex/catalog.go | 55 +++++++++++++++++++ server/pkg/codex/command.go | 40 ++++++++++++++ server/pkg/codex/harness.go | 99 ++++++++++++++++++++++++++++++++++ server/pkg/codex/session.go | 59 ++++++++++++++++++++ server/pkg/codex/settings.go | 30 +++++++++++ server/pkg/codex/transcript.go | 43 +++++++++++++++ server/pkg/codex/turn.go | 71 ++++++++++++++++++++++++ 11 files changed, 500 insertions(+) create mode 100644 server/pkg/codex/approval.go create mode 100644 server/pkg/codex/attachment.go create mode 100644 server/pkg/codex/catalog.go create mode 100644 server/pkg/codex/command.go create mode 100644 server/pkg/codex/harness.go create mode 100644 server/pkg/codex/session.go create mode 100644 server/pkg/codex/settings.go create mode 100644 server/pkg/codex/transcript.go create mode 100644 server/pkg/codex/turn.go diff --git a/AGENTS.md b/AGENTS.md index fa99a50..3241abc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - 具体工具定义放在 `server/internal/agent/tools`。工具名、入参/出参、schema、权限和编排都在本包;Execute 若要调外部能力,只通过 `Ports` 里的接口。Runtime `New` 时由 `cmd/server` 注入 `Ports` 的具体实现,再 `Register`。每个工具只定义入参/出参结构体,执行用 `encoding/json`,schema 从类型推断。`tools` 可 import `memory`,不 import 父包 `internal/agent`。 - Agent 通用无状态逻辑放在 `server/pkg/agent`:类型、token 统计、提示词、上下文、Tool 抽象(不含具体工具定义)、Agent 配置、模型调用。 - Git CLI 操作放在 `server/pkg/git`:无状态,不写产品流程;Handler 直接调用。不进 `pkg/agent`。 +- Codex 对接放在 `server/pkg/codex`:看板的子模块,类型与调用图给看板调用;无状态,不查库、不调本机 CLI。不进 `pkg/agent`。 - 进程内事件总线放在 `server/internal/events`。 - 数据库入口和 sqlc 生成代码放在 `server/pkg/db`。 - 数据库结构演进放在 `server/migrations`。 diff --git a/docs/architecture.md b/docs/architecture.md index 1f10207..b7b4fb8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,6 +39,7 @@ server/internal/agent v server/pkg/agent server/pkg/git +server/pkg/codex ``` `pkg/ai` 已删除。大模型调用放在 `pkg/agent`,由 `ModelConfig` 在方法内创建,不由 Runtime 注入。 @@ -69,6 +70,7 @@ CodeDock/ │ ├── pkg/ │ │ ├── agent/ # 全部通用无状态逻辑,含模型调用与 Tool 抽象 │ │ ├── git/ # 无状态 Git CLI 操作,供 Handler 直接调用 +│ │ ├── codex/ # 看板的 Codex 子模块:类型与调用图,供看板调用 │ │ └── db/ # Client 与 sqlc 生成代码 │ ├── migrations/ │ ├── go.mod @@ -120,6 +122,11 @@ pkg/git 不依赖 handler、internal、sqlc 无状态,只 exec 本机 git;不写产品流程 +pkg/codex + 看板的 Codex 子模块,类型与调用图给看板调用 + 不依赖 handler、internal、sqlc + 无状态,不查库、不调本机 CLI;不进 pkg/agent + packages/core 不依赖 React、Next、DOM、process.env、AI SDK 按业务域拆目录(chat),不要 src/ @@ -211,6 +218,10 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 不查库 - `fake`:读 `Model.Options` 脚本(多段 text / tool_calls、失败次数、可取消挂起),测试不打外网 - `openai`:OpenAI 兼容 HTTP(`BaseURL` + API Key) +### `pkg/codex` + +看板的 Codex 子模块。类型与调用图给看板调用;无状态,不查库、不调本机 CLI。不进 `pkg/agent`。 + ### `pkg/db` 统一数据库入口。SQLite 已接入 sqlc;Handler 和运行时直接使用 `*sqlite.Queries`。启动时按文件名顺序应用 `migrations/*.sql`。 diff --git a/server/pkg/codex/approval.go b/server/pkg/codex/approval.go new file mode 100644 index 0000000..a139886 --- /dev/null +++ b/server/pkg/codex/approval.go @@ -0,0 +1,65 @@ +package codex + +// DecisionScope 是这次作答管一次,还是管整条对话。 +type DecisionScope string + +const ( + ScopeOnce DecisionScope = "once" // 只管这一次。 + ScopeSession DecisionScope = "session" // 本会话以后同类不再问。 +) + +// AskKind 是 Codex 已知反问的种类,这几种都要有完整作答界面。 +type AskKind string + +const ( + AskCommand AskKind = "command" // 能不能跑这条命令。 + AskFileChange AskKind = "file_change" // 能不能改这些文件。 + AskQuestion AskKind = "question" // 让人补一句字,或从几个选项里挑一个。 + AskForm AskKind = "form" // MCP 跑起来之后弹出来、要人填的表单。 +) + +// ApprovalAsk 是一条 Codex 已知的反问。 +type ApprovalAsk struct { + Kind AskKind + Command string + Paths []string + Diff string + Prompt string // 选择题或表单给人看的题面。 + Options []string // 补一句字时的选项。 + Fields []string // MCP 表单字段名。 + ExternalRequestID string // 用来回给 Codex 的那张问票。 +} + +// AskAnswer 是人对这条反问的作答。 +type AskAnswer struct { + Approved bool + Scope DecisionScope + Choice string // 选择题选中的项。 + Values []string // 表单填写结果。 +} + +// Approval 管 Codex 已知的反问:跑命令、改文件、补一句字、MCP 弹出的表单,这几种都做完整界面。 +// 不管官方新加、本模块认不出的提问,也不管本地模型那套工具审批。 +type Approval struct { + Codex *Codex + Turn *Turns +} + +// Require 登记一条 Codex 的反问等人作答,拿到结果后回给 Codex,并让这一轮接着跑。 +// 人不回话 Codex 就一直等着,所以这条必须有人答。 +func (a *Approval) Require(turnID string, ask ApprovalAsk) (approvalID string, err error) { + a.Decide("", AskAnswer{}) + a.Codex.ReplyAsk(ask.ExternalRequestID, AskAnswer{}) + a.Turn.Continue(turnID) + return "", nil +} + +// Decide 记下人对这条反问的作答:批还是拒、管一次还是管整条对话、选了哪一项、表单填了什么。 +func (a *Approval) Decide(approvalID string, answer AskAnswer) error { + return nil +} + +// Expire 这条反问放太久没人理,按拒绝回给 Codex,免得它一直等在那儿。 +func (a *Approval) Expire(approvalID string) error { + return nil +} diff --git a/server/pkg/codex/attachment.go b/server/pkg/codex/attachment.go new file mode 100644 index 0000000..30a5528 --- /dev/null +++ b/server/pkg/codex/attachment.go @@ -0,0 +1,26 @@ +package codex + +// Input 是本条要带给 Codex 的正文、文件提及和图片。 +type Input struct { + Text string + Mentions []string // 仓库内路径,对 Codex 的 mention。 + Images []string // 本地图片路径,对 Codex 的 localImage。 +} + +// Attachment 管本条消息将要带给 Codex 的文件提及和图片。不管发送,不管工作目录从哪来。 +type Attachment struct{} + +// Mention 把一个仓库内文件挂到还没发出去的这条内容上,对应 Codex 的 mention。 +func (a *Attachment) Mention(sessionID, path string) error { + return nil +} + +// AttachImage 把一张本地图片挂到还没发出去的这条内容上,对应 Codex 的 localImage。 +func (a *Attachment) AttachImage(sessionID, path string) error { + return nil +} + +// TakeDraft 取出这条攒好的正文与附件并清空草稿,交给回合发给 Codex。 +func (a *Attachment) TakeDraft(sessionID string) (Input, error) { + return Input{}, nil +} diff --git a/server/pkg/codex/catalog.go b/server/pkg/codex/catalog.go new file mode 100644 index 0000000..0af8399 --- /dev/null +++ b/server/pkg/codex/catalog.go @@ -0,0 +1,55 @@ +package codex + +// EngineStatus 是本机 Codex 能不能用的体检结果。 +type EngineStatus struct { + Available bool // 本机装没装 Codex。 + Authorized bool // 有没有取得 Codex 授权;没授权可以看选项,但不能开回合。 + Version string + Hint string // 不可用时给人看的原因,如未安装或未授权。 +} + +// ModelInfo 是一条 Codex 模型及其支持的推理强度。 +type ModelInfo struct { + ID string + Efforts []string // 该 Codex 模型支持的推理强度。 + DefaultEffort string + Hidden bool // Codex 自己不在选择器里列出来的模型。 + IsDefault bool +} + +// ModeInfo 是一条 Codex 的 Plan 或权限预设。 +type ModeInfo struct { + ID string // Codex 的 Plan 或权限预设名,如 auto、read-only、full-access。 + Kind string // collaboration | permission + Approval string // Codex 的 approval 值;Plan 可空。 + Sandbox string // Codex 的 sandbox 值;Plan 可空。 +} + +// Catalog 管本机有没有 Codex、是否已取得授权、允许选哪些模型与模式。不管对话,不管开回合。 +type Catalog struct { + Session *Sessions + Settings *Configs +} + +// Probe 是「开一条 Codex 对话」的入口:先体检本机装没装 Codex、有没有取得授权, +// 再把可选的模型与模式、这条对话本身和它的生效配置一并备好。 +// 未取得授权时只回 Hint 说明原因,这条对话不该开回合。 +func (c *Catalog) Probe() (EngineStatus, error) { + c.ListModels() + c.ListModes() + c.Session.Create("") + c.Settings.Effective("") + return EngineStatus{}, nil +} + +// ListModels 列出 Codex 允许选的模型,以及每个模型支持哪些推理强度、默认用哪一档。 +// 名单来自 Codex,本模块不自造模型名,也不自造强度档位。 +func (c *Catalog) ListModels() ([]ModelInfo, error) { + return nil, nil +} + +// ListModes 列出 Codex 的 Plan 与权限预设(如 auto、read-only、full-access), +// 连带各自对应的 approval 与 sandbox 值,供人在对话里切换。不自造官方没有的档。 +func (c *Catalog) ListModes() ([]ModeInfo, error) { + return nil, nil +} diff --git a/server/pkg/codex/command.go b/server/pkg/codex/command.go new file mode 100644 index 0000000..014fcad --- /dev/null +++ b/server/pkg/codex/command.go @@ -0,0 +1,40 @@ +package codex + +// CommandAction 是一条斜杠或扩展按钮落地后交给谁办。 +type CommandAction string + +const ( + ActionApplySettings CommandAction = "apply_settings" // 交给官方配置改值。 + ActionTurn CommandAction = "turn" // 交给回合,如发送、打断。 + ActionSession CommandAction = "session" // 交给会话,如分叉、归档、改标题。 + ActionAttach CommandAction = "attach" // 交给附件,如挂文件、贴图。 + ActionHint CommandAction = "hint" // 本模块不落地,只提示去终端改 Codex 配置。 +) + +// CommandSpec 是一条与 Codex 斜杠、官方扩展按钮共用的命令。 +type CommandSpec struct { + Name string // 与 Codex 斜杠同名,如 model、plan、fork、mcp。 + Action CommandAction + Hint string // hint 时给人看的话。 +} + +// CommandResult 是这条命令没法在本模块落地时,给人看的说明。 +type CommandResult struct { + Hint string +} + +// Command 管把 Codex 斜杠名和官方扩展按钮收成同一套动作:能做的往下交给会话、配置、 +// 回合或附件,不能做的只给一句提示。不管怎么对 Codex 说话。 +type Command struct{} + +// List 列出对话框 `/` 里能用的命令。这些和官方扩展按钮共用同一套动作,不搞两套语义。 +// `/mcp`、`/skills` 这类也在列表里,但点了只会给提示。 +func (c *Command) List() []CommandSpec { + return nil +} + +// Invoke 执行与 Codex 同名的那个动作。本模块不做的配置类命令不改 Codex 配置文件, +// 只回一句「去终端改」的提示。 +func (c *Command) Invoke(sessionID, name, args string) (CommandResult, error) { + return CommandResult{}, nil +} diff --git a/server/pkg/codex/harness.go b/server/pkg/codex/harness.go new file mode 100644 index 0000000..e1f6105 --- /dev/null +++ b/server/pkg/codex/harness.go @@ -0,0 +1,99 @@ +package codex + +// Codex 管对本机 Codex 说话:开 thread、开一轮、打断、分叉、压缩、评审, +// 以及把反问递进递出。不管本模块有多少对话,不管本地模型。 +type Codex struct { + Transcript *Transcript + Turn *Turns +} + +// StartThread 让 Codex 新建一条 thread。工作目录和这条对话的生效配置由调用方给。 +func (c *Codex) StartThread(cwd string, settings Settings) (threadID string, err error) { + return "", nil +} + +// ResumeThread 接上一条已有的 Codex thread,继续原来那段上下文。 +// thread 已经没了就让这一轮失败,不静默新开一条。 +func (c *Codex) ResumeThread(threadID string) error { + return nil +} + +// ForkThread 按 Codex 自己落盘的历史分出一条新 thread,原 thread 不动。 +func (c *Codex) ForkThread(threadID string) (newThreadID string, err error) { + return "", nil +} + +// StartTurn 把这条输入交给 Codex,让它开始干这一轮。 +func (c *Codex) StartTurn(threadID string, input Input, settings Settings) (turnID string, err error) { + return "", nil +} + +// Interrupt 按用户请求打断 Codex 当前这一轮,不关掉 Codex 本身。 +func (c *Codex) Interrupt(threadID, turnID string) error { + return nil +} + +// Compact 让 Codex 用它自己的办法压缩这条 thread 的上下文。 +func (c *Codex) Compact(threadID string) error { + return nil +} + +// Review 让 Codex 评审当前工作目录里的改动。 +func (c *Codex) Review(threadID string) error { + return nil +} + +// ReplyAsk 把人对已知反问的回答回给 Codex,它拿到回话才会接着往下走。 +func (c *Codex) ReplyAsk(requestID string, answer AskAnswer) error { + return nil +} + +// RejectUnknown 处理官方新加、本模块认不出的提问:在实录里写明这里接不住, +// 再按拒绝回包,让 Codex 自己换个办法或收尾,别把这一轮卡死。 +// 这不等于用户拒绝了某条命令或某批改文件。 +func (c *Codex) RejectUnknown(requestID string) error { + c.Transcript.AppendProgress("", "", Progress{Kind: ProgressNotice}) + c.Turn.Continue("") + return nil +} + +// Module 持有 Codex 对接的九块,自己没有业务方法,方便调用方拿一份就能调各块。 +type Module struct { + Catalog *Catalog + Session *Sessions + Settings *Configs + Command *Command + Attachment *Attachment + Turn *Turns + Transcript *Transcript + Approval *Approval + Codex *Codex +} + +// New 构造九块,并把互相要调的那几根指针接上。 +func New() *Module { + m := &Module{ + Catalog: &Catalog{}, + Session: &Sessions{}, + Settings: &Configs{}, + Command: &Command{}, + Attachment: &Attachment{}, + Turn: &Turns{}, + Transcript: &Transcript{}, + Approval: &Approval{}, + Codex: &Codex{}, + } + m.Catalog.Session = m.Session + m.Catalog.Settings = m.Settings + m.Session.Codex = m.Codex + m.Settings.Command = m.Command + m.Turn.Session = m.Session + m.Turn.Codex = m.Codex + m.Turn.Transcript = m.Transcript + m.Turn.Attachment = m.Attachment + m.Approval.Codex = m.Codex + m.Approval.Turn = m.Turn + m.Codex.Transcript = m.Transcript + m.Codex.Turn = m.Turn + return m +} diff --git a/server/pkg/codex/session.go b/server/pkg/codex/session.go new file mode 100644 index 0000000..bde61bd --- /dev/null +++ b/server/pkg/codex/session.go @@ -0,0 +1,59 @@ +package codex + +// Session 是一条只走 Codex 的对话。 +type Session struct { + ID string + ThreadID string // Codex thread 编号,首次开回合后才有。 + Title string + ActiveTurnID string // 同时只能有一个进行中的回合。 + Archived bool +} + +// Sessions 管走 Codex 的对话容器、Codex thread 编号,以及新建、归档、改标题、分叉。 +// 不管 Codex 配置项的值,不管回合怎么跑。 +type Sessions struct { + Codex *Codex +} + +// Create 开一条只走 Codex 的对话。刚开出来时还没有 Codex thread,首次开回合才绑上。 +// 对话绑死 Codex,中途不能改成本地模型;要换引擎就另开一条。 +func (s *Sessions) Create(userID string) (Session, error) { + return Session{}, nil +} + +// Get 读一条对话:它绑的 Codex thread、标题、有没有回合正在跑、归没归档。 +func (s *Sessions) Get(sessionID string) (Session, error) { + return Session{}, nil +} + +// BindThread 把 Codex 给的 thread 编号记到这条对话上。只能写一次; +// 这条 thread 以后失效就让回合失败,不静默换一条新的。 +func (s *Sessions) BindThread(sessionID, threadID string) error { + return nil +} + +// Archive 归档这条对话,之后不能再向 Codex 开回合。不删本机 Codex 那边的记录。 +func (s *Sessions) Archive(sessionID string) error { + return nil +} + +// Rename 改这条对话的标题。 +func (s *Sessions) Rename(sessionID, title string) error { + return nil +} + +// Fork 按已落盘历史分叉:新开一条对话,配一条新的 Codex thread,原对话原样不动。 +func (s *Sessions) Fork(sessionID string) (Session, error) { + s.Codex.ForkThread("") + return Session{}, nil +} + +// ClaimActiveTurn 把这一轮标成该对话当前正在执行的回合。一条对话同时只能有一个。 +func (s *Sessions) ClaimActiveTurn(sessionID, turnID string) error { + return nil +} + +// ClearActiveTurn 清掉当前执行标记,好让排队里的下一条能被开起来。 +func (s *Sessions) ClearActiveTurn(sessionID, turnID string) error { + return nil +} diff --git a/server/pkg/codex/settings.go b/server/pkg/codex/settings.go new file mode 100644 index 0000000..a8e880b --- /dev/null +++ b/server/pkg/codex/settings.go @@ -0,0 +1,30 @@ +package codex + +// Settings 是这条对话里最终生效的 Codex 模型、推理强度、Plan 与权限。 +type Settings struct { + Model string + Effort string + CollaborationMode string // Codex 的 Plan;空表示非 Plan。 + ApprovalPolicy string // Codex 的值,如 on-request。 + Sandbox string // Codex 的值,如 workspace-write。 + Cwd string // 由调用方(看板)传入的工作路径;问答可空。 + Overridden []string // 用户改过、需要交给 Codex 的字段名。 +} + +// Configs 管这条对话里生效的 Codex 配置,并且只把用户改过的项交给 Codex。 +// 不管发消息,不管命令怎么拆词。 +type Configs struct { + Command *Command +} + +// Effective 返回 Codex 自己的默认值与用户覆盖合并后、这条对话此刻真正生效的配置。 +func (c *Configs) Effective(sessionID string) (Settings, error) { + return Settings{}, nil +} + +// Apply 记下用户这次改动,只记改过的项并写进 Overridden;没改的仍旧跟 Codex 默认走。 +// 不整份下发,也不自造官方没有的档位。 +func (c *Configs) Apply(sessionID string, patch Settings) (Settings, error) { + c.Command.Invoke(sessionID, "", "") + return Settings{}, nil +} diff --git a/server/pkg/codex/transcript.go b/server/pkg/codex/transcript.go new file mode 100644 index 0000000..e4ad92a --- /dev/null +++ b/server/pkg/codex/transcript.go @@ -0,0 +1,43 @@ +package codex + +// ProgressKind 是实录里一条进展的种类。 +type ProgressKind string + +const ( + ProgressUser ProgressKind = "user" + ProgressText ProgressKind = "text" + ProgressReasoning ProgressKind = "reasoning" + ProgressCommand ProgressKind = "command" + ProgressFileChange ProgressKind = "file_change" + ProgressPlan ProgressKind = "plan" + ProgressNotice ProgressKind = "notice" // 给人看的说明,如这里接不住某种提问。 +) + +// Progress 是给人看、可回放的一条 Codex 进展。 +type Progress struct { + Kind ProgressKind + Text string + Command string + Paths []string + Diff string +} + +// Transcript 管把 Codex 的进展落成给人看、可回放的记录。这份记录只给人看, +// 不回灌给 Codex 当上下文。不管驱动 Codex,不管改磁盘。 +type Transcript struct{} + +// AppendUser 记下用户发的这一条,连同它带的文件提及和图片。 +func (t *Transcript) AppendUser(sessionID, turnID, text string, input Input) error { + return nil +} + +// AppendProgress 记下 Codex 这一步的进展:正文、推理、跑了什么命令、改了哪些文件、 +// 出的方案,或者「这里接不住」这类提示。 +func (t *Transcript) AppendProgress(sessionID, turnID string, item Progress) error { + return nil +} + +// Hydrate 按已落下的记录回放整条对话,供人重连后接着看。 +func (t *Transcript) Hydrate(sessionID string) ([]Progress, error) { + return nil, nil +} diff --git a/server/pkg/codex/turn.go b/server/pkg/codex/turn.go new file mode 100644 index 0000000..6bf592d --- /dev/null +++ b/server/pkg/codex/turn.go @@ -0,0 +1,71 @@ +package codex + +// InputMode 是这条输入进来时,对当前进行中回合怎么处理。 +type InputMode string + +const ( + InputStart InputMode = "start" // 对话空闲,直接开一轮。 + InputQueue InputMode = "queue" // 对话正忙,排队等它结束或被手动打断。 +) + +// TurnStatus 是一轮 Codex 工作的状态。 +type TurnStatus string + +const ( + TurnQueued TurnStatus = "queued" + TurnRunning TurnStatus = "running" + TurnWaitingApproval TurnStatus = "waiting_approval" + TurnCompleted TurnStatus = "completed" + TurnFailed TurnStatus = "failed" + TurnCancelled TurnStatus = "cancelled" +) + +// Turn 是一次用户请求对应的那一轮 Codex 工作。 +type Turn struct { + ID string + SessionID string + Status TurnStatus +} + +// Turns 管一轮 Codex 工作的开始、排队和手动打断。进行中再发下一条只排队、不自动插话; +// 要停掉当前这轮只能手动打断。不管实录怎么记,不管人怎么点批准。 +type Turns struct { + Session *Sessions + Codex *Codex + Transcript *Transcript + Attachment *Attachment +} + +// Start 发出这条输入。对话空闲就向 Codex 开新一轮:取出攒好的附件、占住当前执行位、 +// 首次还要开 thread 并把编号记回对话,再把用户这条写进实录。 +// 对话正忙则按排队处理,不打断在跑的那一轮。 +func (t *Turns) Start(sessionID, content string, input Input, mode InputMode) (turnID string, err error) { + t.Attachment.Mention(sessionID, "") + t.Session.ClaimActiveTurn(sessionID, "") + t.Codex.StartThread("", Settings{}) + t.Session.BindThread(sessionID, "") + t.Codex.StartTurn("", input, Settings{}) + t.Transcript.AppendUser(sessionID, "", content, input) + return "", nil +} + +// Queue 把这条输入排到当前这轮后面,先写进实录给人看; +// 等当前一轮自己结束或被手动打断,再轮到它开。 +func (t *Turns) Queue(sessionID, content string, input Input) (turnID string, err error) { + t.Transcript.AppendUser(sessionID, "", content, input) + return "", nil +} + +// Cancel 按用户请求手动打断当前这一轮:向 Codex 传播取消、腾出当前执行位, +// 再把排着的下一条开起来。 +func (t *Turns) Cancel(turnID string) error { + t.Codex.Interrupt("", turnID) + t.Session.ClearActiveTurn("", turnID) + t.Start("", "", Input{}, InputStart) + return nil +} + +// Continue 在反问有了结果后让这一轮接着跑,不新开回合。 +func (t *Turns) Continue(turnID string) error { + return nil +} From 67b3ee01a4f02660cb173be565f2baf007f3139c Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Mon, 7 Sep 2026 14:27:45 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Codex=20?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=90=8E=E7=AB=AF=E5=B9=B6=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=20app-server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以官方 thread 为数据源,提供 /codex HTTP、进程内排队/问票/SSE,并加上 85% 覆盖率门禁。 Co-authored-by: Cursor --- .env.example | 4 + .github/workflows/ci.yml | 10 + AGENTS.md | 5 +- "docs/Codex\345\257\271\346\216\245.md" | 32 ++ docs/architecture.md | 30 +- server/cmd/server/main.go | 7 +- server/cmd/server/router.go | 8 +- server/internal/codex/approval.go | 82 ++++ server/internal/codex/catalog.go | 118 +++++ server/internal/codex/command.go | 76 +++ server/internal/codex/events.go | 60 +++ server/internal/codex/fake.go | 195 ++++++++ server/internal/codex/live_test.go | 80 ++++ server/internal/codex/loopback.go | 47 ++ server/internal/codex/process.go | 117 +++++ server/internal/codex/process_test.go | 102 ++++ server/internal/codex/runtime.go | 445 ++++++++++++++++++ server/internal/codex/runtime_test.go | 524 +++++++++++++++++++++ server/internal/codex/session.go | 208 +++++++++ server/internal/codex/turn.go | 148 ++++++ server/internal/config/config.go | 2 + server/internal/config/config_test.go | 8 + server/internal/handler/codex/api.go | 402 ++++++++++++++++ server/internal/handler/codex/api_test.go | 242 ++++++++++ server/pkg/codex/approval.go | 71 ++- server/pkg/codex/ask.go | 293 ++++++++++++ server/pkg/codex/attachment.go | 50 +- server/pkg/codex/catalog.go | 58 +-- server/pkg/codex/client.go | 251 ++++++++++ server/pkg/codex/client_test.go | 231 +++++++++ server/pkg/codex/codex_test.go | 544 ++++++++++++++++++++++ server/pkg/codex/command.go | 52 ++- server/pkg/codex/event.go | 29 ++ server/pkg/codex/harness.go | 139 ++---- server/pkg/codex/loopback.go | 41 ++ server/pkg/codex/progress.go | 152 ++++++ server/pkg/codex/protocol.go | 228 +++++++++ server/pkg/codex/protocol_test.go | 97 ++++ server/pkg/codex/rpc.go | 420 +++++++++++++++++ server/pkg/codex/session.go | 72 +-- server/pkg/codex/settings.go | 92 +++- server/pkg/codex/transcript.go | 32 +- server/pkg/codex/transport.go | 137 ++++++ server/pkg/codex/turn.go | 66 +-- 44 files changed, 5650 insertions(+), 357 deletions(-) create mode 100644 "docs/Codex\345\257\271\346\216\245.md" create mode 100644 server/internal/codex/approval.go create mode 100644 server/internal/codex/catalog.go create mode 100644 server/internal/codex/command.go create mode 100644 server/internal/codex/events.go create mode 100644 server/internal/codex/fake.go create mode 100644 server/internal/codex/live_test.go create mode 100644 server/internal/codex/loopback.go create mode 100644 server/internal/codex/process.go create mode 100644 server/internal/codex/process_test.go create mode 100644 server/internal/codex/runtime.go create mode 100644 server/internal/codex/runtime_test.go create mode 100644 server/internal/codex/session.go create mode 100644 server/internal/codex/turn.go create mode 100644 server/internal/handler/codex/api.go create mode 100644 server/internal/handler/codex/api_test.go create mode 100644 server/pkg/codex/ask.go create mode 100644 server/pkg/codex/client.go create mode 100644 server/pkg/codex/client_test.go create mode 100644 server/pkg/codex/codex_test.go create mode 100644 server/pkg/codex/event.go create mode 100644 server/pkg/codex/loopback.go create mode 100644 server/pkg/codex/progress.go create mode 100644 server/pkg/codex/protocol.go create mode 100644 server/pkg/codex/protocol_test.go create mode 100644 server/pkg/codex/rpc.go create mode 100644 server/pkg/codex/transport.go diff --git a/.env.example b/.env.example index 2be9b5c..011bc46 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,7 @@ LLM_BASE_URL= # 撤回会改磁盘。本地试用先跑 scripts/git-sandbox.sh,再把这里指到 tmp/git-sandbox。 # 要操作本仓时显式写成仓根。pnpm dev:api 在沙箱存在时默认用沙箱。 GIT_REPO= + +# 本机 Codex CLI 可执行文件(默认 codex)。未安装时主服务仍可启动,/codex/status 会说明原因。 +CODEX_BIN=codex + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28d7ab8..36b0010 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,16 @@ jobs: - name: Test run: go test ./... + - name: Codex coverage + run: | + go test -covermode=atomic -coverprofile=/tmp/codex.out \ + -coverpkg=codedock/pkg/codex,codedock/internal/codex,codedock/internal/handler/codex \ + ./pkg/codex ./internal/codex ./internal/handler/codex + total=$(go tool cover -func=/tmp/codex.out | awk '/^total:/ {print $3}' | tr -d '%') + echo "codex coverage: ${total}%" + awk -v t="$total" 'BEGIN { if (t+0 < 85) { print "codex coverage below 85%"; exit 1 } }' + + - name: Build run: go build -o /tmp/codedock ./cmd/server diff --git a/AGENTS.md b/AGENTS.md index 3241abc..6434d2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,13 +7,14 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 ## 目录放置规则 - 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。 -- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。 +- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。 - Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。 +- 本机 Codex app-server 生命周期、内存排队/问票/SSE 放在 `server/internal/codex`。不新增 Codex 业务表;凡官方 API 能读到的都不入库。 - Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。 - 具体工具定义放在 `server/internal/agent/tools`。工具名、入参/出参、schema、权限和编排都在本包;Execute 若要调外部能力,只通过 `Ports` 里的接口。Runtime `New` 时由 `cmd/server` 注入 `Ports` 的具体实现,再 `Register`。每个工具只定义入参/出参结构体,执行用 `encoding/json`,schema 从类型推断。`tools` 可 import `memory`,不 import 父包 `internal/agent`。 - Agent 通用无状态逻辑放在 `server/pkg/agent`:类型、token 统计、提示词、上下文、Tool 抽象(不含具体工具定义)、Agent 配置、模型调用。 - Git CLI 操作放在 `server/pkg/git`:无状态,不写产品流程;Handler 直接调用。不进 `pkg/agent`。 -- Codex 对接放在 `server/pkg/codex`:看板的子模块,类型与调用图给看板调用;无状态,不查库、不调本机 CLI。不进 `pkg/agent`。 +- Codex 协议与领域类型放在 `server/pkg/codex`:看板的子模块,JSONL 客户端给 `internal/codex` 调用;不查库、不 spawn CLI。不进 `pkg/agent`。 - 进程内事件总线放在 `server/internal/events`。 - 数据库入口和 sqlc 生成代码放在 `server/pkg/db`。 - 数据库结构演进放在 `server/migrations`。 diff --git "a/docs/Codex\345\257\271\346\216\245.md" "b/docs/Codex\345\257\271\346\216\245.md" new file mode 100644 index 0000000..3d4e335 --- /dev/null +++ "b/docs/Codex\345\257\271\346\216\245.md" @@ -0,0 +1,32 @@ +# Codex 对接 模块 + +把本机已安装的 Codex 接到看板可调用的后端。协议走 `codex app-server` JSONL,不把 Codex 当成本地模型的又一个供应商。 + +## 功能职责 + +- 发现本机是否装了 Codex、是否已取得授权、有哪些模型与模式 +- 一个对话只走 Codex,`session_id` 即官方 `thread_id` +- 只把用户改过的模型、推理强度、Plan、权限交给 Codex +- `/` 命令与官方扩展按钮共用同一套动作 +- 给本条消息挂文件提及或图片 +- 发送、排队、手动打断;正忙时不自动插话 +- 分叉、归档、改标题、新开对话 +- 回放正文、推理、命令、改文件、方案 +- 回答跑命令、改文件、补一句、MCP 表单、额外权限 +- 认不出的官方反问立刻按 `-32601` 回包,避免转圈 + +## 边界 + +- 不走本地对话的工具、记忆和压缩 +- 绑了 Codex 的对话不能中途改成本地模型 +- 一条对话同时只有一个进行中的回合;多条对话可以并行 +- 共用本机 `~/.codex` 配置与授权,不另存密钥 +- 凡 Codex CLI / app-server 能读到的都不进 CodeDock 数据库 +- 排队、附件草稿、问票、SSE 环只在当前进程里;重启后不恢复、不重发 +- `/mcp`、`/skills` 只提示去终端改 + +## 分层 + +- `pkg/codex`:领域类型与协议客户端,不 spawn CLI +- `internal/codex`:本机进程与内存编排 +- `internal/handler/codex`:独立 `/codex/*` HTTP diff --git a/docs/architecture.md b/docs/architecture.md index b7b4fb8..71b5b35 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,9 +59,11 @@ CodeDock/ │ ├── cmd/server/ # 服务启动、配置、Router 和依赖装配 │ ├── internal/ │ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git +│ │ │ └── codex/ # 独立 /codex HTTP 薄桥接 │ │ ├── agent/ # 运行时编排 + sqlc 持久化 │ │ │ ├── memory/ # 热层目录+专题,冷层工作区 FTS 索引 │ │ │ └── tools/ # 具体工具定义:ping、memory_* +│ │ ├── codex/ # 本机 app-server 生命周期与内存排队/问票/SSE │ │ ├── events/ # 进程内事件总线 │ │ ├── config/ │ │ ├── logger/ @@ -70,7 +72,7 @@ CodeDock/ │ ├── pkg/ │ │ ├── agent/ # 全部通用无状态逻辑,含模型调用与 Tool 抽象 │ │ ├── git/ # 无状态 Git CLI 操作,供 Handler 直接调用 -│ │ ├── codex/ # 看板的 Codex 子模块:类型与调用图,供看板调用 +│ │ ├── codex/ # 看板的 Codex 子模块:协议客户端与领域类型 │ │ └── db/ # Client 与 sqlc 生成代码 │ ├── migrations/ │ ├── go.mod @@ -83,6 +85,8 @@ CodeDock/ ```text cmd/server -> internal/handler + -> internal/handler/codex + -> internal/codex -> internal/agent -> internal/events -> pkg/db @@ -94,6 +98,15 @@ internal/handler -> internal/agent # Worker 领取后的 Loop -> internal/agent/memory # 用户侧记忆响应类型 +internal/handler/codex + -> internal/codex + -> pkg/codex + +internal/codex + -> pkg/codex # JSONL 协议客户端;不查库 + 启动本机 `codex app-server --stdio` + 排队、草稿、问票、SSE 只放内存 + internal/agent -> pkg/db/sqlite.Queries -> pkg/agent @@ -123,9 +136,10 @@ pkg/git 无状态,只 exec 本机 git;不写产品流程 pkg/codex - 看板的 Codex 子模块,类型与调用图给看板调用 + 看板的 Codex 子模块:领域类型与 app-server JSONL 协议客户端 不依赖 handler、internal、sqlc - 无状态,不查库、不调本机 CLI;不进 pkg/agent + 不查库、不 spawn `codex`;Transport 由 internal/codex 注入 + 不进 pkg/agent packages/core 不依赖 React、Next、DOM、process.env、AI SDK @@ -220,7 +234,15 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 不查库 ### `pkg/codex` -看板的 Codex 子模块。类型与调用图给看板调用;无状态,不查库、不调本机 CLI。不进 `pkg/agent`。 +看板的 Codex 子模块。领域类型与 `codex app-server` JSONL 协议客户端给看板 / `internal/codex` 调用。不查库、不 spawn CLI。`session_id` 即官方 `thread_id`。不进 `pkg/agent`。 + +### `internal/codex` + +本机 `codex app-server --stdio` 生命周期与内存编排:懒启动、握手、崩溃后不重发当前回合。官方 `thread/list/read` 是历史数据源;排队、附件草稿、问票和 SSE 环只驻进程内。未安装或未授权不能拖垮主服务。 + +### `internal/handler/codex` + +独立 `/codex/*` HTTP 薄桥接。不写 Codex 业务表。 ### `pkg/db` diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 4a3ef7f..7f46061 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -13,9 +13,11 @@ import ( "codedock/internal/agent" agenttools "codedock/internal/agent/tools" + intcodex "codedock/internal/codex" "codedock/internal/config" "codedock/internal/events" "codedock/internal/handler" + codexhttp "codedock/internal/handler/codex" "codedock/internal/logger" pkgagent "codedock/pkg/agent" "codedock/pkg/db" @@ -61,10 +63,13 @@ func main() { defaults := pkgagent.DefaultRunConfig(pkgagent.ModeAskForApproval, model) api := handler.New(client, queries, runtime, bus, defaults, cfg, logger.NewLogger("handler")) + codexRT := intcodex.New(intcodex.Options{Bin: cfg.CodexBin}) + defer func() { _ = codexRT.Close() }() + codexAPI := codexhttp.New(codexRT) server := &http.Server{ Addr: cfg.HTTPAddr, - Handler: newRouter(log, api), + Handler: newRouter(log, api, codexAPI), ReadHeaderTimeout: 5 * time.Second, } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 3ee4f1b..3b7ab8f 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -5,13 +5,14 @@ import ( "net/http" "codedock/internal/handler" + codexhttp "codedock/internal/handler/codex" "codedock/internal/logger" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) -// newRouter 注册健康检查与 Session / Run / Approval / Memory / Git 路由。 -func newRouter(log *slog.Logger, api *handler.API) http.Handler { +// newRouter 注册健康检查、Agent CRUD 与独立 /codex 路由。 +func newRouter(log *slog.Logger, api *handler.API, codexAPI *codexhttp.API) http.Handler { router := chi.NewRouter() router.Use(cors) router.Use(middleware.RequestID) @@ -86,6 +87,9 @@ func newRouter(log *slog.Logger, api *handler.API) http.Handler { r.Post("/undo", api.GitClickUndo) }) } + if codexAPI != nil { + codexAPI.Mount(router) + } return router } diff --git a/server/internal/codex/approval.go b/server/internal/codex/approval.go new file mode 100644 index 0000000..b66f4f7 --- /dev/null +++ b/server/internal/codex/approval.go @@ -0,0 +1,82 @@ +package codex + +import ( + "context" + + cderr "codedock/internal/errors" + pkg "codedock/pkg/codex" +) + +// PendingAsks 列出这条对话还没作答的反问。 +func (rt *Runtime) PendingAsks(sessionID string) []pkg.ApprovalAsk { + st := rt.state(sessionID) + st.mu.Lock() + defer st.mu.Unlock() + out := make([]pkg.ApprovalAsk, 0, len(st.asks)) + for _, item := range st.asks { + if item.resolved { + continue + } + out = append(out, item.ask) + } + return out +} + +func (rt *Runtime) lookupAsk(requestID string) (sessionID string, st *sessionMem) { + rt.mu.Lock() + defer rt.mu.Unlock() + for id, mem := range rt.mem { + mem.mu.Lock() + _, ok := mem.asks[requestID] + mem.mu.Unlock() + if ok { + return id, mem + } + } + return "", nil +} + +// Decide 记下作答并回给 Codex。resolved 之后的迟到作答会被拒绝。 +func (rt *Runtime) Decide(ctx context.Context, requestID string, answer pkg.AskAnswer) error { + sessionID, st := rt.lookupAsk(requestID) + if st == nil { + return cderr.NotFound("ask %s", requestID) + } + st.mu.Lock() + item := st.asks[requestID] + if item == nil { + st.mu.Unlock() + return cderr.NotFound("ask %s", requestID) + } + if item.resolved { + st.mu.Unlock() + return cderr.Conflict("ask %s already resolved", requestID) + } + rpcID := item.rpcID + ask := item.ask + st.mu.Unlock() + + client, err := rt.ensureClient(ctx) + if err != nil { + return err + } + if err := client.Reply(ctx, rpcID, pkg.ReplyBody(ask, answer)); err != nil { + return err + } + + st.mu.Lock() + if current := st.asks[requestID]; current != nil { + current.resolved = true + if st.active != nil { + st.active.Status = pkg.TurnRunning + } + } + st.mu.Unlock() + rt.emit(sessionID, pkg.Event{Type: pkg.EventAskResolved, TurnID: ask.TurnID, Ask: &ask}) + return nil +} + +// Expire 按拒绝回给 Codex,免得它一直等。 +func (rt *Runtime) Expire(ctx context.Context, requestID string) error { + return rt.Decide(ctx, requestID, pkg.AskAnswer{Approved: false}) +} diff --git a/server/internal/codex/catalog.go b/server/internal/codex/catalog.go new file mode 100644 index 0000000..6c9303e --- /dev/null +++ b/server/internal/codex/catalog.go @@ -0,0 +1,118 @@ +package codex + +import ( + "context" + "strings" + + cderr "codedock/internal/errors" + pkg "codedock/pkg/codex" +) + +// Probe 体检本机 Codex:装没装、版本、有没有授权。 +func (rt *Runtime) Probe(ctx context.Context) (pkg.EngineStatus, error) { + path, err := rt.lookPath(rt.bin) + if err != nil { + return pkg.EngineStatus{Hint: "本机没有安装 Codex CLI。"}, nil + } + out, err := rt.version(ctx, path) + version := ParseVersion(out) + if err != nil && version == "" { + return pkg.EngineStatus{Available: true, Version: strings.TrimSpace(out), Hint: "无法读取 Codex 版本。"}, nil + } + status := pkg.EngineStatus{Available: true, Version: version} + client, err := rt.ensureClient(ctx) + if err != nil { + status.Hint = err.Error() + return status, nil + } + account, err := client.AccountRead(ctx) + if err != nil { + status.Hint = err.Error() + return status, nil + } + status.Authorized = pkg.Authorized(account) + if !status.Authorized { + status.Hint = "本机 Codex 尚未取得授权,可以看选项但不能开回合。" + } + return status, nil +} + +// ListModels 列出 Codex 允许选的模型。 +func (rt *Runtime) ListModels(ctx context.Context) ([]pkg.ModelInfo, error) { + client, err := rt.ensureClient(ctx) + if err != nil { + return nil, err + } + includeHidden := false + var models []pkg.ModelInfo + cursor := "" + for { + page, err := client.ModelList(ctx, pkg.ModelListParams{Cursor: cursor, IncludeHidden: &includeHidden, Limit: 100}) + if err != nil { + return nil, mapRPC(err) + } + for _, raw := range page.Data { + info, err := pkg.ParseModel(raw) + if err != nil { + continue + } + models = append(models, info) + } + if page.NextCursor == "" { + break + } + cursor = page.NextCursor + } + return models, nil +} + +// ListModes 列出 Plan 档和权限预设。 +func (rt *Runtime) ListModes(ctx context.Context) ([]pkg.ModeInfo, error) { + modes := pkg.CollaborationModes() + client, err := rt.ensureClient(ctx) + if err != nil { + return modes, err + } + cursor := "" + for { + page, err := client.PermissionProfileList(ctx, pkg.CursorListParams{Cursor: cursor, Limit: 100}) + if err != nil { + return modes, nil + } + for _, raw := range page.Data { + info, err := pkg.ParsePermissionProfile(raw) + if err != nil { + continue + } + modes = append(modes, info) + } + if page.NextCursor == "" { + break + } + cursor = page.NextCursor + } + return modes, nil +} + +// Commands 列出斜杠命令。 +func (rt *Runtime) Commands() []pkg.CommandSpec { + return pkg.Commands() +} + +func (rt *Runtime) requireReady(ctx context.Context) (*pkg.Client, error) { + status, err := rt.Probe(ctx) + if err != nil { + return nil, err + } + if !status.Available { + return nil, cderr.Unavailable("%s", first(status.Hint, "codex is not installed")) + } + client, err := rt.ensureClient(ctx) + if err != nil { + return nil, err + } + if !status.Authorized { + return nil, cderr.Unauthorized("%s", first(status.Hint, "codex is not authorized")) + } + return client, nil +} diff --git a/server/internal/codex/command.go b/server/internal/codex/command.go new file mode 100644 index 0000000..f0e0656 --- /dev/null +++ b/server/internal/codex/command.go @@ -0,0 +1,76 @@ +package codex + +import ( + "context" + "strings" + + cderr "codedock/internal/errors" + pkg "codedock/pkg/codex" +) + +// Invoke 执行与 Codex 同名的动作。 +func (rt *Runtime) Invoke(ctx context.Context, sessionID, name, args string) (pkg.CommandResult, error) { + spec, ok := pkg.LookupCommand(name) + if !ok { + return pkg.CommandResult{}, cderr.Invalid("unknown command %s", name) + } + switch spec.Action { + case pkg.ActionHint: + return pkg.CommandResult{Hint: spec.Hint, Action: spec.Action, Handled: true}, nil + case pkg.ActionApplySettings: + patch := pkg.Settings{} + switch spec.Field { + case "model": + patch.Model = args + case "effort": + patch.Effort = args + case "collaboration_mode": + patch.CollaborationMode = first(args, "plan") + case "sandbox": + patch.Sandbox = args + case "approval_policy": + patch.ApprovalPolicy = args + } + if _, err := rt.ApplySettings(ctx, sessionID, patch); err != nil { + return pkg.CommandResult{}, err + } + return pkg.CommandResult{Action: spec.Action, Handled: true}, nil + case pkg.ActionTurn: + switch spec.Name { + case "stop": + st := rt.state(sessionID) + st.mu.Lock() + id := "" + if st.active != nil { + id = st.active.ID + } + st.mu.Unlock() + if id == "" { + return pkg.CommandResult{}, cderr.Invalid("no running turn") + } + return pkg.CommandResult{Action: spec.Action, Handled: true}, rt.CancelTurn(ctx, sessionID, id) + case "compact": + return pkg.CommandResult{Action: spec.Action, Handled: true}, rt.Compact(ctx, sessionID) + case "review": + return pkg.CommandResult{Action: spec.Action, Handled: true}, rt.Review(ctx, sessionID) + } + case pkg.ActionSession: + switch spec.Name { + case "archive": + return pkg.CommandResult{Action: spec.Action, Handled: true}, rt.Archive(ctx, sessionID) + case "rename": + return pkg.CommandResult{Action: spec.Action, Handled: true}, rt.Rename(ctx, sessionID, strings.TrimSpace(args)) + case "fork": + _, err := rt.Fork(ctx, sessionID) + return pkg.CommandResult{Action: spec.Action, Handled: true}, err + } + case pkg.ActionAttach: + switch spec.Name { + case "mention": + return pkg.CommandResult{Action: spec.Action, Handled: true}, rt.Mention(ctx, sessionID, strings.TrimSpace(args)) + case "image": + return pkg.CommandResult{Action: spec.Action, Handled: true}, rt.AttachImage(ctx, sessionID, strings.TrimSpace(args)) + } + } + return pkg.CommandResult{Action: spec.Action}, nil +} diff --git a/server/internal/codex/events.go b/server/internal/codex/events.go new file mode 100644 index 0000000..5645dd0 --- /dev/null +++ b/server/internal/codex/events.go @@ -0,0 +1,60 @@ +package codex + +import ( + "sync" + + pkg "codedock/pkg/codex" +) + +const ringSize = 256 + +type eventRing struct { + mu sync.Mutex + seq int64 + items []pkg.Event + oldest int64 +} + +func newRing() *eventRing { + return &eventRing{oldest: 1} +} + +func (r *eventRing) append(ev pkg.Event) pkg.Event { + r.mu.Lock() + defer r.mu.Unlock() + r.seq++ + ev.Seq = r.seq + r.items = append(r.items, ev) + if len(r.items) > ringSize { + r.items = r.items[len(r.items)-ringSize:] + r.oldest = r.items[0].Seq + } + return ev +} + +func (r *eventRing) after(seq int64) (events []pkg.Event, reset bool) { + r.mu.Lock() + defer r.mu.Unlock() + if seq < 0 { + seq = 0 + } + if len(r.items) == 0 { + return nil, false + } + if seq+1 < r.oldest { + return append([]pkg.Event(nil), r.items...), true + } + out := make([]pkg.Event, 0, len(r.items)) + for _, ev := range r.items { + if ev.Seq > seq { + out = append(out, ev) + } + } + return out, false +} + +func (r *eventRing) last() int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.seq +} diff --git a/server/internal/codex/fake.go b/server/internal/codex/fake.go new file mode 100644 index 0000000..3cfc385 --- /dev/null +++ b/server/internal/codex/fake.go @@ -0,0 +1,195 @@ +package codex + +import ( + "encoding/json" + "strconv" + "sync" + "sync/atomic" + + pkg "codedock/pkg/codex" +) + +// FakeHandler 是覆盖本模块用到的官方方法的假 app-server。 +type FakeHandler struct { + mu sync.Mutex + Threads map[string]pkg.ThreadObject + ThreadsN atomic.Int64 + Turns atomic.Int64 + Asks atomic.Int64 + Authorized bool + SendAsk bool + Unknown bool + FailTurn bool + OnRequest func(pkg.Envelope) +} + +// NewFakeHandler 默认已授权、可开会话。 +func NewFakeHandler() *FakeHandler { + return &FakeHandler{ + Threads: map[string]pkg.ThreadObject{}, + Authorized: true, + } +} + +// Handle 实现假 app-server。 +func (f *FakeHandler) Handle(env pkg.Envelope) []pkg.Envelope { + if f.OnRequest != nil { + f.OnRequest(env) + } + if env.Method == "" { + return nil + } + switch env.Method { + case pkg.MethodInitialize: + return []pkg.Envelope{{ID: env.ID, Result: json.RawMessage(`{"codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos","userAgent":"codex"}`)}} + case pkg.MethodInitialized: + return nil + case pkg.MethodAccountRead: + if f.Authorized { + return []pkg.Envelope{{ID: env.ID, Result: json.RawMessage(`{"requiresOpenaiAuth":false,"account":{"type":"apiKey"}}`)}} + } + return []pkg.Envelope{{ID: env.ID, Result: json.RawMessage(`{"requiresOpenaiAuth":true,"account":null}`)}} + case pkg.MethodModelList: + cursor := jsonField(env.Params, "cursor") + if cursor == "" { + return []pkg.Envelope{{ID: env.ID, Result: json.RawMessage(`{"data":[{"id":"gpt-5.6","displayName":"GPT","defaultReasoningEffort":"medium","supportedReasoningEfforts":["low","medium",{"effort":"high"},{"id":"minimal"}],"hidden":false,"isDefault":true},"bad"],"nextCursor":"p2"}`)}} + } + return []pkg.Envelope{{ID: env.ID, Result: json.RawMessage(`{"data":[{"model":"gpt-other","displayName":"Other","supportedReasoningEfforts":[],"hidden":true,"isDefault":false}],"nextCursor":null}`)}} + case pkg.MethodPermissionProfileList: + cursor := jsonField(env.Params, "cursor") + if cursor == "" { + return []pkg.Envelope{{ID: env.ID, Result: json.RawMessage(`{"data":[{"id":"read-only","description":"Read only","allowed":true},"bad"],"nextCursor":"p2"}`)}} + } + return []pkg.Envelope{{ID: env.ID, Result: json.RawMessage(`{"data":[{"id":"full-access","description":"Full","allowed":true}],"nextCursor":null}`)}} + case pkg.MethodConfigRead: + return []pkg.Envelope{{ID: env.ID, Result: json.RawMessage(`{"config":{"model":"gpt-5.6","model_reasoning_effort":"medium","approval_policy":"on-request","sandbox_mode":"workspace-write"},"origins":{}}`)}} + case pkg.MethodThreadStart: + th := f.addThread("thread-"+itoa(f.ThreadsN.Add(1)), "New thread") + body, _ := json.Marshal(map[string]any{"thread": th, "model": "gpt-5.6", "cwd": "/tmp", "approvalPolicy": "on-request", "sandbox": "workspace-write"}) + return []pkg.Envelope{{ID: env.ID, Result: body}} + case pkg.MethodThreadList: + f.mu.Lock() + data := make([]pkg.ThreadObject, 0, len(f.Threads)) + for _, th := range f.Threads { + data = append(data, th) + } + f.mu.Unlock() + body, _ := json.Marshal(map[string]any{"data": data}) + return []pkg.Envelope{{ID: env.ID, Result: body}} + case pkg.MethodThreadRead, pkg.MethodThreadResume: + id := jsonField(env.Params, "threadId") + f.mu.Lock() + th, ok := f.Threads[id] + f.mu.Unlock() + if !ok { + return []pkg.Envelope{{ID: env.ID, Error: &pkg.RPCError{Code: -32602, Message: "unknown thread"}}} + } + if env.Method == pkg.MethodThreadRead { + body, _ := json.Marshal(map[string]any{"thread": th}) + return []pkg.Envelope{{ID: env.ID, Result: body}} + } + body, _ := json.Marshal(map[string]any{"thread": th, "model": "gpt-5.6", "cwd": "/tmp", "approvalPolicy": "on-request", "sandbox": "workspace-write"}) + return []pkg.Envelope{{ID: env.ID, Result: body}} + case pkg.MethodThreadFork: + th := f.addThread("thread-fork-"+itoa(f.ThreadsN.Add(1)), "Fork") + body, _ := json.Marshal(map[string]any{"thread": th, "model": "gpt-5.6", "cwd": "/tmp", "approvalPolicy": "on-request", "sandbox": "workspace-write"}) + return []pkg.Envelope{{ID: env.ID, Result: body}} + case pkg.MethodThreadArchive, pkg.MethodThreadUnarchive, pkg.MethodThreadNameSet, pkg.MethodThreadCompact, pkg.MethodReviewStart, pkg.MethodTurnInterrupt: + if env.Method == pkg.MethodTurnInterrupt { + tid := jsonField(env.Params, "threadId") + turnID := jsonField(env.Params, "turnId") + completed, _ := json.Marshal(map[string]any{"threadId": tid, "turn": map[string]any{"id": turnID, "status": "interrupted", "items": []any{}}}) + return []pkg.Envelope{ + {ID: env.ID, Result: json.RawMessage(`{}`)}, + {Method: pkg.MethodTurnCompleted, Params: completed}, + } + } + return []pkg.Envelope{{ID: env.ID, Result: json.RawMessage(`{}`)}} + case pkg.MethodTurnStart: + turnID := "turn-" + itoa(f.Turns.Add(1)) + threadID := jsonField(env.Params, "threadId") + result, _ := json.Marshal(map[string]any{"turn": map[string]any{"id": turnID, "status": "inProgress", "items": []any{}}}) + delta, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "itemId": "m1", "delta": "hello"}) + reason, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "itemId": "r1", "delta": "think"}) + cmdOut, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "itemId": "c1", "delta": "out"}) + fileDelta, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "itemId": "f1", "delta": "+x"}) + planDelta, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "itemId": "p1", "delta": "step"}) + errNote, _ := json.Marshal(map[string]any{"threadId": threadID, "message": "warn"}) + item, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "item": map[string]any{"id": "m1", "type": "agentMessage", "text": "hello"}, "completedAtMs": 1}) + cmdItem, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "item": map[string]any{"id": "c1", "type": "commandExecution", "command": "ls", "aggregatedOutput": "ok", "status": "completed"}}) + fileItem, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "item": map[string]any{"id": "f1", "type": "fileChange", "status": "completed", "changes": []any{map[string]any{"path": "a.go", "diff": "+x"}}}}) + planItem, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "item": map[string]any{"id": "p1", "type": "plan", "text": "do"}}) + reasonItem, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "item": map[string]any{"id": "r1", "type": "reasoning", "summary": []any{"think"}}}) + userItem, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "item": map[string]any{"id": "u1", "type": "userMessage", "content": []any{map[string]any{"type": "text", "text": "hi"}}}}) + otherItem, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "item": map[string]any{"id": "x1", "type": "todoList"}}) + done, _ := json.Marshal(map[string]any{"threadId": threadID, "turn": map[string]any{"id": turnID, "status": "completed", "items": []any{}}}) + failed, _ := json.Marshal(map[string]any{"threadId": threadID, "turn": map[string]any{"id": turnID, "status": "failed", "error": map[string]any{"message": "boom"}, "items": []any{}}}) + out := []pkg.Envelope{ + {ID: env.ID, Result: result}, + {Method: pkg.MethodTurnStarted, Params: mustRaw(map[string]any{"threadId": threadID, "turn": map[string]any{"id": turnID, "status": "inProgress"}})}, + {Method: pkg.MethodAgentMessageDelta, Params: delta}, + {Method: pkg.MethodReasoningTextDelta, Params: reason}, + {Method: pkg.MethodCommandOutputDelta, Params: cmdOut}, + {Method: pkg.MethodFileChangeDelta, Params: fileDelta}, + {Method: pkg.MethodPlanDelta, Params: planDelta}, + {Method: pkg.MethodError, Params: errNote}, + {Method: pkg.MethodItemStarted, Params: item}, + {Method: pkg.MethodItemCompleted, Params: item}, + {Method: pkg.MethodItemCompleted, Params: cmdItem}, + {Method: pkg.MethodItemCompleted, Params: fileItem}, + {Method: pkg.MethodItemCompleted, Params: planItem}, + {Method: pkg.MethodItemCompleted, Params: reasonItem}, + {Method: pkg.MethodItemCompleted, Params: userItem}, + {Method: pkg.MethodItemCompleted, Params: otherItem}, + } + if f.Unknown { + reqID := pkg.IntID(900) + out = append(out, pkg.Envelope{ID: &reqID, Method: "future/unknown", Params: json.RawMessage(`{"threadId":"` + threadID + `"}`)}) + } + if f.FailTurn { + out = append(out, pkg.Envelope{Method: pkg.MethodTurnCompleted, Params: failed}) + return out + } + if f.SendAsk { + askID := pkg.IntID(800 + f.Asks.Add(1)) + params, _ := json.Marshal(map[string]any{"threadId": threadID, "turnId": turnID, "itemId": "c1", "command": "ls", "startedAtMs": 1}) + out = append(out, pkg.Envelope{ID: &askID, Method: pkg.MethodItemCommandApproval, Params: params}) + return out + } + out = append(out, pkg.Envelope{Method: pkg.MethodTurnCompleted, Params: done}) + return out + default: + return []pkg.Envelope{{ID: env.ID, Error: &pkg.RPCError{Code: pkg.CodeMethodNotFound, Message: env.Method}}} + } +} + +func (f *FakeHandler) addThread(id, name string) pkg.ThreadObject { + th := pkg.ThreadObject{ + ID: id, Name: name, Preview: name, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1, + Turns: []pkg.TurnObject{{ + ID: "hist-1", + Status: "completed", + Items: []json.RawMessage{json.RawMessage(`{"id":"u","type":"userMessage","content":[{"type":"text","text":"hi"}]}`)}, + }}, + } + f.mu.Lock() + f.Threads[id] = th + f.mu.Unlock() + return th +} + +func jsonField(raw json.RawMessage, key string) string { + var obj map[string]any + _ = json.Unmarshal(raw, &obj) + s, _ := obj[key].(string) + return s +} + +func mustRaw(v any) json.RawMessage { + body, _ := json.Marshal(v) + return body +} + +func itoa(n int64) string { + return strconv.FormatInt(n, 10) +} diff --git a/server/internal/codex/live_test.go b/server/internal/codex/live_test.go new file mode 100644 index 0000000..52bb87f --- /dev/null +++ b/server/internal/codex/live_test.go @@ -0,0 +1,80 @@ +package codex + +import ( + "context" + "os/exec" + "path/filepath" + "testing" + "time" + + pkg "codedock/pkg/codex" +) + +func TestLiveCodexAppServer(t *testing.T) { + path, err := exec.LookPath("codex") + if err != nil { + t.Skip("local Codex CLI is not installed") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + rt := New(Options{Bin: path}) + defer rt.Close() + + status, err := rt.Probe(ctx) + if err != nil { + t.Fatal(err) + } + if !status.Available { + t.Fatalf("codex found at %s but Probe says unavailable: %+v", path, status) + } + if status.Version == "" { + t.Fatal("empty version") + } + t.Logf("codex version=%s authorized=%v hint=%s", status.Version, status.Authorized, status.Hint) + + models, err := rt.ListModels(ctx) + if err != nil { + t.Fatal(err) + } + t.Logf("models=%d", len(models)) + + modes, err := rt.ListModes(ctx) + if err != nil { + t.Fatal(err) + } + if len(modes) == 0 { + t.Fatal("expected plan modes") + } + t.Logf("modes=%d", len(modes)) + + if !status.Authorized { + t.Log("not authorized; skip thread lifecycle") + return + } + + dir := t.TempDir() + session, err := rt.CreateSession(ctx, pkg.Settings{Cwd: dir}) + if err != nil { + t.Fatal(err) + } + if session.ID == "" { + t.Fatal("empty thread id") + } + t.Logf("thread=%s", session.ID) + if err := rt.Rename(ctx, session.ID, "codedock-live-"+filepath.Base(dir)); err != nil { + t.Fatal(err) + } + got, _, err := rt.GetSession(ctx, session.ID) + if err != nil || got.ID != session.ID { + t.Fatal(got, err) + } + if err := rt.Archive(ctx, session.ID); err != nil { + t.Fatal(err) + } + if _, err := rt.ListSessions(ctx, true, ""); err != nil { + t.Fatal(err) + } + if _, err := rt.Effective(ctx, session.ID); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/codex/loopback.go b/server/internal/codex/loopback.go new file mode 100644 index 0000000..cc4a596 --- /dev/null +++ b/server/internal/codex/loopback.go @@ -0,0 +1,47 @@ +package codex + +import ( + "context" + "io" + "sync" + + pkg "codedock/pkg/codex" +) + +type pipeProc struct { + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser + wait chan struct{} + once sync.Once +} + +func (p *pipeProc) Stdin() io.WriteCloser { return p.stdin } +func (p *pipeProc) Stdout() io.ReadCloser { return p.stdout } +func (p *pipeProc) Stderr() io.ReadCloser { return p.stderr } +func (p *pipeProc) Wait() error { + <-p.wait + return io.EOF +} +func (p *pipeProc) Kill() error { + p.once.Do(func() { + _ = p.stdin.Close() + _ = p.stdout.Close() + _ = p.stderr.Close() + close(p.wait) + }) + return nil +} + +// LoopbackStarter 用内存管道假 app-server,供测试。 +func LoopbackStarter(handler func(pkg.Envelope) []pkg.Envelope) Starter { + return func(ctx context.Context, bin string) (Proc, error) { + sr, sw := io.Pipe() + cr, cw := io.Pipe() + er, ew := io.Pipe() + _ = ew.Close() + server := pkg.NewJSONL(sr, cw, nil, 0) + go pkg.Serve(server, handler) + return &pipeProc{stdin: sw, stdout: cr, stderr: er, wait: make(chan struct{})}, nil + } +} diff --git a/server/internal/codex/process.go b/server/internal/codex/process.go new file mode 100644 index 0000000..034d338 --- /dev/null +++ b/server/internal/codex/process.go @@ -0,0 +1,117 @@ +package codex + +import ( + "context" + "io" + "os/exec" + "strings" + "sync" +) + +// Proc 是一条已启动的 app-server 进程。 +type Proc interface { + Stdin() io.WriteCloser + Stdout() io.ReadCloser + Stderr() io.ReadCloser + Wait() error + Kill() error +} + +// Starter 启动一条 app-server 进程。测试可换成假进程。 +type Starter func(ctx context.Context, bin string) (Proc, error) + +// Versioner 读取 `codex --version`。 +type Versioner func(ctx context.Context, bin string) (string, error) + +// LookPath 解析可执行文件路径。 +type LookPath func(file string) (string, error) + +type execProc struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser + once sync.Once +} + +func (p *execProc) Stdin() io.WriteCloser { return p.stdin } +func (p *execProc) Stdout() io.ReadCloser { return p.stdout } +func (p *execProc) Stderr() io.ReadCloser { return p.stderr } +func (p *execProc) Wait() error { return p.cmd.Wait() } +func (p *execProc) Kill() error { + var err error + p.once.Do(func() { + if p.cmd.Process != nil { + err = p.cmd.Process.Kill() + } + }) + return err +} + +// DefaultStarter 启动 `bin app-server --stdio`。进程寿命不绑在调用方 ctx 上,避免 HTTP 请求结束时把长驻 app-server 杀掉。 +func DefaultStarter(ctx context.Context, bin string) (Proc, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + cmd := exec.Command(bin, "app-server", "--stdio") + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + return &execProc{cmd: cmd, stdin: stdin, stdout: stdout, stderr: stderr}, nil +} + +// DefaultVersioner 跑 `bin --version`。 +func DefaultVersioner(ctx context.Context, bin string) (string, error) { + cmd := exec.CommandContext(ctx, bin, "--version") + out, err := cmd.CombinedOutput() + return string(out), err +} + +// DefaultLookPath 用 exec.LookPath。 +func DefaultLookPath(file string) (string, error) { + if file == "" { + file = "codex" + } + return exec.LookPath(file) +} + +// Drain 丢掉 stderr,避免管道堵住。 +func Drain(r io.Reader) { + if r == nil { + return + } + go func() { + _, _ = io.Copy(io.Discard, r) + }() +} + +// ParseVersion 从 `codex --version` 输出里取出版本号。 +func ParseVersion(out string) string { + lines := strings.Split(out, "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(strings.TrimSuffix(lines[i], "\r")) + if line == "" { + continue + } + if v, ok := strings.CutPrefix(line, "codex-cli "); ok { + return strings.TrimSpace(v) + } + if v, ok := strings.CutPrefix(line, "codex "); ok { + return strings.TrimSpace(v) + } + return line + } + return "" +} diff --git a/server/internal/codex/process_test.go b/server/internal/codex/process_test.go new file mode 100644 index 0000000..8aabd65 --- /dev/null +++ b/server/internal/codex/process_test.go @@ -0,0 +1,102 @@ +package codex + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + pkg "codedock/pkg/codex" +) + +func TestDefaultLookPathAndCancelledStarter(t *testing.T) { + if _, err := DefaultLookPath("true"); err != nil { + t.Fatal(err) + } + _, _ = DefaultLookPath("") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := DefaultStarter(ctx, "true"); err == nil { + t.Fatal("expected canceled starter") + } +} + +func TestDefaultVersionerAndStarter(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "main.go") + bin := filepath.Join(dir, "codex") + code := `package main +import ( + "bufio" + "encoding/json" + "fmt" + "os" +) +func main() { + if len(os.Args) > 1 && os.Args[1] == "--version" { + fmt.Println("codex-cli 0.0.0-test") + return + } + sc := bufio.NewScanner(os.Stdin) + for sc.Scan() { + var msg map[string]any + if json.Unmarshal(sc.Bytes(), &msg) != nil { + continue + } + method, _ := msg["method"].(string) + if method == "initialized" { + continue + } + resp := map[string]any{"id": msg["id"], "result": map[string]any{}} + if method == "initialize" { + resp["result"] = map[string]any{"codexHome": "/tmp", "platformFamily": "unix", "platformOs": "linux", "userAgent": "t"} + } + if method == "account/read" { + resp["result"] = map[string]any{"requiresOpenaiAuth": false, "account": map[string]any{"type": "apiKey"}} + } + body, _ := json.Marshal(resp) + os.Stdout.Write(append(body, '\n')) + } +} +` + if err := os.WriteFile(src, []byte(code), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module fakecodex\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + build := exec.Command("go", "build", "-o", bin, ".") + build.Dir = dir + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build fake codex: %s %v", out, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + ver, err := DefaultVersioner(ctx, bin) + if err != nil { + t.Fatal(err) + } + if ParseVersion(ver) != "0.0.0-test" { + t.Fatal(ver) + } + proc, err := DefaultStarter(ctx, bin) + if err != nil { + t.Fatal(err) + } + Drain(proc.Stderr()) + transport := pkg.NewJSONL(proc.Stdout(), proc.Stdin(), proc.Stdin(), 0) + client := pkg.NewClient(transport) + if _, err := client.Handshake(ctx, pkg.DefaultClientInfo()); err != nil { + _ = proc.Kill() + _ = client.Close() + t.Fatal(err) + } + if err := proc.Kill(); err != nil { + t.Fatal(err) + } + _ = client.Close() + _ = proc.Wait() +} diff --git a/server/internal/codex/runtime.go b/server/internal/codex/runtime.go new file mode 100644 index 0000000..cafa8ba --- /dev/null +++ b/server/internal/codex/runtime.go @@ -0,0 +1,445 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "io" + "strconv" + "strings" + "sync" + + cderr "codedock/internal/errors" + pkg "codedock/pkg/codex" +) + +// Options 装配本机 Codex 运行时。 +type Options struct { + Bin string + LookPath LookPath + Version Versioner + Start Starter + ClientInfo pkg.ClientInfo +} + +// Runtime 管本机 app-server 生命周期和进程内排队、草稿、问票、SSE。 +type Runtime struct { + bin string + lookPath LookPath + version Versioner + start Starter + clientInfo pkg.ClientInfo + + connMu sync.Mutex + client *pkg.Client + proc Proc + + mu sync.Mutex + mem map[string]*sessionMem + listeners map[string][]chan pkg.Event +} + +type sessionMem struct { + mu sync.Mutex + settings pkg.Settings + draft pkg.Input + active *pkg.Turn + queue []queuedTurn + asks map[string]*askMem + ring *eventRing + archived bool +} + +type queuedTurn struct { + turn pkg.Turn + input pkg.Input +} + +type askMem struct { + ask pkg.ApprovalAsk + rpcID pkg.RequestID + resolved bool +} + +// New 构造运行时。未安装 Codex 时仍可 Probe,不会拖垮主服务。 +func New(opts Options) *Runtime { + if opts.LookPath == nil { + opts.LookPath = DefaultLookPath + } + if opts.Version == nil { + opts.Version = DefaultVersioner + } + if opts.Start == nil { + opts.Start = DefaultStarter + } + if opts.Bin == "" { + opts.Bin = "codex" + } + if opts.ClientInfo.Name == "" { + opts.ClientInfo = pkg.DefaultClientInfo() + } + return &Runtime{ + bin: opts.Bin, + lookPath: opts.LookPath, + version: opts.Version, + start: opts.Start, + clientInfo: opts.ClientInfo, + mem: map[string]*sessionMem{}, + listeners: map[string][]chan pkg.Event{}, + } +} + +// Close 杀掉当前 app-server。进行中的回合按失败处理,不重发。 +func (rt *Runtime) Close() error { + rt.connMu.Lock() + defer rt.connMu.Unlock() + rt.dropConnLocked(io.EOF) + return nil +} + +func (rt *Runtime) state(id string) *sessionMem { + rt.mu.Lock() + defer rt.mu.Unlock() + return rt.stateLocked(id) +} + +func (rt *Runtime) stateLocked(id string) *sessionMem { + st, ok := rt.mem[id] + if !ok { + st = &sessionMem{asks: map[string]*askMem{}, ring: newRing()} + rt.mem[id] = st + } + return st +} + +func (rt *Runtime) emit(sessionID string, ev pkg.Event) pkg.Event { + st := rt.state(sessionID) + ev.SessionID = sessionID + ev = st.ring.append(ev) + rt.mu.Lock() + subs := append([]chan pkg.Event(nil), rt.listeners[sessionID]...) + rt.mu.Unlock() + for _, ch := range subs { + select { + case ch <- ev: + default: + } + } + return ev +} + +// Subscribe 订阅一条对话的进程内事件。 +func (rt *Runtime) Subscribe(sessionID string) (<-chan pkg.Event, func()) { + ch := make(chan pkg.Event, 64) + rt.mu.Lock() + rt.listeners[sessionID] = append(rt.listeners[sessionID], ch) + rt.mu.Unlock() + return ch, func() { + rt.mu.Lock() + defer rt.mu.Unlock() + list := rt.listeners[sessionID] + out := list[:0] + for _, item := range list { + if item != ch { + out = append(out, item) + } + } + if len(out) == 0 { + delete(rt.listeners, sessionID) + } else { + rt.listeners[sessionID] = out + } + close(ch) + } +} + +// Events 按 seq 回放内存环。缺口时 reset=true,调用方应再 thread/read。 +func (rt *Runtime) Events(sessionID string, after int64) (events []pkg.Event, reset bool) { + return rt.state(sessionID).ring.after(after) +} + +func (rt *Runtime) ensureClient(ctx context.Context) (*pkg.Client, error) { + rt.connMu.Lock() + defer rt.connMu.Unlock() + if rt.client != nil { + select { + case <-rt.client.Done(): + rt.dropConnLocked(rt.client.Err()) + default: + return rt.client, nil + } + } + path, err := rt.lookPath(rt.bin) + if err != nil { + return nil, cderr.Unavailable("codex is not installed") + } + proc, err := rt.start(ctx, path) + if err != nil { + return nil, cderr.Unavailable("start codex app-server: %s", err.Error()) + } + Drain(proc.Stderr()) + transport := pkg.NewJSONL(proc.Stdout(), proc.Stdin(), proc.Stdin(), 0) + client := pkg.NewClient(transport) + if _, err := client.Handshake(ctx, rt.clientInfo); err != nil { + _ = proc.Kill() + _ = client.Close() + _ = proc.Wait() + return nil, cderr.Unavailable("codex handshake: %s", err.Error()) + } + rt.proc = proc + rt.client = client + go rt.consume(client) + go func() { + _ = proc.Wait() + rt.connMu.Lock() + if rt.client == client { + rt.dropConnLocked(io.EOF) + } + rt.connMu.Unlock() + }() + return client, nil +} + +func (rt *Runtime) dropConnLocked(cause error) { + proc, client := rt.proc, rt.client + rt.proc = nil + rt.client = nil + if proc != nil { + _ = proc.Kill() + } + if client != nil { + _ = client.Close() + } + rt.failLiveTurns(cause) +} + +func (rt *Runtime) failLiveTurns(cause error) { + msg := "codex app-server exited" + if cause != nil && !errors.Is(cause, io.EOF) { + msg = cause.Error() + } + rt.mu.Lock() + ids := make([]string, 0, len(rt.mem)) + for id := range rt.mem { + ids = append(ids, id) + } + rt.mu.Unlock() + for _, id := range ids { + st := rt.state(id) + st.mu.Lock() + if st.active != nil && (st.active.Status == pkg.TurnRunning || st.active.Status == pkg.TurnWaitingApproval) { + st.active.Status = pkg.TurnFailed + st.active.Error = msg + turn := *st.active + st.mu.Unlock() + rt.emit(id, pkg.Event{Type: pkg.EventTurnFailed, TurnID: turn.ID, Turn: &turn, Notice: msg}) + continue + } + st.mu.Unlock() + } +} + +func (rt *Runtime) consume(client *pkg.Client) { + for msg := range client.Incoming() { + switch msg.Kind { + case pkg.KindRequest: + rt.onServerRequest(client, msg) + case pkg.KindNotification: + rt.onNotification(msg) + } + } +} + +func (rt *Runtime) onServerRequest(client *pkg.Client, msg pkg.Message) { + ctx := context.Background() + if ask, ok := pkg.ParseAsk(msg); ok { + st := rt.state(ask.ThreadID) + st.mu.Lock() + if st.asks == nil { + st.asks = map[string]*askMem{} + } + st.asks[ask.ID] = &askMem{ask: ask, rpcID: msg.ID} + if st.active != nil { + st.active.Status = pkg.TurnWaitingApproval + } + st.mu.Unlock() + copied := ask + rt.emit(ask.ThreadID, pkg.Event{Type: pkg.EventAskRequired, TurnID: ask.TurnID, Ask: &copied}) + return + } + notice := "Codex 问了本模块接不住的事,已按不支持回包。" + rt.emit(threadIDOf(msg), pkg.Event{Type: pkg.EventNotice, Notice: notice, Progress: &pkg.Progress{Kind: pkg.ProgressNotice, Text: notice}}) + _ = client.ReplyError(ctx, msg.ID, pkg.CodeMethodNotFound, "method not supported by codedock") +} + +func (rt *Runtime) onNotification(msg pkg.Message) { + threadID, turnID := idsOf(msg) + switch msg.Method { + case pkg.MethodServerRequestResolved: + rt.resolveAsk(threadID, stringIDOf(msg, "requestId")) + case pkg.MethodTurnCompleted: + rt.finishTurn(threadID, turnID, msg) + case pkg.MethodTurnStarted: + st := rt.state(threadID) + st.mu.Lock() + if st.active != nil && st.active.Status != pkg.TurnWaitingApproval { + st.active.Status = pkg.TurnRunning + } + st.mu.Unlock() + } + if ev, ok := pkg.MapNotification(msg); ok { + p := ev.Progress + sid := ev.ThreadID + if sid == "" { + sid = threadID + } + rt.emit(sid, pkg.Event{Type: pkg.EventProgress, TurnID: first(ev.TurnID, turnID), Progress: &p}) + } +} + +func (rt *Runtime) finishTurn(threadID, turnID string, msg pkg.Message) { + st := rt.state(threadID) + st.mu.Lock() + var finished *pkg.Turn + if st.active != nil && (st.active.CodexID == turnID || st.active.ID == turnID || turnID == "") { + status := pkg.MapTurnStatus(turnStatusOf(msg), false) + st.active.Status = status + if status == pkg.TurnFailed { + st.active.Error = turnErrorOf(msg) + } + copyTurn := *st.active + finished = ©Turn + st.active = nil + for id, ask := range st.asks { + if !ask.resolved { + ask.resolved = true + } + delete(st.asks, id) + } + } + next := queuedTurn{} + hasNext := false + if len(st.queue) > 0 { + next = st.queue[0] + st.queue = st.queue[1:] + hasNext = true + } + st.mu.Unlock() + if finished != nil { + typ := pkg.EventTurnCompleted + switch finished.Status { + case pkg.TurnFailed: + typ = pkg.EventTurnFailed + case pkg.TurnCancelled: + typ = pkg.EventTurnCancelled + } + rt.emit(threadID, pkg.Event{Type: typ, TurnID: finished.ID, Turn: finished}) + } + if hasNext { + _, _ = rt.dispatchTurn(context.Background(), threadID, next.turn, next.input) + } +} + +func (rt *Runtime) resolveAsk(threadID, requestID string) { + if threadID == "" || requestID == "" { + return + } + st := rt.state(threadID) + st.mu.Lock() + ask, ok := st.asks[requestID] + if ok { + ask.resolved = true + copied := ask.ask + st.mu.Unlock() + rt.emit(threadID, pkg.Event{Type: pkg.EventAskResolved, TurnID: copied.TurnID, Ask: &copied}) + return + } + st.mu.Unlock() +} + +func mapRPC(err error) error { + if err == nil { + return nil + } + var rpc pkg.RPCError + if errors.As(err, &rpc) { + lower := strings.ToLower(rpc.Message) + if strings.Contains(lower, "not found") || strings.Contains(lower, "unknown thread") { + return cderr.NotFound("%s", rpc.Message) + } + if strings.Contains(lower, "not initialized") { + return cderr.Unavailable("%s", rpc.Message) + } + return cderr.Invalid("%s", rpc.Message) + } + if errors.Is(err, context.Canceled) { + return err + } + return err +} + +func threadIDOf(msg pkg.Message) string { + id, _ := idsOf(msg) + return id +} + +func idsOf(msg pkg.Message) (threadID, turnID string) { + ev, ok := pkg.MapNotification(msg) + if ok { + return ev.ThreadID, ev.TurnID + } + var raw struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + RequestID any `json:"requestId"` + } + _ = json.Unmarshal(msg.Params, &raw) + return raw.ThreadID, raw.TurnID +} + +func stringIDOf(msg pkg.Message, field string) string { + var raw map[string]any + if json.Unmarshal(msg.Params, &raw) != nil { + return "" + } + switch v := raw[field].(type) { + case string: + return v + case float64: + return strconv.FormatInt(int64(v), 10) + default: + return "" + } +} + +func turnStatusOf(msg pkg.Message) string { + var raw struct { + Turn struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"turn"` + } + _ = json.Unmarshal(msg.Params, &raw) + return raw.Turn.Status +} + +func turnErrorOf(msg pkg.Message) string { + var raw struct { + Turn struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } `json:"turn"` + } + _ = json.Unmarshal(msg.Params, &raw) + return raw.Turn.Error.Message +} + +func first(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/server/internal/codex/runtime_test.go b/server/internal/codex/runtime_test.go new file mode 100644 index 0000000..39be36d --- /dev/null +++ b/server/internal/codex/runtime_test.go @@ -0,0 +1,524 @@ +package codex + +import ( + "context" + "os" + "testing" + "time" + + pkg "codedock/pkg/codex" +) + +func TestRuntimeProbeAndCatalog(t *testing.T) { + fake := NewFakeHandler() + rt := New(Options{ + LookPath: func(string) (string, error) { return "/bin/codex", nil }, + Version: func(context.Context, string) (string, error) { return "codex-cli 0.149.0\n", nil }, + Start: LoopbackStarter(fake.Handle), + }) + defer rt.Close() + ctx := context.Background() + status, err := rt.Probe(ctx) + if err != nil || !status.Available || !status.Authorized || status.Version != "0.149.0" { + t.Fatalf("%+v %v", status, err) + } + models, err := rt.ListModels(ctx) + if err != nil || len(models) == 0 { + t.Fatal(models, err) + } + modes, err := rt.ListModes(ctx) + if err != nil || len(modes) < 3 { + t.Fatal(modes, err) + } + if len(rt.Commands()) == 0 { + t.Fatal("commands") + } +} + +func TestRuntimeSessionTurnQueueAsk(t *testing.T) { + fake := NewFakeHandler() + rt := New(Options{ + LookPath: func(string) (string, error) { return "/bin/codex", nil }, + Version: func(context.Context, string) (string, error) { return "codex-cli 0.149.0", nil }, + Start: LoopbackStarter(fake.Handle), + }) + defer rt.Close() + ctx := context.Background() + session, err := rt.CreateSession(ctx, pkg.Settings{Cwd: "/tmp", Model: "gpt-5.6"}) + if err != nil || session.ID == "" { + t.Fatal(session, err) + } + if _, err := rt.ApplySettings(ctx, session.ID, pkg.Settings{Effort: "high"}); err != nil { + t.Fatal(err) + } + if err := rt.Mention(ctx, session.ID, "a.go"); err != nil { + t.Fatal(err) + } + if err := rt.AttachImage(ctx, session.ID, "x.png"); err != nil { + t.Fatal(err) + } + got, progress, err := rt.GetSession(ctx, session.ID) + if err != nil || got.ID != session.ID { + t.Fatal(got, progress, err) + } + page, err := rt.ListSessions(ctx, false, "") + if err != nil || len(page.Sessions) == 0 { + t.Fatal(page, err) + } + + fake.SendAsk = true + turn, err := rt.StartTurn(ctx, session.ID, "do it", pkg.Input{}, pkg.InputStart) + if err != nil { + t.Fatal(err) + } + queued, err := rt.StartTurn(ctx, session.ID, "next", pkg.Input{}, pkg.InputStart) + if err != nil || queued.Status != pkg.TurnQueued { + t.Fatal(queued, err) + } + deadline := time.Now().Add(2 * time.Second) + var asks []pkg.ApprovalAsk + for time.Now().Before(deadline) { + asks = rt.PendingAsks(session.ID) + if len(asks) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if len(asks) == 0 { + t.Fatal("expected ask") + } + if err := rt.Decide(ctx, asks[0].ID, pkg.AskAnswer{Approved: true, Scope: pkg.ScopeOnce}); err != nil { + t.Fatal(err) + } + if err := rt.Decide(ctx, asks[0].ID, pkg.AskAnswer{Approved: true}); err == nil { + t.Fatal("late decide") + } + if err := rt.Rename(ctx, session.ID, "Title"); err != nil { + t.Fatal(err) + } + if _, err := rt.Fork(ctx, session.ID); err != nil { + t.Fatal(err) + } + if err := rt.Compact(ctx, session.ID); err != nil { + t.Fatal(err) + } + if err := rt.Review(ctx, session.ID); err != nil { + t.Fatal(err) + } + if _, err := rt.Invoke(ctx, session.ID, "mcp", ""); err != nil { + t.Fatal(err) + } + if _, err := rt.Invoke(ctx, session.ID, "model", "gpt-5.6"); err != nil { + t.Fatal(err) + } + if err := rt.Archive(ctx, session.ID); err != nil { + t.Fatal(err) + } + if _, err := rt.StartTurn(ctx, session.ID, "no", pkg.Input{}, pkg.InputStart); err == nil { + t.Fatal("archived") + } + events, _ := rt.Events(session.ID, 0) + if len(events) == 0 { + t.Fatal("events") + } + ch, unsub := rt.Subscribe(session.ID) + unsub() + select { + case <-ch: + default: + } + _ = turn +} + +func TestRuntimeUnauthorizedAndMissing(t *testing.T) { + fake := NewFakeHandler() + fake.Authorized = false + rt := New(Options{ + LookPath: func(string) (string, error) { return "/bin/codex", nil }, + Version: func(context.Context, string) (string, error) { return "codex-cli 0.1", nil }, + Start: LoopbackStarter(fake.Handle), + }) + defer rt.Close() + if _, err := rt.CreateSession(context.Background(), pkg.Settings{}); err == nil { + t.Fatal("expected unauthorized") + } + rt2 := New(Options{ + LookPath: func(string) (string, error) { return "", os.ErrNotExist }, + }) + status, err := rt2.Probe(context.Background()) + if err != nil || status.Available { + t.Fatal(status, err) + } +} + +func TestParseVersion(t *testing.T) { + if ParseVersion("WARNING: x\ncodex-cli 0.149.0\n") != "0.149.0" { + t.Fatal(ParseVersion("WARNING: x\ncodex-cli 0.149.0\n")) + } + if ParseVersion("codex 1.2.3") != "1.2.3" { + t.Fatal(ParseVersion("codex 1.2.3")) + } + if ParseVersion("\n0.1\n") != "0.1" { + t.Fatal(ParseVersion("0.1")) + } + if ParseVersion(" \n") != "" { + t.Fatal("empty") + } + Drain(nil) +} + +func TestRuntimeCancelQueuedAndUnknown(t *testing.T) { + fake := NewFakeHandler() + fake.SendAsk = true + fake.Unknown = true + rt := New(Options{ + LookPath: func(string) (string, error) { return "/bin/codex", nil }, + Version: func(context.Context, string) (string, error) { return "codex-cli 0.149.0", nil }, + Start: LoopbackStarter(fake.Handle), + }) + defer rt.Close() + ctx := context.Background() + session, err := rt.CreateSession(ctx, pkg.Settings{}) + if err != nil { + t.Fatal(err) + } + first, err := rt.StartTurn(ctx, session.ID, "one", pkg.Input{}, pkg.InputStart) + if err != nil { + t.Fatal(err) + } + second, err := rt.StartTurn(ctx, session.ID, "two", pkg.Input{}, pkg.InputQueue) + if err != nil { + t.Fatal(err) + } + if err := rt.CancelTurn(ctx, session.ID, second.ID); err != nil { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + _ = rt.CancelTurn(ctx, session.ID, first.ID) +} + +func TestRuntimeInterruptAfterStart(t *testing.T) { + fake := NewFakeHandler() + fake.SendAsk = true + rt := New(Options{ + LookPath: func(string) (string, error) { return "/bin/codex", nil }, + Version: func(context.Context, string) (string, error) { return "codex-cli 0.149.0", nil }, + Start: LoopbackStarter(fake.Handle), + }) + defer rt.Close() + ctx := context.Background() + session, err := rt.CreateSession(ctx, pkg.Settings{}) + if err != nil { + t.Fatal(err) + } + turn, err := rt.StartTurn(ctx, session.ID, "one", pkg.Input{}, pkg.InputStart) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + st := rt.state(session.ID) + st.mu.Lock() + ready := st.active != nil && st.active.CodexID != "" + st.mu.Unlock() + if ready { + break + } + time.Sleep(10 * time.Millisecond) + } + if err := rt.CancelTurn(ctx, session.ID, turn.ID); err != nil { + t.Fatal(err) + } +} + +func loopRT(t *testing.T, fake *FakeHandler) *Runtime { + t.Helper() + if fake == nil { + fake = NewFakeHandler() + } + rt := New(Options{ + LookPath: func(string) (string, error) { return "/bin/codex", nil }, + Version: func(context.Context, string) (string, error) { return "codex-cli 0.149.0", nil }, + Start: LoopbackStarter(fake.Handle), + }) + t.Cleanup(func() { _ = rt.Close() }) + return rt +} + +func TestRuntimeInvokeExpireRingAndFailTurn(t *testing.T) { + fake := NewFakeHandler() + rt := loopRT(t, fake) + ctx := context.Background() + session, err := rt.CreateSession(ctx, pkg.Settings{Model: "gpt-5.6"}) + if err != nil { + t.Fatal(err) + } + for _, cmd := range []struct{ name, args string }{ + {"effort", "high"}, + {"plan", ""}, + {"permissions", "read-only"}, + {"approval", "never"}, + {"mention", "a.go"}, + {"image", "a.png"}, + {"rename", "Title"}, + {"mcp", ""}, + {"skills", ""}, + } { + if _, err := rt.Invoke(ctx, session.ID, cmd.name, cmd.args); err != nil { + t.Fatalf("%s: %v", cmd.name, err) + } + } + if _, err := rt.Invoke(ctx, session.ID, "nope", ""); err == nil { + t.Fatal("unknown command") + } + if _, err := rt.Invoke(ctx, session.ID, "fork", ""); err != nil { + t.Fatal(err) + } + if err := rt.Mention(ctx, session.ID, ""); err == nil { + t.Fatal("empty mention") + } + if err := rt.AttachImage(ctx, session.ID, ""); err == nil { + t.Fatal("empty image") + } + if _, err := rt.StartTurn(ctx, "", "x", pkg.Input{}, pkg.InputStart); err == nil { + t.Fatal("empty session") + } + if err := rt.CancelTurn(ctx, session.ID, ""); err == nil { + t.Fatal("empty turn") + } + if err := rt.CancelTurn(ctx, session.ID, "missing"); err == nil { + t.Fatal("missing turn") + } + if err := rt.Expire(ctx, "missing"); err == nil { + t.Fatal("missing ask") + } + + fake.FailTurn = true + if _, err := rt.StartTurn(ctx, session.ID, "fail", pkg.Input{Text: "x"}, pkg.InputStart); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + events, _ := rt.Events(session.ID, 0) + for _, ev := range events { + if ev.Type == pkg.EventTurnFailed { + goto failed + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("expected failed turn") +failed: + fake.FailTurn = false + fake.SendAsk = true + turn, err := rt.StartTurn(ctx, session.ID, "ask", pkg.Input{}, pkg.InputStart) + if err != nil { + t.Fatal(err) + } + var askID string + askDeadline := time.Now().Add(2 * time.Second) + for time.Now().Before(askDeadline) { + asks := rt.PendingAsks(session.ID) + if len(asks) > 0 { + askID = asks[0].ID + break + } + time.Sleep(10 * time.Millisecond) + } + if askID == "" { + t.Fatal("expected ask") + } + if err := rt.Expire(ctx, askID); err != nil { + t.Fatal(err) + } + if _, err := rt.Invoke(ctx, session.ID, "stop", ""); err != nil && err.Error() == "" { + t.Fatal(err) + } + _ = turn + if _, err := rt.Invoke(ctx, session.ID, "compact", ""); err != nil { + t.Fatal(err) + } + if _, err := rt.Invoke(ctx, session.ID, "review", ""); err != nil { + t.Fatal(err) + } + + for i := 0; i < 300; i++ { + rt.emit(session.ID, pkg.Event{Type: pkg.EventNotice, Notice: "n"}) + } + events, reset := rt.Events(session.ID, 0) + if !reset || len(events) == 0 { + t.Fatalf("reset=%v n=%d", reset, len(events)) + } + if rt.state(session.ID).ring.last() < 256 { + t.Fatal("ring seq") + } + if _, err := rt.Invoke(ctx, session.ID, "archive", ""); err != nil { + t.Fatal(err) + } +} + +func TestRuntimeHandshakeStartAndVersionErrors(t *testing.T) { + ctx := context.Background() + rt := New(Options{ + LookPath: func(string) (string, error) { return "/bin/codex", nil }, + Version: func(context.Context, string) (string, error) { return "", os.ErrNotExist }, + Start: LoopbackStarter(NewFakeHandler().Handle), + }) + defer rt.Close() + status, err := rt.Probe(ctx) + if err != nil || !status.Available { + t.Fatal(status, err) + } + + rt2 := New(Options{ + LookPath: func(string) (string, error) { return "/bin/codex", nil }, + Version: func(context.Context, string) (string, error) { return "codex-cli 0.1", nil }, + Start: func(context.Context, string) (Proc, error) { return nil, os.ErrPermission }, + }) + defer rt2.Close() + status, err = rt2.Probe(ctx) + if err != nil || status.Hint == "" { + t.Fatal(status, err) + } + if _, err := rt2.CreateSession(ctx, pkg.Settings{}); err == nil { + t.Fatal("expected start error") + } + + rt3 := New(Options{ + LookPath: func(string) (string, error) { return "/bin/codex", nil }, + Version: func(context.Context, string) (string, error) { return "codex-cli 0.1", nil }, + Start: LoopbackStarter(func(env pkg.Envelope) []pkg.Envelope { + if env.ID != nil { + return []pkg.Envelope{{ID: env.ID, Error: &pkg.RPCError{Code: -32603, Message: "not initialized"}}} + } + return nil + }), + }) + defer rt3.Close() + if _, err := rt3.CreateSession(ctx, pkg.Settings{}); err == nil { + t.Fatal("expected handshake error") + } + + fake := NewFakeHandler() + rt4 := loopRT(t, fake) + session, err := rt4.CreateSession(ctx, pkg.Settings{}) + if err != nil { + t.Fatal(err) + } + fake.SendAsk = true + if _, err := rt4.StartTurn(ctx, session.ID, "x", pkg.Input{}, pkg.InputStart); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if len(rt4.PendingAsks(session.ID)) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if err := rt4.Close(); err != nil { + t.Fatal(err) + } +} + +func TestRuntimeQueueAfterComplete(t *testing.T) { + fake := NewFakeHandler() + rt := loopRT(t, fake) + ctx := context.Background() + session, err := rt.CreateSession(ctx, pkg.Settings{}) + if err != nil { + t.Fatal(err) + } + first, err := rt.StartTurn(ctx, session.ID, "one", pkg.Input{}, pkg.InputStart) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + st := rt.state(session.ID) + st.mu.Lock() + idle := st.active == nil + st.mu.Unlock() + if idle { + break + } + time.Sleep(10 * time.Millisecond) + } + second, err := rt.StartTurn(ctx, session.ID, "two", pkg.Input{}, pkg.InputStart) + if err != nil { + t.Fatal(err) + } + _ = first + _ = second + got, _, err := rt.GetSession(ctx, "missing") + if err == nil { + t.Fatal(got) + } +} + +func TestRuntimeCancelActiveWithoutCodexID(t *testing.T) { + fake := NewFakeHandler() + rt := loopRT(t, fake) + ctx := context.Background() + session, err := rt.CreateSession(ctx, pkg.Settings{}) + if err != nil { + t.Fatal(err) + } + st := rt.state(session.ID) + st.mu.Lock() + st.active = &pkg.Turn{ID: "local-1", SessionID: session.ID, Status: pkg.TurnRunning} + st.queue = []queuedTurn{{turn: pkg.Turn{ID: "q1", SessionID: session.ID, Status: pkg.TurnQueued}, input: pkg.Input{Text: "n"}}} + st.mu.Unlock() + if err := rt.CancelTurn(ctx, session.ID, "local-1"); err != nil { + t.Fatal(err) + } +} + +func TestRuntimeMapRPCMessages(t *testing.T) { + err := mapRPC(pkg.RPCError{Message: "unknown thread xyz"}) + if err == nil { + t.Fatal("expected mapped") + } + err = mapRPC(pkg.RPCError{Message: "not initialized"}) + if err == nil { + t.Fatal("expected unavailable") + } + err = mapRPC(pkg.RPCError{Message: "bad params"}) + if err == nil { + t.Fatal("expected invalid") + } + if mapRPC(context.Canceled) != context.Canceled { + t.Fatal("canceled") + } + if mapRPC(nil) != nil { + t.Fatal("nil") + } +} + +func TestRuntimeResolveAskNotification(t *testing.T) { + rt := loopRT(t, nil) + st := rt.state("th") + st.mu.Lock() + st.asks["7"] = &askMem{ask: pkg.ApprovalAsk{ID: "7", TurnID: "u", ThreadID: "th"}} + st.mu.Unlock() + rt.onNotification(pkg.Message{ + Kind: pkg.KindNotification, + Method: pkg.MethodServerRequestResolved, + Params: []byte(`{"threadId":"th","requestId":7}`), + }) + rt.onNotification(pkg.Message{ + Kind: pkg.KindNotification, + Method: pkg.MethodServerRequestResolved, + Params: []byte(`{}`), + }) + events, _ := rt.Events("th", 0) + found := false + for _, ev := range events { + if ev.Type == pkg.EventAskResolved { + found = true + } + } + if !found { + t.Fatal("expected resolved event") + } +} diff --git a/server/internal/codex/session.go b/server/internal/codex/session.go new file mode 100644 index 0000000..ef5f426 --- /dev/null +++ b/server/internal/codex/session.go @@ -0,0 +1,208 @@ +package codex + +import ( + "context" + + cderr "codedock/internal/errors" + pkg "codedock/pkg/codex" +) + +// ListSessions 从官方 thread/list 取对话。 +func (rt *Runtime) ListSessions(ctx context.Context, archived bool, cursor string) (pkg.SessionPage, error) { + client, err := rt.ensureClient(ctx) + if err != nil { + return pkg.SessionPage{}, err + } + flag := archived + page, err := client.ThreadList(ctx, pkg.ThreadListParams{Archived: &flag, Cursor: cursor, Limit: 50}) + if err != nil { + return pkg.SessionPage{}, mapRPC(err) + } + out := pkg.SessionPage{NextCursor: page.NextCursor} + for _, th := range page.Data { + st := rt.state(th.ID) + st.mu.Lock() + active := "" + if st.active != nil { + active = st.active.ID + } + st.archived = archived + st.mu.Unlock() + out.Sessions = append(out.Sessions, pkg.MapThread(th, archived, active)) + } + return out, nil +} + +// CreateSession 向 Codex 开一条 thread,session_id 即 thread_id。 +func (rt *Runtime) CreateSession(ctx context.Context, settings pkg.Settings) (pkg.Session, error) { + client, err := rt.requireReady(ctx) + if err != nil { + return pkg.Session{}, err + } + params := pkg.ApplyThreadOverrides(pkg.ThreadStartParams{Cwd: settings.Cwd}, settings) + res, err := client.ThreadStart(ctx, params) + if err != nil { + return pkg.Session{}, mapRPC(err) + } + st := rt.state(res.Thread.ID) + st.mu.Lock() + st.settings = settings.MergeOverride(settings) + if st.settings.Cwd == "" { + st.settings.Cwd = res.Cwd + } + if st.settings.Model == "" { + st.settings.Model = res.Model + } + st.mu.Unlock() + return pkg.MapThread(res.Thread, false, ""), nil +} + +// GetSession 读一条对话:官方 thread 加本进程的执行位。 +func (rt *Runtime) GetSession(ctx context.Context, sessionID string) (pkg.Session, []pkg.Progress, error) { + client, err := rt.ensureClient(ctx) + if err != nil { + return pkg.Session{}, nil, err + } + res, err := client.ThreadRead(ctx, pkg.ThreadReadParams{ThreadID: sessionID, IncludeTurns: true}) + if err != nil { + return pkg.Session{}, nil, mapRPC(err) + } + st := rt.state(sessionID) + st.mu.Lock() + active := "" + if st.active != nil { + active = st.active.ID + } + archived := st.archived + st.mu.Unlock() + return pkg.MapThread(res.Thread, archived, active), pkg.HydrateProgress(res.Thread.Turns), nil +} + +// Rename 改标题。 +func (rt *Runtime) Rename(ctx context.Context, sessionID, title string) error { + if title == "" { + return cderr.Invalid("title is required") + } + client, err := rt.ensureClient(ctx) + if err != nil { + return err + } + return mapRPC(client.ThreadSetName(ctx, sessionID, title)) +} + +// Archive 归档。不删本机 Codex 记录。 +func (rt *Runtime) Archive(ctx context.Context, sessionID string) error { + client, err := rt.ensureClient(ctx) + if err != nil { + return err + } + if err := mapRPC(client.ThreadArchive(ctx, sessionID)); err != nil { + return err + } + st := rt.state(sessionID) + st.mu.Lock() + st.archived = true + st.mu.Unlock() + return nil +} + +// Fork 按官方历史分叉。 +func (rt *Runtime) Fork(ctx context.Context, sessionID string) (pkg.Session, error) { + client, err := rt.requireReady(ctx) + if err != nil { + return pkg.Session{}, err + } + res, err := client.ThreadFork(ctx, pkg.ThreadForkParams{ThreadID: sessionID}) + if err != nil { + return pkg.Session{}, mapRPC(err) + } + src := rt.state(sessionID) + dst := rt.state(res.Thread.ID) + src.mu.Lock() + settings := src.settings + src.mu.Unlock() + dst.mu.Lock() + dst.settings = settings + dst.mu.Unlock() + return pkg.MapThread(res.Thread, false, ""), nil +} + +// Effective 返回 Codex 默认与用户覆盖合并后的配置。 +func (rt *Runtime) Effective(ctx context.Context, sessionID string) (pkg.Settings, error) { + st := rt.state(sessionID) + st.mu.Lock() + local := st.settings + st.mu.Unlock() + client, err := rt.ensureClient(ctx) + if err != nil { + return local, err + } + cfg, err := client.ConfigRead(ctx, local.Cwd) + if err != nil { + return local, nil + } + out := local + if out.Model == "" { + out.Model = pkg.ConfigString(cfg, "model") + } + if out.Effort == "" { + out.Effort = pkg.ConfigString(cfg, "model_reasoning_effort") + } + if out.ApprovalPolicy == "" { + out.ApprovalPolicy = pkg.ConfigString(cfg, "approval_policy") + } + if out.Sandbox == "" { + out.Sandbox = pkg.ConfigString(cfg, "sandbox_mode") + } + return out, nil +} + +// ApplySettings 记下用户改过的项。 +func (rt *Runtime) ApplySettings(ctx context.Context, sessionID string, patch pkg.Settings) (pkg.Settings, error) { + st := rt.state(sessionID) + st.mu.Lock() + st.settings = st.settings.MergeOverride(patch) + st.mu.Unlock() + return rt.Effective(ctx, sessionID) +} + +// Mention 把仓库内文件挂到草稿上。 +func (rt *Runtime) Mention(_ context.Context, sessionID, path string) error { + if path == "" { + return cderr.Invalid("path is required") + } + st := rt.state(sessionID) + st.mu.Lock() + st.draft.Mentions = append(st.draft.Mentions, path) + st.mu.Unlock() + return nil +} + +// AttachImage 把本地图片挂到草稿上。 +func (rt *Runtime) AttachImage(_ context.Context, sessionID, path string) error { + if path == "" { + return cderr.Invalid("path is required") + } + st := rt.state(sessionID) + st.mu.Lock() + st.draft.Images = append(st.draft.Images, path) + st.mu.Unlock() + return nil +} + +func (rt *Runtime) takeDraft(sessionID string, content string, extra pkg.Input) pkg.Input { + st := rt.state(sessionID) + st.mu.Lock() + defer st.mu.Unlock() + in := st.draft + st.draft = pkg.Input{} + if extra.Text != "" { + in.Text = extra.Text + } + if content != "" { + in.Text = content + } + in.Mentions = append(in.Mentions, extra.Mentions...) + in.Images = append(in.Images, extra.Images...) + return in +} diff --git a/server/internal/codex/turn.go b/server/internal/codex/turn.go new file mode 100644 index 0000000..0322caf --- /dev/null +++ b/server/internal/codex/turn.go @@ -0,0 +1,148 @@ +package codex + +import ( + "context" + + cderr "codedock/internal/errors" + "codedock/internal/util" + pkg "codedock/pkg/codex" +) + +// StartTurn 发出这条输入。空闲就开一轮,正忙则排队。 +func (rt *Runtime) StartTurn(ctx context.Context, sessionID, content string, extra pkg.Input, mode pkg.InputMode) (pkg.Turn, error) { + if sessionID == "" { + return pkg.Turn{}, cderr.Invalid("session_id is required") + } + st := rt.state(sessionID) + st.mu.Lock() + if st.archived { + st.mu.Unlock() + return pkg.Turn{}, cderr.Invalid("archived session cannot start a turn") + } + busy := st.active != nil && (st.active.Status == pkg.TurnRunning || st.active.Status == pkg.TurnWaitingApproval || st.active.Status == pkg.TurnQueued) + st.mu.Unlock() + + input := rt.takeDraft(sessionID, content, extra) + turn := pkg.Turn{ID: util.NewID(), SessionID: sessionID, Status: pkg.TurnQueued} + if busy || mode == pkg.InputQueue { + st.mu.Lock() + st.queue = append(st.queue, queuedTurn{turn: turn, input: input}) + st.mu.Unlock() + rt.emit(sessionID, pkg.Event{Type: pkg.EventTurnQueued, TurnID: turn.ID, Turn: &turn}) + return turn, nil + } + return rt.dispatchTurn(ctx, sessionID, turn, input) +} + +func (rt *Runtime) dispatchTurn(ctx context.Context, sessionID string, turn pkg.Turn, input pkg.Input) (pkg.Turn, error) { + client, err := rt.requireReady(ctx) + if err != nil { + turn.Status = pkg.TurnFailed + turn.Error = err.Error() + rt.emit(sessionID, pkg.Event{Type: pkg.EventTurnFailed, TurnID: turn.ID, Turn: &turn, Notice: turn.Error}) + return turn, err + } + st := rt.state(sessionID) + st.mu.Lock() + settings := st.settings + turn.Status = pkg.TurnRunning + copyTurn := turn + st.active = ©Turn + st.mu.Unlock() + + params := pkg.ApplyTurnOverrides(pkg.TurnStartParams{ + ThreadID: sessionID, + Input: pkg.UserInputs(input), + Cwd: settings.Cwd, + }, settings) + res, err := client.TurnStart(ctx, params) + if err != nil { + st.mu.Lock() + if st.active != nil && st.active.ID == turn.ID { + st.active.Status = pkg.TurnFailed + st.active.Error = err.Error() + failed := *st.active + st.active = nil + st.mu.Unlock() + rt.emit(sessionID, pkg.Event{Type: pkg.EventTurnFailed, TurnID: failed.ID, Turn: &failed, Notice: failed.Error}) + return failed, mapRPC(err) + } + st.mu.Unlock() + return turn, mapRPC(err) + } + st.mu.Lock() + if st.active != nil && st.active.ID == turn.ID { + st.active.CodexID = res.Turn.ID + st.active.Status = pkg.MapTurnStatus(res.Turn.Status, len(st.asks) > 0) + turn = *st.active + } + st.mu.Unlock() + rt.emit(sessionID, pkg.Event{Type: pkg.EventTurnStarted, TurnID: turn.ID, Turn: &turn, Progress: &pkg.Progress{Kind: pkg.ProgressUser, Text: input.Text}}) + return turn, nil +} + +// CancelTurn 手动打断当前回合,或从队列里拿掉还没开的那条。 +func (rt *Runtime) CancelTurn(ctx context.Context, sessionID, turnID string) error { + if turnID == "" { + return cderr.Invalid("turn_id is required") + } + st := rt.state(sessionID) + st.mu.Lock() + if st.active != nil && st.active.ID == turnID { + codexID := st.active.CodexID + st.mu.Unlock() + if codexID == "" { + st.mu.Lock() + st.active.Status = pkg.TurnCancelled + done := *st.active + st.active = nil + next := queuedTurn{} + hasNext := false + if len(st.queue) > 0 { + next = st.queue[0] + st.queue = st.queue[1:] + hasNext = true + } + st.mu.Unlock() + rt.emit(sessionID, pkg.Event{Type: pkg.EventTurnCancelled, TurnID: done.ID, Turn: &done}) + if hasNext { + _, _ = rt.dispatchTurn(ctx, sessionID, next.turn, next.input) + } + return nil + } + client, err := rt.ensureClient(ctx) + if err != nil { + return err + } + return mapRPC(client.TurnInterrupt(ctx, sessionID, codexID)) + } + for i, item := range st.queue { + if item.turn.ID == turnID { + st.queue = append(st.queue[:i], st.queue[i+1:]...) + item.turn.Status = pkg.TurnCancelled + st.mu.Unlock() + rt.emit(sessionID, pkg.Event{Type: pkg.EventTurnCancelled, TurnID: item.turn.ID, Turn: &item.turn}) + return nil + } + } + st.mu.Unlock() + return cderr.NotFound("turn %s", turnID) +} + +// Compact 让 Codex 压缩这条 thread。 +func (rt *Runtime) Compact(ctx context.Context, sessionID string) error { + client, err := rt.requireReady(ctx) + if err != nil { + return err + } + return mapRPC(client.ThreadCompact(ctx, sessionID)) +} + +// Review 让 Codex 评审工作目录改动。 +func (rt *Runtime) Review(ctx context.Context, sessionID string) error { + client, err := rt.requireReady(ctx) + if err != nil { + return err + } + return mapRPC(client.ReviewStart(ctx, sessionID)) +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index ff477b8..7b3e529 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -13,6 +13,7 @@ type Config struct { LLMAPIKey string LLMBaseURL string GitRepo string + CodexBin string } // Load 从环境变量读取配置,未设置时使用默认值。 @@ -27,6 +28,7 @@ func Load() Config { LLMAPIKey: env("LLM_API_KEY", ""), LLMBaseURL: env("LLM_BASE_URL", ""), GitRepo: env("GIT_REPO", ""), + CodexBin: env("CODEX_BIN", "codex"), } } diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go index a99c434..8240808 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/config/config_test.go @@ -17,6 +17,7 @@ func TestLoadDefaults(t *testing.T) { t.Setenv("LLM_API_KEY", "") t.Setenv("LLM_BASE_URL", "") t.Setenv("GIT_REPO", "") + t.Setenv("CODEX_BIN", "") cfg := Load() if cfg.HTTPAddr != ":8080" { @@ -40,6 +41,9 @@ func TestLoadDefaults(t *testing.T) { if cfg.GitRepo != "" { t.Fatalf("GitRepo = %q, want empty", cfg.GitRepo) } + if cfg.CodexBin != "codex" { + t.Fatalf("CodexBin = %q, want codex", cfg.CodexBin) + } } // TestLoadFromEnv 校验环境变量覆盖默认配置。 @@ -53,6 +57,7 @@ func TestLoadFromEnv(t *testing.T) { t.Setenv("LLM_API_KEY", "sk-test") t.Setenv("LLM_BASE_URL", "https://api.example.com/v1") t.Setenv("GIT_REPO", "/tmp/repo") + t.Setenv("CODEX_BIN", "/usr/local/bin/codex") cfg := Load() if cfg.HTTPAddr != ":9090" || cfg.LogLevel != "info" || cfg.DBEngine != "postgres" || cfg.DBDSN != "postgres://localhost" { @@ -64,6 +69,9 @@ func TestLoadFromEnv(t *testing.T) { if cfg.GitRepo != "/tmp/repo" { t.Fatalf("GitRepo = %q, want /tmp/repo", cfg.GitRepo) } + if cfg.CodexBin != "/usr/local/bin/codex" { + t.Fatalf("CodexBin = %q, want /usr/local/bin/codex", cfg.CodexBin) + } } // TestParseDotEnvFile 校验注释、引号、export 与行尾注释。 diff --git a/server/internal/handler/codex/api.go b/server/internal/handler/codex/api.go new file mode 100644 index 0000000..f06697f --- /dev/null +++ b/server/internal/handler/codex/api.go @@ -0,0 +1,402 @@ +package codex + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + intcodex "codedock/internal/codex" + cderr "codedock/internal/errors" + pkg "codedock/pkg/codex" +) + +// API 是 /codex HTTP 薄桥接。 +type API struct { + rt *intcodex.Runtime +} + +// New 构造 Codex HTTP 入口。 +func New(rt *intcodex.Runtime) *API { + return &API{rt: rt} +} + +// Mount 把 /codex 路由挂到父路由器上。 +func (a *API) Mount(r chi.Router) { + if a == nil || a.rt == nil { + return + } + r.Route("/codex", func(r chi.Router) { + r.Get("/status", a.Status) + r.Get("/models", a.Models) + r.Get("/modes", a.Modes) + r.Get("/commands", a.Commands) + r.Get("/sessions", a.ListSessions) + r.Post("/sessions", a.CreateSession) + r.Get("/sessions/{id}", a.GetSession) + r.Patch("/sessions/{id}", a.PatchSession) + r.Post("/sessions/{id}/fork", a.ForkSession) + r.Post("/sessions/{id}/archive", a.ArchiveSession) + r.Post("/sessions/{id}/compact", a.CompactSession) + r.Post("/sessions/{id}/review", a.ReviewSession) + r.Get("/sessions/{id}/settings", a.GetSettings) + r.Post("/sessions/{id}/settings", a.ApplySettings) + r.Post("/sessions/{id}/commands", a.InvokeCommand) + r.Post("/sessions/{id}/turns", a.StartTurn) + r.Post("/sessions/{id}/attachments/mention", a.Mention) + r.Post("/sessions/{id}/attachments/image", a.AttachImage) + r.Get("/sessions/{id}/events", a.Events) + r.Get("/sessions/{id}/asks", a.ListAsks) + r.Post("/turns/{id}/interrupt", a.InterruptTurn) + r.Post("/asks/{request_id}/decision", a.Decide) + r.Post("/asks/{request_id}/expire", a.Expire) + }) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body != nil { + _ = json.NewEncoder(w).Encode(body) + } +} + +func writeError(w http.ResponseWriter, err error) { + status := http.StatusInternalServerError + switch { + case cderr.IsNotFound(err): + status = http.StatusNotFound + case cderr.IsConflict(err): + status = http.StatusConflict + case cderr.IsInvalid(err): + status = http.StatusBadRequest + case cderr.IsUnauthorized(err): + status = http.StatusUnauthorized + case cderr.IsUnavailable(err): + status = http.StatusServiceUnavailable + } + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +func decodeJSON(r *http.Request, dest any) error { + if r.Body == nil { + return nil + } + if err := json.NewDecoder(r.Body).Decode(dest); err != nil { + return cderr.Invalid("%s", err.Error()) + } + return nil +} + +func (a *API) Status(w http.ResponseWriter, r *http.Request) { + status, err := a.rt.Probe(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (a *API) Models(w http.ResponseWriter, r *http.Request) { + models, err := a.rt.ListModels(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"models": models}) +} + +func (a *API) Modes(w http.ResponseWriter, r *http.Request) { + modes, err := a.rt.ListModes(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"modes": modes}) +} + +func (a *API) Commands(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"commands": a.rt.Commands()}) +} + +func (a *API) ListSessions(w http.ResponseWriter, r *http.Request) { + archived := r.URL.Query().Get("archived") == "true" + page, err := a.rt.ListSessions(r.Context(), archived, r.URL.Query().Get("cursor")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, page) +} + +type createSessionRequest struct { + Settings pkg.Settings `json:"settings"` +} + +func (a *API) CreateSession(w http.ResponseWriter, r *http.Request) { + var req createSessionRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + session, err := a.rt.CreateSession(r.Context(), req.Settings) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"session": session}) +} + +func (a *API) GetSession(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + session, progress, err := a.rt.GetSession(r.Context(), id) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"session": session, "progress": progress, "asks": a.rt.PendingAsks(id)}) +} + +type patchSessionRequest struct { + Title string `json:"title"` +} + +func (a *API) PatchSession(w http.ResponseWriter, r *http.Request) { + var req patchSessionRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + if err := a.rt.Rename(r.Context(), chi.URLParam(r, "id"), req.Title); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (a *API) ForkSession(w http.ResponseWriter, r *http.Request) { + session, err := a.rt.Fork(r.Context(), chi.URLParam(r, "id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"session": session}) +} + +func (a *API) ArchiveSession(w http.ResponseWriter, r *http.Request) { + if err := a.rt.Archive(r.Context(), chi.URLParam(r, "id")); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (a *API) CompactSession(w http.ResponseWriter, r *http.Request) { + if err := a.rt.Compact(r.Context(), chi.URLParam(r, "id")); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (a *API) ReviewSession(w http.ResponseWriter, r *http.Request) { + if err := a.rt.Review(r.Context(), chi.URLParam(r, "id")); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (a *API) GetSettings(w http.ResponseWriter, r *http.Request) { + settings, err := a.rt.Effective(r.Context(), chi.URLParam(r, "id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"settings": settings}) +} + +func (a *API) ApplySettings(w http.ResponseWriter, r *http.Request) { + var patch pkg.Settings + if err := decodeJSON(r, &patch); err != nil { + writeError(w, err) + return + } + settings, err := a.rt.ApplySettings(r.Context(), chi.URLParam(r, "id"), patch) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"settings": settings}) +} + +type commandRequest struct { + Name string `json:"name"` + Args string `json:"args"` +} + +func (a *API) InvokeCommand(w http.ResponseWriter, r *http.Request) { + var req commandRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + result, err := a.rt.Invoke(r.Context(), chi.URLParam(r, "id"), req.Name, req.Args) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +type startTurnRequest struct { + Content string `json:"content"` + Input pkg.Input `json:"input"` + Mode pkg.InputMode `json:"mode"` +} + +func (a *API) StartTurn(w http.ResponseWriter, r *http.Request) { + var req startTurnRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + mode := req.Mode + if mode == "" { + mode = pkg.InputStart + } + turn, err := a.rt.StartTurn(r.Context(), chi.URLParam(r, "id"), req.Content, req.Input, mode) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"turn": turn}) +} + +type pathRequest struct { + Path string `json:"path"` +} + +func (a *API) Mention(w http.ResponseWriter, r *http.Request) { + var req pathRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + if err := a.rt.Mention(r.Context(), chi.URLParam(r, "id"), req.Path); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (a *API) AttachImage(w http.ResponseWriter, r *http.Request) { + var req pathRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + if err := a.rt.AttachImage(r.Context(), chi.URLParam(r, "id"), req.Path); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (a *API) ListAsks(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"asks": a.rt.PendingAsks(chi.URLParam(r, "id"))}) +} + +type interruptRequest struct { + SessionID string `json:"session_id"` +} + +func (a *API) InterruptTurn(w http.ResponseWriter, r *http.Request) { + var req interruptRequest + _ = decodeJSON(r, &req) + if req.SessionID == "" { + writeError(w, cderr.Invalid("session_id is required")) + return + } + if err := a.rt.CancelTurn(r.Context(), req.SessionID, chi.URLParam(r, "id")); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (a *API) Decide(w http.ResponseWriter, r *http.Request) { + var answer pkg.AskAnswer + if err := decodeJSON(r, &answer); err != nil { + writeError(w, err) + return + } + if err := a.rt.Decide(r.Context(), chi.URLParam(r, "request_id"), answer); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (a *API) Expire(w http.ResponseWriter, r *http.Request) { + if err := a.rt.Expire(r.Context(), chi.URLParam(r, "request_id")); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (a *API) Events(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, fmt.Errorf("streaming unsupported")) + return + } + sessionID := chi.URLParam(r, "id") + after := int64(0) + if raw := r.URL.Query().Get("after"); raw != "" { + after, _ = strconv.ParseInt(raw, 10, 64) + } + if last := r.Header.Get("Last-Event-ID"); last != "" { + after, _ = strconv.ParseInt(last, 10, 64) + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + replay, reset := a.rt.Events(sessionID, after) + if reset { + writeSSE(w, pkg.Event{Type: pkg.EventReset, SessionID: sessionID, Notice: "event gap; rehydrate via GET /codex/sessions/{id}"}) + flusher.Flush() + } + for _, ev := range replay { + writeSSE(w, ev) + flusher.Flush() + } + live, unsub := a.rt.Subscribe(sessionID) + defer unsub() + for { + select { + case <-r.Context().Done(): + return + case ev, ok := <-live: + if !ok { + return + } + writeSSE(w, ev) + flusher.Flush() + } + } +} + +func writeSSE(w http.ResponseWriter, ev pkg.Event) { + body, err := json.Marshal(ev) + if err != nil { + return + } + fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.Seq, ev.Type, body) +} diff --git a/server/internal/handler/codex/api_test.go b/server/internal/handler/codex/api_test.go new file mode 100644 index 0000000..aa02a5e --- /dev/null +++ b/server/internal/handler/codex/api_test.go @@ -0,0 +1,242 @@ +package codex + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + intcodex "codedock/internal/codex" + pkg "codedock/pkg/codex" +) + +func newCodexAPI(t *testing.T, fake *intcodex.FakeHandler) (*API, *intcodex.Runtime) { + t.Helper() + if fake == nil { + fake = intcodex.NewFakeHandler() + } + rt := intcodex.New(intcodex.Options{ + LookPath: func(string) (string, error) { return "/bin/codex", nil }, + Version: func(context.Context, string) (string, error) { return "codex-cli 0.149.0", nil }, + Start: intcodex.LoopbackStarter(fake.Handle), + }) + t.Cleanup(func() { _ = rt.Close() }) + return New(rt), rt +} + +func doJSON(t *testing.T, h http.Handler, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + var r *http.Request + if body == "" { + r = httptest.NewRequest(method, path, nil) + } else { + r = httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return rec +} + +func TestHTTPCatalogAndSession(t *testing.T) { + api, _ := newCodexAPI(t, nil) + r := chi.NewRouter() + api.Mount(r) + + if rec := doJSON(t, r, http.MethodGet, "/codex/status", ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodGet, "/codex/models", ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodGet, "/codex/modes", ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodGet, "/codex/commands", ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + rec := doJSON(t, r, http.MethodPost, "/codex/sessions", `{"settings":{"cwd":"/tmp"}}`) + if rec.Code != http.StatusCreated { + t.Fatal(rec.Body.String()) + } + var created struct { + Session pkg.Session `json:"session"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + id := created.Session.ID + if rec := doJSON(t, r, http.MethodGet, "/codex/sessions", ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodGet, "/codex/sessions/"+id, ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPatch, "/codex/sessions/"+id, `{"title":"Hi"}`); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodGet, "/codex/sessions/"+id+"/settings", ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/sessions/"+id+"/settings", `{"effort":"high"}`); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/sessions/"+id+"/attachments/mention", `{"path":"a.go"}`); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/sessions/"+id+"/attachments/image", `{"path":"a.png"}`); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/sessions/"+id+"/commands", `{"name":"mcp"}`); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/sessions/"+id+"/turns", `{"content":"hello"}`); rec.Code != http.StatusAccepted { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/sessions/"+id+"/fork", ""); rec.Code != http.StatusCreated { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/sessions/"+id+"/compact", ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/sessions/"+id+"/review", ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodGet, "/codex/sessions/"+id+"/asks", ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/sessions/"+id+"/archive", ""); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/asks/missing/decision", `{"approved":true}`); rec.Code != 404 { + t.Fatal(rec.Code, rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/turns/x/interrupt", `{}`); rec.Code != 400 { + t.Fatal(rec.Code) + } +} + +func TestHTTPAskAndSSE(t *testing.T) { + fake := intcodex.NewFakeHandler() + fake.SendAsk = true + api, rt := newCodexAPI(t, fake) + r := chi.NewRouter() + api.Mount(r) + rec := doJSON(t, r, http.MethodPost, "/codex/sessions", `{"settings":{}}`) + var created struct { + Session pkg.Session `json:"session"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &created) + id := created.Session.ID + turnRec := doJSON(t, r, http.MethodPost, "/codex/sessions/"+id+"/turns", `{"content":"hello"}`) + if turnRec.Code != http.StatusAccepted { + t.Fatal(turnRec.Body.String()) + } + var turnBody struct { + Turn pkg.Turn `json:"turn"` + } + _ = json.Unmarshal(turnRec.Body.Bytes(), &turnBody) + deadline := time.Now().Add(2 * time.Second) + var asks struct { + Asks []pkg.ApprovalAsk `json:"asks"` + } + for time.Now().Before(deadline) { + list := doJSON(t, r, http.MethodGet, "/codex/sessions/"+id+"/asks", "") + _ = json.Unmarshal(list.Body.Bytes(), &asks) + if len(asks.Asks) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + if len(asks.Asks) == 0 { + t.Fatal("no asks") + } + if rec := doJSON(t, r, http.MethodPost, "/codex/asks/"+asks.Asks[0].ID+"/decision", `{"approved":false}`); rec.Code != 200 { + t.Fatal(rec.Body.String()) + } + if rec := doJSON(t, r, http.MethodPost, "/codex/turns/"+turnBody.Turn.ID+"/interrupt", `{"session_id":"`+id+`"}`); rec.Code != 200 && rec.Code != 404 { + t.Fatal(rec.Code, rec.Body.String()) + } + + req := httptest.NewRequest(http.MethodGet, "/codex/sessions/"+id+"/events?after=0", nil) + req.Header.Set("Last-Event-ID", "0") + ctx, cancel := context.WithTimeout(req.Context(), 200*time.Millisecond) + defer cancel() + req = req.WithContext(ctx) + sse := httptest.NewRecorder() + r.ServeHTTP(sse, req) + if sse.Code != 200 || !strings.Contains(sse.Body.String(), "event:") { + t.Fatalf("sse code=%d body=%s", sse.Code, sse.Body.String()) + } + _ = rt +} + +func TestMountNil(t *testing.T) { + r := chi.NewRouter() + (&API{}).Mount(r) + New(nil).Mount(r) +} + +func TestHTTPErrorPaths(t *testing.T) { + fake := intcodex.NewFakeHandler() + fake.Authorized = false + api, _ := newCodexAPI(t, fake) + r := chi.NewRouter() + api.Mount(r) + if rec := doJSON(t, r, http.MethodPost, "/codex/sessions", `{"settings":{}}`); rec.Code != http.StatusUnauthorized { + t.Fatal(rec.Code, rec.Body.String()) + } + + okAPI, _ := newCodexAPI(t, nil) + ok := chi.NewRouter() + okAPI.Mount(ok) + if rec := doJSON(t, ok, http.MethodPost, "/codex/sessions", `{`); rec.Code != http.StatusBadRequest { + t.Fatal(rec.Code, rec.Body.String()) + } + created := doJSON(t, ok, http.MethodPost, "/codex/sessions", `{"settings":{}}`) + var body struct { + Session pkg.Session `json:"session"` + } + _ = json.Unmarshal(created.Body.Bytes(), &body) + id := body.Session.ID + if rec := doJSON(t, ok, http.MethodGet, "/codex/sessions/missing", ""); rec.Code != http.StatusNotFound { + t.Fatal(rec.Code, rec.Body.String()) + } + if rec := doJSON(t, ok, http.MethodPost, "/codex/sessions/"+id+"/attachments/mention", `{"path":""}`); rec.Code != http.StatusBadRequest { + t.Fatal(rec.Code, rec.Body.String()) + } + if rec := doJSON(t, ok, http.MethodPost, "/codex/sessions/"+id+"/commands", `{"name":"nope"}`); rec.Code != http.StatusBadRequest { + t.Fatal(rec.Code, rec.Body.String()) + } + if rec := doJSON(t, ok, http.MethodPost, "/codex/asks/missing/expire", ""); rec.Code != http.StatusNotFound { + t.Fatal(rec.Code, rec.Body.String()) + } + if rec := doJSON(t, ok, http.MethodPost, "/codex/asks/missing/decision", `{`); rec.Code != http.StatusBadRequest { + t.Fatal(rec.Code) + } + if rec := doJSON(t, ok, http.MethodPost, "/codex/sessions/"+id+"/commands", `{"name":"stop"}`); rec.Code != http.StatusBadRequest { + t.Fatal(rec.Code, rec.Body.String()) + } + + w := &plainWriter{hdr: make(http.Header)} + req := httptest.NewRequest(http.MethodGet, "/codex/sessions/"+id+"/events", nil) + ok.ServeHTTP(w, req) + if w.code != http.StatusInternalServerError { + t.Fatalf("streaming unsupported code=%d", w.code) + } +} + +type plainWriter struct { + hdr http.Header + code int + buf strings.Builder +} + +func (p *plainWriter) Header() http.Header { return p.hdr } +func (p *plainWriter) Write(b []byte) (int, error) { return p.buf.WriteString(string(b)) } +func (p *plainWriter) WriteHeader(c int) { p.code = c } diff --git a/server/pkg/codex/approval.go b/server/pkg/codex/approval.go index a139886..e31e355 100644 --- a/server/pkg/codex/approval.go +++ b/server/pkg/codex/approval.go @@ -12,54 +12,45 @@ const ( type AskKind string const ( - AskCommand AskKind = "command" // 能不能跑这条命令。 - AskFileChange AskKind = "file_change" // 能不能改这些文件。 - AskQuestion AskKind = "question" // 让人补一句字,或从几个选项里挑一个。 - AskForm AskKind = "form" // MCP 跑起来之后弹出来、要人填的表单。 + AskCommand AskKind = "command" // 能不能跑这条命令。 + AskFileChange AskKind = "file_change" // 能不能改这些文件。 + AskQuestion AskKind = "question" // 让人补一句字,或从几个选项里挑一个。 + AskForm AskKind = "form" // MCP 跑起来之后弹出来、要人填的表单。 + AskPermissions AskKind = "permissions" // 额外权限。 ) // ApprovalAsk 是一条 Codex 已知的反问。 type ApprovalAsk struct { - Kind AskKind - Command string - Paths []string - Diff string - Prompt string // 选择题或表单给人看的题面。 - Options []string // 补一句字时的选项。 - Fields []string // MCP 表单字段名。 - ExternalRequestID string // 用来回给 Codex 的那张问票。 + ID string `json:"id"` + Kind AskKind `json:"kind"` + ThreadID string `json:"thread_id,omitempty"` + TurnID string `json:"turn_id,omitempty"` + Method string `json:"method,omitempty"` + Command string `json:"command,omitempty"` + Paths []string `json:"paths,omitempty"` + Diff string `json:"diff,omitempty"` + Prompt string `json:"prompt,omitempty"` // 选择题或表单给人看的题面。 + Options []string `json:"options,omitempty"` + Fields []string `json:"fields,omitempty"` // MCP 表单字段名。 + ExternalRequestID string `json:"external_request_id"` } // AskAnswer 是人对这条反问的作答。 type AskAnswer struct { - Approved bool - Scope DecisionScope - Choice string // 选择题选中的项。 - Values []string // 表单填写结果。 + Approved bool `json:"approved"` + Scope DecisionScope `json:"scope,omitempty"` + Choice string `json:"choice,omitempty"` // 选择题选中的项。 + Values []string `json:"values,omitempty"` // 表单填写结果。 } -// Approval 管 Codex 已知的反问:跑命令、改文件、补一句字、MCP 弹出的表单,这几种都做完整界面。 -// 不管官方新加、本模块认不出的提问,也不管本地模型那套工具审批。 -type Approval struct { - Codex *Codex - Turn *Turns -} - -// Require 登记一条 Codex 的反问等人作答,拿到结果后回给 Codex,并让这一轮接着跑。 -// 人不回话 Codex 就一直等着,所以这条必须有人答。 -func (a *Approval) Require(turnID string, ask ApprovalAsk) (approvalID string, err error) { - a.Decide("", AskAnswer{}) - a.Codex.ReplyAsk(ask.ExternalRequestID, AskAnswer{}) - a.Turn.Continue(turnID) - return "", nil -} - -// Decide 记下人对这条反问的作答:批还是拒、管一次还是管整条对话、选了哪一项、表单填了什么。 -func (a *Approval) Decide(approvalID string, answer AskAnswer) error { - return nil -} - -// Expire 这条反问放太久没人理,按拒绝回给 Codex,免得它一直等在那儿。 -func (a *Approval) Expire(approvalID string) error { - return nil +// KnownAskMethod 判断这是不是本模块能做完整界面的官方反问。 +func KnownAskMethod(method string) bool { + switch method { + case MethodItemCommandApproval, MethodItemFileApproval, MethodItemPermissionsApproval, + MethodItemToolUserInput, MethodMCPElicitation, + MethodExecCommandApproval, MethodApplyPatchApproval: + return true + default: + return false + } } diff --git a/server/pkg/codex/ask.go b/server/pkg/codex/ask.go new file mode 100644 index 0000000..71d5fe2 --- /dev/null +++ b/server/pkg/codex/ask.go @@ -0,0 +1,293 @@ +package codex + +import ( + "encoding/json" +) + +// ParseAsk 把一条服务端请求编成本模块的反问。认不出则 ok=false。 +func ParseAsk(msg Message) (ApprovalAsk, bool) { + if msg.Kind != KindRequest || !KnownAskMethod(msg.Method) { + return ApprovalAsk{}, false + } + ask := ApprovalAsk{ + ID: msg.ID.String(), + Method: msg.Method, + ExternalRequestID: msg.ID.String(), + } + var raw map[string]json.RawMessage + _ = json.Unmarshal(msg.Params, &raw) + ask.ThreadID = rawString(raw, "threadId") + ask.TurnID = rawString(raw, "turnId") + switch msg.Method { + case MethodItemCommandApproval, MethodExecCommandApproval: + ask.Kind = AskCommand + ask.Command = firstNonEmpty(rawString(raw, "command"), commandFromNested(raw["command"])) + if ask.Command == "" { + ask.Command = rawString(raw, "cmd") + } + case MethodItemFileApproval, MethodApplyPatchApproval: + ask.Kind = AskFileChange + ask.Paths, ask.Diff = fileChangeFromParams(raw, msg.Params) + case MethodItemPermissionsApproval: + ask.Kind = AskPermissions + ask.Prompt = firstNonEmpty(rawString(raw, "reason"), "Codex 请求额外权限") + case MethodItemToolUserInput: + ask.Kind = AskQuestion + ask.Prompt, ask.Options, ask.Fields = questionsFromParams(raw) + case MethodMCPElicitation: + ask.Kind = AskForm + ask.Prompt = firstNonEmpty(rawString(raw, "serverName"), "MCP 表单") + ask.Fields = formFieldsFromParams(raw, msg.Params) + } + return ask, true +} + +// ReplyBody 按官方 schema 把人的作答编成回包。 +func ReplyBody(ask ApprovalAsk, answer AskAnswer) any { + accepted := answer.Approved + session := answer.Scope == ScopeSession + switch ask.Method { + case MethodItemCommandApproval: + return map[string]any{"decision": commandDecision(accepted, session)} + case MethodItemFileApproval: + return map[string]any{"decision": fileDecision(accepted, session)} + case MethodItemPermissionsApproval: + return map[string]any{ + "permissions": map[string]any{}, + "scope": permissionScope(accepted, session), + } + case MethodItemToolUserInput: + return map[string]any{"answers": userInputAnswers(ask, answer)} + case MethodMCPElicitation: + action := "decline" + if accepted { + action = "accept" + } + body := map[string]any{"action": action} + if accepted { + body["content"] = formContent(ask, answer) + } + return body + case MethodExecCommandApproval, MethodApplyPatchApproval: + decision := "denied" + if accepted { + if session { + decision = "approved_for_session" + } else { + decision = "approved" + } + } + return map[string]any{"decision": decision} + default: + return map[string]any{"decision": "decline"} + } +} + +func commandDecision(accepted, session bool) any { + if !accepted { + return "decline" + } + if session { + return "acceptForSession" + } + return "accept" +} + +func fileDecision(accepted, session bool) any { + if !accepted { + return "decline" + } + if session { + return "acceptForSession" + } + return "accept" +} + +func permissionScope(accepted, session bool) string { + if !accepted { + return "turn" + } + if session { + return "session" + } + return "turn" +} + +func userInputAnswers(ask ApprovalAsk, answer AskAnswer) map[string]any { + out := map[string]any{} + values := answer.Values + if answer.Choice != "" { + values = []string{answer.Choice} + } + if len(ask.Fields) == 0 { + out["answer"] = map[string]any{"answers": values} + return out + } + for i, field := range ask.Fields { + item := []string{} + if i < len(values) { + item = []string{values[i]} + } else if answer.Choice != "" && i == 0 { + item = []string{answer.Choice} + } + out[field] = map[string]any{"answers": item} + } + return out +} + +func formContent(ask ApprovalAsk, answer AskAnswer) map[string]any { + out := map[string]any{} + for i, field := range ask.Fields { + if i < len(answer.Values) { + out[field] = answer.Values[i] + } + } + if len(out) == 0 && answer.Choice != "" { + out["value"] = answer.Choice + } + return out +} + +func rawString(raw map[string]json.RawMessage, key string) string { + b, ok := raw[key] + if !ok { + return "" + } + var s string + if err := json.Unmarshal(b, &s); err == nil { + return s + } + return "" +} + +func commandFromNested(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + return "" + } + if s, _ := obj["command"].(string); s != "" { + return s + } + return "" +} + +func fileChangeFromParams(raw map[string]json.RawMessage, params json.RawMessage) ([]string, string) { + var paths []string + var diff string + if b, ok := raw["grantRoot"]; ok { + var root string + if json.Unmarshal(b, &root) == nil && root != "" { + paths = append(paths, root) + } + } + var envelope struct { + Changes []struct { + Path string `json:"path"` + Diff string `json:"diff"` + } `json:"changes"` + FileChanges []struct { + Path string `json:"path"` + Diff string `json:"diff"` + } `json:"fileChanges"` + } + _ = json.Unmarshal(params, &envelope) + for _, ch := range envelope.Changes { + if ch.Path != "" { + paths = append(paths, ch.Path) + } + if ch.Diff != "" { + diff += ch.Diff + } + } + for _, ch := range envelope.FileChanges { + if ch.Path != "" { + paths = append(paths, ch.Path) + } + if ch.Diff != "" { + diff += ch.Diff + } + } + return paths, diff +} + +func questionsFromParams(raw map[string]json.RawMessage) (prompt string, options []string, fields []string) { + b, ok := raw["questions"] + if !ok { + return rawString(raw, "prompt"), nil, nil + } + var questions []map[string]any + if err := json.Unmarshal(b, &questions); err != nil { + return "", nil, nil + } + for _, q := range questions { + id, _ := q["id"].(string) + header, _ := q["header"].(string) + question, _ := q["question"].(string) + if prompt == "" { + prompt = firstNonEmpty(header, question) + } + if id != "" { + fields = append(fields, id) + } + if opts, ok := q["options"].([]any); ok { + for _, opt := range opts { + switch v := opt.(type) { + case string: + options = append(options, v) + case map[string]any: + if s, _ := v["label"].(string); s != "" { + options = append(options, s) + } else if s, _ := v["id"].(string); s != "" { + options = append(options, s) + } + } + } + } + } + return prompt, options, fields +} + +func formFieldsFromParams(raw map[string]json.RawMessage, params json.RawMessage) []string { + if b, ok := raw["requestedSchema"]; ok { + var schema struct { + Properties map[string]any `json:"properties"` + } + if json.Unmarshal(b, &schema) == nil { + fields := make([]string, 0, len(schema.Properties)) + for k := range schema.Properties { + fields = append(fields, k) + } + return fields + } + } + var envelope map[string]any + _ = json.Unmarshal(params, &envelope) + if msg, ok := envelope["message"].(map[string]any); ok { + if schema, ok := msg["requestedSchema"].(map[string]any); ok { + if props, ok := schema["properties"].(map[string]any); ok { + fields := make([]string, 0, len(props)) + for k := range props { + fields = append(fields, k) + } + return fields + } + } + } + return nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/server/pkg/codex/attachment.go b/server/pkg/codex/attachment.go index 30a5528..7c7d3ee 100644 --- a/server/pkg/codex/attachment.go +++ b/server/pkg/codex/attachment.go @@ -2,25 +2,45 @@ package codex // Input 是本条要带给 Codex 的正文、文件提及和图片。 type Input struct { - Text string - Mentions []string // 仓库内路径,对 Codex 的 mention。 - Images []string // 本地图片路径,对 Codex 的 localImage。 + Text string `json:"text,omitempty"` + Mentions []string `json:"mentions,omitempty"` // 仓库内路径,对 Codex 的 mention。 + Images []string `json:"images,omitempty"` // 本地图片路径,对 Codex 的 localImage。 } -// Attachment 管本条消息将要带给 Codex 的文件提及和图片。不管发送,不管工作目录从哪来。 -type Attachment struct{} - -// Mention 把一个仓库内文件挂到还没发出去的这条内容上,对应 Codex 的 mention。 -func (a *Attachment) Mention(sessionID, path string) error { - return nil +// Empty 表示还没有正文或附件。 +func (in Input) Empty() bool { + return in.Text == "" && len(in.Mentions) == 0 && len(in.Images) == 0 } -// AttachImage 把一张本地图片挂到还没发出去的这条内容上,对应 Codex 的 localImage。 -func (a *Attachment) AttachImage(sessionID, path string) error { - return nil +// UserInput 是发给 turn/start 的一条官方输入项。 +type UserInput struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + URL string `json:"url,omitempty"` } -// TakeDraft 取出这条攒好的正文与附件并清空草稿,交给回合发给 Codex。 -func (a *Attachment) TakeDraft(sessionID string) (Input, error) { - return Input{}, nil +// UserInputs 把本模块的 Input 编成 Codex 的 input 数组。 +func UserInputs(in Input) []UserInput { + items := make([]UserInput, 0, 1+len(in.Mentions)+len(in.Images)) + if in.Text != "" { + items = append(items, UserInput{Type: "text", Text: in.Text}) + } + for _, path := range in.Mentions { + if path == "" { + continue + } + items = append(items, UserInput{Type: "mention", Name: path, Path: path}) + } + for _, path := range in.Images { + if path == "" { + continue + } + items = append(items, UserInput{Type: "localImage", Path: path}) + } + if len(items) == 0 { + return []UserInput{{Type: "text", Text: ""}} + } + return items } diff --git a/server/pkg/codex/catalog.go b/server/pkg/codex/catalog.go index 0af8399..d8efd0b 100644 --- a/server/pkg/codex/catalog.go +++ b/server/pkg/codex/catalog.go @@ -2,54 +2,28 @@ package codex // EngineStatus 是本机 Codex 能不能用的体检结果。 type EngineStatus struct { - Available bool // 本机装没装 Codex。 - Authorized bool // 有没有取得 Codex 授权;没授权可以看选项,但不能开回合。 - Version string - Hint string // 不可用时给人看的原因,如未安装或未授权。 + Available bool `json:"available"` // 本机装没装 Codex。 + Authorized bool `json:"authorized"` // 有没有取得 Codex 授权;没授权可以看选项,但不能开回合。 + Version string `json:"version"` + Hint string `json:"hint,omitempty"` // 不可用时给人看的原因,如未安装或未授权。 } // ModelInfo 是一条 Codex 模型及其支持的推理强度。 type ModelInfo struct { - ID string - Efforts []string // 该 Codex 模型支持的推理强度。 - DefaultEffort string - Hidden bool // Codex 自己不在选择器里列出来的模型。 - IsDefault bool + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Efforts []string `json:"efforts"` // 该 Codex 模型支持的推理强度。 + DefaultEffort string `json:"default_effort"` + Hidden bool `json:"hidden"` // Codex 自己不在选择器里列出来的模型。 + IsDefault bool `json:"is_default"` } // ModeInfo 是一条 Codex 的 Plan 或权限预设。 type ModeInfo struct { - ID string // Codex 的 Plan 或权限预设名,如 auto、read-only、full-access。 - Kind string // collaboration | permission - Approval string // Codex 的 approval 值;Plan 可空。 - Sandbox string // Codex 的 sandbox 值;Plan 可空。 -} - -// Catalog 管本机有没有 Codex、是否已取得授权、允许选哪些模型与模式。不管对话,不管开回合。 -type Catalog struct { - Session *Sessions - Settings *Configs -} - -// Probe 是「开一条 Codex 对话」的入口:先体检本机装没装 Codex、有没有取得授权, -// 再把可选的模型与模式、这条对话本身和它的生效配置一并备好。 -// 未取得授权时只回 Hint 说明原因,这条对话不该开回合。 -func (c *Catalog) Probe() (EngineStatus, error) { - c.ListModels() - c.ListModes() - c.Session.Create("") - c.Settings.Effective("") - return EngineStatus{}, nil -} - -// ListModels 列出 Codex 允许选的模型,以及每个模型支持哪些推理强度、默认用哪一档。 -// 名单来自 Codex,本模块不自造模型名,也不自造强度档位。 -func (c *Catalog) ListModels() ([]ModelInfo, error) { - return nil, nil -} - -// ListModes 列出 Codex 的 Plan 与权限预设(如 auto、read-only、full-access), -// 连带各自对应的 approval 与 sandbox 值,供人在对话里切换。不自造官方没有的档。 -func (c *Catalog) ListModes() ([]ModeInfo, error) { - return nil, nil + ID string `json:"id"` // Codex 的 Plan 或权限预设名,如 plan、read-only。 + Label string `json:"label,omitempty"` + Kind string `json:"kind"` // collaboration | permission + Approval string `json:"approval,omitempty"` // Codex 的 approval 值;Plan 可空。 + Sandbox string `json:"sandbox,omitempty"` // Codex 的 sandbox 值;Plan 可空。 + Allowed bool `json:"allowed"` // 当前环境是否允许选这一档。 } diff --git a/server/pkg/codex/client.go b/server/pkg/codex/client.go new file mode 100644 index 0000000..1bf3a06 --- /dev/null +++ b/server/pkg/codex/client.go @@ -0,0 +1,251 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "sync" + "sync/atomic" +) + +// Client 是并发安全的 JSON-RPC 客户端:单读循环、乱序响应分发、写锁由 Transport 负责。 +type Client struct { + t Transport + pending map[string]chan rpcResult + mu sync.Mutex + nextID atomic.Int64 + incoming chan Message + done chan struct{} + err error + errOnce sync.Once +} + +type rpcResult struct { + raw json.RawMessage + err error +} + +// NewClient 启动读循环。调用方负责 Handshake。 +func NewClient(t Transport) *Client { + c := &Client{ + t: t, + pending: make(map[string]chan rpcResult), + incoming: make(chan Message, 256), + done: make(chan struct{}), + } + go c.readLoop() + return c +} + +// Incoming 返回通知和来自服务端的请求。响应不会出现在这里。 +func (c *Client) Incoming() <-chan Message { + return c.incoming +} + +// Done 在读循环结束后关闭。 +func (c *Client) Done() <-chan struct{} { + return c.done +} + +// Err 返回读循环结束原因。 +func (c *Client) Err() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.err +} + +// Close 关掉传输并结束读循环。 +func (c *Client) Close() error { + err := c.t.Close() + <-c.done + return err +} + +// Handshake 发送 initialize 再发 initialized 通知。 +func (c *Client) Handshake(ctx context.Context, info ClientInfo) (InitializeResult, error) { + if info.Name == "" { + info.Name = ClientName + } + if info.Version == "" { + info.Version = ClientVersion + } + var out InitializeResult + if err := c.Call(ctx, MethodInitialize, InitializeParams{ClientInfo: info}, &out); err != nil { + return InitializeResult{}, err + } + if err := c.Notify(ctx, MethodInitialized, map[string]any{}); err != nil { + return InitializeResult{}, err + } + return out, nil +} + +// Call 发一条有编号的请求并等待响应。取消后迟到的响应会被丢掉。 +func (c *Client) Call(ctx context.Context, method string, params any, result any) error { + raw, err := c.CallRaw(ctx, method, params) + if err != nil { + return err + } + if result == nil || len(raw) == 0 || string(raw) == "null" { + return nil + } + if err := json.Unmarshal(raw, result); err != nil { + return fmt.Errorf("decode %s result: %w", method, err) + } + return nil +} + +// CallRaw 发请求并返回原始 result。 +func (c *Client) CallRaw(ctx context.Context, method string, params any) (json.RawMessage, error) { + id := IntID(c.nextID.Add(1)) + ch := make(chan rpcResult, 1) + c.mu.Lock() + if c.err != nil { + err := c.err + c.mu.Unlock() + return nil, err + } + c.pending[id.Key()] = ch + c.mu.Unlock() + + if err := c.send(ctx, &Envelope{ID: &id, Method: method, Params: mustParams(params)}); err != nil { + c.drop(id) + return nil, err + } + + select { + case <-ctx.Done(): + c.drop(id) + return nil, ctx.Err() + case <-c.done: + c.drop(id) + if err := c.Err(); err != nil { + return nil, err + } + return nil, io.EOF + case out := <-ch: + return out.raw, out.err + } +} + +// Notify 发一条没有编号的通知。 +func (c *Client) Notify(ctx context.Context, method string, params any) error { + return c.send(ctx, &Envelope{Method: method, Params: mustParams(params)}) +} + +// Reply 把服务端请求的结果写回去。 +func (c *Client) Reply(ctx context.Context, id RequestID, result any) error { + raw, err := json.Marshal(result) + if err != nil { + return err + } + return c.send(ctx, &Envelope{ID: &id, Result: raw}) +} + +// ReplyError 把服务端请求按错误写回去。 +func (c *Client) ReplyError(ctx context.Context, id RequestID, code int, message string) error { + return c.send(ctx, &Envelope{ID: &id, Error: &RPCError{Code: code, Message: message}}) +} + +func (c *Client) send(ctx context.Context, env *Envelope) error { + body, err := json.Marshal(env) + if err != nil { + return err + } + return c.t.Write(ctx, body) +} + +func (c *Client) drop(id RequestID) { + c.mu.Lock() + delete(c.pending, id.Key()) + c.mu.Unlock() +} + +func (c *Client) readLoop() { + defer close(c.done) + ctx := context.Background() + for { + frame, err := c.t.Read(ctx) + if err != nil { + c.fail(err) + return + } + var env Envelope + if err := json.Unmarshal(frame, &env); err != nil { + c.fail(fmt.Errorf("bad jsonl: %w", err)) + return + } + c.dispatch(env) + } +} + +func (c *Client) dispatch(env Envelope) { + switch Classify(env) { + case KindResponse: + if env.ID == nil { + return + } + c.mu.Lock() + ch, ok := c.pending[env.ID.Key()] + if ok { + delete(c.pending, env.ID.Key()) + } + c.mu.Unlock() + if !ok { + return + } + if env.Error != nil { + ch <- rpcResult{err: *env.Error} + return + } + ch <- rpcResult{raw: env.Result} + case KindRequest, KindNotification: + msg := Message{ + Kind: Classify(env), + Method: env.Method, + Params: env.Params, + } + if env.ID != nil { + msg.ID = *env.ID + } + select { + case c.incoming <- msg: + case <-c.done: + } + } +} + +func (c *Client) fail(err error) { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { + err = io.EOF + } + c.errOnce.Do(func() { + c.mu.Lock() + c.err = err + pending := c.pending + c.pending = map[string]chan rpcResult{} + c.mu.Unlock() + for _, ch := range pending { + ch <- rpcResult{err: err} + } + close(c.incoming) + }) +} + +func mustParams(params any) json.RawMessage { + if params == nil { + return nil + } + if raw, ok := params.(json.RawMessage); ok { + return raw + } + body, err := json.Marshal(params) + if err != nil { + return json.RawMessage(`{}`) + } + if string(body) == "null" { + return nil + } + return body +} diff --git a/server/pkg/codex/client_test.go b/server/pkg/codex/client_test.go new file mode 100644 index 0000000..6683604 --- /dev/null +++ b/server/pkg/codex/client_test.go @@ -0,0 +1,231 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "io" + "sync" + "testing" + "time" +) + +func scriptedClient(t *testing.T, handle func(server Transport, env Envelope) *Envelope) *Client { + t.Helper() + local, remote := PipePair() + t.Cleanup(func() { + _ = local.Close() + _ = remote.Close() + }) + go func() { + ctx := context.Background() + for { + frame, err := remote.Read(ctx) + if err != nil { + return + } + var env Envelope + if json.Unmarshal(frame, &env) != nil { + return + } + resp := handle(remote, env) + if resp == nil { + continue + } + if resp.ID == nil && env.ID != nil { + resp.ID = env.ID + } + body, err := json.Marshal(resp) + if err != nil { + return + } + if err := remote.Write(ctx, body); err != nil { + return + } + } + }() + return NewClient(local) +} + +func TestClientHandshakeAndCall(t *testing.T) { + client := scriptedClient(t, func(_ Transport, env Envelope) *Envelope { + switch env.Method { + case MethodInitialize: + return &Envelope{Result: json.RawMessage(`{"codexHome":"/tmp","platformFamily":"unix","platformOs":"macos","userAgent":"codex"}`)} + case MethodInitialized: + return nil + case MethodAccountRead: + return &Envelope{Result: json.RawMessage(`{"requiresOpenaiAuth":false,"account":{"type":"apiKey"}}`)} + default: + return &Envelope{Error: &RPCError{Code: CodeMethodNotFound, Message: env.Method}} + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + got, err := client.Handshake(ctx, DefaultClientInfo()) + if err != nil { + t.Fatal(err) + } + if got.CodexHome != "/tmp" || got.PlatformOS != "macos" { + t.Fatalf("%+v", got) + } + account, err := client.AccountRead(ctx) + if err != nil { + t.Fatal(err) + } + if !Authorized(account) { + t.Fatal("authorized") + } +} + +func TestClientOutOfOrderAndNotification(t *testing.T) { + var mu sync.Mutex + var seen []string + client := scriptedClient(t, func(server Transport, env Envelope) *Envelope { + mu.Lock() + seen = append(seen, env.Method) + mu.Unlock() + switch env.Method { + case "slow": + note, _ := json.Marshal(Envelope{Method: MethodAgentMessageDelta, Params: json.RawMessage(`{"threadId":"t","turnId":"u","itemId":"i","delta":"hi"}`)}) + _ = server.Write(context.Background(), note) + time.Sleep(30 * time.Millisecond) + return &Envelope{Result: json.RawMessage(`{"n":1}`)} + case "fast": + return &Envelope{Result: json.RawMessage(`{"n":2}`)} + default: + return &Envelope{Result: json.RawMessage(`{}`)} + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var slow, fast map[string]int + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _ = client.Call(ctx, "slow", map[string]any{}, &slow) + }() + time.Sleep(10 * time.Millisecond) + go func() { + defer wg.Done() + _ = client.Call(ctx, "fast", map[string]any{}, &fast) + }() + select { + case msg := <-client.Incoming(): + if msg.Method != MethodAgentMessageDelta { + t.Fatalf("method %s", msg.Method) + } + case <-time.After(time.Second): + t.Fatal("missing notification") + } + wg.Wait() + if slow["n"] != 1 || fast["n"] != 2 { + t.Fatalf("slow=%v fast=%v", slow, fast) + } +} + +func TestClientCancelDropsLateResponse(t *testing.T) { + started := make(chan struct{}) + client := scriptedClient(t, func(_ Transport, env Envelope) *Envelope { + if env.Method == "hang" { + close(started) + time.Sleep(80 * time.Millisecond) + return &Envelope{Result: json.RawMessage(`{"ok":true}`)} + } + return &Envelope{Result: json.RawMessage(`{}`)} + }) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + <-started + cancel() + }() + err := client.Call(ctx, "hang", map[string]any{}, nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err=%v", err) + } + time.Sleep(120 * time.Millisecond) +} + +func TestClientBadJSONEnds(t *testing.T) { + local, remote := PipePair() + t.Cleanup(func() { _ = local.Close(); _ = remote.Close() }) + client := NewClient(local) + _ = remote.Write(context.Background(), []byte(`{not json`)) + select { + case <-client.Done(): + case <-time.After(time.Second): + t.Fatal("client did not stop") + } + if client.Err() == nil { + t.Fatal("expected error") + } +} + +func TestClientServerRequestAndReply(t *testing.T) { + client := scriptedClient(t, func(server Transport, env Envelope) *Envelope { + if env.Method != MethodInitialize { + return &Envelope{Result: json.RawMessage(`{}`)} + } + id := StringID("ask-1") + req, _ := json.Marshal(Envelope{ + ID: &id, + Method: MethodItemCommandApproval, + Params: json.RawMessage(`{"threadId":"th","turnId":"tu","command":"ls"}`), + }) + _ = server.Write(context.Background(), req) + return &Envelope{Result: json.RawMessage(`{"codexHome":"/","platformFamily":"unix","platformOs":"linux","userAgent":"x"}`)} + }) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := client.Handshake(ctx, DefaultClientInfo()); err != nil { + t.Fatal(err) + } + select { + case msg := <-client.Incoming(): + if msg.Kind != KindRequest || msg.Method != MethodItemCommandApproval { + t.Fatalf("%+v", msg) + } + if err := client.Reply(ctx, msg.ID, map[string]any{"decision": "accept"}); err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("missing server request") + } +} + +func TestClientUnknownMethodReplyError(t *testing.T) { + client := scriptedClient(t, func(_ Transport, env Envelope) *Envelope { + return &Envelope{Result: json.RawMessage(`{}`)} + }) + ctx := context.Background() + if err := client.ReplyError(ctx, StringID("x"), CodeMethodNotFound, "unsupported"); err != nil { + t.Fatal(err) + } +} + +func TestClientCloseUnblocksCall(t *testing.T) { + local, remote := PipePair() + client := NewClient(local) + go func() { + time.Sleep(20 * time.Millisecond) + _ = local.Close() + _ = remote.Close() + }() + err := client.Call(context.Background(), "noop", map[string]any{}, nil) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, io.EOF) && err.Error() == "" { + t.Fatalf("err=%v", err) + } +} + +func TestCallRawNilResult(t *testing.T) { + client := scriptedClient(t, func(_ Transport, env Envelope) *Envelope { + return &Envelope{Result: json.RawMessage(`null`)} + }) + if err := client.Call(context.Background(), "x", nil, nil); err != nil { + t.Fatal(err) + } +} diff --git a/server/pkg/codex/codex_test.go b/server/pkg/codex/codex_test.go new file mode 100644 index 0000000..b7c093e --- /dev/null +++ b/server/pkg/codex/codex_test.go @@ -0,0 +1,544 @@ +package codex + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestJSONLTruncatedAndLimit(t *testing.T) { + left, right := PipePair() + limited := left.(*JSONL) + limited.max = 8 + go func() { _ = right.Write(context.Background(), []byte(strings.Repeat("a", 32))) }() + if _, err := limited.Read(context.Background()); err == nil { + t.Fatal("expected overflow") + } + _ = left.Close() + _ = right.Close() +} + +func TestHandshakeAndCall(t *testing.T) { + client := StartLoopback(func(env Envelope) []Envelope { + switch env.Method { + case MethodInitialize: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"codexHome":"/tmp","platformFamily":"unix","platformOs":"macos","userAgent":"codex"}`)}} + case MethodInitialized: + return nil + case MethodModelList: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"data":[{"id":"m","displayName":"M","defaultReasoningEffort":"low","supportedReasoningEfforts":["low",{"effort":"high"}],"isDefault":true}],"nextCursor":null}`)}} + case MethodAccountRead: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"requiresOpenaiAuth":true,"account":{"type":"apiKey"}}`)}} + default: + return []Envelope{{ID: env.ID, Error: &RPCError{Code: CodeMethodNotFound, Message: env.Method}}} + } + }) + defer client.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := client.Handshake(ctx, DefaultClientInfo()); err != nil { + t.Fatal(err) + } + models, err := client.ModelList(ctx, ModelListParams{}) + if err != nil { + t.Fatal(err) + } + info, err := ParseModel(models.Data[0]) + if err != nil || info.ID != "m" || len(info.Efforts) != 2 { + t.Fatalf("%+v %v", info, err) + } + account, err := client.AccountRead(ctx) + if err != nil || !Authorized(account) { + t.Fatal(account, err) + } +} + +func TestCallCancelDropsLateResponse(t *testing.T) { + started := make(chan struct{}) + client := StartLoopback(func(env Envelope) []Envelope { + if env.Method == MethodInitialize { + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"codexHome":"/tmp","platformFamily":"unix","platformOs":"macos","userAgent":"codex"}`)}} + } + if env.Method == "slow" { + close(started) + time.Sleep(200 * time.Millisecond) + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"ok":true}`)}} + } + return nil + }) + defer client.Close() + ctx := context.Background() + if _, err := client.Handshake(ctx, DefaultClientInfo()); err != nil { + t.Fatal(err) + } + slow, cancel := context.WithCancel(ctx) + go func() { + <-started + cancel() + }() + _, err := client.CallRaw(slow, "slow", map[string]any{}) + if err == nil { + t.Fatal("expected cancel") + } +} + +func TestOutOfOrderResponses(t *testing.T) { + var first *Envelope + client := StartLoopback(func(env Envelope) []Envelope { + switch env.Method { + case MethodInitialize: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"codexHome":"/tmp","platformFamily":"unix","platformOs":"macos","userAgent":"codex"}`)}} + case "hold": + cp := env + first = &cp + return nil + case "fast": + out := []Envelope{{ID: env.ID, Result: json.RawMessage(`{"n":2}`)}} + if first != nil { + out = append(out, Envelope{ID: first.ID, Result: json.RawMessage(`{"n":1}`)}) + } + return out + default: + return nil + } + }) + defer client.Close() + ctx := context.Background() + if _, err := client.Handshake(ctx, DefaultClientInfo()); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + var out map[string]int + done <- client.Call(ctx, "hold", map[string]any{}, &out) + }() + time.Sleep(20 * time.Millisecond) + var fast map[string]int + if err := client.Call(ctx, "fast", map[string]any{}, &fast); err != nil || fast["n"] != 2 { + t.Fatal(fast, err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("timeout") + } +} + +func TestServerRequestAndNotify(t *testing.T) { + client := StartLoopback(func(env Envelope) []Envelope { + if env.Method == MethodInitialize { + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"codexHome":"/tmp","platformFamily":"unix","platformOs":"macos","userAgent":"codex"}`)}} + } + if env.Method == MethodTurnStart { + ask := IntID(42) + return []Envelope{ + {ID: env.ID, Result: json.RawMessage(`{"turn":{"id":"t1","status":"inProgress","items":[]}}`)}, + {ID: &ask, Method: MethodItemCommandApproval, Params: json.RawMessage(`{"threadId":"th","turnId":"t1","command":"ls","itemId":"i","startedAtMs":1}`)}, + {Method: MethodAgentMessageDelta, Params: json.RawMessage(`{"threadId":"th","turnId":"t1","itemId":"m","delta":"hi"}`)}, + } + } + return nil + }) + defer client.Close() + ctx := context.Background() + if _, err := client.Handshake(ctx, DefaultClientInfo()); err != nil { + t.Fatal(err) + } + if _, err := client.TurnStart(ctx, TurnStartParams{ThreadID: "th", Input: UserInputs(Input{Text: "hi"})}); err != nil { + t.Fatal(err) + } + gotAsk, gotDelta := false, false + deadline := time.After(time.Second) + for !gotAsk || !gotDelta { + select { + case msg := <-client.Incoming(): + if msg.Method == MethodItemCommandApproval { + ask, ok := ParseAsk(msg) + if !ok || ask.Command != "ls" { + t.Fatalf("%+v", ask) + } + if err := client.Reply(ctx, msg.ID, ReplyBody(ask, AskAnswer{Approved: true})); err != nil { + t.Fatal(err) + } + gotAsk = true + } + if msg.Method == MethodAgentMessageDelta { + ev, ok := MapNotification(msg) + if !ok || ev.Progress.Text != "hi" { + t.Fatal(ev) + } + gotDelta = true + } + case <-deadline: + t.Fatal("timeout") + } + } +} + +func TestUnknownRequestReplyError(t *testing.T) { + client := StartLoopback(func(env Envelope) []Envelope { + if env.Method == MethodInitialize { + id := StringID("srv") + return []Envelope{ + {ID: env.ID, Result: json.RawMessage(`{"codexHome":"/tmp","platformFamily":"unix","platformOs":"macos","userAgent":"codex"}`)}, + {ID: &id, Method: "future/unknown", Params: json.RawMessage(`{}`)}, + } + } + return nil + }) + defer client.Close() + ctx := context.Background() + if _, err := client.Handshake(ctx, DefaultClientInfo()); err != nil { + t.Fatal(err) + } + select { + case msg := <-client.Incoming(): + if KnownAskMethod(msg.Method) { + t.Fatal(msg.Method) + } + if err := client.ReplyError(ctx, msg.ID, CodeMethodNotFound, "unsupported"); err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("timeout") + } +} + +func TestRPCErrorAndSettings(t *testing.T) { + err := RPCError{Code: 1, Message: "nope"} + if !strings.Contains(err.Error(), "nope") { + t.Fatal(err) + } + s := Settings{Model: "m"}.MergeOverride(Settings{Effort: "high", ApprovalPolicy: "never", Sandbox: "read-only", CollaborationMode: "plan", Cwd: "/tmp"}) + over := s.Override() + if over.Model != "" || over.Effort != "high" || over.Cwd != "/tmp" { + t.Fatalf("%+v", over) + } + params := ApplyTurnOverrides(TurnStartParams{ThreadID: "t", Input: UserInputs(Input{Text: "a"})}, s) + if params.Effort != "high" || params.SandboxPolicy == nil || params.CollaborationMode == nil { + t.Fatalf("%+v", params) + } + tp := ApplyThreadOverrides(ThreadStartParams{}, s) + if tp.Sandbox != "read-only" { + t.Fatal(tp) + } +} + +func TestCommandsAndProgress(t *testing.T) { + spec, ok := LookupCommand("mcp") + if !ok || spec.Action != ActionHint { + t.Fatal(spec) + } + if _, ok := LookupCommand("nope"); ok { + t.Fatal("unknown") + } + item, ok := MapItem(json.RawMessage(`{"id":"1","type":"fileChange","status":"completed","changes":[{"path":"a.go","diff":"+x"}]}`)) + if !ok || item.Kind != ProgressFileChange || item.Paths[0] != "a.go" { + t.Fatal(item) + } + turns := []TurnObject{{Items: []json.RawMessage{json.RawMessage(`{"id":"u","type":"userMessage","content":[{"type":"text","text":"hi"}]}`)}}} + if HydrateProgress(turns)[0].Text != "hi" { + t.Fatal(HydrateProgress(turns)) + } + if MapTurnStatus("interrupted", false) != TurnCancelled { + t.Fatal("cancel") + } + if MapTurnStatus("inProgress", true) != TurnWaitingApproval { + t.Fatal("ask") + } + if !Authorized(AccountResult{RequiresOpenaiAuth: false}) { + t.Fatal("no auth required") + } + if Authorized(AccountResult{RequiresOpenaiAuth: true}) { + t.Fatal("missing account") + } +} + +func TestParseAskKinds(t *testing.T) { + id := IntID(1) + cases := []struct { + method string + params string + kind AskKind + }{ + {MethodItemFileApproval, `{"threadId":"t","turnId":"u","itemId":"i","startedAtMs":1,"changes":[{"path":"a","diff":"+"}]}`, AskFileChange}, + {MethodItemPermissionsApproval, `{"threadId":"t","turnId":"u","itemId":"i","startedAtMs":1,"cwd":"/","permissions":{}}`, AskPermissions}, + {MethodItemToolUserInput, `{"threadId":"t","turnId":"u","itemId":"i","isBlocking":true,"questions":[{"id":"q1","header":"pick","options":["a",{"label":"b"}]}]}`, AskQuestion}, + {MethodMCPElicitation, `{"serverName":"s","threadId":"t","requestedSchema":{"properties":{"name":{}}}}`, AskForm}, + {MethodExecCommandApproval, `{"command":"pwd"}`, AskCommand}, + {MethodApplyPatchApproval, `{"fileChanges":[{"path":"b","diff":"-"}]}`, AskFileChange}, + } + for _, tc := range cases { + ask, ok := ParseAsk(Message{Kind: KindRequest, ID: id, Method: tc.method, Params: json.RawMessage(tc.params)}) + if !ok || ask.Kind != tc.kind { + t.Fatalf("%s: %+v", tc.method, ask) + } + _ = ReplyBody(ask, AskAnswer{Approved: true, Scope: ScopeSession, Choice: "a", Values: []string{"v"}}) + _ = ReplyBody(ask, AskAnswer{Approved: false}) + } +} + +func TestCollaborationAndSandboxHelpers(t *testing.T) { + if SandboxPolicy("read-only") == nil || SandboxPolicy("nope") != nil { + t.Fatal("sandbox") + } + if CollaborationModeParams("", "m") != nil || CollaborationModeParams("plan", "") == nil { + t.Fatal("collab") + } + if len(CollaborationModes()) != 2 { + t.Fatal("modes") + } + raw, _ := json.Marshal(map[string]any{"id": "p", "description": "d", "allowed": true}) + info, err := ParsePermissionProfile(raw) + if err != nil || info.Kind != "permission" { + t.Fatal(info, err) + } + cfg := ConfigReadResult{Config: map[string]json.RawMessage{"model": json.RawMessage(`"x"`)}} + if ConfigString(cfg, "model") != "x" || ConfigString(cfg, "missing") != "" { + t.Fatal(cfg) + } +} + +func TestUserInputsEmpty(t *testing.T) { + items := UserInputs(Input{}) + if len(items) != 1 || items[0].Type != "text" { + t.Fatal(items) + } + items = UserInputs(Input{Text: "a", Mentions: []string{"f.go"}, Images: []string{"p.png"}}) + if len(items) != 3 { + t.Fatal(items) + } +} + +func TestJSONLSkipEmptyLine(t *testing.T) { + left, right := PipePair() + defer left.Close() + defer right.Close() + go func() { + _, _ = right.(*JSONL).writer.Write([]byte("\n{\"ok\":true}\n")) + }() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + frame, err := left.Read(ctx) + if err != nil || string(frame) != `{"ok":true}` { + t.Fatalf("%q %v", frame, err) + } +} + +func TestJSONLCRLFAndClose(t *testing.T) { + left, right := PipePair() + defer left.Close() + defer right.Close() + go func() { + _, _ = right.(*JSONL).writer.Write([]byte("{\"ok\":1}\r\n")) + }() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + frame, err := left.Read(ctx) + if err != nil || string(frame) != `{"ok":1}` { + t.Fatalf("%q %v", frame, err) + } + tr := NewJSONL(strings.NewReader(""), nil, nil, 0) + if err := tr.Close(); err != nil { + t.Fatal(err) + } +} + +func TestClientRPCWrappers(t *testing.T) { + client := StartLoopback(func(env Envelope) []Envelope { + if env.Method == MethodInitialize { + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"codexHome":"/tmp","platformFamily":"unix","platformOs":"macos","userAgent":"codex"}`)}} + } + if env.ID == nil { + return nil + } + switch env.Method { + case MethodThreadList: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"data":[{"id":"t1","name":"n","preview":"p","cwd":"/","createdAt":1,"updatedAt":1}]}`)}} + case MethodThreadRead: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"thread":{"id":"t1","name":"n","preview":"p","cwd":"/","ephemeral":true}}`)}} + case MethodThreadStart, MethodThreadResume, MethodThreadFork: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"thread":{"id":"t1","name":"n"},"model":"m","cwd":"/"}`)}} + case MethodTurnStart: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"turn":{"id":"u1","status":"inProgress"}}`)}} + case MethodModelList: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"data":[{"model":"only-model","displayName":"M","supportedReasoningEfforts":[{"id":"low"}]}]}`)}} + case MethodPermissionProfileList: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"data":[]}`)}} + case MethodAccountRead: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"requiresOpenaiAuth":false}`)}} + case MethodConfigRead: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"config":{"model":"x","n":1}}`)}} + default: + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{}`)}} + } + }) + defer client.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := client.Handshake(ctx, ClientInfo{}); err != nil { + t.Fatal(err) + } + if _, err := client.Initialize(ctx, DefaultClientInfo()); err != nil { + t.Fatal(err) + } + if _, err := client.ThreadStart(ctx, ThreadStartParams{Cwd: "/tmp"}); err != nil { + t.Fatal(err) + } + if _, err := client.ThreadResume(ctx, ThreadResumeParams{ThreadID: "t1"}); err != nil { + t.Fatal(err) + } + if _, err := client.ThreadFork(ctx, ThreadForkParams{ThreadID: "t1"}); err != nil { + t.Fatal(err) + } + if err := client.ThreadArchive(ctx, "t1"); err != nil { + t.Fatal(err) + } + if err := client.ThreadUnarchive(ctx, "t1"); err != nil { + t.Fatal(err) + } + if err := client.ThreadSetName(ctx, "t1", "n"); err != nil { + t.Fatal(err) + } + page, err := client.ThreadList(ctx, ThreadListParams{}) + if err != nil || len(page.Data) != 1 { + t.Fatal(page, err) + } + read, err := client.ThreadRead(ctx, ThreadReadParams{ThreadID: "t1", IncludeTurns: true}) + if err != nil || read.Thread.ID != "t1" { + t.Fatal(read, err) + } + if err := client.ThreadCompact(ctx, "t1"); err != nil { + t.Fatal(err) + } + if _, err := client.TurnStart(ctx, TurnStartParams{ThreadID: "t1"}); err != nil { + t.Fatal(err) + } + if err := client.TurnInterrupt(ctx, "t1", "u1"); err != nil { + t.Fatal(err) + } + if err := client.ReviewStart(ctx, "t1"); err != nil { + t.Fatal(err) + } + models, err := client.ModelList(ctx, ModelListParams{}) + if err != nil || len(models.Data) != 1 { + t.Fatal(models, err) + } + info, err := ParseModel(models.Data[0]) + if err != nil || info.ID != "only-model" || len(info.Efforts) != 1 { + t.Fatal(info, err) + } + if _, err := client.PermissionProfileList(ctx, CursorListParams{}); err != nil { + t.Fatal(err) + } + if _, err := client.AccountRead(ctx); err != nil { + t.Fatal(err) + } + cfg, err := client.ConfigRead(ctx, "/tmp") + if err != nil || ConfigString(cfg, "n") != "" { + t.Fatal(cfg, err) + } + sess := MapThread(read.Thread, true, "u1") + if sess.ID != "t1" || !sess.Archived || sess.ActiveTurnID != "u1" { + t.Fatal(sess) + } +} + +func TestParseModelAndAskEdges(t *testing.T) { + if _, err := ParseModel(json.RawMessage(`{`)); err == nil { + t.Fatal("bad model") + } + if _, err := ParsePermissionProfile(json.RawMessage(`{`)); err == nil { + t.Fatal("bad profile") + } + if SandboxPolicy("workspace-write") == nil || SandboxPolicy("danger-full-access") == nil { + t.Fatal("sandbox variants") + } + blank := Input{} + filled := Input{Text: "a"} + if !blank.Empty() || filled.Empty() { + t.Fatal("empty") + } + _ = UserInputs(Input{Mentions: []string{""}, Images: []string{""}}) + if KnownAskMethod("nope") { + t.Fatal("unknown ask") + } + if _, ok := ParseAsk(Message{Kind: KindNotification, Method: MethodItemCommandApproval}); ok { + t.Fatal("not a request") + } + id := IntID(3) + ask, ok := ParseAsk(Message{Kind: KindRequest, ID: id, Method: MethodItemCommandApproval, Params: json.RawMessage(`{"command":{"command":"pwd"}}`)}) + if !ok || ask.Command != "pwd" { + t.Fatal(ask) + } + ask, ok = ParseAsk(Message{Kind: KindRequest, ID: id, Method: MethodItemFileApproval, Params: json.RawMessage(`{"grantRoot":"/tmp"}`)}) + if !ok || len(ask.Paths) != 1 { + t.Fatal(ask) + } + ask, ok = ParseAsk(Message{Kind: KindRequest, ID: id, Method: MethodItemToolUserInput, Params: json.RawMessage(`{"prompt":"q","questions":[{"id":"q1","question":"pick","options":[{"id":"o1"}]}]}`)}) + if !ok || len(ask.Options) != 1 { + t.Fatal(ask) + } + _ = ReplyBody(ask, AskAnswer{Approved: true, Values: []string{"v"}}) + ask, ok = ParseAsk(Message{Kind: KindRequest, ID: id, Method: MethodMCPElicitation, Params: json.RawMessage(`{"message":{"requestedSchema":{"properties":{"n":{}}}}}`)}) + if !ok || len(ask.Fields) != 1 { + t.Fatal(ask) + } + _ = ReplyBody(ask, AskAnswer{Approved: true, Choice: "x"}) + _ = ReplyBody(ApprovalAsk{Method: "future"}, AskAnswer{}) + if MapTurnStatus("failed", false) != TurnFailed || MapTurnStatus("other", false) != TurnRunning { + t.Fatal("status") + } +} + +func TestMapNotificationKinds(t *testing.T) { + note := func(method, params string) Message { + return Message{Kind: KindNotification, Method: method, Params: json.RawMessage(params)} + } + if _, ok := MapNotification(Message{Kind: KindRequest, Method: MethodError}); ok { + t.Fatal("request") + } + cases := []string{ + MethodReasoningSummaryDelta, + MethodItemStarted, + MethodTurnStarted, + MethodTurnCompleted, + "item/unknown", + "other", + } + for _, method := range cases { + _, _ = MapNotification(note(method, `{"threadId":"t","turnId":"u","itemId":"i","delta":"x","text":"y","item":{"id":"i","type":"agentMessage","text":"z"}}`)) + } + if _, ok := MapItem(nil); ok { + t.Fatal("empty item") + } + if _, ok := MapItem(json.RawMessage(`{`)); ok { + t.Fatal("bad item") + } + if p, ok := MapItem(json.RawMessage(`{"id":"1"}`)); ok { + t.Fatal(p) + } +} + +func TestCallResultDecodeError(t *testing.T) { + client := StartLoopback(func(env Envelope) []Envelope { + if env.Method == MethodInitialize { + return []Envelope{{ID: env.ID, Result: json.RawMessage(`{"codexHome":"/tmp","platformFamily":"unix","platformOs":"macos","userAgent":"codex"}`)}} + } + return []Envelope{{ID: env.ID, Result: json.RawMessage(`"nope"`)}} + }) + defer client.Close() + ctx := context.Background() + if _, err := client.Handshake(ctx, DefaultClientInfo()); err != nil { + t.Fatal(err) + } + var out ThreadStartResult + if err := client.Call(ctx, "x", map[string]any{"ch": make(chan int)}, &out); err == nil { + t.Fatal("expected decode error") + } +} diff --git a/server/pkg/codex/command.go b/server/pkg/codex/command.go index 014fcad..d1078e0 100644 --- a/server/pkg/codex/command.go +++ b/server/pkg/codex/command.go @@ -13,28 +13,50 @@ const ( // CommandSpec 是一条与 Codex 斜杠、官方扩展按钮共用的命令。 type CommandSpec struct { - Name string // 与 Codex 斜杠同名,如 model、plan、fork、mcp。 - Action CommandAction - Hint string // hint 时给人看的话。 + Name string `json:"name"` // 与 Codex 斜杠同名,如 model、plan、fork、mcp。 + Action CommandAction `json:"action"` + Hint string `json:"hint,omitempty"` // hint 时给人看的话。 + Field string `json:"field,omitempty"` } // CommandResult 是这条命令没法在本模块落地时,给人看的说明。 type CommandResult struct { - Hint string + Hint string `json:"hint,omitempty"` + Action CommandAction `json:"action,omitempty"` + Handled bool `json:"handled"` } -// Command 管把 Codex 斜杠名和官方扩展按钮收成同一套动作:能做的往下交给会话、配置、 -// 回合或附件,不能做的只给一句提示。不管怎么对 Codex 说话。 -type Command struct{} +const hintUseTerminal = "这条配置请在终端里改 Codex(~/.codex/config.toml),本模块不改官方配置文件。" -// List 列出对话框 `/` 里能用的命令。这些和官方扩展按钮共用同一套动作,不搞两套语义。 -// `/mcp`、`/skills` 这类也在列表里,但点了只会给提示。 -func (c *Command) List() []CommandSpec { - return nil +// Commands 列出对话框 `/` 里能用的命令。这些和官方扩展按钮共用同一套动作。 +func Commands() []CommandSpec { + return []CommandSpec{ + {Name: "model", Action: ActionApplySettings, Field: "model"}, + {Name: "effort", Action: ActionApplySettings, Field: "effort"}, + {Name: "plan", Action: ActionApplySettings, Field: "collaboration_mode"}, + {Name: "permissions", Action: ActionApplySettings, Field: "sandbox"}, + {Name: "approval", Action: ActionApplySettings, Field: "approval_policy"}, + {Name: "stop", Action: ActionTurn}, + {Name: "compact", Action: ActionTurn}, + {Name: "review", Action: ActionTurn}, + {Name: "fork", Action: ActionSession}, + {Name: "archive", Action: ActionSession}, + {Name: "rename", Action: ActionSession}, + {Name: "mention", Action: ActionAttach}, + {Name: "image", Action: ActionAttach}, + {Name: "mcp", Action: ActionHint, Hint: hintUseTerminal}, + {Name: "skills", Action: ActionHint, Hint: hintUseTerminal}, + {Name: "plugins", Action: ActionHint, Hint: hintUseTerminal}, + {Name: "hooks", Action: ActionHint, Hint: hintUseTerminal}, + } } -// Invoke 执行与 Codex 同名的那个动作。本模块不做的配置类命令不改 Codex 配置文件, -// 只回一句「去终端改」的提示。 -func (c *Command) Invoke(sessionID, name, args string) (CommandResult, error) { - return CommandResult{}, nil +// LookupCommand 按与 Codex 同名的斜杠名取命令。 +func LookupCommand(name string) (CommandSpec, bool) { + for _, spec := range Commands() { + if spec.Name == name { + return spec, true + } + } + return CommandSpec{}, false } diff --git a/server/pkg/codex/event.go b/server/pkg/codex/event.go new file mode 100644 index 0000000..faca6aa --- /dev/null +++ b/server/pkg/codex/event.go @@ -0,0 +1,29 @@ +package codex + +// EventType 是本模块对外的短事件名。 +type EventType string + +const ( + EventTurnQueued EventType = "turn.queued" + EventTurnStarted EventType = "turn.started" + EventTurnCompleted EventType = "turn.completed" + EventTurnFailed EventType = "turn.failed" + EventTurnCancelled EventType = "turn.cancelled" + EventProgress EventType = "progress" + EventAskRequired EventType = "ask.required" + EventAskResolved EventType = "ask.resolved" + EventNotice EventType = "notice" + EventReset EventType = "reset" +) + +// Event 是给 HTTP/SSE 的一条 Codex 领域事件。只存在当前进程里。 +type Event struct { + Seq int64 `json:"seq"` + Type EventType `json:"type"` + SessionID string `json:"session_id"` + TurnID string `json:"turn_id,omitempty"` + Progress *Progress `json:"progress,omitempty"` + Turn *Turn `json:"turn,omitempty"` + Ask *ApprovalAsk `json:"ask,omitempty"` + Notice string `json:"notice,omitempty"` +} diff --git a/server/pkg/codex/harness.go b/server/pkg/codex/harness.go index e1f6105..4fd2662 100644 --- a/server/pkg/codex/harness.go +++ b/server/pkg/codex/harness.go @@ -1,99 +1,56 @@ package codex -// Codex 管对本机 Codex 说话:开 thread、开一轮、打断、分叉、压缩、评审, -// 以及把反问递进递出。不管本模块有多少对话,不管本地模型。 -type Codex struct { - Transcript *Transcript - Turn *Turns +// DefaultClientInfo 返回 CodeDock 握手用的客户端信息。 +func DefaultClientInfo() ClientInfo { + return ClientInfo{Name: ClientName, Title: "CodeDock", Version: ClientVersion} } -// StartThread 让 Codex 新建一条 thread。工作目录和这条对话的生效配置由调用方给。 -func (c *Codex) StartThread(cwd string, settings Settings) (threadID string, err error) { - return "", nil -} - -// ResumeThread 接上一条已有的 Codex thread,继续原来那段上下文。 -// thread 已经没了就让这一轮失败,不静默新开一条。 -func (c *Codex) ResumeThread(threadID string) error { - return nil -} - -// ForkThread 按 Codex 自己落盘的历史分出一条新 thread,原 thread 不动。 -func (c *Codex) ForkThread(threadID string) (newThreadID string, err error) { - return "", nil -} - -// StartTurn 把这条输入交给 Codex,让它开始干这一轮。 -func (c *Codex) StartTurn(threadID string, input Input, settings Settings) (turnID string, err error) { - return "", nil -} - -// Interrupt 按用户请求打断 Codex 当前这一轮,不关掉 Codex 本身。 -func (c *Codex) Interrupt(threadID, turnID string) error { - return nil -} - -// Compact 让 Codex 用它自己的办法压缩这条 thread 的上下文。 -func (c *Codex) Compact(threadID string) error { - return nil -} - -// Review 让 Codex 评审当前工作目录里的改动。 -func (c *Codex) Review(threadID string) error { - return nil -} - -// ReplyAsk 把人对已知反问的回答回给 Codex,它拿到回话才会接着往下走。 -func (c *Codex) ReplyAsk(requestID string, answer AskAnswer) error { - return nil -} - -// RejectUnknown 处理官方新加、本模块认不出的提问:在实录里写明这里接不住, -// 再按拒绝回包,让 Codex 自己换个办法或收尾,别把这一轮卡死。 -// 这不等于用户拒绝了某条命令或某批改文件。 -func (c *Codex) RejectUnknown(requestID string) error { - c.Transcript.AppendProgress("", "", Progress{Kind: ProgressNotice}) - c.Turn.Continue("") - return nil -} - -// Module 持有 Codex 对接的九块,自己没有业务方法,方便调用方拿一份就能调各块。 -type Module struct { - Catalog *Catalog - Session *Sessions - Settings *Configs - Command *Command - Attachment *Attachment - Turn *Turns - Transcript *Transcript - Approval *Approval - Codex *Codex +// ApplyTurnOverrides 把用户改过的配置编进 turn/start 参数。 +func ApplyTurnOverrides(params TurnStartParams, settings Settings) TurnStartParams { + over := settings.Override() + if over.Model != "" { + params.Model = over.Model + } + if over.Effort != "" { + params.Effort = over.Effort + } + if over.ApprovalPolicy != "" { + params.ApprovalPolicy = over.ApprovalPolicy + } + if over.Cwd != "" { + params.Cwd = over.Cwd + } else if settings.Cwd != "" && params.Cwd == "" { + params.Cwd = settings.Cwd + } + if over.Sandbox != "" { + params.SandboxPolicy = SandboxPolicy(over.Sandbox) + } + if over.CollaborationMode != "" { + model := over.Model + if model == "" { + model = settings.Model + } + params.CollaborationMode = CollaborationModeParams(over.CollaborationMode, model) + } + return params } -// New 构造九块,并把互相要调的那几根指针接上。 -func New() *Module { - m := &Module{ - Catalog: &Catalog{}, - Session: &Sessions{}, - Settings: &Configs{}, - Command: &Command{}, - Attachment: &Attachment{}, - Turn: &Turns{}, - Transcript: &Transcript{}, - Approval: &Approval{}, - Codex: &Codex{}, +// ApplyThreadOverrides 把用户改过的配置编进 thread/start 参数。 +func ApplyThreadOverrides(params ThreadStartParams, settings Settings) ThreadStartParams { + over := settings.Override() + if over.Model != "" { + params.Model = over.Model + } + if over.ApprovalPolicy != "" { + params.ApprovalPolicy = over.ApprovalPolicy + } + if over.Sandbox != "" { + params.Sandbox = over.Sandbox + } + if over.Cwd != "" { + params.Cwd = over.Cwd + } else if settings.Cwd != "" && params.Cwd == "" { + params.Cwd = settings.Cwd } - m.Catalog.Session = m.Session - m.Catalog.Settings = m.Settings - m.Session.Codex = m.Codex - m.Settings.Command = m.Command - m.Turn.Session = m.Session - m.Turn.Codex = m.Codex - m.Turn.Transcript = m.Transcript - m.Turn.Attachment = m.Attachment - m.Approval.Codex = m.Codex - m.Approval.Turn = m.Turn - m.Codex.Transcript = m.Transcript - m.Codex.Turn = m.Turn - return m + return params } diff --git a/server/pkg/codex/loopback.go b/server/pkg/codex/loopback.go new file mode 100644 index 0000000..4d51391 --- /dev/null +++ b/server/pkg/codex/loopback.go @@ -0,0 +1,41 @@ +package codex + +import ( + "context" + "encoding/json" +) + +// StartLoopback 在管道对面跑假 app-server,返回已连接的 Client(尚未 Handshake)。 +func StartLoopback(handler func(Envelope) []Envelope) *Client { + left, right := PipePair() + go Serve(right, handler) + return NewClient(left) +} + +// Serve 在 Transport 上按 handler 回答请求,直到读失败。 +func Serve(t Transport, handler func(Envelope) []Envelope) { + ctx := context.Background() + for { + frame, err := t.Read(ctx) + if err != nil { + _ = t.Close() + return + } + var env Envelope + if err := json.Unmarshal(frame, &env); err != nil { + _ = t.Close() + return + } + replies := handler(env) + for _, reply := range replies { + body, err := json.Marshal(reply) + if err != nil { + continue + } + if err := t.Write(ctx, body); err != nil { + _ = t.Close() + return + } + } + } +} diff --git a/server/pkg/codex/progress.go b/server/pkg/codex/progress.go new file mode 100644 index 0000000..fad0ef0 --- /dev/null +++ b/server/pkg/codex/progress.go @@ -0,0 +1,152 @@ +package codex + +import ( + "encoding/json" + "strings" +) + +// ProgressEvent 是一条通知映出来的进展,带所属 thread/turn。 +type ProgressEvent struct { + ThreadID string + TurnID string + Progress Progress +} + +// MapNotification 把官方通知映成给人看的进展。对不上的返回 false。 +func MapNotification(msg Message) (ProgressEvent, bool) { + if msg.Kind != KindNotification { + return ProgressEvent{}, false + } + var raw map[string]json.RawMessage + _ = json.Unmarshal(msg.Params, &raw) + ev := ProgressEvent{ + ThreadID: rawString(raw, "threadId"), + TurnID: rawString(raw, "turnId"), + } + switch msg.Method { + case MethodAgentMessageDelta: + ev.Progress = Progress{Kind: ProgressText, ItemID: rawString(raw, "itemId"), Text: rawString(raw, "delta")} + return ev, ev.Progress.Text != "" + case MethodReasoningTextDelta, MethodReasoningSummaryDelta: + text := firstNonEmpty(rawString(raw, "delta"), rawString(raw, "text")) + ev.Progress = Progress{Kind: ProgressReasoning, ItemID: rawString(raw, "itemId"), Text: text} + return ev, text != "" + case MethodCommandOutputDelta: + ev.Progress = Progress{Kind: ProgressCommand, ItemID: rawString(raw, "itemId"), Text: rawString(raw, "delta")} + return ev, ev.Progress.Text != "" + case MethodFileChangeDelta: + ev.Progress = Progress{Kind: ProgressFileChange, ItemID: rawString(raw, "itemId"), Diff: rawString(raw, "delta")} + return ev, ev.Progress.Diff != "" + case MethodPlanDelta: + ev.Progress = Progress{Kind: ProgressPlan, ItemID: rawString(raw, "itemId"), Text: rawString(raw, "delta")} + return ev, ev.Progress.Text != "" + case MethodItemStarted, MethodItemCompleted: + item, ok := MapItem(raw["item"]) + if !ok { + return ProgressEvent{}, false + } + ev.Progress = item + return ev, true + case MethodError: + ev.Progress = Progress{Kind: ProgressNotice, Text: firstNonEmpty(rawString(raw, "message"), string(msg.Params))} + return ev, true + default: + if strings.HasPrefix(msg.Method, "item/") || msg.Method == MethodTurnCompleted || msg.Method == MethodTurnStarted { + return ProgressEvent{}, false + } + return ProgressEvent{}, false + } +} + +// MapItem 把官方 ThreadItem 映成进展。 +func MapItem(raw json.RawMessage) (Progress, bool) { + if len(raw) == 0 { + return Progress{}, false + } + var item map[string]json.RawMessage + if err := json.Unmarshal(raw, &item); err != nil { + return Progress{}, false + } + typ := rawString(item, "type") + id := rawString(item, "id") + switch typ { + case "userMessage": + return Progress{Kind: ProgressUser, ItemID: id, Text: userMessageText(item["content"])}, true + case "agentMessage": + return Progress{Kind: ProgressText, ItemID: id, Text: rawString(item, "text")}, true + case "reasoning": + return Progress{Kind: ProgressReasoning, ItemID: id, Text: strings.Join(rawStringSlice(item["summary"]), "\n")}, true + case "commandExecution": + return Progress{ + Kind: ProgressCommand, + ItemID: id, + Command: rawString(item, "command"), + Text: rawString(item, "aggregatedOutput"), + Status: rawString(item, "status"), + }, true + case "fileChange": + paths, diff := fileChangeFromItem(item["changes"]) + return Progress{Kind: ProgressFileChange, ItemID: id, Paths: paths, Diff: diff, Status: rawString(item, "status")}, true + case "plan": + return Progress{Kind: ProgressPlan, ItemID: id, Text: rawString(item, "text")}, true + default: + if typ == "" { + return Progress{}, false + } + return Progress{Kind: ProgressNotice, ItemID: id, Text: typ}, true + } +} + +// HydrateProgress 把 thread/read 里的 turns/items 编成可回放实录。 +func HydrateProgress(turns []TurnObject) []Progress { + out := make([]Progress, 0) + for _, turn := range turns { + for _, item := range turn.Items { + if p, ok := MapItem(item); ok { + out = append(out, p) + } + } + } + return out +} + +func userMessageText(content json.RawMessage) string { + var items []UserInput + if json.Unmarshal(content, &items) == nil { + parts := make([]string, 0, len(items)) + for _, item := range items { + if item.Text != "" { + parts = append(parts, item.Text) + } + } + return strings.Join(parts, "\n") + } + return "" +} + +func rawStringSlice(raw json.RawMessage) []string { + var items []string + if json.Unmarshal(raw, &items) == nil { + return items + } + return nil +} + +func fileChangeFromItem(raw json.RawMessage) ([]string, string) { + var changes []struct { + Path string `json:"path"` + Diff string `json:"diff"` + } + if json.Unmarshal(raw, &changes) != nil { + return nil, "" + } + paths := make([]string, 0, len(changes)) + var diff strings.Builder + for _, ch := range changes { + if ch.Path != "" { + paths = append(paths, ch.Path) + } + diff.WriteString(ch.Diff) + } + return paths, diff.String() +} diff --git a/server/pkg/codex/protocol.go b/server/pkg/codex/protocol.go new file mode 100644 index 0000000..1249dfc --- /dev/null +++ b/server/pkg/codex/protocol.go @@ -0,0 +1,228 @@ +package codex + +import ( + "encoding/json" + "fmt" + "strconv" +) + +const ( + // MaxFrameBytes 是一条 JSONL 帧的上限。官方 diff 可能超过 64KiB。 + MaxFrameBytes = 16 << 20 + + // ClientName 是握手时告诉 Codex 的客户端名。 + ClientName = "codedock" + // ClientVersion 是握手时告诉 Codex 的客户端版本。 + ClientVersion = "0.1.0" + + MethodInitialize = "initialize" + MethodInitialized = "initialized" + MethodThreadStart = "thread/start" + MethodThreadResume = "thread/resume" + MethodThreadFork = "thread/fork" + MethodThreadArchive = "thread/archive" + MethodThreadUnarchive = "thread/unarchive" + MethodThreadNameSet = "thread/name/set" + MethodThreadList = "thread/list" + MethodThreadRead = "thread/read" + MethodThreadCompact = "thread/compact/start" + MethodTurnStart = "turn/start" + MethodTurnInterrupt = "turn/interrupt" + MethodReviewStart = "review/start" + MethodModelList = "model/list" + MethodPermissionProfileList = "permissionProfile/list" + MethodAccountRead = "account/read" + MethodConfigRead = "config/read" + MethodItemCommandApproval = "item/commandExecution/requestApproval" + MethodItemFileApproval = "item/fileChange/requestApproval" + MethodItemPermissionsApproval = "item/permissions/requestApproval" + MethodItemToolUserInput = "item/tool/requestUserInput" + MethodMCPElicitation = "mcpServer/elicitation/request" + MethodExecCommandApproval = "execCommandApproval" + MethodApplyPatchApproval = "applyPatchApproval" + MethodServerRequestResolved = "serverRequest/resolved" + MethodTurnStarted = "turn/started" + MethodTurnCompleted = "turn/completed" + MethodItemStarted = "item/started" + MethodItemCompleted = "item/completed" + MethodAgentMessageDelta = "item/agentMessage/delta" + MethodReasoningTextDelta = "item/reasoning/textDelta" + MethodReasoningSummaryDelta = "item/reasoning/summaryTextDelta" + MethodCommandOutputDelta = "item/commandExecution/outputDelta" + MethodFileChangeDelta = "item/fileChange/outputDelta" + MethodPlanDelta = "item/plan/delta" + MethodError = "error" + MethodThreadStarted = "thread/started" + + CodeParseError = -32700 + CodeInvalidRequest = -32600 + CodeMethodNotFound = -32601 + CodeInvalidParams = -32602 + CodeInternalError = -32603 +) + +// RequestID 是 JSON-RPC 的请求编号,可能是字符串或整数。 +type RequestID struct { + str string + num int64 + isNum bool + set bool +} + +// IntID 构造整数请求编号。 +func IntID(n int64) RequestID { + return RequestID{num: n, isNum: true, set: true} +} + +// StringID 构造字符串请求编号。 +func StringID(s string) RequestID { + return RequestID{str: s, set: true} +} + +// ParseRequestID 把 URL 或文本里的编号还原;纯数字优先当整数。 +func ParseRequestID(raw string) RequestID { + if raw == "" { + return RequestID{} + } + if n, err := strconv.ParseInt(raw, 10, 64); err == nil { + return IntID(n) + } + return StringID(raw) +} + +// IsZero 表示还没有编号。 +func (id RequestID) IsZero() bool { return !id.set } + +// String 返回给人看、给 URL 用的编号。 +func (id RequestID) String() string { + if !id.set { + return "" + } + if id.isNum { + return strconv.FormatInt(id.num, 10) + } + return id.str +} + +// Key 区分「字符串 1」和整数 1,给 pending map 用。 +func (id RequestID) Key() string { + if !id.set { + return "" + } + if id.isNum { + return "n:" + strconv.FormatInt(id.num, 10) + } + return "s:" + id.str +} + +// MarshalJSON 按官方协议写出字符串或整数。 +func (id RequestID) MarshalJSON() ([]byte, error) { + if !id.set { + return []byte("null"), nil + } + if id.isNum { + return json.Marshal(id.num) + } + return json.Marshal(id.str) +} + +// UnmarshalJSON 读官方协议里的字符串或整数编号。 +func (id *RequestID) UnmarshalJSON(data []byte) error { + if string(data) == "null" || len(data) == 0 { + *id = RequestID{} + return nil + } + if data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *id = StringID(s) + return nil + } + var n int64 + if err := json.Unmarshal(data, &n); err != nil { + return fmt.Errorf("request id: %w", err) + } + *id = IntID(n) + return nil +} + +// RPCError 是 JSON-RPC 错误体。 +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +// Error 实现 error。 +func (e RPCError) Error() string { + if e.Message == "" { + return fmt.Sprintf("rpc error %d", e.Code) + } + return fmt.Sprintf("rpc error %d: %s", e.Code, e.Message) +} + +// Envelope 是一条 JSONL 上的 JSON-RPC 消息。官方省略 jsonrpc 字段。 +type Envelope struct { + ID *RequestID `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +// Kind 是这条消息在协议里的角色。 +type Kind int + +const ( + KindUnknown Kind = iota + KindRequest + KindNotification + KindResponse +) + +// Classify 判断信封是请求、通知还是响应。 +func Classify(env Envelope) Kind { + switch { + case env.Method != "" && env.ID != nil && !env.ID.IsZero(): + return KindRequest + case env.Method != "" && (env.ID == nil || env.ID.IsZero()): + return KindNotification + case env.ID != nil && !env.ID.IsZero(): + return KindResponse + default: + return KindUnknown + } +} + +// Message 是读循环交给上层的一条入站消息。 +type Message struct { + Kind Kind + ID RequestID + Method string + Params json.RawMessage + Result json.RawMessage + Error *RPCError +} + +// ClientInfo 是 initialize 时带的客户端信息。 +type ClientInfo struct { + Name string `json:"name"` + Title string `json:"title,omitempty"` + Version string `json:"version"` +} + +// InitializeParams 是握手请求。 +type InitializeParams struct { + ClientInfo ClientInfo `json:"clientInfo"` + Capabilities map[string]interface{} `json:"capabilities,omitempty"` +} + +// InitializeResult 是握手响应。 +type InitializeResult struct { + CodexHome string `json:"codexHome"` + PlatformFamily string `json:"platformFamily"` + PlatformOS string `json:"platformOs"` + UserAgent string `json:"userAgent"` +} diff --git a/server/pkg/codex/protocol_test.go b/server/pkg/codex/protocol_test.go new file mode 100644 index 0000000..9fcc2fb --- /dev/null +++ b/server/pkg/codex/protocol_test.go @@ -0,0 +1,97 @@ +package codex + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + "testing" +) + +func TestRequestIDRoundTrip(t *testing.T) { + cases := []RequestID{IntID(7), StringID("req-1"), ParseRequestID("42"), ParseRequestID("abc")} + for _, id := range cases { + raw, err := json.Marshal(id) + if err != nil { + t.Fatal(err) + } + var out RequestID + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if out.Key() != id.Key() || out.String() != id.String() { + t.Fatalf("%s -> %s key=%s want %s", raw, out.String(), out.Key(), id.Key()) + } + } + var unset RequestID + raw, _ := json.Marshal(unset) + if string(raw) != "null" { + t.Fatalf("unset = %s", raw) + } + if err := json.Unmarshal([]byte("true"), &unset); err == nil { + t.Fatal("expected error") + } +} + +func TestClassify(t *testing.T) { + id := IntID(1) + if Classify(Envelope{ID: &id, Method: "initialize"}) != KindRequest { + t.Fatal("request") + } + if Classify(Envelope{Method: "initialized"}) != KindNotification { + t.Fatal("notification") + } + if Classify(Envelope{ID: &id, Result: json.RawMessage(`{}`)}) != KindResponse { + t.Fatal("response") + } + if Classify(Envelope{}) != KindUnknown { + t.Fatal("unknown") + } +} + +func TestJSONLSkipEmptyAndOversize(t *testing.T) { + pr, pw := io.Pipe() + tr := NewJSONL(pr, io.Discard, pw, 16) + go func() { + _, _ = pw.Write([]byte("\n\n{\"ok\":true}\n" + strings.Repeat("x", 32) + "\n")) + _ = pw.Close() + }() + ctx := context.Background() + frame, err := tr.Read(ctx) + if err != nil || string(frame) != `{"ok":true}` { + t.Fatalf("frame=%q err=%v", frame, err) + } + if _, err := tr.Read(ctx); err == nil { + t.Fatal("expected oversize") + } +} + +func TestJSONLTruncated(t *testing.T) { + pr, pw := io.Pipe() + tr := NewJSONL(pr, io.Discard, pw, 0) + go func() { + _, _ = pw.Write([]byte(`{"partial":`)) + _ = pw.Close() + }() + if _, err := tr.Read(context.Background()); err == nil { + t.Fatal("expected truncated") + } +} + +func TestJSONLWriteCancelled(t *testing.T) { + tr := NewJSONL(bytes.NewReader(nil), io.Discard, nil, 0) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := tr.Write(ctx, []byte(`{}`)); err == nil { + t.Fatal("expected cancel") + } +} + +func TestRPCErrorError(t *testing.T) { + plain := RPCError{Code: 1} + withMsg := RPCError{Code: 1, Message: "nope"} + if plain.Error() == "" || withMsg.Error() == "" { + t.Fatal("error string") + } +} diff --git a/server/pkg/codex/rpc.go b/server/pkg/codex/rpc.go new file mode 100644 index 0000000..648f96d --- /dev/null +++ b/server/pkg/codex/rpc.go @@ -0,0 +1,420 @@ +package codex + +import ( + "context" + "encoding/json" +) + +// ThreadStartParams 是 thread/start 的入参。字段名跟官方 camelCase 对齐。 +type ThreadStartParams struct { + Cwd string `json:"cwd,omitempty"` + Model string `json:"model,omitempty"` + ApprovalPolicy string `json:"approvalPolicy,omitempty"` + Sandbox string `json:"sandbox,omitempty"` + Ephemeral bool `json:"ephemeral,omitempty"` +} + +// ThreadIDParams 只带 threadId。 +type ThreadIDParams struct { + ThreadID string `json:"threadId"` +} + +// ThreadResumeParams 是 thread/resume 的入参。 +type ThreadResumeParams struct { + ThreadID string `json:"threadId"` + Cwd string `json:"cwd,omitempty"` + Model string `json:"model,omitempty"` + ApprovalPolicy string `json:"approvalPolicy,omitempty"` + Sandbox string `json:"sandbox,omitempty"` +} + +// ThreadForkParams 是 thread/fork 的入参。 +type ThreadForkParams struct { + ThreadID string `json:"threadId"` + Cwd string `json:"cwd,omitempty"` + Ephemeral bool `json:"ephemeral,omitempty"` +} + +// ThreadNameParams 是 thread/name/set 的入参。 +type ThreadNameParams struct { + ThreadID string `json:"threadId"` + Name string `json:"name"` +} + +// ThreadListParams 是 thread/list 的入参。 +type ThreadListParams struct { + Archived *bool `json:"archived,omitempty"` + Cursor string `json:"cursor,omitempty"` + Cwd string `json:"cwd,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// ThreadReadParams 是 thread/read 的入参。 +type ThreadReadParams struct { + ThreadID string `json:"threadId"` + IncludeTurns bool `json:"includeTurns"` +} + +// TurnStartParams 是 turn/start 的入参。 +type TurnStartParams struct { + ThreadID string `json:"threadId"` + Input []UserInput `json:"input"` + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` + ApprovalPolicy string `json:"approvalPolicy,omitempty"` + Cwd string `json:"cwd,omitempty"` + SandboxPolicy json.RawMessage `json:"sandboxPolicy,omitempty"` + CollaborationMode json.RawMessage `json:"collaborationMode,omitempty"` +} + +// TurnInterruptParams 是 turn/interrupt 的入参。 +type TurnInterruptParams struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` +} + +// ReviewStartParams 是 review/start 的入参。默认评审工作区未提交改动。 +type ReviewStartParams struct { + ThreadID string `json:"threadId"` + Target map[string]any `json:"target"` +} + +// ModelListParams 是 model/list 的入参。 +type ModelListParams struct { + Cursor string `json:"cursor,omitempty"` + IncludeHidden *bool `json:"includeHidden,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// CursorListParams 是带分页游标的列表入参。 +type CursorListParams struct { + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// ConfigReadParams 是 config/read 的入参。 +type ConfigReadParams struct { + Cwd string `json:"cwd,omitempty"` +} + +// ThreadObject 是官方 thread 对象里本模块用到的字段。 +type ThreadObject struct { + ID string `json:"id"` + Name string `json:"name"` + Preview string `json:"preview"` + Cwd string `json:"cwd"` + Ephemeral bool `json:"ephemeral"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + Turns []TurnObject `json:"turns"` + Status json.RawMessage `json:"status"` +} + +// TurnObject 是官方 turn 对象里本模块用到的字段。 +type TurnObject struct { + ID string `json:"id"` + Status string `json:"status"` + Error json.RawMessage `json:"error"` + Items []json.RawMessage `json:"items"` +} + +// ThreadStartResult 是 thread/start、resume、fork 的响应。 +type ThreadStartResult struct { + Thread ThreadObject `json:"thread"` + Model string `json:"model"` + Cwd string `json:"cwd"` + ApprovalPolicy json.RawMessage `json:"approvalPolicy"` + Sandbox json.RawMessage `json:"sandbox"` + ReasoningEffort json.RawMessage `json:"reasoningEffort"` +} + +// ThreadListResult 是 thread/list 的响应。 +type ThreadListResult struct { + Data []ThreadObject `json:"data"` + NextCursor string `json:"nextCursor"` +} + +// ThreadReadResult 是 thread/read 的响应。 +type ThreadReadResult struct { + Thread ThreadObject `json:"thread"` +} + +// TurnStartResult 是 turn/start 的响应。 +type TurnStartResult struct { + Turn TurnObject `json:"turn"` +} + +// ModelListResult 是 model/list 的响应。 +type ModelListResult struct { + Data []json.RawMessage `json:"data"` + NextCursor string `json:"nextCursor"` +} + +// AccountResult 是 account/read 的响应。 +type AccountResult struct { + RequiresOpenaiAuth bool `json:"requiresOpenaiAuth"` + Account json.RawMessage `json:"account"` +} + +// ConfigReadResult 是 config/read 的响应。 +type ConfigReadResult struct { + Config map[string]json.RawMessage `json:"config"` +} + +// PermissionProfileListResult 是 permissionProfile/list 的响应。 +type PermissionProfileListResult struct { + Data []json.RawMessage `json:"data"` + NextCursor string `json:"nextCursor"` +} + +// SandboxPolicy 把官方 sandbox 字符串编成 turn/start 要的对象。 +func SandboxPolicy(mode string) json.RawMessage { + switch mode { + case "read-only": + return json.RawMessage(`{"type":"readOnly"}`) + case "workspace-write": + return json.RawMessage(`{"type":"workspaceWrite"}`) + case "danger-full-access": + return json.RawMessage(`{"type":"dangerFullAccess"}`) + default: + return nil + } +} + +// CollaborationModeParams 编 Plan 模式。settings.model 官方必填。 +func CollaborationModeParams(mode, model string) json.RawMessage { + if mode == "" { + return nil + } + if model == "" { + model = "default" + } + body, err := json.Marshal(map[string]any{ + "mode": mode, + "settings": map[string]any{"model": model}, + }) + if err != nil { + return nil + } + return body +} + +// Initialize 发送握手请求。 +func (c *Client) Initialize(ctx context.Context, info ClientInfo) (InitializeResult, error) { + var out InitializeResult + err := c.Call(ctx, MethodInitialize, InitializeParams{ClientInfo: info}, &out) + return out, err +} + +// ThreadStart 让 Codex 新建一条 thread。 +func (c *Client) ThreadStart(ctx context.Context, params ThreadStartParams) (ThreadStartResult, error) { + var out ThreadStartResult + err := c.Call(ctx, MethodThreadStart, params, &out) + return out, err +} + +// ThreadResume 接上一条已有的 Codex thread。 +func (c *Client) ThreadResume(ctx context.Context, params ThreadResumeParams) (ThreadStartResult, error) { + var out ThreadStartResult + err := c.Call(ctx, MethodThreadResume, params, &out) + return out, err +} + +// ThreadFork 按官方历史分出一条新 thread。 +func (c *Client) ThreadFork(ctx context.Context, params ThreadForkParams) (ThreadStartResult, error) { + var out ThreadStartResult + err := c.Call(ctx, MethodThreadFork, params, &out) + return out, err +} + +// ThreadArchive 归档一条 thread。 +func (c *Client) ThreadArchive(ctx context.Context, threadID string) error { + return c.Call(ctx, MethodThreadArchive, ThreadIDParams{ThreadID: threadID}, nil) +} + +// ThreadUnarchive 取消归档。 +func (c *Client) ThreadUnarchive(ctx context.Context, threadID string) error { + return c.Call(ctx, MethodThreadUnarchive, ThreadIDParams{ThreadID: threadID}, nil) +} + +// ThreadSetName 改标题。 +func (c *Client) ThreadSetName(ctx context.Context, threadID, name string) error { + return c.Call(ctx, MethodThreadNameSet, ThreadNameParams{ThreadID: threadID, Name: name}, nil) +} + +// ThreadList 列出 thread。 +func (c *Client) ThreadList(ctx context.Context, params ThreadListParams) (ThreadListResult, error) { + var out ThreadListResult + err := c.Call(ctx, MethodThreadList, params, &out) + return out, err +} + +// ThreadRead 读一条 thread 及其可选历史。 +func (c *Client) ThreadRead(ctx context.Context, params ThreadReadParams) (ThreadReadResult, error) { + var out ThreadReadResult + err := c.Call(ctx, MethodThreadRead, params, &out) + return out, err +} + +// ThreadCompact 让 Codex 自己压缩这条 thread。 +func (c *Client) ThreadCompact(ctx context.Context, threadID string) error { + return c.Call(ctx, MethodThreadCompact, ThreadIDParams{ThreadID: threadID}, nil) +} + +// TurnStart 开一轮。 +func (c *Client) TurnStart(ctx context.Context, params TurnStartParams) (TurnStartResult, error) { + var out TurnStartResult + err := c.Call(ctx, MethodTurnStart, params, &out) + return out, err +} + +// TurnInterrupt 打断一轮。 +func (c *Client) TurnInterrupt(ctx context.Context, threadID, turnID string) error { + return c.Call(ctx, MethodTurnInterrupt, TurnInterruptParams{ThreadID: threadID, TurnID: turnID}, nil) +} + +// ReviewStart 让 Codex 评审当前工作目录改动。 +func (c *Client) ReviewStart(ctx context.Context, threadID string) error { + return c.Call(ctx, MethodReviewStart, ReviewStartParams{ + ThreadID: threadID, + Target: map[string]any{"type": "uncommittedChanges"}, + }, nil) +} + +// ModelList 列出模型。 +func (c *Client) ModelList(ctx context.Context, params ModelListParams) (ModelListResult, error) { + var out ModelListResult + err := c.Call(ctx, MethodModelList, params, &out) + return out, err +} + +// PermissionProfileList 列出权限预设。 +func (c *Client) PermissionProfileList(ctx context.Context, params CursorListParams) (PermissionProfileListResult, error) { + var out PermissionProfileListResult + err := c.Call(ctx, MethodPermissionProfileList, params, &out) + return out, err +} + +// AccountRead 读本机授权。 +func (c *Client) AccountRead(ctx context.Context) (AccountResult, error) { + var out AccountResult + err := c.Call(ctx, MethodAccountRead, map[string]any{}, &out) + return out, err +} + +// ConfigRead 读 Codex 当前生效配置。 +func (c *Client) ConfigRead(ctx context.Context, cwd string) (ConfigReadResult, error) { + var out ConfigReadResult + err := c.Call(ctx, MethodConfigRead, ConfigReadParams{Cwd: cwd}, &out) + return out, err +} + +// MapThread 把官方 thread 映到本模块 Session。 +func MapThread(th ThreadObject, archived bool, activeTurnID string) Session { + title := th.Name + return Session{ + ID: th.ID, + ThreadID: th.ID, + Title: title, + Preview: th.Preview, + Cwd: th.Cwd, + ActiveTurnID: activeTurnID, + Archived: archived, + Ephemeral: th.Ephemeral, + CreatedAt: th.CreatedAt, + UpdatedAt: th.UpdatedAt, + } +} + +// ParseModel 从 model/list 的一条里取出 ModelInfo。 +func ParseModel(raw json.RawMessage) (ModelInfo, error) { + var row struct { + ID string `json:"id"` + Model string `json:"model"` + DisplayName string `json:"displayName"` + DefaultReasoningEffort string `json:"defaultReasoningEffort"` + SupportedReasoningEfforts []any `json:"supportedReasoningEfforts"` + Hidden bool `json:"hidden"` + IsDefault bool `json:"isDefault"` + } + if err := json.Unmarshal(raw, &row); err != nil { + return ModelInfo{}, err + } + id := row.ID + if id == "" { + id = row.Model + } + efforts := make([]string, 0, len(row.SupportedReasoningEfforts)) + for _, item := range row.SupportedReasoningEfforts { + switch v := item.(type) { + case string: + if v != "" { + efforts = append(efforts, v) + } + case map[string]any: + if s, _ := v["effort"].(string); s != "" { + efforts = append(efforts, s) + } else if s, _ := v["id"].(string); s != "" { + efforts = append(efforts, s) + } + } + } + return ModelInfo{ + ID: id, + DisplayName: row.DisplayName, + Efforts: efforts, + DefaultEffort: row.DefaultReasoningEffort, + Hidden: row.Hidden, + IsDefault: row.IsDefault, + }, nil +} + +// ParsePermissionProfile 从 permissionProfile/list 的一条里取出 ModeInfo。 +func ParsePermissionProfile(raw json.RawMessage) (ModeInfo, error) { + var row struct { + ID string `json:"id"` + Description string `json:"description"` + Allowed bool `json:"allowed"` + } + if err := json.Unmarshal(raw, &row); err != nil { + return ModeInfo{}, err + } + return ModeInfo{ + ID: row.ID, + Label: row.Description, + Kind: "permission", + Allowed: row.Allowed, + }, nil +} + +// CollaborationModes 返回官方 Plan 档。0.149.0 没有 collaborationMode/list。 +func CollaborationModes() []ModeInfo { + return []ModeInfo{ + {ID: "default", Label: "Default", Kind: "collaboration", Allowed: true}, + {ID: "plan", Label: "Plan", Kind: "collaboration", Allowed: true}, + } +} + +// ConfigString 从 config/read 里取字符串配置。 +func ConfigString(cfg ConfigReadResult, key string) string { + raw, ok := cfg.Config[key] + if !ok { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + return "" +} + +// Authorized 根据 account/read 判断能不能开回合。 +func Authorized(account AccountResult) bool { + if !account.RequiresOpenaiAuth { + return true + } + if len(account.Account) == 0 || string(account.Account) == "null" { + return false + } + return true +} diff --git a/server/pkg/codex/session.go b/server/pkg/codex/session.go index bde61bd..4d0992b 100644 --- a/server/pkg/codex/session.go +++ b/server/pkg/codex/session.go @@ -1,59 +1,21 @@ package codex -// Session 是一条只走 Codex 的对话。 +// Session 是一条只走 Codex 的对话。session_id 即官方 thread_id。 type Session struct { - ID string - ThreadID string // Codex thread 编号,首次开回合后才有。 - Title string - ActiveTurnID string // 同时只能有一个进行中的回合。 - Archived bool -} - -// Sessions 管走 Codex 的对话容器、Codex thread 编号,以及新建、归档、改标题、分叉。 -// 不管 Codex 配置项的值,不管回合怎么跑。 -type Sessions struct { - Codex *Codex -} - -// Create 开一条只走 Codex 的对话。刚开出来时还没有 Codex thread,首次开回合才绑上。 -// 对话绑死 Codex,中途不能改成本地模型;要换引擎就另开一条。 -func (s *Sessions) Create(userID string) (Session, error) { - return Session{}, nil -} - -// Get 读一条对话:它绑的 Codex thread、标题、有没有回合正在跑、归没归档。 -func (s *Sessions) Get(sessionID string) (Session, error) { - return Session{}, nil -} - -// BindThread 把 Codex 给的 thread 编号记到这条对话上。只能写一次; -// 这条 thread 以后失效就让回合失败,不静默换一条新的。 -func (s *Sessions) BindThread(sessionID, threadID string) error { - return nil -} - -// Archive 归档这条对话,之后不能再向 Codex 开回合。不删本机 Codex 那边的记录。 -func (s *Sessions) Archive(sessionID string) error { - return nil -} - -// Rename 改这条对话的标题。 -func (s *Sessions) Rename(sessionID, title string) error { - return nil -} - -// Fork 按已落盘历史分叉:新开一条对话,配一条新的 Codex thread,原对话原样不动。 -func (s *Sessions) Fork(sessionID string) (Session, error) { - s.Codex.ForkThread("") - return Session{}, nil -} - -// ClaimActiveTurn 把这一轮标成该对话当前正在执行的回合。一条对话同时只能有一个。 -func (s *Sessions) ClaimActiveTurn(sessionID, turnID string) error { - return nil -} - -// ClearActiveTurn 清掉当前执行标记,好让排队里的下一条能被开起来。 -func (s *Sessions) ClearActiveTurn(sessionID, turnID string) error { - return nil + ID string `json:"id"` + ThreadID string `json:"thread_id"` // 与 ID 相同,方便看板对照官方编号。 + Title string `json:"title,omitempty"` + Preview string `json:"preview,omitempty"` + Cwd string `json:"cwd,omitempty"` + ActiveTurnID string `json:"active_turn_id,omitempty"` // 同时只能有一个进行中的回合。 + Archived bool `json:"archived"` + Ephemeral bool `json:"ephemeral,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` +} + +// SessionPage 是 thread/list 的一页。 +type SessionPage struct { + Sessions []Session `json:"sessions"` + NextCursor string `json:"next_cursor,omitempty"` } diff --git a/server/pkg/codex/settings.go b/server/pkg/codex/settings.go index a8e880b..4366365 100644 --- a/server/pkg/codex/settings.go +++ b/server/pkg/codex/settings.go @@ -2,29 +2,79 @@ package codex // Settings 是这条对话里最终生效的 Codex 模型、推理强度、Plan 与权限。 type Settings struct { - Model string - Effort string - CollaborationMode string // Codex 的 Plan;空表示非 Plan。 - ApprovalPolicy string // Codex 的值,如 on-request。 - Sandbox string // Codex 的值,如 workspace-write。 - Cwd string // 由调用方(看板)传入的工作路径;问答可空。 - Overridden []string // 用户改过、需要交给 Codex 的字段名。 + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` + CollaborationMode string `json:"collaboration_mode,omitempty"` // Codex 的 Plan;空表示非 Plan。 + ApprovalPolicy string `json:"approval_policy,omitempty"` // Codex 的值,如 on-request。 + Sandbox string `json:"sandbox,omitempty"` // Codex 的值,如 workspace-write。 + Cwd string `json:"cwd,omitempty"` // 由调用方(看板)传入的工作路径;问答可空。 + Overridden []string `json:"overridden,omitempty"` // 用户改过、需要交给 Codex 的字段名。 } -// Configs 管这条对话里生效的 Codex 配置,并且只把用户改过的项交给 Codex。 -// 不管发消息,不管命令怎么拆词。 -type Configs struct { - Command *Command +// Override 返回只含用户改过字段的配置,没改的不带给 Codex。 +func (s Settings) Override() Settings { + if len(s.Overridden) == 0 { + return Settings{Cwd: s.Cwd} + } + wanted := map[string]bool{} + for _, name := range s.Overridden { + wanted[name] = true + } + out := Settings{Cwd: s.Cwd, Overridden: append([]string(nil), s.Overridden...)} + if wanted["model"] { + out.Model = s.Model + } + if wanted["effort"] { + out.Effort = s.Effort + } + if wanted["collaboration_mode"] { + out.CollaborationMode = s.CollaborationMode + } + if wanted["approval_policy"] { + out.ApprovalPolicy = s.ApprovalPolicy + } + if wanted["sandbox"] { + out.Sandbox = s.Sandbox + } + return out } -// Effective 返回 Codex 自己的默认值与用户覆盖合并后、这条对话此刻真正生效的配置。 -func (c *Configs) Effective(sessionID string) (Settings, error) { - return Settings{}, nil -} - -// Apply 记下用户这次改动,只记改过的项并写进 Overridden;没改的仍旧跟 Codex 默认走。 -// 不整份下发,也不自造官方没有的档位。 -func (c *Configs) Apply(sessionID string, patch Settings) (Settings, error) { - c.Command.Invoke(sessionID, "", "") - return Settings{}, nil +// MergeOverride 把补丁记进当前配置,并登记改过的字段名。 +func (s Settings) MergeOverride(patch Settings) Settings { + out := s + seen := map[string]bool{} + for _, name := range out.Overridden { + seen[name] = true + } + add := func(name string) { + if seen[name] { + return + } + seen[name] = true + out.Overridden = append(out.Overridden, name) + } + if patch.Model != "" { + out.Model = patch.Model + add("model") + } + if patch.Effort != "" { + out.Effort = patch.Effort + add("effort") + } + if patch.CollaborationMode != "" { + out.CollaborationMode = patch.CollaborationMode + add("collaboration_mode") + } + if patch.ApprovalPolicy != "" { + out.ApprovalPolicy = patch.ApprovalPolicy + add("approval_policy") + } + if patch.Sandbox != "" { + out.Sandbox = patch.Sandbox + add("sandbox") + } + if patch.Cwd != "" { + out.Cwd = patch.Cwd + } + return out } diff --git a/server/pkg/codex/transcript.go b/server/pkg/codex/transcript.go index e4ad92a..81152da 100644 --- a/server/pkg/codex/transcript.go +++ b/server/pkg/codex/transcript.go @@ -15,29 +15,11 @@ const ( // Progress 是给人看、可回放的一条 Codex 进展。 type Progress struct { - Kind ProgressKind - Text string - Command string - Paths []string - Diff string -} - -// Transcript 管把 Codex 的进展落成给人看、可回放的记录。这份记录只给人看, -// 不回灌给 Codex 当上下文。不管驱动 Codex,不管改磁盘。 -type Transcript struct{} - -// AppendUser 记下用户发的这一条,连同它带的文件提及和图片。 -func (t *Transcript) AppendUser(sessionID, turnID, text string, input Input) error { - return nil -} - -// AppendProgress 记下 Codex 这一步的进展:正文、推理、跑了什么命令、改了哪些文件、 -// 出的方案,或者「这里接不住」这类提示。 -func (t *Transcript) AppendProgress(sessionID, turnID string, item Progress) error { - return nil -} - -// Hydrate 按已落下的记录回放整条对话,供人重连后接着看。 -func (t *Transcript) Hydrate(sessionID string) ([]Progress, error) { - return nil, nil + Kind ProgressKind `json:"kind"` + ItemID string `json:"item_id,omitempty"` + Text string `json:"text,omitempty"` + Command string `json:"command,omitempty"` + Paths []string `json:"paths,omitempty"` + Diff string `json:"diff,omitempty"` + Status string `json:"status,omitempty"` } diff --git a/server/pkg/codex/transport.go b/server/pkg/codex/transport.go new file mode 100644 index 0000000..b3f4388 --- /dev/null +++ b/server/pkg/codex/transport.go @@ -0,0 +1,137 @@ +package codex + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "sync" +) + +// Transport 是一条 JSONL 字节流。pkg 不启动进程。 +type Transport interface { + Read(ctx context.Context) ([]byte, error) + Write(ctx context.Context, frame []byte) error + Close() error +} + +// JSONL 把读写流编成带长度上限的 JSONL Transport。 +type JSONL struct { + reader *bufio.Reader + writer io.Writer + closer io.Closer + mu sync.Mutex + max int +} + +// NewJSONL 包装 r/w。closer 在 Close 时关掉;maxBytes<=0 时用 MaxFrameBytes。 +func NewJSONL(r io.Reader, w io.Writer, closer io.Closer, maxBytes int) *JSONL { + if maxBytes <= 0 { + maxBytes = MaxFrameBytes + } + return &JSONL{ + reader: bufio.NewReaderSize(r, 64*1024), + writer: w, + closer: closer, + max: maxBytes, + } +} + +// Read 读下一帧;空行跳过。超过上限或半帧结束则报错。 +func (t *JSONL) Read(ctx context.Context) ([]byte, error) { + type result struct { + line []byte + err error + } + ch := make(chan result, 1) + go func() { + for { + line, err := t.readLine() + if err != nil { + ch <- result{err: err} + return + } + if len(line) == 0 { + continue + } + ch <- result{line: line} + return + } + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case out := <-ch: + return out.line, out.err + } +} + +func (t *JSONL) readLine() ([]byte, error) { + var buf []byte + for { + chunk, err := t.reader.ReadSlice('\n') + if err == bufio.ErrBufferFull { + if t.max > 0 && len(buf)+len(chunk) > t.max { + return nil, fmt.Errorf("jsonl frame exceeds %d bytes", t.max) + } + buf = append(buf, chunk...) + continue + } + if err != nil { + n := len(buf) + len(chunk) + if err == io.EOF && n > 0 { + return nil, fmt.Errorf("truncated jsonl frame") + } + return nil, err + } + chunk = bytes.TrimSuffix(chunk, []byte{'\n'}) + chunk = bytes.TrimSuffix(chunk, []byte{'\r'}) + if t.max > 0 && len(buf)+len(chunk) > t.max { + return nil, fmt.Errorf("jsonl frame exceeds %d bytes", t.max) + } + return append(buf, chunk...), nil + } +} + +// Write 写出一帧并补换行。并发写出串行化。 +func (t *JSONL) Write(ctx context.Context, frame []byte) error { + if err := ctx.Err(); err != nil { + return err + } + t.mu.Lock() + defer t.mu.Unlock() + if _, err := t.writer.Write(frame); err != nil { + return err + } + _, err := t.writer.Write([]byte{'\n'}) + return err +} + +// Close 关闭底层 closer。 +func (t *JSONL) Close() error { + if t.closer != nil { + return t.closer.Close() + } + return nil +} + +type closerPair struct{ a, b io.Closer } + +func (c closerPair) Close() error { + err1 := c.a.Close() + err2 := c.b.Close() + if err1 != nil { + return err1 + } + return err2 +} + +// PipePair 返回一对相连的 JSONL Transport,供测试当假 app-server。 +func PipePair() (Transport, Transport) { + ar, aw := io.Pipe() + br, bw := io.Pipe() + left := NewJSONL(ar, bw, closerPair{aw, br}, 0) + right := NewJSONL(br, aw, closerPair{bw, ar}, 0) + return left, right +} diff --git a/server/pkg/codex/turn.go b/server/pkg/codex/turn.go index 6bf592d..1e3fde6 100644 --- a/server/pkg/codex/turn.go +++ b/server/pkg/codex/turn.go @@ -22,50 +22,28 @@ const ( // Turn 是一次用户请求对应的那一轮 Codex 工作。 type Turn struct { - ID string - SessionID string - Status TurnStatus + ID string `json:"id"` + SessionID string `json:"session_id"` + CodexID string `json:"codex_id,omitempty"` // 官方 turn id;排队中还没有。 + Status TurnStatus `json:"status"` + Error string `json:"error,omitempty"` } -// Turns 管一轮 Codex 工作的开始、排队和手动打断。进行中再发下一条只排队、不自动插话; -// 要停掉当前这轮只能手动打断。不管实录怎么记,不管人怎么点批准。 -type Turns struct { - Session *Sessions - Codex *Codex - Transcript *Transcript - Attachment *Attachment -} - -// Start 发出这条输入。对话空闲就向 Codex 开新一轮:取出攒好的附件、占住当前执行位、 -// 首次还要开 thread 并把编号记回对话,再把用户这条写进实录。 -// 对话正忙则按排队处理,不打断在跑的那一轮。 -func (t *Turns) Start(sessionID, content string, input Input, mode InputMode) (turnID string, err error) { - t.Attachment.Mention(sessionID, "") - t.Session.ClaimActiveTurn(sessionID, "") - t.Codex.StartThread("", Settings{}) - t.Session.BindThread(sessionID, "") - t.Codex.StartTurn("", input, Settings{}) - t.Transcript.AppendUser(sessionID, "", content, input) - return "", nil -} - -// Queue 把这条输入排到当前这轮后面,先写进实录给人看; -// 等当前一轮自己结束或被手动打断,再轮到它开。 -func (t *Turns) Queue(sessionID, content string, input Input) (turnID string, err error) { - t.Transcript.AppendUser(sessionID, "", content, input) - return "", nil -} - -// Cancel 按用户请求手动打断当前这一轮:向 Codex 传播取消、腾出当前执行位, -// 再把排着的下一条开起来。 -func (t *Turns) Cancel(turnID string) error { - t.Codex.Interrupt("", turnID) - t.Session.ClearActiveTurn("", turnID) - t.Start("", "", Input{}, InputStart) - return nil -} - -// Continue 在反问有了结果后让这一轮接着跑,不新开回合。 -func (t *Turns) Continue(turnID string) error { - return nil +// MapTurnStatus 把官方 turn.status 映到本模块状态。 +func MapTurnStatus(status string, waitingAsk bool) TurnStatus { + if waitingAsk && (status == "" || status == "inProgress") { + return TurnWaitingApproval + } + switch status { + case "completed": + return TurnCompleted + case "interrupted": + return TurnCancelled + case "failed": + return TurnFailed + case "inProgress", "": + return TurnRunning + default: + return TurnRunning + } } From f78364df30150e2cae971bda81faed680f5d57e5 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Thu, 17 Sep 2026 08:59:23 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E6=8A=8A=20Codex=20=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E8=BF=9B=20Agent=20=E5=AF=B9=E8=AF=9D=E9=A1=B5?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E7=94=A8=E7=B3=BB=E7=BB=9F=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E6=A1=86=E9=80=89=E7=9B=AE=E5=BD=95=E5=92=8C=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建会话时选 Agent 或 Codex,不再单独开 Codex 页。设置点选即生效;工作目录和挂文件走系统对话框。 Co-authored-by: Cursor --- AGENTS.md | 4 +- apps/web/app/(chat)/s/c/[sessionId]/page.tsx | 3 + apps/web/app/api/directories/pick/route.ts | 18 + apps/web/app/api/directories/route.ts | 24 -- apps/web/app/api/files/pick/route.ts | 26 ++ apps/web/app/chat-host.tsx | 31 +- apps/web/app/providers.tsx | 15 +- apps/web/lib/directories.ts | 15 - apps/web/lib/native-file-pick.ts | 225 +++++++++++ apps/web/lib/pick-directory.ts | 12 + apps/web/lib/pick-files.ts | 25 ++ apps/web/next.config.ts | 6 + "docs/Codex\345\257\271\346\216\245.md" | 4 + docs/architecture.md | 4 +- packages/core/codex/client.test.ts | 160 ++++++++ packages/core/codex/client.ts | 222 +++++++++++ packages/core/codex/index.ts | 37 ++ packages/core/codex/reducer.test.ts | 124 ++++++ packages/core/codex/reducer.ts | 206 ++++++++++ packages/core/codex/sse.ts | 115 ++++++ packages/core/codex/types.ts | 192 +++++++++ packages/core/index.ts | 14 + packages/core/package.json | 5 +- packages/ui/components/prompt-input.tsx | 2 + packages/views/chat/chat-page.tsx | 374 +++++++++++++----- packages/views/chat/index.ts | 4 +- packages/views/chat/prompt-bar.tsx | 52 +-- packages/views/chat/session-sidebar.tsx | 57 ++- packages/views/chat/workspace-picker.tsx | 129 ------ packages/views/codex/ask-dock.tsx | 164 ++++++++ packages/views/codex/codex-pane.tsx | 242 ++++++++++++ packages/views/codex/composer-menus.tsx | 249 ++++++++++++ .../views/codex/hooks/use-codex-catalog.ts | 39 ++ .../views/codex/hooks/use-session-list.ts | 107 +++++ packages/views/codex/hooks/use-session.ts | 325 +++++++++++++++ packages/views/codex/index.ts | 5 + packages/views/codex/lib/format.ts | 46 +++ packages/views/codex/prompt-bar.tsx | 230 +++++++++++ packages/views/codex/provider.tsx | 22 ++ packages/views/codex/timeline.tsx | 160 ++++++++ packages/views/index.ts | 8 +- packages/views/package.json | 3 +- packages/views/provider.tsx | 27 +- server/internal/codex/catalog.go | 11 +- server/internal/codex/runtime_test.go | 2 +- server/internal/codex/session.go | 59 +++ server/internal/codex/session_test.go | 25 ++ server/pkg/codex/catalog.go | 2 +- server/pkg/codex/codex_test.go | 7 + server/pkg/codex/rpc.go | 27 +- 50 files changed, 3497 insertions(+), 368 deletions(-) create mode 100644 apps/web/app/(chat)/s/c/[sessionId]/page.tsx create mode 100644 apps/web/app/api/directories/pick/route.ts delete mode 100644 apps/web/app/api/directories/route.ts create mode 100644 apps/web/app/api/files/pick/route.ts delete mode 100644 apps/web/lib/directories.ts create mode 100644 apps/web/lib/native-file-pick.ts create mode 100644 apps/web/lib/pick-directory.ts create mode 100644 apps/web/lib/pick-files.ts create mode 100644 packages/core/codex/client.test.ts create mode 100644 packages/core/codex/client.ts create mode 100644 packages/core/codex/index.ts create mode 100644 packages/core/codex/reducer.test.ts create mode 100644 packages/core/codex/reducer.ts create mode 100644 packages/core/codex/sse.ts create mode 100644 packages/core/codex/types.ts delete mode 100644 packages/views/chat/workspace-picker.tsx create mode 100644 packages/views/codex/ask-dock.tsx create mode 100644 packages/views/codex/codex-pane.tsx create mode 100644 packages/views/codex/composer-menus.tsx create mode 100644 packages/views/codex/hooks/use-codex-catalog.ts create mode 100644 packages/views/codex/hooks/use-session-list.ts create mode 100644 packages/views/codex/hooks/use-session.ts create mode 100644 packages/views/codex/index.ts create mode 100644 packages/views/codex/lib/format.ts create mode 100644 packages/views/codex/prompt-bar.tsx create mode 100644 packages/views/codex/provider.tsx create mode 100644 packages/views/codex/timeline.tsx create mode 100644 server/internal/codex/session_test.go diff --git a/AGENTS.md b/AGENTS.md index e2b6f0c..70039c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 ## 目录放置规则 - 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。 -- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出目录浏览框(`apps/web` 列本机目录),不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。 +- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框,不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。 - Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。 - 本机 Codex app-server 生命周期、内存排队/问票/SSE 放在 `server/internal/codex`。不新增 Codex 业务表;凡官方 API 能读到的都不入库。 - Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。 @@ -22,7 +22,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`、`codex/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。Codex 用独立 `CodexClient`,不扩 `AgentClient`。 - 无业务 UI 放在 `packages/ui`(`@codedock/ui`):`components/`、`lib/`、`styles/`,不要 `src/`,不按业务域拆。不依赖 core,不知道 Session / Run / TimelineItem。 - 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`、`codex/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`;Codex 用 `views/codex` 的 `CodexProvider`,由 `ChatPage` 在 Codex 模式下组合,不单独做 Codex 页。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 -- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 不单独路由,走 `/` 与 `/s/...`。开发态切页顶栏只放 web。不解析 SSE。 +- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 不单独路由,走 `/` 与 `/s/c/:id`。开发态切页顶栏只放 web。不解析 SSE。 - 依赖方向:`apps/web` → `packages/views` → `packages/core`;`packages/views` → `packages/ui`。`ui` 不依赖 `core`。未来 CLI 只依赖 `core`。 - 不要创建 `server/pkg/ai`。大模型调用属于 `pkg/agent`。 diff --git a/apps/web/app/(chat)/s/c/[sessionId]/page.tsx b/apps/web/app/(chat)/s/c/[sessionId]/page.tsx new file mode 100644 index 0000000..c9a4dc1 --- /dev/null +++ b/apps/web/app/(chat)/s/c/[sessionId]/page.tsx @@ -0,0 +1,3 @@ +export default function CodexSessionPage() { + return null; +} diff --git a/apps/web/app/api/directories/pick/route.ts b/apps/web/app/api/directories/pick/route.ts new file mode 100644 index 0000000..7e864fc --- /dev/null +++ b/apps/web/app/api/directories/pick/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +import { pickDirectoryNative } from "@/lib/native-file-pick"; + +export const maxDuration = 300; + +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as { start?: string }; + try { + const path = await pickDirectoryNative({ start: body.start }); + return NextResponse.json({ path }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "无法打开系统目录选择框" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/api/directories/route.ts b/apps/web/app/api/directories/route.ts deleted file mode 100644 index c86f23f..0000000 --- a/apps/web/app/api/directories/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { readdir } from "node:fs/promises"; -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { NextResponse } from "next/server"; - -export async function GET(req: Request) { - const raw = new URL(req.url).searchParams.get("path")?.trim() || homedir(); - const current = resolve(raw); - try { - const dirents = await readdir(current, { withFileTypes: true }); - const entries = dirents - .filter((entry) => !entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink())) - .map((entry) => ({ name: entry.name, path: join(current, entry.name) })) - .sort((a, b) => a.name.localeCompare(b.name, "zh")); - const parent = dirname(current); - return NextResponse.json({ - path: current, - parent: parent === current ? undefined : parent, - entries, - }); - } catch { - return NextResponse.json({ error: "无法打开该目录" }, { status: 400 }); - } -} diff --git a/apps/web/app/api/files/pick/route.ts b/apps/web/app/api/files/pick/route.ts new file mode 100644 index 0000000..dfbbbab --- /dev/null +++ b/apps/web/app/api/files/pick/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; + +import { pickFilesNative } from "@/lib/native-file-pick"; + +export const maxDuration = 300; + +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as { + images?: boolean; + multiple?: boolean; + start?: string; + }; + try { + const paths = await pickFilesNative({ + images: Boolean(body.images), + multiple: body.multiple !== false, + start: body.start, + }); + return NextResponse.json({ paths }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "无法打开系统文件选择框" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/app/chat-host.tsx b/apps/web/app/chat-host.tsx index 2e31146..07dc15c 100644 --- a/apps/web/app/chat-host.tsx +++ b/apps/web/app/chat-host.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChatPage } from "@codedock/views/chat"; +import { ChatPage, type SessionEngine } from "@codedock/views/chat"; import { usePathname, useRouter } from "next/navigation"; import { rememberSession } from "@/lib/session"; @@ -8,19 +8,20 @@ import { rememberSession } from "@/lib/session"; export function ChatHost() { const pathname = usePathname(); const router = useRouter(); - const match = pathname.match(/^\/s\/([^/]+)/); - const sessionId = match?.[1] ? decodeURIComponent(match[1]) : undefined; - if (sessionId) { - rememberSession(sessionId); + const parsed = parseChatPath(pathname); + if (parsed.sessionId && parsed.engine === "agent") { + rememberSession(parsed.sessionId); } return ( { - if (id !== sessionId) { - router.push(`/s/${id}`); + onOpenSession={(id, engine = parsed.engine ?? "agent") => { + const path = engine === "codex" ? `/s/c/${id}` : `/s/${id}`; + if (pathname !== path) { + router.push(path); } }} onNewConversation={() => { @@ -31,3 +32,15 @@ export function ChatHost() { /> ); } + +function parseChatPath(pathname: string): { sessionId?: string; engine?: SessionEngine } { + const codex = pathname.match(/^\/s\/c\/([^/]+)/); + if (codex?.[1]) { + return { sessionId: decodeURIComponent(codex[1]), engine: "codex" }; + } + const agent = pathname.match(/^\/s\/([^/]+)/); + if (agent?.[1] && agent[1] !== "c") { + return { sessionId: decodeURIComponent(agent[1]), engine: "agent" }; + } + return {}; +} diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index caebd09..3f0d9e2 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -1,17 +1,26 @@ "use client"; import { AgentClient } from "@codedock/core/chat"; +import { CodexClient } from "@codedock/core/codex"; +import { CodexProvider } from "@codedock/views/codex"; import { AgentProvider } from "@codedock/views"; import { useMemo, type ReactNode } from "react"; -import { listDirectories } from "@/lib/directories"; import { apiBase, defaultUserId } from "@/lib/env"; +import { pickDirectory } from "@/lib/pick-directory"; +import { pickFiles } from "@/lib/pick-files"; export function Providers({ children }: { children: ReactNode }) { const client = useMemo(() => new AgentClient({ baseUrl: apiBase }), []); + const codex = useMemo(() => new CodexClient({ baseUrl: apiBase }), []); return ( - - {children} + + {children} ); } diff --git a/apps/web/lib/directories.ts b/apps/web/lib/directories.ts deleted file mode 100644 index 5865f0f..0000000 --- a/apps/web/lib/directories.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { DirectoryListing } from "@codedock/views"; - -export async function listDirectories(path?: string): Promise { - const query = path?.trim() ? `?path=${encodeURIComponent(path.trim())}` : ""; - const res = await fetch(`/api/directories${query}`); - const body = (await res.json().catch(() => ({}))) as DirectoryListing & { error?: string }; - if (!res.ok) { - throw new Error(body.error || "无法列出目录"); - } - return { - path: body.path, - parent: body.parent, - entries: body.entries ?? [], - }; -} diff --git a/apps/web/lib/native-file-pick.ts b/apps/web/lib/native-file-pick.ts new file mode 100644 index 0000000..65a35cf --- /dev/null +++ b/apps/web/lib/native-file-pick.ts @@ -0,0 +1,225 @@ +import { execFile } from "node:child_process"; +import { stat } from "node:fs/promises"; +import { homedir, platform } from "node:os"; +import { dirname } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export async function pickFilesNative(options: { + images?: boolean; + multiple?: boolean; + start?: string; +}): Promise { + const start = await existingDir(options.start); + switch (platform()) { + case "darwin": + return pickMacFiles({ ...options, start }); + case "linux": + return pickLinuxFiles({ ...options, start }); + case "win32": + return pickWindowsFiles({ ...options, start }); + default: + throw new Error("当前系统不支持弹出文件选择框"); + } +} + +export async function pickDirectoryNative(options: { start?: string } = {}): Promise { + const start = await existingDir(options.start); + switch (platform()) { + case "darwin": + return pickMacDirectory(start); + case "linux": + return pickLinuxDirectory(start); + case "win32": + return pickWindowsDirectory(start); + default: + throw new Error("当前系统不支持弹出目录选择框"); + } +} + +async function existingDir(path?: string): Promise { + const raw = path?.trim(); + if (!raw) { + return homedir(); + } + try { + const info = await stat(raw); + if (info.isDirectory()) { + return raw; + } + if (info.isFile()) { + return dirname(raw); + } + } catch { + // fall through + } + return homedir(); +} + +function splitPaths(stdout: string): string[] { + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +async function pickMacFiles(options: { images?: boolean; multiple?: boolean; start?: string }): Promise { + const prompt = options.images ? "选择图片" : "选择要挂上的文件"; + const typeClause = options.images ? ' of type {"public.image"}' : ""; + const multiClause = options.multiple ? " with multiple selections allowed" : ""; + const defaultClause = options.start + ? ` default location POSIX file ${appleString(options.start)}` + : ""; + const script = ` +try + set theChoice to choose file with prompt ${appleString(prompt)}${typeClause}${defaultClause}${multiClause} + set output to "" + if class of theChoice is list then + repeat with f in theChoice + set output to output & POSIX path of f & linefeed + end repeat + else + set output to POSIX path of theChoice + end if + return output +on error number -128 + return "" +end try +`; + try { + const { stdout } = await execFileAsync("osascript", ["-e", script], { timeout: 300_000 }); + return splitPaths(stdout); + } catch (err) { + throw nativePickError(err, "无法打开系统文件选择框"); + } +} + +async function pickMacDirectory(start?: string): Promise { + const defaultClause = start ? ` default location POSIX file ${appleString(start)}` : ""; + const script = ` +try + set theChoice to choose folder with prompt ${appleString("选择工作目录")}${defaultClause} + return POSIX path of theChoice +on error number -128 + return "" +end try +`; + try { + const { stdout } = await execFileAsync("osascript", ["-e", script], { timeout: 300_000 }); + return firstPath(stdout); + } catch { + throw new Error("无法打开系统目录选择框"); + } +} + +async function pickLinuxFiles(options: { images?: boolean; multiple?: boolean; start?: string }): Promise { + const args = ["--file-selection", "--separator=\n"]; + if (options.multiple) { + args.push("--multiple"); + } + if (options.images) { + args.push("--file-filter=图片 | *.png *.jpg *.jpeg *.gif *.webp *.heic *.bmp *.svg"); + } + if (options.start) { + args.push(`--filename=${options.start.replace(/\/?$/, "/")}`); + } + try { + const { stdout } = await execFileAsync("zenity", args, { timeout: 300_000 }); + return splitPaths(stdout); + } catch (err) { + if (isCancel(err)) { + return []; + } + throw nativePickError(err, "无法打开系统文件选择框(需要 zenity)"); + } +} + +async function pickLinuxDirectory(start?: string): Promise { + const args = ["--file-selection", "--directory"]; + if (start) { + args.push(`--filename=${start.replace(/\/?$/, "/")}`); + } + try { + const { stdout } = await execFileAsync("zenity", args, { timeout: 300_000 }); + return firstPath(stdout); + } catch (err) { + if (isCancel(err)) { + return undefined; + } + throw new Error("无法打开系统目录选择框(需要 zenity)"); + } +} + +async function pickWindowsFiles(options: { images?: boolean; multiple?: boolean; start?: string }): Promise { + const filter = options.images + ? "Images (*.png;*.jpg;*.jpeg;*.gif;*.webp;*.bmp)|*.png;*.jpg;*.jpeg;*.gif;*.webp;*.bmp|All files (*.*)|*.*" + : "All files (*.*)|*.*"; + const script = ` +Add-Type -AssemblyName System.Windows.Forms +$dialog = New-Object System.Windows.Forms.OpenFileDialog +$dialog.Multiselect = $${options.multiple ? "true" : "false"} +$dialog.Filter = ${psString(filter)} +${options.start ? `$dialog.InitialDirectory = ${psString(options.start)}` : ""} +if ($dialog.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { exit 0 } +$dialog.FileNames -join [Environment]::NewLine +`; + try { + const { stdout } = await execFileAsync( + "powershell", + ["-NoProfile", "-NonInteractive", "-Command", script], + { timeout: 300_000 }, + ); + return splitPaths(stdout); + } catch (err) { + throw nativePickError(err, "无法打开系统文件选择框"); + } +} + +async function pickWindowsDirectory(start?: string): Promise { + const script = ` +Add-Type -AssemblyName System.Windows.Forms +$dialog = New-Object System.Windows.Forms.FolderBrowserDialog +$dialog.Description = '选择工作目录' +${start ? `$dialog.SelectedPath = ${psString(start)}` : ""} +if ($dialog.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) { exit 0 } +$dialog.SelectedPath +`; + try { + const { stdout } = await execFileAsync( + "powershell", + ["-NoProfile", "-NonInteractive", "-Command", script], + { timeout: 300_000 }, + ); + return firstPath(stdout); + } catch { + throw new Error("无法打开系统目录选择框"); + } +} + +function firstPath(stdout: string): string | undefined { + const path = splitPaths(stdout)[0]; + if (!path) { + return undefined; + } + if (path === "/" || /^[A-Za-z]:[\\/]?$/.test(path)) { + return path; + } + return path.replace(/[/\\]+$/, ""); +} + +function appleString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function psString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function isCancel(err: unknown): boolean { + return Boolean(err && typeof err === "object" && "code" in err && (err as { code?: number }).code === 1); +} + +function nativePickError(_err: unknown, fallback: string): Error { + return new Error(fallback); +} diff --git a/apps/web/lib/pick-directory.ts b/apps/web/lib/pick-directory.ts new file mode 100644 index 0000000..faef41f --- /dev/null +++ b/apps/web/lib/pick-directory.ts @@ -0,0 +1,12 @@ +export async function pickDirectory(options?: { start?: string }): Promise { + const res = await fetch("/api/directories/pick", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ start: options?.start }), + }); + const body = (await res.json().catch(() => ({}))) as { path?: string; error?: string }; + if (!res.ok) { + throw new Error(body.error || "无法打开系统目录选择框"); + } + return body.path || undefined; +} diff --git a/apps/web/lib/pick-files.ts b/apps/web/lib/pick-files.ts new file mode 100644 index 0000000..da3389b --- /dev/null +++ b/apps/web/lib/pick-files.ts @@ -0,0 +1,25 @@ +import type { PickedLocalFile } from "@codedock/views"; + +export async function pickFiles(options?: { + images?: boolean; + multiple?: boolean; + start?: string; +}): Promise { + const res = await fetch("/api/files/pick", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + images: options?.images, + multiple: options?.multiple ?? true, + start: options?.start, + }), + }); + const body = (await res.json().catch(() => ({}))) as { paths?: string[]; error?: string }; + if (!res.ok) { + throw new Error(body.error || "无法打开系统文件选择框"); + } + return (body.paths ?? []).map((path) => ({ + path, + name: path.split(/[/\\]/).filter(Boolean).at(-1) || path, + })); +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 6ebb7d3..8fc0f9e 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,12 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + async redirects() { + return [ + { source: "/codex", destination: "/", permanent: false }, + { source: "/codex/:path*", destination: "/", permanent: false }, + ]; + }, transpilePackages: [ "@codedock/core", "@codedock/ui", diff --git "a/docs/Codex\345\257\271\346\216\245.md" "b/docs/Codex\345\257\271\346\216\245.md" index 3d4e335..1456e0c 100644 --- "a/docs/Codex\345\257\271\346\216\245.md" +++ "b/docs/Codex\345\257\271\346\216\245.md" @@ -18,6 +18,7 @@ ## 边界 - 不走本地对话的工具、记忆和压缩 +- 前端复用 Agent 对话页:新建会话时选 Agent 或 Codex,Codex 会话走 `/s/c/:id`,不单独开页面 - 绑了 Codex 的对话不能中途改成本地模型 - 一条对话同时只有一个进行中的回合;多条对话可以并行 - 共用本机 `~/.codex` 配置与授权,不另存密钥 @@ -30,3 +31,6 @@ - `pkg/codex`:领域类型与协议客户端,不 spawn CLI - `internal/codex`:本机进程与内存编排 - `internal/handler/codex`:独立 `/codex/*` HTTP +- `packages/core/codex`:无头 `CodexClient` 与 SSE/reducer +- `packages/views/codex`:由 `ChatPage` 在 Codex 模式下组合(输入栏设置、时间线、问票、prompt);归档在侧栏顶部 +- `apps/web`:对话页 `/` 与 `/s/c/:id` 装配 `CodexClient`,不单独开 Codex 页 diff --git a/docs/architecture.md b/docs/architecture.md index 7986f40..e2a9fc2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -163,7 +163,7 @@ packages/views apps/web -> packages/views - -> packages/core # 创建 AgentClient + -> packages/core # 创建 AgentClient / GitClient / CodexClient -> packages/ui # 引入 tokens.css 不直接解析 SSE 或 event type ``` @@ -174,7 +174,7 @@ apps/web 承担大部分接口逻辑: -- Session / Message / Usage / Approval 的增删改查。创建 Session 时在本包冻结 `workspace_id`(工作目录):用户指定的路径必须是已存在目录,否则 400;未指定(空或 `default`)则 `GIT_REPO`,再否则 cwd。不 import `internal/agent/tools`。新对话选目录由 web 弹出目录浏览框。`sessions.summary` 在首次用户消息写入,列表与详情返回 +- Session / Message / Usage / Approval 的增删改查。创建 Session 时在本包冻结 `workspace_id`(工作目录):用户指定的路径必须是已存在目录,否则 400;未指定(空或 `default`)则 `GIT_REPO`,再否则 cwd。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框。`sessions.summary` 在首次用户消息写入,列表与详情返回 - 用户侧 TextMemory 的查看与删除(不提供写入,不暴露 message 索引;List 用 user_id / workspace_id,Get/Delete 用 name 默认目录) - SSE:先按 `afterSeq` / `Last-Event-ID` 回放已落库事件,再 `SubscribeAll` 并按 Session 过滤;客户端断开不取消 Run - 事件 JSON 回放:`GET /sessions/{id}/event-log`,供前端一次 hydrate,不替代 SSE 直播 diff --git a/packages/core/codex/client.test.ts b/packages/core/codex/client.test.ts new file mode 100644 index 0000000..9eb2e57 --- /dev/null +++ b/packages/core/codex/client.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { CodexClient, CodexClientError } from "./client.ts"; + +test("CodexClient hits every /codex route", async () => { + const calls: { url: string; method: string; body?: unknown; headers?: string }[] = []; + const client = new CodexClient({ + baseUrl: "http://api.test/", + fetch: async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const raw = typeof init?.body === "string" ? init.body : undefined; + calls.push({ + url, + method, + body: raw ? JSON.parse(raw) : undefined, + headers: new Headers(init?.headers).get("Last-Event-ID") ?? undefined, + }); + if (url.endsWith("/codex/status")) { + return json({ available: true, authorized: true, version: "0.149.0" }); + } + if (url.endsWith("/codex/models")) { + return json({ models: [{ id: "gpt-5.6", efforts: ["low"], default_effort: "low", hidden: false, is_default: true }] }); + } + if (url.endsWith("/codex/modes")) { + return json({ modes: [{ id: "plan", kind: "collaboration", allowed: true }] }); + } + if (url.endsWith("/codex/commands")) { + return json({ commands: [{ name: "mcp", action: "hint", hint: "终端" }] }); + } + if (url.includes("/codex/sessions?") || url.endsWith("/codex/sessions")) { + if (method === "POST") { + return json({ session: { id: "th1", thread_id: "th1", archived: false } }, 201); + } + return json({ sessions: [{ id: "th1", thread_id: "th1", archived: false }], next_cursor: "c2" }); + } + if (url.endsWith("/codex/sessions/th1")) { + if (method === "PATCH") { + return json({ ok: "true" }); + } + return json({ + session: { id: "th1", thread_id: "th1", archived: false }, + progress: [{ kind: "user", text: "hi" }], + asks: [], + }); + } + if (url.endsWith("/fork")) { + return json({ session: { id: "th2", thread_id: "th2", archived: false } }, 201); + } + if (url.endsWith("/settings")) { + return json({ settings: { model: "gpt-5.6", effort: "high" } }); + } + if (url.endsWith("/commands")) { + return json({ handled: true, hint: "终端", action: "hint" }); + } + if (url.endsWith("/turns")) { + return json({ turn: { id: "t1", session_id: "th1", status: "running" } }, 202); + } + if (url.includes("/asks/") && url.endsWith("/decision")) { + return json({ ok: "true" }); + } + if (url.includes("/asks/") && url.endsWith("/expire")) { + return json({ ok: "true" }); + } + if (url.endsWith("/asks")) { + return json({ asks: [{ id: "8", kind: "command", command: "ls", external_request_id: "8" }] }); + } + if (url.includes("/turns/") && url.endsWith("/interrupt")) { + return json({ ok: "true" }); + } + return json({ ok: "true" }); + }, + }); + + const status = await client.status(); + assert.equal(status.version, "0.149.0"); + assert.equal((await client.listModels())[0]?.id, "gpt-5.6"); + assert.equal((await client.listModes())[0]?.id, "plan"); + assert.equal((await client.listCommands())[0]?.name, "mcp"); + const page = await client.listSessions({ archived: true, cursor: "abc" }); + assert.equal(page.next_cursor, "c2"); + const created = await client.createSession({ cwd: "/tmp" }); + assert.equal(created.id, "th1"); + const detail = await client.getSession("th1"); + assert.equal(detail.progress[0]?.text, "hi"); + await client.renameSession("th1", "Hi"); + const forked = await client.forkSession("th1"); + assert.equal(forked.id, "th2"); + await client.archiveSession("th1"); + await client.compactSession("th1"); + await client.reviewSession("th1"); + assert.equal((await client.getSettings("th1")).model, "gpt-5.6"); + assert.equal((await client.applySettings("th1", { effort: "high" })).effort, "high"); + assert.equal((await client.invokeCommand("th1", "mcp")).handled, true); + const turn = await client.startTurn("th1", { content: "hello", mode: "queue" }); + assert.equal(turn.id, "t1"); + await client.mention("th1", "a.go"); + await client.attachImage("th1", "a.png"); + assert.equal((await client.listAsks("th1"))[0]?.id, "8"); + await client.interruptTurn("t1", "th1"); + await client.decideAsk("8", { approved: true, scope: "once" }); + await client.expireAsk("8"); + assert.equal(client.eventsUrl("th1", 3), "http://api.test/codex/sessions/th1/events?after=3"); + + const urls = calls.map((call) => `${call.method} ${call.url}`); + assert.deepEqual(urls, [ + "GET http://api.test/codex/status", + "GET http://api.test/codex/models", + "GET http://api.test/codex/modes", + "GET http://api.test/codex/commands", + "GET http://api.test/codex/sessions?archived=true&cursor=abc", + "POST http://api.test/codex/sessions", + "GET http://api.test/codex/sessions/th1", + "PATCH http://api.test/codex/sessions/th1", + "POST http://api.test/codex/sessions/th1/fork", + "POST http://api.test/codex/sessions/th1/archive", + "POST http://api.test/codex/sessions/th1/compact", + "POST http://api.test/codex/sessions/th1/review", + "GET http://api.test/codex/sessions/th1/settings", + "POST http://api.test/codex/sessions/th1/settings", + "POST http://api.test/codex/sessions/th1/commands", + "POST http://api.test/codex/sessions/th1/turns", + "POST http://api.test/codex/sessions/th1/attachments/mention", + "POST http://api.test/codex/sessions/th1/attachments/image", + "GET http://api.test/codex/sessions/th1/asks", + "POST http://api.test/codex/turns/t1/interrupt", + "POST http://api.test/codex/asks/8/decision", + "POST http://api.test/codex/asks/8/expire", + ]); + assert.deepEqual(calls[5]?.body, { settings: { cwd: "/tmp" } }); + assert.deepEqual(calls[7]?.body, { title: "Hi" }); + assert.deepEqual(calls[15]?.body, { content: "hello", input: {}, mode: "queue" }); + assert.deepEqual(calls[19]?.body, { session_id: "th1" }); + assert.deepEqual(calls[20]?.body, { approved: true, scope: "once" }); +}); + +test("CodexClient maps error JSON", async () => { + const client = new CodexClient({ + baseUrl: "http://api.test", + fetch: async () => + new Response(JSON.stringify({ error: "codex is not authorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + }); + await assert.rejects(() => client.createSession(), (err: unknown) => { + assert.ok(err instanceof CodexClientError); + assert.equal(err.status, 401); + assert.equal(err.message, "codex is not authorized"); + return true; + }); +}); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/packages/core/codex/client.ts b/packages/core/codex/client.ts new file mode 100644 index 0000000..2895677 --- /dev/null +++ b/packages/core/codex/client.ts @@ -0,0 +1,222 @@ +import type { + ApprovalAsk, + AskAnswer, + CommandResult, + CommandSpec, + EngineStatus, + ModeInfo, + ModelInfo, + Session, + SessionDetail, + SessionPage, + Settings, + StartTurnRequest, + Turn, +} from "./types.ts"; + +export class CodexClientError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "CodexClientError"; + this.status = status; + } +} + +export type CodexClientOptions = { + baseUrl: string; + fetch?: typeof fetch; +}; + +export class CodexClient { + readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: CodexClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.fetchImpl = options.fetch ?? fetch.bind(globalThis); + } + + async status(): Promise { + return this.request("/codex/status"); + } + + async listModels(): Promise { + const body = await this.request<{ models?: ModelInfo[] }>("/codex/models"); + return body.models ?? []; + } + + async listModes(): Promise { + const body = await this.request<{ modes?: ModeInfo[] }>("/codex/modes"); + return body.modes ?? []; + } + + async listCommands(): Promise { + const body = await this.request<{ commands?: CommandSpec[] }>("/codex/commands"); + return body.commands ?? []; + } + + async listSessions(opts?: { archived?: boolean; cursor?: string }): Promise { + const query = new URLSearchParams(); + if (opts?.archived) { + query.set("archived", "true"); + } + if (opts?.cursor) { + query.set("cursor", opts.cursor); + } + const suffix = query.toString() ? `?${query}` : ""; + const body = await this.request(`/codex/sessions${suffix}`); + return { sessions: body.sessions ?? [], next_cursor: body.next_cursor }; + } + + async createSession(settings: Settings = {}): Promise { + const body = await this.request<{ session: Session }>("/codex/sessions", { + method: "POST", + json: { settings }, + }); + return body.session; + } + + async getSession(sessionId: string, signal?: AbortSignal): Promise { + const body = await this.request<{ + session: Session; + progress?: SessionDetail["progress"]; + asks?: ApprovalAsk[]; + }>(`/codex/sessions/${sessionId}`, { signal }); + return { + session: body.session, + progress: body.progress ?? [], + asks: body.asks ?? [], + }; + } + + async renameSession(sessionId: string, title: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}`, { + method: "PATCH", + json: { title }, + }); + } + + async forkSession(sessionId: string): Promise { + const body = await this.request<{ session: Session }>(`/codex/sessions/${sessionId}/fork`, { + method: "POST", + }); + return body.session; + } + + async archiveSession(sessionId: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}/archive`, { method: "POST" }); + } + + async compactSession(sessionId: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}/compact`, { method: "POST" }); + } + + async reviewSession(sessionId: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}/review`, { method: "POST" }); + } + + async getSettings(sessionId: string): Promise { + const body = await this.request<{ settings: Settings }>(`/codex/sessions/${sessionId}/settings`); + return body.settings ?? {}; + } + + async applySettings(sessionId: string, patch: Settings): Promise { + const body = await this.request<{ settings: Settings }>(`/codex/sessions/${sessionId}/settings`, { + method: "POST", + json: patch, + }); + return body.settings ?? {}; + } + + async invokeCommand(sessionId: string, name: string, args = ""): Promise { + return this.request(`/codex/sessions/${sessionId}/commands`, { + method: "POST", + json: { name, args }, + }); + } + + async startTurn(sessionId: string, req: StartTurnRequest = {}): Promise { + const body = await this.request<{ turn: Turn }>(`/codex/sessions/${sessionId}/turns`, { + method: "POST", + json: { + content: req.content ?? "", + input: req.input ?? {}, + mode: req.mode ?? "start", + }, + }); + return body.turn; + } + + async mention(sessionId: string, path: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}/attachments/mention`, { + method: "POST", + json: { path }, + }); + } + + async attachImage(sessionId: string, path: string): Promise { + await this.request<{ ok: string }>(`/codex/sessions/${sessionId}/attachments/image`, { + method: "POST", + json: { path }, + }); + } + + async listAsks(sessionId: string): Promise { + const body = await this.request<{ asks?: ApprovalAsk[] }>(`/codex/sessions/${sessionId}/asks`); + return body.asks ?? []; + } + + async interruptTurn(turnId: string, sessionId: string): Promise { + await this.request<{ ok: string }>(`/codex/turns/${turnId}/interrupt`, { + method: "POST", + json: { session_id: sessionId }, + }); + } + + async decideAsk(requestId: string, answer: AskAnswer): Promise { + await this.request<{ ok: string }>(`/codex/asks/${requestId}/decision`, { + method: "POST", + json: answer, + }); + } + + async expireAsk(requestId: string): Promise { + await this.request<{ ok: string }>(`/codex/asks/${requestId}/expire`, { method: "POST" }); + } + + eventsUrl(sessionId: string, after = 0): string { + return `${this.baseUrl}/codex/sessions/${sessionId}/events?after=${after}`; + } + + private async request(path: string, init: RequestInit & { json?: unknown } = {}): Promise { + const headers = new Headers(init.headers); + if (init.json !== undefined) { + headers.set("Content-Type", "application/json"); + } + const { json, ...rest } = init; + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...rest, + headers, + body: json !== undefined ? JSON.stringify(json) : rest.body, + }); + const text = await res.text(); + let parsed: unknown = undefined; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = { error: text }; + } + } + if (!res.ok) { + const message = + parsed && typeof parsed === "object" && "error" in parsed + ? String((parsed as { error: unknown }).error) + : res.statusText; + throw new CodexClientError(res.status, message); + } + return parsed as T; + } +} diff --git a/packages/core/codex/index.ts b/packages/core/codex/index.ts new file mode 100644 index 0000000..365ea69 --- /dev/null +++ b/packages/core/codex/index.ts @@ -0,0 +1,37 @@ +export { CodexClient, CodexClientError, type CodexClientOptions } from "./client.ts"; +export { + applyCodexEvent, + applyOptimisticUser, + dropOptimisticUser, + emptyCodexState, + hydrateCodex, +} from "./reducer.ts"; +export { parseSSEBlock, parseSSEChunk, watchCodexEvents, type WatchCodexEventsOptions } from "./sse.ts"; +export type { + ApprovalAsk, + AskAnswer, + AskKind, + CodexEvent, + CodexEventType, + CodexViewState, + CommandAction, + CommandResult, + CommandSpec, + DecisionScope, + EngineStatus, + Input, + InputMode, + ModeInfo, + ModelInfo, + Progress, + ProgressKind, + Session, + SessionDetail, + SessionPage, + Settings, + StartTurnRequest, + TimelineItem, + Turn, + TurnStatus, +} from "./types.ts"; +export { isLiveTurn, LIVE_TURN_STATUSES } from "./types.ts"; diff --git a/packages/core/codex/reducer.test.ts b/packages/core/codex/reducer.test.ts new file mode 100644 index 0000000..6143cfa --- /dev/null +++ b/packages/core/codex/reducer.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { applyCodexEvent, applyOptimisticUser, emptyCodexState, hydrateCodex } from "./reducer.ts"; +import { parseSSEBlock } from "./sse.ts"; + +test("hydrateCodex and applyCodexEvent cover turn, progress, ask, reset", () => { + let state = hydrateCodex({ + session: { id: "th", thread_id: "th", archived: false, active_turn_id: "t1" }, + progress: [{ kind: "user", item_id: "u1", text: "hi" }], + asks: [], + settings: { model: "gpt-5.6" }, + }); + assert.equal(state.items[0]?.text, "hi"); + assert.equal(state.activeTurn?.id, "t1"); + assert.equal(state.settings.model, "gpt-5.6"); + + state = applyCodexEvent(state, { + seq: 1, + type: "progress", + session_id: "th", + progress: { kind: "text", item_id: "m1", text: "hel" }, + }); + state = applyCodexEvent(state, { + seq: 2, + type: "progress", + session_id: "th", + progress: { kind: "text", item_id: "m1", text: "lo" }, + }); + const message = state.items.find((item) => item.id === "m1"); + assert.equal(message?.text, "hello"); + + state = applyCodexEvent(state, { + seq: 3, + type: "ask.required", + session_id: "th", + ask: { id: "8", kind: "command", command: "ls", external_request_id: "8" }, + }); + assert.equal(state.asks.length, 1); + state = applyCodexEvent(state, { + seq: 4, + type: "ask.resolved", + session_id: "th", + ask: { id: "8", kind: "command", command: "ls", external_request_id: "8" }, + }); + assert.equal(state.asks.length, 0); + + state = applyCodexEvent(state, { + seq: 5, + type: "turn.completed", + session_id: "th", + turn: { id: "t1", session_id: "th", status: "completed" }, + }); + assert.equal(state.activeTurn, null); + + state = applyCodexEvent(state, { + seq: 6, + type: "notice", + session_id: "th", + notice: "不支持", + }); + state = applyCodexEvent(state, { + seq: 7, + type: "reset", + session_id: "th", + notice: "event gap", + }); + assert.equal(state.reset, true); + assert.equal(state.lastSeq, 7); +}); + +test("parseSSEBlock reads Codex event data", () => { + const ev = parseSSEBlock( + [ + "id: 3", + "event: progress", + 'data: {"seq":3,"type":"progress","session_id":"th","progress":{"kind":"text","text":"hi"}}', + ].join("\n"), + ); + assert.ok(ev); + assert.equal(ev.seq, 3); + assert.equal(ev.type, "progress"); + assert.equal(ev.progress?.text, "hi"); +}); + +test("user progress replaces the optimistic bubble", () => { + let state = applyOptimisticUser(emptyCodexState(), "hi"); + state = applyCodexEvent(state, { + seq: 1, + type: "turn.started", + session_id: "th", + turn: { id: "t1", session_id: "th", status: "running" }, + progress: { kind: "user", item_id: "u1", text: "hi" }, + }); + const users = state.items.filter((item) => item.kind === "user"); + assert.equal(users.length, 1); + assert.equal(users[0]?.id, "u1"); + assert.equal(state.activeTurn?.id, "t1"); +}); + +test("duplicate user progress is ignored", () => { + let state = applyOptimisticUser(emptyCodexState(), "hi"); + state = applyCodexEvent(state, { + seq: 1, + type: "progress", + session_id: "th", + progress: { kind: "user", item_id: "u1", text: "hi" }, + }); + state = applyCodexEvent(state, { + seq: 2, + type: "progress", + session_id: "th", + progress: { kind: "user", item_id: "u2", text: "hi" }, + }); + const users = state.items.filter((item) => item.kind === "user"); + assert.equal(users.length, 1); + assert.equal(users[0]?.text, "hi"); +}); + +test("emptyCodexState starts idle", () => { + const state = emptyCodexState(); + assert.equal(state.items.length, 0); + assert.equal(state.lastSeq, 0); +}); diff --git a/packages/core/codex/reducer.ts b/packages/core/codex/reducer.ts new file mode 100644 index 0000000..90e99f7 --- /dev/null +++ b/packages/core/codex/reducer.ts @@ -0,0 +1,206 @@ +import type { + ApprovalAsk, + CodexEvent, + CodexViewState, + Progress, + Session, + Settings, + TimelineItem, + Turn, +} from "./types.ts"; + +export function emptyCodexState(): CodexViewState { + return { + session: null, + settings: {}, + items: [], + asks: [], + lastSeq: 0, + activeTurn: null, + reset: false, + }; +} + +export function hydrateCodex(detail: { + session: Session; + progress: Progress[]; + asks: ApprovalAsk[]; + settings?: Settings; +}): CodexViewState { + return { + session: detail.session, + settings: detail.settings ?? {}, + items: detail.progress.map((item, index) => progressItem(item, index)), + asks: detail.asks.slice(), + lastSeq: 0, + activeTurn: detail.session.active_turn_id + ? { + id: detail.session.active_turn_id, + session_id: detail.session.id, + status: "running", + } + : null, + reset: false, + }; +} + +export function applyCodexEvent(state: CodexViewState, event: CodexEvent): CodexViewState { + const next: CodexViewState = { + ...state, + items: state.items.slice(), + asks: state.asks.slice(), + lastSeq: Math.max(state.lastSeq, event.seq), + reset: false, + }; + switch (event.type) { + case "reset": + if (event.notice) { + next.items.push(noticeItem(event.notice, event.seq)); + } + return { ...next, reset: true }; + case "turn.queued": + case "turn.started": + case "turn.completed": + case "turn.failed": + case "turn.cancelled": + if (event.turn) { + next.activeTurn = isOpen(event.turn) ? event.turn : null; + next.items.push(turnItem(event.turn)); + } + if (event.progress) { + if (event.progress.kind === "user") { + next.items = next.items.filter((item) => item.id !== "local-user"); + } + next.items = upsertProgress(next.items, event.progress); + } + if (event.notice) { + next.items.push(noticeItem(event.notice, event.seq)); + } + return next; + case "progress": + if (event.progress) { + if (event.progress.kind === "user") { + next.items = next.items.filter((item) => item.id !== "local-user"); + } + next.items = upsertProgress(next.items, event.progress); + } + return next; + case "ask.required": + if (event.ask) { + next.asks = next.asks.filter((ask) => ask.id !== event.ask?.id); + next.asks.push(event.ask); + if (next.activeTurn) { + next.activeTurn = { ...next.activeTurn, status: "waiting_approval" }; + } + } + return next; + case "ask.resolved": + if (event.ask) { + next.asks = next.asks.filter((ask) => ask.id !== event.ask?.id); + } + return next; + case "notice": + if (event.notice) { + next.items.push(noticeItem(event.notice, event.seq)); + } + return next; + default: + return next; + } +} + +export function applyOptimisticUser(state: CodexViewState, text: string): CodexViewState { + return { + ...state, + items: [...state.items, { id: "local-user", kind: "user", text, streaming: false }], + }; +} + +export function dropOptimisticUser(state: CodexViewState): CodexViewState { + return { + ...state, + items: state.items.filter((item) => item.id !== "local-user"), + }; +} + +function isOpen(turn: Turn): boolean { + return turn.status === "queued" || turn.status === "running" || turn.status === "waiting_approval"; +} + +function progressItem(progress: Progress, index: number): TimelineItem { + return { + id: progress.item_id || `progress-${index}`, + kind: progress.kind, + text: progress.text, + command: progress.command, + paths: progress.paths, + diff: progress.diff, + status: progress.status, + }; +} + +function turnItem(turn: Turn): TimelineItem { + return { + id: `turn-${turn.id}-${turn.status}`, + kind: "turn", + status: turn.status, + error: turn.error, + text: + turn.status === "queued" + ? "已排队,等当前回合结束" + : turn.status === "completed" + ? "本轮完成" + : turn.status === "cancelled" + ? "已打断" + : turn.status === "failed" + ? turn.error || "本轮失败" + : turn.status === "waiting_approval" + ? "等待你的作答" + : "Codex 正在工作", + }; +} + +function noticeItem(text: string, seq: number): TimelineItem { + return { id: `notice-${seq}`, kind: "notice", text }; +} + +function upsertProgress(items: TimelineItem[], progress: Progress): TimelineItem[] { + if (progress.kind === "user") { + const text = progress.text ?? ""; + if (items.some((item) => item.kind === "user" && item.text === text)) { + return items.filter((item) => item.id !== "local-user"); + } + return [...items.filter((item) => item.id !== "local-user"), progressItem(progress, items.length)]; + } + const id = progress.item_id; + if (!id) { + return [...items, progressItem(progress, items.length)]; + } + const index = items.findIndex((item) => item.id === id && item.kind !== "turn"); + if (index < 0) { + return [...items, progressItem(progress, items.length)]; + } + const current = items[index]; + const next = items.slice(); + next[index] = { + ...current, + kind: progress.kind || current.kind, + text: append(current.text, progress.text), + command: progress.command || current.command, + paths: progress.paths?.length ? progress.paths : current.paths, + diff: append(current.diff, progress.diff), + status: progress.status || current.status, + streaming: progress.kind === "text" || progress.kind === "reasoning", + }; + return next; +} + +function append(left: string | undefined, right: string | undefined): string | undefined { + if (!right) { + return left; + } + if (!left) { + return right; + } + return left + right; +} diff --git a/packages/core/codex/sse.ts b/packages/core/codex/sse.ts new file mode 100644 index 0000000..9feba78 --- /dev/null +++ b/packages/core/codex/sse.ts @@ -0,0 +1,115 @@ +import type { CodexEvent } from "./types.ts"; + +export function parseSSEChunk(buffer: string): { events: CodexEvent[]; rest: string } { + const parts = buffer.split("\n\n"); + const rest = parts.pop() ?? ""; + const events: CodexEvent[] = []; + for (const block of parts) { + const ev = parseSSEBlock(block); + if (ev) { + events.push(ev); + } + } + return { events, rest }; +} + +export function parseSSEBlock(block: string): CodexEvent | null { + let data = ""; + for (const raw of block.split("\n")) { + const line = raw.replace(/\r$/, ""); + if (line.startsWith("data:")) { + data += line.slice(5).trim(); + } + } + if (!data) { + return null; + } + return JSON.parse(data) as CodexEvent; +} + +export interface WatchCodexEventsOptions { + baseUrl: string; + sessionId: string; + getAfterSeq: () => number; + onEvent: (event: CodexEvent) => void; + signal: AbortSignal; + fetch?: typeof fetch; + retryDelayMs?: number; +} + +export async function watchCodexEvents(options: WatchCodexEventsOptions): Promise { + const fetchImpl = options.fetch ?? fetch.bind(globalThis); + const retryDelayMs = options.retryDelayMs ?? 500; + const baseUrl = options.baseUrl.replace(/\/$/, ""); + + while (!options.signal.aborted) { + const after = options.getAfterSeq(); + const url = `${baseUrl}/codex/sessions/${options.sessionId}/events?after=${after}`; + try { + const res = await fetchImpl(url, { + headers: { Accept: "text/event-stream", "Last-Event-ID": String(after) }, + signal: options.signal, + }); + if (!res.ok || !res.body) { + throw new Error(`sse status ${res.status}`); + } + await readSSEStream(res.body, options.onEvent, options.signal); + } catch (err) { + if (options.signal.aborted) { + return; + } + const name = err instanceof Error ? err.name : ""; + if (name === "AbortError") { + return; + } + } + if (options.signal.aborted) { + return; + } + await sleep(retryDelayMs, options.signal); + } +} + +async function readSSEStream( + body: ReadableStream, + onEvent: (event: CodexEvent) => void, + signal: AbortSignal, +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const parsed = parseSSEChunk(buffer); + buffer = parsed.rest; + for (const event of parsed.events) { + onEvent(event); + } + } + } finally { + reader.releaseLock(); + } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/core/codex/types.ts b/packages/core/codex/types.ts new file mode 100644 index 0000000..c312a5c --- /dev/null +++ b/packages/core/codex/types.ts @@ -0,0 +1,192 @@ +export type EngineStatus = { + available: boolean; + authorized: boolean; + version?: string; + hint?: string; +}; + +export type ModelInfo = { + id: string; + display_name?: string; + efforts: string[]; + default_effort: string; + hidden: boolean; + is_default: boolean; +}; + +export type ModeInfo = { + id: string; + label?: string; + kind: "collaboration" | "permission" | string; + approval?: string; + sandbox?: string; + allowed: boolean; +}; + +export type CommandAction = "apply_settings" | "turn" | "session" | "attach" | "hint" | string; + +export type CommandSpec = { + name: string; + action: CommandAction; + hint?: string; + field?: string; +}; + +export type CommandResult = { + hint?: string; + action?: CommandAction; + handled: boolean; +}; + +export type Session = { + id: string; + thread_id: string; + title?: string; + preview?: string; + cwd?: string; + active_turn_id?: string; + archived: boolean; + ephemeral?: boolean; + created_at?: number; + updated_at?: number; +}; + +export type SessionPage = { + sessions: Session[]; + next_cursor?: string; +}; + +export type Settings = { + model?: string; + effort?: string; + collaboration_mode?: string; + approval_policy?: string; + sandbox?: string; + cwd?: string; + overridden?: string[]; +}; + +export type InputMode = "start" | "queue"; + +export type TurnStatus = + | "queued" + | "running" + | "waiting_approval" + | "completed" + | "failed" + | "cancelled"; + +export type Turn = { + id: string; + session_id: string; + codex_id?: string; + status: TurnStatus; + error?: string; +}; + +export type Input = { + text?: string; + mentions?: string[]; + images?: string[]; +}; + +export type StartTurnRequest = { + content?: string; + input?: Input; + mode?: InputMode; +}; + +export type ProgressKind = "user" | "text" | "reasoning" | "command" | "file_change" | "plan" | "notice"; + +export type Progress = { + kind: ProgressKind; + item_id?: string; + text?: string; + command?: string; + paths?: string[]; + diff?: string; + status?: string; +}; + +export type AskKind = "command" | "file_change" | "question" | "form" | "permissions"; + +export type DecisionScope = "once" | "session"; + +export type ApprovalAsk = { + id: string; + kind: AskKind; + thread_id?: string; + turn_id?: string; + method?: string; + command?: string; + paths?: string[]; + diff?: string; + prompt?: string; + options?: string[]; + fields?: string[]; + external_request_id: string; +}; + +export type AskAnswer = { + approved: boolean; + scope?: DecisionScope; + choice?: string; + values?: string[]; +}; + +export type CodexEventType = + | "turn.queued" + | "turn.started" + | "turn.completed" + | "turn.failed" + | "turn.cancelled" + | "progress" + | "ask.required" + | "ask.resolved" + | "notice" + | "reset"; + +export type CodexEvent = { + seq: number; + type: CodexEventType; + session_id: string; + turn_id?: string; + progress?: Progress; + turn?: Turn; + ask?: ApprovalAsk; + notice?: string; +}; + +export type SessionDetail = { + session: Session; + progress: Progress[]; + asks: ApprovalAsk[]; +}; + +export type TimelineItem = { + id: string; + kind: ProgressKind | "turn"; + text?: string; + command?: string; + paths?: string[]; + diff?: string; + status?: string; + streaming?: boolean; + error?: string; +}; + +export type CodexViewState = { + session: Session | null; + settings: Settings; + items: TimelineItem[]; + asks: ApprovalAsk[]; + lastSeq: number; + activeTurn: Turn | null; + reset: boolean; +}; + +export const LIVE_TURN_STATUSES: readonly TurnStatus[] = ["queued", "running", "waiting_approval"]; + +export function isLiveTurn(status: TurnStatus | undefined): boolean { + return Boolean(status && (LIVE_TURN_STATUSES as readonly string[]).includes(status)); +} diff --git a/packages/core/index.ts b/packages/core/index.ts index b19268e..b2cf33b 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -34,6 +34,20 @@ export { type WatchEventsOptions, } from "./chat/index.ts"; export { GitClient, GitClientError, type GitClientOptions } from "./git/index.ts"; +export { CodexClient, CodexClientError, type CodexClientOptions } from "./codex/index.ts"; +export type { + ApprovalAsk, + AskAnswer, + CodexEvent, + CodexViewState, + CommandSpec, + EngineStatus, + ModeInfo, + ModelInfo, + Session as CodexSession, + Settings as CodexSettings, + Turn as CodexTurn, +} from "./codex/index.ts"; export type { Branch, BranchView, diff --git a/packages/core/package.json b/packages/core/package.json index 8ba12e4..900af33 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,10 +6,11 @@ "exports": { ".": "./index.ts", "./chat": "./chat/index.ts", - "./git": "./git/index.ts" + "./git": "./git/index.ts", + "./codex": "./codex/index.ts" }, "scripts": { - "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/wait-run.test.ts git/client.test.ts" + "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/wait-run.test.ts git/client.test.ts codex/client.test.ts codex/reducer.test.ts" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/ui/components/prompt-input.tsx b/packages/ui/components/prompt-input.tsx index 97bddee..cf9fe88 100644 --- a/packages/ui/components/prompt-input.tsx +++ b/packages/ui/components/prompt-input.tsx @@ -31,6 +31,8 @@ export function PromptInput({ return; } onSend?.({ text }); + const box = form.querySelector('textarea[name="message"]'); + box?.focus(); }} {...props} > diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index d96f89c..596bc77 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -1,13 +1,17 @@ "use client"; -import type { ApprovalMode, TimelineItem, WorkMode } from "@codedock/core/chat"; +import type { ApprovalMode, Session, TimelineItem, WorkMode } from "@codedock/core/chat"; +import type { Session as CodexSession } from "@codedock/core/codex"; import { Button } from "@codedock/ui"; -import { useState, type ReactNode } from "react"; +import { FolderOpen } from "lucide-react"; +import { useMemo, useState, useEffect, type ReactNode } from "react"; +import { CodexPane } from "../codex/codex-pane.tsx"; +import { useCodexSessionList } from "../codex/hooks/use-session-list.ts"; +import { useCodex } from "../codex/provider.tsx"; import { useAgent } from "../provider.tsx"; import { ApprovalDock } from "./approval-dock.tsx"; import { ConversationTimeline } from "./conversation-timeline.tsx"; -import { PendingDock } from "./pending-dock.tsx"; import { useSessionList } from "./hooks/use-session-list.ts"; import { useSessionTimeline } from "./hooks/use-session-timeline.ts"; import { shortWorkspace } from "./lib/format.ts"; @@ -17,13 +21,16 @@ import { readLastWorkspace, writeLastWorkspace, } from "./lib/workspace.ts"; +import { PendingDock } from "./pending-dock.tsx"; import { PromptBar } from "./prompt-bar.tsx"; -import { SessionSidebar } from "./session-sidebar.tsx"; -import { WorkspacePicker } from "./workspace-picker.tsx"; +import { SessionSidebar, type SidebarSession } from "./session-sidebar.tsx"; + +export type SessionEngine = "agent" | "codex"; export type ChatPageProps = { sessionId?: string; - onOpenSession: (id: string) => void; + engine?: SessionEngine; + onOpenSession: (id: string, engine?: SessionEngine) => void; onNewConversation: () => void; brandSrc?: string; headerActions?: ReactNode; @@ -31,21 +38,43 @@ export type ChatPageProps = { export function ChatPage({ sessionId, + engine, onOpenSession, onNewConversation, brandSrc, headerActions, }: ChatPageProps) { - const { client, listDirectories } = useAgent(); + const { client, pickDirectory, pickFiles } = useAgent(); + const { client: codexClient } = useCodex(); const list = useSessionList(); - const timeline = useSessionTimeline(sessionId); + const codexList = useCodexSessionList(); + const [draftEngine, setDraftEngine] = useState(engine ?? "agent"); + const activeEngine: SessionEngine = sessionId ? (engine ?? "agent") : draftEngine; + useEffect(() => { + if (engine) { + setDraftEngine(engine); + } + }, [engine]); + const timeline = useSessionTimeline(activeEngine === "agent" ? sessionId : undefined); const [starting, setStarting] = useState(false); const [composerError, setComposerError] = useState(null); - const [workspaceDraft, setWorkspaceDraft] = useState(readLastWorkspace); - const [pickerOpen, setPickerOpen] = useState(false); + const [workspaceDraft, setWorkspaceDraft] = useState(""); + const [pickingWorkspace, setPickingWorkspace] = useState(false); + useEffect(() => { + setWorkspaceDraft(readLastWorkspace()); + }, []); + + const sessions = useMemo( + () => mergeSessions(list.sessions, codexList.sessions), + [codexList.sessions, list.sessions], + ); + const currentKey = sessionId ? `${activeEngine}:${sessionId}` : undefined; + const current = sessions.find((session) => `${session.engine}:${session.id}` === currentKey); const frozenWorkspace = - timeline.workspaceId ?? list.sessions.find((session) => session.id === sessionId)?.workspace_id ?? ""; + activeEngine === "agent" + ? (timeline.workspaceId ?? current?.workspace_id ?? "") + : (current?.workspace_id ?? ""); const workspaceTitle = sessionId ? frozenWorkspace : workspaceDraft.trim() || "默认仓库目录"; const workspaceLabel = sessionId ? frozenWorkspace @@ -72,7 +101,7 @@ export function ChatPage({ setWorkspaceDraft(session.workspace_id); await client.startRun(session.id, { content: text, mode, approval }); await list.refresh(); - onOpenSession(session.id); + onOpenSession(session.id, "agent"); } catch (err) { setComposerError(createSessionError(err, "发送失败")); await list.refresh(); @@ -84,42 +113,138 @@ export function ChatPage({ return (
void codexList.loadMore() : undefined} onCreate={onNewConversation} - onSelect={onOpenSession} + onSelect={(id, nextEngine) => onOpenSession(id, nextEngine ?? "agent")} onRecover={async (runId) => { await timeline.recover(runId); await list.refresh(); }} onDelete={async (session) => { const deletedId = session.id; - await list.removeSession(session); - if (sessionId === deletedId) { + const deletedEngine = session.engine ?? "agent"; + if (session.engine === "codex") { + await codexClient.archiveSession(session.id); + await codexList.refresh(); + } else { + await list.removeSession(session); + } + if (sessionId === deletedId && (engine ?? "agent") === deletedEngine) { onNewConversation(); } }} - canRecoverCurrent={timeline.canRecover} + canRecoverCurrent={activeEngine === "agent" && timeline.canRecover} + canArchive={activeEngine === "codex" && Boolean(sessionId)} + onArchive={ + activeEngine === "codex" && sessionId + ? async () => { + await codexClient.archiveSession(sessionId); + await codexList.refresh(); + onNewConversation(); + } + : undefined + } brandSrc={brandSrc} />
- {sessionId ? "对话" : "新对话"} - {workspaceLabel ? ( - <> - · - + {sessionId ? (activeEngine === "codex" ? "Codex 对话" : "对话") : "新对话"} + + {sessionId ? null : ( +
+ { + setDraftEngine("agent"); + setComposerError(null); + }} > - {workspaceLabel} - - - ) : null} - {timeline.canRecover ? ( + Agent + + { + setDraftEngine("codex"); + setComposerError(null); + }} + > + Codex + +
+ )} + {sessionId ? ( + workspaceLabel ? ( + <> + · + + {workspaceLabel} + + + ) : null + ) : ( +
+ + {workspaceDraft.trim() ? ( + + ) : null} +
+ )} + {activeEngine === "agent" && timeline.canRecover ? (
: null} - {timeline.error || composerError ? ( -
- {timeline.error ?? composerError} -
- ) : null} - -
- - - { - setComposerError(null); - setPickerOpen(true); - } - } - onClearWorkspace={ - sessionId - ? undefined - : () => { - setWorkspaceDraft(""); - clearLastWorkspace(); - } - } - onSend={onSend} - onCancel={timeline.cancel} - /> -
+ {activeEngine === "codex" ? ( + <> + {composerError ? ( +
+ {composerError} +
+ ) : null} + onOpenSession(id, "codex")} + onNewConversation={onNewConversation} + onListChange={codexList.refresh} + /> + + ) : ( + <> + {timeline.error || composerError ? ( +
+ {timeline.error ?? composerError} +
+ ) : null} + +
+ + + +
+ + )} - {pickerOpen && listDirectories ? ( - setPickerOpen(false)} - onSelect={(path) => { - setWorkspaceDraft(path); - writeLastWorkspace(path); - setPickerOpen(false); - }} - /> - ) : null} ); } + +function EngineToggle({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function mergeSessions(agent: Session[], codex: CodexSession[]): SidebarSession[] { + const mapped: SidebarSession[] = [ + ...agent.map((session) => ({ ...session, engine: "agent" as const })), + ...codex.map(asSidebarSession), + ]; + const seen = new Map(); + for (const session of mapped) { + const key = `${session.engine ?? "agent"}:${session.id}`; + const prev = seen.get(key); + if (!prev || prev.updated_at < session.updated_at) { + seen.set(key, session); + } + } + return [...seen.values()].sort((left, right) => (left.updated_at < right.updated_at ? 1 : -1)); +} + +function asSidebarSession(session: CodexSession): SidebarSession { + return { + id: session.id, + tenant_id: "", + user_id: "", + agent_id: "codex", + workspace_id: session.cwd ?? "", + status: session.archived ? "archived" : "active", + last_event_seq: 1, + compaction_seq: 0, + summary: session.title || session.preview, + created_at: stampToIso(session.created_at), + updated_at: stampToIso(session.updated_at), + engine: "codex", + }; +} + +function stampToIso(value?: number): string { + if (!value) { + return new Date(0).toISOString(); + } + const ms = value > 1e11 ? value : value * 1000; + const date = new Date(ms); + if (Number.isNaN(date.getTime())) { + return new Date(0).toISOString(); + } + return date.toISOString(); +} diff --git a/packages/views/chat/index.ts b/packages/views/chat/index.ts index 7103454..8442005 100644 --- a/packages/views/chat/index.ts +++ b/packages/views/chat/index.ts @@ -1,6 +1,6 @@ -export { ChatPage, type ChatPageProps } from "./chat-page.tsx"; +export { ChatPage, type ChatPageProps, type SessionEngine } from "./chat-page.tsx"; export { ConversationTimeline } from "./conversation-timeline.tsx"; export { PromptBar } from "./prompt-bar.tsx"; -export { SessionSidebar } from "./session-sidebar.tsx"; +export { SessionSidebar, type SidebarSession } from "./session-sidebar.tsx"; export { useSessionList } from "./hooks/use-session-list.ts"; export { useSessionTimeline } from "./hooks/use-session-timeline.ts"; diff --git a/packages/views/chat/prompt-bar.tsx b/packages/views/chat/prompt-bar.tsx index d451c80..4098fc2 100644 --- a/packages/views/chat/prompt-bar.tsx +++ b/packages/views/chat/prompt-bar.tsx @@ -10,11 +10,9 @@ import { PromptInputTools, isImeConfirm, } from "@codedock/ui"; -import { ChevronUp, FolderOpen } from "lucide-react"; +import { ChevronUp } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { shortWorkspace } from "./lib/format.ts"; - const workModes: { value: WorkMode; label: string }[] = [ { value: "agent", label: "agent" }, { value: "ask", label: "ask" }, @@ -30,23 +28,21 @@ const approvalModes: { value: ApprovalMode; label: string }[] = [ export function PromptBar({ running, sending, - workspace, - onPickWorkspace, - onClearWorkspace, onSend, onCancel, }: { running: boolean; sending: boolean; - workspace?: string; - onPickWorkspace?: () => Promise; - onClearWorkspace?: () => void; onSend: (text: string, mode: WorkMode, approval: ApprovalMode) => Promise; onCancel: () => Promise; }) { const [text, setText] = useState(""); const [mode, setMode] = useState("agent"); const [approval, setApproval] = useState("manual"); + const inputRef = useRef(null); + const keepFocus = () => { + inputRef.current?.focus(); + }; return (
@@ -57,40 +53,18 @@ export function PromptBar({ return; } setText(""); - await onSend(next, mode, approval); + keepFocus(); + try { + await onSend(next, mode, approval); + } finally { + keepFocus(); + } }} > - {onPickWorkspace ? ( -
- 工作目录 - - {workspace?.trim() && onClearWorkspace ? ( - - ) : null} -
- ) : null} setText(event.currentTarget.value)} onKeyDown={(event) => { diff --git a/packages/views/chat/session-sidebar.tsx b/packages/views/chat/session-sidebar.tsx index e0f493b..5d1d2fe 100644 --- a/packages/views/chat/session-sidebar.tsx +++ b/packages/views/chat/session-sidebar.tsx @@ -2,35 +2,45 @@ import type { Session } from "@codedock/core/chat"; import { Button, cn } from "@codedock/ui"; -import { PlusIcon, Trash2Icon } from "lucide-react"; +import { Archive, PlusIcon, Trash2Icon } from "lucide-react"; import { useEffect, useState } from "react"; import { relativeTime, sessionTitle, shortId } from "./lib/format.ts"; +export type SidebarSession = Session & { engine?: "agent" | "codex" }; + export function SessionSidebar({ sessions, currentId, busy, error, + hasMore = false, + onLoadMore, onCreate, onSelect, onRecover, onDelete, + onArchive, canRecoverCurrent = false, + canArchive = false, brandSrc, }: { - sessions: Session[]; + sessions: SidebarSession[]; currentId?: string; busy: boolean; error: string | null; + hasMore?: boolean; + onLoadMore?: () => void; onCreate: () => void; - onSelect: (id: string) => void; + onSelect: (id: string, engine?: "agent" | "codex") => void; onRecover?: (runId: string) => Promise; - onDelete?: (session: Session) => Promise; + onDelete?: (session: SidebarSession) => Promise; + onArchive?: () => Promise; canRecoverCurrent?: boolean; + canArchive?: boolean; brandSrc?: string; }) { - const [pending, setPending] = useState(null); + const [pending, setPending] = useState(null); useEffect(() => { if (!pending) { @@ -60,15 +70,25 @@ export function SessionSidebar({
{error ?

{error}

: null} + {canArchive && onArchive ? ( +
+ +
+ ) : null} {pending && onDelete ? (
Promise; - onSelect: (path: string) => void; - onClose: () => void; -}) { - const [listing, setListing] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - - const load = async (path?: string) => { - setLoading(true); - setError(null); - try { - setListing(await listDirectories(path)); - } catch (err) { - if (path) { - try { - setListing(await listDirectories()); - setError(null); - return; - } catch { - // keep the original error - } - } - setError(err instanceof Error ? err.message : "无法打开目录"); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - void load(initialPath); - }, [initialPath]); - - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") { - onClose(); - } - }; - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [onClose]); - - return ( -
-
event.stopPropagation()} - > -
-

- 选择工作目录 -

-

- {listing?.path ?? "…"} -

-
-
- {listing?.parent ? ( - - ) : null} - {loading && !listing ? ( -

正在读取目录…

- ) : null} - {error ?

{error}

: null} - {listing?.entries.map((entry) => ( - - ))} - {listing && listing.entries.length === 0 && !loading ? ( -

这个目录下没有子文件夹

- ) : null} -
-
- - -
-
-
- ); -} diff --git a/packages/views/codex/ask-dock.tsx b/packages/views/codex/ask-dock.tsx new file mode 100644 index 0000000..67aef51 --- /dev/null +++ b/packages/views/codex/ask-dock.tsx @@ -0,0 +1,164 @@ +"use client"; + +import type { ApprovalAsk, AskAnswer, DecisionScope } from "@codedock/core/codex"; +import { + Button, + Confirmation, + ConfirmationAction, + ConfirmationActions, + ConfirmationRequest, + ConfirmationTitle, +} from "@codedock/ui"; +import { useState } from "react"; + +export function CodexAskDock({ + asks, + onDecide, + onExpire, +}: { + asks: ApprovalAsk[]; + onDecide: (requestId: string, answer: AskAnswer) => Promise; + onExpire: (requestId: string) => Promise; +}) { + if (asks.length === 0) { + return null; + } + return ( +
+ {asks.map((ask) => ( + + ))} +
+ ); +} + +function AskCard({ + ask, + onDecide, + onExpire, +}: { + ask: ApprovalAsk; + onDecide: (requestId: string, answer: AskAnswer) => Promise; + onExpire: (requestId: string) => Promise; +}) { + const [busy, setBusy] = useState(false); + const [scope, setScope] = useState("once"); + const [choice, setChoice] = useState(ask.options?.[0] ?? ""); + const [values, setValues] = useState(ask.fields?.map(() => "") ?? []); + + const run = async (fn: () => Promise) => { + setBusy(true); + try { + await fn(); + } finally { + setBusy(false); + } + }; + + return ( + + {titleFor(ask)} + + {ask.command ?
{ask.command}
: null} + {ask.prompt ?

{ask.prompt}

: null} + {ask.paths?.length ?

{ask.paths.join("\n")}

: null} + {ask.diff ?
{ask.diff}
: null} + {ask.kind === "question" && ask.options?.length ? ( + + ) : null} + {ask.kind === "question" && !ask.options?.length ? ( + setChoice(event.target.value)} + /> + ) : null} + {ask.kind === "form" + ? (ask.fields ?? []).map((field, index) => ( + + )) + : null} +
+ {ask.kind === "command" || ask.kind === "file_change" || ask.kind === "permissions" ? ( + + ) : null} + + void run(() => onExpire(ask.id || ask.external_request_id))} + > + 过期 + + void run(() => onDecide(ask.id || ask.external_request_id, { approved: false, scope }))} + > + 拒绝 + + + void run(() => + onDecide(ask.id || ask.external_request_id, { + approved: true, + scope, + choice: choice || undefined, + values: ask.kind === "form" ? values : choice ? [choice] : undefined, + }), + ) + } + > + 允许 + + +
+ ); +} + +function titleFor(ask: ApprovalAsk): string { + switch (ask.kind) { + case "command": + return "能不能跑这条命令"; + case "file_change": + return "能不能改这些文件"; + case "permissions": + return "Codex 请求额外权限"; + case "question": + return "需要你补一句"; + case "form": + return "MCP 表单"; + default: + return "需要你作答"; + } +} diff --git a/packages/views/codex/codex-pane.tsx b/packages/views/codex/codex-pane.tsx new file mode 100644 index 0000000..1068d9b --- /dev/null +++ b/packages/views/codex/codex-pane.tsx @@ -0,0 +1,242 @@ +"use client"; + +import type { Settings } from "@codedock/core/codex"; +import { Button } from "@codedock/ui"; +import { useState } from "react"; + +import type { PickedLocalFile, PickFilesOptions } from "../provider.tsx"; + +import { CodexAskDock } from "./ask-dock.tsx"; +import { useCodexCatalog } from "./hooks/use-codex-catalog.ts"; +import { useCodexSession } from "./hooks/use-session.ts"; +import { useCodex } from "./provider.tsx"; +import { CodexPromptBar } from "./prompt-bar.tsx"; +import { CodexTimeline } from "./timeline.tsx"; + +export function CodexPane({ + sessionId, + workspace, + pickFiles, + onOpenSession, + onNewConversation, + onListChange, +}: { + sessionId?: string; + workspace?: string; + pickFiles?: (options?: PickFilesOptions) => Promise; + onOpenSession: (id: string) => void; + onNewConversation: () => void; + onListChange?: () => Promise; +}) { + const catalog = useCodexCatalog(); + const session = useCodexSession(sessionId); + const { client } = useCodex(); + const [composerError, setComposerError] = useState(null); + const [pageNotice, setPageNotice] = useState(null); + const [pendingSettings, setPendingSettings] = useState({}); + const [starting, setStarting] = useState(false); + const [forking, setForking] = useState(false); + + const settings = sessionId + ? session.state.settings + : { ...pendingSettings, cwd: workspace?.trim() || pendingSettings.cwd }; + const notice = session.notice ?? pageNotice; + const fail = (err: unknown, fallback: string) => { + setComposerError(err instanceof Error ? err.message : fallback); + }; + + const refreshList = async () => { + await onListChange?.(); + }; + + const openCreated = async (id: string) => { + await refreshList(); + onOpenSession(id); + }; + + const createWithSettings = async () => { + const created = await client.createSession({ + ...pendingSettings, + cwd: workspace?.trim() || pendingSettings.cwd, + }); + return created.id; + }; + + const applySettings = (patch: Settings) => { + if (!sessionId) { + setPendingSettings((current) => ({ + ...current, + ...patch, + cwd: workspace?.trim() || patch.cwd || current.cwd, + })); + return; + } + void session.applySettings(patch); + }; + + const onSend = async (text: string, mode: "start" | "queue") => { + setComposerError(null); + if (sessionId) { + await session.send(text, mode); + await refreshList(); + return; + } + setStarting(true); + try { + const id = await createWithSettings(); + await client.startTurn(id, { content: text, mode }); + await openCreated(id); + } catch (err) { + fail(err, "无法开对话"); + } finally { + setStarting(false); + } + }; + + const onFork = async () => { + if (!sessionId || forking) { + return; + } + setForking(true); + try { + const forked = await session.fork(); + await refreshList(); + if (forked) { + onOpenSession(forked.id); + } + } finally { + setForking(false); + } + }; + + return ( +
+ {catalog.error || session.error || composerError || notice ? ( +
+ {catalog.error ?? session.error ?? composerError ?? notice} + +
+ ) : null} + +
+ + session.compact()} + onAttachError={(message) => fail(new Error(message), message)} + onAttach={async (files) => { + setComposerError(null); + const cwd = workspace?.trim(); + try { + const id = sessionId ?? (await createWithSettings()); + const names: string[] = []; + for (const file of files) { + if (imageExt.test(file.path)) { + if (id === sessionId) { + await session.attachImage(file.path); + } else { + await client.attachImage(id, file.path); + } + } else { + const path = mentionPath(file.path, cwd); + if (id === sessionId) { + await session.attachMention(path); + } else { + await client.mention(id, path); + } + } + names.push(file.name); + } + if (id !== sessionId) { + setPageNotice(`已挂 ${names.join("、")},随下一条发送`); + await openCreated(id); + } + } catch (err) { + fail(err, "挂文件失败"); + } + }} + onCommand={async (name, args) => { + setComposerError(null); + try { + if (!sessionId) { + const id = await createWithSettings(); + const result = await client.invokeCommand(id, name, args); + if (result.hint) { + setPageNotice(result.hint); + } + await openCreated(id); + return; + } + if (name === "archive") { + await session.archive(); + await refreshList(); + onNewConversation(); + return; + } + if (name === "fork") { + await onFork(); + return; + } + if (name === "stop") { + await session.interrupt(); + return; + } + await session.runCommand(name, args); + await refreshList(); + } catch (err) { + fail(err, "命令失败"); + } + }} + /> +
+
+ ); +} + +const imageExt = /\.(png|jpe?g|gif|webp|heic|bmp|svg)$/i; + +function mentionPath(abs: string, cwd?: string): string { + const root = cwd?.trim(); + if (!root) { + return abs; + } + const prefix = root.endsWith("/") || root.endsWith("\\") ? root : `${root}/`; + if (abs.startsWith(prefix)) { + return abs.slice(prefix.length); + } + return abs; +} diff --git a/packages/views/codex/composer-menus.tsx b/packages/views/codex/composer-menus.tsx new file mode 100644 index 0000000..5207ebe --- /dev/null +++ b/packages/views/codex/composer-menus.tsx @@ -0,0 +1,249 @@ +"use client"; + +import type { ModeInfo, ModelInfo, Settings } from "@codedock/core/codex"; +import { Button } from "@codedock/ui"; +import { ChevronUp } from "lucide-react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; + +export function ModelEffortMenu({ + models, + settings, + onApply, +}: { + models: ModelInfo[]; + settings: Settings; + onApply: (patch: Settings) => void; +}) { + const defaultModel = models.find((model) => model.is_default) ?? models[0]; + const modelId = settings.model || defaultModel?.id || ""; + const selected = models.find((model) => model.id === modelId); + const efforts = selected?.efforts?.length + ? selected.efforts + : selected?.default_effort + ? [selected.default_effort] + : []; + const effortId = + [settings.effort, selected?.default_effort].find( + (value) => Boolean(value) && (efforts.length === 0 || efforts.includes(value as string)), + ) || ""; + const modelLabel = selected?.display_name || selected?.id || "模型"; + const label = effortId ? `${modelLabel} · ${effortId}` : modelLabel; + + return ( + + + {models.length === 0 ? 没有可选模型 : null} + {models.map((model) => ( + + onApply({ + model: model.id, + effort: model.default_effort || effortId || undefined, + }) + } + > + {model.display_name || model.id} + + ))} + + + {efforts.length === 0 ? 先选模型 : null} + {efforts.map((effort) => ( + onApply({ effort })} + > + {effort} + + ))} + + + ); +} + +export function ModePermissionMenu({ + modes, + settings, + onApply, +}: { + modes: ModeInfo[]; + settings: Settings; + onApply: (patch: Settings) => void; +}) { + const plans = modes.filter((mode) => mode.kind === "collaboration"); + const permissions = modes.filter((mode) => mode.kind === "permission"); + const planId = settings.collaboration_mode || "default"; + const plan = plans.find((mode) => mode.id === planId); + const permissionId = permissionValue(settings.sandbox, permissions); + const permission = permissions.find((mode) => mode.id === permissionId); + const planLabel = plan ? modeLabel(plan) : "模式"; + const permissionLabel = permission ? modeLabel(permission) : "权限"; + const label = permissionId ? `${planLabel} · ${permissionLabel}` : planLabel; + + return ( + + + {plans.length === 0 ? 没有可选模式 : null} + {plans.map((mode) => ( + onApply({ collaboration_mode: mode.id })} + > + {modeLabel(mode)} + + ))} + + + {permissions.length === 0 ? 没有可选权限 : null} + {permissions.map((mode) => ( + + onApply({ + sandbox: sandboxOf(mode) || mode.id, + approval_policy: mode.approval, + }) + } + > + {modeLabel(mode)} + + ))} + + + ); +} + +export function UpPopover({ + label, + ariaLabel, + align = "left", + children, +}: { + label: ReactNode; + ariaLabel: string; + align?: "left" | "right"; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + return ( +
+ + {open ? ( +
+ {children} +
+ ) : null} +
+ ); +} + +function MenuSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function MenuOption({ + selected, + disabled, + onSelect, + children, +}: { + selected: boolean; + disabled?: boolean; + onSelect: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function EmptyRow({ children }: { children: ReactNode }) { + return
{children}
; +} + +function modeLabel(mode: ModeInfo): string { + return mode.label || mode.id.replace(/^:/, "") || mode.id; +} + +function sandboxOf(mode?: ModeInfo): string { + if (!mode) { + return ""; + } + if (mode.sandbox) { + return mode.sandbox; + } + return mode.id.replace(/^:/, ""); +} + +function permissionValue(sandbox: string | undefined, permissions: ModeInfo[]): string { + if (!sandbox) { + return ""; + } + const match = permissions.find((mode) => mode.id === sandbox || sandboxOf(mode) === sandbox); + return match?.id ?? sandbox; +} diff --git a/packages/views/codex/hooks/use-codex-catalog.ts b/packages/views/codex/hooks/use-codex-catalog.ts new file mode 100644 index 0000000..fb558d1 --- /dev/null +++ b/packages/views/codex/hooks/use-codex-catalog.ts @@ -0,0 +1,39 @@ +"use client"; + +import type { CommandSpec, EngineStatus, ModeInfo, ModelInfo } from "@codedock/core/codex"; +import { useCallback, useEffect, useState } from "react"; + +import { useCodex } from "../provider.tsx"; + +export function useCodexCatalog() { + const { client } = useCodex(); + const [status, setStatus] = useState(null); + const [models, setModels] = useState([]); + const [modes, setModes] = useState([]); + const [commands, setCommands] = useState([]); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const [nextStatus, nextModels, nextModes, nextCommands] = await Promise.all([ + client.status(), + client.listModels().catch(() => [] as ModelInfo[]), + client.listModes().catch(() => [] as ModeInfo[]), + client.listCommands(), + ]); + setStatus(nextStatus); + setModels(nextModels); + setModes(nextModes); + setCommands(nextCommands); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "无法读取 Codex 状态"); + } + }, [client]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { status, models, modes, commands, error, refresh }; +} diff --git a/packages/views/codex/hooks/use-session-list.ts b/packages/views/codex/hooks/use-session-list.ts new file mode 100644 index 0000000..3a23b29 --- /dev/null +++ b/packages/views/codex/hooks/use-session-list.ts @@ -0,0 +1,107 @@ +"use client"; + +import type { Session, Settings } from "@codedock/core/codex"; +import { useCallback, useEffect, useState } from "react"; + +import { useCodex } from "../provider.tsx"; + +export function useCodexSessionList() { + const { client } = useCodex(); + const [sessions, setSessions] = useState([]); + const [cursor, setCursor] = useState(); + const [archived, setArchived] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [loaded, setLoaded] = useState(false); + + const refresh = useCallback(async () => { + try { + const page = await client.listSessions({ archived }); + setSessions(uniqueSessions(page.sessions)); + setCursor(page.next_cursor); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "无法加载 Codex 对话"); + } finally { + setLoaded(true); + } + }, [archived, client]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const loadMore = useCallback(async () => { + if (!cursor) { + return; + } + try { + const page = await client.listSessions({ archived, cursor }); + setSessions((current) => uniqueSessions([...current, ...page.sessions])); + setCursor(page.next_cursor); + } catch (err) { + setError(err instanceof Error ? err.message : "无法继续加载"); + } + }, [archived, client, cursor]); + + const createSession = useCallback( + async (settings: Settings = {}) => { + setBusy(true); + try { + const session = await client.createSession(compactSettings(settings)); + await refresh(); + return session; + } finally { + setBusy(false); + } + }, + [client, refresh], + ); + + return { + sessions, + error, + busy, + loaded, + archived, + setArchived, + hasMore: Boolean(cursor), + refresh, + loadMore, + createSession, + }; +} + +function uniqueSessions(sessions: Session[]): Session[] { + const seen = new Map(); + for (const session of sessions) { + const prev = seen.get(session.id); + if (!prev || (session.updated_at ?? 0) >= (prev.updated_at ?? 0)) { + seen.set(session.id, session); + } + } + return [...seen.values()]; +} + +function compactSettings(settings: Settings): Settings { + const next: Settings = {}; + if (settings.model) { + next.model = settings.model; + } + if (settings.effort) { + next.effort = settings.effort; + } + if (settings.collaboration_mode) { + next.collaboration_mode = settings.collaboration_mode; + } + if (settings.approval_policy) { + next.approval_policy = settings.approval_policy; + } + if (settings.sandbox) { + next.sandbox = settings.sandbox; + } + if (settings.cwd) { + next.cwd = settings.cwd; + } + return next; +} diff --git a/packages/views/codex/hooks/use-session.ts b/packages/views/codex/hooks/use-session.ts new file mode 100644 index 0000000..aaa0949 --- /dev/null +++ b/packages/views/codex/hooks/use-session.ts @@ -0,0 +1,325 @@ +"use client"; + +import { + applyCodexEvent, + applyOptimisticUser, + dropOptimisticUser, + emptyCodexState, + hydrateCodex, + isLiveTurn, + watchCodexEvents, + type ApprovalAsk, + type AskAnswer, + type CodexViewState, + type InputMode, + type SessionDetail, + type Settings, +} from "@codedock/core/codex"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useCodex } from "../provider.tsx"; + +export function useCodexSession(sessionId: string | undefined) { + const { client } = useCodex(); + const [state, setState] = useState(emptyCodexState); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [sending, setSending] = useState(false); + const [loading, setLoading] = useState(Boolean(sessionId)); + const stateRef = useRef(state); + stateRef.current = state; + + const hydrate = useCallback( + async (id: string, signal?: AbortSignal) => { + const [detail, settings, asks] = await Promise.all([ + client.getSession(id, signal).catch((err: unknown): SessionDetail => { + if (!isEmptyThread(err)) { + throw err; + } + return { session: { id, thread_id: id, archived: false }, progress: [], asks: [] }; + }), + client.getSettings(id).catch(() => ({}) as Settings), + client.listAsks(id).catch(() => [] as ApprovalAsk[]), + ]); + if (signal?.aborted) { + return; + } + const next = hydrateCodex({ ...detail, asks, settings }); + next.lastSeq = Math.max(stateRef.current.lastSeq, next.lastSeq); + stateRef.current = next; + setState(next); + setLoading(false); + setError(null); + }, + [client], + ); + + useEffect(() => { + if (!sessionId) { + setState(emptyCodexState()); + setLoading(false); + setError(null); + setNotice(null); + return; + } + setLoading(true); + const ac = new AbortController(); + let cancelled = false; + void (async () => { + try { + await hydrate(sessionId, ac.signal); + await watchCodexEvents({ + baseUrl: client.baseUrl, + sessionId, + getAfterSeq: () => stateRef.current.lastSeq, + onEvent: (event) => { + setState((current) => { + const next = applyCodexEvent(current, event); + if (next.reset) { + void hydrate(sessionId); + } + return next; + }); + }, + signal: ac.signal, + }); + } catch (err) { + if (cancelled || ac.signal.aborted) { + return; + } + setLoading(false); + setError(err instanceof Error ? err.message : "无法订阅 Codex 事件"); + } + })(); + return () => { + cancelled = true; + ac.abort(); + }; + }, [client, hydrate, sessionId]); + + const send = useCallback( + async (content: string, mode: InputMode) => { + if (!sessionId || !content.trim()) { + return; + } + setSending(true); + setState((current) => applyOptimisticUser(current, content)); + try { + const turn = await client.startTurn(sessionId, { content, mode }); + setState((current) => ({ + ...current, + activeTurn: isLiveTurn(turn.status) ? turn : current.activeTurn, + })); + setError(null); + } catch (err) { + setState((current) => dropOptimisticUser(current)); + setError(err instanceof Error ? err.message : "发送失败"); + } finally { + setSending(false); + } + }, + [client, sessionId], + ); + + const interrupt = useCallback(async () => { + const turn = stateRef.current.activeTurn; + if (!sessionId || !turn) { + return; + } + try { + await client.interruptTurn(turn.id, sessionId); + } catch (err) { + setError(err instanceof Error ? err.message : "打断失败"); + } + }, [client, sessionId]); + + const decide = useCallback( + async (requestId: string, answer: AskAnswer) => { + try { + await client.decideAsk(requestId, answer); + } catch (err) { + setError(err instanceof Error ? err.message : "作答失败"); + } + }, + [client], + ); + + const expire = useCallback( + async (requestId: string) => { + try { + await client.expireAsk(requestId); + } catch (err) { + setError(err instanceof Error ? err.message : "过期失败"); + } + }, + [client], + ); + + const applySettings = useCallback( + async (patch: Settings) => { + if (!sessionId) { + return; + } + try { + const settings = await client.applySettings(sessionId, patch); + setState((current) => ({ ...current, settings })); + } catch (err) { + setError(err instanceof Error ? err.message : "保存设置失败"); + } + }, + [client, sessionId], + ); + + const runCommand = useCallback( + async (name: string, args = "") => { + if (!sessionId) { + return; + } + try { + const result = await client.invokeCommand(sessionId, name, args); + if (result.hint) { + setNotice(result.hint); + } + if (name === "fork") { + return result; + } + await hydrate(sessionId); + } catch (err) { + setError(err instanceof Error ? err.message : "命令失败"); + } + }, + [client, hydrate, sessionId], + ); + + const attachMention = useCallback( + async (path: string) => { + if (!sessionId) { + return; + } + try { + await client.mention(sessionId, path); + setNotice(`已挂文件 ${path},随下一条发送`); + } catch (err) { + setError(err instanceof Error ? err.message : "挂文件失败"); + } + }, + [client, sessionId], + ); + + const attachImage = useCallback( + async (path: string) => { + if (!sessionId) { + return; + } + try { + await client.attachImage(sessionId, path); + setNotice(`已挂图片 ${path},随下一条发送`); + } catch (err) { + setError(err instanceof Error ? err.message : "挂图片失败"); + } + }, + [client, sessionId], + ); + + const rename = useCallback( + async (title: string) => { + if (!sessionId) { + return; + } + try { + await client.renameSession(sessionId, title); + await hydrate(sessionId); + } catch (err) { + setError(err instanceof Error ? err.message : "改标题失败"); + } + }, + [client, hydrate, sessionId], + ); + + const fork = useCallback(async () => { + if (!sessionId) { + return undefined; + } + try { + return await client.forkSession(sessionId); + } catch (err) { + setError(err instanceof Error ? err.message : "分叉失败"); + return undefined; + } + }, [client, sessionId]); + + const archive = useCallback(async () => { + if (!sessionId) { + return; + } + try { + await client.archiveSession(sessionId); + } catch (err) { + setError(err instanceof Error ? err.message : "归档失败"); + } + }, [client, sessionId]); + + const compact = useCallback(async () => { + if (!sessionId) { + return; + } + try { + await client.compactSession(sessionId); + setNotice("已请 Codex 压缩这条对话"); + } catch (err) { + setError(err instanceof Error ? err.message : "压缩失败"); + } + }, [client, sessionId]); + + const review = useCallback(async () => { + if (!sessionId) { + return; + } + try { + await client.reviewSession(sessionId); + setNotice("已请 Codex 评审未提交改动"); + } catch (err) { + setError(err instanceof Error ? err.message : "评审失败"); + } + }, [client, sessionId]); + + const refreshAsks = useCallback(async () => { + if (!sessionId) { + return; + } + const asks = await client.listAsks(sessionId); + setState((current) => ({ ...current, asks })); + }, [client, sessionId]); + + const running = isLiveTurn(state.activeTurn?.status); + + return { + state, + error, + setError, + notice, + setNotice, + sending, + loading, + running, + send, + interrupt, + decide, + expire, + applySettings, + runCommand, + attachMention, + attachImage, + rename, + fork, + archive, + compact, + review, + refreshAsks, + }; +} + +function isEmptyThread(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return msg.includes("not materialized") || msg.includes("includeTurns") || msg.includes("thread not loaded"); +} diff --git a/packages/views/codex/index.ts b/packages/views/codex/index.ts new file mode 100644 index 0000000..5417867 --- /dev/null +++ b/packages/views/codex/index.ts @@ -0,0 +1,5 @@ +export { CodexPane } from "./codex-pane.tsx"; +export { CodexProvider, useCodex } from "./provider.tsx"; +export { useCodexCatalog } from "./hooks/use-codex-catalog.ts"; +export { useCodexSession } from "./hooks/use-session.ts"; +export { useCodexSessionList } from "./hooks/use-session-list.ts"; diff --git a/packages/views/codex/lib/format.ts b/packages/views/codex/lib/format.ts new file mode 100644 index 0000000..616f5ac --- /dev/null +++ b/packages/views/codex/lib/format.ts @@ -0,0 +1,46 @@ +export function shortId(id: string): string { + return id.replace(/-/g, "").slice(0, 8); +} + +export function sessionTitle(session: { id: string; title?: string; preview?: string }): string { + const text = session.title?.trim() || session.preview?.trim(); + if (text) { + return text.length > 36 ? `${text.slice(0, 36)}…` : text; + } + return `对话 ${shortId(session.id)}`; +} + +export function formatStamp(value?: number): string { + if (!value) { + return ""; + } + const ms = value > 1e11 ? value : value * 1000; + const then = new Date(ms).getTime(); + if (Number.isNaN(then)) { + return ""; + } + const minutes = Math.floor((Date.now() - then) / 60_000); + if (minutes < 1) { + return "刚刚"; + } + if (minutes < 60) { + return `${minutes} 分钟前`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours} 小时前`; + } + return new Date(ms).toLocaleDateString(); +} + +export function shortWorkspace(path: string, max = 42): string { + const trimmed = path.trim(); + if (!trimmed || trimmed.length <= max) { + return trimmed; + } + const parts = trimmed.split(/[/\\]/).filter(Boolean); + if (parts.length >= 2) { + return `…/${parts.slice(-2).join("/")}`; + } + return `…${trimmed.slice(-(max - 1))}`; +} diff --git a/packages/views/codex/prompt-bar.tsx b/packages/views/codex/prompt-bar.tsx new file mode 100644 index 0000000..2cf134c --- /dev/null +++ b/packages/views/codex/prompt-bar.tsx @@ -0,0 +1,230 @@ +"use client"; + +import type { CommandSpec, InputMode, ModeInfo, ModelInfo, Settings } from "@codedock/core/codex"; +import { + Button, + PromptInput, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + isImeConfirm, +} from "@codedock/ui"; +import { Plus } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; + +import type { PickedLocalFile, PickFilesOptions } from "../provider.tsx"; +import { ModePermissionMenu, ModelEffortMenu } from "./composer-menus.tsx"; + +export function CodexPromptBar({ + running, + sending, + sessionId, + workspace, + pickFiles, + commands, + models, + modes, + settings, + onSend, + onCancel, + onCommand, + onAttach, + onAttachError, + onApply, + onCompact, +}: { + running: boolean; + sending: boolean; + sessionId?: string; + workspace?: string; + pickFiles?: (options?: PickFilesOptions) => Promise; + commands: CommandSpec[]; + models: ModelInfo[]; + modes: ModeInfo[]; + settings: Settings; + onSend: (text: string, mode: InputMode) => Promise; + onCancel: () => Promise; + onCommand: (name: string, args: string) => Promise; + onAttach: (files: PickedLocalFile[]) => Promise; + onAttachError?: (message: string) => void; + onApply: (patch: Settings) => void; + onCompact: () => Promise; +}) { + const [text, setText] = useState(""); + const [queue, setQueue] = useState(false); + const [attached, setAttached] = useState([]); + const [picking, setPicking] = useState(false); + const inputRef = useRef(null); + const keepFocus = () => { + inputRef.current?.focus(); + }; + const slash = useMemo(() => { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) { + return [] as CommandSpec[]; + } + const token = trimmed.slice(1).split(/\s/)[0] ?? ""; + return commands.filter((item) => item.name.startsWith(token)); + }, [commands, text]); + + return ( +
+ {slash.length > 0 ? ( +
+ {slash.map((item) => ( + + ))} +
+ ) : null} + {attached.length > 0 ? ( +
+ {attached.map((file) => ( + + {file.name} + + ))} +
+ ) : null} + { + const next = message.text.trim(); + if (!next) { + return; + } + if (next.startsWith("/")) { + const [name, ...rest] = next.slice(1).split(/\s+/); + if (name) { + setText(""); + keepFocus(); + try { + await onCommand(name, rest.join(" ")); + } finally { + keepFocus(); + } + return; + } + } + setText(""); + setAttached([]); + keepFocus(); + try { + await onSend(next, queue || running ? "queue" : "start"); + } finally { + keepFocus(); + } + }} + > +
+ { + if (!pickFiles || picking) { + return; + } + setPicking(true); + try { + const files = await pickFiles({ + multiple: true, + start: workspace?.trim() || settings.cwd, + }); + if (files.length === 0) { + return; + } + await onAttach(files); + setAttached((current) => mergeAttached(current, files)); + } catch (err) { + onAttachError?.(err instanceof Error ? err.message : "挂文件失败"); + } finally { + setPicking(false); + keepFocus(); + } + }} + /> + setText(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.shiftKey || isImeConfirm(event)) { + return; + } + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + }} + /> +
+ + + + + + + {running ? ( + + ) : null} + + + +
+
+ ); +} + +function AttachButton({ + disabled, + picking, + onPick, +}: { + disabled: boolean; + picking: boolean; + onPick: () => Promise; +}) { + return ( +
+ +
+ ); +} + +function mergeAttached(current: PickedLocalFile[], next: PickedLocalFile[]): PickedLocalFile[] { + const seen = new Set(current.map((file) => file.path)); + const extra = next.filter((file) => !seen.has(file.path)); + return extra.length === 0 ? current : [...current, ...extra]; +} diff --git a/packages/views/codex/provider.tsx b/packages/views/codex/provider.tsx new file mode 100644 index 0000000..8ce114a --- /dev/null +++ b/packages/views/codex/provider.tsx @@ -0,0 +1,22 @@ +"use client"; + +import type { CodexClient } from "@codedock/core/codex"; +import { createContext, useContext, type ReactNode } from "react"; + +type CodexContextValue = { + client: CodexClient; +}; + +const CodexContext = createContext(null); + +export function CodexProvider({ client, children }: { client: CodexClient; children: ReactNode }) { + return {children}; +} + +export function useCodex(): CodexContextValue { + const ctx = useContext(CodexContext); + if (!ctx) { + throw new Error("useCodex must be used within CodexProvider"); + } + return ctx; +} diff --git a/packages/views/codex/timeline.tsx b/packages/views/codex/timeline.tsx new file mode 100644 index 0000000..0e79320 --- /dev/null +++ b/packages/views/codex/timeline.tsx @@ -0,0 +1,160 @@ +"use client"; + +import type { CodexViewState, TimelineItem } from "@codedock/core/codex"; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + Message, + MessageContent, + MessageResponse, + Reasoning, + ReasoningContent, + ReasoningTrigger, + Tool, + ToolContent, + ToolHeader, + ToolOutput, +} from "@codedock/ui"; +import { GitFork } from "lucide-react"; + +export function CodexTimeline({ + state, + loading = false, + scrollKey, + canFork = false, + onFork, +}: { + state: CodexViewState; + loading?: boolean; + scrollKey?: string; + canFork?: boolean; + onFork?: () => Promise; +}) { + const items = state.items.filter((item) => item.kind !== "turn" || Boolean(item.text)); + if (items.length === 0) { + if (loading) { + return ( + + + + ); + } + return ( + + + + ); + } + return ( + + + {items.map((item, index) => ( + + ))} + + + ); +} + +function TimelineRow({ + item, + canFork, + onFork, +}: { + item: TimelineItem; + canFork: boolean; + onFork?: () => Promise; +}) { + switch (item.kind) { + case "user": + return ( +
+ + +

{item.text}

+
+
+ +
+ ); + case "text": + return ( +
+ + + {item.text || (item.streaming ? "…" : "")} + + + +
+ ); + case "reasoning": + return ( + + + {item.text || "思考中"} + + ); + case "command": + return ( + + + + + + + ); + case "file_change": + return ( +
+
改文件 {item.paths?.join(", ")}
+ {item.diff ? ( +
{item.diff}
+ ) : null} +
+ ); + case "plan": + return
{item.text}
; + case "notice": + case "turn": + return
{item.text}
; + default: + return null; + } +} + +function ForkAction({ + align, + disabled, + onFork, +}: { + align: "start" | "end"; + disabled: boolean; + onFork?: () => Promise; +}) { + if (!onFork) { + return null; + } + return ( +
+ +
+ ); +} diff --git a/packages/views/index.ts b/packages/views/index.ts index 087a7eb..1fa23fb 100644 --- a/packages/views/index.ts +++ b/packages/views/index.ts @@ -1,10 +1,12 @@ export { AgentProvider, useAgent, - type DirectoryEntry, - type DirectoryListing, + type PickedLocalFile, + type PickDirectoryOptions, + type PickFilesOptions, } from "./provider.tsx"; export { GitPage, GitProvider, useGit, type GitPageProps } from "./git/index.ts"; +export { CodexPane, CodexProvider, useCodex } from "./codex/index.ts"; export { ChatPage, ConversationTimeline, @@ -13,4 +15,6 @@ export { useSessionList, useSessionTimeline, type ChatPageProps, + type SessionEngine, + type SidebarSession, } from "./chat/index.ts"; diff --git a/packages/views/package.json b/packages/views/package.json index 6732b6f..74a41ec 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./index.ts", "./chat": "./chat/index.ts", - "./git": "./git/index.ts" + "./git": "./git/index.ts", + "./codex": "./codex/index.ts" }, "dependencies": { "@codedock/core": "workspace:*", diff --git a/packages/views/provider.tsx b/packages/views/provider.tsx index 8e264ba..d92a8d2 100644 --- a/packages/views/provider.tsx +++ b/packages/views/provider.tsx @@ -3,21 +3,26 @@ import type { AgentClient } from "@codedock/core/chat"; import { createContext, useContext, type ReactNode } from "react"; -export type DirectoryEntry = { - name: string; +export type PickedLocalFile = { path: string; + name: string; }; -export type DirectoryListing = { - path: string; - parent?: string; - entries: DirectoryEntry[]; +export type PickFilesOptions = { + images?: boolean; + multiple?: boolean; + start?: string; +}; + +export type PickDirectoryOptions = { + start?: string; }; type AgentContextValue = { client: AgentClient; userId: string; - listDirectories?: (path?: string) => Promise; + pickDirectory?: (options?: PickDirectoryOptions) => Promise; + pickFiles?: (options?: PickFilesOptions) => Promise; }; const AgentContext = createContext(null); @@ -25,16 +30,18 @@ const AgentContext = createContext(null); export function AgentProvider({ client, userId, - listDirectories, + pickDirectory, + pickFiles, children, }: { client: AgentClient; userId: string; - listDirectories?: (path?: string) => Promise; + pickDirectory?: (options?: PickDirectoryOptions) => Promise; + pickFiles?: (options?: PickFilesOptions) => Promise; children: ReactNode; }) { return ( - + {children} ); diff --git a/server/internal/codex/catalog.go b/server/internal/codex/catalog.go index 6c9303e..3bda941 100644 --- a/server/internal/codex/catalog.go +++ b/server/internal/codex/catalog.go @@ -2,24 +2,19 @@ package codex import ( "context" - "strings" cderr "codedock/internal/errors" pkg "codedock/pkg/codex" ) -// Probe 体检本机 Codex:装没装、版本、有没有授权。 +// Probe 体检本机 Codex:装没装、版本、有没有授权。版本读不到就留空,不单独报错。 func (rt *Runtime) Probe(ctx context.Context) (pkg.EngineStatus, error) { path, err := rt.lookPath(rt.bin) if err != nil { return pkg.EngineStatus{Hint: "本机没有安装 Codex CLI。"}, nil } - out, err := rt.version(ctx, path) - version := ParseVersion(out) - if err != nil && version == "" { - return pkg.EngineStatus{Available: true, Version: strings.TrimSpace(out), Hint: "无法读取 Codex 版本。"}, nil - } - status := pkg.EngineStatus{Available: true, Version: version} + out, _ := rt.version(ctx, path) + status := pkg.EngineStatus{Available: true, Version: ParseVersion(out)} client, err := rt.ensureClient(ctx) if err != nil { status.Hint = err.Error() diff --git a/server/internal/codex/runtime_test.go b/server/internal/codex/runtime_test.go index 39be36d..6368ade 100644 --- a/server/internal/codex/runtime_test.go +++ b/server/internal/codex/runtime_test.go @@ -366,7 +366,7 @@ func TestRuntimeHandshakeStartAndVersionErrors(t *testing.T) { }) defer rt.Close() status, err := rt.Probe(ctx) - if err != nil || !status.Available { + if err != nil || !status.Available || status.Version != "" { t.Fatal(status, err) } diff --git a/server/internal/codex/session.go b/server/internal/codex/session.go index ef5f426..eb09f08 100644 --- a/server/internal/codex/session.go +++ b/server/internal/codex/session.go @@ -2,6 +2,7 @@ package codex import ( "context" + "strings" cderr "codedock/internal/errors" pkg "codedock/pkg/codex" @@ -30,9 +31,30 @@ func (rt *Runtime) ListSessions(ctx context.Context, archived bool, cursor strin st.mu.Unlock() out.Sessions = append(out.Sessions, pkg.MapThread(th, archived, active)) } + out.Sessions = dedupeSessions(out.Sessions) return out, nil } +// dedupeSessions 去掉官方 thread/list 里同一 thread_id 的重复行,留下更新时间最新的一条。 +func dedupeSessions(in []pkg.Session) []pkg.Session { + if len(in) < 2 { + return in + } + index := make(map[string]int, len(in)) + out := make([]pkg.Session, 0, len(in)) + for _, sess := range in { + if i, ok := index[sess.ID]; ok { + if sess.UpdatedAt >= out[i].UpdatedAt { + out[i] = sess + } + continue + } + index[sess.ID] = len(out) + out = append(out, sess) + } + return out +} + // CreateSession 向 Codex 开一条 thread,session_id 即 thread_id。 func (rt *Runtime) CreateSession(ctx context.Context, settings pkg.Settings) (pkg.Session, error) { client, err := rt.requireReady(ctx) @@ -64,7 +86,13 @@ func (rt *Runtime) GetSession(ctx context.Context, sessionID string) (pkg.Sessio return pkg.Session{}, nil, err } res, err := client.ThreadRead(ctx, pkg.ThreadReadParams{ThreadID: sessionID, IncludeTurns: true}) + if err != nil && includeTurnsUnavailable(err) { + res, err = client.ThreadRead(ctx, pkg.ThreadReadParams{ThreadID: sessionID}) + } if err != nil { + if emptyThread(err) { + return stubSession(rt, sessionID), nil, nil + } return pkg.Session{}, nil, mapRPC(err) } st := rt.state(sessionID) @@ -206,3 +234,34 @@ func (rt *Runtime) takeDraft(sessionID string, content string, extra pkg.Input) in.Images = append(in.Images, extra.Images...) return in } + +func includeTurnsUnavailable(err error) bool { + return emptyThread(err) +} + +func emptyThread(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "includeturns") || + strings.Contains(msg, "not materialized") || + strings.Contains(msg, "thread not loaded") +} + +func stubSession(rt *Runtime, sessionID string) pkg.Session { + st := rt.state(sessionID) + st.mu.Lock() + defer st.mu.Unlock() + active := "" + if st.active != nil { + active = st.active.ID + } + return pkg.Session{ + ID: sessionID, + ThreadID: sessionID, + Cwd: st.settings.Cwd, + ActiveTurnID: active, + Archived: st.archived, + } +} diff --git a/server/internal/codex/session_test.go b/server/internal/codex/session_test.go new file mode 100644 index 0000000..657d337 --- /dev/null +++ b/server/internal/codex/session_test.go @@ -0,0 +1,25 @@ +package codex + +import ( + "testing" + + pkg "codedock/pkg/codex" +) + +func TestDedupeSessionsKeepsNewest(t *testing.T) { + got := dedupeSessions([]pkg.Session{ + {ID: "a", Preview: "old", UpdatedAt: 1}, + {ID: "b", Preview: "only", UpdatedAt: 2}, + {ID: "a", Preview: "new", UpdatedAt: 3}, + {ID: "a", Preview: "mid", UpdatedAt: 2}, + }) + if len(got) != 2 { + t.Fatalf("len=%d", len(got)) + } + if got[0].ID != "a" || got[0].Preview != "new" { + t.Fatalf("first %+v", got[0]) + } + if got[1].ID != "b" { + t.Fatalf("second %+v", got[1]) + } +} diff --git a/server/pkg/codex/catalog.go b/server/pkg/codex/catalog.go index d8efd0b..9a70eb0 100644 --- a/server/pkg/codex/catalog.go +++ b/server/pkg/codex/catalog.go @@ -4,7 +4,7 @@ package codex type EngineStatus struct { Available bool `json:"available"` // 本机装没装 Codex。 Authorized bool `json:"authorized"` // 有没有取得 Codex 授权;没授权可以看选项,但不能开回合。 - Version string `json:"version"` + Version string `json:"version,omitempty"` Hint string `json:"hint,omitempty"` // 不可用时给人看的原因,如未安装或未授权。 } diff --git a/server/pkg/codex/codex_test.go b/server/pkg/codex/codex_test.go index b7c093e..42a8d6d 100644 --- a/server/pkg/codex/codex_test.go +++ b/server/pkg/codex/codex_test.go @@ -450,6 +450,13 @@ func TestClientRPCWrappers(t *testing.T) { } func TestParseModelAndAskEdges(t *testing.T) { + info, err := ParseModel(json.RawMessage(`{"id":"gpt-5.6-sol","displayName":"GPT-5.6-Sol","defaultReasoningEffort":"low","supportedReasoningEfforts":[{"reasoningEffort":"low","description":"Fast"},{"reasoningEffort":"medium","description":"Default"},{"reasoningEffort":"xhigh","description":"Max"}],"isDefault":true}`)) + if err != nil || info.ID != "gpt-5.6-sol" || info.DefaultEffort != "low" { + t.Fatal(info, err) + } + if strings.Join(info.Efforts, ",") != "low,medium,xhigh" { + t.Fatalf("efforts=%v", info.Efforts) + } if _, err := ParseModel(json.RawMessage(`{`)); err == nil { t.Fatal("bad model") } diff --git a/server/pkg/codex/rpc.go b/server/pkg/codex/rpc.go index 648f96d..8253d5c 100644 --- a/server/pkg/codex/rpc.go +++ b/server/pkg/codex/rpc.go @@ -346,17 +346,8 @@ func ParseModel(raw json.RawMessage) (ModelInfo, error) { } efforts := make([]string, 0, len(row.SupportedReasoningEfforts)) for _, item := range row.SupportedReasoningEfforts { - switch v := item.(type) { - case string: - if v != "" { - efforts = append(efforts, v) - } - case map[string]any: - if s, _ := v["effort"].(string); s != "" { - efforts = append(efforts, s) - } else if s, _ := v["id"].(string); s != "" { - efforts = append(efforts, s) - } + if s := effortID(item); s != "" { + efforts = append(efforts, s) } } return ModelInfo{ @@ -369,6 +360,20 @@ func ParseModel(raw json.RawMessage) (ModelInfo, error) { }, nil } +func effortID(item any) string { + switch v := item.(type) { + case string: + return v + case map[string]any: + for _, key := range []string{"reasoningEffort", "effort", "id"} { + if s, _ := v[key].(string); s != "" { + return s + } + } + } + return "" +} + // ParsePermissionProfile 从 permissionProfile/list 的一条里取出 ModeInfo。 func ParsePermissionProfile(raw json.RawMessage) (ModeInfo, error) { var row struct { From 1590502b58cc85682df021b695337eac39b434b1 Mon Sep 17 00:00:00 2001 From: 2penheimer <2603237065@qq.com> Date: Thu, 17 Sep 2026 10:05:18 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A5=BD=20Codex=20?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E5=B9=B6=E8=A1=A5=E7=94=A8=E9=87=8F=E4=B8=8E?= =?UTF-8?q?=E6=84=8F=E5=9B=BE=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史会话先 resume 再写,回合带上生效模型;压缩显示剩余窗口;侧栏只留归档;问票选项展开后可改写。 Co-authored-by: Cursor --- "docs/Codex\345\257\271\346\216\245.md" | 5 +- packages/core/codex/client.ts | 2 + packages/core/codex/index.ts | 3 + packages/core/codex/reducer.test.ts | 12 +- packages/core/codex/reducer.ts | 8 + packages/core/codex/types.ts | 27 ++- packages/ui/components/conversation.tsx | 41 ++-- packages/views/chat/chat-page.tsx | 42 ++-- packages/views/chat/conversation-timeline.tsx | 6 +- packages/views/chat/session-sidebar.tsx | 90 +------- packages/views/codex/ask-dock.tsx | 200 +++++++++++++++--- packages/views/codex/codex-pane.tsx | 4 +- packages/views/codex/composer-menus.tsx | 47 +++- .../views/codex/hooks/use-codex-catalog.ts | 11 + packages/views/codex/hooks/use-session.ts | 7 +- packages/views/codex/lib/format.ts | 23 ++ packages/views/codex/prompt-bar.tsx | 34 ++- packages/views/codex/timeline.tsx | 151 +++++++++++-- server/internal/codex/catalog.go | 3 + server/internal/codex/fake.go | 34 ++- server/internal/codex/runtime.go | 18 ++ server/internal/codex/runtime_test.go | 23 ++ server/internal/codex/session.go | 78 ++++++- server/internal/codex/session_test.go | 75 +++++++ server/internal/codex/turn.go | 33 ++- server/internal/handler/codex/api.go | 6 +- server/pkg/codex/approval.go | 50 +++-- server/pkg/codex/ask.go | 114 ++++++++-- server/pkg/codex/catalog.go | 50 +++++ server/pkg/codex/client.go | 8 +- server/pkg/codex/client_test.go | 7 + server/pkg/codex/codex_test.go | 64 +++++- server/pkg/codex/event.go | 2 + server/pkg/codex/harness.go | 16 +- server/pkg/codex/protocol.go | 2 + server/pkg/codex/rpc.go | 23 +- server/pkg/codex/usage.go | 116 ++++++++++ 37 files changed, 1168 insertions(+), 267 deletions(-) create mode 100644 server/pkg/codex/usage.go diff --git "a/docs/Codex\345\257\271\346\216\245.md" "b/docs/Codex\345\257\271\346\216\245.md" index 1456e0c..e2c0e85 100644 --- "a/docs/Codex\345\257\271\346\216\245.md" +++ "b/docs/Codex\345\257\271\346\216\245.md" @@ -6,7 +6,7 @@ - 发现本机是否装了 Codex、是否已取得授权、有哪些模型与模式 - 一个对话只走 Codex,`session_id` 即官方 `thread_id` -- 只把用户改过的模型、推理强度、Plan、权限交给 Codex +- 回合带上当前生效的模型与推理强度(本机配置或用户改过的);Plan、权限仍只传用户改过的 - `/` 命令与官方扩展按钮共用同一套动作 - 给本条消息挂文件提及或图片 - 发送、排队、手动打断;正忙时不自动插话 @@ -14,6 +14,7 @@ - 回放正文、推理、命令、改文件、方案 - 回答跑命令、改文件、补一句、MCP 表单、额外权限 - 认不出的官方反问立刻按 `-32601` 回包,避免转圈 +- 压缩按钮显示官方 `thread/tokenUsage/updated` 的剩余上下文(进程内存 + SSE,不入库) ## 边界 @@ -32,5 +33,5 @@ - `internal/codex`:本机进程与内存编排 - `internal/handler/codex`:独立 `/codex/*` HTTP - `packages/core/codex`:无头 `CodexClient` 与 SSE/reducer -- `packages/views/codex`:由 `ChatPage` 在 Codex 模式下组合(输入栏设置、时间线、问票、prompt);归档在侧栏顶部 +- `packages/views/codex`:由 `ChatPage` 在 Codex 模式下组合(输入栏设置、时间线、问票、prompt);归档在侧栏每条会话右侧 - `apps/web`:对话页 `/` 与 `/s/c/:id` 装配 `CodexClient`,不单独开 Codex 页 diff --git a/packages/core/codex/client.ts b/packages/core/codex/client.ts index 2895677..0ad180c 100644 --- a/packages/core/codex/client.ts +++ b/packages/core/codex/client.ts @@ -83,11 +83,13 @@ export class CodexClient { session: Session; progress?: SessionDetail["progress"]; asks?: ApprovalAsk[]; + usage?: SessionDetail["usage"]; }>(`/codex/sessions/${sessionId}`, { signal }); return { session: body.session, progress: body.progress ?? [], asks: body.asks ?? [], + usage: body.usage, }; } diff --git a/packages/core/codex/index.ts b/packages/core/codex/index.ts index 365ea69..0dec36c 100644 --- a/packages/core/codex/index.ts +++ b/packages/core/codex/index.ts @@ -11,6 +11,8 @@ export type { ApprovalAsk, AskAnswer, AskKind, + AskOption, + AskQuestion, CodexEvent, CodexEventType, CodexViewState, @@ -31,6 +33,7 @@ export type { Settings, StartTurnRequest, TimelineItem, + TokenUsage, Turn, TurnStatus, } from "./types.ts"; diff --git a/packages/core/codex/reducer.test.ts b/packages/core/codex/reducer.test.ts index 6143cfa..4a36606 100644 --- a/packages/core/codex/reducer.test.ts +++ b/packages/core/codex/reducer.test.ts @@ -14,6 +14,16 @@ test("hydrateCodex and applyCodexEvent cover turn, progress, ask, reset", () => assert.equal(state.items[0]?.text, "hi"); assert.equal(state.activeTurn?.id, "t1"); assert.equal(state.settings.model, "gpt-5.6"); + assert.equal(state.usage, undefined); + + state = applyCodexEvent(state, { + seq: 8, + type: "token.usage", + session_id: "th", + usage: { used: 250, window: 1000 }, + }); + assert.equal(state.usage?.used, 250); + assert.equal(state.usage?.window, 1000); state = applyCodexEvent(state, { seq: 1, @@ -66,7 +76,7 @@ test("hydrateCodex and applyCodexEvent cover turn, progress, ask, reset", () => notice: "event gap", }); assert.equal(state.reset, true); - assert.equal(state.lastSeq, 7); + assert.equal(state.lastSeq, 8); }); test("parseSSEBlock reads Codex event data", () => { diff --git a/packages/core/codex/reducer.ts b/packages/core/codex/reducer.ts index 90e99f7..5fda778 100644 --- a/packages/core/codex/reducer.ts +++ b/packages/core/codex/reducer.ts @@ -18,6 +18,7 @@ export function emptyCodexState(): CodexViewState { lastSeq: 0, activeTurn: null, reset: false, + usage: undefined, }; } @@ -26,6 +27,7 @@ export function hydrateCodex(detail: { progress: Progress[]; asks: ApprovalAsk[]; settings?: Settings; + usage?: CodexViewState["usage"]; }): CodexViewState { return { session: detail.session, @@ -41,6 +43,7 @@ export function hydrateCodex(detail: { } : null, reset: false, + usage: detail.usage, }; } @@ -104,6 +107,11 @@ export function applyCodexEvent(state: CodexViewState, event: CodexEvent): Codex next.items.push(noticeItem(event.notice, event.seq)); } return next; + case "token.usage": + if (event.usage) { + next.usage = event.usage; + } + return next; default: return next; } diff --git a/packages/core/codex/types.ts b/packages/core/codex/types.ts index c312a5c..1a6c7f0 100644 --- a/packages/core/codex/types.ts +++ b/packages/core/codex/types.ts @@ -112,6 +112,20 @@ export type AskKind = "command" | "file_change" | "question" | "form" | "permiss export type DecisionScope = "once" | "session"; +export type AskOption = { + id?: string; + label: string; + recommended?: boolean; + other?: boolean; +}; + +export type AskQuestion = { + id?: string; + header?: string; + prompt?: string; + options?: AskOption[]; +}; + export type ApprovalAsk = { id: string; kind: AskKind; @@ -123,6 +137,7 @@ export type ApprovalAsk = { diff?: string; prompt?: string; options?: string[]; + questions?: AskQuestion[]; fields?: string[]; external_request_id: string; }; @@ -132,6 +147,7 @@ export type AskAnswer = { scope?: DecisionScope; choice?: string; values?: string[]; + answers?: Record; }; export type CodexEventType = @@ -144,7 +160,13 @@ export type CodexEventType = | "ask.required" | "ask.resolved" | "notice" - | "reset"; + | "reset" + | "token.usage"; + +export type TokenUsage = { + used: number; + window: number; +}; export type CodexEvent = { seq: number; @@ -155,12 +177,14 @@ export type CodexEvent = { turn?: Turn; ask?: ApprovalAsk; notice?: string; + usage?: TokenUsage; }; export type SessionDetail = { session: Session; progress: Progress[]; asks: ApprovalAsk[]; + usage?: TokenUsage; }; export type TimelineItem = { @@ -183,6 +207,7 @@ export type CodexViewState = { lastSeq: number; activeTurn: Turn | null; reset: boolean; + usage?: TokenUsage; }; export const LIVE_TURN_STATUSES: readonly TurnStatus[] = ["queued", "running", "waiting_approval"]; diff --git a/packages/ui/components/conversation.tsx b/packages/ui/components/conversation.tsx index 3a0434c..7b9b92d 100644 --- a/packages/ui/components/conversation.tsx +++ b/packages/ui/components/conversation.tsx @@ -18,12 +18,10 @@ import { Button } from "./ui/button.tsx"; const LATEST_ANCHOR = 0.7; /** 新消息入列后再滚到锚点的时长。 */ const FOLLOW_MS = 200; -/** 流式生成时按这个间隔把最新内容拉回锚点。 */ -const STREAM_FOLLOW_MS = 500; export function Conversation({ className, children, ...props }: HTMLAttributes) { return ( -
+
{children}
); @@ -117,24 +115,33 @@ export function ConversationContent({ useLayoutEffect(() => { applyPad(); + }); + + useLayoutEffect(() => { if (!skipFollow.current) { return; } goToLatest(0); - }); + skipFollow.current = false; + }, [goToLatest, scrollKey]); useEffect(() => { const el = ref.current; + const inner = innerRef.current; if (!el) { return; } - const ro = new ResizeObserver(() => { + const sync = () => { applyPad(); if (following.current && !animating.current) { el.scrollTop = targetTop(streaming ? "bottom" : "top"); } - }); + }; + const ro = new ResizeObserver(sync); ro.observe(el); + if (inner) { + ro.observe(inner); + } return () => ro.disconnect(); }, [applyPad, streaming, targetTop]); @@ -155,19 +162,7 @@ export function ConversationContent({ cancelAnimationFrame(inner); stopAnim(); }; - }, [followKey, goToLatest, stopAnim]); - - useEffect(() => { - if (!streaming) { - return; - } - const timer = window.setInterval(() => { - if (following.current) { - goToLatest(FOLLOW_MS, "bottom"); - } - }, STREAM_FOLLOW_MS); - return () => window.clearInterval(timer); - }, [goToLatest, streaming]); + }, [followKey, goToLatest, scrollKey, stopAnim]); useEffect(() => { const ended = wasStreaming.current && !streaming; @@ -198,10 +193,14 @@ export function ConversationContent({ onPointerDown={() => { stopAnim(); }} - className="mx-auto min-h-0 w-full max-w-3xl flex-1 overflow-y-auto" + className="min-h-0 w-full flex-1 overflow-y-auto" data-conversation-scroll="" > -
+
{children}
diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index 596bc77..64a4275 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -110,6 +110,20 @@ export function ChatPage({ } }; + const hideSession = async (session: SidebarSession) => { + const hiddenId = session.id; + const hiddenEngine = session.engine ?? "agent"; + if (session.engine === "codex") { + await codexClient.archiveSession(session.id); + await codexList.refresh(); + } else { + await list.removeSession(session); + } + if (sessionId === hiddenId && (engine ?? "agent") === hiddenEngine) { + onNewConversation(); + } + }; + return (
{ - const deletedId = session.id; - const deletedEngine = session.engine ?? "agent"; - if (session.engine === "codex") { - await codexClient.archiveSession(session.id); - await codexList.refresh(); - } else { - await list.removeSession(session); - } - if (sessionId === deletedId && (engine ?? "agent") === deletedEngine) { - onNewConversation(); - } - }} canRecoverCurrent={activeEngine === "agent" && timeline.canRecover} - canArchive={activeEngine === "codex" && Boolean(sessionId)} - onArchive={ - activeEngine === "codex" && sessionId - ? async () => { - await codexClient.archiveSession(sessionId); - await codexList.refresh(); - onNewConversation(); - } - : undefined - } + onArchive={async (session) => { + await hideSession(session); + }} brandSrc={brandSrc} /> -
+
{sessionId ? (activeEngine === "codex" ? "Codex 对话" : "对话") : "新对话"} diff --git a/packages/views/chat/conversation-timeline.tsx b/packages/views/chat/conversation-timeline.tsx index b51d060..e421d14 100644 --- a/packages/views/chat/conversation-timeline.tsx +++ b/packages/views/chat/conversation-timeline.tsx @@ -61,13 +61,13 @@ export function ConversationTimeline({ if (items.length === 0) { if (loading) { return ( - + ); } return ( - + ); @@ -86,7 +86,7 @@ export function ConversationTimeline({ ); return ( - + {sections.map((section, sectionIndex) => (
diff --git a/packages/views/chat/session-sidebar.tsx b/packages/views/chat/session-sidebar.tsx index 5d1d2fe..f7dcff5 100644 --- a/packages/views/chat/session-sidebar.tsx +++ b/packages/views/chat/session-sidebar.tsx @@ -2,8 +2,7 @@ import type { Session } from "@codedock/core/chat"; import { Button, cn } from "@codedock/ui"; -import { Archive, PlusIcon, Trash2Icon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Archive, PlusIcon } from "lucide-react"; import { relativeTime, sessionTitle, shortId } from "./lib/format.ts"; @@ -19,10 +18,8 @@ export function SessionSidebar({ onCreate, onSelect, onRecover, - onDelete, onArchive, canRecoverCurrent = false, - canArchive = false, brandSrc, }: { sessions: SidebarSession[]; @@ -34,27 +31,10 @@ export function SessionSidebar({ onCreate: () => void; onSelect: (id: string, engine?: "agent" | "codex") => void; onRecover?: (runId: string) => Promise; - onDelete?: (session: SidebarSession) => Promise; - onArchive?: () => Promise; + onArchive?: (session: SidebarSession) => Promise; canRecoverCurrent?: boolean; - canArchive?: boolean; brandSrc?: string; }) { - const [pending, setPending] = useState(null); - - useEffect(() => { - if (!pending) { - return; - } - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape" && !busy) { - setPending(null); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [busy, pending]); - return (
@@ -162,54 +134,6 @@ export function SessionSidebar({ ) : null} - {pending && onDelete ? ( -
{ - if (!busy) { - setPending(null); - } - }} - > -
event.stopPropagation()} - > -

- 删除对话 -

-

- 确定删除「{sessionTitle(pending.id, pending.summary)}」吗?删除后将从列表中移除。 - {pending.active_run_id ? " 当前任务会先中止。" : ""} -

-
- - -
-
-
- ) : null} ); } diff --git a/packages/views/codex/ask-dock.tsx b/packages/views/codex/ask-dock.tsx index 67aef51..b1e7ca9 100644 --- a/packages/views/codex/ask-dock.tsx +++ b/packages/views/codex/ask-dock.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ApprovalAsk, AskAnswer, DecisionScope } from "@codedock/core/codex"; +import type { ApprovalAsk, AskAnswer, AskOption, AskQuestion, DecisionScope } from "@codedock/core/codex"; import { Button, Confirmation, @@ -8,8 +8,9 @@ import { ConfirmationActions, ConfirmationRequest, ConfirmationTitle, + cn, } from "@codedock/ui"; -import { useState } from "react"; +import { useMemo, useState } from "react"; export function CodexAskDock({ asks, @@ -41,10 +42,12 @@ function AskCard({ onDecide: (requestId: string, answer: AskAnswer) => Promise; onExpire: (requestId: string) => Promise; }) { + const questions = useMemo(() => questionsOf(ask), [ask]); const [busy, setBusy] = useState(false); const [scope, setScope] = useState("once"); const [choice, setChoice] = useState(ask.options?.[0] ?? ""); const [values, setValues] = useState(ask.fields?.map(() => "") ?? []); + const [answers, setAnswers] = useState>(() => emptyAnswers(questions)); const run = async (fn: () => Promise) => { setBusy(true); @@ -55,35 +58,35 @@ function AskCard({ } }; + const requestId = ask.id || ask.external_request_id; + const canAllow = + ask.kind !== "question" || + questions.every((question) => answers[questionKey(question, questions.indexOf(question))]?.trim()); + return ( - {titleFor(ask)} + {titleFor(ask, questions)} {ask.command ?
{ask.command}
: null} - {ask.prompt ?

{ask.prompt}

: null} + {ask.kind !== "question" && ask.prompt ?

{ask.prompt}

: null} {ask.paths?.length ?

{ask.paths.join("\n")}

: null} {ask.diff ?
{ask.diff}
: null} - {ask.kind === "question" && ask.options?.length ? ( - - ) : null} - {ask.kind === "question" && !ask.options?.length ? ( - setChoice(event.target.value)} - /> - ) : null} + {ask.kind === "question" + ? questions.map((question, index) => ( + { + const key = questionKey(question, index); + setAnswers((current) => ({ ...current, [key]: next })); + }} + /> + )) + : null} {ask.kind === "form" ? (ask.fields ?? []).map((field, index) => (