diff --git a/docs/agents/zcode.md b/docs/agents/zcode.md index 36a7334a22..c84641361e 100644 --- a/docs/agents/zcode.md +++ b/docs/agents/zcode.md @@ -17,6 +17,12 @@ The bridge reuses ZCode's existing authentication. Codeg does not copy credentia ZCode Start Plan accounts cannot run through the bridge. Their model requests need a captcha that only the ZCode desktop app can solve, so each prompt fails with a message saying so. GLM Coding Plan providers are not affected. +## Attachments + +Images and embedded resources follow the bridge's negotiated ACP capabilities. Pasted, dropped and uploaded images are sent as image blocks, including uploads hydrated by the Codeg server. Embedded PDFs retain their bytes even when their URI has no local file. File links remain references for native tools; remote links are not downloaded. Audio transcription is not supported. + +The bridge accepts up to 100 content blocks and 50 MiB of decoded content per prompt, with a 10 MiB limit per image. Image attachments must be PNG, JPEG, WebP, GIF, BMP or SVG. Other image formats, invalid base64 and oversized prompts return an error. Pause any native goal loop before sending binary attachments; queued goal prompts accept only text. + ## Sessions and tools A new session explicitly confirms Build mode before sending its first prompt. A valid saved mode, including Yolo, takes precedence. Confirming the mode makes the bridge create the ZCode backend session when the conversation connects rather than at its first prompt. Existing sessions use the normal restore and preference flow. Model and thought selectors follow ACP configuration updates rather than a hardcoded model list. @@ -29,4 +35,4 @@ Manage stdio MCP servers through Codeg's MCP settings and assign them to ZCode. ## Current scope -The first integration covers ACP conversations, permission requests, cancellation, Codeg transcript history, and stdio MCP. Message-level fork is disabled while the bridge's fork response and checkpoint behavior are incompatible with Codeg's typed flow. Native history import, a ZCode provider/configuration panel, HTTP MCP, attachments, native Goal/queue extensions and remote hub are outside this phase. Runtime validation with official ZCode has been performed on Windows, with bridge 0.69.2; macOS and Linux discovery paths still need platform testing. +The integration covers ACP conversations, permission requests, cancellation, Codeg transcript history, stdio MCP, images and embedded resources. Message-level fork is disabled while the bridge's fork response and checkpoint behavior are incompatible with Codeg's typed flow. Native history import, a ZCode provider/configuration panel, HTTP MCP, audio, native Goal/queue extensions and remote hub are outside this phase. Initial runtime validation with official ZCode used bridge 0.69.2 on Windows; image and embedded-resource validation also covers 0.72.0. macOS and Linux discovery paths still need platform testing. diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 044401c52b..472c2effe4 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -6115,11 +6115,11 @@ fn effective_prompt_capabilities( capabilities: &agent_client_protocol::schema::v1::PromptCapabilities, ) -> PromptCapabilitiesInfo { PromptCapabilitiesInfo { - // ZCode attachments are outside the validated first integration. - image: agent_type != AgentType::ZCode - && (capabilities.image || agent_type == AgentType::Grok), + image: capabilities.image || agent_type == AgentType::Grok, + // The ZCode bridge rejects audio blocks (it has no transcription), so + // audio stays off for it even if a release starts advertising it. audio: agent_type != AgentType::ZCode && capabilities.audio, - embedded_context: agent_type != AgentType::ZCode && capabilities.embedded_context, + embedded_context: capabilities.embedded_context, } } @@ -41934,13 +41934,19 @@ mod zcode_mode_tests { } #[test] - fn zcode_does_not_offer_unvalidated_attachments() { + fn zcode_negotiates_images_and_resources_but_not_audio() { let advertised = agent_client_protocol::schema::v1::PromptCapabilities::default() .image(true) .audio(true) .embedded_context(true); let zcode = effective_prompt_capabilities(AgentType::ZCode, &advertised); - assert!(!zcode.image && !zcode.audio && !zcode.embedded_context); + assert!(zcode.image && zcode.embedded_context); + assert!(!zcode.audio); + let absent = effective_prompt_capabilities( + AgentType::ZCode, + &agent_client_protocol::schema::v1::PromptCapabilities::default(), + ); + assert!(!absent.image && !absent.audio && !absent.embedded_context); let other = effective_prompt_capabilities(AgentType::KimiCode, &advertised); assert!(other.image && other.audio && other.embedded_context); } diff --git a/src-tauri/src/parsers/acp_native.rs b/src-tauri/src/parsers/acp_native.rs index 6e12d134b0..07daa62881 100644 --- a/src-tauri/src/parsers/acp_native.rs +++ b/src-tauri/src/parsers/acp_native.rs @@ -2447,6 +2447,97 @@ mod tests { mod zcode_history_tests { use super::*; + #[tokio::test] + async fn zcode_attachment_bytes_survive_recording_echo_and_cold_reopen() { + let dir = tempfile::tempdir().unwrap(); + let agent = crate::acp::registry::registry_id_for(AgentType::ZCode); + let wire = crate::acp::connection::map_prompt_blocks(vec![ + PromptInputBlock::Text { + text: "Read these attachments".into(), + }, + PromptInputBlock::Image { + data: "cGl4ZWxz".into(), + mime_type: "image/png".into(), + uri: None, + }, + PromptInputBlock::Resource { + uri: "clipboard:///report.pdf".into(), + mime_type: Some("application/pdf".into()), + text: None, + blob: Some("JVBERi0xLjQ=".into()), + }, + ]); + let payload = serde_json::to_value(&wire).unwrap(); + assert_eq!(payload[1]["data"], "cGl4ZWxz"); + assert_eq!(payload[2]["resource"]["blob"], "JVBERi0xLjQ="); + acp_transcript::record_header_in( + dir.path(), + agent, + &acp_transcript::TranscriptHeader::new("zcode", "attachments", "/workspace", 1), + ) + .await + .unwrap(); + acp_transcript::record_entry_in( + dir.path(), + agent, + "attachments", + EntryKind::Prompt, + payload.clone(), + ) + .await + .unwrap(); + for content in payload.as_array().unwrap() { + acp_transcript::record_entry_in( + dir.path(), + agent, + "attachments", + EntryKind::Update, + serde_json::json!({"sessionUpdate":"user_message_chunk", "content":content}), + ) + .await + .unwrap(); + } + acp_transcript::record_entry_in( + dir.path(), agent, "attachments", EntryKind::Update, + serde_json::json!({"sessionUpdate":"agent_message_chunk", "content":{"type":"text","text":"Read both"}}), + ).await.unwrap(); + acp_transcript::record_entry_in( + dir.path(), + agent, + "attachments", + EntryKind::TurnEnd, + serde_json::json!({"stopReason":"end_turn"}), + ) + .await + .unwrap(); + let parser = AcpNativeParser::new_in(AgentType::ZCode, dir.path().to_path_buf()); + let cold = parser.get_conversation("attachments").unwrap(); + assert_eq!( + cold.turns.len(), + 2, + "the prompt echo must not duplicate attachments" + ); + assert_eq!(cold.turns[0].blocks.len(), 3); + assert!( + matches!(&cold.turns[0].blocks[1], ContentBlock::Image { data, mime_type, uri } + if data == "cGl4ZWxz" && mime_type == "image/png" && uri.is_none()) + ); + assert!( + matches!(&cold.turns[0].blocks[2], ContentBlock::Text { text } + if text.contains("report.pdf") && !text.contains("JVBERi0xLjQ=")) + ); + let stored = acp_transcript::read_transcript_in(dir.path(), agent, "attachments"); + assert_eq!( + stored + .entries + .iter() + .find(|e| e.k == EntryKind::Prompt) + .unwrap() + .p, + payload + ); + } + #[tokio::test] async fn zcode_history_survives_reopen_and_keeps_tools_usage_and_continuations() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/work_task/git.rs b/src-tauri/src/work_task/git.rs index d88eb4479e..763f5c9451 100644 --- a/src-tauri/src/work_task/git.rs +++ b/src-tauri/src/work_task/git.rs @@ -412,7 +412,19 @@ impl ScratchIndex { // racing an agent's own `git add` reads one whole version or the // other — never a half-written file. let index = git_dir.join("index"); - if let Err(e) = std::fs::copy(&index, &scratch.path) { + let copied = std::fs::File::open(&index).and_then(|mut source| { + let metadata = source.metadata()?; + let mut destination = std::fs::File::create(&scratch.path)?; + std::io::copy(&mut source, &mut destination)?; + destination.set_permissions(metadata.permissions())?; + // Git rehashes an entry whose timestamp is at least the index's + // timestamp (a "racily clean" entry). A fresh mtime on the copy + // would disable that check and hide same-size edits made within + // the filesystem's timestamp resolution. Read both the contents + // and metadata from one handle, even if git replaces the source. + destination.set_modified(metadata.modified()?) + }); + if let Err(e) = copied { match anchor { Some(anchor) => { let read = @@ -819,6 +831,66 @@ mod tests { } } + /// Copying an index must keep Git's racy-clean check: a same-size edit + /// whose stat data still matches the cached entry needs to be rehashed. + #[tokio::test] + async fn a_copied_index_still_stages_a_racily_clean_edit() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let path = root.to_str().expect("utf-8 path"); + git_run(root, &["init", "-q"]); + git_run(root, &["config", "core.trustctime", "false"]); + git_run(root, &["config", "core.checkStat", "minimal"]); + let file = root.join("Cargo.lock"); + // Whole seconds model a filesystem whose timestamp resolution cannot + // distinguish the two writes, independently of how fast this runs. + let timestamp = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); + std::fs::write(&file, "a\n").unwrap(); + std::fs::File::options() + .write(true) + .open(&file) + .unwrap() + .set_modified(timestamp) + .unwrap(); + git_run(root, &["add", "Cargo.lock"]); + git_run(root, &["commit", "-q", "-m", "base"]); + + std::fs::write(&file, "b\n").unwrap(); + std::fs::File::options() + .write(true) + .open(&file) + .unwrap() + .set_modified(timestamp) + .unwrap(); + let index = root.join(".git/index"); + std::fs::File::options() + .write(true) + .open(&index) + .unwrap() + .set_modified(timestamp) + .unwrap(); + let original = std::fs::read(&index).unwrap(); + let original_mtime = std::fs::metadata(&index).unwrap().modified().unwrap(); + + let scratch = ScratchIndex::copy_of(path, None).await.unwrap(); + let add = run_git_with_index(path, &scratch.path, &["add", "--", "Cargo.lock"]) + .await + .unwrap(); + assert!(add.status.success(), "{:?}", add.stderr); + let diff = run_git_with_index(path, &scratch.path, &["diff", "--cached"]) + .await + .unwrap(); + assert!(diff.status.success(), "{:?}", diff.stderr); + let patch = String::from_utf8(diff.stdout).unwrap(); + assert!(patch.contains("-a\n+b\n"), "same-size edit lost: {patch}"); + assert_eq!(std::fs::read(&index).unwrap(), original); + assert_eq!( + std::fs::metadata(&index).unwrap().modified().unwrap(), + original_mtime, + "the real index's contents and timestamp stay untouched" + ); + } + /// Why removing a task worktree has to consult BOTH probes: each is blind /// to exactly what the other sees. `remove_worktree_and_branch` runs /// `worktree remove --force` + `branch -D`, so anything either probe would