Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions cmd/codeaf/chatv3_host_teams_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,21 @@ func TestTheWrapUpDoorsCrossHostOnlyWhenTheEngineSaysSo(t *testing.T) {
t.Fatal("an engine without the wrap-up doors was handed them, or lost the others")
}
}

// Contract 6.2: The host wire never lends the laptop a closed team's packet files.
func TestHostTeamsKeepsClosedHistoryOffTheWire(t *testing.T) {
loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) {
return &remote.Engine{Agent: &quietAgent{}, ProfileDir: t.TempDir()}, nil
}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = loop.Close() })
welcome := loop.Client.Welcome()
if !welcome.Teams || !welcome.Delegation || !welcome.WrapUp {
t.Fatalf("the engine welcome has no complete teams road: %+v", welcome)
}
if seam := hostTeamsSeam(hostFar{client: loop.Client}, welcome); seam.History != nil {
t.Fatal("the host seam offered to read closed history from this laptop")
}
}
8 changes: 8 additions & 0 deletions cmd/codeaf/chatv3_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/Agent-Field/codeaf/internal/remote"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/subharness"
teamstore "github.com/Agent-Field/codeaf/internal/teams"
"github.com/Agent-Field/codeaf/internal/tui3"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
)
Expand Down Expand Up @@ -361,6 +362,13 @@ func localDoors(options *tui3.Options, welcome remote.Welcome, settings config.C
profileDir = settings.ProfileDir
}
options.ProfileDir = profileDir
if options.Teams.Load != nil {
// The linked-local engine keeps its profile on this machine, so its closed
// teams' packets can be read here without borrowing the --host wire.
options.Teams.History = func(team string) ([]teamstore.Packet, error) {
return teamstore.Packets(profileDir, team)
}
}
options.EngineRoad = true
options.ReadCredits = v3LocalCreditReader(settings)
options.Connections = v3Connections(v3Connect(profileDir))
Expand Down
47 changes: 47 additions & 0 deletions cmd/codeaf/chatv3_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,56 @@ import (
"github.com/Agent-Field/codeaf/internal/modelsource/sourcestub"
"github.com/Agent-Field/codeaf/internal/remote"
"github.com/Agent-Field/codeaf/internal/session"
teamstore "github.com/Agent-Field/codeaf/internal/teams"
"github.com/Agent-Field/codeaf/internal/tui3"
)

// Contract 6.1: A plain launch reads a closed team's report from the engine profile on this machine.
func TestPlainLaunchReadsClosedTeamReportFromEngineProfile(t *testing.T) {
engineProfile := t.TempDir()
surfaceProfile := t.TempDir()
t.Setenv("CODEAF_HOME", surfaceProfile)
t.Setenv("CODEAF_PROFILE_DIR", surfaceProfile)
t.Setenv("HOME", t.TempDir())
t.Setenv(config.APIKeyEnv, "not-a-real-key")
t.Cleanup(func() { stopPoolErrands(surfaceProfile) })
const harbor = "0a0a0a0a0a0a"
if err := teamstore.Save(engineProfile, []teamstore.Team{{ID: harbor, Name: "harbor", Manager: "hm",
Members: []teamstore.Member{{Key: "hm", Handle: "boss"}}}}); err != nil {
t.Fatal(err)
}
p, err := teamstore.Raise(engineProfile, teamstore.Packet{Team: teamstore.Person, Origin: harbor,
Kind: teamstore.PacketClosing, RaisedBy: teamstore.FromManager, Question: "close harbor?",
Options: []teamstore.Option{{ID: teamstore.OptionClose, Label: "Close", Consequence: "the team closes"}},
Report: &teamstore.ClosingReport{Done: "the parser"}})
if err != nil {
t.Fatal(err)
}
p, err = teamstore.Decide(engineProfile, p.ID, teamstore.Person, teamstore.OptionClose, "")
if err != nil {
t.Fatal(err)
}
if closed, err := teamstore.AcceptClosing(engineProfile, p); err != nil || !closed {
t.Fatalf("close on report: %v, %v", closed, err)
}
welcome := remote.Welcome{Version: remote.Version, Workspace: "/srv/app", ProfileDir: engineProfile,
Teams: true, Delegation: true, WrapUp: true}
fleet := onePipeFleet("", hostedClient(t))
t.Cleanup(fleet.closeAll)
options, settings := hostOptions(fleet, welcome, false)
if options.Teams.Load == nil {
t.Fatal("the engine did not hand teams to the plain launch")
}
localDoors(&options, welcome, settings)
if options.Teams.History == nil {
t.Fatal("the plain launch has no history door")
}
got, err := options.Teams.History(harbor)
if err != nil || len(got) != 1 || got[0].ID != p.ID || got[0].Report == nil || got[0].Report.Done != "the parser" {
t.Fatalf("closed team's report from engine profile: %+v, %v", got, err)
}
}

