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 000000000..d372115f8 --- /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 116ac2742..247c2f5ff 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 4e2595b94..980ab918e 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 dd59c9205..9be1be982 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 000000000..4b0786ee9 --- /dev/null +++ b/internal/session/skillturn_display_test.go @@ -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)) + } +}