diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index a7fc5a69ad..da53035360 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -2108,6 +2108,12 @@ func v3BuiltinApprovals() map[string]any { // A conflict raised to the manager above the parties: a packet and a // line of Traffic, and nothing spent or started. "team_raise": "allow", + // A manager taking back a team that never held anyone, which the + // person declined in its own conversation: one record nobody used is + // forgotten, and nothing is started, stopped or spent. A team that + // was used is refused by the store ([teams.Dissolve]), whatever this + // row says. + "team_dissolve": "allow", } } diff --git a/cmd/codeaf/chatv3_approvalfloor_test.go b/cmd/codeaf/chatv3_approvalfloor_test.go index abcfc4dced..c23dd0f19f 100644 --- a/cmd/codeaf/chatv3_approvalfloor_test.go +++ b/cmd/codeaf/chatv3_approvalfloor_test.go @@ -209,13 +209,14 @@ func TestTheFloorDoesNotWidenAnySafetyFloor(t *testing.T) { // THE TEAM VERBS SPLIT READ AND MESSAGE FROM START (internal/session's // tools_team.go). A manager looking at its team, sending it a line, ending a // member's turn and a member posting to its room stay inside the team the person -// made and are logged in its traffic, so they sit on the floor; a new member is a +// made and are logged in its traffic, so they sit on the floor, and so does a +// manager taking back a team that never held anyone; a new member is a // new conversation that spends money, so team_start is the blanket mode's and // asks under the shipped default. func TestTheTeamVerbsAreAllowedAndTeamStartAsks(t *testing.T) { dir := v3Profile(t, map[string]any{"tools.approvalMode": "prompt"}) policy := gateOf(t, dir) - for _, tool := range []string{"team_status", "team_read", "team_send", "team_stop", "team_post", "team_decide", "team_escalate", "team_close_report", "team_raise"} { + for _, tool := range []string{"team_status", "team_read", "team_send", "team_stop", "team_post", "team_decide", "team_escalate", "team_close_report", "team_raise", "team_dissolve"} { wantAction(t, policy, tool, `{"to":"web","text":"x","handle":"web"}`, approval.ActionAllow) } wantAction(t, policy, "team_start", `{"handle":"docs","brief":"write the README"}`, approval.ActionPrompt) diff --git a/docs/changes/unreleased/1881-helpers.md b/docs/changes/unreleased/1881-helpers.md new file mode 100644 index 0000000000..acd10d0492 --- /dev/null +++ b/docs/changes/unreleased/1881-helpers.md @@ -0,0 +1,15 @@ +--- +kind: added +title: + New from this session makes this chat a team's manager, and /helpers asks it for helpers +pr: 1881 +surface: [chat, engine, docs] +invalidates: + - "Every way to make a team started from the team, and a team was made without a manager; the only way to make an existing conversation one was Add chat, then Choose AI manager. The Teams ▾ dropdown's `+ New from this session` makes a new top-level team with the conversation in front as its manager the moment it is chosen, in one save, selects it, and puts `/helpers` and a one-sentence default ask in the box." + - "The team the row makes is named twice: the folder's name on the click, then one or two words from the naming model over the conversation's title, applied only while the team still has its first name and no other team has the new one. `Teams.Name` takes an optional `words` limit; an engine without it answers up to three words, which the surface cuts to two." + - "`/helpers` asks the team the conversation in front manages for helpers: it sends exactly the words after it (bare, the default ask, shown in full) as the person's message, and makes no team. In a conversation that manages none it changes nothing and says to make one with + New from this session." + - "The Teams ▾ chip was absent with no teams, and its dropdown printed an up/down, enter, esc footer. The chip shows with no teams, the footer is gone, and with no team selected the dropdown ends with `+ New from this session`. With a team selected the row is absent: the team it makes is top-level, and nesting is + Add subteam." + - "A manager had no way to take its team back. `team_dissolve` forgets a team that has never held anyone but its manager (no member, former member, subteam or Traffic); it is on the belt only while that is true, for every team however it was made, and does not ask first." + - "A member a manager started with team_start never took its first turn on an ordinary launch: the engine's teams door stamps a new member's join time, the join bound dropped the start that made it, and the member sat idle with nothing addressed to it. A member's own start is exempt from the bound now, and it wakes with its brief; every other line from before the join is still withheld." + - "There are twenty-five tips, not twenty-four: `+ New from this session in the Teams menu makes this chat a team's manager`." + - "An interactive conversation in no team is now told once, after the person's fourth message, that it may suggest + New from this session when the work is long and has parts a few helpers could take on at once. It is a note, not a paragraph of the system prompt." +--- diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index fa98ec23ff..66ff67a0d6 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -323,6 +323,7 @@ command). | `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+5`) | | `/wall` | | | every saved conversation at once, with live or saved previews and card-based team creation (also `alt+v`, or `▦` under the box) | | `/teams` | | | the teams page: your teams as a tree, what waits on you, member previews and the selected team's interaction table (also `alt+2`, or `teams` on the tab bar) | +| `/helpers` | | `` | asks the team this conversation manages to suggest helpers: sends your words after it, or bare a one-sentence default ask, shown in full; makes no team, and in a conversation that manages none it says to make one with `+ New from this session`, last in the Teams dropdown while no team is selected, which makes the team at once and puts `/helpers` and the ask in the box; on home it says to open that conversation first | | `/cost` | `/usage`, `/tokens` | — | prints what this conversation has spent, and on what | | `/effort` | `/think`, `/thinking` | — | opens this conversation's thinking levels; says it is unavailable when the session has no dial | | `/effort` | `/think`, `/thinking` | `` | sets this conversation's thinking level; an unknown rung lists the accepted levels and changes nothing | diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 1df8c532ae..5815d11972 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -7,10 +7,11 @@ with no team overlay selected. Clearing the team's visual overlay does not end membership, change reporting authority, or stop background work. The composer has no team-recipient hint and the sidebar uses the ordinary task view. -With no overlay, the strip shows **Teams ▾**. Selecting a team replaces it with a coloured +With no overlay, the strip shows **Teams ▾**, even before you have a team. Selecting a team replaces it with a coloured team chip and a separate **x**. Click the name to switch teams; click x to remove the overlay and restore ordinary Chats. Clearing selects **All teams** in Teams without -changing membership or background work. The dropdown lists actual teams only, with no **None** option. +changing membership or background work. The dropdown lists actual teams, with no **None** option, +and, with no team selected, ends with **+ New from this session**, which makes a team of the conversation in front. The **▦ All** button still opens the independent conversation grid. The Teams dropdown is the only team selector in Chats; there are no membership buttons @@ -216,14 +217,21 @@ them; Undo does not reopen them. Organization otherwise only adds memberships: i not rename teams or remove conversations. Nothing runs automatically. Press Organize again to refresh suggestions. The conversation grid has no Organize or Undo control. -## The team switcher on the tab strip +## The team switcher on the tab strip — the Teams dropdown and + New from this session The dropdown is first on the Chats tab strip. With no overlay its label is **Teams ▾**; with one selected it shows that team's name, colour and a separate **x** to clear it. The dropdown offers **All teams** when a global manager exists, then the active team hierarchy. All teams selects the global-manager overlay; it is separate from the team-agnostic `▦ All` grid. A root without a manager or with a known missing manager transcript has no All teams choice. -The picker has no None option or management actions, and is absent when no overlay is available. +The picker has no None option or management actions. With **no team selected**, its last row, +under a rule, is **+ New from this session**: it makes a team at once with the conversation in +front as its manager, selects it, and puts `/helpers` and a default ask in the message box, +replacing what was there (that draft is kept in `/drafts`); enter sends the ask (see *Turn +this conversation into a team* on the team manager page). So +the chip is there even with no teams yet; it is absent only over `--host` to an engine without +teams. With a team selected the row is not offered: it makes a top-level team, and a team inside +another is `+ Add subteam` on Teams. Closed teams are available through Show closed on Teams, not through this picker. Selecting a team restores its conversation and horizontal strip position. A new overlay @@ -239,7 +247,9 @@ it does not select an overlay. A **manager** is a conversation that runs the team: you talk to it, it sends work to members and reports progress. Teams offers **Choose AI manager** for existing members and **+ Manager** -when a team has none. Add a new member first when you need a new conversation to replace +when a team has none. In the conversation you are working in, **`+ New from this session`**, +last in the Teams dropdown, makes a new team with that conversation as its manager, and +**`/helpers`** asks it to propose helpers for you to accept, change or cancel. Add a new member first when you need a new conversation to replace an existing manager. All teams has a dedicated Global manager card. `+ Global manager` creates its optional conversation. Its reports are the managers of open top-level teams; subteam managers report to their parent. Its card's `x` deletes the global-manager conversation with the diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index e3a0fcea38..22804e36be 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -44,7 +44,7 @@ screen you pass through, so the tip worth saying differs: a conversation gets th urgent thing that is true right now, and home gets everything in turn. Home's row does not wait for quiet; a conversation's waits 15 seconds. -Both rows draw from **one list** of twenty-four tips (below), and using a gesture on +Both rows draw from **one list** of twenty-five tips (below), and using a gesture on either retires it on both. **A conversation's tip has moved several times.** It was the keys row's lowest rung until @@ -121,7 +121,7 @@ later when the ring comes round. It jumps once and then takes its turn like the ## Every hint codeaf can show, and what makes each one go away -There are twenty-four, one list for both boxes. Each one says the moment it first appears +There are twenty-five, one list for both boxes. Each one says the moment it first appears and the gesture that retires it. The list is the program's own table (the surface refuses to build if the two disagree), so a tip you saw is on it word for word. @@ -150,6 +150,10 @@ build if the two disagree), so a tip you saw is on it word for word. it. It is the first tip a conversation says, ahead of `/task`. - `/task starts a single-shot task on the side` — after the first exchange. Retired when `/task` is typed, bare or with a brief. +- `+ New from this session in the Teams menu makes this chat a team's manager` — after the + first exchange, in a conversation with no team selected on the strip that does not already + manage a team; never on home. Retired the first time that row is chosen or `/helpers` is + typed. - `/automations add sets one up exactly · or just say when, like every Monday at 9` — from the first minute on home and after the first exchange in a conversation. It is the other half of the `/automations` row above — the place lists them, and this is how one is made, diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 3c4a572a26..91b392c1a7 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1342,7 +1342,7 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | | **`onto home's tray`** | `/attach ` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | | **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/skill` `/skills` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/task ` | Opens a conversation at the target — the folder at the right of the keys row and the model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. | -| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/debug` · `/stop` · `/remember` · `/always` · `/always ` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. | +| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/debug` · `/stop` · `/remember` · `/always` · `/always ` · `/helpers` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. `/helpers` asks the team a conversation manages for helpers, and home has none in front, so it says `/helpers asks the team a conversation manages for helpers · open that conversation first`. | | **`opens the crew panel`** | `/crew` and every `/crew` shortcut | Home steps aside and the crew panel opens over the conversation behind it; `esc` on the panel brings you back to home. A shortcut opens it with a tick on the row it changed. | | **`runs on the conversation behind home`** | `/land` · `/land ` · `/workspace ` · `/delete` | `/delete` asks to permanently delete the conversation behind Home; it opens its confirmation here and never starts a new conversation. The other commands act on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. | | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index f7ef7c74d6..2000d46830 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -55,6 +55,9 @@ its tab strip and never scrolls away: - **Choose AI manager** on Teams assigns an existing member. To use a new conversation, first use Add chat, then Choose AI manager. The Chats dropdown and conversation grid offer no manager-assignment controls. +- **`+ New from this session`**, last in the Teams dropdown, in the conversation you are + working in: a new team is made at once with that conversation as its manager, and + `/helpers` then asks it for helpers (see **Turn this conversation into a team**). The manager's place reads **`◆ Manager`**. Its tile in the team-agnostic grid keeps its ordinary title and position. The tab's hint names its team and title. `alt+m` goes to the manager from any conversation in the team. @@ -66,6 +69,91 @@ If the handle is already taken in that team, a number is added. Another manager `team_send` before its first turn. Once its title is made, the ordinary title-based handle choice may replace that fallback. +## Turn this conversation into a team — + New from this session, /helpers, convert a chat, bring in helpers + +You are working in a conversation, the work has grown, and you want helpers with this +conversation in charge. With no team selected, open the **Teams ▾** dropdown at the left of +the tab strip and choose its last row, **`+ New from this session`**. On the spot: + +- A new top-level team is made with **this conversation as its manager** and only member, + in the next free colour, and a moment later named after this conversation (see **How a + team made from a conversation is named**). The strip narrows to it and reads `◆ Manager`, + and the column turns to the team's Traffic. Every team's rules apply. Once it is saved: + `made the team guitar pickups · this conversation is its manager`. +- The message box is given this, in place of what was there (kept in `/drafts`): + `/helpers Suggest two or three helpers for this work, and I'll say yes, change them, or cancel.` + +Edit it and press enter. **`/helpers`** asks the team this conversation manages for helpers: +it sends your words after it, exactly as the transcript shows them, and a bare `/helpers` +sends that default. It makes no team. Reply to the proposal in your own words. Yes, and it +starts each helper with `team_start`, under your approval posture; each opens as a tab on +this team's strip and starts working at once, while you stay in front of the manager. A +different list, and it proposes again. Cancel, and it takes the team back with +`team_dissolve`. Changed your mind before sending anything? `Disband` the team on Teams. + +## How a team made from a conversation is named — + New from this session's team name, rename it + +`+ New from this session` names the team twice. On the click it takes the conversation's +folder name, lowercased (`codeaf` for `~/Code/CodeAF`), or, when a team already has that +name, a word such as `harbor`, so the strip can change at once. At the same time the +model that names your conversations is asked, once, for one or two words naming the work, +from the conversation's title or its opening words; it waits at most five seconds. When +the answer comes the team takes it, and the chip changes to it. If it came after the +receipt, a line says `named the team guitar pickups`. + +The first name stays when there is no naming model or no answer in time, when the answer +is not a name, when another team already has that name, or when the team was renamed or +disbanded first, here or in another window. A suggested name never replaces one you chose. +Rename the team any time in Teams, under `Settings`. It costs one short call on the naming +model. + +## When + New from this session or /helpers says no + +They change nothing and say why: + +- `/helpers` in a conversation that manages no team: `/helpers asks this conversation's team for helpers, and it has none yet · make one with + New from this session in the Teams menu`. +- On home, or any page: `/helpers asks the team a conversation manages for helpers · open that conversation first`. +- The row on the welcome screen of a new chat: `+ New from this session makes a team of a conversation you have started · say what you are working on first`. +- The row in a conversation that already manages a team: `this conversation already manages harbor · ask it with /helpers`, and the strip shows that team. +- With a team selected on the strip the dropdown has no `+ New from this session` row: the team it makes is always top-level, and a team inside a team is `+ Add subteam` on Teams. +- On a connection that holds one conversation at a time: `helpers open beside this conversation, and a connection holds one conversation at a time`. +- Over `--host` to an engine without teams: `managers are not available over --host yet`. +- The row chosen again while its team is being saved: `the team is still being saved`. + +If the save is refused — another window made this conversation another team's manager — +the strip goes back, the box is emptied, and the reason is said. A `/helpers` sent before +the save finished is held and sent once it is saved. + +## Why the chat suggested bringing in helpers + +A conversation that has gone on may end a reply with one sentence like "This could be +split up and worked on in parallel by a small team. Want to bring in some helpers? Choose ++ New from this session in the Teams menu and I'll suggest who." That is codeaf letting it +say so: after your fourth message, a conversation in no team is told, once, that it may +suggest a team when the work is long and has parts a few helpers could work on at once. It +is told to say it at most once, never for small or quick work, never instead of what you +asked, and never again after you pass on it. Reopening a long conversation tells it once +more, and it can see in its history that it already asked. + +It cannot make the team itself; only you can, with `+ New from this session` in the Teams +dropdown. Ignore it, or say no, and nothing changes. A conversation already in a team, a +task's worker, an automation's run and home's `ask here` are never told this. + +## Cancel a team nobody joined — team_dissolve + +Say no to the proposed helpers, or "cancel", and the manager calls `team_dissolve`. The +team is forgotten as if it had never been made: no row under `Show closed`, nothing +stopped, and the conversation, its history and its tasks stay. The strip goes back to all +your chats and the column to `Tasks`. + +A manager has `team_dissolve` only while its team has never held anyone but it: no +member, no former member, no subteam, and nothing in its Traffic. The rule is the same for +every team, however it was made. It does not ask first, because it can only remove a team +nothing happened in. Once anyone joins, it leaves at the manager's next step, and a +remembered call says `team_dissolve is no longer one of your tools: the team has held +members or exchanged messages, so only the person can disband it, from Teams.` A team +that was used ends with `Disband` on Teams, which keeps its history. + ## The manager's screen While the manager is in front, the message box says `to ◆ manager of harbor`, using the @@ -283,6 +371,7 @@ on one waits for you. | `team_decide` | answers a decision packet waiting on the manager, most often a member's question: an option, or its own words | no | | `team_escalate` | sends a packet waiting on the manager up, to its own manager or to you, with the reason it is not the manager's to decide | no | | `team_close_report` | brings you the team's closing report (done, left, where the files are) after you asked it to wrap up | no | +| `team_dissolve` | takes back a team that has never held anyone but its manager, when you decline it; the team is forgotten and the conversation goes on alone. Only offered while the team is that empty (see **Cancel a team nobody joined**) | no | | `team_raise` | raises a conflict to the manager above every party (see **Conflicts between members and teams**); members have it too | no | ## Adding existing sessions and removing members through the manager @@ -312,8 +401,9 @@ Like any tool, each can be set to ask or allow in `/settings` under the tool app ## What happens when a conversation stops being the manager At its next step, a conversation removed as manager loses `team_status`, `team_read`, -`team_send`, `team_stop`, `team_start`, `team_add`, `team_remove`, `team_decide`, `team_escalate` and -`team_close_report`. If it is still a member of a managed team, it keeps `team_post` and +`team_send`, `team_stop`, `team_start`, `team_add`, `team_remove`, `team_decide`, `team_escalate`, +`team_close_report`, and `team_dissolve` if it had it. A team it dissolved with +`team_dissolve` takes them all the same way. If it is still a member of a managed team, it keeps `team_post` and `team_raise`; outside a team it loses those too. A remembered call to a removed manager tool says `team_send is no longer one of your tools: this conversation no longer manages a team.` A removed member tool says `team_post is no longer one of your tools: this diff --git a/internal/manual/chat/teams-page.md b/internal/manual/chat/teams-page.md index 5ba1058986..756f4c659f 100644 --- a/internal/manual/chat/teams-page.md +++ b/internal/manual/chat/teams-page.md @@ -438,7 +438,8 @@ changes fields, arrows move, `space` selects a highlighted member, `enter` creat cancels. No internal IDs are shown. A duplicate name or invalid parent is refused. You may create an empty team and add members afterward. Creation never assigns a manager or -opens its members. Both routes land on the new team overview. A failed save is reported there. +opens its members. To make a team from the conversation you are working in, with that +conversation as its manager, choose `+ New from this session` in the Teams dropdown there (the team manager page has it). Both routes land on the new team overview. A failed save is reported there. Chats also offers `+ New AI team` in its full saved-conversation grid: select cards, then name and create. The full conversation grid itself lives only in Chats. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 8ec1caede6..168a6e6fc3 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4117,6 +4117,42 @@ func TestChangedInputQuestionReachesRetainedHistory(t *testing.T) { } } +// /helpers: the questions a person in the middle of some work asks when they +// want it to become a team, and the ones they ask about saying no, reach the +// sections that answer them by name, not just the pages they are on. +func TestHelpersQuestionsReachTheirSections(t *testing.T) { + for _, probe := range []struct{ question, page, title string }{ + {"how do I turn this conversation into a team", "team-manager", "Turn this conversation into a team"}, + {"convert this chat to a team with it as the manager", "team-manager", "Turn this conversation into a team"}, + {"bring in helpers for what I am working on", "team-manager", "Turn this conversation into a team"}, + {"what does /helpers send to the model", "team-manager", "Turn this conversation into a team"}, + {"what does + New from this session in the Teams dropdown do", "conversations-and-teams", "the Teams dropdown and + New from this session"}, + {"why is + New from this session missing when a team is selected", "conversations-and-teams", "the Teams dropdown and + New from this session"}, + {"why does /helpers say this conversation has no team", "team-manager", "When + New from this session or /helpers says no"}, + {"/helpers says open that conversation first", "team-manager", "When + New from this session or /helpers says no"}, + {"what does /helpers do in a manager", "team-manager", "Turn this conversation into a team"}, + {"how is the team from + New from this session named", "team-manager", "How a team made from a conversation is named"}, + {"why did my new team's name change", "team-manager", "How a team made from a conversation is named"}, + {"why did the chat suggest bringing in helpers", "team-manager", "Why the chat suggested bringing in helpers"}, + {"the model asked if I want a small team working in parallel", "team-manager", "Why the chat suggested bringing in helpers"}, + {"I said no to the helpers, how do I cancel the team", "team-manager", "Cancel a team nobody joined"}, + {"what does team_dissolve do", "team-manager", "Cancel a team nobody joined"}, + } { + found := Chat().Search(probe.question, DefaultResults) + reached := false + where := make([]string, 0, len(found)) + for _, section := range found { + where = append(where, section.Page+" · "+section.Title) + if section.Page == probe.page && strings.Contains(section.Title, probe.title) { + reached = true + } + } + if !reached { + t.Errorf("%q does not reach %s · %q; it reached %v", probe.question, probe.page, probe.title, where) + } + } +} + // A rule beyond the section cap is absent from the answer a model actually reads, // even when searching finds the correct page. Stopped-result handling therefore // needs its own searchable section and both halves of the rule in the context. diff --git a/internal/remote/teamask.go b/internal/remote/teamask.go index b3de818ac3..7a2c9c2534 100644 --- a/internal/remote/teamask.go +++ b/internal/remote/teamask.go @@ -25,6 +25,13 @@ type teamAskDoor interface { ProposeTeams(ctx context.Context, in session.TeamProposalInput) (session.TeamProposal, error) } +// teamAskWordsDoor is the naming ask with a word limit, which the engine +// answers when a request carries one ([TeamNameArgs.Words]). An agent without +// it is asked the group's question, as before. +type teamAskWordsDoor interface { + NameTeamWords(ctx context.Context, titles []string, words int) (string, error) +} + // teamAskKnown is [Welcome.TeamAsk]: whether the agent this engine has open // answers both asks. func teamAskKnown(agent any) bool { _, ok := agent.(teamAskDoor); return ok } @@ -69,7 +76,12 @@ func teamAskCall(agent WrappedAgent, call Frame) (json.RawMessage, bool, error) } ctx, cancel := teamAskWithin(args.Budget) defer cancel() - name, err := door.NameTeam(ctx, args.Titles) + var name string + if worded, ok := agent.(teamAskWordsDoor); ok && args.Words > 0 { + name, err = worded.NameTeamWords(ctx, args.Titles, args.Words) + } else { + name, err = door.NameTeam(ctx, args.Titles) + } if err != nil { return nil, true, err } @@ -131,6 +143,28 @@ func (a *Agent) NameTeam(ctx context.Context, titles []string) (string, error) { return name, nil } +// NameTeamWords is [Agent.NameTeam] for a name of at most words words. The +// limit rides the request; an engine from before it answers the group's +// question, and the caller cuts that answer to the words it asked for. +func (a *Agent) NameTeamWords(ctx context.Context, titles []string, words int) (string, error) { + if !a.c.Welcome().TeamAsk { + return "", errors.New(teamAskOffWord) + } + budget, ok := teamAskBudget(ctx) + if !ok { + return "", context.DeadlineExceeded + } + payload, err := a.c.call(ctx, MethodTeamsName, TeamNameArgs{Titles: titles, Budget: budget, Words: words}) + if err != nil { + return "", err + } + var name string + if err := json.Unmarshal(payload, &name); err != nil { + return "", err + } + return name, nil +} + // ProposeTeams asks the engine's naming role which teams the conversations in // in could form: the wall's teamProposer door, over the wire, refused here for // an engine without it as [Agent.NameTeam] is. diff --git a/internal/remote/teamask_test.go b/internal/remote/teamask_test.go index cb6b9f39c3..e449d1ceab 100644 --- a/internal/remote/teamask_test.go +++ b/internal/remote/teamask_test.go @@ -190,3 +190,40 @@ func TestAnOlderEngineIsNotAskedForATeamName(t *testing.T) { // The actual engine, rather than only a fixture, must answer both asks, or the // flag would be false on every real engine and the wall would never ask. var _ teamAskDoor = (*session.Agent)(nil) + +// And the word-limited ask, which a team made from one conversation is named +// through (internal/tui3's helpers.go). +var _ teamAskWordsDoor = (*session.Agent)(nil) + +// wordsAgent is [askAgent] with the word-limited ask, which remembers the +// limit it was given. +type wordsAgent struct { + *askAgent + words int +} + +func (a *wordsAgent) NameTeamWords(ctx context.Context, titles []string, words int) (string, error) { + a.titles, a.words = titles, words + a.note(ctx) + return "pickups", nil +} + +// THE WORD LIMIT CROSSES, AND AN ENGINE WITHOUT IT IS ASKED THE GROUP'S +// QUESTION. A request with Words reaches an engine's NameTeamWords with that +// limit; the same request to an engine whose agent has only NameTeam is +// answered by NameTeam, whose answer the surface cuts down. +func TestTheTeamNameWordLimitCrossesAndFallsBack(t *testing.T) { + worded := &wordsAgent{askAgent: &askAgent{fakeAgent: &fakeAgent{model: "m"}}} + loop := askLoop(t, worded) + name, err := loop.Client.Agent().NameTeamWords(context.Background(), []string{"guitar pickups"}, 2) + if err != nil || name != "pickups" || worded.words != 2 || len(worded.titles) != 1 { + t.Fatalf("the limit did not cross: %q, %v, words %d, titles %q", name, err, worded.words, worded.titles) + } + + plain := &askAgent{fakeAgent: &fakeAgent{model: "m"}} + loop = askLoop(t, plain) + name, err = loop.Client.Agent().NameTeamWords(context.Background(), []string{"guitar pickups"}, 2) + if err != nil || name != "harbor" || plain.calls != 1 { + t.Fatalf("an engine without the limit was not asked the group's question: %q, %v", name, err) + } +} diff --git a/internal/remote/wire_teams.go b/internal/remote/wire_teams.go index 6efeb92f94..a5731c6b92 100644 --- a/internal/remote/wire_teams.go +++ b/internal/remote/wire_teams.go @@ -119,9 +119,15 @@ const ( // TeamNameArgs is the titles a group is named from, and how long the wall // will wait. Zero Budget uses the engine's ceiling; a negative Budget has // already expired and never starts a model call. +// +// Words is the most words the name may have, zero for the group's three. It +// is optional on the wire both ways: an engine from before it reads a request +// without it and answers up to three words, which the surface cuts to what it +// asked for (internal/tui3's helpers.go). type TeamNameArgs struct { Titles []string `json:"titles"` Budget time.Duration `json:"budget,omitempty"` + Words int `json:"words,omitempty"` } // TeamProposeArgs is everything one Organize ask is about, and how long the diff --git a/internal/session/actioncategory.go b/internal/session/actioncategory.go index bbc3242e84..2b9a388ac0 100644 --- a/internal/session/actioncategory.go +++ b/internal/session/actioncategory.go @@ -260,7 +260,7 @@ func ActionCategoryForTool(tool string) ActionCategory { case "propose_task", "quick_task", "divide_work", "workspace_fork", "automation", // A manager starting a member or ending its turn, or deciding what a // member asked it. - "team_start", "team_stop", "team_decide", "team_add", "team_remove": + "team_start", "team_stop", "team_decide", "team_add", "team_remove", "team_dissolve": return ActionCoordinate // Keeping the account of the work rather than doing it. `items` is a quick diff --git a/internal/session/agent.go b/internal/session/agent.go index b58e81e694..b1897c625b 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2860,6 +2860,10 @@ func (a *Agent) landVolatileLocked() { // read made before this request ([Agent.teamBoundary]) rather than beside // the work, so it is never a step late (team.go's [teamRoleNoteOpening]). a.landTeamRoleLocked() + // AND, ONCE, IN A CONVERSATION IN NO TEAM THAT HAS GONE ON, leave to + // suggest /helpers (helpersnote.go). After the role, which is what says + // whether there is a team. + a.landHelpersNoteLocked() a.landNoteLocked(volatileNoteOpening, a.volatileBlockLocked()) // AND A MANAGER'S TEAM, in a note of its own: a team moves whenever a member // does, and riding the card's note @@ -2929,7 +2933,8 @@ func isVolatileNote(text string) bool { return strings.HasPrefix(text, volatileNoteOpening) || strings.HasPrefix(text, bashBeltFrameOpening) || strings.HasPrefix(text, teamNoteOpening) || - strings.HasPrefix(text, teamRoleNoteOpening) + strings.HasPrefix(text, teamRoleNoteOpening) || + strings.HasPrefix(text, helpersNoteOpening) } // mayBashBelt is [Config.mayBashBelt] asked of a live agent, so that the diff --git a/internal/session/helpersnote.go b/internal/session/helpersnote.go new file mode 100644 index 0000000000..1a0a8a148e --- /dev/null +++ b/internal/session/helpersnote.go @@ -0,0 +1,75 @@ +package session + +// THE ONE NOTE THAT LETS A CONVERSATION SUGGEST BRINGING IN HELPERS. +// +// A person deep in a piece of work can turn it into a team effort with the +// conversation in charge: the chat's Teams menu has `+ New from this session`, +// which makes a new team with this conversation as its manager, and `/helpers` +// then asks it for helpers (internal/tui3's helpers.go). Nobody finds a menu +// row they have never heard of in the middle of their work, and the model, +// which can see the work growing, is the one placed to mention it. So a +// conversation is told, once, that it may. +// +// IT IS A NOTE AND NOT A PARAGRAPH OF THE PAGE, for two reasons that are one. +// The page (prompts/system.md) is the fixed prefix every request of every turn +// of every conversation pays for, and it is held under a budget that only +// shrinks (prefixbudget_test.go); a sentence most conversations never act on +// does not belong there. And the suggestion only fits a conversation that has +// gone on: a note that lands after [helpersNoteAfter] things the person has +// said costs nothing in the short exchanges that are most of them, and arrives +// in the long ones, which are the ones whose work may have outgrown a +// conversation. +// +// IT IS ONCE, AND SAID AS RARE. It lands at most once in a process, never in a +// conversation that already has a part in a team (its role note says what it +// is), and its words make the suggestion a single sentence at the end of a +// reply, never repeated, never for small work and never instead of the work. +// Only the person can make the team; the note says so, because a model that +// thought it could would promise one and fail. +// +// IT IS FOR A CONVERSATION A PERSON SITS IN, ON THE SURFACE THAT HAS THE +// COMMAND ([Config.mayOfferHelpers]): not a task's node or worker, not an +// automation's run, not home's short `ask here` exchange. + +// helpersNoteOpening is the note's identity: [isVolatileNote] tells it from +// the person's own words by it, and [Agent.lastNoteLocked] finds it by it. +const helpersNoteOpening = "Instructions from codeaf, not from the person: when to suggest helpers." + +// helpersNoteAfter is how many messages the person has sent before the note +// lands: past a quick question and its follow-ups, into work that is going on. +const helpersNoteAfter = 4 + +// helpersNoteBody is what the note says. +const helpersNoteBody = "If the work here has grown long, with parts that could be worked on at once by a few helpers, " + + "you may suggest a team once, briefly, at the end of a reply, in words like: " + + "\"This could be split up and worked on in parallel by a small team. Want to bring in some helpers? " + + "Choose + New from this session in the Teams menu and I'll suggest who.\" " + + "Only the person can make the team, with that menu row; this conversation then becomes its manager. " + + "Not for small or quick work, never instead of doing what was asked, and never again once you have suggested it or they passed." + +// mayOfferHelpers says whether this conversation may be told it can suggest +// /helpers: one a person is sitting in ([Config.Interactive], which the +// surface sets and an automation's run clears), that is not a task's node or +// worker, not home's short `ask here` exchange, and that can be in a team at +// all ([Config.teamProfile]). +func (c Config) mayOfferHelpers() bool { + return c.Interactive && !c.InTask && c.taskID == 0 && c.AutomationRun == nil && !c.Errand && c.teamProfile() != "" +} + +// landHelpersNoteLocked lands the note when this conversation has earned it +// and has not had it. The caller holds a.mu, at the step boundary +// ([Agent.landVolatileLocked]), after the team role has been composed, so a +// conversation that is in a team is known to be one. +func (a *Agent) landHelpersNoteLocked() { + if a.helpersNoted || a.personSeq < helpersNoteAfter || !a.config.mayOfferHelpers() { + return + } + if a.teamRoleText != "" || a.lastNoteLocked(teamRoleNoteOpening) != "" { + return + } + a.helpersNoted = true + if a.lastNoteLocked(helpersNoteOpening) != "" { + return + } + a.landNoteLocked(helpersNoteOpening, helpersNoteBody) +} diff --git a/internal/session/helpersnote_test.go b/internal/session/helpersnote_test.go new file mode 100644 index 0000000000..8c7b677eb0 --- /dev/null +++ b/internal/session/helpersnote_test.go @@ -0,0 +1,144 @@ +package session + +// THE /helpers NOTE, AS TESTS: a conversation a person sits in, in no team, is +// told once — after it has gone on — that it may suggest /helpers; and nobody +// else is told at all. + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/manual" +) + +// helpersNotes is every /helpers note in one request, in order. +func helpersNotes(messages []ai.Message) []string { + var notes []string + for _, message := range messages { + if text := messageText(message); message.Role == "user" && strings.HasPrefix(text, helpersNoteOpening) { + notes = append(notes, text) + } + } + return notes +} + +// loneConversation is a transcript beside the fixture's team and in no team +// of its own: an ordinary conversation on the same profile. +func loneConversation(t *testing.T, fixture teamFixture) string { + t.Helper() + dir := filepath.Join(filepath.Dir(filepath.Dir(fixture.manager)), "lone") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, placeTranscript) + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// interactive is a conversation a person is sitting in, as the chat door +// builds one. +func interactive(config *Config) { config.Interactive = true } + +// THE NOTE LANDS ONCE, AFTER THE PERSON HAS SAID [helpersNoteAfter] THINGS, and +// stays where it landed: the requests before carry none, and every request +// after carries the one note, never a second. It is the session's note and +// not the person's words, and it names the command and no tool. +func TestTheHelpersNoteLandsOnceAfterTheConversationHasGoneOn(t *testing.T) { + fixture := newTeamFixture(t, true) + turns := helpersNoteAfter + 2 + completer := oneAnswer(turns) + agent := teamAgent(t, fixture, loneConversation(t, fixture), completer, interactive) + for i := 0; i < turns; i++ { + submitAndWait(t, agent, "and the next part of the work") + } + for i := 0; i < turns; i++ { + notes := helpersNotes(completer.request(i)) + want := 0 + if i >= helpersNoteAfter-1 { + want = 1 + } + if len(notes) != want { + t.Fatalf("request %d carried %d /helpers notes, want %d:\n%s", i, len(notes), want, userTextIn(completer.request(i))) + } + } + note := helpersNotes(completer.request(turns - 1))[0] + if !isVolatileNote(note) { + t.Fatal("the note would be drawn as something the person typed") + } + for _, want := range []string{"+ New from this session in the Teams menu", "Only the person can make the team", "once", "Not for small or quick work"} { + if !strings.Contains(note, want) { + t.Errorf("the note does not say %q:\n%s", want, note) + } + } + if strings.Contains(note, "team_") { + t.Errorf("the note names a tool the conversation does not have:\n%s", note) + } +} + +// THE MANUAL SAYS "after your fourth message" (team-manager.md, *Why the chat +// suggested bringing in helpers*). Move the threshold and that sentence moves +// with it, in the same change. +func TestTheHelpersNoteThresholdIsTheOneTheManualStates(t *testing.T) { + const manualSays = 4 + if helpersNoteAfter != manualSays { + t.Fatalf("the note lands after %d messages and the manual says the fourth; change both", helpersNoteAfter) + } + if !manual.Chat().Mentions("after your fourth message") { + t.Fatal("the manual no longer says when the note lands") + } +} + +// A CONVERSATION IN A TEAM IS NEVER TOLD: its role note says what it is, and +// a manager suggesting a second team would be the nesting /helpers refuses. +func TestAConversationInATeamIsNeverToldToSuggestHelpers(t *testing.T) { + fixture := newTeamFixture(t, true) + turns := helpersNoteAfter + 1 + for _, transcript := range []string{fixture.manager, fixture.web} { + completer := oneAnswer(turns) + agent := teamAgent(t, fixture, transcript, completer, interactive) + for i := 0; i < turns; i++ { + submitAndWait(t, agent, "and the next part of the work") + } + if notes := helpersNotes(completer.request(turns - 1)); len(notes) != 0 { + t.Fatalf("%s, in a team, was told to suggest helpers:\n%s", filepath.Base(filepath.Dir(transcript)), notes[0]) + } + } +} + +// NOBODY BUT A CONVERSATION A PERSON SITS IN IS TOLD: a headless one, an +// automation's run, home's `ask here` exchange, a task's node, and one with no +// profile a team could be kept in. +func TestOnlyAConversationAPersonSitsInMayOfferHelpers(t *testing.T) { + fixture := newTeamFixture(t, true) + base := Config{Interactive: true, ProfileDir: fixture.profile, SessionFile: loneConversation(t, fixture)} + if !base.mayOfferHelpers() { + t.Fatal("an interactive conversation on a profile may not offer helpers") + } + for name, change := range map[string]func(*Config){ + "headless": func(c *Config) { c.Interactive = false }, + "automation run": func(c *Config) { c.AutomationRun = &AutomationRun{} }, + "ask here": func(c *Config) { c.Errand = true }, + "task node": func(c *Config) { c.InTask = true }, + "no transcript": func(c *Config) { c.SessionFile = "" }, + } { + config := base + change(&config) + if config.mayOfferHelpers() { + t.Errorf("a %s conversation may offer helpers", name) + } + } + + completer := oneAnswer(helpersNoteAfter + 1) + agent := teamAgent(t, fixture, loneConversation(t, fixture), completer, nil) + for i := 0; i <= helpersNoteAfter; i++ { + submitAndWait(t, agent, "and the next part of the work") + } + if notes := helpersNotes(completer.request(helpersNoteAfter)); len(notes) != 0 { + t.Fatal("a headless conversation was told to suggest helpers") + } +} diff --git a/internal/session/session.go b/internal/session/session.go index b59a6fc377..3c2bd73c49 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2978,6 +2978,9 @@ type Agent struct { // [teamRoleBlock]), set at every step boundary before the request and // landed in a note of its own ahead of the others. teamRoleText string + // helpersNoted says the one note that lets this conversation suggest + // /helpers has landed in this process (helpersnote.go). + helpersNoted bool // team is this conversation's account of its teams: its key, its roles and // its Traffic cursors (team.go). It has its own lock and is never read // under mu. diff --git a/internal/session/team.go b/internal/session/team.go index 9ebde11827..207b7f75ce 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -103,6 +103,10 @@ type teamRole struct { // manager is the global manager, and its other members are the top-level // teams' managers. root bool + // dissolvable says this conversation manages the team and the team has + // never held anyone but it ([teams.File.Dissolvable]), which is when its + // manager is given team_dissolve (team_dissolve.go). + dissolvable bool // key is the conversation key this conversation is stored under in the // team, the one spelling of [Agent.teamKeysLocked] the file holds. An event // this conversation writes names it ([teams.Entry.Member]). @@ -402,6 +406,7 @@ func rolesFor(file *teams.File, keys []string, d teams.Defaults) []teamRole { derived: member.HandleDerived(), joinedAt: member.JoinedAt, } + role.dissolvable = role.manager && file.Dissolvable(team.ID) if hasHome { role.questionsUp = file.Effective(home.Team, d).QuestionsUp } @@ -511,16 +516,20 @@ func (a *Agent) teamBoundary() string { // armTeamTools puts the verbs these roles give onto the belt. func (a *Agent) armTeamTools(roles []teamRole) { var arriving []bare.Tool - manager, member := false, false + manager, member, dissolvable := false, false, false for _, role := range roles { manager = manager || role.manager member = member || (!role.manager && role.managed) + dissolvable = dissolvable || role.dissolvable } if manager { arriving = append(arriving, a.managerTools()...) arriving = append(arriving, a.packetTools()...) arriving = append(arriving, a.wrapUpTools()...) } + if dissolvable { + arriving = append(arriving, a.dissolveTools()...) + } if member { arriving = append(arriving, a.memberTools()...) } @@ -581,6 +590,11 @@ func (a *Agent) retireTeamTools(allowed map[string]bool) { if teamManagerToolName(tool.Name) { a.teamRetired[tool.Name] = teamRetiredManager } + // team_dissolve leaves a conversation that still manages its team + // when the team was used, and that is a different sentence. + if tool.Name == teamDissolveToolName && allowed[teamStatusToolName] { + a.teamRetired[tool.Name] = teamRetiredDissolve + } } a.tools = kept a.definitions, _ = toolDefinitions(kept) @@ -655,7 +669,7 @@ func (a *Agent) teamNewsLocked(profile string, roles []teamRole) string { var lines []string for _, entry := range entries { cursor = entry.ID - if !role.joinedAt.IsZero() && entry.At.Before(role.joinedAt) { + if !role.joinedAt.IsZero() && entry.At.Before(role.joinedAt) && !a.teamOwnStart(role, entry) { continue } if line := a.teamEntryLineLocked(profile, role, entry); line != "" { @@ -704,6 +718,23 @@ func (a *Agent) teamNewsLocked(profile string, roles []teamRole) string { // it, because the start carries the brief, and the brief is handed to the new // member here, marked as the manager's, on its first request ([teamBriefLine]). // The person never typed it, so it must not arrive as the person's message. +// teamOwnStart reports whether entry is the start that made this +// conversation: a manager's start addressed to its handle in the team, inside +// [teamStartGrace] of when this process began. It is the one line a member is +// owed from before it joined, the line [Agent.firstTeamCursor] reaches back +// for, and the join bound in [Agent.teamNewsLocked] must let it through. +// +// THE JOIN IS ALWAYS AFTER THE START. The manager writes the start, the +// interface opens the conversation on reading it, and only then makes it a +// member; and the engine's teams door stamps a new member's join with the time +// it took the edit (internal/remote's teams.go). A bound that took the start +// too handed every started member nothing, so none of them ever woke +// (team_wake.go), and a manager reported helpers running that were idle. +func (a *Agent) teamOwnStart(role teamRole, entry teams.Entry) bool { + return !role.manager && role.handle != "" && entry.Kind == teams.KindStart && entry.To == role.handle && + !entry.At.Before(a.startedAt.Add(-teamStartGrace)) +} + func (a *Agent) firstTeamCursor(profile string, role teamRole) string { tail, err := teams.ReadTraffic(profile, role.id, "", teamFirstLook) if err != nil { diff --git a/internal/session/team_dissolve.go b/internal/session/team_dissolve.go new file mode 100644 index 0000000000..3b9d218935 --- /dev/null +++ b/internal/session/team_dissolve.go @@ -0,0 +1,73 @@ +package session + +// team_dissolve: a manager taking back a team that never held anyone. +// +// A conversation the person turns into a team (the chat's `/helpers`) is its +// manager from that moment, with nobody under it, and its first job is to +// propose helpers. The person answers in their own words, and one answer is +// "no, cancel it". The manager has every verb for growing a team and, until +// this one, none for taking the team back, so a declined proposal left an empty +// team on the Teams page that only the person could disband — and disbanding +// keeps a read-only history row for a team that never did anything. +// +// THE VERB IS ON THE BELT ONLY WHILE IT CAN WORK. It is armed at a boundary that +// finds this conversation managing a team that has never held anyone but it +// ([teams.File.Dissolvable]), and it leaves at the first boundary after anyone +// else joins, with the reason said to a model that calls it from memory. A +// capability that cannot work is absent, not broken. The call itself checks +// again, under the store's lock, and also refuses a team whose Traffic has +// anything in it ([teams.Dissolve]), because a start already written there is a +// member on its way. +// +// IT IS ONE RULE FOR EVERY TEAM. Nothing here asks which door made the team: a +// team made on the Teams page and given a manager there is dissolvable by the +// same rule while it is just as empty. Disbanding a team that was used stays the +// person's, on Teams. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/teams" +) + +const teamDissolveToolName = "team_dissolve" + +const teamDissolveDescription = "Take back the team you manage while it has never held anyone but you: it is forgotten as if it had never been made, nobody is stopped, and this conversation goes on as an ordinary one without your team verbs. " + + "Use it only when the person declines the team, for example by saying no to the helpers you proposed and asking to cancel. " + + "It is offered only until anyone else joins the team or anything is said in its traffic; after that the person disbands the team from Teams." + +const teamDissolveSchema = `{"type":"object","properties":{` + teamArgSchema + `},"additionalProperties":false}` + +// teamRetiredDissolve is why team_dissolve left the belt of a conversation that +// still manages a team: the team was used, so it is not the manager's to undo. +const teamRetiredDissolve = "the team has held members or exchanged messages, so only the person can disband it, from Teams" + +// dissolveTools is the manager's one verb for an untouched team. +func (a *Agent) dissolveTools() []bare.Tool { + return []bare.Tool{ + {Name: teamDissolveToolName, Description: teamDissolveDescription, Schema: json.RawMessage(teamDissolveSchema), Execute: a.teamDissolveTool}, + } +} + +func (a *Agent) teamDissolveTool(ctx context.Context, args json.RawMessage) (string, bool, error) { + var p struct{ Team string } + if err := decodeToolArguments(args, &p); err != nil { + return invalidArgumentsPrefix + err.Error(), true, nil + } + t, role, refusal := a.teamTarget(p.Team, true) + if refusal != "" { + return refusal, true, nil + } + err := teams.Dissolve(a.config.teamProfile(), t.ID, role.key) + if errors.Is(err, teams.ErrNotDissolvable) || errors.Is(err, teams.ErrRoot) { + return fmt.Sprintf("%q has already held members or exchanged messages, so it is not yours to take back. Nothing was changed; the person can disband it from Teams.", t.Name), true, nil + } + if err != nil { + return "The team could not be dissolved: " + err.Error(), true, nil + } + return fmt.Sprintf("Dissolved %q. Nobody was started or stopped, and this conversation goes on as an ordinary one; your team verbs leave at your next step.", t.Name), false, nil +} diff --git a/internal/session/team_dissolve_test.go b/internal/session/team_dissolve_test.go new file mode 100644 index 0000000000..144c081d12 --- /dev/null +++ b/internal/session/team_dissolve_test.go @@ -0,0 +1,149 @@ +package session + +// team_dissolve, as tests: it is on the belt exactly while the team it would +// take back has never held anyone but its manager, it leaves with a reason the +// moment anybody joins, and a call it answers forgets the team and nothing else. +// +// Like the rest of the team tests, every fixture is a real teams.json and a +// real Traffic log, written through internal/teams. + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/teams" +) + +// newHelpersFixture is the team the chat's `/helpers` makes: the fixture's +// manager conversation as manager and only member, and the web and parser +// transcripts beside it, in no team, to be added by a test. +func newHelpersFixture(t *testing.T) teamFixture { + t.Helper() + fixture := newTeamFixture(t, true) + if err := teams.Update(fixture.profile, func(f *teams.File) error { + off := false + team := teams.Team{ID: fixture.teamID, Name: "harbor"} + team.Settings.Wake = &off + f.Teams = []teams.Team{team} + if err := f.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, fixture.manager), File: fixture.manager, Word: "Harbor manager", Handle: "boss"}); err != nil { + return err + } + return f.SetManager(fixture.teamID, convKeyOf(t, fixture.manager)) + }); err != nil { + t.Fatal(err) + } + return fixture +} + +func TestADissolvableTeamsManagerIsOfferedTeamDissolveAndAUsedOneIsNot(t *testing.T) { + fresh := newHelpersFixture(t) + manager := teamAgent(t, fresh, fresh.manager, nil, nil) + manager.teamBoundary() + if !holds(manager, teamDissolveToolName) { + t.Fatal("the manager of a team that never held anyone was not offered team_dissolve") + } + for _, name := range []string{teamStatusToolName, teamSendToolName, teamStartToolName, teamCloseReportToolName} { + if !holds(manager, name) { + t.Errorf("a dissolvable team's manager lacks %s, which every manager has", name) + } + } + + used := newTeamFixture(t, true) + busy := teamAgent(t, used, used.manager, nil, nil) + busy.teamBoundary() + if holds(busy, teamDissolveToolName) { + t.Fatal("the manager of a team with members was offered team_dissolve") + } + web := teamAgent(t, used, used.web, nil, nil) + web.teamBoundary() + if holds(web, teamDissolveToolName) { + t.Fatal("a member was offered team_dissolve") + } +} + +// THE VERB LEAVES WHEN ANYONE JOINS, BY ANY ROUTE, and a call from memory is +// told why. The other manager verbs stay: the conversation still manages. +func TestTeamDissolveLeavesWhenAMemberJoinsAndSaysWhy(t *testing.T) { + fixture := newHelpersFixture(t) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + if err := teams.Update(fixture.profile, func(f *teams.File) error { + return f.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, fixture.web), File: fixture.web, Word: "web frontend", Handle: "web"}) + }); err != nil { + t.Fatal(err) + } + manager.teamBoundary() + if holds(manager, teamDissolveToolName) { + t.Fatal("team_dissolve stayed after a member joined") + } + if !holds(manager, teamSendToolName) || !holds(manager, teamStartToolName) { + t.Fatal("a member joining took the manager's other verbs") + } + result := manager.executeTool(context.Background(), nil, nil, withdrawnCall("c1", teamDissolveToolName, `{}`), "") + if want := "team_dissolve is no longer one of your tools: " + teamRetiredDissolve + "."; result.text != want { + t.Fatalf("a retired team_dissolve answered %q, want %q", result.text, want) + } +} + +// A CALL FORGETS THE TEAM, and only the team: the transcript is where it was, +// and at the next boundary every team verb leaves because there is no team. +func TestTeamDissolveForgetsTheTeamAndTheVerbsLeaveWithIt(t *testing.T) { + fixture := newHelpersFixture(t) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + said, failed, err := manager.teamDissolveTool(context.Background(), json.RawMessage(`{}`)) + if failed || err != nil { + t.Fatalf("dissolve: %s %v", said, err) + } + if !strings.Contains(said, `Dissolved "harbor"`) { + t.Fatalf("dissolve said %q", said) + } + file, err := teams.Load(fixture.profile) + if err != nil { + t.Fatal(err) + } + if _, ok := file.Team(fixture.teamID); ok || len(file.ClosedTeams()) != 0 { + t.Fatal("the team is still in the file, open or as history") + } + if _, err := os.Stat(fixture.manager); err != nil { + t.Fatal("dissolving touched the manager's transcript") + } + manager.teamBoundary() + for _, name := range teamToolNames { + if holds(manager, name) { + t.Errorf("%s stayed on the belt of a conversation whose team was dissolved", name) + } + } + result := manager.executeTool(context.Background(), nil, nil, withdrawnCall("c2", teamSendToolName, `{}`), "") + if want := "team_send is no longer one of your tools: " + teamRetiredManager + "."; result.text != want { + t.Fatalf("team_send after dissolving answered %q, want %q", result.text, want) + } +} + +// A START ALREADY IN THE TRAFFIC IS A MEMBER ON ITS WAY. A call made after the +// boundary armed the verb, with a team_start written since, is refused and +// changes nothing. +func TestTeamDissolveRefusesATeamWithTrafficAndWritesNothing(t *testing.T) { + fixture := newHelpersFixture(t) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "docs", Text: "write the README"}) + before, err := os.ReadFile(teams.Path(fixture.profile)) + if err != nil { + t.Fatal(err) + } + said, failed, _ := manager.teamDissolveTool(context.Background(), json.RawMessage(`{}`)) + if !failed || !strings.Contains(said, "not yours to take back") { + t.Fatalf("dissolving a team with traffic said %q (failed %v)", said, failed) + } + after, err := os.ReadFile(teams.Path(fixture.profile)) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("a refused dissolve wrote the teams file") + } +} diff --git a/internal/session/team_wake_test.go b/internal/session/team_wake_test.go index 2aa8001baf..5ea17af6a1 100644 --- a/internal/session/team_wake_test.go +++ b/internal/session/team_wake_test.go @@ -65,6 +65,55 @@ func TestAStartedMemberWakesOnItsOwnWithTheBriefOnce(t *testing.T) { } } +// AND IT WAKES WHEN THE STORE STAMPED ITS JOIN, as every ordinary launch's +// does. The engine's teams door gives a new member the time it joined +// (internal/remote's teams.go), and a member a manager started always joins +// after the start that made it was written. The join bound keeps a membership +// from being handed lines from before it began; its own start is the one line +// it is owed from before, and the bound must not take that away. It used to: +// the brief was filtered out, nothing was addressed to the member, and every +// helper a manager started sat idle until the person asked why. +// +// A DIRECTIVE FROM BEFORE THE JOIN IS STILL NOT HANDED OVER: the exemption is +// the start alone. +func TestAStartedMemberWhoseJoinWasStampedStillWakesWithItsBrief(t *testing.T) { + fixture := newWakingTeamFixture(t) + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "lexer", Text: "Rewrite the lexer."}) + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindDirective, From: teams.FromManager, To: "lexer", Text: "Also delete the parser."}) + dir := filepath.Join(filepath.Dir(filepath.Dir(fixture.parser)), "lexer") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + transcript := filepath.Join(dir, placeTranscript) + if err := os.WriteFile(transcript, nil, 0o600); err != nil { + t.Fatal(err) + } + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("on it"), nil }, + }} + teamAgent(t, fixture, transcript, completer, nil) + time.Sleep(10 * time.Millisecond) + if err := teams.Update(fixture.profile, func(file *teams.File) error { + return file.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, transcript), File: transcript, Handle: "lexer", JoinedAt: time.Now()}) + }); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for completer.requests() == 0 && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if completer.requests() == 0 { + t.Fatal("a started member whose join was stamped never took its first turn") + } + first := userTextIn(completer.request(0)) + if !briefIn(first, "Rewrite the lexer.") { + t.Fatalf("the first request did not carry the brief:\n%s", first) + } + if strings.Contains(first, "Also delete the parser.") { + t.Fatalf("a directive from before the join was handed over:\n%s", first) + } +} + // WITH THE TEAM'S AUTO-WAKE OFF, A START OPENS THE MEMBER AND STARTS NO TURN. // The brief stays in the Traffic and is handed over on the first turn something // else starts, and the Traffic says why nothing ran. diff --git a/internal/session/teamname.go b/internal/session/teamname.go index efb4a8d30a..f655a892b9 100644 --- a/internal/session/teamname.go +++ b/internal/session/teamname.go @@ -26,8 +26,14 @@ import ( // already and a slow answer is simply not used. // teamNameAsk is the instruction, last in the user message for the reason -// titleSystem's comment gives. -const teamNameAsk = "Give these conversations one short shared name: one to three plain lowercase words, no quotes. Answer with the name only." +// titleSystem's comment gives; teamNameAskShort is the same for a name of at +// most two words, which is what a team made of one conversation is given +// (internal/tui3's helpers.go): a team named for one piece of work wants the +// work's name, and a third word is where a small model starts describing it. +const ( + teamNameAsk = "Give these conversations one short shared name: one to three plain lowercase words, no quotes. Answer with the name only." + teamNameAskShort = "Give this work one short name: one or two plain lowercase words, no quotes. Answer with the name only." +) // teamNameClip bounds each title handed to the namer, and teamNameTitles how // many are sent: a name for a group is read off a handful of short titles. @@ -44,6 +50,22 @@ const teamNameWords = 3 // bounds with ctx, and returns the name or an error; an answer that is not a // name is an error, never a name. func (a *Agent) NameTeam(ctx context.Context, titles []string) (string, error) { + return a.NameTeamWords(ctx, titles, teamNameWords) +} + +// NameTeamWords is [Agent.NameTeam] for a name of at most words words: two +// asks the short question ([teamNameAskShort]), and anything else, or a number +// out of range, the group's. The answer is cut to that many words whatever the +// model said. +func (a *Agent) NameTeamWords(ctx context.Context, titles []string, words int) (string, error) { + instruction := teamNameAsk + if words <= 0 || words > teamNameWords { + words = teamNameWords + } + if words <= 2 { + instruction = teamNameAskShort + } + clean := func(raw string) string { return cleanTeamNameIn(raw, words, instruction) } var lines []string for _, t := range titles { if t = strings.TrimSpace(t); t != "" && len(lines) < teamNameTitles { @@ -59,11 +81,11 @@ func (a *Agent) NameTeam(ctx context.Context, titles []string) (string, error) { if closed { return "", errors.New("the conversation is closed") } - ask := "Conversations:\n" + strings.Join(lines, "\n") + "\n\n" + teamNameAsk + ask := "Conversations:\n" + strings.Join(lines, "\n") + "\n\n" + instruction response, named, err := a.callRoleChecked(withDetachedUsage(ctx), roles.RoleTitle, model, []ai.Message{textMessage("system", titleSystem), textMessage("user", ask)}, func(response *ai.Response, named string) bool { - if cleanTeamName(response.Text()) != "" { + if clean(response.Text()) != "" { return true } a.addDetachedUsageAs(response, named, 1, auxRoleTitle) @@ -76,7 +98,7 @@ func (a *Agent) NameTeam(ctx context.Context, titles []string) (string, error) { return "", errEmptyAnswer } a.addDetachedUsageAs(response, named, 1, auxRoleTitle) - name := cleanTeamName(response.Text()) + name := clean(response.Text()) if name == "" { return "", errInvalidName } @@ -88,7 +110,11 @@ func (a *Agent) NameTeam(ctx context.Context, titles []string) (string, error) { // the speaker), lowercased, cut to [teamNameWords] words, and with anything but // letters, digits and inner hyphens taken off each word. It is "" when nothing // usable is left. -func cleanTeamName(raw string) string { +func cleanTeamName(raw string) string { return cleanTeamNameIn(raw, teamNameWords, teamNameAsk) } + +// cleanTeamNameIn is [cleanTeamName] cut to limit words, refusing an answer +// that echoes instruction, the ask the model was given. +func cleanTeamNameIn(raw string, limit int, instruction string) string { title := cleanTitle(raw) if title == "" { return "" @@ -105,13 +131,13 @@ func cleanTeamName(raw string) string { if w != "" { words = append(words, w) } - if len(words) == teamNameWords { + if len(words) == limit { break } } name := strings.Join(words, " ") // A small model that answers with the instruction has named nothing. - if len(words) >= 2 && strings.Contains(strings.ToLower(teamNameAsk), name) { + if len(words) >= 2 && strings.Contains(strings.ToLower(instruction), name) { return "" } return name diff --git a/internal/session/teamname_test.go b/internal/session/teamname_test.go index a207304326..851a492000 100644 --- a/internal/session/teamname_test.go +++ b/internal/session/teamname_test.go @@ -20,3 +20,20 @@ func TestCleanTeamName(t *testing.T) { } } } + +// A NAME FOR ONE PIECE OF WORK IS ONE OR TWO WORDS (internal/tui3's helpers.go): +// the same cleaning, cut at two, and an echo of the short ask is nothing. +func TestCleanTeamNameForOnePieceOfWorkKeepsTwoWords(t *testing.T) { + for raw, want := range map[string]string{ + "fizzbuzz": "fizzbuzz", + "Guitar Pickups": "guitar pickups", + "the relay audit follow-up": "the relay", + "\"flaky tests\".": "flaky tests", + "Give this work one short": "", + "": "", + } { + if got := cleanTeamNameIn(raw, 2, teamNameAskShort); got != want { + t.Errorf("cleanTeamNameIn(%q, 2) = %q, want %q", raw, got, want) + } + } +} diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go index 5ae9424eb7..f892a033d5 100644 --- a/internal/session/tools_team.go +++ b/internal/session/tools_team.go @@ -68,7 +68,7 @@ const teamGroup = "team" var teamToolNames = []string{ teamStatusToolName, teamReadToolName, teamSendToolName, teamStopToolName, teamStartToolName, - teamDecideToolName, teamEscalateToolName, teamCloseReportToolName, + teamDecideToolName, teamEscalateToolName, teamCloseReportToolName, teamDissolveToolName, teamPostToolName, teamRaiseToolName, teamAddToolName, teamRemoveToolName, } diff --git a/internal/teams/dissolve.go b/internal/teams/dissolve.go new file mode 100644 index 0000000000..7197c2dc40 --- /dev/null +++ b/internal/teams/dissolve.go @@ -0,0 +1,103 @@ +package teams + +import ( + "errors" + "fmt" + "os" +) + +// ── DISSOLVING A TEAM THAT NEVER HELD ANYONE ───────────────────────────────── +// +// A conversation the person turns into a team (the chat's `/helpers`) is made +// its manager at once, with nobody under it, and is asked to propose helpers. +// The person may decline them all. What they are declining is the team itself, +// so the manager is given one way to take it back while there is still nothing +// to take back: a team that has never held anyone but its manager, has no team +// under it and has said nothing in its Traffic is forgotten whole, as if it had +// never been made. The conversation is untouched and goes on as an ordinary one. +// +// IT IS NOT DISBANDING. Disbanding (lifecycle.go) is the person's, from Teams, +// and keeps the team as read-only history, because a team that ran has a record +// worth keeping. A team that never ran has none, so there is nothing to keep and +// no history row to leave behind. The moment the team has held a second +// conversation, under any route, this door is shut for good, and the team ends +// the way every other team does. +// +// NOTHING HERE KNOWS WHICH DOOR MADE THE TEAM. A team made on the Teams page +// and given a manager there, and then never used, is dissolvable by the same +// rule: one rule for every team, whichever way it was made. + +// ErrNotDissolvable is [Dissolve] asked to forget a team that has held more than +// its manager, has a team under it, or has Traffic. +var ErrNotDissolvable = errors.New("teams: this team has already held members or exchanged messages; the person disbands it from Teams") + +// Dissolvable reports whether team id has never held anyone but its manager: it +// is open, it is not the root, it has a manager, that manager is its one member, +// it has no former member or former manager, and no team sits under it. It reads +// the file only; [Dissolve] also asks the Traffic. +func (f *File) Dissolvable(id string) bool { + t, ok := f.Team(id) + if !ok || t.Closed() || t.Root || t.Manager == "" { + return false + } + if len(t.Members) != 1 || t.Members[0].Key != t.Manager { + return false + } + if len(t.FormerMembers) > 0 || t.FormerManager != "" { + return false + } + return len(f.Children(id)) == 0 +} + +// Dissolve forgets team id when it is [File.Dissolvable], manager is still its +// manager, and its Traffic is empty. It is checked and written under the +// store's lock, so a member added since the caller looked is seen. The team's +// directory goes with it. No conversation is touched. +func Dissolve(profileDir, id, manager string) error { + err := Update(profileDir, func(f *File) error { + t, ok := f.Team(id) + if !ok { + return fmt.Errorf("no team %s", id) + } + if t.Manager != manager { + return errors.New("this conversation no longer manages that team") + } + if !f.Dissolvable(id) || trafficSaid(profileDir, id) { + return ErrNotDissolvable + } + kept := f.Teams[:0:0] + for _, other := range f.Teams { + if other.ID != id { + kept = append(kept, other) + } + } + f.Teams = kept + return nil + }) + if err != nil { + return err + } + if safeTeamID(id) == nil { + if err := os.RemoveAll(TeamDir(profileDir, id)); err != nil { + return err + } + } + forgetPackets(profileDir) + return nil +} + +// trafficSaid reports whether team id's Traffic has anything in it, either +// file of it. An unreadable log counts as said, so a doubt keeps the team. +func trafficSaid(profileDir, id string) bool { + path := TrafficPath(profileDir, id) + for _, file := range []string{path, trafficRotated(path)} { + info, err := os.Stat(file) + if err == nil && info.Size() > 0 { + return true + } + if err != nil && !os.IsNotExist(err) { + return true + } + } + return false +} diff --git a/internal/teams/dissolve_test.go b/internal/teams/dissolve_test.go new file mode 100644 index 0000000000..b3aaffec47 --- /dev/null +++ b/internal/teams/dissolve_test.go @@ -0,0 +1,117 @@ +package teams + +import ( + "errors" + "os" + "testing" + "time" +) + +// leadOnly is a profile holding one team, `solo`, whose one member is its +// manager, beside a second team that has nothing to do with it. +func leadOnly(t *testing.T) string { + t.Helper() + dir := t.TempDir() + must(t, Update(dir, func(f *File) error { + f.Teams = append(f.Teams, Team{ID: "aaaaaaaaaaaa", Name: "solo"}, Team{ID: "cccccccccccc", Name: "yard"}) + must(t, f.AddMember("aaaaaaaaaaaa", Member{Key: "lead", File: "lead", Word: "the lead"})) + must(t, f.AddMember("cccccccccccc", Member{Key: "lead", File: "lead", Word: "the lead"})) + must(t, f.AddMember("cccccccccccc", Member{Key: "y1", File: "y1", Word: "yard work"})) + return f.SetManager("aaaaaaaaaaaa", "lead") + })) + return dir +} + +// A TEAM THAT NEVER HELD ANYONE BUT ITS MANAGER IS FORGOTTEN WHOLE: its record +// and its directory go, and the manager's other memberships stay. +func TestDissolveForgetsATeamThatOnlyEverHeldItsManager(t *testing.T) { + dir := leadOnly(t) + must(t, os.MkdirAll(TeamDir(dir, "aaaaaaaaaaaa"), 0o700)) + must(t, Dissolve(dir, "aaaaaaaaaaaa", "lead")) + f, err := Load(dir) + must(t, err) + if _, ok := f.Team("aaaaaaaaaaaa"); ok { + t.Fatal("the dissolved team is still in the file") + } + if len(f.ClosedTeams()) != 0 { + t.Fatal("dissolving left a disbanded row behind") + } + yard, ok := f.Team("cccccccccccc") + if !ok || !yard.Holds("lead") { + t.Fatal("dissolving touched the manager's other membership") + } + if _, err := os.Stat(TeamDir(dir, "aaaaaaaaaaaa")); !os.IsNotExist(err) { + t.Fatalf("the team's directory survived: %v", err) + } +} + +// EVERY SIGN THAT THE TEAM WAS USED SHUTS THE DOOR, and a refusal writes +// nothing: a second member, a removed member, a former manager, a team under +// it, a line of Traffic, a disband, and a caller that is not its manager. +func TestDissolveRefusesATeamThatWasUsed(t *testing.T) { + cases := []struct { + name string + used func(t *testing.T, dir string) + manager string + }{ + {"a second member", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { return f.AddMember("aaaaaaaaaaaa", Member{Key: "web", File: "web"}) })) + }, "lead"}, + {"a member since removed", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { return f.AddMember("aaaaaaaaaaaa", Member{Key: "web", File: "web"}) })) + must(t, Update(dir, func(f *File) error { return f.RemoveMember("aaaaaaaaaaaa", "web") })) + }, "lead"}, + {"a team under it", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { + f.Teams = append(f.Teams, Team{ID: "bbbbbbbbbbbb", Name: "under", Parent: "aaaaaaaaaaaa"}) + return nil + })) + }, "lead"}, + {"a line of traffic", func(t *testing.T, dir string) { + must(t, AppendTraffic(dir, "aaaaaaaaaaaa", Entry{Kind: KindNote, From: FromManager, To: ToEveryone, Text: "hello"})) + }, "lead"}, + {"a disband", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { return f.Disband("aaaaaaaaaaaa", time.Time{}, "") })) + }, "lead"}, + {"another conversation asking", func(t *testing.T, dir string) {}, "y1"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := leadOnly(t) + c.used(t, dir) + before, err := os.ReadFile(Path(dir)) + must(t, err) + if err := Dissolve(dir, "aaaaaaaaaaaa", c.manager); err == nil { + t.Fatal("a used team was dissolved") + } + after, err := os.ReadFile(Path(dir)) + must(t, err) + if string(before) != string(after) { + t.Fatal("a refused dissolve wrote the teams file") + } + }) + } +} + +// THE ROOT IS NEVER DISSOLVABLE, even holding only its global manager. +func TestTheRootIsNotDissolvable(t *testing.T) { + f := &File{Teams: []Team{{ID: "rrrrrrrrrrrr", Name: "All teams", Root: true, Manager: "g", Members: []Member{{Key: "g"}}}}} + if f.Dissolvable("rrrrrrrrrrrr") { + t.Fatal("the root reads as dissolvable") + } +} + +// A TEAM ANOTHER WRITER FILLED SINCE THE CALLER LOOKED is refused under the +// store's lock, with the error a caller can name. +func TestDissolveSeesAMemberAddedSinceTheCallerLooked(t *testing.T) { + dir := leadOnly(t) + f, err := Load(dir) + must(t, err) + if !f.Dissolvable("aaaaaaaaaaaa") { + t.Fatal("the fixture is not dissolvable") + } + must(t, Update(dir, func(f *File) error { return f.AddMember("aaaaaaaaaaaa", Member{Key: "web", File: "web"}) })) + if err := Dissolve(dir, "aaaaaaaaaaaa", "lead"); !errors.Is(err, ErrNotDissolvable) { + t.Fatalf("dissolve answered %v, want ErrNotDissolvable", err) + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 3384e04d88..0fbf6c11b2 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1462,6 +1462,12 @@ type app struct { deletedSessionRows map[tasksKey]bool deleteTask func(file, id string) error tmemberStart teamMemberStart + // helpers is a `/helpers` waiting for the store to take its team before + // the conversation is asked for its helpers (helpers.go). + helpers helpersPending + // helpersName is the one name asked for a team the Teams row made + // (helpers.go). + helpersName helpersNaming // tp is the teams page's own state: its selection, its reading of the // store and the targets it drew (teamspage.go). tp teamsPage @@ -7972,6 +7978,10 @@ func (a *app) slash(line string) tea.Cmd { // THE TEAM-LEVEL VIEW, which is a place (place_teams.go). return a.showPage(pageTeams) + case "helpers": + // THIS CONVERSATION AS A TEAM'S MANAGER, asked for helpers (helpers.go). + return a.runHelpersCommand(rest) + case "spend": // AND THE WHOLE MACHINE'S BILL, which is a place and not a note. This word // was an alias of /cost until this wave, so the one guess a developer makes diff --git a/internal/tui3/caption.go b/internal/tui3/caption.go index f0be25c4e7..f411b1b435 100644 --- a/internal/tui3/caption.go +++ b/internal/tui3/caption.go @@ -378,6 +378,8 @@ func teamCaptionGloss(e entry) string { return "passing a decision up" case "team_close_report": return "writing the closing report" + case "team_dissolve": + return "dissolving the team" } return "" } @@ -478,7 +480,7 @@ func captionVerb(tool string) string { return "listing" case "team_send", "team_post": return "sending" - case "team_start", "team_stop", "team_read", "team_status", "team_raise", "team_decide", "team_escalate", "team_close_report": + case "team_start", "team_stop", "team_read", "team_status", "team_raise", "team_decide", "team_escalate", "team_close_report", "team_dissolve": return "managing" default: return firstNonEmpty(tool, "working") @@ -494,27 +496,28 @@ func captionVerb(tool string) string { // change, and guessing one would garble the only part of the row that is quoted // rather than composed. var captionPastVerbs = map[string]string{ - "asking": "asked", - "building": "built", - "checking": "checked", - "deciding": "decided", - "editing": "edited", - "fetching": "fetched", - "listing": "listed", - "looking": "looked", - "managing": "managed", - "messaging": "messaged", - "passing": "passed", - "posting": "posted", - "raising": "raised", - "reading": "read", - "running": "ran", - "searching": "searched", - "sending": "sent", - "starting": "started", - "stopping": "stopped", - "working": "worked", - "writing": "wrote", + "asking": "asked", + "building": "built", + "checking": "checked", + "deciding": "decided", + "dissolving": "dissolved", + "editing": "edited", + "fetching": "fetched", + "listing": "listed", + "looking": "looked", + "managing": "managed", + "messaging": "messaged", + "passing": "passed", + "posting": "posted", + "raising": "raised", + "reading": "read", + "running": "ran", + "searching": "searched", + "sending": "sent", + "starting": "started", + "stopping": "stopped", + "working": "worked", + "writing": "wrote", } // captionPast is the one door onto that table: a floor caption composed in the diff --git a/internal/tui3/chatsnavigation_test.go b/internal/tui3/chatsnavigation_test.go index 7a109a8e0d..7bd7e39fd8 100644 --- a/internal/tui3/chatsnavigation_test.go +++ b/internal/tui3/chatsnavigation_test.go @@ -194,18 +194,24 @@ func TestTeamMenuLongTreeScrollsAndTinyResizeCannotChoose(t *testing.T) { if !a.teamMenu.card.holds(a.teamMenu.card.x0, a.teamMenu.card.y0) || len(a.teamMenu.hits) >= len(a.teamMenuRows()) { t.Fatal("long picker was not bounded") } - for range 37 { + // The cursor counts the rows it can land on. With harbor selected there + // is no `+ New from this session` (teammenu.go), so the last is a team. + picks := teamMenuPicks(a.teamMenuRows()) + for range len(picks) { drive(t, a, key("down")) menuFrame(t, a) } - chosen := a.teamMenuRows()[a.teamMenu.cursor] + chosen := picks[a.teamMenu.cursor] + if chosen.code != wallPopTeam || a.teamMenu.cursor != len(picks)-1 { + t.Fatalf("the last row the cursor reaches is %+v", chosen) + } hit := menuHit(t, a, chosen.code, chosen.id) if !a.teamMenu.card.holds(hit.x0, hit.y0) || a.teamMenu.top == 0 { t.Fatal("selected last row is offscreen") } drive(t, a, tea.MouseWheelMsg{X: hit.x0, Y: hit.y0, Button: tea.MouseWheelUp}) menuFrame(t, a) - if !a.teamMenu.on || a.teamMenu.cursor >= 37 || a.wall.activeID != harbor { + if !a.teamMenu.on || a.teamMenu.cursor >= len(picks) || a.wall.activeID != harbor { t.Fatal("wheel escaped the picker or selected a team") } for width := 22; width <= 80; width++ { diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 2f2d97b076..85b8599526 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -155,6 +155,13 @@ var commands = []command{ // conversations big, and this is the team-level view, every member open or // not, the manager's conversation and what waits on you (place_teams.go). {name: "teams", desc: "your teams, their managers and what waits on you · " + placeChord(pageTeams)}, + // AND THE ASK A MANAGER IS GIVEN FOR HELPERS, under the page its team is on + // (helpers.go): the team is made by the Teams menu's `+ New from this + // session`, and this sends the manager the words after the command. Two + // rows, the way /manual has two, because bare it sends a default ask, + // written out in the transcript, and with words it sends those instead. + {name: "helpers", desc: "asks the team this conversation manages to suggest helpers · make the team with + New from this session in the Teams menu"}, + {name: "helpers", args: "", desc: "…the same, sending your own words instead of the default ask"}, {name: "spend", desc: "what this machine has cost, by the day · " + placeChord(pageSpend)}, // It sits AFTER /compact and before /help because those two are the pair a // person reads together when a conversation has gone wrong: compacting is diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go new file mode 100644 index 0000000000..6ae06bfd1a --- /dev/null +++ b/internal/tui3/helpers.go @@ -0,0 +1,447 @@ +package tui3 + +// + New from this session, AND /helpers — THIS CONVERSATION BECOMES A TEAM'S +// MANAGER, AND THE TEAM IS ASKED FOR HELPERS. +// +// Every other door onto a team starts from the team: the Teams page's `+ New AI +// team`, the conversation grid's `+ New team`, Organize, and a manager's own +// sub-team start. Each makes a team first and a manager after, if at all, and +// the manager `+ AI manager` makes is a NEW conversation that knows nothing yet. +// This door starts from the other end. The person is already deep in a piece of +// work, it has grown, and they want it to become a team effort with the +// conversation that knows the work running it. +// +// IT IS TWO STEPS, AND EACH ONE ANSWERS AT ONCE. +// +// 1. The Teams dropdown's last row, `+ New from this session` (teammenu.go), +// MAKES THE TEAM WHEN IT IS CHOSEN ([app.helpersNewTeam]): a new top-level +// team with the conversation in front as its manager and only member, in +// ONE store edit ([app.teamCreateLed]) — the same edit the Teams page's +// dialog makes, with the manager line added, so every rule a team made +// there meets is met here. The strip narrows to it on the same frame, so +// the person sees `◆ Manager` and the column turns to the team's Traffic: +// the choice has an answer nobody can miss. The box is given `/helpers` +// and [helpersPrompt], in place of what was there, for the second step. +// 2. `/helpers` ASKS THE TEAM THIS CONVERSATION MANAGES FOR HELPERS +// ([app.runHelpersCommand]): the words after it, or [helpersPrompt], sent +// as the person's message, and nothing else. It makes no team. In a +// conversation that manages none it changes nothing and says where the +// team is made. +// +// IT USED TO BE ONE STEP, and that was the defect. `/helpers` made the team and +// sent the ask, and the row only filled the box; a person who chose the row saw +// nothing happen above, and the words that appeared in the box below were +// missed. A team made on the click is the reaction; helpers make helpers. +// +// NOTHING IS SENT THAT THE PERSON DID NOT SEE. The words the model reads are +// the words in the transcript after `/helpers`, exactly. What the model knows +// beyond those words is what every manager knows: the role note internal/session +// lands at its first step, and its verbs' own descriptions. A /helpers pressed +// before the store has the team is held until it does ([app.helpersSettled]), +// so the turn's first step finds the conversation already a manager. +// +// THE ROW IS FOR A CONVERSATION SEEN WITH NO TEAM SELECTED. With a team's +// overlay on the strip the row is absent (teammenu.go's [app.teamMenuHelpers]), +// because the team it makes is a top-level team and making it from inside +// another team would read as nesting; a team inside a team is `+ Add subteam` +// on Teams, or a manager's own sub-team start. +// +// THE PERSON ANSWERS IN THEIR OWN WORDS. There is no card. Yes is a reply the +// manager acts on with `team_start`, which runs under the approval posture every +// other start does, and each helper it starts opens as a tab in this team's +// overlay (teamtraffic.go's [app.trafficStarted]); a different list is a reply +// it proposes again from; and cancel is a reply it acts on with +// `team_dissolve`, which takes back a team that has never held anyone but its +// manager and is on the belt only while that is true (internal/session's +// team_dissolve.go). Disband on Teams ends it too, and keeps it as history. + +import ( + "context" + "fmt" + "math/rand/v2" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// helpersPrompt is what a bare /helpers asks, and what the dropdown's row puts +// in the box after `/helpers `. It is the person speaking, in one sentence of +// words they would use, because it is their message and it is on their screen: +// it names no tool and nothing the person could not have typed. It asks for a +// proposal and says what happens next — the person answers yes, a change, or +// cancel — which is what keeps the manager from starting anyone first. +// +// IT DESCRIBES THIS EXCHANGE AND STATES NO STANDING RULE. An earlier wording +// ended "Don't start anyone until I agree, and if I say cancel, undo the team.", +// and the memory that notices rules (internal/session's memory) kept that as a +// standing order for the whole project, read by every manager after. Said as +// the next step of this one conversation, there is nothing in it to keep. +const helpersPrompt = "Suggest two or three helpers for this work, and I'll say yes, change them, or cancel." + +// The words this door says. Each is quoted in the manual exactly as spelled. +const ( + // helpersRowWord is the Teams dropdown's last row, which makes the team + // and puts /helpers and [helpersPrompt] in the box. + helpersRowWord = "+ New from this session" + // helpersPlaceWord is /helpers, or the row, where no conversation is in + // front. + helpersPlaceWord = "/helpers asks the team a conversation manages for helpers · open that conversation first" + // helpersFreshWord is the row on the welcome screen, before there is a + // conversation to make a team of. + helpersFreshWord = "+ New from this session makes a team of a conversation you have started · say what you are working on first" + // helpersNotManagerWord is /helpers in a conversation that manages no + // team: it says where the team is made, and changes nothing. + helpersNotManagerWord = "/helpers asks this conversation's team for helpers, and it has none yet · make one with + New from this session in the Teams menu" + // helpersManagesWord is the row in a conversation that already manages a + // team; the strip is narrowed to that team. + helpersManagesWord = "this conversation already manages %s · ask it with /helpers" + // helpersPendingWord is the row chosen again while its team is being saved. + helpersPendingWord = "the team is still being saved" + // helpersMadeWord is the receipt, once the store has the team. + helpersMadeWord = "made the team %s · this conversation is its manager" + // helpersAwayWord is the receipt when the person left the conversation + // before the store answered, with a /helpers waiting: the team is made and + // the message is not sent. + helpersAwayWord = "made the team %s · its manager is the conversation you left; ask it with /helpers there" +) + +// helpersShared is the refusal on a connection that holds one conversation at +// a time: a helper is a conversation that opens beside its manager. +var helpersShared = "helpers open beside this conversation, and " + oneConversationWord + +// helpersPending is one team the row made, between its edit and the store's +// answer: the edit's notice tie, the conversation it made a manager, the +// team's id and the name it was made with, the line it put in the box, and a +// /helpers pressed meanwhile (ask and shown), held until the store has the +// team. due says the answer is in and [app.helpersSettled] has not acted on +// it yet. +type helpersPending struct { + said teamWriteSaid + key string + id string + name string + box string + ask string + shown string + due bool +} + +// ── THE TEAM'S NAME, FROM THE CONVERSATION ────────────────────────────────── +// +// THE ROW NAMES THE TEAM TWICE, and the first name is only a placeholder. A +// click has to answer on its frame, and no model answers on a frame, so the +// team is made at once under its folder's name ([teamFreshName], the same word +// the conversation grid offers). Beside the loop, the naming role is asked — +// once, bounded by [teamNameWait], on the cheap model conversations name +// themselves with — for one or two words naming the work in this +// conversation, read off its title or its opening words. When that answer +// comes, the team takes it. +// +// THE ANSWER NEVER OVERWRITES A CHOICE. It is applied inside the store's own +// edit, and only while the team still has the name it was made with: a team +// the person has renamed meanwhile, a team that is gone, and a name another +// team already has all leave the team exactly as it is, and so does a failure, +// a timeout or an answer that is not a name. Nothing is said for any of them; +// the folder's name was a real name all along. + +// helpersNameWords is the most words a team made from one conversation is +// named with: the work's name, not a description of it. +const helpersNameWords = 2 + +// helpersNamedWord is said in the manager's conversation when the team takes +// its name from the conversation. +const helpersNamedWord = "named the team %s" + +// helpersNamer is the naming ask with a word limit (internal/session's +// teamname.go, and over the wire internal/remote's teamask.go). An agent that +// only has the group's ask ([teamNamer]) is asked that, and its answer is cut +// to [helpersNameWords]. +type helpersNamer interface { + NameTeamWords(ctx context.Context, titles []string, words int) (string, error) +} + +// helpersNaming is the one name being asked for: which ask it is (gen), and +// the team, the name it was made with, and its manager. +type helpersNaming struct { + gen int + id string + from string + key string +} + +// helpersAskName asks for team id's name, beside the loop, from title: the +// conversation in front, which the row has just made the team's manager. +func (a *app) helpersAskName(id, from, key, title string) tea.Cmd { + a.helpersName.gen++ + a.helpersName = helpersNaming{gen: a.helpersName.gen, id: id, from: from, key: key} + title = strings.TrimSpace(title) + if title == "" || a.agent == nil { + return nil + } + worded, hasWords := a.agent.(helpersNamer) + grouped, hasGroup := a.agent.(teamNamer) + if !hasWords && !hasGroup { + return nil + } + gen := a.helpersName.gen + return a.besideLine(func() func(bool) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), teamNameWait) + defer cancel() + var name string + var err error + if hasWords { + name, err = worded.NameTeamWords(ctx, []string{title}, helpersNameWords) + } else { + name, err = grouped.NameTeam(ctx, []string{title}) + } + return func(bool) tea.Cmd { + a.helpersNamed(gen, name, err) + return a.wake() + } + }) +} + +// helpersNamed takes answer gen as the team's name, when it is a name and the +// team will still take it (see above). The edit itself checks again under the +// store's lock, so a rename made in another window is not undone either. +func (a *app) helpersNamed(gen int, name string, err error) { + n := a.helpersName + if gen != n.gen || err != nil { + return + } + words := strings.Fields(strings.ToLower(strings.TrimSpace(name))) + if len(words) > helpersNameWords { + words = words[:helpersNameWords] + } + name = ansi.Truncate(strings.Join(words, " "), teamNameCells, "") + if name == "" || strings.EqualFold(name, n.from) { + return + } + t, ok := a.teamByID(n.id) + if !ok || t.Closed() || t.Name != n.from { + return + } + for _, other := range a.wall.teams { + if other.ID != n.id && strings.EqualFold(other.Name, name) { + return + } + } + id, from := n.id, n.from + if a.teamEdit(func(f *teamstore.File) error { + i := teamIndex(f.Teams, id) + if i < 0 || f.Teams[i].Closed() || f.Teams[i].Name != from { + return nil + } + for j, other := range f.Teams { + if j != i && strings.EqualFold(other.Name, name) { + return nil + } + } + f.Teams[i].Name = name + return nil + }) != nil { + return + } + // THE RECEIPT SAYS THE NAME ONCE. A name in before the store has taken + // the team is in the receipt that follows ([app.helpersSettled] reads the + // team's name as it is then); one after it is said on its own line. + waiting := a.helpers.id == n.id && a.helpers.said.pending + if a.frontTabKey() == n.key && !waiting { + a.note(fmt.Sprintf(helpersNamedWord, name)) + } + a.touch() +} + +// helpersTab is the conversation in front as a strip tab, and false when there +// is none to make a team of. +func (a *app) helpersTab() (chatTab, bool) { + if a.agent == nil || a.startingChat() { + return chatTab{}, false + } + key := a.frontTabKey() + if key == "" { + return chatTab{}, false + } + return chatTab{key: key, file: a.file, where: a.workspace, word: a.conversationName()}, true +} + +// helpersTeam is the open team the conversation with key manages, the global +// manager's `All teams` aside: it holds managers rather than helpers. Memory +// only. +func (a *app) helpersTeam(key string) (team, bool) { + if !a.wall.loaded || key == "" { + return team{}, false + } + if t, ok := a.teamActive(); ok && t.Manager == key && !t.Closed() && !t.Root { + return t, true + } + for _, t := range a.wall.teams { + if t.Manager == key && !t.Closed() && !t.Root { + return t, true + } + } + return team{}, false +} + +// helpersRowRefusal is why the row cannot make a team of the conversation in +// front, "" when it can, and the team it already manages when that is why. +func (a *app) helpersRowRefusal() (string, team) { + switch { + case a.pageShowing(): + return helpersPlaceWord, team{} + case a.teamsOff(): + return teamHostedWord, team{} + case a.shared: + return helpersShared, team{} + } + tab, ok := a.helpersTab() + if !ok { + return helpersFreshWord, team{} + } + if a.helpers.said.pending { + return helpersPendingWord, team{} + } + a.teamsEnsure() + if t, manages := a.helpersTeam(tab.key); manages { + return fmt.Sprintf(helpersManagesWord, t.Name), t + } + return "", team{} +} + +// helpersTipArmed is the 💡 tip's rule (notice.go): a conversation that has +// been spoken to, in front, with no team selected, not already a manager, and +// on a window that can keep teams and open helpers beside it — which is where +// the row would make its team. It reads memory only, because the table asks it +// at every event. +func helpersTipArmed(a *app) bool { + if a.turn < 1 || a.pageShowing() || a.teamsOff() || a.shared || !a.wall.loaded { + return false + } + if _, selected := a.teamActive(); selected { + return false + } + _, manages := a.helpersTeam(a.frontTabKey()) + return !manages +} + +// helpersNewTeam is the Teams dropdown's `+ New from this session`: the team +// made and shown at once, and `/helpers ` and [helpersPrompt] put in the box, +// in place of what was there, for the person to read, change and send. What +// was in the box goes onto the drafts ring first, as a draft restored over it +// does (draftpage.go), so replacing it loses nothing. Where the row cannot +// make a team it says why and leaves the box alone. +func (a *app) helpersNewTeam() tea.Cmd { + a.noticeEvent(eventHelpersUsed) + a.closeTeamMenu() + if a.wall.on { + a.closeWall() + } + if why, managed := a.helpersRowRefusal(); why != "" { + a.note(why) + if managed.ID != "" { + return a.teamActivate(managed.ID) + } + return nil + } + tab, _ := a.helpersTab() + name := teamFreshName([]chatTab{tab}, a.teamNames(), "", rand.IntN) + hue := nextTeamHue(a.teamHues(""), teamReservedHues(a.pal)) + id, err := a.teamCreateLed(name, []chatTab{tab}, hue, tab.key) + if err != nil { + // THE EDIT REFUSED WHAT THIS WINDOW HOLDS, so nothing was queued and + // nothing changed: the store's own rules, in the store's own words. + a.note("could not make a team of this conversation: " + err.Error()) + return nil + } + // A REFUSAL FROM THE STORE PUTS THE WINDOW BACK the way a refused `+ AI + // manager` does ([app.teamsTake]): the overlay closes and the store's + // teams are taken, so a team the store never had is not drawn. + a.teamsWatchManagerStart(tab.key) + line := "/helpers " + helpersPrompt + if live := a.input.String(); strings.TrimSpace(live) != "" && live != line { + a.drafts.push([]rune(live)) + } + a.input.setText(line) + a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, id: id, name: name, box: line} + a.touch() + return tea.Batch(a.teamActivate(id), a.syncLists(), a.helpersAskName(id, name, tab.key, tab.word)) +} + +// runHelpersCommand is /helpers: the person's words, or [helpersPrompt], sent +// to the conversation in front as their message, when it manages a team. The +// transcript shows exactly what was sent. A team the row is still saving holds +// the words until the store has it. +func (a *app) runHelpersCommand(rest string) tea.Cmd { + a.noticeEvent(eventHelpersUsed) + switch { + case a.pageShowing(): + a.note(helpersPlaceWord) + return nil + case a.teamsOff(): + a.note(teamHostedWord) + return nil + case a.shared: + a.note(helpersShared) + return nil + } + ask := strings.TrimSpace(rest) + if ask == "" { + ask = helpersPrompt + } + shown := "/helpers " + ask + front := a.frontTabKey() + if a.helpers.said.pending && a.helpers.key == front && front != "" { + a.helpers.ask, a.helpers.shown = ask, shown + return nil + } + a.teamsEnsure() + t, manages := a.helpersTeam(front) + if a.agent == nil || a.startingChat() || !manages { + a.note(helpersNotManagerWord) + return nil + } + // THE STRIP SHOWS THE TEAM BEING ASKED. The conversation in front is its + // manager, so this moves no focus. + var show tea.Cmd + if active, ok := a.teamActive(); !ok || active.ID != t.ID { + show = a.teamActivate(t.ID) + } + return tea.Batch(show, a.submitShown(ask, shown, nil)) +} + +// helpersSettled is a row's team whose write has come back: the receipt when +// the store took the team, and a /helpers held meanwhile sent; and, when it +// refused it, the box given back its own words, because [app.teamsTake] has +// already said why and the window has taken the store's teams back. +func (a *app) helpersSettled() tea.Cmd { + p := a.helpers + if !p.due { + return nil + } + a.helpers = helpersPending{} + if p.said.why != "" { + if a.input.String() == p.box { + a.input.reset() + a.touch() + } + return nil + } + name := p.name + if t, ok := a.teamByID(p.id); ok { + name = t.Name + } + if a.frontTabKey() != p.key { + if p.ask != "" { + a.note(fmt.Sprintf(helpersAwayWord, name)) + } + return nil + } + a.note(fmt.Sprintf(helpersMadeWord, name)) + if p.ask == "" { + return nil + } + return a.submitShown(p.ask, p.shown, nil) +} diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go new file mode 100644 index 0000000000..880ea42a51 --- /dev/null +++ b/internal/tui3/helpers_test.go @@ -0,0 +1,676 @@ +package tui3 + +import ( + "context" + "fmt" + "strings" + "testing" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// helpersApp is [tabApp] with its teams on a profile of the test's own and no +// team yet: a person three conversations into their work, in front of +// "Shipping the parser" in /tmp/lab. +func helpersApp(t *testing.T) (*app, *fakeAgent) { + t.Helper() + a, _, _ := tabApp(t) + a.profileDir = t.TempDir() + a.teamsEnsure() + fake, _ := a.agent.(*fakeAgent) + if fake == nil { + t.Fatal("the fixture's agent is not the fake") + } + return a, fake +} + +// helpersDisk is the teams this window's profile holds on disk. +func helpersDisk(t *testing.T, a *app) []team { + t.Helper() + disk, err := loadTeams(a.profileDir, nil) + if err != nil { + t.Fatal(err) + } + return disk +} + +// lastPersonLine is the newest line of the person's in the transcript. +func lastPersonLine(t *testing.T, a *app) string { + t.Helper() + for i := len(a.entries) - 1; i >= 0; i-- { + if a.entries[i].kind == entryUser { + return a.entries[i].text + } + } + t.Fatal("no line of the person's in the transcript") + return "" +} + +// transcriptSays reports whether any note in the transcript holds want. +func transcriptSays(a *app, want string) bool { + for _, e := range a.entries { + if e.kind == entryNote && strings.Contains(e.text, want) { + return true + } + } + return false +} + +// helpersMenuRow is the Teams dropdown's `+ New from this session` row, and +// whether it is the last row. +func helpersMenuRow(t *testing.T, a *app) (teamMenuRow, bool) { + t.Helper() + rows := a.teamMenuRows() + for i, r := range rows { + if !r.rule && r.code == wallPopHelpers { + return r, i == len(rows)-1 + } + } + t.Fatalf("the dropdown has no %q row: %+v", helpersRowWord, rows) + return teamMenuRow{}, false +} + +// helpersMakeTeam chooses `+ New from this session` and lets the store take +// the team, as a person's click and the loop after it do. It hands back the +// team as the store holds it. +func helpersMakeTeam(t *testing.T, a *app) team { + t.Helper() + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) + teamsFlush(t, a) + front := a.frontTabKey() + for _, made := range helpersDisk(t, a) { + if made.Manager == front { + return made + } + } + t.Fatalf("the row made no team this conversation manages: %+v", helpersDisk(t, a)) + return team{} +} + +// `+ New from this session` MAKES THE TEAM WHEN IT IS CHOSEN. On the same frame, +// before the store has answered, the strip is the new team's, the column is +// the manager's and the box holds `/helpers` and the default ask, with what was +// there kept on the drafts ring; nothing is sent. Once the store has it, the +// team is on disk with this conversation as manager and only member, and the +// receipt says so. Enter then sends the ask, exactly as it reads. +func TestNewFromThisSessionMakesTheTeamAtOnce(t *testing.T) { + a, fake := helpersApp(t) + front := a.frontTabKey() + a.input.insert("half a thought") + a.openTeamMenu() + for range teamMenuPicks(a.teamMenuRows()) { + drive(t, a, key("down")) + } + drive(t, a, key("enter")) + if a.teamMenu.on { + t.Fatal("the dropdown stayed up after its row was chosen") + } + active, ok := a.teamActive() + if !ok || active.Manager != front || a.sideKind() != sideKindManager { + t.Fatal("choosing the row did not show the new team, with this conversation as its manager, at once") + } + if hint := a.trafficHint(); !strings.Contains(hint, "manager of lab") { + t.Fatalf("the composer says %q, not that it talks to the manager", hint) + } + if got := a.input.String(); got != "/helpers "+helpersPrompt { + t.Fatalf("the box holds %q", got) + } + if len(a.drafts.entries) == 0 || string(a.drafts.entries[0].text) != "half a thought" { + t.Fatal("what was in the box was not kept on the drafts ring") + } + if len(fake.sent) != 0 { + t.Fatalf("choosing the row sent %q", fake.sent) + } + + teamsFlush(t, a) + disk := helpersDisk(t, a) + if len(disk) != 1 { + t.Fatalf("the row left %d teams on disk: %+v", len(disk), disk) + } + made := disk[0] + if made.Manager != front || len(made.Members) != 1 || made.Members[0].Key != front || made.Name != "lab" || made.Parent != "" { + t.Fatalf("the team is not this conversation alone, as manager, at the top level: %+v", made) + } + if !transcriptSays(a, fmt.Sprintf(helpersMadeWord, "lab")) { + t.Fatal("no receipt says the team was made") + } + if len(fake.sent) != 0 { + t.Fatalf("the store's answer sent %q before enter", fake.sent) + } + + drive(t, a, key("enter")) + if len(fake.sent) != 1 || fake.sent[0] != helpersPrompt { + t.Fatalf("enter sent %q", fake.sent) + } + if shown := lastPersonLine(t, a); shown != "/helpers "+helpersPrompt { + t.Fatalf("the transcript says %q", shown) + } + if n := len(helpersDisk(t, a)); n != 1 { + t.Fatalf("/helpers made another team: %d", n) + } +} + +// /helpers IN A MANAGER SENDS EXACTLY ITS WORDS. The model is handed what the +// transcript shows after the command and nothing else, and no team is made. +func TestHelpersSendsTheManagerExactlyItsWords(t *testing.T) { + a, fake := helpersApp(t) + helpersMakeTeam(t, a) + a.input.reset() + typeLine(t, a, "/helpers suggest one helper for the flaky tests") + teamsFlush(t, a) + if len(fake.sent) != 1 || fake.sent[0] != "suggest one helper for the flaky tests" { + t.Fatalf("the model was handed %q", fake.sent) + } + if shown := lastPersonLine(t, a); shown != "/helpers suggest one helper for the flaky tests" { + t.Fatalf("the transcript says %q", shown) + } + if n := len(helpersDisk(t, a)); n != 1 { + t.Fatalf("/helpers made a team: %d on disk", n) + } +} + +// THE DEFAULT ASK IS ONE SENTENCE IN THE PERSON'S WORDS. It names no tool and +// none of codeaf's machinery, and it says what happens next rather than laying +// down a rule a memory could keep. +func TestTheDefaultHelpersAskIsOneSentenceInThePersonsWords(t *testing.T) { + if strings.Count(helpersPrompt, ".") != 1 || !strings.HasSuffix(helpersPrompt, ".") { + t.Fatalf("the default ask is not one sentence: %q", helpersPrompt) + } + for _, internal := range []string{"team_", "tool", "manager", "codeaf", "handle", "don't", "until"} { + if strings.Contains(strings.ToLower(helpersPrompt), internal) { + t.Errorf("the default ask says %q: %q", internal, helpersPrompt) + } + } + for _, want := range []string{"two or three helpers", "yes", "cancel"} { + if !strings.Contains(helpersPrompt, want) { + t.Errorf("the default ask does not say %q: %q", want, helpersPrompt) + } + } +} + +// /helpers BEFORE THE STORE HAS THE TEAM IS HELD, NOT LOST. The ask waits for +// the write that carries the team, so the model's first step finds the +// conversation already a manager; it is sent once, after the receipt. (The +// command is run directly: the loop that carries a key also carries the write, +// in the same message.) +func TestHelpersBeforeTheStoreHasTheTeamIsHeld(t *testing.T) { + a, fake := helpersApp(t) + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) + spend(t, a, a.runHelpersCommand("")) + if len(fake.sent) != 0 { + t.Fatal("the ask went out before the store took the team") + } + teamsFlush(t, a) + if len(fake.sent) != 1 || fake.sent[0] != helpersPrompt { + t.Fatalf("the store's answer sent %q", fake.sent) + } + if shown := lastPersonLine(t, a); shown != "/helpers "+helpersPrompt { + t.Fatalf("the transcript says %q", shown) + } + if n := len(helpersDisk(t, a)); n != 1 { + t.Fatalf("%d teams on disk", n) + } +} + +// /helpers MAKES NO TEAM. In a conversation that manages none — with no team +// selected, or seen as an ordinary member of the team selected — it changes +// nothing, sends nothing, and says where the team is made. On home it answers +// the same way. +func TestHelpersInAConversationThatManagesNoTeamSaysWhereTheTeamIsMade(t *testing.T) { + if got := homeFate("helpers", ""); got != fateAnswers { + t.Fatalf("the drop-up says /helpers %q on home", got) + } + a, fake := helpersApp(t) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + if !transcriptSays(a, helpersNotManagerWord) || len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { + t.Fatal("/helpers in a conversation in no team made a team or sent something") + } + + harbor, err := a.teamMake("harbor", a.tabList()) + if err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + spend(t, a, a.teamActivate(harbor)) + a.entries = nil + typeLine(t, a, "/helpers") + teamsFlush(t, a) + if !transcriptSays(a, helpersNotManagerWord) || len(fake.sent) != 0 { + t.Fatal("/helpers in a member of the selected team, not its manager, did not refuse") + } + if got := mustTeam(t, a, harbor); got.Manager != "" { + t.Fatalf("/helpers gave the selected team a manager: %q", got.Manager) + } +} + +// THE ROW KEEPS THE RULES EVERY TEAM KEEPS. A name already taken is not reused; +// in a conversation that already manages a team it makes no second one, +// shows the one it manages, and leaves the box alone. +func TestNewFromThisSessionKeepsTheRulesEveryTeamKeeps(t *testing.T) { + a, _ := helpersApp(t) + if _, err := a.teamMake("lab", nil); err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + made := helpersMakeTeam(t, a) + if strings.EqualFold(made.Name, "lab") { + t.Fatalf("the new team took a name another team has: %+v", helpersDisk(t, a)) + } + + spend(t, a, a.teamOverlayChoose("")) + a.input.setText("keep this") + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) + teamsFlush(t, a) + if n := len(helpersDisk(t, a)); n != 2 { + t.Fatalf("the row in a manager made another team: %d teams", n) + } + if !transcriptSays(a, fmt.Sprintf(helpersManagesWord, made.Name)) { + t.Fatal("the row did not say which team this conversation manages") + } + if got := a.input.String(); got != "keep this" { + t.Fatalf("the row replaced the box in a manager: %q", got) + } + if active, ok := a.teamActive(); !ok || active.ID != made.ID { + t.Fatal("the row in a manager did not show the team it manages") + } +} + +// THE STORE'S REFUSAL IS FINAL. Another window made this conversation another +// team's manager after this one loaded: the window's own check passes, the +// store refuses with the one-manager rule, the window takes the store's teams +// back, closes the overlay, gives the box back, and the model is asked nothing. +func TestNewFromThisSessionRefusedByTheStorePutsTheWindowBack(t *testing.T) { + a, fake := helpersApp(t) + front := a.frontTabKey() + elsewhere := teamstore.NewID() + if err := teamstore.Update(a.profileDir, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: elsewhere, Name: "orbit"}) + if err := f.AddMember(elsewhere, teamstore.Member{Key: front, File: a.file, Where: a.workspace}); err != nil { + return err + } + return f.SetManager(elsewhere, front) + }); err != nil { + t.Fatal(err) + } + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) + teamsFlush(t, a) + if len(fake.sent) != 0 { + t.Fatal("a refused team still sent something") + } + disk := helpersDisk(t, a) + if len(disk) != 1 || disk[0].ID != elsewhere { + t.Fatalf("the store holds %+v, want only the other window's team", disk) + } + if active, ok := a.teamActive(); ok && active.ID != elsewhere { + t.Fatal("the overlay stayed on a team the store refused") + } + if got := a.input.String(); got != "" { + t.Fatalf("the box still holds the refused team's ask: %q", got) + } + if !transcriptSays(a, "a conversation can manage one team and its descendants") { + t.Fatal("the refusal was not said in the store's words") + } +} + +// WHERE THE ROW CANNOT WORK IT SAYS SO AND MAKES NOTHING: a connection that +// holds one conversation at a time, where a helper could not open beside its +// manager, and the welcome screen. +func TestNewFromThisSessionRefusesWhereItCannotWork(t *testing.T) { + a, fake := helpersApp(t) + a.shared = true + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) + if !transcriptSays(a, helpersShared) || len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { + t.Fatal("a one-conversation connection made a team") + } + + a, fake = helpersApp(t) + a.welcome.open, a.welcome.start = true, true + row, _ = helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) + if !transcriptSays(a, helpersFreshWord) || len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { + t.Fatal("the welcome screen made a team") + } +} + +// THE TEAMS DROPDOWN IS THERE WITH NO TEAMS, and its last row is `+ New from +// this session`. With teams, that row stays last, under a rule. The dropdown +// says nothing about its keys: no footer of up/down, enter and esc. +func TestTheTeamsDropdownIsThereWithNoTeamsAndEndsWithNewFromThisSession(t *testing.T) { + a, _ := helpersApp(t) + if len(a.wall.teams) != 0 { + t.Fatalf("the fixture has teams: %+v", a.wall.teams) + } + if word := a.tabTeamWord(); !strings.Contains(word, "Teams") { + t.Fatalf("with no teams the strip's chip is %q", word) + } + a.openTeamMenu() + if !a.teamMenu.on { + t.Fatal("the dropdown did not open with no teams") + } + if _, last := helpersMenuRow(t, a); !last { + t.Fatal("+ New from this session is not the last row") + } + frame, _ := menuFrame(t, a) + if !strings.Contains(frame, helpersRowWord) { + t.Fatalf("the dropdown does not draw %q:\n%s", helpersRowWord, frame) + } + for _, hint := range []string{"up/down move", "enter choose", "esc cancel"} { + if strings.Contains(frame, hint) { + t.Errorf("the dropdown still says %q:\n%s", hint, frame) + } + } + + if _, err := a.teamMake("harbor", nil); err != nil { + t.Fatal(err) + } + rows := a.teamMenuRows() + if len(rows) != 3 || rows[0].code != wallPopTeam || !rows[1].rule || rows[2].code != wallPopHelpers { + t.Fatalf("with a team the rows are %+v, want the team, a rule, and the row", rows) + } +} + +// WITH A TEAM SELECTED THERE IS NO `+ New from this session`: the team it +// makes is a top-level team, and made from inside another team's overlay it +// would read as nesting. Nesting is `+ Add subteam` on Teams. Clearing the +// selection brings the row back. +func TestWithATeamSelectedThereIsNoNewFromThisSession(t *testing.T) { + a, _ := helpersApp(t) + harbor, err := a.teamMake("harbor", a.tabList()) + if err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + spend(t, a, a.teamActivate(harbor)) + for _, r := range a.teamMenuRows() { + if r.rule || r.code == wallPopHelpers { + t.Fatalf("with a team selected the dropdown offers %+v", a.teamMenuRows()) + } + } + a.openTeamMenu() + if frame, _ := menuFrame(t, a); strings.Contains(frame, helpersRowWord) { + t.Fatalf("with a team selected the dropdown draws %q:\n%s", helpersRowWord, frame) + } + a.closeTeamMenu() + spend(t, a, a.teamOverlayChoose("")) + if _, last := helpersMenuRow(t, a); !last { + t.Fatal("clearing the selection did not bring the row back last") + } +} + +// THE HELPERS IT STARTS OPEN AS TABS IN THE TEAM'S OVERLAY. The manager's +// team_start is carried out by this window behind the manager; the new +// conversation is a member, held open, its tab on the strip, and the strip is +// this team's even if the person had cleared the overlay meanwhile. The person +// stays in front of the manager, with what they were typing. +func TestHelpersStartedOpenAsTabsInTheTeamsOverlay(t *testing.T) { + a, _ := helpersApp(t) + made := helpersMakeTeam(t, a) + front := a.frontTabKey() + spend(t, a, a.teamOverlayChoose("")) + if _, ok := a.teamActive(); ok { + t.Fatal("the overlay did not clear") + } + n := 0 + a.start = func(workspace string) (Conversation, error) { + n++ + return Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: fmt.Sprintf("/tmp/lab/helper-%d.jsonl", n), Workspace: workspace}, nil + } + a.input.setText("half a reply") + for _, handle := range []string{"tests", "docs"} { + trafficAppend(t, a, made.ID, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, To: handle, Text: "take the " + handle}) + } + trafficReadNow(t, a) + trafficReadNow(t, a) + if n != 2 { + t.Fatalf("two starts opened %d conversations", n) + } + if active, ok := a.teamActive(); !ok || active.ID != made.ID { + t.Fatal("the strip is not the team's after its helpers started") + } + if a.frontTabKey() != front || a.input.String() != "half a reply" { + t.Fatal("a start moved the person off the manager or out of their words") + } + a.touch() + row := plain(a.tabsRow(a.width)) + for _, handle := range []string{"tests", "docs"} { + m, ok := mustTeam(t, a, made.ID).ByHandle(handle) + if !ok || a.behind[m.Key] == nil { + t.Fatalf("@%s is not a member held open: %+v", handle, mustTeam(t, a, made.ID).Members) + } + if !strings.Contains(row, "@"+handle) { + t.Fatalf("@%s has no tab on the team's strip: %q", handle, row) + } + } +} + +// THE MANAGER'S NO PUTS THE CHAT BACK AS IT WAS. The manager's team_dissolve +// writes the store from the engine (internal/teams' Dissolve); the window's +// Traffic read finds the team gone, closes its overlay, and the column is a +// plain chat's again. Nothing else in the window moved. +func TestADissolvedTeamLeavesThePlainChat(t *testing.T) { + a, _ := helpersApp(t) + front := a.frontTabKey() + made := helpersMakeTeam(t, a) + tabs := len(a.tabList()) + if err := teamstore.Dissolve(a.profileDir, made.ID, front); err != nil { + t.Fatal(err) + } + trafficReadNow(t, a) + if _, ok := a.teamByID(made.ID); ok { + t.Fatal("the window still holds the dissolved team") + } + if _, ok := a.teamActive(); ok { + t.Fatal("the strip is still narrowed to the dissolved team") + } + if a.sideKind() != sideKindPlain || a.frontTabKey() != front { + t.Fatal("the chat in front is not the same plain chat") + } + if got := len(a.tabList()); got != tabs { + t.Fatalf("dissolving changed the strip from %d tabs to %d", tabs, got) + } + if _, last := helpersMenuRow(t, a); !last { + t.Fatal("the dropdown lost its last row") + } +} + +// THE 💡 TIP IS SAID WHERE THE ROW WOULD ACT, AND ONLY THERE: after the first +// exchange, with no team selected, in a conversation that manages none; never +// before a word was said, never with a team on the strip, never in a manager. +// Choosing the row retires it for good. +func TestTheHelpersTipIsArmedWhereTheRowWouldAct(t *testing.T) { + var row notice + for _, n := range notices { + if n.id == "helpers-team" { + row = n + } + } + if row.id == "" || !strings.HasPrefix(row.text, helpersRowWord) || row.retire != eventHelpersUsed { + t.Fatalf("the tip is %+v", row) + } + a, _ := helpersApp(t) + a.turn = 0 + if row.armed(a) { + t.Fatal("the tip is armed before the first exchange") + } + a.turn = 1 + if !row.armed(a) { + t.Fatal("the tip is not armed in a spoken-to conversation with no team") + } + harbor, err := a.teamMake("harbor", a.tabList()) + if err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + spend(t, a, a.teamActivate(harbor)) + if row.armed(a) { + t.Fatal("the tip is armed with a team selected") + } + spend(t, a, a.teamOverlayChoose("")) + if !row.armed(a) { + t.Fatal("clearing the team did not arm the tip again") + } + helpersMakeTeam(t, a) + if !a.notices.retired(row.id) { + t.Fatal("choosing the row did not retire its tip") + } + spend(t, a, a.teamOverlayChoose("")) + if row.armed(a) { + t.Fatal("the tip is armed in a conversation that already manages a team") + } +} + +// wordNamingAgent is the fake conversation with the naming ask: it answers name +// (or err), and keeps what it was asked. +type wordNamingAgent struct { + *fakeAgent + name string + err error + titles []string + words int +} + +func (n *wordNamingAgent) NameTeamWords(_ context.Context, titles []string, words int) (string, error) { + n.titles, n.words = append([]string(nil), titles...), words + return n.name, n.err +} + +// groupNamingAgent has only the group's ask, as an engine from before the word +// limit does: up to three words, which the surface cuts to two. +type groupNamingAgent struct { + *fakeAgent + name string +} + +func (n *groupNamingAgent) NameTeam(context.Context, []string) (string, error) { return n.name, nil } + +// THE TEAM IS NAMED AFTER THE CONVERSATION. The click makes it under the +// folder's name at once; the naming role is asked for two words over this +// conversation's title, and the team takes the answer, cleaned — on disk, on +// the strip, and in the receipt, which says the final name once. +func TestNewFromThisSessionNamesTheTeamAfterTheConversation(t *testing.T) { + a, fake := helpersApp(t) + namer := &wordNamingAgent{fakeAgent: fake, name: "Parser Port"} + a.agent = namer + made := helpersMakeTeam(t, a) + if len(namer.titles) != 1 || namer.titles[0] != "Shipping the parser" || namer.words != helpersNameWords { + t.Fatalf("the namer was asked %q for %d words", namer.titles, namer.words) + } + if made.Name != "parser port" { + t.Fatalf("the team on disk is called %q, want the conversation's name", made.Name) + } + if active, ok := a.teamActive(); !ok || active.Name != "parser port" { + t.Fatal("the strip does not show the team's new name") + } + if !transcriptSays(a, fmt.Sprintf(helpersMadeWord, "parser port")) { + t.Fatal("the receipt does not name the team by its final name") + } + if transcriptSays(a, fmt.Sprintf(helpersNamedWord, "parser port")) { + t.Fatal("the name was said twice") + } +} + +// AN OLDER ENGINE'S THREE WORDS ARE CUT TO TWO. +func TestAGroupNamersAnswerIsCutToTwoWords(t *testing.T) { + a, fake := helpersApp(t) + a.agent = &groupNamingAgent{fakeAgent: fake, name: "guitar pickup history"} + if made := helpersMakeTeam(t, a); made.Name != "guitar pickup" { + t.Fatalf("the team is called %q", made.Name) + } +} + +// A NAME THAT ARRIVES AFTER THE RECEIPT IS SAID ON ITS OWN LINE. +func TestALateNameIsSaid(t *testing.T) { + a, _ := helpersApp(t) + made := helpersMakeTeam(t, a) + a.helpersNamed(a.helpersName.gen, "guitar pickups", nil) + teamsFlush(t, a) + if got := mustTeam(t, a, made.ID).Name; got != "guitar pickups" { + t.Fatalf("the team is called %q", got) + } + if disk := helpersDisk(t, a); disk[0].Name != "guitar pickups" { + t.Fatalf("the name did not reach the disk: %q", disk[0].Name) + } + if !transcriptSays(a, fmt.Sprintf(helpersNamedWord, "guitar pickups")) { + t.Fatal("a late name was not said") + } +} + +// THE ANSWER NEVER OVERWRITES A CHOICE OR TAKES A NAME. The team keeps the +// name it has when the person renamed it here, another window renamed it in +// the store, another team has the suggested name, the ask failed, the answer +// is not a name, or a newer ask replaced it; a team that is gone stays gone. +func TestTheSuggestedNameNeverOverwritesAChoice(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, a *app, id string) (gen int, answer string, err error) + want string + }{ + {"renamed here", func(t *testing.T, a *app, id string) (int, string, error) { + if err := a.teamRename(id, "mine"); err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + return a.helpersName.gen, "guitar pickups", nil + }, "mine"}, + {"renamed in another window", func(t *testing.T, a *app, id string) (int, string, error) { + if err := teamstore.Update(a.profileDir, func(f *teamstore.File) error { + f.Teams[teamIndex(f.Teams, id)].Name = "theirs" + return nil + }); err != nil { + t.Fatal(err) + } + return a.helpersName.gen, "guitar pickups", nil + }, "theirs"}, + {"name taken", func(t *testing.T, a *app, id string) (int, string, error) { + if _, err := a.teamMake("guitar pickups", nil); err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + return a.helpersName.gen, "Guitar Pickups", nil + }, "lab"}, + {"ask failed", func(t *testing.T, a *app, id string) (int, string, error) { + return a.helpersName.gen, "guitar pickups", fmt.Errorf("no model") + }, "lab"}, + {"not a name", func(t *testing.T, a *app, id string) (int, string, error) { + return a.helpersName.gen, " ", nil + }, "lab"}, + {"an older ask", func(t *testing.T, a *app, id string) (int, string, error) { + return a.helpersName.gen - 1, "guitar pickups", nil + }, "lab"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, _ := helpersApp(t) + made := helpersMakeTeam(t, a) + gen, answer, err := c.setup(t, a, made.ID) + a.helpersNamed(gen, answer, err) + teamsFlush(t, a) + for _, team := range helpersDisk(t, a) { + if team.ID == made.ID && team.Name != c.want { + t.Fatalf("the team is called %q on disk, want %q", team.Name, c.want) + } + } + if got := mustTeam(t, a, made.ID).Name; got != c.want { + t.Fatalf("the window calls the team %q, want %q", got, c.want) + } + }) + } + + a, _ := helpersApp(t) + made := helpersMakeTeam(t, a) + if err := teamstore.Dissolve(a.profileDir, made.ID, made.Manager); err != nil { + t.Fatal(err) + } + trafficReadNow(t, a) + a.helpersNamed(a.helpersName.gen, "guitar pickups", nil) + teamsFlush(t, a) + if len(helpersDisk(t, a)) != 0 { + t.Fatal("naming a team that was gone brought it back") + } +} diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index e0bcbe94cd..ff397df8e2 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -273,6 +273,14 @@ func homeFate(word, rest string) string { // BOTH FORMS ANSWER HERE, and home is where a rule is the person's // everywhere rather than one project's (memory.go's [app.runAlways]). return fateAnswers + case "helpers": + // IT ANSWERS HERE, AND DOES NOTHING ELSE. /helpers makes a team of the + // conversation a person has been working in (helpers.go), and home has + // none in front: opening a fresh one would make a manager of a + // conversation that knows nothing, which is `+ AI manager`'s job, and + // acting on the one behind home would send its ask where nobody can + // read it. So it says where it works. + return fateAnswers } return "" } diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index 3ac6cf46ab..6aa95ff8db 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -248,10 +248,11 @@ func TestEveryTipIsOnTheManualPage(t *testing.T) { // The cut was thirty, /project made it thirty-one, and three reads of the whole // list by the owner took it to twenty-two, and the way home by two spaces made // it twenty-three on 2026-09-24. The separate conversation-budget hint makes -// twenty-four. There is ONE set: every hint draws +// twenty-four, and /helpers made it twenty-five on 2026-10-19, at the owner's +// word. There is ONE set: every hint draws // on both boxes, a news row on neither, and a row filed under home's slot does // not build. -func TestTheTableIsTwentyFourHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { +func TestTheTableIsTwentyFiveHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { hints := 0 for _, n := range notices { if n.slot != slotHint { @@ -265,8 +266,8 @@ func TestTheTableIsTwentyFourHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { t.Errorf("hint %q draws in the transcript", n.id) } } - if hints != 24 { - t.Fatalf("the table holds %d hints, want 24 — the cut is deliberate, and the manual page counts them", hints) + if hints != 25 { + t.Fatalf("the table holds %d hints, want 25 — the cut is deliberate, and the manual page counts them", hints) } news := notice{id: "noted", slot: slotNote, armed: ready, text: "x"} if news.draws(slotHint) || news.draws(slotHome) || !news.draws(slotNote) { diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 72e84f7796..0db586d10f 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -197,6 +197,9 @@ const ( // are doors a person already knows, and the tip is about the one they // cannot see. eventHomeGesture = "home-gesture" + // eventHelpersUsed is /helpers reaching its command, bare or with words, + // or the Teams dropdown's `+ New from this session` chosen (helpers.go). + eventHelpersUsed = "helpers-used" ) // noticeEvents is every event there is, in one list, so the table check can @@ -211,7 +214,7 @@ var noticeEvents = []string{ eventConversationBudgetShown, eventSpendOpened, eventSteered, eventQueued, eventChatStarted, eventPlaceJumped, eventRemembered, eventSubharnessOpened, - eventConnectOpened, eventAutonomyAsked, eventHomeGesture, + eventConnectOpened, eventAutonomyAsked, eventHomeGesture, eventHelpersUsed, } // notice is one thing the surface may tell a person, and the whole of the rule @@ -422,6 +425,21 @@ var notices = []notice{ text: "/task starts a single-shot task on the side", retire: eventTaskTyped, }, + { + // THE BIGGER HALF OF HANDING WORK OUT, at the owner's word on + // 2026-10-19: /task sends one piece of the work away, and the Teams + // dropdown's `+ New from this session` keeps this conversation and makes + // it the manager of a team for the rest (helpers.go). It names the row + // and not /helpers, because /helpers asks a team that already exists. It + // is armed only where the row would act: a conversation that has been + // spoken to, no team selected on the strip, and not already a manager + // ([helpersTipArmed]). It retires the first time the row is chosen or + // /helpers is typed. + id: "helpers-team", slot: slotHint, + armed: helpersTipArmed, + text: "+ New from this session in the Teams menu makes this chat a team's manager", + retire: eventHelpersUsed, + }, { // THE OTHER HALF OF THE SAME LESSON: an automation is made by typing it // exactly or by saying what you want and when, and the card shows the diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index e3e120dc74..b3c65c3655 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -753,6 +753,7 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { eventSubharnessOpened: func(t *testing.T, a *app) { a.slash("/subharness") }, eventConnectOpened: func(t *testing.T, a *app) { a.slash("/connect") }, eventAutonomyAsked: func(t *testing.T, a *app) { a.slash("/autonomy") }, + eventHelpersUsed: func(t *testing.T, a *app) { a.slash("/helpers") }, eventHomeGesture: func(t *testing.T, a *app) { // A door that can open a conversation is what opens home at all // ([app.homeDoorOpen]); the bare test app has none. diff --git a/internal/tui3/offlooplaw_test.go b/internal/tui3/offlooplaw_test.go index 99cc75ec13..b0009f781c 100644 --- a/internal/tui3/offlooplaw_test.go +++ b/internal/tui3/offlooplaw_test.go @@ -440,6 +440,7 @@ var doorsBesideTheLine = map[string]string{ "Delegates": "reads the programs the engine machine's build carries to generate their command rows at the launch and on a switch (delegate.go); nobody pressed for it", "NameTeam": "asks a model for a suggested team name under a budget of seconds; it changes nothing on the engine, typing overrides it, and a message sent while it thinks must not wait behind it", "ProposeTeams": "asks a model for Organize's proposals under a budget of seconds; it changes nothing on the engine (Apply writes through the teams store), and nothing after it depends on the engine having seen it", + "NameTeamWords": "asks a model for the name of a team the Teams row just made, under a budget of seconds; it changes nothing on the engine (the rename is a teams-store edit, and only while the team keeps its first name), and the /helpers sent right after the click must not wait behind it", } func TestOnlyReadsNobodyPressedForAreAskedBesideTheLine(t *testing.T) { diff --git a/internal/tui3/teammenu.go b/internal/tui3/teammenu.go index 5dfad56751..54e9da84b2 100644 --- a/internal/tui3/teammenu.go +++ b/internal/tui3/teammenu.go @@ -10,11 +10,22 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) -// The strip's switcher chooses only a team overlay. The chip's separate clear +// The strip's switcher chooses a team overlay. The chip's separate clear // action restores ordinary Chats; selecting a team restores its last conversation and strip position. // Management belongs to Teams, so this menu cannot change a membership or manager. // It is modal: arrows choose a row, enter activates it, and escape or a press -// outside closes the menu without changing the view. +// outside closes the menu without changing the view. It says none of that in a +// footer: a list of rows under a chip is read and worked the way every list is. +// +// WITH NO TEAM SELECTED ITS LAST ROW IS `+ New from this session`, under a +// rule, which makes a team of the conversation in front when it is chosen, shows +// it, and puts `/helpers` in the box to ask it for helpers (helpers.go). +// Because that row is there whenever no team is, the +// chip is there too with no team yet: `Teams ▾` is how a person with none finds +// how to make one from what they are doing. With a team selected the row is +// gone ([app.teamMenuHelpers] says why). Over --host to an engine without the +// teams doors nothing here can work, and the row, and with no teams the chip, +// are absent. // teamMenu is the switcher's state: whether it is up, the row the keyboard is // on, the row the pointer is on, and where the last frame drew it and its rows, @@ -48,9 +59,33 @@ func (a *app) teamMenuRows() []teamMenuRow { for _, r := range a.teamsOpenTree() { rows = append(rows, teamMenuRow{code: wallPopTeam, id: r.id, depth: r.depth}) } + if a.teamMenuHelpers() { + if len(rows) > 0 { + rows = append(rows, teamMenuRow{rule: true}) + } + rows = append(rows, teamMenuRow{code: wallPopHelpers}) + } return rows } +// teamMenuHelpers reports whether the switcher carries `+ New from this +// session`: only while NO team is selected, wherever a team can be kept. +// +// WITH A TEAM SELECTED THE ROW IS NOT THERE AT ALL. A new team made from a +// conversation seen through a team's overlay reads as one made inside that +// team, and it is not: the row always makes a top-level team. Nesting has its +// own doors, `+ Add subteam` on Teams and a manager's own sub-team start, with +// their own rules, and this row must not look like a third. Where the row +// cannot make a team of the conversation in front for another reason, it is +// still there and says why when chosen. +func (a *app) teamMenuHelpers() bool { + if a.teamsOff() { + return false + } + _, selected := a.teamActive() + return !selected +} + // A picker with no available overlay is absent, including retained teams and // an optional global manager that has not been created. func (a *app) teamMenuOffers(t team) bool { @@ -58,6 +93,9 @@ func (a *app) teamMenuOffers(t team) bool { } func (a *app) teamMenuAvailable() bool { + if a.teamMenuHelpers() { + return true + } for _, t := range a.wall.teams { if a.teamMenuOffers(t) { return true @@ -87,7 +125,9 @@ func (a *app) openTeamMenu() { } a.teamMenu = teamMenu{on: true} for i, r := range picks { - if r.id == a.wall.activeID { + // Only a team's row is the team shown: `+ New from this session` has + // no id, and with no team shown would otherwise match the empty one. + if r.code == wallPopTeam && r.id == a.wall.activeID { a.teamMenu.cursor = i break } @@ -114,10 +154,15 @@ func (a *app) teamMenuFront() chatTab { // teamMenuDo is one row chosen, by the keyboard or the pointer. func (a *app) teamMenuDo(r teamMenuRow) tea.Cmd { - if r.code != wallPopTeam { + switch { + case r.rule: return nil + case r.code == wallPopHelpers: + return a.helpersNewTeam() + case r.code == wallPopTeam: + return a.teamOverlayChoose(r.id) } - return a.teamOverlayChoose(r.id) + return nil } // The chip's clear action and menu selections share the same transition, so @@ -276,7 +321,14 @@ func (a *app) teamMenuCard(width, height int) wallCard { var lines []line for _, r := range rows { ln := line{row: r} + if r.rule { + lines = append(lines, ln) + continue + } switch r.code { + case wallPopHelpers: + ln.left = pal.ink(helpersRowWord) + ln.leftW = ansi.StringWidth(helpersRowWord) case wallPopTeam: t, _ := a.teamByID(r.id) radio := off @@ -320,19 +372,28 @@ func (a *app) teamMenuCard(width, height int) wallCard { } w := inner + 2 + 2*padX top := tabStripRow + 1 - footer := teamFooter(pal, inner, teamHint("up/down", "move"), teamHint("enter", "choose"), teamHint("esc", "cancel")) - capacity := height - top - 2 - len(footer) + capacity := height - top - 2 if w > width-2 || capacity < 1 { return wallCard{} } m := &a.teamMenu - m.cursor = min(max(m.cursor, 0), len(lines)-1) + m.cursor = min(max(m.cursor, 0), len(picks)-1) + // THE CURSOR COUNTS PICKS AND THE WINDOW COUNTS LINES, and the rule above + // the last row is a line that is not a pick; the window is kept on the + // line the cursor's pick is drawn on. + at := 0 + for i, ln := range lines { + if !ln.row.rule && ln.row.code == picks[m.cursor].code && ln.row.id == picks[m.cursor].id { + at = i + break + } + } m.top = min(max(m.top, 0), max(len(lines)-capacity, 0)) - if m.cursor < m.top { - m.top = m.cursor + if at < m.top { + m.top = at } - if m.cursor >= m.top+capacity { - m.top = m.cursor - capacity + 1 + if at >= m.top+capacity { + m.top = at - capacity + 1 } end := min(m.top+capacity, len(lines)) title := "Teams" @@ -360,6 +421,5 @@ func (a *app) teamMenuCard(width, height int) wallCard { hits: []wallHit{{x0: 0, y0: 0, x1: inner, y1: 1, kind: wallHitPopRow, arg: ln.row.code, id: ln.row.id}}, }) } - cardLines = append(cardLines, footer...) return wallCardBuild(pal, ansi.Truncate(title, w-5, g.more), cardLines, x, top, w, padX, 0) } diff --git a/internal/tui3/teammenu_test.go b/internal/tui3/teammenu_test.go index bf8d1f0a0b..2c0f8c21f6 100644 --- a/internal/tui3/teammenu_test.go +++ b/internal/tui3/teammenu_test.go @@ -193,9 +193,22 @@ func TestTeamMenuOffersOnlyOverlayChoices(t *testing.T) { t.Fatalf("management action %q remains in overlay picker", word) } } + // With a team selected its rows are the open teams and nothing else. if len(a.teamMenuRows()) != len(a.teamsOpenTree()) { t.Fatal("picker has extra rows") } + // With none selected they end, under a rule, with `+ New from this + // session`, which puts /helpers in the box and edits no team (helpers.go). + spend(t, a, a.teamOverlayChoose("")) + rows := a.teamMenuRows() + if len(rows) != len(a.teamsOpenTree())+2 || !rows[len(rows)-2].rule || rows[len(rows)-1].code != wallPopHelpers { + t.Fatalf("with no team selected the rows are %+v", rows) + } + for _, r := range rows[:len(rows)-2] { + if r.code != wallPopTeam { + t.Fatalf("picker has a row that is not a team: %+v", r) + } + } } // A PRESS OFF THE MENU PUTS IT AWAY AND DOES NOTHING ELSE; esc does the same, @@ -246,13 +259,16 @@ func TestTeamMenuChoosesOverlayFromGridWithoutFilteringIt(t *testing.T) { } } -// WITH NO TEAM SHOWN THE CHIP IS A QUIET `Teams ▾` while there are teams, and -// is not there at all while there are none. +// WITH NO TEAM SHOWN THE CHIP IS A QUIET `Teams ▾`, with teams and without: +// with none, its one row is `+ New from this session` (helpers.go). func TestTeamMenuQuietChipWithNoTeamShown(t *testing.T) { a, _, _ := tabApp(t) a.teamsEnsure() - if row := plain(a.tabsRow(a.width)); strings.Contains(row, "▾") { - t.Fatalf("a chip with no teams: %q", row) + if row := plain(a.tabsRow(a.width)); !strings.Contains(row, " Teams ▾ ") { + t.Fatalf("no chip with no teams: %q", row) + } + if rows := a.teamMenuRows(); len(rows) != 1 || rows[0].code != wallPopHelpers { + t.Fatalf("with no teams the switcher's rows are %+v", rows) } a, _, _ = menuApp(t) a.teamActivate("") @@ -296,13 +312,17 @@ func TestTeamMenuWithoutAvailableTeamsHasNoPlaceholderChoice(t *testing.T) { t.Fatal(err) } a.teamOverlayChoose("") - row := plain(a.tabsRow(a.width)) - if len(a.teamMenuRows()) != 0 || a.wall.chip.pressable() || strings.Contains(row, "Teams ▾") { - t.Fatal("retained teams or an absent global manager left a placeholder picker") + // No disbanded team and no absent global manager is a choice; the one + // row left is `+ New from this session`, which is not a placeholder but + // the way to make a team from here (helpers.go). + rows := a.teamMenuRows() + if len(rows) != 1 || rows[0].code != wallPopHelpers { + t.Fatalf("retained teams or an absent global manager left a placeholder choice: %+v", rows) } a.openTeamMenu() - if a.teamMenu.on || len(a.teamMenuCard(a.width, a.height).rows) != 0 { - t.Fatal("empty overlay picker opens or draws a placeholder choice") + card := a.teamMenuCard(a.width, a.height) + if !a.teamMenu.on || len(card.hits) != 1 || card.hits[0].arg != wallPopHelpers { + t.Fatalf("the picker with no team to choose draws %+v", card.hits) } } @@ -330,9 +350,14 @@ func TestTeamMenuDismissesWhenRefreshRemovesItsLastChoice(t *testing.T) { a.teamAdopt(nil) } a.Update(nil) - if a.teamMenu.on || len(a.teamMenu.hits) != 0 { - t.Fatal("refresh left an invisible picker owning keyboard input") + // THE PICKER NEVER OWNS THE KEYBOARD INVISIBLY. Its last team + // choice gone, it still has `+ New from this session`, so it + // stays up and draws that row; esc gives the keys back. + menuFrame(t, a) + if a.teamMenu.on && (len(a.teamMenu.hits) != 1 || a.teamMenu.hits[0].arg != wallPopHelpers) { + t.Fatalf("refresh left a picker drawing %+v", a.teamMenu.hits) } + a.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) a.Update(tea.KeyPressMsg{Code: 'z', Text: "z"}) if !strings.Contains(a.input.String(), "z") { t.Fatal("typing did not return to the ordinary conversation") diff --git a/internal/tui3/teamoverlay_test.go b/internal/tui3/teamoverlay_test.go index 2fd43308c7..538871255d 100644 --- a/internal/tui3/teamoverlay_test.go +++ b/internal/tui3/teamoverlay_test.go @@ -382,7 +382,8 @@ func TestTeamOverlayRootUsesCurrentManagersAndProtectsAutomaticMemberships(t *te a.teamViewSet(root) a.teamOverlaySync() for _, row := range a.teamMenuRows() { - if row.code != wallPopTeam { + // The rule and `+ New from this session` edit no membership. + if !row.rule && row.code != wallPopTeam && row.code != wallPopHelpers { t.Fatal("root offers ordinary membership editing") } } diff --git a/internal/tui3/teams.go b/internal/tui3/teams.go index b9cfcec979..24823bae26 100644 --- a/internal/tui3/teams.go +++ b/internal/tui3/teams.go @@ -445,6 +445,19 @@ func (a *app) teamCreateIn(name string, tabs []chatTab, hue teamHueSpec, parent } func (a *app) teamMakeInChecked(name string, tabs []chatTab, hue teamHueSpec, parent string, freshOnly bool) (string, error) { + return a.teamMakeLed(name, tabs, hue, parent, freshOnly, "") +} + +// teamCreateLed is [app.teamCreateIn] at the top level with manager, one of +// tabs' keys, made the new team's manager IN THE SAME EDIT, so no reader of the +// store ever sees the team without it and a refusal of either refuses both. It +// is `/helpers`'s door (helpers.go); every rule a team made on the Teams page +// meets is met here, because it is the same edit with one more line. +func (a *app) teamCreateLed(name string, tabs []chatTab, hue teamHueSpec, manager string) (string, error) { + return a.teamMakeLed(name, tabs, hue, "", true, manager) +} + +func (a *app) teamMakeLed(name string, tabs []chatTab, hue teamHueSpec, parent string, freshOnly bool, manager string) (string, error) { a.teamsEnsure() name = strings.TrimSpace(name) if name == "" { @@ -485,6 +498,9 @@ func (a *app) teamMakeInChecked(name string, tabs []chatTab, hue teamHueSpec, pa } f.Teams = append(f.Teams, made) id = fresh + if manager != "" { + return f.SetManager(fresh, manager) + } return nil } // A team remade under its name keeps what its members already had (a diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go index 83fa43463b..ffd8a260fd 100644 --- a/internal/tui3/teamseam.go +++ b/internal/tui3/teamseam.go @@ -401,7 +401,9 @@ func (a *app) teamsWrite() tea.Cmd { } return func(bool) tea.Cmd { a.teamsTake(teamsWrote{teams: teams, stamp: stamp, err: err, covers: covers, seqs: seqs, refused: refusedBy}) - return nil + // A team the Teams row made is said made, and a /helpers held for it + // sent, now that this write carried it (helpers.go). + return a.helpersSettled() } })) } diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index d8d84d7a05..9619fbcdee 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -552,6 +552,16 @@ func (a *app) trafficStarted(id, handle string, conv Conversation, err error) te } } if t.Manager != "" && t.Manager == a.frontTabKey() { + // THE STRIP SHOWS THE TEAM WHOSE MEMBER JUST STARTED, when the person + // is in front of that team's manager: the new tab is on the strip + // because the strip is this team's. It is the same conversation in + // front and the same keyboard, so nothing moves the person's focus; + // only the overlay is set, the way choosing the team in the + // dropdown sets it. On the wall or a page the person is somewhere + // else, and the strip is left as it is. + if a.wall.activeID != t.ID && !a.wall.on && !a.pageShowing() && !t.Closed() { + a.teamViewSet(t.ID) + } a.feed.note(a.teamManagerMark() + " started @" + handle + " · its tab is on the strip") } a.touch() diff --git a/internal/tui3/teamwritesaid.go b/internal/tui3/teamwritesaid.go index df98324747..fead41acab 100644 --- a/internal/tui3/teamwritesaid.go +++ b/internal/tui3/teamwritesaid.go @@ -61,6 +61,12 @@ func (s *teamWriteSaid) settle(w teamsWrote) bool { // a slow write over a connection still shows its notice for the whole time. func (a *app) teamsWriteSettled(w teamsWrote) { a.tmemberStart.said.settle(w) + // A team the Teams row made acts on its answer from the write's own + // command, which can hand back a turn ([app.helpersSettled]); here it is + // only marked due. + if a.helpers.said.settle(w) { + a.helpers.due = true + } if a.tp.disbandSaid.settle(w) { a.teamsDisbandSaid(a.tp.disbandName, a.tp.disbandSaid.why) } diff --git a/internal/tui3/wallclick_test.go b/internal/tui3/wallclick_test.go index 797dd862d2..d38c84ea1b 100644 --- a/internal/tui3/wallclick_test.go +++ b/internal/tui3/wallclick_test.go @@ -159,12 +159,13 @@ func TestWallKeepsTeamControlsOnTeamsPage(t *testing.T) { } // WHILE A TEAM NARROWS THE STRIP, THE STRIP SAYS WHICH: a chip at its left -// end, which opens the team switcher (teammenu.go). +// end, which opens the team switcher (teammenu.go). With none shown, and none +// made yet, the chip is the quiet `Teams ▾`, whose last row makes one. func TestTabTeamChipNamesTheShownTeam(t *testing.T) { a, _, _ := tabApp(t) plainRow := plain(a.tabsRow(a.width)) - if strings.Contains(plainRow, "▾") { - t.Fatalf("a chip with no team shown: %q", plainRow) + if !strings.Contains(plainRow, " Teams ▾ ") || strings.Contains(plainRow, "●") { + t.Fatalf("the chip with no team shown: %q", plainRow) } tabs := a.tabList() i, err := a.teamMake("harbor", tabs[:1]) diff --git a/internal/tui3/wallcontract.go b/internal/tui3/wallcontract.go index 1a818ae35c..896c45d8d3 100644 --- a/internal/tui3/wallcontract.go +++ b/internal/tui3/wallcontract.go @@ -245,6 +245,7 @@ const ( wallPopConfirm = -3 // Delete, confirmed wallPopKeep = -4 // Keep, the delete undone wallPopManager = -6 // Make manager, or Remove manager, in the team shown + wallPopHelpers = -7 // + New from this session: /helpers in the box (helpers.go) ) // wallPopKind is which popover is up.