// consentSource is an OpenAI-shaped fake whose turn always asks for the same
// harmless bash command and then finishes after the tool result comes back.
func consentSource(t *testing.T, command string) *httptest.Server {
Expand Down
13 changes: 13 additions & 0 deletions docs/changes/unreleased/1516-teams-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
kind: fixed
title: wall tiles say what each conversation spent, and the teams surface stops saying untrue things
pr: 1516
surface: [chat, engine]
invalidates:
- "A wall tile drew no spend, although #1429 promised it. A tile's state line now ends in the figure that conversation's own status line shows (the work it started included), and draws nothing when it spent nothing."
- "A team write the store refused (a lock, an unreadable file, a far machine that kept changing) still drew `Made beta · 1`, `Organized · …` or `harbor is closed`. Those notices now wait for the write that carried their edit, and a refusal takes their place: `beta was not saved · <reason>`."
- "Wall tiles, and a reopened manager conversation, drew the wake sentence (`Your team's replies started this turn; the person did not speak. …`) that the live conversation never draws. Neither draws it now; a team delivery draws as its lines."
- "A team cap under a cent read `$0.00` and offered `Raise to $0`, whose stored ceiling lifted nothing, and the settings card said `$0.0010 a day`. Every place now spells a cap one way (`$5`, `$5.50`, `$0.001`), and Raise always offers twice the ceiling and names it exactly. The card spells a whole-dollar cap `$5`, where it said `$5.00`."
- "A team that was wrapping up showed no deadline anywhere. The teams page header and the manager's side column now say `wrapping up · 12m left`, then `under a minute left`, then `out of time`."
- "On a plain local launch, a closed team's pane said its report `is not readable over this connection`. The report now opens there; only over --host does that sentence remain."
---
19 changes: 17 additions & 2 deletions internal/manual/chat/conversations-and-teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,15 @@ A tile reads top to bottom:
- **the title** on the top border, the brightest thing in the tile, after the dots of the
teams it is in (up to three, then `+N`)
- **one dim line** saying what the conversation is doing now, like `running bash · 2m`,
`writing` or `? waiting on you · 3m`, or when it last moved, like `updated 5m ago`
`writing` or `? waiting on you · 3m`, or when it last moved, like `updated 5m ago`, and at
its right end **what the conversation has spent**, like `$0.42`: the same figure its own
status line shows, the work it started included. A conversation that has spent nothing
shows no figure, and on a narrow tile the figure gives way before the words do
- **the conversation's own newest lines**, drawn the way the conversation draws them, fading
with age so the newest are where your eye lands; lines that just arrived are lifted for a
moment and then settle
moment and then settle. A turn a team started shows what the team sent (`◆ manager →
@api do` and its words) and the reply, never the note codeaf wrote to start the turn, the
same as the conversation itself
- a small **activity line** on the bottom border while there is activity to show

A conversation this window can only show as a snapshot (over a shared connection only the one
Expand Down Expand Up @@ -336,6 +341,16 @@ names, members and colours, and it is renamed to `spaces.json.migrated`. A teams
cannot be read is moved aside as `teams.json.unreadable-<number>` rather than written over,
so nothing you made is lost.

## The wall said my team was not saved

A change to your teams shows in this window at once and is saved a moment later. What says
it happened waits for the save: `Made harbor · 2` on the Teams row, `Organized · 1 new team`,
and on the teams page `harbor is closed` or a move's words. When the save is refused (another
codeaf holding the file, a file that would not read, a far machine whose teams kept changing),
those words never show. The row says `harbor was not saved · <the reason>` instead, and the
teams page says `the close of harbor was not saved` or `the move was not saved`. The change is
still in this window, and Undo is still offered where it was.

## Every key in the conversations view

`?` (or `Help ?`) opens a sheet of all of these, and every row on it is a button that does
Expand Down
16 changes: 16 additions & 0 deletions internal/manual/chat/team-questions-and-caps.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ When the pool reaches its cap:
A manager can never raise a cap: money is yours. Every held wake is one line in the traffic,
`held @web: harbor reached its $5 cap today`.

## Can a team cap be less than a cent

Yes. A cap is spelled as you set it everywhere it appears, on the card, the teams page and the
team's settings: `$5`, `$5.50`, and under a cent `$0.001`, never rounded to `$0.00`. **Raise
to** always offers twice the ceiling the team reached, and names exactly that figure: a
`$0.001` cap offers `Raise to $0.002`.

## What if today's team spending cannot be read

When a capped team's usage ledger or team list cannot be read, no new paid team work starts.
Expand Down Expand Up @@ -103,6 +110,15 @@ The wrap-up has 15 minutes and $2 of team spend. When it runs out of either befo
manager reports, codeaf brings you the report itself, marked `wrap-up incomplete`, with
**Close now** and **Keep going**.

## How long does my team have left to wrap up

While a team is wrapping up, the teams page's header for it and the Traffic column beside its
manager say how long it has: `wrapping up · 12m left`, then `wrapping up · under a minute
left`, and `wrapping up · out of time` once the 15 minutes are gone and the report has not
come yet. The words go when the report arrives. The time is counted from when the wrap-up
began, kept with the team, so it reads the same after a restart. The team's chip on the tab
strip does not show it.

## What if the wrap-up report could not be sent, the decisions file was busy

A wrap-up that runs out of its 15 minutes or its $2 before the manager reports is
Expand Down
15 changes: 9 additions & 6 deletions internal/manual/chat/teams-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,11 @@ waits under `▸ Closed · N`.
Over `--host`, against an engine that does not offer the wrap-up, the card says
`Wrap up first is not offered over this connection` and offers `Close now` and `Cancel`.

## Closed teams: reopening and deleting
## Closed teams: reopening, reports and deleting

Open `▸ Closed · N` on the rail and choose a team. The pane shows when it was opened and
closed, its closing report when it closed on one (what was done, what was left, where the files
are, what it spent), and its members, each still a door to its conversation. Two buttons:
Open `▸ Closed · N` on the rail and choose a team. On this machine, the pane shows when it was
opened and closed, its `closing report` when it closed on one (`done`, `left`, `files`, and
`spent`), and its members, each still a door to its conversation. Two buttons:

- **`Reopen`** (`r`) opens the team again: its members' tabs come back and its manager is
brought in front. A team whose parent is closed too offers **`Reopen harbor too`**, because a
Expand All @@ -341,13 +341,16 @@ The page says what a team is in one sentence and offers two buttons: **`✦ Orga
conversations`**, which suggests teams from the conversations you have open, and
**`+ New team`**. `o` and `n` press them.

## Over --host
## Over --host: why a closed team's report is not readable

Over `--host` the page shows the teams of the machine the conversations run on: their
decisions, their spend and their managers. The **Teams** tab of `/settings` edits that
machine's defaults, and each value says `from Settings`. An older engine keeps the tab
read only and says `changing them is not available over this connection`. A closed team's
report is not read over the connection yet, and the page says so where the report would be.
report is not read over the connection yet, and the page says
`its closing report is kept where the team ran, and is not readable over this connection`
where the report would be. A plain local launch reads the report from this machine's engine
profile and shows it in the closed team's pane.

## Why the page looks the way it does

Expand Down
5 changes: 5 additions & 0 deletions internal/manual/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"will closing a sub-team stop its manager if that manager is also in the parent team", "teams-page"},
{"the pane said the manager was open in another window", "teams-page"},
{"how do I reopen a closed team", "teams-page"},
{"why does my closed team say its report is not readable", "teams-page"},
{"can a team cap be less than a cent", "team-questions-and-caps"},
{"the wall said my team was not saved", "conversations-and-teams"},
{"how long does my team have left to wrap up", "team-questions-and-caps"},
{"does the conversations view show what each conversation spent", "conversations-and-teams"},
{"where do I change one team's settings", "teams-page"},
{"what does the ? 2 mark on a team mean", "teams-page"},
// Nesting on the teams page (teams-page.md).
Expand Down
22 changes: 11 additions & 11 deletions internal/session/team_cap.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ package session

