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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/en/wegent/developer-guide/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,8 @@ EXECUTOR_IMAGE: wegent-executor:latest # Executor image

Rust executor is the only executor runtime implementation. Backend Chat shell work may still use an in-process path, while other tasks run through standalone/local executor. In Wework packaged App local-first mode, the app does not start a local Backend; it calls the executor sidecar directly over Tauri app IPC. Codex runtime control uses `codex app-server --stdio` JSON-RPC to create, continue, read, archive, and rename threads. The executor stores only the local task index and the required `localTaskId -> threadId` mapping.

When Claude Code resumes an interactive-form session, the executor treats a defer as stale resume output only when it has the same `tool_use_id` as the form being answered and is still an interactive-form tool. A later form with a different `tool_use_id` is a new clarification request; even if that response also contains text, the executor must proxy it to the interactive MCP and wait for user input instead of discarding it as stale.

Before attachments enter Codex, the executor converts them by type: images become local image inputs, text attachments include a bounded preview and their complete local path, and binary attachments such as ZIP or PDF include their filename, MIME type, size, and local path. Codex can therefore locate a file even when the user sends an attachment without message text. These contexts are mutually exclusive by type so image and text attachments are not injected twice.

Image conversion may create temporary `*.model-input.*` files that exist only for model consumption; those paths must not become persistent Wework message attachment URLs. When restoring user messages from the Codex transcript, the executor prefers the original attachment path retained in the file-mention context or local runtime handle. Temporary model inputs are used only during inference, so historical messages, task switching, and reopened tasks continue to render the original image after temporary files are cleaned up.
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/wegent/developer-guide/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,8 @@ EXECUTOR_IMAGE: wegent-executor:latest # 执行器镜像

Rust executor 是唯一的 executor 运行时实现。Backend 的 Chat shell 仍可走进程内路径,其他任务由 standalone/local executor 执行;Wework 打包 App 的 local-first 模式不启动本地 Backend,而是通过 Tauri app IPC 直接调用 executor。Codex 运行时通过 `codex app-server --stdio` 的 JSON-RPC 协议创建、继续、读取、归档和重命名线程,executor 只保存必要的本地任务索引和 `localTaskId -> threadId` 关联。

Claude Code 恢复交互表单会话时,executor 只把与本次已回答表单具有相同 `tool_use_id`、且工具类型仍为交互表单的 defer 视为恢复阶段残留结果。模型随后返回不同 `tool_use_id` 的表单表示新的用户澄清,即使同一响应还包含文本,也必须继续代理到交互 MCP 并等待用户输入,不能按旧 defer 丢弃。

附件在进入 Codex 前由 executor 按类型转换:图片作为本地图片输入,文本附件附带受限预览和完整本地路径,ZIP、PDF 等二进制附件则附带文件名、MIME 类型、大小和本地路径。即使用户只发送附件而正文为空,Codex 仍能从输入上下文定位该文件;不同类型的上下文互斥生成,避免图片或文本附件被重复注入。

图片转换可能生成仅供模型读取的临时 `*.model-input.*` 文件,但该路径不能作为 Wework 消息附件的持久化地址。executor 从 Codex transcript 恢复用户消息时,优先使用文件提及上下文或本地 runtime handle 中保留的原始附件路径;临时模型输入仅用于推理阶段。这样临时文件清理后,历史消息、任务切换和重开任务仍能显示原始图片预览。
Expand Down
13 changes: 6 additions & 7 deletions executor/src/process/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,18 +611,17 @@ async fn handle_deferred_mcp_loop(
let Some(deferred_tool_use) = summary.deferred_tool_use.clone() else {
return summary.outcome;
};
// After draining an already answered form, a non-empty final answer is
// authoritative; a leftover deferred form is stale Claude session state.
if stale_answer_defer_drained
&& answered_interactive_form_tool_use_id(&request).is_some()
let answered_tool_use_id = answered_interactive_form_tool_use_id(&request);
if answered_tool_use_id.as_deref() == Some(deferred_tool_use.id.as_str())
&& crate::agents::interactive_mcp::is_interactive_form_tool(&deferred_tool_use.name)
&& completed_with_content(&summary.outcome)
{
log_executor_event("ignoring stale deferred form after answered drain", &fields);
log_executor_event("ignoring stale deferred form after answer", &fields);
return summary.outcome;
}
if !stale_answer_defer_drained
&& answered_interactive_form_tool_use_id(&request)
.is_some_and(|tool_use_id| tool_use_id == deferred_tool_use.id)
&& answered_tool_use_id.as_deref() == Some(deferred_tool_use.id.as_str())
&& crate::agents::interactive_mcp::is_interactive_form_tool(&deferred_tool_use.name)
{
stale_answer_defer_drained = true;
log_executor_event("draining stale answered interactive form defer", &fields);
Expand Down
79 changes: 78 additions & 1 deletion executor/tests/agent_runtime_capabilities_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,55 @@ async fn claude_runtime_streams_answer_drain_follow_up_output() {
}));
}

