From 3599c2b7b7a714df687cceb4c869585873680b3e Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 23:05:29 -0400 Subject: [PATCH 1/3] fix(teams): manager conversation initialization, handle fallback, and approval posture inheritance (#1551) --- .../unreleased/1551-teams-manager-init.md | 9 +++ internal/session/consent.go | 5 +- internal/session/team_nest.go | 2 +- internal/session/teamevent_test.go | 8 +++ internal/session/tools_team.go | 2 +- internal/teams/handle.go | 16 ++++++ internal/teams/handle_test.go | 28 ++++++++++ internal/teams/teams.go | 4 +- internal/teams/traffic.go | 3 + internal/tui3/teammanager.go | 13 ++++- internal/tui3/teamsacts.go | 13 ++++- internal/tui3/teamtraffic.go | 43 +++++++++++++- internal/tui3/teamtraffic_test.go | 56 +++++++++++++++++++ 13 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 docs/changes/unreleased/1551-teams-manager-init.md diff --git a/docs/changes/unreleased/1551-teams-manager-init.md b/docs/changes/unreleased/1551-teams-manager-init.md new file mode 100644 index 0000000000..d8dc424487 --- /dev/null +++ b/docs/changes/unreleased/1551-teams-manager-init.md @@ -0,0 +1,9 @@ +--- +kind: fixed +title: manager conversation initialization, handle fallback, and approval posture inheritance +pr: 1551 +surface: [chat, engine] +invalidates: + - "Sub-team manager conversations started via team_start previously ran under default approval postures rather than inheriting the initiating conversation's posture. They now inherit and persist the initiating posture." + - "Top-level team managers without an explicit title previously lacked a fallback handle. They now receive an assigned fallback handle upon creation so they can be messaged immediately." +--- diff --git a/internal/session/consent.go b/internal/session/consent.go index 0c25435ea4..037ae945e1 100644 --- a/internal/session/consent.go +++ b/internal/session/consent.go @@ -640,13 +640,14 @@ func ConsentHead(tool, args string) string { // consentRule is [Event.Rule] for a call: the policy's own words, and for a // start with none, what the person is agreeing to pay for ([teamStartCost]). func consentRule(call ai.ToolCall, decision approval.Decision) string { - if rule := strings.TrimSpace(decision.Rule); rule != "" { + rule := strings.TrimSpace(decision.Rule) + if rule != "" && rule != "default" && rule != "default (unset)" { return rule } if call.Function.Name == teamStartToolName { return teamStartCost } - return "" + return rule } // consentReason is why the gate is asking, in the policy's own words where it diff --git a/internal/session/team_nest.go b/internal/session/team_nest.go index 372cfae9e9..af81f45cae 100644 --- a/internal/session/team_nest.go +++ b/internal/session/team_nest.go @@ -241,7 +241,7 @@ func (a *Agent) teamStartSubTeam(team teams.Team, role teamRole, handle, brief, // here, where the membership was written. A refusal returned above and // wrote nothing. _ = teams.WriteMoveNotices(profile, movedIn) - start := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief, Team: childID} + start := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief, Team: childID, Approval: a.ResolvedApprovalPosture()} if err := teams.AppendTraffic(profile, team.ID, start); err != nil { return "The team " + strconv.Quote(name) + " was made, but its manager's start could not be written to the traffic: " + err.Error(), true } diff --git a/internal/session/teamevent_test.go b/internal/session/teamevent_test.go index 0d6743683f..f2461e0dfe 100644 --- a/internal/session/teamevent_test.go +++ b/internal/session/teamevent_test.go @@ -258,6 +258,14 @@ func TestTheStartsCardSaysWhoIsStartedAndWhatItCosts(t *testing.T) { if rule := consentRule(call, approval.Decision{Rule: "a rule said so"}); rule != "a rule said so" { t.Errorf("a policy's own words were replaced: %q", rule) } + for _, defRule := range []string{"default", "default (unset)"} { + if rule := consentRule(call, approval.Decision{Rule: defRule}); rule != teamStartCost { + t.Errorf("for rule %q, consentRule returned %q, want %q", defRule, rule, teamStartCost) + } + if reason := consentReason(call, approval.Decision{Rule: defRule}); reason != teamStartCost { + t.Errorf("for rule %q, consentReason returned %q, want %q", defRule, reason, teamStartCost) + } + } bash := ai.ToolCall{Function: ai.ToolCallFunction{Name: "bash", Arguments: `{"command":"ls"}`}} if head := ConsentHead("bash", bash.Function.Arguments); head != "needs your ok to run bash" { t.Errorf("an ordinary head changed: %q", head) diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go index 09920c4ce5..5113c1fe3f 100644 --- a/internal/session/tools_team.go +++ b/internal/session/tools_team.go @@ -668,7 +668,7 @@ func (a *Agent) teamStartTool(ctx context.Context, args json.RawMessage) (string if _, taken := team.ByHandle(handle); taken { return fmt.Sprintf("@%s is already a member of %q. Pick another handle, or team_send it the work.", handle, team.Name), true, nil } - entry := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief} + entry := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief, Approval: a.ResolvedApprovalPosture()} if err := teams.AppendTraffic(a.config.teamProfile(), team.ID, entry); err != nil { return "The start could not be written to the team's traffic: " + err.Error(), true, nil } diff --git a/internal/teams/handle.go b/internal/teams/handle.go index 992366a75e..55cb0142bc 100644 --- a/internal/teams/handle.go +++ b/internal/teams/handle.go @@ -223,6 +223,19 @@ func uniqueHandle(t Team, key, base string) string { } } +// FallbackHandle returns a deterministic, valid handle for a member of t +// (such as an untitled manager) that is unique within t. +func FallbackHandle(t Team, key string) string { + base := "" + if !t.Root && t.Name != "" { + base = DeriveHandle(t.Name) + } + if base == "" || ValidHandle(base) != nil { + base = "lead" + } + return uniqueHandle(t, key, base) +} + // assignHandles clears every handle in t that is invalid or repeats one an // earlier member has, then gives each member with a title and no handle one, // in member order. It reports whether it changed anything. @@ -247,6 +260,9 @@ func assignHandles(t *Team) bool { continue } base := DeriveHandle(m.Word) + if base == "" && t.Manager != "" && t.Manager == m.Key { + base = FallbackHandle(*t, m.Key) + } if base == "" { continue } diff --git a/internal/teams/handle_test.go b/internal/teams/handle_test.go index 2ff9ce8192..d8285b9a92 100644 --- a/internal/teams/handle_test.go +++ b/internal/teams/handle_test.go @@ -234,3 +234,31 @@ func TestChooseHandleClashesAndTypedHandles(t *testing.T) { t.Errorf("an unusable answer changed the member: %+v", after) } } + +func TestFallbackHandle(t *testing.T) { + team := Team{ID: "t1", Name: "harbor", Members: []Member{{Key: "m1", Word: ""}}} + fb := FallbackHandle(team, "m1") + if fb != "harbor" { + t.Errorf("got %q, want harbor", fb) + } + if err := ValidHandle(fb); err != nil { + t.Errorf("fallback handle %q is invalid: %v", fb, err) + } + + // Collision resolution: + team.Members = append(team.Members, Member{Key: "m2", Handle: "harbor"}) + fb2 := FallbackHandle(team, "m1") + if fb2 != "harbor2" { + t.Errorf("got %q, want harbor2", fb2) + } + + // Root team falls back to lead + rootTeam := Team{ID: "root", Name: RootName, Root: true, Members: []Member{{Key: "gm"}}} + rfb := FallbackHandle(rootTeam, "gm") + if rfb != "lead" { + t.Errorf("got %q, want lead", rfb) + } + if err := ValidHandle(rfb); err != nil { + t.Errorf("root fallback %q is invalid: %v", rfb, err) + } +} diff --git a/internal/teams/teams.go b/internal/teams/teams.go index 2696503522..ba1fab1026 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -432,7 +432,9 @@ func (f *File) SetManager(id, key string) error { if err := f.AddMember(id, Member{Key: key}); err != nil { return err } - f.Teams[Index(f.Teams, id)].Manager = key + idx := Index(f.Teams, id) + f.Teams[idx].Manager = key + assignHandles(&f.Teams[idx]) return nil } diff --git a/internal/teams/traffic.go b/internal/teams/traffic.go index 5bbdf6bfac..3381502cd3 100644 --- a/internal/teams/traffic.go +++ b/internal/teams/traffic.go @@ -109,6 +109,9 @@ type Entry struct { // brief, makes itself that team's manager. It is empty on every other // start and every other kind. Team string `json:"team,omitempty"` + // Approval is, on a [KindStart], the approval posture the started + // conversation inherits from the manager unless overridden. + Approval string `json:"approval,omitempty"` } // trafficRotateBytes is the size past which the log starts a new file. diff --git a/internal/tui3/teammanager.go b/internal/tui3/teammanager.go index 194638b894..aed30b8f60 100644 --- a/internal/tui3/teammanager.go +++ b/internal/tui3/teammanager.go @@ -118,7 +118,18 @@ func (a *app) teamMakeManager(id string, tab chatTab) error { return err } } - return f.SetManager(id, tab.key) + if err := f.SetManager(id, tab.key); err != nil { + return err + } + if t, ok := f.Team(id); ok { + if got, _ := t.Member(tab.key); got.Handle == "" { + fb := teamstore.FallbackHandle(t, tab.key) + if teamstore.ValidHandle(fb) == nil { + _ = f.SetHandle(id, tab.key, fb) + } + } + } + return nil }) } diff --git a/internal/tui3/teamsacts.go b/internal/tui3/teamsacts.go index bd2c176872..51024237fd 100644 --- a/internal/tui3/teamsacts.go +++ b/internal/tui3/teamsacts.go @@ -259,7 +259,18 @@ func (a *app) teamsRootManagerStart() tea.Cmd { return err } } - return f.SetManager(id, tab.key) + if err := f.SetManager(id, tab.key); err != nil { + return err + } + if t, ok := f.Team(id); ok { + if got, _ := t.Member(tab.key); got.Handle == "" { + fb := teamstore.FallbackHandle(t, tab.key) + if teamstore.ValidHandle(fb) == nil { + _ = f.SetHandle(id, tab.key, fb) + } + } + } + return nil }) if err != nil { a.note("the manager is set for this window, but " + err.Error()) diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index 6054c49ef2..c9ef633180 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -1,11 +1,13 @@ package tui3 import ( + "path/filepath" "strings" "time" tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" teamstore "github.com/Agent-Field/codeaf/internal/teams" ) @@ -465,10 +467,23 @@ func (a *app) trafficStart(t team, e teamstore.Entry) tea.Cmd { a.trafficStartRefused(handle, newUnavailableWord) return nil } + posture := strings.TrimSpace(e.Approval) + if posture == "" { + if t.Manager == a.frontTabKey() { + posture = a.approvalPostureWord() + } else if held := a.behind[t.Manager]; held != nil && held.conv.Agent != nil { + if dial, ok := held.conv.Agent.(approvalDialer); ok { + posture = dial.ResolvedApprovalPosture() + } + } + if posture == "" { + posture = a.approvalPostureWord() + } + } start, where, id := a.start, a.teamWhere(t), t.ID return a.besideLine(func() func(bool) tea.Cmd { conv, err := start(where) - return func(bool) tea.Cmd { return a.trafficStarted(id, handle, conv, err) } + return func(bool) tea.Cmd { return a.trafficStarted(id, handle, conv, posture, err) } }) } @@ -479,7 +494,7 @@ func (a *app) trafficStartRefused(handle, why string) { // trafficStarted is the start's conversation back from the engine: held // behind, joined to the team under its handle, and said beside the manager. -func (a *app) trafficStarted(id, handle string, conv Conversation, err error) tea.Cmd { +func (a *app) trafficStarted(id, handle string, conv Conversation, posture string, err error) tea.Cmd { if err != nil || conv.Agent == nil { why := "the conversation did not open" if err != nil { @@ -488,6 +503,22 @@ func (a *app) trafficStarted(id, handle string, conv Conversation, err error) te a.trafficStartRefused(handle, why) return nil } + if posture != "" && conv.Agent != nil { + if dial, ok := conv.Agent.(approvalDialer); ok { + _ = dial.SetApprovalPosture(posture) + } else if setter, ok := conv.Agent.(interface{ SetApprovalPosture(string) error }); ok { + _ = setter.SetApprovalPosture(posture) + } + } + if posture != "" && conv.SessionFile != "" { + dir := filepath.Dir(conv.SessionFile) + meta, _ := session.LoadMeta(dir) + if meta.ID == "" { + meta.ID = filepath.Base(dir) + } + meta.Approval = posture + _ = session.SaveMeta(dir, meta) + } t, ok := a.teamByID(id) if !ok { // The team went while the conversation was opening. It is held all the @@ -504,8 +535,14 @@ func (a *app) trafficStarted(id, handle string, conv Conversation, err error) te if err := f.AddMember(t.ID, m); err != nil { return err } - if got, _ := f.Teams[teamIndex(f.Teams, t.ID)].Member(m.Key); got.Handle != handle && teamstore.ValidHandle(handle) == nil { + curTeam := f.Teams[teamIndex(f.Teams, t.ID)] + if got, _ := curTeam.Member(m.Key); got.Handle != handle && teamstore.ValidHandle(handle) == nil { _ = f.SetHandle(t.ID, m.Key, handle) + } else if got, _ := curTeam.Member(m.Key); got.Handle == "" { + fb := teamstore.FallbackHandle(curTeam, m.Key) + if teamstore.ValidHandle(fb) == nil { + _ = f.SetHandle(t.ID, m.Key, fb) + } } return nil }); err != nil { diff --git a/internal/tui3/teamtraffic_test.go b/internal/tui3/teamtraffic_test.go index 1ad937c771..01253cbcea 100644 --- a/internal/tui3/teamtraffic_test.go +++ b/internal/tui3/teamtraffic_test.go @@ -2,6 +2,7 @@ package tui3 import ( "fmt" + "path/filepath" "strings" "testing" @@ -515,3 +516,58 @@ func TestAReplayedBriefIsTheManagersCard(t *testing.T) { t.Fatalf("the brief draws as:\n%s", rows) } } + +type postureTestAgent struct { + fakeAgent + posture string +} + +func (p *postureTestAgent) SetApprovalPosture(posture string) error { + p.posture = posture + return nil +} + +func TestTrafficStartInheritsApprovalPosture(t *testing.T) { + a, harbor, _, _ := trafficApp(t) + var fresh *postureTestAgent + tmpDir := t.TempDir() + sessionFile := filepath.Join(tmpDir, "session.jsonl") + a.start = func(workspace string) (Conversation, error) { + fresh = &postureTestAgent{fakeAgent: fakeAgent{model: "m"}} + return Conversation{Agent: fresh, SessionFile: sessionFile, Workspace: workspace}, nil + } + trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, To: "submgr", Text: "lead the sub team", Approval: session.PostureAllow}) + trafficReadNow(t, a) + trafficReadNow(t, a) + + if fresh == nil || fresh.posture != session.PostureAllow { + t.Fatalf("started agent did not inherit posture: got %+v", fresh) + } + meta, err := session.LoadMeta(tmpDir) + if err != nil { + t.Fatalf("LoadMeta failed: %v", err) + } + if meta.Approval != session.PostureAllow { + t.Fatalf("meta.json approval = %q, want %q", meta.Approval, session.PostureAllow) + } +} + +func TestUntitledManagerGetsFallbackHandle(t *testing.T) { + a, harbor, _, _ := trafficApp(t) + tab := chatTab{key: "mgr-key-untitled", word: ""} + if err := a.teamMakeManager(harbor, tab); err != nil { + t.Fatalf("teamMakeManager failed: %v", err) + } + teamsFlush(t, a) + team := mustTeam(t, a, harbor) + m, ok := team.Member("mgr-key-untitled") + if !ok { + t.Fatalf("manager not in team: %+v", team.Members) + } + if m.Handle == "" { + t.Fatalf("manager has empty handle: %+v", m) + } + if err := teamstore.ValidHandle(m.Handle); err != nil { + t.Fatalf("fallback handle %q is invalid: %v", m.Handle, err) + } +} From 7ffe3b372c3501bafb7d7246ed08821f859c979e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 10:43:12 -0400 Subject: [PATCH 2/3] fix(teams): inherit approvals through the session before starting work --- .../unreleased/1551-teams-manager-init.md | 3 +- internal/manual/chat/team-manager.md | 8 +++ internal/tui3/teammanager.go | 13 +---- internal/tui3/teamsacts.go | 13 +---- internal/tui3/teamtraffic.go | 58 +++++++++---------- internal/tui3/teamtraffic_test.go | 31 +++++++--- 6 files changed, 61 insertions(+), 65 deletions(-) diff --git a/docs/changes/unreleased/1551-teams-manager-init.md b/docs/changes/unreleased/1551-teams-manager-init.md index d8dc424487..9438bc7449 100644 --- a/docs/changes/unreleased/1551-teams-manager-init.md +++ b/docs/changes/unreleased/1551-teams-manager-init.md @@ -1,9 +1,10 @@ --- kind: fixed title: manager conversation initialization, handle fallback, and approval posture inheritance -pr: 1551 +pr: 1597 surface: [chat, engine] invalidates: - "Sub-team manager conversations started via team_start previously ran under default approval postures rather than inheriting the initiating conversation's posture. They now inherit and persist the initiating posture." - "Top-level team managers without an explicit title previously lacked a fallback handle. They now receive an assigned fallback handle upon creation so they can be messaged immediately." + - "Approval inheritance now uses the session-owned setter before team membership can wake work. A failed setter refuses startup; the UI no longer overwrites session metadata or borrows an unrelated foreground conversation’s posture." --- diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index ae3ff57821..d07044d469 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -389,3 +389,11 @@ while it was closed. The traffic itself is kept in the profile of the machine the conversations run on, in `teams//traffic.jsonl`, one line per message, only ever added to. + +## Which approval rules do new members inherit? + +A member started by the manager inherits the manager's approval posture before +it joins and receives work. The conversation saves its own setting for resume. +If the new conversation cannot apply the posture, the start is refused and the +manager sees the reason. An untitled manager receives an available handle from +the team store, so it can be addressed immediately. diff --git a/internal/tui3/teammanager.go b/internal/tui3/teammanager.go index aed30b8f60..194638b894 100644 --- a/internal/tui3/teammanager.go +++ b/internal/tui3/teammanager.go @@ -118,18 +118,7 @@ func (a *app) teamMakeManager(id string, tab chatTab) error { return err } } - if err := f.SetManager(id, tab.key); err != nil { - return err - } - if t, ok := f.Team(id); ok { - if got, _ := t.Member(tab.key); got.Handle == "" { - fb := teamstore.FallbackHandle(t, tab.key) - if teamstore.ValidHandle(fb) == nil { - _ = f.SetHandle(id, tab.key, fb) - } - } - } - return nil + return f.SetManager(id, tab.key) }) } diff --git a/internal/tui3/teamsacts.go b/internal/tui3/teamsacts.go index 51024237fd..bd2c176872 100644 --- a/internal/tui3/teamsacts.go +++ b/internal/tui3/teamsacts.go @@ -259,18 +259,7 @@ func (a *app) teamsRootManagerStart() tea.Cmd { return err } } - if err := f.SetManager(id, tab.key); err != nil { - return err - } - if t, ok := f.Team(id); ok { - if got, _ := t.Member(tab.key); got.Handle == "" { - fb := teamstore.FallbackHandle(t, tab.key) - if teamstore.ValidHandle(fb) == nil { - _ = f.SetHandle(id, tab.key, fb) - } - } - } - return nil + return f.SetManager(id, tab.key) }) if err != nil { a.note("the manager is set for this window, but " + err.Error()) diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index c9ef633180..6d5f4eaebd 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -1,13 +1,12 @@ package tui3 import ( - "path/filepath" + "fmt" "strings" "time" tea "charm.land/bubbletea/v2" - "github.com/Agent-Field/codeaf/internal/session" teamstore "github.com/Agent-Field/codeaf/internal/teams" ) @@ -469,21 +468,36 @@ func (a *app) trafficStart(t team, e teamstore.Entry) tea.Cmd { } posture := strings.TrimSpace(e.Approval) if posture == "" { + // Old traffic has no captured posture. Only the initiating manager + // can supply it; an unrelated foreground conversation cannot. + var manager Agent if t.Manager == a.frontTabKey() { - posture = a.approvalPostureWord() - } else if held := a.behind[t.Manager]; held != nil && held.conv.Agent != nil { - if dial, ok := held.conv.Agent.(approvalDialer); ok { - posture = dial.ResolvedApprovalPosture() - } + manager = a.agent + } else if held := a.behind[t.Manager]; held != nil { + manager = held.conv.Agent } - if posture == "" { - posture = a.approvalPostureWord() + if dial, ok := manager.(interface{ ResolvedApprovalPosture() string }); ok { + posture = dial.ResolvedApprovalPosture() } } start, where, id := a.start, a.teamWhere(t), t.ID return a.besideLine(func() func(bool) tea.Cmd { conv, err := start(where) - return func(bool) tea.Cmd { return a.trafficStarted(id, handle, conv, posture, err) } + // The session owns both the gate and its persisted posture. Apply it + // off the UI loop, before membership can wake the first turn. + if err == nil && conv.Agent != nil && posture != "" { + setter, ok := conv.Agent.(interface{ SetApprovalPosture(string) error }) + if !ok { + err = fmt.Errorf("the new conversation cannot inherit approvals") + } else { + err = setter.SetApprovalPosture(posture) + } + if err != nil { + _ = conv.Agent.Close() + err = fmt.Errorf("inherit approvals: %w", err) + } + } + return func(bool) tea.Cmd { return a.trafficStarted(id, handle, conv, err) } }) } @@ -494,7 +508,7 @@ func (a *app) trafficStartRefused(handle, why string) { // trafficStarted is the start's conversation back from the engine: held // behind, joined to the team under its handle, and said beside the manager. -func (a *app) trafficStarted(id, handle string, conv Conversation, posture string, err error) tea.Cmd { +func (a *app) trafficStarted(id, handle string, conv Conversation, err error) tea.Cmd { if err != nil || conv.Agent == nil { why := "the conversation did not open" if err != nil { @@ -503,22 +517,6 @@ func (a *app) trafficStarted(id, handle string, conv Conversation, posture strin a.trafficStartRefused(handle, why) return nil } - if posture != "" && conv.Agent != nil { - if dial, ok := conv.Agent.(approvalDialer); ok { - _ = dial.SetApprovalPosture(posture) - } else if setter, ok := conv.Agent.(interface{ SetApprovalPosture(string) error }); ok { - _ = setter.SetApprovalPosture(posture) - } - } - if posture != "" && conv.SessionFile != "" { - dir := filepath.Dir(conv.SessionFile) - meta, _ := session.LoadMeta(dir) - if meta.ID == "" { - meta.ID = filepath.Base(dir) - } - meta.Approval = posture - _ = session.SaveMeta(dir, meta) - } t, ok := a.teamByID(id) if !ok { // The team went while the conversation was opening. It is held all the @@ -538,11 +536,7 @@ func (a *app) trafficStarted(id, handle string, conv Conversation, posture strin curTeam := f.Teams[teamIndex(f.Teams, t.ID)] if got, _ := curTeam.Member(m.Key); got.Handle != handle && teamstore.ValidHandle(handle) == nil { _ = f.SetHandle(t.ID, m.Key, handle) - } else if got, _ := curTeam.Member(m.Key); got.Handle == "" { - fb := teamstore.FallbackHandle(curTeam, m.Key) - if teamstore.ValidHandle(fb) == nil { - _ = f.SetHandle(t.ID, m.Key, fb) - } + } return nil }); err != nil { diff --git a/internal/tui3/teamtraffic_test.go b/internal/tui3/teamtraffic_test.go index 01253cbcea..fa1aac2bfa 100644 --- a/internal/tui3/teamtraffic_test.go +++ b/internal/tui3/teamtraffic_test.go @@ -1,6 +1,7 @@ package tui3 import ( + "errors" "fmt" "path/filepath" "strings" @@ -520,11 +521,12 @@ func TestAReplayedBriefIsTheManagersCard(t *testing.T) { type postureTestAgent struct { fakeAgent posture string + setErr error } func (p *postureTestAgent) SetApprovalPosture(posture string) error { p.posture = posture - return nil + return p.setErr } func TestTrafficStartInheritsApprovalPosture(t *testing.T) { @@ -543,13 +545,7 @@ func TestTrafficStartInheritsApprovalPosture(t *testing.T) { if fresh == nil || fresh.posture != session.PostureAllow { t.Fatalf("started agent did not inherit posture: got %+v", fresh) } - meta, err := session.LoadMeta(tmpDir) - if err != nil { - t.Fatalf("LoadMeta failed: %v", err) - } - if meta.Approval != session.PostureAllow { - t.Fatalf("meta.json approval = %q, want %q", meta.Approval, session.PostureAllow) - } + } func TestUntitledManagerGetsFallbackHandle(t *testing.T) { @@ -571,3 +567,22 @@ func TestUntitledManagerGetsFallbackHandle(t *testing.T) { t.Fatalf("fallback handle %q is invalid: %v", m.Handle, err) } } + +// A child must not join the team and wake under a gate it failed to inherit. +func TestTrafficStartRefusesFailedApprovalInheritance(t *testing.T) { + a, harbor, _, _ := trafficApp(t) + fresh := &postureTestAgent{setErr: errors.New("cannot rebuild rules")} + file := filepath.Join(t.TempDir(), "session.jsonl") + a.start = func(workspace string) (Conversation, error) { + return Conversation{Agent: fresh, SessionFile: file, Workspace: workspace}, nil + } + trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, To: "worker", Text: "start", Approval: session.PostureDeny}) + trafficReadNow(t, a) + trafficReadNow(t, a) + if fresh.closes != 1 { + t.Fatalf("failed child closed %d times", fresh.closes) + } + if _, ok := mustTeam(t, a, harbor).ByHandle("worker"); ok { + t.Fatal("failed child joined team") + } +} From fbb38632cc4e768f69de48364f53e02d2137c926 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 10:48:00 -0400 Subject: [PATCH 3/3] docs: align change entry filename with pull request number --- .../{1551-teams-manager-init.md => 1597-teams-manager-init.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changes/unreleased/{1551-teams-manager-init.md => 1597-teams-manager-init.md} (100%) diff --git a/docs/changes/unreleased/1551-teams-manager-init.md b/docs/changes/unreleased/1597-teams-manager-init.md similarity index 100% rename from docs/changes/unreleased/1551-teams-manager-init.md rename to docs/changes/unreleased/1597-teams-manager-init.md