import (
"fmt"
"math"
"sync"

"github.com/Agent-Field/codeaf/internal/teams"
Expand Down Expand Up @@ -154,7 +153,7 @@ func (a *Agent) poolHold(profile string, owner teams.Team, cap float64) string {
return ""
}
held := fmt.Sprintf("%s reached its %s cap today (spent %s); the person has been asked whether to raise it, and nothing new starts until they answer",
owner.Name, teamMoney(ceiling), teamMoney(spent))
owner.Name, teamMoney(ceiling), teamSpendMoney(spent))
if found && (latest.Waiting() || latest.Cap.CapUSD >= ceiling) {
if !latest.Waiting() {
held = fmt.Sprintf("%s reached its %s cap today and the person chose to stop it for today", owner.Name, teamMoney(ceiling))
Expand Down Expand Up @@ -187,7 +186,7 @@ func latestCapPacket(profile, owner, day string) (teams.Packet, bool) {

// capPacket is the packet a pool at its ceiling raises to the person.
func capPacket(owner teams.Team, day string, ceiling, spent float64) teams.Packet {
raiseTo := math.Round(ceiling*2*100) / 100
raiseTo := teams.RaiseTo(ceiling)
return teams.Packet{
Team: teams.Person, Origin: owner.ID, Kind: teams.PacketCap, RaisedBy: teams.FromSystem,
Question: fmt.Sprintf("%s reached its %s cap today", owner.Name, teamMoney(ceiling)),
Expand All @@ -199,14 +198,15 @@ func capPacket(owner teams.Team, day string, ceiling, spent float64) teams.Packe
},
Recommendation: &teams.Recommendation{Option: teams.OptionStopToday,
Reason: "the cap is the limit you set; raise it only if today's work is worth more to you"},
Cap: &teams.CapFacts{Team: owner.ID, Day: day, CapUSD: ceiling, SpentUSD: math.Round(spent*100) / 100, RaiseTo: raiseTo},
Cap: &teams.CapFacts{Team: owner.ID, Day: day, CapUSD: ceiling, SpentUSD: teams.RoundMoney(spent), RaiseTo: raiseTo},
}
}

// teamMoney is dollars as the person reads them: $5, $5.50.
func teamMoney(usd float64) string {
if usd == math.Trunc(usd) {
return fmt.Sprintf("$%.0f", usd)
}
return fmt.Sprintf("$%.2f", usd)
}
// teamMoney is dollars as the person reads them: $5, $5.50, $0.001. It is
// teams' one spelling of a cap, so the refusal here, the packet and every
// screen that draws the same cap say the same figure ([teams.Money]).
func teamMoney(usd float64) string { return teams.Money(usd) }

// teamSpendMoney is a MEASURED spend as the person reads it: kept to the cent,
// or finer under a cent ([teams.RoundMoney]), then spelled as a cap is.
func teamSpendMoney(usd float64) string { return teams.Money(teams.RoundMoney(usd)) }
75 changes: 75 additions & 0 deletions internal/session/team_cap_precision_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package session

import (
"strings"
"testing"

"github.com/Agent-Field/codeaf/internal/teams"
)

// Contract 4.1 and 4.2: Every raise names a larger cap and keeps positive sub-cent spend.
func TestTeamCapPacketKeepsPreciseFigures(t *testing.T) {
for _, tc := range []struct {
cap, raise float64
capWord, raiseWord string
}{
{0.00001, 0.00002, "$0.00001", "$0.00002"},
{0.0004, 0.0008, "$0.0004", "$0.0008"},
{0.001, 0.002, "$0.001", "$0.002"},
{0.004, 0.008, "$0.004", "$0.008"},
{0.005, 0.01, "$0.005", "$0.01"},
{0.009, 0.018, "$0.009", "$0.018"},
{0.01, 0.02, "$0.01", "$0.02"},
{0.015, 0.03, "$0.015", "$0.03"},
{1, 2, "$1", "$2"},
{5, 10, "$5", "$10"},
{7.5, 15, "$7.50", "$15"},
{1234.5, 2469, "$1,234.50", "$2,469"},
} {
p := capPacket(teams.Team{ID: "aaaaaaaaaaaa", Name: "harbor"}, "2026-09-25", tc.cap, tc.cap)
if p.Cap == nil || p.Cap.RaiseTo <= tc.cap || p.Cap.RaiseTo != tc.raise || p.Cap.SpentUSD <= 0 {
t.Errorf("cap %.8f: facts %+v", tc.cap, p.Cap)
continue
}
if want := "harbor reached its " + tc.capWord + " cap today"; p.Question != want {
t.Errorf("cap %.8f: question %q, want %q", tc.cap, p.Question, want)
}
if want := "Raise to " + tc.raiseWord; p.Options[0].Label != want {
t.Errorf("cap %.8f: label %q, want %q", tc.cap, p.Options[0].Label, want)
}
if want := "harbor and its sub-teams go on until " + tc.raiseWord + " today"; p.Options[0].Consequence != want {
t.Errorf("cap %.8f: consequence %q, want %q", tc.cap, p.Options[0].Consequence, want)
}
}
}

// Contract 4.2 and 4.3: A decided sub-cent raise admits new work until its new ceiling.
func TestTeamSubCentRaiseAdmitsWorkUntilNewCeiling(t *testing.T) {
fixture := newTeamFixture(t, true)
cap := 0.001
if err := teams.Update(fixture.profile, func(f *teams.File) error {
return f.SetSettings(fixture.teamID, func(s *teams.Settings) { s.CapUSDDay = &cap })
}); err != nil {
t.Fatal(err)
}
spend := &capSpend{usd: cap, stamp: "one"}
stubCapSpend(t, spend)
manager := teamAgent(t, fixture, fixture.manager, nil, nil)
manager.teamBoundary()
roles := manager.teamRoles()
if held := manager.teamCapHold(fixture.profile, roles); !strings.Contains(held, "$0.001 cap") || !strings.Contains(held, "spent $0.001") {
t.Fatalf("at the first ceiling: %q", held)
}
p := onlyPacket(t, fixture.profile, teams.Person)
if _, err := teams.Decide(fixture.profile, p.ID, teams.Person, teams.OptionRaiseCap, ""); err != nil {
t.Fatal(err)
}
spend.usd, spend.stamp = 0.0015, "two"
if held := manager.teamCapHold(fixture.profile, roles); held != "" {
t.Fatalf("below the raised ceiling: %q", held)
}
spend.usd, spend.stamp = 0.002, "three"
if held := manager.teamCapHold(fixture.profile, roles); !strings.Contains(held, "$0.002 cap") {
t.Fatalf("at the raised ceiling: %q", held)
}
}
4 changes: 2 additions & 2 deletions internal/session/team_wakewatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ func (a *Agent) teamWakeMember(profile string, roles []teamRole, now time.Time)
// handed the directive over already; there is nothing left to wake on.
return
}
text := "Your manager started this turn (a directive, or the answer to your question); the person did not speak.\n\n" + news
text := teamWakeMemberLead + "\n\n" + news
woke, reason := a.teamWakeWith(text)
if woke {
a.teamWakeCount(now)
Expand Down Expand Up @@ -524,7 +524,7 @@ func (a *Agent) teamWakeManager(profile string, roles []teamRole, batch map[stri
if len(groups) == 0 {
return
}
text := "Your team's replies started this turn; the person did not speak. Act on them: hand out what comes next, or tell the person where the work stands.\n\n" +
text := teamWakeManagerLead + "\n\n" +
strings.Join(groups, "\n\n")
woke, reason := a.teamWakeWith(text)
if woke {
Expand Down
2 changes: 1 addition & 1 deletion internal/session/team_wrapup.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ func (a *Agent) teamWrapUpDue(profile string, now time.Time) {
case now.Sub(w.started) >= wrapBound(w):
why = fmt.Sprintf("the wrap-up ran out of time (%s) before the manager brought its report", wrapBound(w).Round(time.Minute))
case spendErr == nil && w.measured && spent-w.spentAt >= wrapUpSpendUSD:
why = fmt.Sprintf("the wrap-up spent %s, its limit, before the manager brought its report", teamMoney(spent-w.spentAt))
why = fmt.Sprintf("the wrap-up spent %s, its limit, before the manager brought its report", teamSpendMoney(spent-w.spentAt))
default:
continue
}
Expand Down
Loading
Loading