| 1 | //! Host observations and registration replacing the concrete App access in |
| 2 | //! groups/session/structcopy.rs. The existing runtime owners still enforce |
| 3 | //! workflow/session scope, plan locking and safe structured-image projection. |
| 4 | |
| 5 | use super::SharedCommandHost; |
| 6 | #[cfg(test)] |
| 7 | use crate::commands::CommandResult as HostResult; |
| 8 | use codewhale_command_contract::facets::*; |
| 9 | #[cfg(test)] |
| 10 | use codewhale_command_contract::outcome::StructcopyCommandResult; |
| 11 | use codewhale_models::ContentBlock; |
| 12 | use codewhale_secrets::sanitize::is_internal_role; |
| 13 | use std::path::Path; |
| 14 | |
| 15 | pub(super) struct SessionStructcopyAdapter<'a> { |
| 16 | pub(super) host: SharedCommandHost<'a>, |
| 17 | } |
| 18 | |
| 19 | impl CommandSessionStructcopyContext for SessionStructcopyAdapter<'_> { |
| 20 | fn transcript_item(&self, index: usize) -> Result<StructcopyTranscript, StructcopyError> { |
| 21 | let app = self.host.app.borrow(); |
| 22 | let message = index |
| 23 | .checked_sub(1) |
| 24 | .and_then(|index| app.api_messages.get(index)) |
| 25 | .ok_or(StructcopyError::Unavailable)?; |
| 26 | let content = if is_internal_role(message.role.as_str()) { |
| 27 | StructcopyContent::InternalContext |
| 28 | } else { |
| 29 | StructcopyContent::Visible(message.content.iter().map(project_block).collect()) |
| 30 | }; |
| 31 | Ok(StructcopyTranscript { |
| 32 | index, |
| 33 | role: message.role.to_string(), |
| 34 | content, |
| 35 | }) |
| 36 | } |
| 37 | |
| 38 | fn tool_pair(&self, call_id: &str) -> Result<StructcopyToolPair, StructcopyError> { |
| 39 | let app = self.host.app.borrow(); |
| 40 | if call_id.trim().is_empty() { |
| 41 | return Err(StructcopyError::Unavailable); |
| 42 | } |
| 43 | // Preserve upstream execution identity, including ambiguous local/legacy |
| 44 | // collisions. An explicit execution ID never falls back to a wire ID. |
| 45 | let mut calls = app |
| 46 | .api_messages |
| 47 | .iter() |
| 48 | .flat_map(|message| &message.content) |
| 49 | .filter_map(|block| match block { |
| 50 | ContentBlock::ToolUse { |
| 51 | id, name, input, .. |
| 52 | } => block |
| 53 | .tool_call_key() |
| 54 | .filter(|key| key.as_str() == call_id) |
| 55 | .map(|key| (key, id, name, input)), |
| 56 | _ => None, |
| 57 | }); |
| 58 | let Some((key, provider_id, name, input)) = calls.next() else { |
| 59 | return Err(StructcopyError::Unavailable); |
| 60 | }; |
| 61 | if calls.next().is_some() || provider_id.trim().is_empty() { |
| 62 | return Err(StructcopyError::Unavailable); |
| 63 | } |
| 64 | let mut result = None; |
| 65 | for block in app.api_messages.iter().flat_map(|message| &message.content) { |
| 66 | if block.tool_call_key() != Some(key) { |
| 67 | continue; |
| 68 | } |
| 69 | if let ContentBlock::ToolResult { |
| 70 | tool_use_id, |
| 71 | content, |
| 72 | is_error, |
| 73 | content_blocks, |
| 74 | .. |
| 75 | } = block |
| 76 | { |
| 77 | if tool_use_id != provider_id || result.is_some() { |
| 78 | return Err(StructcopyError::Unavailable); |
| 79 | } |
| 80 | result = Some(project_result( |
| 81 | content, |
| 82 | *is_error, |
| 83 | content_blocks.as_deref(), |
| 84 | )); |
| 85 | } |
| 86 | } |
| 87 | Ok(StructcopyToolPair { |
| 88 | name: name.clone(), |
| 89 | input: input.clone(), |
| 90 | result, |
| 91 | }) |
| 92 | } |
| 93 | |
| 94 | fn plan_snapshot(&self) -> Result<StructcopyPlan, StructcopyError> { |
| 95 | let app = self.host.app.borrow(); |
| 96 | let snapshot = app |
| 97 | .plan_state |
| 98 | .try_lock() |
| 99 | .map_err(|_| StructcopyError::Busy)? |
| 100 | .snapshot(); |
| 101 | if snapshot.is_empty() { |
| 102 | return Err(StructcopyError::Unavailable); |
| 103 | } |
| 104 | // Preserve the original owner's serialization/omission rules, then |
| 105 | // validate its known projection into the contract shape. No host blob |
| 106 | // crosses the boundary and no plan-state implementation is duplicated. |
| 107 | serde_json::to_value(snapshot) |
| 108 | .and_then(serde_json::from_value) |
| 109 | .map_err(|error| StructcopyError::Preparation(error.to_string())) |
| 110 | } |
| 111 | |
| 112 | fn workflow_projection(&self, run_id: &str) -> Result<StructcopyWorkflow, StructcopyError> { |
| 113 | let app = self.host.app.borrow(); |
| 114 | let projection = crate::tools::workflow::structcopy_run_projection( |
| 115 | &app.workspace, |
| 116 | run_id, |
| 117 | app.current_session_id.as_deref(), |
| 118 | ) |
| 119 | .ok_or(StructcopyError::Unavailable)?; |
| 120 | // This is the existing bounded projection, not serialized runtime |
| 121 | // state: ownership, retention and filename-only policy stay upstream. |
| 122 | serde_json::from_value(projection) |
| 123 | .map_err(|error| StructcopyError::Preparation(error.to_string())) |
| 124 | } |
| 125 | |
| 126 | fn path_roots(&self) -> StructcopyPathRoots { |
| 127 | observe_path_roots(&self.host.app.borrow().workspace) |
| 128 | } |
| 129 | |
| 130 | fn write_clipboard(&self, text: &str) -> Result<StructcopyTransport, String> { |
| 131 | use crate::tui::clipboard::CopyTransport; |
| 132 | self.host |
| 133 | .app |
| 134 | .borrow_mut() |
| 135 | .clipboard |
| 136 | .write_text_status(text) |
| 137 | .map(|transport| match transport { |
| 138 | CopyTransport::Native => StructcopyTransport::Native, |
| 139 | CopyTransport::Terminal => StructcopyTransport::TerminalQueued, |
| 140 | }) |
| 141 | .map_err(|error| error.to_string()) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | pub(in crate::commands) fn observe_path_roots(workspace: &Path) -> StructcopyPathRoots { |
| 146 | StructcopyPathRoots { |
| 147 | workspace: workspace.to_string_lossy().into_owned(), |
| 148 | canonical_workspace: workspace |
| 149 | .canonicalize() |
| 150 | .ok() |
| 151 | .map(|path| path.to_string_lossy().into_owned()), |
| 152 | home: std::env::var_os("HOME") |
| 153 | .or_else(|| std::env::var_os("USERPROFILE")) |
| 154 | .map(|home| home.to_string_lossy().into_owned()), |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | fn project_result( |
| 159 | content: &str, |
| 160 | is_error: Option<bool>, |
| 161 | content_blocks: Option<&[serde_json::Value]>, |
| 162 | ) -> StructcopyToolResult { |
| 163 | StructcopyToolResult { |
| 164 | content: content.into(), |
| 165 | is_error, |
| 166 | content_blocks: crate::image_attach::safe_tool_result_content_blocks(content_blocks), |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | fn project_block(block: &ContentBlock) -> StructcopyBlock { |
| 171 | match block { |
| 172 | ContentBlock::Text { text, .. } => StructcopyBlock::Text(text.clone()), |
| 173 | ContentBlock::Thinking { .. } => StructcopyBlock::ThinkingOmitted, |
| 174 | ContentBlock::ToolUse { |
| 175 | id, |
| 176 | name, |
| 177 | input, |
| 178 | caller, |
| 179 | .. |
| 180 | } => StructcopyBlock::ToolUse { |
| 181 | id: id.clone(), |
| 182 | name: name.clone(), |
| 183 | input: input.clone(), |
| 184 | caller_type: caller.as_ref().map(|caller| caller.caller_type.clone()), |
| 185 | }, |
| 186 | ContentBlock::ToolResult { |
| 187 | tool_use_id, |
| 188 | content, |
| 189 | is_error, |
| 190 | content_blocks, |
| 191 | .. |
| 192 | } => StructcopyBlock::ToolResult { |
| 193 | tool_use_id: tool_use_id.clone(), |
| 194 | result: project_result(content, *is_error, content_blocks.as_deref()), |
| 195 | }, |
| 196 | ContentBlock::ImageUrl { image_url } => { |
| 197 | if image_url.url.starts_with("http://") || image_url.url.starts_with("https://") { |
| 198 | StructcopyBlock::ImageUrl(image_url.url.clone()) |
| 199 | } else { |
| 200 | StructcopyBlock::ImageOmitted |
| 201 | } |
| 202 | } |
| 203 | ContentBlock::ServerToolUse { id, name, input } => StructcopyBlock::ServerToolUse { |
| 204 | id: id.clone(), |
| 205 | name: name.clone(), |
| 206 | input: input.clone(), |
| 207 | }, |
| 208 | ContentBlock::ToolSearchToolResult { |
| 209 | tool_use_id, |
| 210 | content, |
| 211 | } => StructcopyBlock::ToolSearchToolResult { |
| 212 | tool_use_id: tool_use_id.clone(), |
| 213 | content: content.clone(), |
| 214 | }, |
| 215 | ContentBlock::CodeExecutionToolResult { |
| 216 | tool_use_id, |
| 217 | content, |
| 218 | } => StructcopyBlock::CodeExecutionToolResult { |
| 219 | tool_use_id: tool_use_id.clone(), |
| 220 | content: content.clone(), |
| 221 | }, |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | #[cfg(test)] |
| 226 | pub(in crate::commands) fn host_result(result: StructcopyCommandResult) -> HostResult { |
| 227 | HostResult { |
| 228 | message: result.message, |
| 229 | action: result.action.map(|impossible| match impossible {}), |
| 230 | is_error: result.is_error, |
| 231 | } |
| 232 | } |
| 233 |