#[tokio::test]
async fn claude_runtime_preserves_new_deferred_form_after_answer_drain() {
let _lock = env_lock().await;
let workspace_root = unique_dir("claude-runtime-answer-new-defer-workspace");
let marker = unique_dir("claude-runtime-answer-new-defer-marker").join("count");
let fake_claude = write_fake_claude_answer_drain_with_new_defer(&marker);
let waiting_payload = json!({
"__deferred_user_input__": true,
"success": true,
"status": "waiting_for_user_response"
});
let mcp_url = spawn_mcp_server(vec![
json!({"jsonrpc": "2.0", "id": 1, "result": {}}),
json!({"jsonrpc": "2.0", "result": {}}),
json!({
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{
"type": "text",
"text": waiting_payload.to_string()
}]
}
}),
])
.await;
let _workspace = EnvGuard::set("WORKSPACE_ROOT", &workspace_root.display().to_string());
let _mode = EnvGuard::set("EXECUTOR_MODE", "docker");
let engine = AgentProcessEngine::new(AgentCommandPlanner::new(
fake_claude.display().to_string(),
"codex",
));
let mut request = interactive_form_answer_request(7795, 106);
request.mcp_servers = vec![json!({
"name": "interactive-wegent-interactive-form-question",
"type": "streamable-http",
"url": mcp_url
})];

let outcome = engine.run(request).await;

assert_eq!(
outcome,
ExecutionOutcome::WaitingForUserInput {
stop_reason: "tool_deferred".to_owned()
}
);
}

async fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().await
Expand Down Expand Up @@ -1045,7 +1094,35 @@ if ! grep -q 'tool-answered' >/dev/null 2>&1; then
exit 9
fi
printf '%s\n' '{{"type":"assistant","message":{{"content":[{{"type":"text","text":"published"}}]}}}}'
printf '%s\n' '{{"type":"result","subtype":"success","is_error":false,"session_id":"session-answer-stale","stop_reason":"tool_deferred","usage":{{}},"deferred_tool_use":{{"id":"tool-stale-followup","name":"mcp__interactive_wegent-interactive-form-question__interactive_form_question","input":{{"questions":[{{"id":"confirm","question":"Confirm?"}}]}}}}}}'
printf '%s\n' '{{"type":"result","subtype":"success","is_error":false,"session_id":"session-answer-stale","stop_reason":"tool_deferred","usage":{{}},"deferred_tool_use":{{"id":"tool-answered","name":"mcp__interactive_wegent-interactive-form-question__interactive_form_question","input":{{"questions":[]}}}}}}'
"#,
marker.display()
);
fs::write(&path, content).unwrap();
make_executable(&path);
path
}

fn write_fake_claude_answer_drain_with_new_defer(marker: &Path) -> PathBuf {
if let Some(parent) = marker.parent() {
fs::create_dir_all(parent).unwrap();
}
let path = unique_dir("fake-claude-answer-new-defer").join("claude");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let content = format!(
r#"#!/bin/sh
MARKER='{}'
if [ ! -f "$MARKER" ]; then
printf 1 > "$MARKER"
printf '%s\n' '{{"type":"system","subtype":"init","session_id":"session-answer-new-defer"}}'
printf '%s\n' '{{"type":"result","subtype":"success","is_error":false,"session_id":"session-answer-new-defer","stop_reason":"tool_deferred","usage":{{}},"deferred_tool_use":{{"id":"tool-answered","name":"mcp__interactive_wegent-interactive-form-question__interactive_form_question","input":{{"questions":[]}}}}}}'
exit 0
fi
if ! grep -q 'tool-answered' >/dev/null 2>&1; then
exit 9
fi
printf '%s\n' '{{"type":"assistant","message":{{"content":[{{"type":"text","text":"one verification decision remains"}}]}}}}'
printf '%s\n' '{{"type":"result","subtype":"success","is_error":false,"session_id":"session-answer-new-defer","stop_reason":"tool_deferred","usage":{{}},"deferred_tool_use":{{"id":"tool-new","name":"mcp__interactive_wegent-interactive-form-question__interactive_form_question","input":{{"questions":[{{"id":"verification_scope","question":"Which verification scope?"}}]}}}}}}'
"#,
marker.display()
);
Expand Down
Loading
Loading