@@ -154,16 +154,18 @@ func isParallelSafe(name string) bool {
154154func (s * Session ) execute (ctx context.Context , u wire.Block ) toolResult {
155155 s .mu .Lock ()
156156 s .metrics .toolCalls ++
157+ seq := s .metrics .toolCalls
157158 s .mu .Unlock ()
158159 // Redact the tool input before it is persisted — the model may pass a secret
159160 // (e.g. a bearer token in a command).
160161 s .emit (ctx , events .KindToolCalled , map [string ]any {"tool" : u .Name , "input" : s .redactor .Redact (string (u .Input ))})
161162 tr := s .dispatch (ctx , u )
163+ s .mu .Lock ()
162164 if tr .isError {
163- s .mu .Lock ()
164165 s .metrics .toolErrors ++
165- s .mu .Unlock ()
166166 }
167+ s .noteCompletionBlockerLocked (seq , u , tr )
168+ s .mu .Unlock ()
167169 // Central guarantee: no known secret value ever reaches the model — redact
168170 // the text of every block (images carry no text).
169171 for i := range tr .blocks {
@@ -174,6 +176,62 @@ func (s *Session) execute(ctx context.Context, u wire.Block) toolResult {
174176 return tr
175177}
176178
179+ // noteCompletionBlockerLocked records failed tools that are themselves a
180+ // deliverable the user can inspect (PR creation, artifact publish, etc.). The
181+ // todo tracker refuses to mark work done while one is unresolved, so a model
182+ // cannot paper over "GitHub(create PR) · failed" with "Task(done)".
183+ func (s * Session ) noteCompletionBlockerLocked (seq int , u wire.Block , tr toolResult ) {
184+ label , ok := completionBlockerLabel (u )
185+ if ! ok {
186+ return
187+ }
188+ if tr .isError {
189+ s .metrics .blockerSeq = seq
190+ s .metrics .blockerLabel = label
191+ s .metrics .blockerDetail = s .redactor .Redact (firstLine (tr .text ()))
192+ return
193+ }
194+ if s .metrics .blockerLabel == label {
195+ s .metrics .blockerSeq = 0
196+ s .metrics .blockerLabel = ""
197+ s .metrics .blockerDetail = ""
198+ }
199+ }
200+
201+ func completionBlockerLabel (u wire.Block ) (string , bool ) {
202+ switch u .Name {
203+ case tools .RunTests :
204+ return "test run" , true
205+ case tools .Diagnostics :
206+ return "diagnostics check" , true
207+ case tools .GitHub :
208+ var in tools.GitHubInput
209+ if json .Unmarshal (u .Input , & in ) != nil {
210+ return "" , false
211+ }
212+ switch in .Action {
213+ case "pr_create" :
214+ return "GitHub create PR" , true
215+ case "comment" :
216+ return "GitHub comment" , true
217+ }
218+ case tools .Artifact :
219+ var in tools.ArtifactInput
220+ if json .Unmarshal (u .Input , & in ) != nil {
221+ return "" , false
222+ }
223+ switch in .Action {
224+ case "publish" :
225+ return "artifact publish" , true
226+ case "update" :
227+ return "artifact update" , true
228+ case "delete" :
229+ return "artifact delete" , true
230+ }
231+ }
232+ return "" , false
233+ }
234+
177235func (s * Session ) dispatch (ctx context.Context , u wire.Block ) toolResult {
178236 // A reader sub-agent has no mutating tools; reject them defensively even if one
179237 // is somehow proposed (they aren't advertised — see toolDefs). Bash is the
@@ -1375,7 +1433,7 @@ func (s *Session) editFile(ctx context.Context, input json.RawMessage) toolResul
13751433 verb = "Write"
13761434 }
13771435 safeDiff := s .redactor .Redact (res .Diff )
1378- newContent := s .redactor . Redact ( in .NewString )
1436+ newContent := s .newFilePreviewContent ( res . Path , in .NewString )
13791437 if secrets .IsSecretPath (in .Path ) {
13801438 // Editing a credential file: mask values in both the diff and new content.
13811439 safeDiff = secrets .RedactSecretFile (safeDiff )
@@ -1536,13 +1594,19 @@ func (s *Session) applyPatch(ctx context.Context, input json.RawMessage) toolRes
15361594
15371595 var b strings.Builder
15381596 fmt .Fprintf (& b , "OK, applied %d edits across %d file(s) atomically:\n " , len (applied ), len (dedupePaths (paths )))
1539- for _ , res := range applied {
1597+ for i , res := range applied {
15401598 verb := "Update"
15411599 if res .Created {
15421600 verb = "Write"
15431601 }
15441602 s .toolLine (true , verb , res .Path , "" , false )
1545- if d := s .redactor .Redact (res .Diff ); d != "" {
1603+ if res .Created {
1604+ newContent := s .newFilePreviewContent (res .Path , in .Edits [i ].NewString )
1605+ if secrets .IsSecretPath (res .Path ) {
1606+ newContent = secrets .RedactSecretFile (newContent )
1607+ }
1608+ renderNewFile (s .out , newContent , res .Path , s .diffWidth ())
1609+ } else if d := s .redactor .Redact (res .Diff ); d != "" {
15461610 renderDiff (s .out , d , res .Path , s .diffWidth ())
15471611 }
15481612 fmt .Fprintf (& b , " %s %s\n " , verb , res .Path )
@@ -1555,6 +1619,15 @@ func (s *Session) applyPatch(ctx context.Context, input json.RawMessage) toolRes
15551619 return textResult (out )
15561620}
15571621
1622+ func (s * Session ) newFilePreviewContent (path , fallback string ) string {
1623+ if abs , err := safeJoin (s .root , path ); err == nil {
1624+ if b , err := os .ReadFile (abs ); err == nil {
1625+ return s .redactor .Redact (string (b ))
1626+ }
1627+ }
1628+ return s .redactor .Redact (fallback )
1629+ }
1630+
15581631// dedupePaths returns the unique paths in first-seen order.
15591632func dedupePaths (paths []string ) []string {
15601633 seen := map [string ]bool {}
0 commit comments