Skip to content
Closed
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
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1504-transcript-keeps-your-words.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions internal/manual/chat/skills-a-turn-used.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions internal/session/lanenews_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion internal/session/programfolder_ignored_receipt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
72 changes: 72 additions & 0 deletions internal/session/skillturn_display_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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)
}
// 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)
}
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))
}
}
Loading