Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/changes/unreleased/1807-report-first-prose-one-reader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
kind: fixed
title: a report that opens with a code fence quotes its sentence on every surface, not only the landed card
pr: 1807
surface: [chat, engine]
invalidates:
- >-
#889 was believed to have fixed the three-backticks quote everywhere. It
fixed only the landed card. A report that opened with a code fence still
drew `` ``` `` on the `/tasks` table, the home panels and the task record,
because `taskOutcome` in internal/session read the literal first line while
the card read the first PROSE line. Every surface now reads a report through
one shared reader, internal/reportline, so the fence is skipped once and for
all of them.
---

A task that answers with a diff, a command's output or a JSON block opens its
report with the code fence around that answer, and a fence marker says nothing.
The landed card learned to skip it in #889, but the card was not the only path:
`taskOutcome` wrote `TaskIndexEntry.Outcome` — the field the tasks table, the
home panels and the task record all quote — from the literal first line, so the
same work landed with a sentence on its card and three backticks on its row.

The two readers could not reach each other: `firstProseLine` and the fence
detector it leaned on, `mdFenceOpen`, were private to internal/tui3, and
`taskOutcome` lives in internal/session. The fence-marker detection and the
first-prose-line reading now live in one leaf package, `internal/reportline`,
that both the surface and the engine import. `taskOutcome`, `taskFirstSentence`,
`switcherFirstLine` and the renderer's `mdFenceOpen` all route through it, and a
structural law pins that they do, so a later hand cannot reach for `firstLine`
and put the fence back on a surface. No wording changed: a row that quoted a
sentence still does, and a row with nothing to quote still draws nothing.
20 changes: 12 additions & 8 deletions internal/manual/chat/reading-a-task-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ wall-clock time.

## The card quotes three backticks instead of a sentence — a code fence at the top of a task's report

**The quoted line on a landed card is the report's first line that says something**, not its
literal first line. A task that answers with a diff, a command's output or a JSON block
opens its report with the code fence around that answer, and a fence marker — three
backticks or three tildes, with or without a language word like `go` or `diff` after it — is
punctuation rather than a sentence. Blank lines and fence markers are passed over, and the
first line that is neither is what the card quotes.
**The quoted line a task shows is the report's first line that says something**, not its
literal first line, and this holds everywhere a report is quoted: the landed card in the
conversation, the row on the `/tasks` table, the work band on the home page, and the task
record. A task that answers with a diff, a command's output or a JSON block opens its report
with the code fence around that answer, and a fence marker — three backticks or three
tildes, with or without a language word like `go` or `diff` after it — is punctuation rather
than a sentence. Blank lines and fence markers are passed over, and the first line that is
neither is what gets quoted.

So a report that is nothing but a fenced block quotes the first line inside the block, which
is the answer itself:
Expand All @@ -77,9 +79,11 @@ A report with no line to quote at all draws no quotation marks — an empty pair
card claiming the work said something. The card falls back to its subtitle and, failing
that, to `started 14:02` on its own.

Until 2026-09-11 the card took the report's first line literally, so work that answered
Until 2026-09-11 every surface took the report's first line literally, so work that answered
inside a fence drew a quoted half of three backticks and nothing else — the one line the
card exists for spent on the punctuation around the answer.
quote exists for spent on the punctuation around the answer. The landed card learned to skip
the fence first; the tasks table, the home panels and the task record quote the sentence
too, because every surface now reads the report through one shared reader.

## Why most of the work is hidden on a task page — the `▸ worked` chips, the caption outline, and `ctrl+e`

Expand Down
126 changes: 126 additions & 0 deletions internal/reportline/law_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package reportline

import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)

// EVERY PATH THAT QUOTES A REPORT'S FIRST LINE ROUTES THROUGH THIS PACKAGE.
//
// The bug (#931) was two readers: the surface skipped the fence through
// firstProseLine while the engine read the literal first line through taskOutcome,
// so the same report drew a sentence on the landed card and three backticks on the
// tasks table. The fix is one reader, and this law is what keeps it one — a future
// hand that reaches for firstLine instead of FirstProse to open a report fails here
// before it can put the fence back on a surface.
//
// The sanctioned readers are reportline.FirstProse itself and tui3's firstProseLine,
// the thin wrapper the surface already calls (and which this law also pins to
// FirstProse). Each named function below must call one of them.
func TestEverySurfaceThatQuotesAReportRoutesThroughFirstProse(t *testing.T) {
_, here, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate repository")
}
root := filepath.Clean(filepath.Join(filepath.Dir(here), "..", ".."))

// name → whether the function's body calls a sanctioned report reader.
mustRoute := map[string]bool{
"taskOutcome": false, // internal/session/task_index.go
"taskFirstSentence": false, // internal/tui3/taskstable.go
"switcherFirstLine": false, // internal/tui3/switcher.go
"landedCard": false, // internal/tui3/taskdone.go
}
seen := map[string]bool{}
// firstProseLine is the surface's wrapper; it must itself reach FirstProse.
wrapperReachesFirstProse := false
wrapperSeen := false

set := token.NewFileSet()
walkErr := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
switch entry.Name() {
case ".git", "bin", "third_party", "testdata":
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, err := parser.ParseFile(set, path, nil, 0)
if err != nil {
return err
}
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
if _, watched := mustRoute[fn.Name.Name]; watched {
seen[fn.Name.Name] = true
if bodyCallsAny(fn.Body, "FirstProse", "firstProseLine") {
mustRoute[fn.Name.Name] = true
}
}
if fn.Name.Name == "firstProseLine" {
wrapperSeen = true
if bodyCallsAny(fn.Body, "FirstProse") {
wrapperReachesFirstProse = true
}
}
}
return nil
})
if walkErr != nil {
t.Fatal(walkErr)
}

for name, routed := range mustRoute {
if !seen[name] {
t.Errorf("%s was not found in the tree — if it was renamed, teach this law its new name", name)
continue
}
if !routed {
t.Errorf("%s does not route through reportline.FirstProse (or tui3's firstProseLine); a report read with firstLine puts the fence back on the surface (#931)", name)
}
}
if !wrapperSeen {
t.Error("tui3's firstProseLine was not found; it is the surface's sanctioned wrapper over reportline.FirstProse")
} else if !wrapperReachesFirstProse {
t.Error("tui3's firstProseLine must delegate to reportline.FirstProse so there is one reading, not a copied loop (#931)")
}
}

// bodyCallsAny reports whether body contains a call whose function name — a bare
// identifier or the selector's final name — is one of names.
func bodyCallsAny(body *ast.BlockStmt, names ...string) bool {
allow := make(map[string]bool, len(names))
for _, name := range names {
allow[name] = true
}
found := false
ast.Inspect(body, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
switch fun := call.Fun.(type) {
case *ast.SelectorExpr:
found = found || allow[fun.Sel.Name]
case *ast.Ident:
found = found || allow[fun.Name]
}
return true
})
return found
}
83 changes: 83 additions & 0 deletions internal/reportline/reportline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Package reportline holds the ONE reading of a task report's first prose line,
// and the fence-marker detection that reading rests on.
//
// A task report is the model's own Markdown. A model that has just run a command
// or produced a diff opens with the fence around it rather than with a sentence —
// the fence is not a defect in the report, it is how the answer is spelled. Every
// surface that quotes a report's opening (the landed card, the tasks table, the
// home panels, the switcher) wants the first line that SAYS something, not the
// row of punctuation around the answer.
//
// That reading lived in internal/tui3 as firstProseLine (#889), and the fence
// detector it leaned on, mdFenceOpen, is private to that package. internal/session
// writes the same field through taskOutcome and could not reach either, so it read
// the literal first line and the fence came back on the tasks table and the home
// panels while the card was already correct (#931). Copying the loop into session
// would have been the wrong fix: TWO OPINIONS ABOUT WHAT A FENCE IS DRIFT. So the
// reading and the detection live here, in a leaf package both the surface and the
// engine import, and there is exactly one of each.
package reportline

import "strings"

// Fence reports whether line opens a Markdown code fence, returning the delimiter
// run and the info string the way the renderer reads them. The caller passes a
// line already trimmed of leading space: a fence marker says nothing wherever it
// stands, and FirstProse trims before it asks.
//
// This is the renderer's own fence opinion — internal/tui3's mdFenceOpen is a thin
// wrapper over it — so a marker with a language word after it (go, sh, diff, json)
// is a marker here for exactly the reason it is one there. There is no second
// opinion about what a fence is.
func Fence(line string) (delim, info string, ok bool) {
var mark byte
switch {
case strings.HasPrefix(line, "```"):
mark = '`'
case strings.HasPrefix(line, "~~~"):
mark = '~'
default:
return "", "", false
}
n := 0
for n < len(line) && line[n] == mark {
n++
}
info = strings.TrimSpace(line[n:])
// A backtick in a backtick fence's info string is not an info string —
// CommonMark says so, and the line is prose.
if mark == '`' && strings.ContainsRune(info, '`') {
return "", "", false
}
// The language is the first word; the rest is metadata no lexer reads.
if cut := strings.IndexAny(info, " \t"); cut >= 0 {
info = info[:cut]
}
return line[:n], info, true
}

