@@ -70,6 +70,28 @@ pub(crate) fn enforce_result_toml_path_contract(
7070 return ;
7171 }
7272
73+ // Disk-based fallback: the agent wrote result.toml to session_dir but the
74+ // path was not found in output/summary (e.g. verbose output truncated the
75+ // path, or ACP message boundaries split it). Accept the file if it exists,
76+ // passes validation, and contains valid TOML.
77+ if session_result_fallback_is_valid ( & expected_path) {
78+ let warning = format ! (
79+ "contract warning: output/summary path not found; accepted verified session result '{}'" ,
80+ expected_path. display( )
81+ ) ;
82+ warn ! (
83+ summary = %result. summary,
84+ fallback = %expected_path. display( ) ,
85+ "Session output path not in output/summary; accepting verified session-dir result.toml fallback"
86+ ) ;
87+ if !result. stderr_output . is_empty ( ) && !result. stderr_output . ends_with ( '\n' ) {
88+ result. stderr_output . push ( '\n' ) ;
89+ }
90+ result. stderr_output . push_str ( & warning) ;
91+ result. stderr_output . push ( '\n' ) ;
92+ return ;
93+ }
94+
7395 let reason = if path_candidate. is_empty ( ) {
7496 format ! (
7597 "contract violation: expected existing absolute result path '{}' or '{}' in output/summary, but output and summary were empty" ,
@@ -106,6 +128,7 @@ fn prompt_requires_result_toml_path(prompt: &str) -> bool {
106128}
107129
108130fn contract_result_toml_path_candidate ( result : & ExecutionResult ) -> & str {
131+ // 1. Whole-line match (exact path on its own line).
109132 let output_path_candidate = result
110133 . output
111134 . lines ( )
@@ -116,11 +139,24 @@ fn contract_result_toml_path_candidate(result: &ExecutionResult) -> &str {
116139 return candidate;
117140 }
118141
142+ // 2. Summary as path.
119143 let summary_candidate = normalize_contract_path_candidate ( & result. summary ) ;
120- if !summary_candidate. is_empty ( ) {
144+ if !summary_candidate. is_empty ( ) && line_looks_like_result_toml_path ( summary_candidate ) {
121145 return summary_candidate;
122146 }
123147
148+ // 3. Embedded path extraction: scan output lines for an absolute result.toml
149+ // path that appears as a substring within a longer line.
150+ if let Some ( embedded) = extract_embedded_result_toml_path ( & result. output ) {
151+ return embedded;
152+ }
153+
154+ // 4. Embedded path in summary.
155+ if let Some ( embedded) = extract_embedded_result_toml_path ( & result. summary ) {
156+ return embedded;
157+ }
158+
159+ // 5. Last non-empty output line (legacy fallback).
124160 result
125161 . output
126162 . lines ( )
@@ -130,6 +166,91 @@ fn contract_result_toml_path_candidate(result: &ExecutionResult) -> &str {
130166 . unwrap_or ( "" )
131167}
132168
169+ /// Extract an embedded absolute `result.toml` or `user-result.toml` path from
170+ /// text that may contain the path as a substring within a longer line.
171+ ///
172+ /// Scans each line for a `/` character that begins an absolute path ending with
173+ /// `result.toml` or `user-result.toml`, stripping surrounding quotes/backticks.
174+ fn extract_embedded_result_toml_path ( text : & str ) -> Option < & str > {
175+ for line in text. lines ( ) . rev ( ) {
176+ if let Some ( path) = find_result_toml_path_in_line ( line) {
177+ return Some ( path) ;
178+ }
179+ }
180+ None
181+ }
182+
183+ /// Find an absolute result.toml path embedded anywhere in a single line.
184+ /// Returns the longest matching substring that starts with `/` and ends with
185+ /// `result.toml` or `user-result.toml`.
186+ fn find_result_toml_path_in_line ( line : & str ) -> Option < & str > {
187+ const SUFFIXES : & [ & str ] = & [ "result.toml" , "user-result.toml" ] ;
188+
189+ for suffix in SUFFIXES {
190+ // Search from the end to prefer the last occurrence.
191+ let mut search_from = line. len ( ) ;
192+ while let Some ( end_pos) = line[ ..search_from] . rfind ( suffix) {
193+ let candidate_end = end_pos + suffix. len ( ) ;
194+ // Walk backwards from end_pos to find the start `/`.
195+ // The path must start with `/` and contain only path-legal characters.
196+ if let Some ( start) = find_absolute_path_start ( & line[ ..end_pos] ) {
197+ let raw = & line[ start..candidate_end] ;
198+ let cleaned = raw. trim_matches ( |c : char | c == '"' || c == '`' || c == '\'' ) ;
199+ let path = Path :: new ( cleaned) ;
200+ if path. is_absolute ( )
201+ && path. file_name ( ) . and_then ( |n| n. to_str ( ) ) . is_some_and ( |n| {
202+ n. eq_ignore_ascii_case ( "result.toml" )
203+ || n. eq_ignore_ascii_case ( "user-result.toml" )
204+ } )
205+ {
206+ return Some ( cleaned) ;
207+ }
208+ }
209+ // Continue searching before this occurrence.
210+ search_from = end_pos;
211+ if search_from == 0 {
212+ break ;
213+ }
214+ }
215+ }
216+ None
217+ }
218+
219+ /// Find the start index of an absolute path that ends at `before_suffix`.
220+ /// Walks backwards from the end to find a `/` that begins the path,
221+ /// skipping only characters valid in Unix paths.
222+ fn find_absolute_path_start ( before_suffix : & str ) -> Option < usize > {
223+ // Walk backwards to find the leading `/` of the absolute path.
224+ // Path characters: anything except whitespace and certain delimiters.
225+ let bytes = before_suffix. as_bytes ( ) ;
226+ let mut i = bytes. len ( ) ;
227+ while i > 0 {
228+ i -= 1 ;
229+ let c = bytes[ i] ;
230+ // Stop at whitespace or common non-path delimiters, but allow the
231+ // path to start with `/` preceded by whitespace.
232+ if c == b'/' {
233+ // Check if this is the root `/` (preceded by start-of-string,
234+ // whitespace, quote, or backtick).
235+ if i == 0
236+ || matches ! (
237+ bytes[ i - 1 ] ,
238+ b' ' | b'\t' | b'"' | b'\'' | b'`' | b'(' | b'[' | b'{'
239+ )
240+ {
241+ return Some ( i) ;
242+ }
243+ // Otherwise it's a path separator within the path, keep going.
244+ continue ;
245+ }
246+ if c. is_ascii_whitespace ( ) || c == b'(' || c == b'[' || c == b'{' {
247+ // The path starts after this delimiter.
248+ return None ;
249+ }
250+ }
251+ None
252+ }
253+
133254fn path_matches_expected_contract_result ( path_candidate : & str , expected_path : & Path ) -> bool {
134255 let path = Path :: new ( path_candidate) ;
135256 path == expected_path && expected_contract_file_is_valid ( expected_path)
@@ -181,6 +302,25 @@ fn user_result_fallback_is_valid(path: &Path) -> bool {
181302 )
182303}
183304
305+ /// Validates session-dir result.toml as a disk-based fallback when the path
306+ /// could not be extracted from output/summary. Applies the same validation as
307+ /// user-result fallback: file must exist, not be a symlink, have nlink==1,
308+ /// and contain valid non-empty TOML.
309+ fn session_result_fallback_is_valid ( path : & Path ) -> bool {
310+ if !expected_contract_file_is_valid ( path) {
311+ return false ;
312+ }
313+
314+ let Ok ( contents) = std:: fs:: read_to_string ( path) else {
315+ return false ;
316+ } ;
317+
318+ matches ! (
319+ toml:: from_str:: <toml:: Value >( & contents) ,
320+ Ok ( toml:: Value :: Table ( table) ) if !table. is_empty( )
321+ )
322+ }
323+
184324fn line_looks_like_result_toml_path ( line : & str ) -> bool {
185325 let candidate = normalize_contract_path_candidate ( line) ;
186326 let path = Path :: new ( candidate) ;
0 commit comments