From a02cefb939044b9c209a47a7459381941d1104b4 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 17:04:02 -0400 Subject: [PATCH 1/4] session: refuse a namer's plan as a name, before the cut to three words A reasoning namer answered its plan instead of a label (#942) and the reader took the first three words of it, titling a task 'I'll start by'. The refusal now lives in cleanTitle, on the repaired answer and before any caller cuts: a first-person opener, 'let me', or a sequencing adverb holding its comma was never a name at any length. A noun phrase that merely ran long is still cut to three. Defined once in title.go so the session's namer and the task's namer cannot drift apart. Co-Authored-By: aforge --- internal/session/taskname_test.go | 11 +++ internal/session/title.go | 113 ++++++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/internal/session/taskname_test.go b/internal/session/taskname_test.go index 7bf5e5572f..02675d0640 100644 --- a/internal/session/taskname_test.go +++ b/internal/session/taskname_test.go @@ -87,6 +87,17 @@ func TestCleanTaskNameCutsToThreeWordsAndRefusesWhatIsNotAName(t *testing.T) { {"", ""}, {" ", ""}, {"/var/folders/j7/59f75dnd", ""}, + // THE OBSERVED ANSWERS OF #942. A reasoning namer answered its plan instead + // of a label, and the reader used to take the first three words of it. A + // sentence about the speaker or the plan is refused BY ITS SHAPE and at ANY + // LENGTH — the refusal runs before the cut to three words, so the cut can + // never manufacture a label out of the front of a sentence. + {"I'll start by creating the four bakery landing pages one at a time.", ""}, + {"Let me first look at the brief.", ""}, + {"First, I will write the four pages.", ""}, + {"I will write the four pages.", ""}, + {"We'll begin with the first page.", ""}, + {"Okay so first we look at the brief.", ""}, } { if got := cleanTaskName(c.raw); got != c.want { t.Errorf("cleanTaskName(%q) = %q, want %q", c.raw, got, c.want) diff --git a/internal/session/title.go b/internal/session/title.go index 08b978bac6..73f9961240 100644 --- a/internal/session/title.go +++ b/internal/session/title.go @@ -583,10 +583,14 @@ func compactTitle(raw string) string { // cleanTitle takes the first line and strips the things a model adds against // the instruction: the throat-clearing it opens with ("Title:", "Sure, here is -// the name:"), the MARKDOWN it emphasises with, surrounding quotes, a trailing +// the name:"), the MARKUP it emphasises with, surrounding quotes, a trailing // full stop, and the separators of a name answered as a SLUG. Then it REFUSES an -// answer that is not a name at all — the instruction handed back, or an opener -// with nothing behind it. +// answer that is not a name at all — the instruction handed back, an opener with +// nothing behind it, or a SENTENCE ABOUT THE SPEAKER OR THE PLAN ([opensAsPreamble]). +// The preamble refusal is what keeps a namer that answered its plan from being +// shortened into a label: it runs HERE, before any caller cuts the answer to a +// few words, because the front of "I'll start by creating the four bakery +// landing pages" is not a name at any length. // // The slug is the one worth explaining. The instruction asks for words, and a // model that has spent its life reading identifiers sometimes answers @@ -621,7 +625,7 @@ func cleanTitle(raw string) string { title = strings.Join(strings.Fields(title), " ") } title = clip(strings.TrimSpace(title), titleLimit) - if namesTheInstruction(title) || unusableName(title) { + if namesTheInstruction(title) || unusableName(title) || opensAsPreamble(title) { return "" } return title @@ -748,6 +752,21 @@ func namesTheInstruction(name string) bool { return false } +// isOpener reads the words in front of a colon and says whether they are an +// announcement. Either end decides it: an interjection at the front ("sure, +// here is the name"), or one of the words a label ends on at the back ("title", +// "session name", "the session is about", "the title is"). +func isOpener(words []string) bool { + if isInterjection(words[0]) { + return true + } + switch words[len(words)-1] { + case "title", "name", "is", "about", "called", "answer", "caption", "full", "tab": + return true + } + return false +} + // openerLimit and openerWords bound what may be read as throat-clearing. A // label is short and stands at the very front; anything longer is a sentence // the model meant, and cutting at a colon inside one would take half a name @@ -830,18 +849,88 @@ func allOpenerWords(words []string) bool { return true } -// isOpener reads the words in front of a colon and says whether they are an -// announcement. Either end decides it: an interjection at the front ("sure, -// here is the name"), or one of the words a label ends on at the back ("title", -// "session name", "the session is about", "the title is"). -func isOpener(words []string) bool { - if isInterjection(words[0]) { +// ── an answer that was never a name ───────────────────────────────────────── +// +// A REASONING MODEL NARRATES ITS PLAN BEFORE IT FOLLOWS ONE, and a "name this" +// call does not stop it doing that. Measured in the field (#942): every +// tier and role pinned to a reasoning model, and the namer answered +// "I'll start by creating the four bakery landing pages one at a time." — +// 264 completion tokens about what it was about to do. The name reader took +// the first three words of that and a task was titled `I'll start by`, in a +// column a person reads, while the node's own record still carried the +// person's words beside it. The cut that makes a label out of any answer is +// the defect: a sentence is not refused for being a sentence, it is shortened +// until it is the right length to look like a name. +// +// So a name is REFUSED BY ITS SHAPE AND NEVER BY ITS LENGTH: an answer that +// opens as a sentence about the speaker or the plan — a first-person pronoun or +// its contraction, `let me`, a sequencing adverb holding its comma — was never a +// label at any length, where a noun phrase that merely ran long +// (`launch post for existing users`) is cut to three and stays a name. The +// refusal lives HERE, in the one hand both namers read through, so the session's +// namer and the task's namer cannot drift apart on it. + +// preambleVocabulary is the words a sentence about the speaker or the plan opens +// with. It is matched on the first words of the answer only, never on the whole +// of it, so a name that merely contains one of these words ("a fix for my i o +// error", "first pass on the brief") is untouched — the test is whether the +// answer OPENS as narration, not whether narration appears in it. It sits beside +// [openerVocabulary] because the two are the same kind of thing: the words a +// model reaches for when it is not answering. +var preambleVocabulary = map[string]bool{ + // First-person pronouns and their contractions, in every spelling a + // normalizedWords fold can produce ("id" is "I'd", "ill" is "I'll"). + "i": true, "id": true, "ill": true, "im": true, "ive": true, "i'm": true, + "i'll": true, "i've": true, "i'd": true, "we": true, "we've": true, "we're": true, + "we'll": true, "lets": true, "let": true, "me": true, "my": true, "myself": true, + "us": true, "our": true, "ours": true, +} + +// preambleAdverbs are the sequencing openers a plan narrates itself with. They +// are refused WITH THEIR COMMA — the adverb alone ("first pass on the brief") +// is a noun phrase that begins a real name, where "First, I will write the four +// pages" is a clause about what is about to happen. +var preambleAdverbs = []string{"first", "next", "then", "now", "okay", "ok", "alright", "sure"} + +// opensAsPreamble reports whether an answer opens as a sentence about the +// speaker or the plan rather than as a name. It runs on the REPAIRED answer, +// before any caller cuts it, because the shape being refused is the front of a +// sentence and a cut to three words would leave the check reading "I'll start +// by" as though it were a label. +func opensAsPreamble(title string) bool { + words := normalizedWords(title) + if len(words) == 0 { + return false + } + if preambleVocabulary[words[0]] { return true } - switch words[len(words)-1] { - case "title", "name", "is", "about", "called", "answer", "caption", "full", "tab": + // "let me" is its own opener; a lone "let" or "me" above already covers the + // one-word case. + if len(words) > 1 && (words[0] == "let" || words[0] == "lets") && words[1] == "me" { return true } + // A sequencing adverb holding its comma opens a clause: "First, I will …". + // The comma is the test — "first pass on the brief" is a noun phrase that + // begins a real name, where the adverb in front of a comma is narrating the + // plan before doing it. + firstRaw := strings.IndexAny(title, ",") + if firstRaw > 0 { + if lead := strings.TrimSpace(title[:firstRaw]); len(strings.Fields(lead)) == 1 { + for _, adverb := range preambleAdverbs { + if strings.EqualFold(strings.TrimSpace(lead), adverb) { + return true + } + } + } + } + // "okay so" and its spellings — a plan clearing its throat in two words. + lower := strings.ToLower(strings.Join(words, " ")) + for _, opener := range []string{"okay so", "ok so", "alright so"} { + if strings.HasPrefix(lower, opener+" ") { + return true + } + } return false } From 91c8425a55331cf221032353bab8635e20905ed7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 17:55:04 -0400 Subject: [PATCH 2/4] lane #942: what the run left on disk Committed by af-lane.sh, not by the worker. Read the diff before landing any of it. --- .../unreleased/942-namer-refuses-a-plan.md | 29 +++++ internal/manual/chat/tasks.md | 5 +- internal/session/taskname_test.go | 120 ++++++++++++------ 3 files changed, 112 insertions(+), 42 deletions(-) create mode 100644 docs/changes/unreleased/942-namer-refuses-a-plan.md diff --git a/docs/changes/unreleased/942-namer-refuses-a-plan.md b/docs/changes/unreleased/942-namer-refuses-a-plan.md new file mode 100644 index 0000000000..1cacb7d09f --- /dev/null +++ b/docs/changes/unreleased/942-namer-refuses-a-plan.md @@ -0,0 +1,29 @@ +--- +kind: changed +title: A namer that answers its plan is refused, so a task keeps the person's own words as its title +pr: 942 +surface: [chat, engine] +invalidates: + - "A task whose namer answered with a sentence was named from the front of that sentence: the rail row and the task page read `I'll start by` while the node's own record carried the person's words right beside the name it had been given. No longer true: an answer that opens as a sentence about the speaker or the plan is refused at any length, and the row keeps the fallback title — the person's own words, clipped by the column exactly as an unnamed task's title always was." + - "The repair a namer's answer was read through (`cleanTitle` in `internal/session/title.go`) covered markup, quotes, announcements, instruction echoes and empty-subject answers, and a first-person preamble was none of those. No longer true: it refuses `I'll start by`, `Let me first`, `First, I will`, `We'll begin`, `okay so` — a first-person pronoun or contraction, a sequencing adverb holding its comma — before `firstWordsOf` cuts to three words, so the cut can never manufacture a label out of the front of a sentence." + - "The session's namer and the task's namer each read an answer through its own hand, so the two could drift apart on what counts as a name. No longer true: the preamble vocabulary lives beside `openerVocabulary` in the one shared cleaner both namers call, so a conversation title and a task name are refused for the same words." + - "A four-word answer was cut to three words whatever it was, and that same cut is what let a paragraph pass as a label. No longer true: `launch post for existing users` is still cut to `launch post for`, because that answer was a label that ran long; `I'll start by creating the four bakery landing pages` is not a label at any length." +--- + +The namer is the cheapest call in the session, asked for two or three lowercase +words, and the prompt is what kept it to that — until a run pinned every tier +and role to a reasoning model and the namer answered its plan instead: 264 +completion tokens of "I'll start by creating the four bakery landing pages one +at a time." Nothing in the path refused a sentence. `firstWordsOf` cut it to +three words and trimmed the trailing marks, `taskNameNeeded` asked only whether +an answer was empty, an echo, a path or over three words, and the task was +titled `I'll start by` on the rail, on the task page and in its own record — +while `spec.title`, the person's own words, sat there as the fallback the +truncated preamble had overwritten. The refusal now lives where both namers +already read an answer: `cleanTitle` returns the empty string for an answer that +opens as narration, the task namer leaves the row's title exactly where it was, +and that title is the person's own sentence clipped by the column. A refusal +costs the same as a timeout and lands on a road that already worked, so the +session's namer gains the same refusal for free. The manual page for tasks +(`internal/manual/chat/tasks.md`, *Why my task is called something I did not +type*) says so in the same words. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 315234fae4..c9dcb4514a 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -462,7 +462,10 @@ field in an answer aforge was already paying for and already waiting on. all — only the sentence it was started from: a `/task` whose shaping could not run, work that started on its own after a words-only turn, an adaptive run's own row. That title is handed to the cheap `taskname` role, which reads the work and answers with two or three lowercase words. -Empty replies, instruction echoes and placeholders such as `nothing to name` are refused. +Empty replies, instruction echoes and placeholders such as `nothing to name` are refused. An answer +that is a sentence about what the model is about to do — `I'll start by …`, `Let me first …`, +`First, I will …` — is refused too: a name is a label for the work, not the front of a plan, so +the row keeps the fallback instead of being titled after somebody's opening words. The next configured naming model may answer within the same time limit; if it cannot, the existing fallback stays. A previously saved placeholder such as `nothing to name` is named again from its saved brief when you reopen the conversation; the work itself diff --git a/internal/session/taskname_test.go b/internal/session/taskname_test.go index 02675d0640..3f78d5f434 100644 --- a/internal/session/taskname_test.go +++ b/internal/session/taskname_test.go @@ -107,49 +107,87 @@ func TestCleanTaskNameCutsToThreeWordsAndRefusesWhatIsNotAName(t *testing.T) { // THE WHOLE FEATURE, END TO END: a task admitted under a sentence is renamed by // one cheap call, the new name is what every surface is told, and it is in the -// checkpoint so a restart keeps it. +// checkpoint so a restart keeps it. THE SECOND ROW IS #942'S WHOLE DOOR: a +// namer that answered its plan instead of a label — 264 completion tokens of it, +// measured in the field — is refused, and the row keeps the person's own words, +// which is the title it was already drawn under. func TestWorkAdmittedUnderASentenceIsNamedByTheCheapModel(t *testing.T) { - client := &scriptedCompleter{steps: []step{func(_ context.Context, messages []ai.Message) (*ai.Response, error) { - if !isNameCall(messages) { - t.Errorf("the first call was not the namer's: %q", messageContentText(messages[0])) - } - return textResponse("parser recon"), nil - }}} - agent, _ := newTestAgent(t, client, func(c *Config) { c.RolesSource = nameSettings() }) - ran := make(chan uint64, 4) - graph := stubbedGraph(agent, func(node *TaskNode) { ran <- node.id }) - - updates := agent.TaskUpdates() - id := graph.reserve() - graph.admit(id, taskSpec{ - title: "read /Users/me/src and say what the parser does", - summary: "read the parser", - brief: "Read the parser under /Users/me/src and write down what it does.", - acceptance: "a note saying what the parser does", - }) - select { - case <-ran: - case <-time.After(5 * time.Second): - t.Fatal("the admitted node never ran") - } + const sentence = "read /Users/me/src and say what the parser does" + for _, c := range []struct { + name string + answer string + want string + renames bool + }{ + {"a namer that answers with a label", "parser recon", "parser recon", true}, + // THE OBSERVED ANSWER OF #942. A refusal is the empty string, and that is + // a road which already works: the caller's only response is to leave the + // title where it was, and where it was is the person's own words. Nothing + // is published, so no surface is ever told "I'll start by". + {"a namer that answers its plan", "I'll start by creating the four bakery landing pages one at a time.", sentence, false}, + } { + t.Run(c.name, func(t *testing.T) { + client := &scriptedCompleter{steps: []step{func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + if !isNameCall(messages) { + t.Errorf("the first call was not the namer's: %q", messageContentText(messages[0])) + } + return textResponse(c.answer), nil + }}} + agent, _ := newTestAgent(t, client, func(cfg *Config) { cfg.RolesSource = nameSettings() }) + ran := make(chan uint64, 4) + graph := stubbedGraph(agent, func(node *TaskNode) { ran <- node.id }) - // THE NAME LANDS ON THE NODE, and it is the title from then on. - if !nameLanded(func() bool { return graph.node(id).title() == "parser recon" }) { - t.Fatalf("the node is still called %q", graph.node(id).title()) - } - // AND THE WORLD IS TOLD. A row already on screen under the sentence learns - // the name from an ordinary update. - if !waitForNamedRow(updates, id, "parser recon") { - t.Fatal("no update carried the new name") - } - // AND IT IS KEPT. The checkpoint is what a resumed session reads its roster - // back out of, so a name that only lived in this process would be a row that - // went back to its sentence after a restart. - if got := graph.document().Nodes[0].Title; got != "parser recon" { - t.Fatalf("the checkpoint says %q", got) - } - if got := client.model(0); got != "cheap/model" { - t.Fatalf("the namer ran on %q, want the cheap tier", got) + updates := agent.TaskUpdates() + id := graph.reserve() + graph.admit(id, taskSpec{ + title: sentence, + summary: "read the parser", + brief: "Read the parser under /Users/me/src and write down what it does.", + acceptance: "a note saying what the parser does", + }) + select { + case <-ran: + case <-time.After(5 * time.Second): + t.Fatal("the admitted node never ran") + } + // THE NAMER REALLY WAS ASKED, in both rows: a title the call never + // touched reads the same as one a refusal left alone, and only one of + // those is this feature working. + if !nameLanded(func() bool { return client.requests() > 0 }) { + t.Fatal("the namer was never asked") + } + // THE NAME LANDS ON THE NODE, and it is the title from then on. In the + // refused row this is the row it never left. + if !nameLanded(func() bool { return graph.node(id).title() == c.want }) { + t.Fatalf("the node is called %q, want %q", graph.node(id).title(), c.want) + } + // AND THE WORLD IS TOLD. A row already on screen under the sentence + // learns the name from an ordinary update — and a refusal publishes + // nothing at all, which is the other half of the claim: no surface is + // ever handed the front of a sentence. + if c.renames && !waitForNamedRow(updates, id, c.want) { + t.Fatal("no update carried the new name") + } + // AND IT IS KEPT. The checkpoint is what a resumed session reads its + // roster back out of, so a name that only lived in this process would + // be a row that went back to its sentence after a restart. + if got := graph.document().Nodes[0].Title; got != c.want { + t.Fatalf("the checkpoint says %q, want %q", got, c.want) + } + if got := client.model(0); got != "cheap/model" { + t.Fatalf("the namer ran on %q, want the cheap tier", got) + } + // AND A REFUSAL IS A WINDOW IN WHICH A RENAME COULD STILL HAVE ARRIVED. + // The poll above can be satisfied before the answer has been read back, + // so the refused row waits out the answer's own latency and then holds + // the title: nothing arrives, and the person's words are what stays. + if !c.renames { + time.Sleep(200 * time.Millisecond) + if got := graph.node(id).title(); got != c.want { + t.Fatalf("a refused name moved the row to %q", got) + } + } + }) } } From 846974332284b5c07c49e8395fe6c5c33d975dd8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 18:08:22 -0400 Subject: [PATCH 3/4] session: a name is a noun phrase, and the opener comes off before it is read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refusal the lane left behind was a second vocabulary of its own — a map of first-person spellings the fold in normalizedWords can never produce, a list of sequencing adverbs matched against the raw comma, and a third list for "okay so" — three mechanisms deciding one thing, and a name opening on "my" or "me" refused with them. It is one rule now, and it is grammar rather than a list of sentences: a first-person subject followed by the word that makes it the subject of something about to happen (opensAsPlan). BOTH HALVES ARE REQUIRED, so "we chat cutover" is still a name where "we'll begin with the first page" is not. The throat-clearing a plan opens on is not a second rule: "first", "next", "then", "now" and "so" join the interjections stripInterjection already takes off every answer both namers read, which also mints "First, the bakery pages" as "the bakery pages" — and that stripped opener is why the pronoun is the first word by the time opensAsPlan reads it. The law covers both namers in one test (titleecho_test.go), with the three answers observed in the field, the shapes around them, and the controls that must keep passing: a label that ran long is still cut to three words. The door fixture scripts every rung the ladder has, because a refused answer is asked again below and a one-step script was answered "(unscripted)" — which the reader then accepted as a name. Its refusing row no longer sleeps: rename is only ever reached with a name, so once every rung refuses there is no later moment at which the row could move. Co-Authored-By: Claude Opus 5 (1M context) --- .../unreleased/942-namer-refuses-a-plan.md | 39 ++-- internal/session/taskname_test.go | 55 ++++-- internal/session/title.go | 173 +++++++++--------- internal/session/titleecho_test.go | 45 +++++ 4 files changed, 184 insertions(+), 128 deletions(-) diff --git a/docs/changes/unreleased/942-namer-refuses-a-plan.md b/docs/changes/unreleased/942-namer-refuses-a-plan.md index 1cacb7d09f..60f0b88965 100644 --- a/docs/changes/unreleased/942-namer-refuses-a-plan.md +++ b/docs/changes/unreleased/942-namer-refuses-a-plan.md @@ -1,29 +1,30 @@ --- -kind: changed -title: A namer that answers its plan is refused, so a task keeps the person's own words as its title +kind: fixed +title: A namer that answers with its plan is refused, so the task keeps the person's own words pr: 942 surface: [chat, engine] invalidates: - - "A task whose namer answered with a sentence was named from the front of that sentence: the rail row and the task page read `I'll start by` while the node's own record carried the person's words right beside the name it had been given. No longer true: an answer that opens as a sentence about the speaker or the plan is refused at any length, and the row keeps the fallback title — the person's own words, clipped by the column exactly as an unnamed task's title always was." - - "The repair a namer's answer was read through (`cleanTitle` in `internal/session/title.go`) covered markup, quotes, announcements, instruction echoes and empty-subject answers, and a first-person preamble was none of those. No longer true: it refuses `I'll start by`, `Let me first`, `First, I will`, `We'll begin`, `okay so` — a first-person pronoun or contraction, a sequencing adverb holding its comma — before `firstWordsOf` cuts to three words, so the cut can never manufacture a label out of the front of a sentence." - - "The session's namer and the task's namer each read an answer through its own hand, so the two could drift apart on what counts as a name. No longer true: the preamble vocabulary lives beside `openerVocabulary` in the one shared cleaner both namers call, so a conversation title and a task name are refused for the same words." - - "A four-word answer was cut to three words whatever it was, and that same cut is what let a paragraph pass as a label. No longer true: `launch post for existing users` is still cut to `launch post for`, because that answer was a label that ran long; `I'll start by creating the four bakery landing pages` is not a label at any length." + - "A task whose namer answered with a sentence was named from the front of that sentence: the rail row and the task page read `I'll start by` while the node's own record carried the person's words right beside it. No longer true: an answer that opens as a clause about the speaker is refused at any length, and the row keeps the fallback — the person's own words, clipped by the column exactly as an unnamed task's title always was." + - "The repair both namers read an answer through (`cleanTitle` in `internal/session/title.go`) covered markup, quotes, announcements, instruction echoes and empty-subject answers, and a first-person preamble was none of those. No longer true: a first-person subject followed by an auxiliary — `I'll start by`, `We'll begin`, `Let me first`, `I am going to` — is refused before `firstWordsOf` cuts to three words, so the cut can never manufacture a label out of the front of a sentence. Both halves are required, so `we chat cutover` is still a name." + - "Throat-clearing a model puts in front of an answer was the agreement it opened on — `Sure,`, `Okay!`. It is now also the sequencing adverb a plan narrates itself with, so `First, I will write the four pages` and `Okay so I'll look at the brief` have their opener taken off by the same `stripInterjection` every namer already shared, and `First, the bakery pages` is minted as `the bakery pages`." --- The namer is the cheapest call in the session, asked for two or three lowercase -words, and the prompt is what kept it to that — until a run pinned every tier +words, and the prompt was what kept it to that — until a run pinned every tier and role to a reasoning model and the namer answered its plan instead: 264 completion tokens of "I'll start by creating the four bakery landing pages one at a time." Nothing in the path refused a sentence. `firstWordsOf` cut it to -three words and trimmed the trailing marks, `taskNameNeeded` asked only whether -an answer was empty, an echo, a path or over three words, and the task was -titled `I'll start by` on the rail, on the task page and in its own record — -while `spec.title`, the person's own words, sat there as the fallback the -truncated preamble had overwritten. The refusal now lives where both namers -already read an answer: `cleanTitle` returns the empty string for an answer that -opens as narration, the task namer leaves the row's title exactly where it was, -and that title is the person's own sentence clipped by the column. A refusal -costs the same as a timeout and lands on a road that already worked, so the -session's namer gains the same refusal for free. The manual page for tasks -(`internal/manual/chat/tasks.md`, *Why my task is called something I did not -type*) says so in the same words. +three words, `taskNameNeeded` asked only whether an answer was empty, an echo, a +path or over three words, and the task was titled `I'll start by` on the rail, on +the task page and in its own record — while `spec.title`, the person's own words, +sat there as the fallback the truncated preamble had overwritten. + +The refusal is grammar rather than a list of sentences, and it lives where both +namers already read an answer: a first-person subject followed by the word that +makes it the subject of something about to happen. A refusal is the empty string, +which is a road that already worked — the task namer leaves the row's title +exactly where it was, and that title is the person's own sentence clipped by the +column. A noun phrase that merely ran long is still cut to three words and is +still a name, which is the whole difficulty of the fix: `launch post for existing +users` was a label, and `I'll start by creating the four bakery landing pages` was +never one at any length. diff --git a/internal/session/taskname_test.go b/internal/session/taskname_test.go index 3f78d5f434..2da668b725 100644 --- a/internal/session/taskname_test.go +++ b/internal/session/taskname_test.go @@ -87,17 +87,27 @@ func TestCleanTaskNameCutsToThreeWordsAndRefusesWhatIsNotAName(t *testing.T) { {"", ""}, {" ", ""}, {"/var/folders/j7/59f75dnd", ""}, - // THE OBSERVED ANSWERS OF #942. A reasoning namer answered its plan instead - // of a label, and the reader used to take the first three words of it. A - // sentence about the speaker or the plan is refused BY ITS SHAPE and at ANY - // LENGTH — the refusal runs before the cut to three words, so the cut can - // never manufacture a label out of the front of a sentence. + // THE THREE ANSWERS OBSERVED IN #942, and the shapes beside them. A + // reasoning namer answered its plan instead of a label and the reader took + // the first three words of it. A clause about the speaker is refused BY ITS + // SHAPE AND AT ANY LENGTH — the refusal runs before the cut to three words, + // so the cut can never manufacture a label out of the front of a sentence. {"I'll start by creating the four bakery landing pages one at a time.", ""}, {"Let me first look at the brief.", ""}, {"First, I will write the four pages.", ""}, {"I will write the four pages.", ""}, {"We'll begin with the first page.", ""}, - {"Okay so first we look at the brief.", ""}, + {"Okay so I'll look at the brief.", ""}, + // AND A NAME THAT MERELY OPENS ON ONE OF THOSE WORDS IS NOT TOUCHED, which + // is what the second half of the test buys: the pronoun has to be the + // subject of something about to happen before the answer stops being a + // name. The four-word row above it is the other control — that answer was + // a label that ran long, so it is cut and kept. + {"we chat cutover", "we chat cutover"}, + // AND A SEQUENCING ADVERB THAT IS PART OF A NAME KEEPS ITS PLACE. It is + // throat-clearing only where it stands in front of an answer, punctuated + // off or carried by "so", which is [stripInterjection]'s whole test. + {"first pass on the brief", "first pass on"}, } { if got := cleanTaskName(c.raw); got != c.want { t.Errorf("cleanTaskName(%q) = %q, want %q", c.raw, got, c.want) @@ -127,12 +137,23 @@ func TestWorkAdmittedUnderASentenceIsNamedByTheCheapModel(t *testing.T) { {"a namer that answers its plan", "I'll start by creating the four bakery landing pages one at a time.", sentence, false}, } { t.Run(c.name, func(t *testing.T) { - client := &scriptedCompleter{steps: []step{func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + // EVERY RUNG THE LADDER HAS IS SCRIPTED, and the refusing row is why. + // An answer the reader refuses is not an answer, so the ladder asks the + // rung below it ([Agent.callRoleChecked]'s accept check) — a script one + // step long would be answered "(unscripted)" on that second ask and the + // fixture would be measuring the fixture. Both rungs say the same thing, + // which is what the model in the field did. + answer := func(_ context.Context, messages []ai.Message) (*ai.Response, error) { if !isNameCall(messages) { - t.Errorf("the first call was not the namer's: %q", messageContentText(messages[0])) + t.Errorf("a call that was not the namer's: %q", messageContentText(messages[0])) } return textResponse(c.answer), nil - }}} + } + steps := make([]step, roleFallThroughs+1) + for i := range steps { + steps[i] = answer + } + client := &scriptedCompleter{steps: steps} agent, _ := newTestAgent(t, client, func(cfg *Config) { cfg.RolesSource = nameSettings() }) ran := make(chan uint64, 4) graph := stubbedGraph(agent, func(node *TaskNode) { ran <- node.id }) @@ -177,16 +198,12 @@ func TestWorkAdmittedUnderASentenceIsNamedByTheCheapModel(t *testing.T) { if got := client.model(0); got != "cheap/model" { t.Fatalf("the namer ran on %q, want the cheap tier", got) } - // AND A REFUSAL IS A WINDOW IN WHICH A RENAME COULD STILL HAVE ARRIVED. - // The poll above can be satisfied before the answer has been read back, - // so the refused row waits out the answer's own latency and then holds - // the title: nothing arrives, and the person's words are what stays. - if !c.renames { - time.Sleep(200 * time.Millisecond) - if got := graph.node(id).title(); got != c.want { - t.Fatalf("a refused name moved the row to %q", got) - } - } + // AND A REFUSAL NEEDS NO WINDOW WAITED OUT. [TaskGraph.rename] is only + // ever reached with a name that is not the empty string, so once every + // answer this fixture can give is one the reader refuses, there is no + // later moment at which the row could still move. The poll above is + // what proves the call was made at all, which is the half a sleep + // could not have proved either way. }) } } diff --git a/internal/session/title.go b/internal/session/title.go index 73f9961240..dea6585c09 100644 --- a/internal/session/title.go +++ b/internal/session/title.go @@ -586,11 +586,11 @@ func compactTitle(raw string) string { // the name:"), the MARKUP it emphasises with, surrounding quotes, a trailing // full stop, and the separators of a name answered as a SLUG. Then it REFUSES an // answer that is not a name at all — the instruction handed back, an opener with -// nothing behind it, or a SENTENCE ABOUT THE SPEAKER OR THE PLAN ([opensAsPreamble]). -// The preamble refusal is what keeps a namer that answered its plan from being -// shortened into a label: it runs HERE, before any caller cuts the answer to a -// few words, because the front of "I'll start by creating the four bakery -// landing pages" is not a name at any length. +// nothing behind it, or A CLAUSE ABOUT THE SPEAKER RATHER THAN A NAME FOR THE +// WORK ([opensAsPlan]). That last refusal is what keeps a namer that answered +// its plan from being shortened into a label: it runs HERE, before any caller +// cuts the answer to a few words, because the front of "I'll start by creating +// the four bakery landing pages" is not a name at any length. // // The slug is the one worth explaining. The instruction asks for words, and a // model that has spent its life reading identifiers sometimes answers @@ -625,7 +625,7 @@ func cleanTitle(raw string) string { title = strings.Join(strings.Fields(title), " ") } title = clip(strings.TrimSpace(title), titleLimit) - if namesTheInstruction(title) || unusableName(title) || opensAsPreamble(title) { + if namesTheInstruction(title) || unusableName(title) || opensAsPlan(title) { return "" } return title @@ -819,7 +819,16 @@ func stripOpener(title string) string { func stripInterjection(title string) string { if cut := strings.IndexAny(title, ",!"); cut > 0 && cut <= openerLimit { if words := normalizedWords(title[:cut]); len(words) == 1 && isInterjection(words[0]) { - title = strings.TrimSpace(title[cut+1:]) + return strings.TrimSpace(title[cut+1:]) + } + } + // AND IT NEEDS NO PUNCTUATION WHEN "SO" IS CARRYING IT. A model writing the + // way somebody talks opens "okay so the bakery pages" with no comma at all, + // and `so` after an agreement is that comma spelled as a word. Something has + // to be left behind it, or this would strip an answer down to nothing. + if fields := strings.Fields(title); len(fields) > 2 && strings.EqualFold(fields[1], "so") { + if words := normalizedWords(fields[0]); len(words) == 1 && isInterjection(words[0]) { + return strings.Join(fields[2:], " ") } } return title @@ -851,94 +860,78 @@ func allOpenerWords(words []string) bool { // ── an answer that was never a name ───────────────────────────────────────── // -// A REASONING MODEL NARRATES ITS PLAN BEFORE IT FOLLOWS ONE, and a "name this" -// call does not stop it doing that. Measured in the field (#942): every -// tier and role pinned to a reasoning model, and the namer answered -// "I'll start by creating the four bakery landing pages one at a time." — -// 264 completion tokens about what it was about to do. The name reader took -// the first three words of that and a task was titled `I'll start by`, in a -// column a person reads, while the node's own record still carried the -// person's words beside it. The cut that makes a label out of any answer is -// the defect: a sentence is not refused for being a sentence, it is shortened -// until it is the right length to look like a name. -// -// So a name is REFUSED BY ITS SHAPE AND NEVER BY ITS LENGTH: an answer that -// opens as a sentence about the speaker or the plan — a first-person pronoun or -// its contraction, `let me`, a sequencing adverb holding its comma — was never a -// label at any length, where a noun phrase that merely ran long -// (`launch post for existing users`) is cut to three and stays a name. The -// refusal lives HERE, in the one hand both namers read through, so the session's -// namer and the task's namer cannot drift apart on it. - -// preambleVocabulary is the words a sentence about the speaker or the plan opens -// with. It is matched on the first words of the answer only, never on the whole -// of it, so a name that merely contains one of these words ("a fix for my i o -// error", "first pass on the brief") is untouched — the test is whether the -// answer OPENS as narration, not whether narration appears in it. It sits beside -// [openerVocabulary] because the two are the same kind of thing: the words a -// model reaches for when it is not answering. -var preambleVocabulary = map[string]bool{ - // First-person pronouns and their contractions, in every spelling a - // normalizedWords fold can produce ("id" is "I'd", "ill" is "I'll"). - "i": true, "id": true, "ill": true, "im": true, "ive": true, "i'm": true, - "i'll": true, "i've": true, "i'd": true, "we": true, "we've": true, "we're": true, - "we'll": true, "lets": true, "let": true, "me": true, "my": true, "myself": true, - "us": true, "our": true, "ours": true, -} - -// preambleAdverbs are the sequencing openers a plan narrates itself with. They -// are refused WITH THEIR COMMA — the adverb alone ("first pass on the brief") -// is a noun phrase that begins a real name, where "First, I will write the four -// pages" is a clause about what is about to happen. -var preambleAdverbs = []string{"first", "next", "then", "now", "okay", "ok", "alright", "sure"} - -// opensAsPreamble reports whether an answer opens as a sentence about the -// speaker or the plan rather than as a name. It runs on the REPAIRED answer, -// before any caller cuts it, because the shape being refused is the front of a -// sentence and a cut to three words would leave the check reading "I'll start -// by" as though it were a label. -func opensAsPreamble(title string) bool { +// A REASONING MODEL NARRATES ITS PLAN BEFORE IT FOLLOWS ONE, and a call asking +// for a name does not stop it doing that. Measured in the field (#942), with +// every tier and role pinned to a reasoning model, the namer answered "I'll +// start by creating the four bakery landing pages one at a time." — 264 +// completion tokens about what it was about to do. The reader took the first +// three words of it and a task stood on the rail called `I'll start by`, while +// the node's own record still carried the person's words right beside the name +// it had been given. +// +// THE CUT IS WHAT MADE A SENTENCE LOOK LIKE A LABEL. Nothing refused an answer +// for being a sentence; it was shortened until it was the right length to pass +// for a name. So an answer is refused BY ITS SHAPE AND AT ANY LENGTH: a name is +// a noun phrase, and a clause about the speaker is not one. A noun phrase that +// merely ran long ("launch post for existing users") is still cut to three +// words and is still a name. +// +// THE TEST IS THE GRAMMAR AND NOT A LIST OF SENTENCES: a first-person subject, +// followed by the word that makes it the subject of something about to happen — +// an auxiliary, the remnant the fold in [normalizedWords] leaves of one written +// as a contraction, or the person `let` is addressed about. BOTH HALVES ARE +// REQUIRED, which is what keeps a real name that opens on one of those words +// ("I/O error fix") out of it. +// +// The throat-clearing a plan opens on — "First, I will …", "Okay so I'll …" — +// is NOT a second rule here. It is an opener, and [stripInterjection] is the one +// place this file takes an opener off an answer, so it is taken off there and +// this reads what is left. + +// planSubjects is who a plan is about. The fold in [normalizedWords] reads every +// mark as a space, so a contraction arrives as its stem and a remnant ("I'll" is +// "i ll", "let's" is "let s") and the pronoun itself is the only spelling that +// has to be written down. It sits beside [openerVocabulary] because the two are +// the same kind of thing: the words a model reaches for when it is not +// answering the question it was asked. +var planSubjects = map[string]bool{"i": true, "we": true, "let": true, "lets": true} + +// planPredicates is the word that follows that subject when the sentence is +// about what the speaker is about to do: the auxiliary or modal a finite clause +// hangs on, the remnant a contraction leaves of one, or the person `let` is +// addressed about. IT IS A CLOSED CLASS OF GRAMMAR AND NOT A LIST OF OBSERVED +// SENTENCES, which is why it is this short and why a preamble nobody has met yet +// is already refused. +var planPredicates = map[string]bool{ + "ll": true, "m": true, "ve": true, "d": true, "re": true, "s": true, + "will": true, "would": true, "shall": true, "should": true, "can": true, + "could": true, "may": true, "might": true, "must": true, "am": true, + "is": true, "are": true, "was": true, "were": true, "have": true, + "has": true, "had": true, "do": true, "does": true, "did": true, + "need": true, "going": true, "me": true, "us": true, +} + +// opensAsPlan reports whether an answer opens as a clause about the speaker +// rather than as a name for the work. It runs on the REPAIRED answer and before +// any caller cuts it, because the shape it refuses is the FRONT of a sentence: +// read after the cut to three words it would be handed "I'll start by", which +// is the same three words a label would have been. +func opensAsPlan(title string) bool { words := normalizedWords(title) - if len(words) == 0 { - return false - } - if preambleVocabulary[words[0]] { - return true - } - // "let me" is its own opener; a lone "let" or "me" above already covers the - // one-word case. - if len(words) > 1 && (words[0] == "let" || words[0] == "lets") && words[1] == "me" { - return true - } - // A sequencing adverb holding its comma opens a clause: "First, I will …". - // The comma is the test — "first pass on the brief" is a noun phrase that - // begins a real name, where the adverb in front of a comma is narrating the - // plan before doing it. - firstRaw := strings.IndexAny(title, ",") - if firstRaw > 0 { - if lead := strings.TrimSpace(title[:firstRaw]); len(strings.Fields(lead)) == 1 { - for _, adverb := range preambleAdverbs { - if strings.EqualFold(strings.TrimSpace(lead), adverb) { - return true - } - } - } - } - // "okay so" and its spellings — a plan clearing its throat in two words. - lower := strings.ToLower(strings.Join(words, " ")) - for _, opener := range []string{"okay so", "ok so", "alright so"} { - if strings.HasPrefix(lower, opener+" ") { - return true - } - } - return false + return len(words) > 1 && planSubjects[words[0]] && planPredicates[words[1]] } -// isInterjection is the handful of words a model agrees with the request in -// before it answers it. +// isInterjection is the handful of words a model clears its throat with before +// it answers: the agreement it opens on, and the SEQUENCING ADVERB a plan +// narrates itself with ("First, I will write the four pages"). They are one list +// because they are one thing to a reader — a word about the answering rather +// than about the work — and because the plan a reasoning namer opens on (#942) +// has to have its opener taken off it HERE, before [opensAsPlan] reads what is +// left, or the pronoun that decides it is never the first word. func isInterjection(word string) bool { switch word { - case "sure", "ok", "okay", "certainly", "absolutely", "here", "heres": + case "sure", "ok", "okay", "certainly", "absolutely", "here", "heres", + "first", "next", "then", "now", "alright", "so": return true } return false diff --git a/internal/session/titleecho_test.go b/internal/session/titleecho_test.go index 5b12113c4d..97e611b4d3 100644 --- a/internal/session/titleecho_test.go +++ b/internal/session/titleecho_test.go @@ -71,6 +71,51 @@ func TestTheOpenerIsStrippedAndAnEmptyOneRefused(t *testing.T) { } } +// A NAMER THAT ANSWERED ITS PLAN HAS NOT NAMED ANYTHING EITHER, and #942 is +// where that was measured: a reasoning model on the task namer's call wrote a +// paragraph about what it was about to do, and the reader cut the first three +// words of it into a task called `I'll start by`. A clause about the speaker is +// refused at any length, in the ONE HAND BOTH NAMERS READ THROUGH — so the +// session's name and a task's name cannot drift apart on what a name is. +func TestANameThatIsAPlanIsRefusedByBothNamers(t *testing.T) { + for _, said := range []string{ + // The three observed in the field. + "I'll start by creating the four bakery landing pages one at a time.", + "Let me first look at the brief.", + "First, I will write the four pages.", + // And the same shape in the spellings around them: the opener taken off + // by punctuation or by "so", the plural speaker, the bare auxiliary. + "Okay so I'll look at the brief.", + "We'll begin with the first page.", + "I am going to write the four pages.", + "Then, we can start on the pages.", + } { + if got := cleanTitle(said); got != "" { + t.Errorf("cleanTitle(%q) = %q, want a plan refused", said, got) + } + if got := cleanTaskName(said); got != "" { + t.Errorf("cleanTaskName(%q) = %q, want a plan refused", said, got) + } + if got := cleanConversationTitle(said); got.full != "" || got.short != "" { + t.Errorf("cleanConversationTitle(%q) = %+v, want a plan refused", said, got) + } + } + // AND THE CONTROLS, which are the whole difficulty: a label that merely ran + // long is a label, and a name that opens on one of those words is a name. The + // refusal is the grammar of the opening and never the length of the answer. + for _, row := range []struct{ said, want string }{ + {"launch post for existing users", "launch post for existing users"}, + {"nil-map crash fix", "nil-map crash fix"}, + {"we chat cutover", "we chat cutover"}, + {"first pass on the brief", "first pass on the brief"}, + {"let go of the lock", "let go of the lock"}, + } { + if got := cleanTitle(row.said); got != row.want { + t.Errorf("cleanTitle(%q) = %q, want %q", row.said, got, row.want) + } + } +} + // THE INSTRUCTION IS ASKED WHERE A SMALL MODEL READS IT: last in the user // message, after the exchange, with the system line saying only who is asked. func TestTheNamerAsksAtTheEndOfTheUserMessage(t *testing.T) { From 20d88e9a72e7a802d44889bc90a7403f2041ef53 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 19:36:44 -0400 Subject: [PATCH 4/4] changelog: the entry carries its pull request number Co-Authored-By: Claude Opus 5 (1M context) --- ...{942-namer-refuses-a-plan.md => 975-task-name-is-a-label.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{942-namer-refuses-a-plan.md => 975-task-name-is-a-label.md} (99%) diff --git a/docs/changes/unreleased/942-namer-refuses-a-plan.md b/docs/changes/unreleased/975-task-name-is-a-label.md similarity index 99% rename from docs/changes/unreleased/942-namer-refuses-a-plan.md rename to docs/changes/unreleased/975-task-name-is-a-label.md index 60f0b88965..ffa6782110 100644 --- a/docs/changes/unreleased/942-namer-refuses-a-plan.md +++ b/docs/changes/unreleased/975-task-name-is-a-label.md @@ -1,7 +1,7 @@ --- kind: fixed title: A namer that answers with its plan is refused, so the task keeps the person's own words -pr: 942 +pr: 975 surface: [chat, engine] invalidates: - "A task whose namer answered with a sentence was named from the front of that sentence: the rail row and the task page read `I'll start by` while the node's own record carried the person's words right beside it. No longer true: an answer that opens as a clause about the speaker is refused at any length, and the row keeps the fallback — the person's own words, clipped by the column exactly as an unnamed task's title always was."