// FirstProse is the first line of s that says something, trimmed: a blank line
// says nothing, a fence marker says nothing, and the first line that is neither is
// what the work came to.
//
// A REPORT THAT IS NOTHING BUT A FENCED BLOCK RETURNS THE FIRST LINE INSIDE IT,
// which is the point rather than an exception to it: the work's answer IS the
// block's contents, and a reading that stepped over the whole block to look for
// prose underneath would return nothing at all where there was something to say.
//
// A REPORT WITH NO PROSE AT ALL RETURNS "", which hands the row to the surface's
// emptiness law: a card draws its subtitle or its start stamp rather than an empty
// pair of quotation marks claiming the work said something.
func FirstProse(s string) string {
for _, line := range strings.Split(s, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if _, _, fence := Fence(trimmed); fence {
continue
}
return trimmed
}
return ""
}
63 changes: 63 additions & 0 deletions internal/reportline/reportline_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package reportline

import "testing"

// THE ONE READING OF A REPORT'S FIRST PROSE LINE. A leading fence is skipped
// whether a language word follows it or not, blank lines are skipped, a report
// that is nothing but a fence returns the first line inside it, and a report with
// no prose at all returns nothing — which is the surface's emptiness law, not this
// reader's. This table is the rule every surface routes through (#931), moved here
// whole from the tui3 card test it began life as (#889).
func TestFirstProseSkipsFencesBlankLinesAndWrappers(t *testing.T) {
for _, tc := range []struct {
name string
report string
want string
}{
{"a report that opens with prose", "the seven files are written and the suite passes", "the seven files are written and the suite passes"},
{"a leading fence is skipped", "```\nthe seven files are written and the suite passes\n```", "the seven files are written and the suite passes"},
{"a fence's language word is skipped too", "```go\npackage main\n```\nthe suite passes", "package main"},
{"a tilde fence is skipped too", "~~~\nthe suite passes\n~~~", "the suite passes"},
{"a leading blank line is skipped", "\n\nthe suite passes", "the suite passes"},
{"blank lines inside a fence are skipped", "```\n\nthe suite passes\n```", "the suite passes"},
{"a report that is nothing but a fence returns its first line", "```\n7 files changed, 15 insertions(+)\n```", "7 files changed, 15 insertions(+)"},
{"a closed fence with nothing inside falls on", "```\n```\nthe suite passes", "the suite passes"},
{"an unclosed fence with nothing yet inside falls on", "```go\n\nthe suite passes", "the suite passes"},
{"an indented fence marker is still a fence", " ```\nthe suite passes", "the suite passes"},
{"a marker is skipped wherever it stands, not only in front", "```\n```\n```diff\n- old\n+ new\n```", "- old"},
{"a backtick in a backtick fence's info string is prose", "```not`a`fence\nthe suite passes", "```not`a`fence"},
{"an empty report says nothing", "", ""},
{"a report of blank lines says nothing", "\n \n", ""},
{"a report of fence markers alone says nothing", "```\n", ""},
} {
if got := FirstProse(tc.report); got != tc.want {
t.Errorf("%s: FirstProse(%q) = %q, want %q", tc.name, tc.report, got, tc.want)
}
}
}

// FENCE IS THE RENDERER'S OWN OPINION. A marker with a language word after it is
// still a marker; a backtick fence whose info string carries a backtick is prose,
// per CommonMark. These pin the detector FirstProse rests on.
func TestFenceReadsMarkersTheWayTheRendererDoes(t *testing.T) {
for _, tc := range []struct {
line string
delim string
info string
ok bool
}{
{"```", "```", "", true},
{"```go", "```", "go", true},
{"~~~", "~~~", "", true},
{"~~~~ diff meta", "~~~~", "diff", true},
{"````", "````", "", true},
{"```not`a`fence", "", "", false},
{"plain prose", "", "", false},
{"", "", "", false},
} {
delim, info, ok := Fence(tc.line)
if ok != tc.ok || delim != tc.delim || info != tc.info {
t.Errorf("Fence(%q) = (%q, %q, %v), want (%q, %q, %v)", tc.line, delim, info, ok, tc.delim, tc.info, tc.ok)
}
}
}
13 changes: 10 additions & 3 deletions internal/session/task_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import (

"github.com/Agent-Field/codeaf/internal/delegate"
"github.com/Agent-Field/codeaf/internal/plandb"
"github.com/Agent-Field/codeaf/internal/reportline"
)

// taskIndexName is the file, in the session directory. One name, shared by
Expand Down Expand Up @@ -994,10 +995,16 @@ func taskURI(path string) string {
return ""
}

