返回 CodeWhale
session_structcopy_host.rs
根目录 / crates / tui / src / commands / session_structcopy_host.rs
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
233 lines RUST