Skip to content

Commit 0695e91

Browse files
committed
refactor: 重构run_check命令,支持无项目上下文的健康检查
1. 调整run_check流程,无项目配置时返回友好提示而非报错 2. 拆分健康检查和完整验证逻辑,分离项目上下文依赖 3. 优化错误提示信息,区分不同场景下的报错内容 style: 简化tool_call_output的content提取逻辑 refactor: 重构early_stream_failed_response的事件发送顺序 perf: 调整opencode adapter认证超时时间至5秒 test: 新增多项目管理相关测试用例 test: 修复并新增工具调用输出内容校验测试 docs: 更新文档中的超时配置示例
1 parent 05b672a commit 0695e91

12 files changed

Lines changed: 513 additions & 38 deletions

config.toml.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ stream_idle_timeout_ms = 120000
1919
[model_providers.opencode_go_adapter.auth]
2020
command = "codex-opencode-adapter"
2121
args = ["auth", "print-local-token"]
22-
timeout_ms = 1000
22+
timeout_ms = 5000
2323

2424
[agents.oss_mimo]
2525
description = "OpenCode Go MiMo worker"

docs/USAGE.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ codex-opencode-adapter init --api-key "<你的 OpenCode Go API Key>"
9292
[model_providers.opencode_go_adapter.auth]
9393
command = "codex-opencode-adapter"
9494
args = ["auth", "print-local-token"]
95-
timeout_ms = 1000
95+
timeout_ms = 5000
9696
```
9797

9898
[config.toml.example](../config.toml.example) 是合并模板,不应整份复制成项目

scripts/install-user-provider.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ stream_idle_timeout_ms = 120000
2222
[model_providers.opencode_go_adapter.auth]
2323
command = "codex-opencode-adapter"
2424
args = ["auth", "print-local-token"]
25-
timeout_ms = 1000
25+
timeout_ms = 5000
2626
"@
2727
exit 0
2828
}

src/conversion/responses_to_chat.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,12 +319,8 @@ fn extract_request_with_context(
319319
"tool output item received in Responses input"
320320
);
321321
flush_pending(&mut messages, &mut pending_calls);
322-
let content = if kind == "function_call_output" {
323-
let empty = Value::String(String::new());
324-
as_text(obj.get("output").unwrap_or(&empty))
325-
} else {
326-
compact_json(&Value::Object(obj.clone()))
327-
};
322+
let empty = Value::String(String::new());
323+
let content = as_text(obj.get("output").unwrap_or(&empty));
328324
tool_outputs.push(json!({
329325
"role":"tool",
330326
"tool_call_id":call_id,

src/init.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ fn build_global_codex_config(path: &Path, port: u16) -> anyhow::Result<String> {
174174
args.push("auth");
175175
args.push("print-local-token");
176176
auth["args"] = Item::Value(args.into());
177-
auth["timeout_ms"] = value(1000);
177+
auth["timeout_ms"] = value(5000);
178178

179179
Ok(document.to_string())
180180
}

src/main.rs

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -126,32 +126,69 @@ async fn run_server(args: RunArgs) -> anyhow::Result<()> {
126126
}
127127

128128
async fn run_check() -> anyhow::Result<()> {
129-
let config = load_project_config(RunArgs::default())?;
130-
let raw_token = config
131-
.local_token
132-
.as_deref()
133-
.filter(|v| !v.is_empty())
134-
.ok_or_else(|| anyhow::anyhow!("CODEX_OPENCODE_LOCAL_TOKEN is missing"))?;
135-
let signed_token = sign_adapter_token(raw_token);
136-
137-
let base = format!("http://{}:{}", config.host, config.port);
138129
let client = reqwest::Client::new();
130+
131+
// Phase 1: Resolve project context for host/port/token.
132+
// If no project context is available, fall back to defaults for a basic health check.
133+
let (base, config) = match load_project_config(RunArgs::default()) {
134+
Ok(config) => {
135+
let base = format!("http://{}:{}", config.host, config.port);
136+
(base, Some(config))
137+
}
138+
Err(error) => {
139+
eprintln!("Warning: could not load project config: {error}");
140+
let base = format!("http://{}:{}", DEFAULT_HOST, DEFAULT_PORT);
141+
(base, None)
142+
}
143+
};
144+
145+
// Phase 2: Health check works without project context.
139146
let health = client
140147
.get(format!("{base}/health"))
141148
.send()
142149
.await
143150
.map_err(|_| {
144-
anyhow::anyhow!("Adapter is not running. Start it with 'codex-opencode-adapter run' or 'codex-opencode-adapter start'.")
151+
if config.is_some() {
152+
anyhow::anyhow!(
153+
"Adapter is not running at {base}. Start it with 'codex-opencode-adapter run' or 'codex-opencode-adapter start'."
154+
)
155+
} else {
156+
anyhow::anyhow!(
157+
"Could not reach adapter at {base}.\n\
158+
Either start the adapter, or run from a project directory / set CODEX_OPENCODE_PROJECT_ID\n\
159+
to check the correct host/port from your project configuration."
160+
)
161+
}
145162
})?;
146-
anyhow::ensure!(health.status().is_success(), "health check failed");
163+
anyhow::ensure!(health.status().is_success(), "health check failed at {base}");
164+
println!("\u{2713} Adapter health check passed at {base}");
165+
166+
// Phase 3: Models check requires project context.
167+
match config {
168+
Some(config) => {
169+
let raw_token = config
170+
.local_token
171+
.as_deref()
172+
.filter(|v| !v.is_empty())
173+
.ok_or_else(|| anyhow::anyhow!("CODEX_OPENCODE_LOCAL_TOKEN is missing in project config"))?;
174+
let signed_token = sign_adapter_token(raw_token);
175+
176+
let models = client
177+
.get(format!("{base}/v1/models"))
178+
.bearer_auth(&signed_token)
179+
.send()
180+
.await?;
181+
anyhow::ensure!(models.status().is_success(), "/v1/models check failed");
182+
println!("\u{2713} Models endpoint verified");
183+
println!("Adapter check passed.");
184+
}
185+
None => {
186+
println!("\u{2713} Adapter is reachable.");
187+
println!(" For full verification including /v1/models, run from a project directory");
188+
println!(" or set CODEX_OPENCODE_PROJECT_ID to your project ID.");
189+
}
190+
}
147191

148-
let models = client
149-
.get(format!("{base}/v1/models"))
150-
.bearer_auth(&signed_token)
151-
.send()
152-
.await?;
153-
anyhow::ensure!(models.status().is_success(), "/v1/models check failed");
154-
println!("Adapter check passed.");
155192
Ok(())
156193
}
157194

src/server.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -666,25 +666,40 @@ async fn admin_refresh(State(state): State<AppState>, headers: HeaderMap) -> Res
666666

667667
let mut projects = state.projects.write().unwrap();
668668
let mut added = Vec::new();
669-
let mut skipped = Vec::new();
669+
let mut already_loaded = Vec::new();
670+
let mut failed = Vec::new();
671+
672+
// Remove projects that no longer exist in the registry.
673+
let mut removed = Vec::new();
674+
let to_remove: Vec<String> = projects
675+
.keys()
676+
.filter(|id| !registry.projects.contains_key(id.as_str()))
677+
.cloned()
678+
.collect();
679+
for id in &to_remove {
680+
projects.remove(id);
681+
removed.push(id.clone());
682+
}
670683

671684
for (project_id, entry) in &registry.projects {
672685
if projects.contains_key(project_id) {
673-
skipped.push(project_id.clone());
686+
already_loaded.push(project_id.clone());
674687
continue;
675688
}
676689

677690
let root = PathBuf::from(&entry.root);
678691
let env_path = root.join(PROJECT_ENV_FILENAME);
679692
if !env_path.exists() {
680693
tracing::warn!("refresh: project {project_id} missing env file, skipping");
694+
failed.push(json!({"project_id": project_id, "reason": "missing env file"}));
681695
continue;
682696
}
683697

684698
let project_env = match read_project_env(&env_path) {
685699
Ok(e) => e,
686700
Err(e) => {
687701
tracing::warn!("refresh: cannot read env for {project_id}: {e}");
702+
failed.push(json!({"project_id": project_id, "reason": format!("read env failed: {e}")}));
688703
continue;
689704
}
690705
};
@@ -694,6 +709,7 @@ async fn admin_refresh(State(state): State<AppState>, headers: HeaderMap) -> Res
694709
Ok(c) => c,
695710
Err(e) => {
696711
tracing::warn!("refresh: bad config for {project_id}: {e}");
712+
failed.push(json!({"project_id": project_id, "reason": format!("bad config: {e}")}));
697713
continue;
698714
}
699715
};
@@ -705,6 +721,7 @@ async fn admin_refresh(State(state): State<AppState>, headers: HeaderMap) -> Res
705721
Ok(s) => s,
706722
Err(e) => {
707723
tracing::warn!("refresh: cannot create state for {project_id}: {e}");
724+
failed.push(json!({"project_id": project_id, "reason": format!("state init failed: {e}")}));
708725
continue;
709726
}
710727
};
@@ -716,6 +733,7 @@ async fn admin_refresh(State(state): State<AppState>, headers: HeaderMap) -> Res
716733
Ok(c) => c,
717734
Err(e) => {
718735
tracing::warn!("refresh: cannot create client for {project_id}: {e}");
736+
failed.push(json!({"project_id": project_id, "reason": format!("client init failed: {e}")}));
719737
continue;
720738
}
721739
};
@@ -730,7 +748,7 @@ async fn admin_refresh(State(state): State<AppState>, headers: HeaderMap) -> Res
730748
added.push(project_id.clone());
731749
}
732750

733-
Json(json!({"status":"ok","added":added,"already_loaded":skipped})).into_response()
751+
Json(json!({"status":"ok","added":added,"already_loaded":already_loaded,"removed":removed,"failed":failed})).into_response()
734752
}
735753

736754
fn parse_routed_model(model: &str) -> Result<(String, String), &'static str> {
@@ -810,13 +828,23 @@ fn early_stream_failed_response(
810828
kind: &'static str,
811829
message: &str,
812830
) -> Response {
831+
let response_id = format!("resp_{}", Uuid::new_v4().simple());
832+
let created_at = now_ts();
833+
let shell = json!({"id":response_id,"object":"response","created_at":created_at,"status":"in_progress","error":null,"incomplete_details":null,"instructions":body.get("instructions").cloned().unwrap_or(Value::Null),"model":model_alias,"output":[],"parallel_tool_calls":body.get("parallel_tool_calls").and_then(Value::as_bool).unwrap_or(false),"previous_response_id":body.get("previous_response_id").cloned().unwrap_or(Value::Null),"store":false,"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0},"metadata":body.get("metadata").cloned().unwrap_or_else(|| json!({}))});
813834
let (tx, rx) =
814835
tokio::sync::mpsc::unbounded_channel::<Result<axum::response::sse::Event, Infallible>>();
815-
let response = responses_failed_value(&body, &model_alias, kind, message);
816-
let event = json!({"type":"response.failed","response":response});
836+
let _ = tx.send(Ok(axum::response::sse::Event::default()
837+
.event("response.created")
838+
.data(json!({"type":"response.created","response":shell.clone()}).to_string())));
839+
let _ = tx.send(Ok(axum::response::sse::Event::default()
840+
.event("response.in_progress")
841+
.data(json!({"type":"response.in_progress","response":shell.clone()}).to_string())));
842+
let mut failed_response = shell;
843+
failed_response["status"] = json!("failed");
844+
failed_response["error"] = json!({"type":kind,"code":kind,"message":message.chars().take(1000).collect::<String>()});
817845
let _ = tx.send(Ok(axum::response::sse::Event::default()
818846
.event("response.failed")
819-
.data(event.to_string())));
847+
.data(json!({"type":"response.failed","response":failed_response}).to_string())));
820848
let _ = tx.send(Ok(axum::response::sse::Event::default().data("[DONE]")));
821849
let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
822850
Sse::new(stream).into_response()

tests/cli_distribution_flow.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ wire_api = "responses"
9595
[model_providers.opencode_go_adapter.auth]
9696
command = "cmd.exe"
9797
args = ["/d", "/s", "/c", "echo old"]
98-
timeout_ms = 1000
98+
timeout_ms = 5000
9999
"#;
100100
fs::write(&config_path, original).unwrap();
101101

@@ -247,6 +247,37 @@ fn auth_rejects_recovered_project_when_registry_mismatches_env() {
247247
);
248248
}
249249

250+
// ---------------------------------------------------------------------------
251+
// Test: check without project context falls through to health check
252+
#[test]
253+
fn check_without_project_context_shows_connectivity_error() {
254+
let sandbox = TestSandbox::new("check-no-context");
255+
// No init means no projects at all and no env file.
256+
let external_dir = sandbox.root().join("external");
257+
fs::create_dir_all(&external_dir).unwrap();
258+
259+
let output = sandbox.run_in(&external_dir, ["check"]);
260+
let stderr_text = stderr(&output);
261+
let stdout_text = stdout(&output);
262+
263+
// Must never mention "Project is not initialized"; that was the original bug.
264+
assert!(
265+
!stderr_text.contains("Project is not initialized"),
266+
"must not mention project init: {stderr_text}"
267+
);
268+
// The warning prints the original config error, so "No OpenCode" is
269+
// expected in stderr. It must NOT appear in stdout though.
270+
assert!(
271+
!stdout_text.contains("No OpenCode"),
272+
"must not mention 'no projects' in stdout: {stdout_text}"
273+
);
274+
// Verify the warning is present so the user knows why.
275+
assert!(
276+
stderr_text.contains("Warning: could not load project config"),
277+
"stderr should contain config resolution warning: {stderr_text}"
278+
);
279+
}
280+
250281
#[test]
251282
fn check_uses_project_env_and_succeeds() {
252283
let sandbox = TestSandbox::new("check-success");

tests/common/mod.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use codex_opencode_adapter::upstream::OpenCodeGoClient;
1010
use serde_json::{json, Value};
1111
use std::collections::HashMap;
1212
use std::net::SocketAddr;
13-
use std::sync::{Arc, RwLock};
13+
use std::sync::{Arc, OnceLock, RwLock};
1414
use tokio::net::TcpListener;
1515
use tokio::sync::Semaphore;
1616
use uuid::Uuid;
@@ -108,6 +108,14 @@ pub fn adapter_url(addr: SocketAddr, path: &str) -> String {
108108
format!("http://{}{}", addr, path)
109109
}
110110

111+
/// A global lock for tests that modify HOME/USERPROFILE environment variables.
112+
/// Concurrent tests within the same binary that set these env vars would
113+
/// otherwise overwrite each other's values, causing flaky failures.
114+
pub fn env_lock() -> &'static tokio::sync::Mutex<()> {
115+
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
116+
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
117+
}
118+
111119
pub struct RealSmokeConfig {
112120
pub upstream_base: String,
113121
pub upstream_key: String,

0 commit comments

Comments
 (0)