From 6176a8f6b76c5089f903191407a790e5e5b42b87 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 08:58:33 -0400 Subject: [PATCH 1/7] Honor per-task model choices and allow chats to launch model-selected conversations --- .../unreleased/1934-chat-launch-model.md | 22 +++ internal/e2e/launch_models_e2e_test.go | 172 ++++++++++++++++++ .../manual/chat/conversations-and-teams.md | 17 ++ internal/manual/chat/tasks.md | 5 + internal/manual/chat/worker-harness.md | 4 +- internal/plandb/model.go | 8 +- internal/plandb/persist.go | 15 +- internal/plandb/store.go | 1 + internal/run/crew.go | 3 + internal/run/crew_test.go | 14 ++ internal/session/consent.go | 2 +- internal/session/manual_test.go | 2 + internal/session/session.go | 3 + internal/session/start_chat_tool_test.go | 56 ++++++ internal/session/task.go | 2 +- internal/session/task_launch_model_test.go | 85 +++++++++ internal/session/task_quick.go | 6 +- internal/session/task_run_belt.go | 18 +- internal/session/team_nest.go | 4 +- internal/session/tools.go | 1 + internal/session/tools_capabilities.go | 3 + internal/session/tools_start_chat.go | 63 +++++++ internal/session/tools_team.go | 17 +- internal/teams/traffic.go | 2 + internal/tui3/app.go | 3 + internal/tui3/keeper.go | 13 +- internal/tui3/startchat.go | 67 +++++++ internal/tui3/startchat_test.go | 46 +++++ internal/tui3/teamtraffic.go | 4 + 29 files changed, 634 insertions(+), 24 deletions(-) create mode 100644 docs/changes/unreleased/1934-chat-launch-model.md create mode 100644 internal/e2e/launch_models_e2e_test.go create mode 100644 internal/session/start_chat_tool_test.go create mode 100644 internal/session/task_launch_model_test.go create mode 100644 internal/session/tools_start_chat.go create mode 100644 internal/tui3/startchat.go create mode 100644 internal/tui3/startchat_test.go diff --git a/docs/changes/unreleased/1934-chat-launch-model.md b/docs/changes/unreleased/1934-chat-launch-model.md new file mode 100644 index 0000000000..813eed1be7 --- /dev/null +++ b/docs/changes/unreleased/1934-chat-launch-model.md @@ -0,0 +1,22 @@ +--- +kind: fixed +title: Chats can name models for new chats and tasks that join an existing run +pr: 1934 +surface: [chat, engine, docs] +invalidates: + - "A proposal joining a running task discarded its requested model and used the run's worker instead; the joined task now retains and uses its own work model." + - "The worker-harness manual omitted the proposal's model override; naming a model for one task overrides the task default and crew worker without changing profile pins." + - "A manager could not select a model for a new teammate conversation; team_start now accepts a model for a member or sub-team manager." + - "A chat could not request an independent chat through a dedicated tool; start_chat now asks the attached terminal to open one with its chosen model and first message." +--- + +The new chat opens behind the requesting chat and inherits its approval posture. +Unknown or ambiguous model names are refused before a chat opens. The start receipt +confirms the request; the terminal reports whether the new chat actually opened. + +Named task workers survive plan-store persistence, including tasks sharing one run. +Planning and checking keep their crew seats. The one-model launch option continues +to force every task seat onto the conversation model. + +Quick tasks retain their per-call model argument on the node task engine. The default +worker harness continues to expose the full task proposal door instead. diff --git a/internal/e2e/launch_models_e2e_test.go b/internal/e2e/launch_models_e2e_test.go new file mode 100644 index 0000000000..2823613c31 --- /dev/null +++ b/internal/e2e/launch_models_e2e_test.go @@ -0,0 +1,172 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" + "time" +) + +// TestChatLaunchModels asks a live chat to choose models through the shipped tools. +// Every auxiliary seat stays on the chat model; only explicitly named work changes. +func TestChatLaunchModels(t *testing.T) { + requireTmuxAndKey(t) + const chat = "deepseek/deepseek-v4.1-flash" + const other = "moonshotai/kimi-k3" + for _, kind := range []string{"quick_task", "propose_task", "start_chat", "team_start", "joined_task"} { + t.Run(kind, func(t *testing.T) { + home := newHome(t, map[string]any{ + "model.talk": chat, "model.work": chat, "model.plan": chat, "task.model": chat, + "models.tiers.worker": chat, "models.tiers.mastermind": chat, "models.tiers.reflex": chat, "models.tiers.low": chat, "models.tiers.high": chat, + "models.crew.allowed": chat + "," + other, + "models.fallbacks": "", "tools.approvalMode": "allow", "task.audit": "off", "task.max_load": 0, "task.autoapprove_seconds": 1, "task.min_free_mb": 0, "daily_budget_usd": 0, "model_pool": "off", + }) + ws := newWorkspace(t, "launch-model", false) + launch := startDefault + if kind == "quick_task" { + launch = start + } + args := []string{"chat", "--model", chat, "--no-host", "--debug"} + if kind == "team_start" { + sid := "55aa55aa55aa55aa" + bucket := strings.ReplaceAll(filepath.Clean(ws), string(filepath.Separator), "-") + dir := filepath.Join(home, "v3", "projects", bucket, sid) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + journal := filepath.Join(dir, "transcript.jsonl") + header, _ := json.Marshal(map[string]any{"type": "session", "version": 1, "id": sid, "cwd": ws, "model": chat, "timestamp": time.Now().UTC()}) + if err := os.WriteFile(journal, append(header, '\n'), 0600); err != nil { + t.Fatal(err) + } + team := teamstore.Team{ID: teamstore.NewID(), Name: "Model check", Manager: journal, Members: []teamstore.Member{{Key: journal, File: journal, Where: ws, Handle: "manager"}}} + if err := teamstore.Save(home, []teamstore.Team{team}); err != nil { + t.Fatal(err) + } + args = append(args, "--session", journal) + } + r := launch(t, "launch_"+kind, home, ws, 140, 45, args...) + prompt := "Use " + kind + " to start independent work on model " + other + ". The assignment: read README.md, then report LAUNCH_MODEL_VERIFIED. No files to change. Set model explicitly on the tool call. Do not run the assignment yourself. Use load_tools if needed. If that exact tool is unavailable, report that and stop; do not substitute a different tool. End your turn after the launch receipt." + if kind == "joined_task" { + r.lit("Use propose_task with model deepseek/deepseek-v4.1-flash. Assignment: run bash sleep 40, then report FIRST_WORKER_DONE. No edits. Do not run it yourself. After its receipt, answer only these words joined by spaces: First / task / started.") + r.keys("Enter") + cleanChatWaitAnswer(t, r, "First task started", 2*time.Minute) + prompt = "Use propose_task to start another task on model moonshotai/kimi-k3. Read README.md and report LAUNCH_MODEL_VERIFIED. No edits. Set the model argument explicitly. Let it join the current run. End your turn after its receipt." + } + if kind == "team_start" { + prompt += " Give the new member handle scout." + } + r.lit(prompt) + r.keys("Enter") + deadline := time.Now().Add(5 * time.Minute) + found := false + for time.Now().Before(deadline) { + raw, _ := os.ReadFile(filepath.Join(home, "logs", "calls.jsonl")) + for _, line := range strings.Split(string(raw), "\n") { + var row map[string]any + if json.Unmarshal([]byte(line), &row) == nil && row["model"] == other && row["phase"] != "start" { + found = true + } + } + if found { + break + } + time.Sleep(pollEvery) + } + cleanChatCapture(t, r, "launch-"+kind) + wantedTool := kind + if kind == "joined_task" { + wantedTool = "propose_task" + } + matchingCalls := 0 + reported := false + joined := false + _ = filepath.WalkDir(home, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() || !strings.HasSuffix(path, ".jsonl") { + return nil + } + raw, _ := os.ReadFile(path) + for _, line := range strings.Split(string(raw), "\n") { + if strings.Contains(line, "It joined the work already underway") { + joined = true + } + var row struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []struct { + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"toolCalls"` + } + if json.Unmarshal([]byte(line), &row) != nil { + continue + } + for _, call := range row.ToolCalls { + var params struct { + Model string `json:"model"` + } + _ = json.Unmarshal([]byte(call.Function.Arguments), ¶ms) + if call.Function.Name == wantedTool && params.Model == other { + matchingCalls++ + } + } + if row.Role == "assistant" && strings.Contains(row.Content, "LAUNCH_MODEL_VERIFIED") && len(row.ToolCalls) == 0 { + reported = true + } + } + return nil + }) + if kind == "joined_task" && !joined { + t.Error("second proposal did not join the first run") + } + if matchingCalls == 0 { + t.Errorf("no %s call explicitly selected %s", wantedTool, other) + } + t.Logf("explicit %s calls on requested model: %d; result observed: %v", wantedTool, matchingCalls, reported) + if !found { + t.Fatalf("%s never completed a call on %s; screen:\n%s", kind, other, r.capture()) + } + raw, _ := os.ReadFile(filepath.Join(home, "logs", "calls.jsonl")) + counts := map[string]int{} + safe := []map[string]any{} + for _, line := range strings.Split(string(raw), "\n") { + var row map[string]any + if json.Unmarshal([]byte(line), &row) != nil || row["phase"] == "start" { + continue + } + receipt := map[string]any{} + for _, field := range []string{"id", "phase", "tag", "model", "status"} { + if value, ok := row[field]; ok { + receipt[field] = value + } + } + safe = append(safe, receipt) + model, _ := row["model"].(string) + if model != "" { + counts[model]++ + if model != chat && model != other { + t.Errorf("unexpected model %s", model) + } + } + } + if dir := os.Getenv("CODEAF_E2E_EVIDENCE_DIR"); dir != "" { + data, _ := json.MarshalIndent(safe, "", " ") + if err := os.WriteFile(filepath.Join(dir, "calls-"+kind+".json"), data, 0600); err != nil { + t.Fatal(err) + } + } + t.Logf("completed live calls: %v", counts) + if counts[chat] == 0 { + t.Fatal("launching chat did not use requested DeepSeek model") + } + }) + } +} diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index c3f3f54b6e..9e39fed372 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -430,3 +430,20 @@ cleanup is omitted from the saved conversation grid and membership pickers. No. Deleting a conversation removes its transcript, task records and team memberships. Memories already saved for a user, project or machine remain available, with their source identity. Use the Memories tab to forget a saved memory separately. Deleting a team also preserves those memories. + + +## Ask the chat to start another chat on a different model + +Ask “start another chat on kimi-k3 to read the README.” Load the `chats` capability group if needed. The `start_chat` tool takes +`brief`, the complete first message, and `model`, a catalog ID or unique model name. +Omitting `model` uses the requesting chat's current model. An unknown or ambiguous +name is refused before any chat opens. The new chat opens behind the current one, +with its model and approval posture set before its first message. Your focus stays +in the original chat. Its receipt confirms the request; the terminal reports whether +it actually opened. This needs an attached interactive terminal that can open chats. +A new independent chat does not report its answer back here automatically; use a task +when that is the desired handoff. + +For a teammate, the manager's `team_start` also takes `model`, including for a new +sub-team's manager. Omitting it keeps the profile default. Neither tool rewrites the +profile or changes the requesting chat's model. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index c6f223f6f9..3d3b9e8006 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -4378,6 +4378,11 @@ The day turns over at midnight on this machine's clock, and the spend starts aga ## Naming a model for one task +The chat sets `model` on `propose_task` (or `quick_task` on the node task engine). +That argument overrides `task.model` and the crew worker for this task only; it does +not change a profile pin or the conversation model. Ask for a model in the task request, +not a global `/crew pin` workaround. + You ask in words — "let opus do this one", "run that on gpt-5". There is no key, command or field for it: the model that grooms the work sets the model on the proposal. The word may be a whole catalog id (`anthropic/claude-opus-5`), the tail after the vendor diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 9d9017bc2c..1229f61b7f 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -847,7 +847,9 @@ named on a door or in the profile: - **`--model` is the work seat** — the model a leaf that does the work itself runs on. `codeaf do` reads it from `--model`, then `CODEAF_MODEL`, then a `/crew pin`, then the worker the crew picks for this task; a `/task` in a - conversation takes the worker its crew picked for it. A root is born a leaf, so its first + conversation takes the worker its crew picked for it, unless the chat names a model + in `propose_task`: that model overrides `task.model` and the crew worker for this + task only. No `/crew pin` is needed. A root is born a leaf, so its first launch rides this seat, and so does every task the plan adds under it. - **`--plan-model` is the plan seat** — the model the root and every task that has children run their coordinating turns on. `codeaf do` resolves it the same diff --git a/internal/plandb/model.go b/internal/plandb/model.go index 8c39b15d79..ba0ec7b283 100644 --- a/internal/plandb/model.go +++ b/internal/plandb/model.go @@ -62,9 +62,11 @@ const ( ) type TaskSpec struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + // Model overrides the work seat for this task; planning and checks keep their seats. + Model string `json:"model,omitempty"` Question string `json:"question,omitempty"` Kind string `json:"kind,omitempty"` ParentID string `json:"parent_id,omitempty"` diff --git a/internal/plandb/persist.go b/internal/plandb/persist.go index 231ec0d6f8..c1b15b4206 100644 --- a/internal/plandb/persist.go +++ b/internal/plandb/persist.go @@ -379,6 +379,9 @@ func migrateColumns(tx *sql.Tx) error { return err } for _, table := range []string{"tasks", "archived_tasks"} { + if err := ensureColumn(tx, table, "model", "TEXT", "''"); err != nil { + return err + } if err := ensureColumn(tx, table, "owner", "TEXT", "''"); err != nil { return err } @@ -532,7 +535,7 @@ const taskColumns = `id, title, description, question, kind, parent_id, priority parallel, isolation, role, agent, acceptance, checks, capabilities, resources, context_inputs, deliverables, evidence_requirements, status, composite, claimed_by, result, err, artifacts, evidence, created_at, updated_at, completed_at, project, chat, paused, owner, seen_at, - waiting, waited_at, verdict_basis` + waiting, waited_at, verdict_basis, model` // rowQuerier is the read half both the database handle and a transaction // carry, so the archive reader can share the task scan with the loader @@ -562,7 +565,7 @@ func scanTask(row *sql.Rows, extra ...any) (*Task, error) { &checks, &capabilities, &resources, &contextInputs, &deliverables, &evidenceRequirements, &task.Status, &composite, &task.ClaimedBy, &task.Result, &task.Error, &artifacts, &evidence, &createdAt, &updatedAt, &completedAt, &task.Project, &task.Chat, &paused, - &task.Owner, &seenAt, &waiting, &waitedAt, &basis, + &task.Owner, &seenAt, &waiting, &waitedAt, &basis, &task.Model, } dest = append(dest, extra...) if err := row.Scan(dest...); err != nil { @@ -669,7 +672,7 @@ func insertArchived(tx *sql.Tx, tasks []*Task, now time.Time) error { id, ord, title, description, question, kind, parent_id, priority, effect, parallel, isolation, role, agent, acceptance, checks, capabilities, resources, context_inputs, deliverables, evidence_requirements, status, composite, claimed_by, result, err, artifacts, evidence, - created_at, updated_at, completed_at, project, chat, paused, owner, seen_at, waiting, waited_at, archived_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + created_at, updated_at, completed_at, project, chat, paused, owner, seen_at, waiting, waited_at, archived_at, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return err } @@ -713,7 +716,7 @@ func insertArchived(tx *sql.Tx, tasks []*Task, now time.Time) error { evidenceRequirements, string(task.Status), boolInt(task.Composite), task.ClaimedBy, task.Result, task.Error, artifacts, evidence, formatTime(task.CreatedAt), formatTime(task.UpdatedAt), formatTime(task.CompletedAt), task.Project, task.Chat, - boolInt(task.Paused), task.Owner, formatTime(task.SeenAt), boolInt(task.Waiting), formatTime(task.WaitedAt), formatTime(now)); err != nil { + boolInt(task.Paused), task.Owner, formatTime(task.SeenAt), boolInt(task.Waiting), formatTime(task.WaitedAt), formatTime(now), task.Model); err != nil { return err } } @@ -828,7 +831,7 @@ func saveTasks(tx *sql.Tx, value state) error { role, agent, acceptance, checks, capabilities, resources, context_inputs, deliverables, evidence_requirements, status, composite, claimed_by, result, err, artifacts, evidence, created_at, updated_at, completed_at, project, chat, paused, owner, seen_at, waiting, waited_at, - verdict_basis) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + verdict_basis, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return err } @@ -877,7 +880,7 @@ func saveTasks(tx *sql.Tx, value state) error { evidenceRequirements, string(task.Status), boolInt(task.Composite), task.ClaimedBy, task.Result, task.Error, artifacts, evidence, formatTime(task.CreatedAt), formatTime(task.UpdatedAt), formatTime(task.CompletedAt), task.Project, task.Chat, boolInt(task.Paused), - task.Owner, formatTime(task.SeenAt), boolInt(task.Waiting), formatTime(task.WaitedAt), basis); err != nil { + task.Owner, formatTime(task.SeenAt), boolInt(task.Waiting), formatTime(task.WaitedAt), basis, task.Model); err != nil { return err } } diff --git a/internal/plandb/store.go b/internal/plandb/store.go index 546bcef303..4003d9148b 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -2352,6 +2352,7 @@ func (s *Store) changeTask(id string, change func(*state, *Task, time.Time) erro } func normalizeSpec(spec TaskSpec, rootID string) TaskSpec { + spec.Model = strings.TrimSpace(spec.Model) spec.ID = strings.TrimSpace(strings.TrimPrefix(spec.ID, "t-")) spec.Title = strings.TrimSpace(spec.Title) spec.ParentID = strings.TrimSpace(strings.TrimPrefix(spec.ParentID, "t-")) diff --git a/internal/run/crew.go b/internal/run/crew.go index e9909cebf8..6d07e1d5a0 100644 --- a/internal/run/crew.go +++ b/internal/run/crew.go @@ -179,6 +179,9 @@ func crewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, role, _ := store.RoleOf(task.ID) tier := SeatFor(role) model := seatModel(profileDir, tier, seats) + if tier == config.ModelTierWorker && task.Model != "" { + model = task.Model + } if model == "" { return seatlessWorker{tier: tier} } diff --git a/internal/run/crew_test.go b/internal/run/crew_test.go index e856eba991..edb04cd910 100644 --- a/internal/run/crew_test.go +++ b/internal/run/crew_test.go @@ -376,3 +376,17 @@ func TestCrewFactorySeatsTheChatDoorsCheckOnTheCrewsChecker(t *testing.T) { } } } + +func TestCrewFactoryHonorsAJoinedTasksOwnModel(t *testing.T) { + store := runOpenStore(t) + if _, err := store.AddMany([]plandb.TaskSpec{{ID: "named", Title: "Read", ParentID: store.RootID(), Model: "moonshotai/kimi-k3"}}); err != nil { + t.Fatal(err) + } + dir := crewProfile(t, map[string]string{config.KeyTierWorkerModel: "deepseek/deepseek-v4.1-flash"}) + recorder := &recordingCompleter{} + factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{Work: "deepseek/deepseek-v4.1-flash"}, "", false, recorder.forModel) + factory(*store.Task("named")) + if len(recorder.models) != 1 || recorder.models[0] != "moonshotai/kimi-k3" { + t.Fatalf("models = %v", recorder.models) + } +} diff --git a/internal/session/consent.go b/internal/session/consent.go index 518258565c..a2b39a33dd 100644 --- a/internal/session/consent.go +++ b/internal/session/consent.go @@ -647,7 +647,7 @@ func consentRule(call ai.ToolCall, decision approval.Decision) string { if rule != "" && rule != "default" && rule != "default (unset)" { return rule } - if call.Function.Name == teamStartToolName { + if call.Function.Name == teamStartToolName || call.Function.Name == "start_chat" { return teamStartCost } return rule diff --git a/internal/session/manual_test.go b/internal/session/manual_test.go index 439a56c951..102b204943 100644 --- a/internal/session/manual_test.go +++ b/internal/session/manual_test.go @@ -33,6 +33,8 @@ func TestTheManualMentionsEveryToolOnTheBelt(t *testing.T) { } t.Cleanup(func() { _ = db.Close() }) agent.config.Memory = db + agent.config.Interactive = true + agent.config.Place = Place{Dir: t.TempDir()} // AND THE AUTOMATIONS STORE IS OPENED, for the shelf's reason. `automation` is // on the belt only where a store sits behind it (automation_tool.go), and every // conversation door hands one in — so a gate that built the belt without one diff --git a/internal/session/session.go b/internal/session/session.go index a6a08e2df7..3200b97db3 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -642,6 +642,8 @@ const ( // in this turn — saved, paused, resumed, deleted, asked to run now. It is a // report and never a question. EventAutomationUpdate + // EventChatStart asks the attached terminal to open an independent chat. + EventChatStart ) // TaskReplyTag is the task identity a surface places beside the answer its @@ -683,6 +685,7 @@ type TaskReplyTag struct { // today — sees all of it in order and needs no second rule; a caller that stops // at the terminal event stops at the terminal event of the FIRST turn. type Event struct { + ChatStart *ChatStart `json:",omitempty"` ReplayCursor ReplayCursor `json:",omitempty"` ReplayObserved bool `json:"-"` diff --git a/internal/session/start_chat_tool_test.go b/internal/session/start_chat_tool_test.go new file mode 100644 index 0000000000..3bbcc5a17a --- /dev/null +++ b/internal/session/start_chat_tool_test.go @@ -0,0 +1,56 @@ +package session + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +func TestStartChatResolvesModelAndEmitsOnlyARequest(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("new-chat", "start_chat", `{"brief":"Read README","model":"kimi-k3"}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("Requested."), nil }, + }} + agent, _ := newTestAgent(t, completer, func(c *Config) { + c.Interactive = true + c.Place = Place{Dir: t.TempDir()} + c.TaskModels = func() []string { return []string{"moonshotai/kimi-k3", "deepseek/deepseek-v4.1-flash"} } + }) + if _, failed := agent.loadCapability("chats"); failed { + t.Fatal("chat capability missing") + } + stream, err := agent.Submit(t.Context(), "Start a chat on kimi-k3") + if err != nil { + t.Fatal(err) + } + var request *ChatStart + for _, event := range collect(t, stream) { + if event.Kind == EventChatStart { + request = event.ChatStart + } + } + if request == nil || request.Model != "moonshotai/kimi-k3" || request.Brief != "Read README" { + t.Fatalf("request=%+v", request) + } + if agent.Model() != "test/model" { + t.Fatal("request changed parent model") + } +} + +func TestStartChatRefusesUnknownAndAmbiguousModels(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.TaskModels = func() []string { return []string{"moonshotai/kimi-k3", "moonshotai/kimi-k3.1-pro"} } + }) + for _, word := range []string{"missing/model", "kimi"} { + args, _ := json.Marshal(map[string]string{"brief": "Read README", "model": word}) + answer, failed, err := agent.startChatTool(t.Context(), args) + if err != nil || !failed || strings.Contains(answer, "Requested a new chat") { + t.Fatalf("%s: %s %v", word, answer, err) + } + } +} diff --git a/internal/session/task.go b/internal/session/task.go index b334070e9c..9bf6c87f25 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -234,7 +234,7 @@ var taskSchemaJSON = `{"type":"object","properties":{` + checksSchemaJSON + `,` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids that must finish first, only ones propose_task returned in this session. Its brief is given their reports; an unknown or failed id refuses the proposal"},` + `"wide":{"type":"boolean","description":"Optional. True when the work is wider than one pair of hands. Say true whenever you judged it broad; a wrong true costs nothing"},` + - `"model":{"type":"string","description":"Optional, only where the person asked for one: a catalog id or part of one, never a class word, so resolve \"fast\" to a concrete model. A word fitting several is shown to the person to settle"},` + + `"model":{"type":"string","description":"One-task override of task.model and crew. Catalog ID or unique name, only when requested; ambiguous names are offered for confirmation."},` + `"effort":{"type":"string","enum":["best","cheap"],"description":"Only when the person asked"},` + taskViaSchemaJSON + `"max_steps":{"type":"integer","description":"Optional. Finished tool calls per progress checkpoint (default ` + strconv.Itoa(taskMaxSteps) + `); work still advancing is given more."},` + diff --git a/internal/session/task_launch_model_test.go b/internal/session/task_launch_model_test.go new file mode 100644 index 0000000000..5dbd9b284a --- /dev/null +++ b/internal/session/task_launch_model_test.go @@ -0,0 +1,85 @@ +package session + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/teams" +) + +func TestProposalNamedModelReachesRunWithoutCrewRouter(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + agent := batchAgent(t, t.TempDir()) + var args taskArguments + if err := json.Unmarshal(beltProposalArgs("Read the parser", "report received"), &args); err != nil { + t.Fatal(err) + } + args.Model = "moonshotai/kimi-k3" + body, _ := json.Marshal(args) + answer, failed, err := approveBeltProposal(t, agent, body) + if err != nil || failed { + t.Fatalf("proposal: %s, %v", answer, err) + } + <-double.entered + double.mu.Lock() + got := double.spec.WorkModel + double.mu.Unlock() + endBeltRun(t, agent, double) + if got != args.Model { + t.Fatalf("worker model = %q, want %q", got, args.Model) + } +} + +func TestTeamStartCarriesRequestedModel(t *testing.T) { + fixture := newTeamFixture(t, true) + agent := teamAgent(t, fixture, fixture.manager, nil, func(c *Config) { + c.TaskModels = func() []string { return []string{"moonshotai/kimi-k3", "deepseek/deepseek-v4.1-flash"} } + }) + answer, failed, err := agent.teamStartTool(t.Context(), json.RawMessage(`{"handle":"scout","brief":"Report ready","model":"kimi-k3"}`)) + if failed || err != nil { + t.Fatalf("team_start: %s, %v", answer, err) + } + rows, err := teams.ReadTraffic(fixture.profile, fixture.teamID, "", 100) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(rows) + if !strings.Contains(string(raw), `"model":"moonshotai/kimi-k3"`) { + t.Fatalf("start lost requested model: %s", raw) + } +} + +func TestJoinedProposalRetainsItsOwnModel(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + dir := t.TempDir() + agent := batchAgent(t, dir) + if _, _, _, err := agent.StartTask(t.Context(), "read the seed", false); err != nil { + t.Fatal(err) + } + var args taskArguments + _ = json.Unmarshal(beltProposalArgs("Read separately", "report ready"), &args) + args.Model = "moonshotai/kimi-k3" + body, _ := json.Marshal(args) + answer, failed, err := approveBeltProposal(t, agent, body) + if err != nil || failed { + t.Fatalf("proposal: %s %v", answer, err) + } + store := beltRunStoreAt(t, dir) + defer store.Close() + var recorded string + for _, task := range store.Tasks() { + if task.Title == args.Title { + raw, _ := json.Marshal(task) + recorded = string(raw) + } + } + endBeltRun(t, agent, double) + if !strings.Contains(recorded, `"model":"moonshotai/kimi-k3"`) { + t.Fatalf("joined task lost model: %s", recorded) + } +} diff --git a/internal/session/task_quick.go b/internal/session/task_quick.go index 752d7ad085..017186f263 100644 --- a/internal/session/task_quick.go +++ b/internal/session/task_quick.go @@ -345,12 +345,12 @@ var quickTaskDescription = "A task gets its own copy of the folder, is checked, // their model is already holding. const quickTaskSchemaJSON = `{"type":"object","properties":{` + `"line":{"type":"string","description":"What to do, one sentence"},` + - `"items":{"type":"array","items":{"type":"string"},"description":"Ordered steps it works through and ticks off"},` + + `"items":{"type":"array","items":{"type":"string"},"description":"Ordered steps to complete and tick"},` + `"files":{"type":"array","items":{"type":"string"},"description":"Paths it will write. Two claiming one path run one after the other; named none, it writes anywhere"},` + `"inherit":{"type":"boolean","description":"Opens on this conversation"},` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids whose result it needs"},` + - `"title":{"type":"string","description":"Row title; the line is used without one"},` + - `"model":{"type":"string","description":"ONLY where the person named a model"}` + + `"title":{"type":"string","description":"Row title; defaults to line"},` + + `"model":{"type":"string","description":"Overrides task.model/crew on request"}` + `},"required":["line"],"additionalProperties":false}` // quickArguments is the wire form. diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 8dfcb0f67e..1b24502641 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -432,6 +432,8 @@ type beltRun struct { thinking effort.Rung carry *programCarry crewEffort crewroute.Effort + // worker is the explicit per-task choice, retained even without a router. + worker string // crew is routed for an ordinary task. A program keeps its requested // models and run ceiling instead of receiving a task router's seats. crew *taskCrew @@ -797,6 +799,7 @@ func (a *Agent) prepareBeltRunStart(ctx context.Context, id uint64, title, brief // beyond its brief ([programWish]) and the crew word it carried ([crewWish]), // so a held run that is readied later still has them. func (run *beltRun) wishedFrom(ctx context.Context) { + run.worker = crewWishOf(ctx).worker if run.delegate == nil { return } @@ -904,7 +907,7 @@ func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, titl return nil, standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} } _, err := live.store.AddMany([]plandb.TaskSpec{{ - ID: storeID, ParentID: live.root, Title: title, Description: brief, Dependencies: dependencies, + ID: storeID, ParentID: live.root, Title: title, Description: brief, Dependencies: dependencies, Model: crewWishOf(ctx).worker, }}) if err == nil { live.joined = append(live.joined, id) @@ -1022,6 +1025,9 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { if run.delegate != nil && workSeat == "" { workSeat = programCrew.Hands } + if run.worker != "" { + workSeat = run.worker + } checkSeat := "" // A ROUTED RUN IS SEATED ON ITS OWN CREW, all three seats, and the check // seat is the checker the router picked for this task — never the plan @@ -2630,12 +2636,12 @@ func (run *beltRun) crewDecision() *crewroute.Decision { } // crewWorker is the routed worker's id, the model the run's row names; empty -// when the run was not routed. +// when neither routing nor a proposal named one. func (run *beltRun) crewWorker() string { if d := run.crewDecision(); d != nil { return d.Seat(crewroute.Worker).Model } - return "" + return run.worker } // initialCrewRecord records the accepted seats even when routing is disabled. @@ -2643,7 +2649,11 @@ func (a *Agent) initialCrewRecord(run *beltRun) *TaskCrewRecord { if run.crew != nil { return run.crew.record() } - return a.unroutedCrewRecord() + record := a.unroutedCrewRecord() + if run.worker != "" && record.OneModel == "" { + record.Work = run.worker + } + return record } // carryRunIdentity fills only absent identity fields from the matching row. diff --git a/internal/session/team_nest.go b/internal/session/team_nest.go index d0174d38e2..ab31c475b0 100644 --- a/internal/session/team_nest.go +++ b/internal/session/team_nest.go @@ -146,7 +146,7 @@ func (e errNest) Error() string { return string(e) } // teamStartSubTeam makes the child team under team, moves in the members // named, and writes the start that opens its manager. It answers what to hand // the model and whether that is a refusal. -func (a *Agent) teamStartSubTeam(team teams.Team, role teamRole, handle, brief, name string, moving []string) (string, bool) { +func (a *Agent) teamStartSubTeam(team teams.Team, role teamRole, handle, brief, name string, moving []string, model string) (string, bool) { profile := a.config.teamProfile() name = strings.Join(strings.Fields(name), " ") if name == "" { @@ -248,7 +248,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, Approval: a.ResolvedApprovalPosture()} + start := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief, Team: childID, Approval: a.ResolvedApprovalPosture(), Model: model} startID, err := teams.AppendTrafficID(profile, team.ID, start) if 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/tools.go b/internal/session/tools.go index c0387a8cbf..85b1a11642 100644 --- a/internal/session/tools.go +++ b/internal/session/tools.go @@ -213,6 +213,7 @@ func (a *Agent) belt() []bare.Tool { // (task_quick.go). The judge that decides between the two is written once, in // its description. tools = append(tools, a.quickTools()...) + tools = append(tools, a.startChatTools()...) // use_skill rides on the same predicate as propose_task, plus a store to read // the shelf from: a worker that may hand work out may also look up what this // project already knows how to do (tools_skill.go). A floor node is handed no diff --git a/internal/session/tools_capabilities.go b/internal/session/tools_capabilities.go index 5e174e5b19..b375748619 100644 --- a/internal/session/tools_capabilities.go +++ b/internal/session/tools_capabilities.go @@ -76,6 +76,9 @@ type capabilityGroup struct { // stays carried and the other making verbs do not: looking at something the // person has just put in front of you is ordinary work, and making a film is not. var capabilityGroups = []capabilityGroup{{ + name: "chats", + members: []string{"start_chat"}, +}, { name: questionsGroup, members: []string{"ask"}, }, { diff --git a/internal/session/tools_start_chat.go b/internal/session/tools_start_chat.go new file mode 100644 index 0000000000..3d2bda02db --- /dev/null +++ b/internal/session/tools_start_chat.go @@ -0,0 +1,63 @@ +package session + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +// ChatStart is a request to the attached terminal, which owns conversation doors. +// It travels with the live turn rather than making the engine import a surface. +type ChatStart struct { + ID string + Workspace string + Model string + Brief string + Approval string +} + +func (a *Agent) startChatTools() []bare.Tool { + if !a.config.Interactive || a.config.InTask || strings.TrimSpace(a.config.Place.Dir) == "" { + return nil + } + return []bare.Tool{{Name: "start_chat", Description: "Request another independent chat in the attached terminal, keeping this chat in front. Give its complete first message and, when the person names one, its model. The terminal reports whether it opened; this receipt only confirms the request. Use team_start for a teammate, quick_task for a result returned here, or propose_task for checked work in its own copy.", Schema: json.RawMessage(`{"type":"object","properties":{"brief":{"type":"string","description":"Complete first message for the new chat."},"model":{"type":"string","description":"Catalog ID or unique model name requested for this chat. Omit to use this chat's model."}},"required":["brief"],"additionalProperties":false}`), Execute: a.startChatTool}} +} + +func (a *Agent) startChatTool(ctx context.Context, args json.RawMessage) (string, bool, error) { + var parsed struct { + Brief string `json:"brief"` + Model string `json:"model"` + } + if err := decodeToolArguments(args, &parsed); err != nil { + return invalidArgumentsPrefix + err.Error(), true, nil + } + brief := strings.TrimSpace(parsed.Brief) + if brief == "" { + return invalidArgumentsPrefix + "brief is empty", true, nil + } + model := a.Model() + if word := strings.TrimSpace(parsed.Model); word != "" { + choice := a.resolveTaskModel(word) + if choice.problem != "" { + return choice.problem, true, nil + } + if len(choice.options) > 0 { + return "Name one exact model: " + strings.Join(choice.options, ", "), true, nil + } + model = choice.model + } + a.mu.Lock() + hub := a.hub + a.mu.Unlock() + if hub == nil { + return "No attached turn can deliver a new-chat request.", true, nil + } + request := &ChatStart{ID: a.journalID() + ":" + strconv.FormatInt(time.Now().UnixNano(), 10), Workspace: a.config.Workspace, Model: model, Brief: brief, Approval: a.ResolvedApprovalPosture()} + hub.send(Event{Kind: EventChatStart, ChatStart: request}) + return fmt.Sprintf("Requested a new chat on %s. The attached terminal will report whether it opened.", model), false, nil +} diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go index 0774cf706c..14c616f10e 100644 --- a/internal/session/tools_team.go +++ b/internal/session/tools_team.go @@ -114,6 +114,7 @@ const teamStartDescription = "Start a new member conversation in this team with const teamStartSchema = `{"type":"object","properties":{"handle":{"type":"string","description":"2 to 12 lowercase letters or digits, unique in the team. For kind team, the new team's manager."},` + `"brief":{"type":"string","description":"The whole assignment, as its first message."},` + + `"model":{"type":"string","description":"Model for this new conversation, when the person names one; catalog ID or unique name. Omit for the profile default."},` + `"kind":{"type":"string","enum":["member","team"],"description":"Default member."},` + `"name":{"type":"string","description":"kind team: the new team's name."},` + `"members":{"type":"array","items":{"type":"string"},"description":"kind team: handles of your members to move into it."},` + @@ -657,6 +658,7 @@ func (a *Agent) teamStartTool(ctx context.Context, args json.RawMessage) (string Kind string `json:"kind"` Name string `json:"name"` Members []string `json:"members"` + Model string `json:"model"` Team string `json:"team"` } if err := decodeToolArguments(args, &parsed); err != nil { @@ -679,8 +681,19 @@ func (a *Agent) teamStartTool(ctx context.Context, args json.RawMessage) (string if refusal != "" { return refusal, true, nil } + model := "" + if word := strings.TrimSpace(parsed.Model); word != "" { + choice := a.resolveTaskModel(word) + if choice.problem != "" { + return choice.problem, true, nil + } + if len(choice.options) > 0 { + return "Name one exact model: " + strings.Join(choice.options, ", "), true, nil + } + model = choice.model + } if strings.TrimSpace(parsed.Kind) == "team" { - said, failed := a.teamStartSubTeam(team, role, handle, brief, parsed.Name, parsed.Members) + said, failed := a.teamStartSubTeam(team, role, handle, brief, parsed.Name, parsed.Members, model) return said, failed, nil } if strings.TrimSpace(parsed.Name) != "" || len(parsed.Members) > 0 { @@ -692,7 +705,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, Approval: a.ResolvedApprovalPosture()} + entry := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief, Approval: a.ResolvedApprovalPosture(), Model: model} id, err := teams.AppendTrafficID(a.config.teamProfile(), team.ID, entry) if err != nil { return "The start could not be written to the team's traffic: " + err.Error(), true, nil diff --git a/internal/teams/traffic.go b/internal/teams/traffic.go index 67e1c8dfe6..ddcafd309f 100644 --- a/internal/teams/traffic.go +++ b/internal/teams/traffic.go @@ -115,6 +115,8 @@ type Entry struct { // Approval is, on a [KindStart], the approval posture the started // conversation inherits from the manager unless overridden. Approval string `json:"approval,omitempty"` + // Model is the resolved model requested for this new conversation. + Model string `json:"model,omitempty"` } // trafficRotateBytes is the size past which the log starts a new file. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index bc0a92cc67..f7d34f14b0 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -885,6 +885,7 @@ type ( ) type app struct { + chatStarts map[string]bool conversationRequest uint64 conversationOpening bool conversationPreview *conversationPreview @@ -6441,6 +6442,8 @@ func (a *app) applyEvent(ev session.Event, lump bool) tea.Cmd { // reducer would pass tests and the task room and never reach the chat. a.ingestStream(ev, lump) switch ev.Kind { + case session.EventChatStart: + after = a.startRequestedChat(ev.ChatStart) case session.EventTextDelta, session.EventThinking, session.EventReasoning: a.lastDelta = time.Now() diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index 52ecf135cb..bacba74923 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -97,7 +97,8 @@ type kept struct { // since been closed finds nothing in the map and does nothing at all — the same // shape a stale generation has, needing no new rule. type behindStirMsg struct { - key string + key string + chatStart *session.ChatStart // quiet is a stir that is ONLY a redraw: the conversation said something // that changes what it is CALLED and nothing about what it is doing. It is a // separate bit rather than a second lane because the banner rules @@ -615,6 +616,13 @@ func (w *behindWatch) run() { // session's own arrangement: a wake is handed over between turns. replaceTurn(stream, nil) case ev, ok := <-turn: + if ok && ev.Kind == session.EventChatStart && ev.ChatStart != nil { + select { + case w.out <- behindStirMsg{key: w.key, chatStart: ev.ChatStart}: + case <-w.quit: + return + } + } if ok && ev.Kind == session.EventNotice && ev.Unrecorded && ev.Text != "" { w.holdTurnNotice(ev.Text) } @@ -682,6 +690,9 @@ func (a *app) stirLane() tea.Cmd { // a stir about it is to do nothing. func (a *app) behindStir(note behindStirMsg) tea.Cmd { next := waitStir(a.stirs) + if note.chatStart != nil { + return tea.Batch(next, a.startRequestedChat(note.chatStart)) + } held := a.behind[note.key] if held == nil { return next diff --git a/internal/tui3/startchat.go b/internal/tui3/startchat.go new file mode 100644 index 0000000000..be10dbea2b --- /dev/null +++ b/internal/tui3/startchat.go @@ -0,0 +1,67 @@ +package tui3 + +import ( + "context" + "fmt" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" +) + +// startRequestedChat keeps the requesting conversation in front. The new model +// and approvals are applied off the UI loop before the first message is sent. +func (a *app) startRequestedChat(request *session.ChatStart) tea.Cmd { + if request == nil || request.ID == "" { + return nil + } + if a.chatStarts == nil { + a.chatStarts = map[string]bool{} + } + if a.chatStarts[request.ID] { + return nil + } + a.chatStarts[request.ID] = true + if a.shared || a.start == nil || !a.canStart() { + a.note("could not start chat · " + newUnavailableWord) + return nil + } + start := a.start + return a.besideLine(func() func(bool) tea.Cmd { + conv, err := start(request.Workspace) + var events <-chan session.Event + if err == nil && conv.Agent == nil { + err = fmt.Errorf("the new conversation has no agent") + } + if err == nil { + conv.Agent.SetModel(request.Model) + if request.Approval != "" { + if setter, ok := conv.Agent.(interface{ SetApprovalPosture(string) error }); ok { + err = setter.SetApprovalPosture(request.Approval) + } else { + err = fmt.Errorf("the new conversation cannot inherit approvals") + } + } + } + if err == nil { + events, err = conv.Agent.Submit(context.Background(), request.Brief) + } + if err != nil && conv.Agent != nil { + _ = conv.Agent.Close() + } + return func(bool) tea.Cmd { + if err != nil { + a.note("could not start chat · " + err.Error()) + return nil + } + cmd := a.stow(conv, nil) + key := a.convKey(conv.SessionFile) + if held := a.behind[key]; held != nil { + held.watch.adopt(events, nil) + } + a.trafficBehindTop(key) + a.chatTabBar = tabBar{} + a.note("started chat · " + request.Model) + return cmd + } + }) +} diff --git a/internal/tui3/startchat_test.go b/internal/tui3/startchat_test.go new file mode 100644 index 0000000000..ab0ec29159 --- /dev/null +++ b/internal/tui3/startchat_test.go @@ -0,0 +1,46 @@ +package tui3 + +import ( + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" + "path/filepath" + "testing" +) + +func TestStartedTeammateUsesNamedModelBeforeWaking(t *testing.T) { + a, harbor, _, _ := trafficApp(t) + var fresh *postureTestAgent + a.start = func(where string) (Conversation, error) { + fresh = &postureTestAgent{fakeAgent: fakeAgent{model: "deepseek/deepseek-v4.1-flash"}} + return Conversation{Agent: fresh, SessionFile: filepath.Join(t.TempDir(), "transcript.jsonl"), Workspace: where}, nil + } + trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, To: "scout", Text: "report ready", Model: "moonshotai/kimi-k3", Approval: session.PostureAllow}) + trafficReadNow(t, a) + trafficReadNow(t, a) + if fresh == nil || fresh.Model() != "moonshotai/kimi-k3" { + t.Fatalf("new chat did not take named model: %+v", fresh) + } +} + +func TestRequestedChatKeepsFocusAndUsesNamedModel(t *testing.T) { + a, _, _ := tabApp(t) + front := a.frontTabKey() + fresh := &postureTestAgent{fakeAgent: fakeAgent{model: "deepseek/deepseek-v4.1-flash"}} + a.start = func(where string) (Conversation, error) { + return Conversation{Agent: fresh, SessionFile: filepath.Join(t.TempDir(), "transcript.jsonl"), Workspace: where}, nil + } + request := &session.ChatStart{ID: "request-one", Workspace: a.workspace, Model: "moonshotai/kimi-k3", Brief: "Report ready", Approval: session.PostureAllow} + drive(t, a, runCmd(a.startRequestedChat(request))...) + if fresh.Model() != request.Model { + t.Fatalf("new chat model = %s", fresh.Model()) + } + if a.frontTabKey() != front { + t.Fatal("start stole focus") + } + if fresh.posture != session.PostureAllow { + t.Fatal("start lost approvals") + } + if a.startRequestedChat(request) != nil { + t.Fatal("duplicate request started another chat") + } +} diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index 3b0aafa7b7..063448042d 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -491,6 +491,10 @@ func (a *app) trafficStart(t team, e teamstore.Entry) tea.Cmd { start, where, id := a.start, a.teamWhere(t), t.ID return a.besideLine(func() func(bool) tea.Cmd { conv, err := start(where) + // Seat the conversation before membership can wake its first turn. + if err == nil && conv.Agent != nil && e.Model != "" { + conv.Agent.SetModel(e.Model) + } // 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 != "" { From a8075d47444d9e1f7127fb7bb28ddc9ae3a3de4d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 09:02:31 -0400 Subject: [PATCH 2/7] Keep task model overrides through splits and require completed live child results --- .../unreleased/1934-chat-launch-model.md | 1 + internal/e2e/launch_models_e2e_test.go | 105 +++++++++++------- internal/manual/chat/tasks.md | 4 +- internal/manual/chat/worker-harness.md | 3 +- internal/plandb/model.go | 3 +- internal/run/crew.go | 20 +++- internal/run/crew_test.go | 18 ++- internal/session/task_run_belt.go | 4 +- 8 files changed, 103 insertions(+), 55 deletions(-) diff --git a/docs/changes/unreleased/1934-chat-launch-model.md b/docs/changes/unreleased/1934-chat-launch-model.md index 813eed1be7..3019a1a155 100644 --- a/docs/changes/unreleased/1934-chat-launch-model.md +++ b/docs/changes/unreleased/1934-chat-launch-model.md @@ -15,6 +15,7 @@ Unknown or ambiguous model names are refused before a chat opens. The start rece confirms the request; the terminal reports whether the new chat actually opened. Named task workers survive plan-store persistence, including tasks sharing one run. +Work descendants inherit the nearest named model when a task splits. Planning and checking keep their crew seats. The one-model launch option continues to force every task seat onto the conversation model. diff --git a/internal/e2e/launch_models_e2e_test.go b/internal/e2e/launch_models_e2e_test.go index 2823613c31..8a2f263e96 100644 --- a/internal/e2e/launch_models_e2e_test.go +++ b/internal/e2e/launch_models_e2e_test.go @@ -70,11 +70,16 @@ func TestChatLaunchModels(t *testing.T) { raw, _ := os.ReadFile(filepath.Join(home, "logs", "calls.jsonl")) for _, line := range strings.Split(string(raw), "\n") { var row map[string]any - if json.Unmarshal([]byte(line), &row) == nil && row["model"] == other && row["phase"] != "start" { + if json.Unmarshal([]byte(line), &row) == nil && row["model"] == other && row["phase"] != "start" && row["status"] == float64(200) { found = true } } - if found { + wantedTool := kind + if kind == "joined_task" { + wantedTool = "propose_task" + } + _, reported, _ := launchJournalEvidence(home, wantedTool, other) + if found && reported { break } time.Sleep(pollEvery) @@ -84,49 +89,13 @@ func TestChatLaunchModels(t *testing.T) { if kind == "joined_task" { wantedTool = "propose_task" } - matchingCalls := 0 - reported := false - joined := false - _ = filepath.WalkDir(home, func(path string, entry os.DirEntry, err error) error { - if err != nil || entry.IsDir() || !strings.HasSuffix(path, ".jsonl") { - return nil - } - raw, _ := os.ReadFile(path) - for _, line := range strings.Split(string(raw), "\n") { - if strings.Contains(line, "It joined the work already underway") { - joined = true - } - var row struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []struct { - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - } `json:"toolCalls"` - } - if json.Unmarshal([]byte(line), &row) != nil { - continue - } - for _, call := range row.ToolCalls { - var params struct { - Model string `json:"model"` - } - _ = json.Unmarshal([]byte(call.Function.Arguments), ¶ms) - if call.Function.Name == wantedTool && params.Model == other { - matchingCalls++ - } - } - if row.Role == "assistant" && strings.Contains(row.Content, "LAUNCH_MODEL_VERIFIED") && len(row.ToolCalls) == 0 { - reported = true - } - } - return nil - }) + matchingCalls, reported, joined := launchJournalEvidence(home, wantedTool, other) if kind == "joined_task" && !joined { t.Error("second proposal did not join the first run") } + if !reported { + t.Error("requested-model child did not complete its answer") + } if matchingCalls == 0 { t.Errorf("no %s call explicitly selected %s", wantedTool, other) } @@ -170,3 +139,55 @@ func TestChatLaunchModels(t *testing.T) { }) } } + +// launchJournalEvidence distinguishes the child model from a parent quoting its +// instructions. For a new chat, the target model must occur in another journal. +func launchJournalEvidence(home, tool, model string) (matching int, reported, joined bool) { + _ = filepath.WalkDir(home, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() || !strings.HasSuffix(path, ".jsonl") { + return nil + } + raw, _ := os.ReadFile(path) + currentModel := "" + for _, line := range strings.Split(string(raw), "\n") { + var row struct { + Type string `json:"type"` + Model string `json:"model"` + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []struct { + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"toolCalls"` + } + if json.Unmarshal([]byte(line), &row) != nil { + continue + } + if row.Model != "" && (row.Type == "session" || row.Type == "model") { + currentModel = row.Model + } + if strings.Contains(row.Content, "It joined the work already underway") { + joined = true + } + for _, call := range row.ToolCalls { + var params struct { + Model string `json:"model"` + } + _ = json.Unmarshal([]byte(call.Function.Arguments), ¶ms) + if call.Function.Name == tool && params.Model == model { + matching++ + } + if currentModel == model && call.Function.Name == "bash" && strings.Contains(call.Function.Arguments, "plandb done") && strings.Contains(call.Function.Arguments, "LAUNCH_MODEL_VERIFIED") { + reported = true + } + } + if currentModel == model && row.Role == "assistant" && strings.Contains(row.Content, "LAUNCH_MODEL_VERIFIED") && len(row.ToolCalls) == 0 { + reported = true + } + } + return nil + }) + return +} diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 3d3b9e8006..55239069c0 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -4380,7 +4380,9 @@ The day turns over at midnight on this machine's clock, and the spend starts aga The chat sets `model` on `propose_task` (or `quick_task` on the node task engine). That argument overrides `task.model` and the crew worker for this task only; it does -not change a profile pin or the conversation model. Ask for a model in the task request, +not change a profile pin or the conversation model. Work descendants inherit the +nearest named model if the task splits; planning and checking keep their crew seats. +Ask for a model in the task request, not a global `/crew pin` workaround. You ask in words — "let opus do this one", "run that on gpt-5". There is no key, command or diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 1229f61b7f..610707f1da 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -849,7 +849,8 @@ named on a door or in the profile: `/crew pin`, then the worker the crew picks for this task; a `/task` in a conversation takes the worker its crew picked for it, unless the chat names a model in `propose_task`: that model overrides `task.model` and the crew worker for this - task only. No `/crew pin` is needed. A root is born a leaf, so its first + task and its work descendants only. Planning and checking keep their crew seats. + No `/crew pin` is needed. A root is born a leaf, so its first launch rides this seat, and so does every task the plan adds under it. - **`--plan-model` is the plan seat** — the model the root and every task that has children run their coordinating turns on. `codeaf do` resolves it the same diff --git a/internal/plandb/model.go b/internal/plandb/model.go index ba0ec7b283..33f0cda8ae 100644 --- a/internal/plandb/model.go +++ b/internal/plandb/model.go @@ -65,7 +65,8 @@ type TaskSpec struct { ID string `json:"id"` Title string `json:"title"` Description string `json:"description,omitempty"` - // Model overrides the work seat for this task; planning and checks keep their seats. + // Model overrides the work seat for this task and its descendants. + // Planning and checks keep their seats; a nearer override wins. Model string `json:"model,omitempty"` Question string `json:"question,omitempty"` Kind string `json:"kind,omitempty"` diff --git a/internal/run/crew.go b/internal/run/crew.go index 6d07e1d5a0..05cc02339c 100644 --- a/internal/run/crew.go +++ b/internal/run/crew.go @@ -179,8 +179,10 @@ func crewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, role, _ := store.RoleOf(task.ID) tier := SeatFor(role) model := seatModel(profileDir, tier, seats) - if tier == config.ModelTierWorker && task.Model != "" { - model = task.Model + if tier == config.ModelTierWorker { + if named := taskWorkModel(store, task); named != "" { + model = named + } } if model == "" { return seatlessWorker{tier: tier} @@ -194,6 +196,20 @@ func crewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, } } +// taskWorkModel keeps a named work model through splits. Coordinators and checks +// retain their own seats; work leaves use the nearest explicit ancestor override. +func taskWorkModel(store *plandb.Store, task plandb.Task) string { + for current := &task; current != nil; current = store.Task(current.ParentID) { + if current.Model != "" { + return current.Model + } + if current.ParentID == "" { + break + } + } + return "" +} + // seatModel is the model a task riding tier is seated on, and the one answer // both the crew's workers and a delegated program's model API read. // diff --git a/internal/run/crew_test.go b/internal/run/crew_test.go index edb04cd910..e5bc8b73ac 100644 --- a/internal/run/crew_test.go +++ b/internal/run/crew_test.go @@ -379,14 +379,22 @@ func TestCrewFactorySeatsTheChatDoorsCheckOnTheCrewsChecker(t *testing.T) { func TestCrewFactoryHonorsAJoinedTasksOwnModel(t *testing.T) { store := runOpenStore(t) - if _, err := store.AddMany([]plandb.TaskSpec{{ID: "named", Title: "Read", ParentID: store.RootID(), Model: "moonshotai/kimi-k3"}}); err != nil { + if _, err := store.AddMany([]plandb.TaskSpec{ + {ID: "named", Title: "Read", ParentID: store.RootID(), Model: "moonshotai/kimi-k3"}, + {ID: "child", Title: "Read part", ParentID: "named"}, + {ID: "grandchild", Title: "Read detail", ParentID: "child"}, + {ID: "check", Title: "Check", ParentID: "named", Role: plandb.RoleCheck}, + }); err != nil { t.Fatal(err) } dir := crewProfile(t, map[string]string{config.KeyTierWorkerModel: "deepseek/deepseek-v4.1-flash"}) recorder := &recordingCompleter{} - factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{Work: "deepseek/deepseek-v4.1-flash"}, "", false, recorder.forModel) - factory(*store.Task("named")) - if len(recorder.models) != 1 || recorder.models[0] != "moonshotai/kimi-k3" { - t.Fatalf("models = %v", recorder.models) + factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{Work: "deepseek/deepseek-v4.1-flash", Plan: "vendor/planner", Check: "vendor/checker"}, "", false, recorder.forModel) + for _, test := range []struct{ id, want string }{{"named", "vendor/planner"}, {"child", "vendor/planner"}, {"grandchild", "moonshotai/kimi-k3"}, {"check", "vendor/checker"}} { + recorder.models = nil + factory(*store.Task(test.id)) + if len(recorder.models) != 1 || recorder.models[0] != test.want { + t.Errorf("task %s models = %v, want %s", test.id, recorder.models, test.want) + } } } diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 1b24502641..e951168668 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -1025,9 +1025,7 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { if run.delegate != nil && workSeat == "" { workSeat = programCrew.Hands } - if run.worker != "" { - workSeat = run.worker - } + workSeat = firstNonEmptyString(run.worker, workSeat) checkSeat := "" // A ROUTED RUN IS SEATED ON ITS OWN CREW, all three seats, and the check // seat is the checker the router picked for this task — never the plan From 94ec364ad6ce6e066cd3fed663b52af319d13d6b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 09:12:41 -0400 Subject: [PATCH 3/7] Claim new chat launches once across attached windows and replay --- internal/e2e/launch_models_e2e_test.go | 30 +++++++++-- .../manual/chat/conversations-and-teams.md | 4 +- internal/remote/callclass.go | 2 +- internal/remote/chatstart_test.go | 31 ++++++++++++ internal/remote/client.go | 13 +++++ internal/remote/server.go | 14 ++++++ internal/remote/wire.go | 2 + internal/session/capabilities_test.go | 4 ++ internal/session/session.go | 1 + internal/session/start_chat_tool_test.go | 50 +++++++++++++++++++ internal/session/tools_start_chat.go | 23 +++++++++ internal/tui3/app.go | 2 +- internal/tui3/keeper.go | 9 ++-- internal/tui3/salience_test.go | 2 + internal/tui3/startchat.go | 13 ++++- internal/tui3/startchat_test.go | 42 ++++++++++++++-- 16 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 internal/remote/chatstart_test.go diff --git a/internal/e2e/launch_models_e2e_test.go b/internal/e2e/launch_models_e2e_test.go index 8a2f263e96..3eeb21c08a 100644 --- a/internal/e2e/launch_models_e2e_test.go +++ b/internal/e2e/launch_models_e2e_test.go @@ -32,7 +32,10 @@ func TestChatLaunchModels(t *testing.T) { if kind == "quick_task" { launch = start } - args := []string{"chat", "--model", chat, "--no-host", "--debug"} + args := []string{"chat", "--model", chat, "--debug"} + if kind != "start_chat" { + args = append(args, "--no-host") + } if kind == "team_start" { sid := "55aa55aa55aa55aa" bucket := strings.ReplaceAll(filepath.Clean(ws), string(filepath.Separator), "-") @@ -143,15 +146,22 @@ func TestChatLaunchModels(t *testing.T) { // launchJournalEvidence distinguishes the child model from a parent quoting its // instructions. For a new chat, the target model must occur in another journal. func launchJournalEvidence(home, tool, model string) (matching int, reported, joined bool) { + sourceFiles := map[string]bool{} + resultFiles := map[string]bool{} _ = filepath.WalkDir(home, func(path string, entry os.DirEntry, err error) error { if err != nil || entry.IsDir() || !strings.HasSuffix(path, ".jsonl") { return nil } raw, _ := os.ReadFile(path) currentModel := "" + childReported := false for _, line := range strings.Split(string(raw), "\n") { var row struct { - Type string `json:"type"` + Type string `json:"type"` + Call struct { + Model string `json:"model"` + Role string `json:"role"` + } `json:"call"` Model string `json:"model"` Role string `json:"role"` Content string `json:"content"` @@ -165,6 +175,9 @@ func launchJournalEvidence(home, tool, model string) (matching int, reported, jo if json.Unmarshal([]byte(line), &row) != nil { continue } + if row.Type == "call" && row.Call.Role == "" && row.Call.Model != "" { + currentModel = row.Call.Model + } if row.Model != "" && (row.Type == "session" || row.Type == "model") { currentModel = row.Model } @@ -178,16 +191,25 @@ func launchJournalEvidence(home, tool, model string) (matching int, reported, jo _ = json.Unmarshal([]byte(call.Function.Arguments), ¶ms) if call.Function.Name == tool && params.Model == model { matching++ + sourceFiles[path] = true } if currentModel == model && call.Function.Name == "bash" && strings.Contains(call.Function.Arguments, "plandb done") && strings.Contains(call.Function.Arguments, "LAUNCH_MODEL_VERIFIED") { - reported = true + childReported = true } } if currentModel == model && row.Role == "assistant" && strings.Contains(row.Content, "LAUNCH_MODEL_VERIFIED") && len(row.ToolCalls) == 0 { - reported = true + childReported = true } } + if childReported { + resultFiles[path] = true + } return nil }) + for path := range resultFiles { + if (tool != "start_chat" && tool != "team_start") || !sourceFiles[path] { + reported = true + } + } return } diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 9e39fed372..576ca6f901 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -441,7 +441,9 @@ name is refused before any chat opens. The new chat opens behind the current one with its model and approval posture set before its first message. Your focus stays in the original chat. Its receipt confirms the request; the terminal reports whether it actually opened. This needs an attached interactive terminal that can open chats. -A new independent chat does not report its answer back here automatically; use a task +Only one attached window can claim each request, so delivery to several windows or +replay does not open copies. A failed launcher is not retried automatically; ask again +to issue a new request. A new independent chat does not report its answer back here automatically; use a task when that is the desired handoff. For a teammate, the manager's `team_start` also takes `model`, including for a new diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 691798ee6d..79dd2790f1 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -167,7 +167,7 @@ func classify(method string) callClass { MethodAutomationsList, MethodAutomationsRuns, MethodAutomationsChanges, MethodAutomationsCursor, MethodAutomationsActive, MethodAutomationsWindows: return classGetter - case MethodQuestionResolve, MethodQuestionHold, + case MethodClaimChatStart, MethodQuestionResolve, MethodQuestionHold, MethodConsent, MethodConsentRemember, MethodHarness, MethodConnect, MethodConnectKey, MethodNoteConnected, diff --git a/internal/remote/chatstart_test.go b/internal/remote/chatstart_test.go new file mode 100644 index 0000000000..d0d8fc530a --- /dev/null +++ b/internal/remote/chatstart_test.go @@ -0,0 +1,31 @@ +package remote + +import ( + "sync" + "testing" +) + +type chatClaimAgent struct { + *fakeAgent + claimed sync.Map +} + +func (a *chatClaimAgent) ClaimChatStart(id string) (bool, error) { + _, loaded := a.claimed.LoadOrStore(id, true) + return !loaded, nil +} + +func TestChatStartClaimCrossesTheWireOnce(t *testing.T) { + far := &chatClaimAgent{fakeAgent: &fakeAgent{}} + first := laneLoop(t, far) + second := laneLoop(t, far) + for i, client := range []*Client{first.Client, second.Client, first.Client} { + claimed, err := client.Agent().ClaimChatStart("request-one") + if err != nil { + t.Fatal(err) + } + if claimed != (i == 0) { + t.Fatalf("claim %d=%v", i, claimed) + } + } +} diff --git a/internal/remote/client.go b/internal/remote/client.go index cf011ff842..12e78dab72 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -2060,6 +2060,19 @@ func (a *Agent) ResolveConsentRemember(id uint64, allow bool, scope session.Cons // already gone. A question CAN be refused with something a person needs to read // — the work it was about finished, somebody else answered it first — and the // page draws exactly that sentence where its foot was. +// ClaimChatStart keeps launch ownership on the engine shared by all windows. +func (a *Agent) ClaimChatStart(id string) (bool, error) { + raw, err := a.c.call(nil, MethodClaimChatStart, id) + if err != nil { + return false, err + } + var claimed bool + if err := json.Unmarshal(raw, &claimed); err != nil { + return false, err + } + return claimed, nil +} + func (a *Agent) ResolveQuestion(answer session.Answer) error { _, err := a.c.call(nil, MethodQuestionResolve, QuestionArgs{Answer: answer}) return err diff --git a/internal/remote/server.go b/internal/remote/server.go index b82f8010f8..d104c9c43d 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2289,6 +2289,20 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { // including the one it is already carrying (standinglane.go). sess.watchLane(s, laneTitle) return nil, nil + case MethodClaimChatStart: + id, err := arg[string](call) + if err != nil { + return nil, err + } + door, ok := agent.(interface{ ClaimChatStart(string) (bool, error) }) + if !ok { + return nil, errors.New("engine: this session cannot claim new-chat requests") + } + claimed, err := door.ClaimChatStart(id) + if err != nil { + return nil, err + } + return json.Marshal(claimed) case MethodQuestionResolve: args, err := arg[QuestionArgs](call) if err != nil { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 3bcd0719bb..3e2bbb5a1c 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -537,6 +537,8 @@ const ( // which lane it belongs to. The per-lane frames above stay exactly as they // are: they are what an older window on the other end of this wire sends, and // this one is what a window that has the whole object sends. + // MethodClaimChatStart grants one terminal an issued chat launch. + MethodClaimChatStart = "ClaimChatStart" // string request ID → bool MethodQuestionResolve = "ResolveQuestion" // QuestionArgs → nothing (or a refusal) // MethodQuestionHold is the OTHER thing a key on a question can mean: stop // the clock, do not answer. A question with a deadline takes the asker's own diff --git a/internal/session/capabilities_test.go b/internal/session/capabilities_test.go index 28173f3cca..b2a1ce0476 100644 --- a/internal/session/capabilities_test.go +++ b/internal/session/capabilities_test.go @@ -824,6 +824,8 @@ func TestTheCapabilityTableIsWellFormed(t *testing.T) { // name nothing produces. built := map[string]bool{} wide := shelfAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Interactive = true + config.Place = Place{Dir: t.TempDir()} config.Media = &scriptedMedia{} config.MediaModel = allMediaModels() config.Memory = openTestBrain(t) @@ -868,6 +870,8 @@ func TestTheCapabilityTableIsWellFormed(t *testing.T) { // first and the parse is re-run here on the real answer. func TestALoadAnswersWithItsGroupsProseUnderAnUnchangedLoadedLine(t *testing.T) { agent := shelfAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Interactive = true + config.Place = Place{Dir: t.TempDir()} config.Media = &scriptedMedia{} config.MediaModel = allMediaModels() config.HarnessCards = true diff --git a/internal/session/session.go b/internal/session/session.go index 3200b97db3..5ca2181c9f 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2392,6 +2392,7 @@ type Config struct { // events — every Submit streams, whether it started the turn or steered it. // The methods live in agent.go; the loop they drive lives in loop.go. type Agent struct { + chatStarts map[string]bool // jailNotice keeps a host limit from becoming noise on every tool call. jailNotice sync.Once diff --git a/internal/session/start_chat_tool_test.go b/internal/session/start_chat_tool_test.go index 3bbcc5a17a..d33c3f10ac 100644 --- a/internal/session/start_chat_tool_test.go +++ b/internal/session/start_chat_tool_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "strings" + "sync" + "sync/atomic" "testing" "github.com/Agent-Field/agentfield/sdk/go/ai" @@ -54,3 +56,51 @@ func TestStartChatRefusesUnknownAndAmbiguousModels(t *testing.T) { } } } + +func TestChatStartClaimHasOneWinner(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("new-chat", "start_chat", `{"brief":"Read README"}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("Requested."), nil }, + }} + agent, _ := newTestAgent(t, completer, func(c *Config) { c.Interactive = true; c.Place = Place{Dir: t.TempDir()} }) + _, _ = agent.loadCapability("chats") + stream, err := agent.Submit(t.Context(), "Start another chat") + if err != nil { + t.Fatal(err) + } + var request *ChatStart + for _, event := range collect(t, stream) { + if event.Kind == EventChatStart { + request = event.ChatStart + } + } + if request == nil { + t.Fatal("no request") + } + door, ok := any(agent).(interface{ ClaimChatStart(string) (bool, error) }) + if !ok { + t.Fatal("no shared chat-start claim door") + } + var winners atomic.Int32 + var group sync.WaitGroup + for range 8 { + group.Go(func() { + claimed, err := door.ClaimChatStart(request.ID) + if err != nil { + t.Error(err) + } + if claimed { + winners.Add(1) + } + }) + } + group.Wait() + if winners.Load() != 1 { + t.Fatalf("launch winners=%d", winners.Load()) + } + if claimed, err := door.ClaimChatStart("unknown"); err == nil || claimed { + t.Fatal("unknown request claimed") + } +} diff --git a/internal/session/tools_start_chat.go b/internal/session/tools_start_chat.go index 3d2bda02db..6691db0bef 100644 --- a/internal/session/tools_start_chat.go +++ b/internal/session/tools_start_chat.go @@ -58,6 +58,29 @@ func (a *Agent) startChatTool(ctx context.Context, args json.RawMessage) (string return "No attached turn can deliver a new-chat request.", true, nil } request := &ChatStart{ID: a.journalID() + ":" + strconv.FormatInt(time.Now().UnixNano(), 10), Workspace: a.config.Workspace, Model: model, Brief: brief, Approval: a.ResolvedApprovalPosture()} + a.mu.Lock() + if a.chatStarts == nil { + a.chatStarts = map[string]bool{} + } + a.chatStarts[request.ID] = false + a.mu.Unlock() hub.send(Event{Kind: EventChatStart, ChatStart: request}) return fmt.Sprintf("Requested a new chat on %s. The attached terminal will report whether it opened.", model), false, nil } + +// ClaimChatStart admits one terminal to open an issued request. The claim lives +// with the source session, so broadcast delivery and replay cannot create copies. +// A lost launcher is not retried automatically: another user request can try again. +func (a *Agent) ClaimChatStart(id string) (bool, error) { + a.mu.Lock() + defer a.mu.Unlock() + claimed, known := a.chatStarts[id] + if !known { + return false, fmt.Errorf("unknown new-chat request") + } + if claimed { + return false, nil + } + a.chatStarts[id] = true + return true, nil +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index f7d34f14b0..b10b726f90 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -6443,7 +6443,7 @@ func (a *app) applyEvent(ev session.Event, lump bool) tea.Cmd { a.ingestStream(ev, lump) switch ev.Kind { case session.EventChatStart: - after = a.startRequestedChat(ev.ChatStart) + after = a.startRequestedChat(ev.ChatStart, a.agent) case session.EventTextDelta, session.EventThinking, session.EventReasoning: a.lastDelta = time.Now() diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index bacba74923..6796a600f0 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -690,13 +690,16 @@ func (a *app) stirLane() tea.Cmd { // a stir about it is to do nothing. func (a *app) behindStir(note behindStirMsg) tea.Cmd { next := waitStir(a.stirs) - if note.chatStart != nil { - return tea.Batch(next, a.startRequestedChat(note.chatStart)) - } held := a.behind[note.key] + if note.chatStart != nil && note.key == a.frontTabKey() { + return tea.Batch(next, a.startRequestedChat(note.chatStart, a.agent)) + } if held == nil { return next } + if note.chatStart != nil { + return tea.Batch(next, a.startRequestedChat(note.chatStart, held.conv.Agent)) + } a.keepHeldNotices(held) // A CONVERSATION THAT HAS JUST NAMED ITSELF IS A REDRAW AND NOTHING ELSE. // The tab reads the name off the agent on the frame ([hopRawTitle]), so the diff --git a/internal/tui3/salience_test.go b/internal/tui3/salience_test.go index ad4aeeaa57..5a349b6353 100644 --- a/internal/tui3/salience_test.go +++ b/internal/tui3/salience_test.go @@ -179,6 +179,8 @@ var salienceTable = []salienceCase{ {name: "EventAutomationUpdate", chatOnly: "automations belong to the machine and the conversation that made " + "them, not to one node's work: the line is the conversation's (automation.go)", ev: session.Event{Kind: session.EventAutomationUpdate}}, + {name: "EventChatStart", chatOnly: "only interactive conversations request new chats; task workers carry no start_chat tool", + ev: session.Event{Kind: session.EventChatStart}}, {name: "EventQuestionDiscussion", chatOnly: "a clarification belongs to the person's pending decision and arrives on the conversation's question subscription", ev: session.Event{Kind: session.EventQuestionDiscussion, Discussion: &session.QuestionDiscussion{ ID: "clarify-1", Seq: 1, Event: &session.Event{Kind: session.EventTextDelta, Text: "the command only reads the report"}, diff --git a/internal/tui3/startchat.go b/internal/tui3/startchat.go index be10dbea2b..e1af62c518 100644 --- a/internal/tui3/startchat.go +++ b/internal/tui3/startchat.go @@ -10,7 +10,7 @@ import ( // startRequestedChat keeps the requesting conversation in front. The new model // and approvals are applied off the UI loop before the first message is sent. -func (a *app) startRequestedChat(request *session.ChatStart) tea.Cmd { +func (a *app) startRequestedChat(request *session.ChatStart, owner Agent) tea.Cmd { if request == nil || request.ID == "" { return nil } @@ -27,6 +27,17 @@ func (a *app) startRequestedChat(request *session.ChatStart) tea.Cmd { } start := a.start return a.besideLine(func() func(bool) tea.Cmd { + claimer, ok := owner.(interface{ ClaimChatStart(string) (bool, error) }) + if !ok { + return func(bool) tea.Cmd { a.note("could not start chat · launch ownership unavailable"); return nil } + } + claimed, claimErr := claimer.ClaimChatStart(request.ID) + if claimErr != nil { + return func(bool) tea.Cmd { a.note("could not start chat · " + claimErr.Error()); return nil } + } + if !claimed { + return func(bool) tea.Cmd { return nil } + } conv, err := start(request.Workspace) var events <-chan session.Event if err == nil && conv.Agent == nil { diff --git a/internal/tui3/startchat_test.go b/internal/tui3/startchat_test.go index ab0ec29159..73e7dfd9f9 100644 --- a/internal/tui3/startchat_test.go +++ b/internal/tui3/startchat_test.go @@ -1,10 +1,12 @@ package tui3 import ( - "github.com/Agent-Field/codeaf/internal/session" - teamstore "github.com/Agent-Field/codeaf/internal/teams" "path/filepath" + "sync" "testing" + + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" ) func TestStartedTeammateUsesNamedModelBeforeWaking(t *testing.T) { @@ -25,12 +27,13 @@ func TestStartedTeammateUsesNamedModelBeforeWaking(t *testing.T) { func TestRequestedChatKeepsFocusAndUsesNamedModel(t *testing.T) { a, _, _ := tabApp(t) front := a.frontTabKey() + owner := &claimingChatAgent{Agent: a.agent, claimed: &sync.Map{}} fresh := &postureTestAgent{fakeAgent: fakeAgent{model: "deepseek/deepseek-v4.1-flash"}} a.start = func(where string) (Conversation, error) { return Conversation{Agent: fresh, SessionFile: filepath.Join(t.TempDir(), "transcript.jsonl"), Workspace: where}, nil } request := &session.ChatStart{ID: "request-one", Workspace: a.workspace, Model: "moonshotai/kimi-k3", Brief: "Report ready", Approval: session.PostureAllow} - drive(t, a, runCmd(a.startRequestedChat(request))...) + drive(t, a, runCmd(a.startRequestedChat(request, owner))...) if fresh.Model() != request.Model { t.Fatalf("new chat model = %s", fresh.Model()) } @@ -40,7 +43,38 @@ func TestRequestedChatKeepsFocusAndUsesNamedModel(t *testing.T) { if fresh.posture != session.PostureAllow { t.Fatal("start lost approvals") } - if a.startRequestedChat(request) != nil { + if a.startRequestedChat(request, owner) != nil { t.Fatal("duplicate request started another chat") } } + +// The session owns this claim; multiple terminal adapters share the same door. +type claimingChatAgent struct { + Agent + claimed *sync.Map +} + +func (a *claimingChatAgent) ClaimChatStart(id string) (bool, error) { + _, loaded := a.claimed.LoadOrStore(id, true) + return !loaded, nil +} + +func TestTwoWindowsOpenOnlyOneRequestedChat(t *testing.T) { + a, _, _ := tabApp(t) + b, _, _ := tabApp(t) + replay, _, _ := tabApp(t) + owner := &claimingChatAgent{Agent: a.agent, claimed: &sync.Map{}} + started := 0 + start := func(where string) (Conversation, error) { + started++ + return Conversation{Agent: &fakeAgent{}, SessionFile: filepath.Join(t.TempDir(), "transcript.jsonl"), Workspace: where}, nil + } + request := &session.ChatStart{ID: "shared-request", Workspace: a.workspace, Model: "moonshotai/kimi-k3", Brief: "Report ready"} + for _, window := range []*app{a, b, replay} { + window.start = start + drive(t, window, runCmd(window.startRequestedChat(request, owner))...) + } + if started != 1 { + t.Fatalf("one request opened %d chats", started) + } +} From 49d0e43441581d7585f6bdd946a28630a6d8d394 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 09:15:10 -0400 Subject: [PATCH 4/7] Keep note delivery acceptance independent of optional engine discovery --- internal/run/note_channel_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/run/note_channel_test.go b/internal/run/note_channel_test.go index d214b153f7..9102a52c20 100644 --- a/internal/run/note_channel_test.go +++ b/internal/run/note_channel_test.go @@ -257,6 +257,9 @@ func seatTranscript(s *seat) string { // to each while both are in flight, and each seat is asserted to have been // handed its own note and NEVER the other's. func TestTwoWorkersLiveAtOnceEachGetOnlyItsOwnNote(t *testing.T) { + // This fixture measures note delivery; optional workspace-engine discovery + // must not consume the bounded wait for the first worker command. + t.Setenv("CODEAF_FURROW", filepath.Join(t.TempDir(), "not-installed")) t.Setenv("CODEAF_TASK_BELT", "bash") t.Setenv("CODEAF_PLANDB_BIN", realPlandbDoor(t)) store := runOpenStore(t) From 0ff1062f56e29103ff81f58a2bec18d7a2e579b3 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 09:19:20 -0400 Subject: [PATCH 5/7] Accept completed teammate reports through their team-post receipt --- internal/e2e/launch_models_e2e_test.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/e2e/launch_models_e2e_test.go b/internal/e2e/launch_models_e2e_test.go index 3eeb21c08a..274c5b1c29 100644 --- a/internal/e2e/launch_models_e2e_test.go +++ b/internal/e2e/launch_models_e2e_test.go @@ -155,6 +155,7 @@ func launchJournalEvidence(home, tool, model string) (matching int, reported, jo raw, _ := os.ReadFile(path) currentModel := "" childReported := false + pendingPosts := map[string]bool{} for _, line := range strings.Split(string(raw), "\n") { var row struct { Type string `json:"type"` @@ -162,10 +163,12 @@ func launchJournalEvidence(home, tool, model string) (matching int, reported, jo Model string `json:"model"` Role string `json:"role"` } `json:"call"` - Model string `json:"model"` - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []struct { + Model string `json:"model"` + Role string `json:"role"` + Content string `json:"content"` + ToolCallID string `json:"toolCallId"` + ToolCalls []struct { + ID string `json:"id"` Function struct { Name string `json:"name"` Arguments string `json:"arguments"` @@ -184,7 +187,13 @@ func launchJournalEvidence(home, tool, model string) (matching int, reported, jo if strings.Contains(row.Content, "It joined the work already underway") { joined = true } + if row.Role == "tool" && pendingPosts[row.ToolCallID] && strings.HasPrefix(row.Content, "Posted to ") { + childReported = true + } for _, call := range row.ToolCalls { + if currentModel == model && call.Function.Name == "team_post" && strings.Contains(call.Function.Arguments, "LAUNCH_MODEL_VERIFIED") { + pendingPosts[call.ID] = true + } var params struct { Model string `json:"model"` } From 818f3247fe52d24af94eb795039fc632ffbe46b9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 09:21:46 -0400 Subject: [PATCH 6/7] Isolate hosted stop acceptance from optional workspace discovery --- internal/remote/hosted_stop_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/remote/hosted_stop_test.go b/internal/remote/hosted_stop_test.go index 8cbdf95cbc..e399f8d2fd 100644 --- a/internal/remote/hosted_stop_test.go +++ b/internal/remote/hosted_stop_test.go @@ -131,6 +131,8 @@ func hostedTaskFrame(t *testing.T, lane <-chan session.Event, id uint64, stopped // A fake remote agent would only prove its own Cancel method; this test keeps // session.Agent, Serve, Dial, the task lane and the plan store in the path. func TestHostedTasksToolStopEndsTheRunAndReportsWhatHappened(t *testing.T) { + // Stopping a run must not wait for unrelated optional workspace discovery. + t.Setenv("CODEAF_FURROW", filepath.Join(t.TempDir(), "not-installed")) t.Setenv("CODEAF_TASK_BELT", "bash") started := make(chan struct{}) session.RegisterRunEngine(hostedSlowRun{started: started}) From 28fa8021194c5b82f797c875c00b4b66d51f5c6e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 25 Oct 2026 09:30:15 -0400 Subject: [PATCH 7/7] Associate change entry with replacement review --- .../{1934-chat-launch-model.md => 1935-chat-launch-model.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{1934-chat-launch-model.md => 1935-chat-launch-model.md} (99%) diff --git a/docs/changes/unreleased/1934-chat-launch-model.md b/docs/changes/unreleased/1935-chat-launch-model.md similarity index 99% rename from docs/changes/unreleased/1934-chat-launch-model.md rename to docs/changes/unreleased/1935-chat-launch-model.md index 3019a1a155..41ab619510 100644 --- a/docs/changes/unreleased/1934-chat-launch-model.md +++ b/docs/changes/unreleased/1935-chat-launch-model.md @@ -1,7 +1,7 @@ --- kind: fixed title: Chats can name models for new chats and tasks that join an existing run -pr: 1934 +pr: 1935 surface: [chat, engine, docs] invalidates: - "A proposal joining a running task discarded its requested model and used the run's worker instead; the joined task now retains and uses its own work model."