From eb6e982ce68c728055193da113825a9d2729b307 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Fri, 25 Sep 2026 22:11:34 -0400 Subject: [PATCH 1/2] session: the transcript keeps the person's words when a turn carries skills (#1504) The skills block rides the copy the model reads; the record the surfaces draw keeps the person's own words, and the dim skills-carried line is the one visible channel. Adds the end-to-end regression test through the real chat door, and lands the full internal/session suite green: the lane-news test helper no longer hands an earlier test's held sighting to the first reader of the next, and the ignored-folder receipt test wants the canonical spelling the receipt actually prints. --- .../1504-transcript-keeps-your-words.md | 14 ++++ internal/manual/chat/skills-a-turn-used.md | 5 ++ internal/session/lanenews_test.go | 10 +++ .../programfolder_ignored_receipt_test.go | 7 +- internal/session/skillturn_display_test.go | 68 +++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 docs/changes/unreleased/1504-transcript-keeps-your-words.md create mode 100644 internal/session/skillturn_display_test.go diff --git a/docs/changes/unreleased/1504-transcript-keeps-your-words.md b/docs/changes/unreleased/1504-transcript-keeps-your-words.md new file mode 100644 index 0000000000..d372115f85 --- /dev/null +++ b/docs/changes/unreleased/1504-transcript-keeps-your-words.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: a message that carries skills shows your words, not the skills' description sheet +pr: 1504 +surface: [chat] +invalidates: + - "A message whose words matched skills on the shelf printed a `Skills suited to this message:` block as part of the person's own message in every transcript: the block was chosen for the model and said nothing the person typed. The block still reaches the model — the turn reasons from it exactly as before — and the transcript keeps the person's words; the dim `skills carried` line under the message is the one thing a surface draws about what the turn carried." +--- + +The skills a turn carries are chosen from that message's words, so they belong +to the turn and not to the conversation's record. The person's own row keeps +its own words: what the model reads grows by the block, what every surface +replays is what was typed, and the carried skills are named once, in the dim +line the transcript already had. diff --git a/internal/manual/chat/skills-a-turn-used.md b/internal/manual/chat/skills-a-turn-used.md index 116ac2742e..247c2f5ffc 100644 --- a/internal/manual/chat/skills-a-turn-used.md +++ b/internal/manual/chat/skills-a-turn-used.md @@ -17,6 +17,11 @@ Those names come from the turn's skill list, not by taking apart the words in th line. The row is a record of what that turn carried with it. It is not a warning, a question or work waiting for you, so it has no attention mark, count or action. +The message in the transcript is your words and nothing else. What a turn +carries for the model — the skill bodies it reads with your sentence — rides the +copy the model reads, and no surface prints it: the row above is the one thing +you are shown. + ## Did it use my skill? If your skill's name is in that line, the turn carried it. The line belongs to that diff --git a/internal/session/lanenews_test.go b/internal/session/lanenews_test.go index 4e2595b945..980ab918eb 100644 --- a/internal/session/lanenews_test.go +++ b/internal/session/lanenews_test.go @@ -23,6 +23,16 @@ func hears(t *testing.T) func() []LaneNews { t.Helper() var mu sync.Mutex var heard []LaneNews + // A SIGHTING LEFT OVER FROM AN EARLIER TEST IS NOT THIS TEST'S NEWS. The + // desk holds the last landed sighting for the surface that registers late + // ([laneNewsHeld]), and a whole-package run walks that held one into the + // first reader the next test registers — two posts for one answer, want one + // (full-package run, 2026-09-25). A test registers to hear ITS answer, so + // the held one is dropped on the way in; the replay test asserts the holding + // itself, on [OnLaneNews] directly. + laneNewsMu.Lock() + laneNewsHeld = nil + laneNewsMu.Unlock() previous := OnLaneNews(func(news LaneNews) { mu.Lock() heard = append(heard, news) diff --git a/internal/session/programfolder_ignored_receipt_test.go b/internal/session/programfolder_ignored_receipt_test.go index dd59c92059..9be1be982e 100644 --- a/internal/session/programfolder_ignored_receipt_test.go +++ b/internal/session/programfolder_ignored_receipt_test.go @@ -29,7 +29,12 @@ func TestIgnoredFolderStartReceiptNamesWhyItHasNoBranch(t *testing.T) { } defer folder.Finish("") receipt := delegateFolderReceipt(ignored, program, nil) - if strings.Contains(receipt, "holds your home folder") || !strings.Contains(receipt, "git ignores this folder inside "+repo) { + // THE REPOSITORY IS NAMED IN THE RECEIPT'S OWN SPELLING, the canonical one + // the home-folder receipt wants too: on macOS the temporary folder the test + // named says /var/folders and the system reads /private/var/folders, and a + // receipt that answered one with the other read as two places. The receipt + // prints what the system resolves, so the want is resolved beside it. + if strings.Contains(receipt, "holds your home folder") || !strings.Contains(receipt, "git ignores this folder inside "+canonicalPath(repo)) { t.Fatalf("ignored folder start receipt = %q", receipt) } } diff --git a/internal/session/skillturn_display_test.go b/internal/session/skillturn_display_test.go new file mode 100644 index 0000000000..250578f6a8 --- /dev/null +++ b/internal/session/skillturn_display_test.go @@ -0,0 +1,68 @@ +package session + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// THE TRANSCRIPT SHOWS ONLY WHAT THE PERSON TYPED. The skills a turn carries +// are model context, chosen for one message ([attachTurnSkillsLocked]); the +// block rides the copy the model reads, and the dim `skills carried` notice is +// the one channel a surface draws. The owner, 2026-09-25, #1504: the block was +// printed as part of the person's message, so every surface that renders the +// conversation printed a list nobody asked for. +// +// This is the END-TO-END reading of that law: through the real chat door, with +// an active shelf and a message that matches one of its skills, the record the +// surface draws — the store thread the replay reads — keeps the person's words +// and nothing else. +func TestTheTranscriptKeepsThePersonsWordsWholeWhenSkillsRide(t *testing.T) { + brain := openTestBrain(t) + activeSkill(t, brain, "tool:lint", "checks the lint rules for this repo", "/shelf/lint") + + completer := &scriptedCompleter{steps: []step{ + func(_ context.Context, _ []ai.Message) (*ai.Response, error) { + return textResponse("run the lint check"), nil + }, + }} + agent, _ := newTestAgent(t, completer, func(config *Config) { + config.Memory = brain + }) + + words := "how should I lint this repo?" + events, err := agent.Submit(context.Background(), words) + if err != nil { + t.Fatalf("submit: %v", err) + } + drainSkillsNotice(t, events) + + // The model's copy carries the block; the record the surface draws does not. + sent := userTextIn(completer.request(0)) + if !strings.Contains(sent, "Skills suited to this message:") { + t.Fatalf("the model's copy lost the block:\n%s", sent) + } + messages, err := brain.Messages(agent.threadID(), 0, 0) + if err != nil { + t.Fatalf("read the transcript back: %v", err) + } + said := false + for _, message := range messages { + if message.Role == "user" && strings.Contains(message.Body, words) { + said = true + } + if strings.Contains(message.Body, "Skills suited to this message:") { + t.Fatalf("the transcript printed the skills block:\n%s", message.Body) + } + if message.Role == "user" && strings.Contains(message.Body, words) { + if !strings.HasPrefix(message.Body, words) { + t.Fatalf("the transcript's copy of the message does not start with the person's words:\n%s", message.Body) + } + } + } + if !said { + t.Fatalf("the transcript never carried the person's message; thread has %d line(s)", len(messages)) + } +} From c8688615ebf45d054d8fe722ed250901ac33067e Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Fri, 25 Sep 2026 22:22:44 -0400 Subject: [PATCH 2/2] session: the transcript test settles the chatlog before reading the store back The end-to-end test read brain.Messages straight after the turn's events closed, but the transcript reaches the store through the batching chat log: the read raced the writer and on CI lost, finding an empty thread. Close the log first, the way chatlog_test reads its thread back. --- internal/session/skillturn_display_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/session/skillturn_display_test.go b/internal/session/skillturn_display_test.go index 250578f6a8..4b0786ee94 100644 --- a/internal/session/skillturn_display_test.go +++ b/internal/session/skillturn_display_test.go @@ -44,6 +44,10 @@ func TestTheTranscriptKeepsThePersonsWordsWholeWhenSkillsRide(t *testing.T) { if !strings.Contains(sent, "Skills suited to this message:") { t.Fatalf("the model's copy lost the block:\n%s", sent) } + // The transcript is written through a batching writer (chatlog.go), so the + // events closing does not mean the store has the words yet — close settles + // the queue into the store first, the way chatlog_test reads its thread back. + agent.chatlog.close() messages, err := brain.Messages(agent.threadID(), 0, 0) if err != nil { t.Fatalf("read the transcript back: %v", err)