// taskOutcome is the report's first sentence, capped. The whole report is in
// the transcript this row points at.
// taskOutcome is the report's first prose sentence, capped. It reads through
// reportline.FirstProse (#931), the same reading internal/tui3's landed card
// takes, so a report that opens with a fenced block — a diff, a command's output,
// a JSON result — writes its first real line into this row rather than the three
// backticks around the answer. The whole report is in the transcript this row
// points at. The surfaces this feeds (the tasks table, the home panels, the task
// record) can no longer recover the sentence once the fence has been written here,
// which is why the reading belongs at this source and not downstream.
func taskOutcome(report string) string {
line := strings.TrimSpace(firstLine(report))
line := reportline.FirstProse(report)
if len(line) > taskOutcomeLimit {
line = strings.TrimSpace(line[:taskOutcomeLimit]) + "…"
}
Expand Down
28 changes: 28 additions & 0 deletions internal/session/taskoutcome_fence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package session

import "testing"

// A TASK OUTCOME SKIPS A LEADING FENCE AND KEEPS THE FIRST PROSE LINE. This is
// #931 at its source: taskOutcome writes TaskIndexEntry.Outcome, the field the
// tasks table, the home panels and the task record all quote, and a report that
// opens with a fenced block used to write three backticks into it while the
// landed card (fixed in #889) already showed the sentence. The reading now runs
// through reportline.FirstProse, the same door the card takes.
func TestTaskOutcomeSkipsAFence(t *testing.T) {
const sentence = "the seven files are written and the suite passes"
for _, tc := range []struct {
name string
report string
want string
}{
{"a fenced block in front of the answer", "```\n" + sentence + "\n```", sentence},
{"a fence with a language word", "```go\n" + sentence + "\n```", sentence},
{"a report that is nothing but a fenced block keeps its first line", "```diff\n- old\n+ new\n```", "- old"},
{"a report that opens with prose is unchanged", sentence, sentence},
{"a report with nothing to say stays empty", "```\n```\n", ""},
} {
if got := taskOutcome(tc.report); got != tc.want {
t.Errorf("%s: taskOutcome(%q) = %q, want %q", tc.name, tc.report, got, tc.want)
}
}
}
Loading