Skip to content
23 changes: 23 additions & 0 deletions docs/changes/unreleased/1935-chat-launch-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
kind: fixed
title: Chats can name models for new chats and tasks that join an existing run
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."
- "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.
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.

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.
224 changes: 224 additions & 0 deletions internal/e2e/launch_models_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//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, "--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), "-")
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" && row["status"] == float64(200) {
found = true
}
}
wantedTool := kind
if kind == "joined_task" {
wantedTool = "propose_task"
}
_, reported, _ := launchJournalEvidence(home, wantedTool, other)
if found && reported {
break
}
time.Sleep(pollEvery)
}
cleanChatCapture(t, r, "launch-"+kind)
wantedTool := kind
if kind == "joined_task" {
wantedTool = "propose_task"
}
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)
}
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")
}
})
}
}

// 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
pendingPosts := map[string]bool{}
for _, line := range strings.Split(string(raw), "\n") {
var row struct {
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"`
ToolCallID string `json:"toolCallId"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"toolCalls"`
}
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
}
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"`
}
_ = json.Unmarshal([]byte(call.Function.Arguments), &params)
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") {
childReported = true
}
}
if currentModel == model && row.Role == "assistant" && strings.Contains(row.Content, "LAUNCH_MODEL_VERIFIED") && len(row.ToolCalls) == 0 {
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
}
19 changes: 19 additions & 0 deletions internal/manual/chat/conversations-and-teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,3 +430,22 @@ 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.
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
sub-team's manager. Omitting it keeps the profile default. Neither tool rewrites the
profile or changes the requesting chat's model.
7 changes: 7 additions & 0 deletions internal/manual/chat/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -4378,6 +4378,13 @@ 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. 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
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
Expand Down
5 changes: 4 additions & 1 deletion internal/manual/chat/worker-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -847,7 +847,10 @@ 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 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
Expand Down
9 changes: 6 additions & 3 deletions internal/plandb/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ 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 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"`
ParentID string `json:"parent_id,omitempty"`
Expand Down
15 changes: 9 additions & 6 deletions internal/plandb/persist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
}
Expand Down
1 change: 1 addition & 0 deletions internal/plandb/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-"))
Expand Down
2 changes: 1 addition & 1 deletion internal/remote/callclass.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading