Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 42 additions & 18 deletions cmd/gc/cmd_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -1116,11 +1116,10 @@ func cmdSessionAttach(args []string, stdout, stderr io.Writer) int {
//
// stderr receives projection errors (use io.Discard to ignore).
//
// sessionKind mirrors the real_world_app_session_kind bead metadata: "provider" means
// the session was created from a bare provider name (not an agent template),
// so the agent-template lookup should be skipped. This matches the guard in
// the API handler (handler_session_chat.go).
func buildResumeCommand(cityPath string, cfg *config.City, info session.Info, sessionKind string, stderr io.Writer) (string, runtime.Config) {
// sessionKind is the persisted session kind when available. A provider session
// was created from a bare provider name, so agent-template lookup must be
// skipped to avoid agent/provider name collisions.
func buildResumeCommand(cityPath string, cfg *config.City, info session.Info, sessionKind string, metadata map[string]string, stderr io.Writer) (string, runtime.Config) {
cmd := session.BuildResumeCommand(info)
if cfg == nil {
return cmd, runtime.Config{WorkDir: info.WorkDir}
Expand All @@ -1134,16 +1133,33 @@ func buildResumeCommand(cityPath string, cfg *config.City, info session.Info, se
// Build command with default args and settings, matching the
// reconciler's template_resolve.go command construction.
command := resolved.CommandString()
if defaultArgs := resolved.ResolveDefaultArgs(); len(defaultArgs) > 0 {
command = command + " " + shellquote.Join(defaultArgs)
resumeCommand := resolved.ResumeCommand
appendDefaultArgs := func() {
if defaultArgs := resolved.ResolveDefaultArgs(); len(defaultArgs) > 0 {
command = command + " " + shellquote.Join(defaultArgs)
}
}
if overrides, err := session.ParseTemplateOverrides(metadata); err == nil {
transport := strings.TrimSpace(info.Transport)
launchCommand, err := config.BuildProviderLaunchCommand(cityPath, resolved, overrides, transport)
if err == nil && strings.TrimSpace(launchCommand.Command) != "" {
command = launchCommand.Command
} else {
appendDefaultArgs()
}
if command, err := config.BuildProviderResumeCommand(resolved, overrides); err == nil && strings.TrimSpace(command) != "" {
resumeCommand = command
}
} else {
appendDefaultArgs()
}
// buildResumeCommand is best-effort: log projection failures and
// continue so `gc session attach` still starts the agent. The strict
// path is resolveTemplate at reconciler time, which fails agent
// creation on projection errors.
providerFamily := resolvedProviderLaunchFamily(resolved)
sa, saErr := ensureClaudeSettingsArgs(fsys.OSFS{}, cityPath, providerFamily, stderr)
if saErr == nil && sa != "" {
if saErr == nil && sa != "" && !storedCommandHasSettingsArg(command) {
command = command + " " + sa
} else if saErr != nil {
// Projection failed this tick. Fall back to the last-known-good
Expand All @@ -1162,7 +1178,7 @@ func buildResumeCommand(cityPath string, cfg *config.City, info session.Info, se
resolvedInfo.Provider = resolved.Name
resolvedInfo.ResumeFlag = resolved.ResumeFlag
resolvedInfo.ResumeStyle = resolved.ResumeStyle
resolvedInfo.ResumeCommand = resolved.ResumeCommand
resolvedInfo.ResumeCommand = resumeCommand
return session.BuildResumeCommand(resolvedInfo), runtime.Config{
WorkDir: info.WorkDir,
ReadyPromptPrefix: resolved.ReadyPromptPrefix,
Expand All @@ -1174,21 +1190,29 @@ func buildResumeCommand(cityPath string, cfg *config.City, info session.Info, se
}
}

// Check persisted kind to avoid agent/provider name collisions.
// If kind is "provider", skip the agent template lookup entirely.
if sessionKind != "provider" {
// Prefer the current resolved agent template/provider config over stale
// stored command text so submit/restart paths honor provider overrides.
if found, ok := resolveAgentIdentity(cfg, info.Template, ""); ok {
// Prefer the current resolved agent template/provider config over stale
// stored command text so submit/restart paths honor provider overrides.
// Use the same collision guard as the runtime resolver so provider-track
// sessions do not accidentally resolve through an agent with the same name.
found, foundAgent := resolveAgentIdentity(cfg, info.Template, "")
if session.UseAgentTemplateForProviderResolution(sessionKind, metadata, info.Provider, found.Provider, foundAgent) {
if foundAgent {
if resolved, err := config.ResolveProvider(&found, &cfg.Workspace, cfg.Providers, exec.LookPath); err == nil {
return buildResolved(resolved)
}
}
}

// Fallback for provider-only sessions whose Template is a provider name.
if resolved, err := config.ResolveProvider(&config.Agent{Provider: info.Template}, &cfg.Workspace, cfg.Providers, exec.LookPath); err == nil {
return buildResolved(resolved)
// Fallback for provider-only sessions. Prefer the persisted provider so
// resumed sessions use the same schema-backed provider selected at create.
for _, providerName := range []string{info.Provider, info.Template} {
providerName = strings.TrimSpace(providerName)
if providerName == "" {
continue
}
if resolved, err := config.ResolveProvider(&config.Agent{Provider: providerName}, &cfg.Workspace, cfg.Providers, exec.LookPath); err == nil {
return buildResolved(resolved)
}
}

return cmd, runtime.Config{WorkDir: info.WorkDir}
Expand Down
201 changes: 196 additions & 5 deletions cmd/gc/cmd_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -779,7 +779,7 @@ func TestBuildResumeCommandUsesResolvedProviderCommand(t *testing.T) {
WorkDir: "/tmp/workdir",
}

cmd, hints := buildResumeCommand(t.TempDir(), cfg, info, "", io.Discard)
cmd, hints := buildResumeCommand(t.TempDir(), cfg, info, "", nil, io.Discard)
if got, want := cmd, "aimux run gemini -- --approval-mode yolo"; got != want {
t.Fatalf("resume command = %q, want %q", got, want)
}
Expand Down Expand Up @@ -820,7 +820,7 @@ func TestBuildResumeCommandIncludesSettingsAndDefaultArgs(t *testing.T) {
ResumeFlag: "--resume",
}

cmd, _ := buildResumeCommand(cityDir, cfg, info, "", io.Discard)
cmd, _ := buildResumeCommand(cityDir, cfg, info, "", nil, io.Discard)

// Must include --settings pointing to .gc/settings.json.
wantSettings := fmt.Sprintf("--settings %q", filepath.Join(gcDir, "settings.json"))
Expand All @@ -830,6 +830,9 @@ func TestBuildResumeCommandIncludesSettingsAndDefaultArgs(t *testing.T) {
if !strings.Contains(cmd, wantSettings) {
t.Fatalf("resume command has wrong --settings path:\n got: %s\n want: ...%s...", cmd, wantSettings)
}
if got := strings.Count(cmd, "--settings"); got != 1 {
t.Fatalf("resume command has %d --settings flags, want 1:\n got: %s", got, cmd)
}

// Must include --resume flag.
if !strings.Contains(cmd, "--resume abc-123") {
Expand Down Expand Up @@ -861,12 +864,15 @@ func TestBuildResumeCommandUsesBuiltinAncestorForClaudeSettings(t *testing.T) {
WorkDir: "/tmp/workdir",
}

cmd, _ := buildResumeCommand(cityDir, cfg, info, "", io.Discard)
cmd, _ := buildResumeCommand(cityDir, cfg, info, "", nil, io.Discard)

wantSettings := fmt.Sprintf("--settings %q", filepath.Join(cityDir, ".gc", "settings.json"))
if !strings.Contains(cmd, wantSettings) {
t.Fatalf("wrapped Claude resume command missing settings:\n got: %s\n want: ...%s...", cmd, wantSettings)
}
if got := strings.Count(cmd, "--settings"); got != 1 {
t.Fatalf("wrapped Claude resume command has %d --settings flags, want 1:\n got: %s", got, cmd)
}
}

func TestBuildResumeCommandIncludesWrappedCodexResumeDefaults(t *testing.T) {
Expand Down Expand Up @@ -900,13 +906,131 @@ func TestBuildResumeCommandIncludesWrappedCodexResumeDefaults(t *testing.T) {
SessionKey: "abc-123",
}

cmd, _ := buildResumeCommand(cityDir, cfg, info, "", io.Discard)
cmd, _ := buildResumeCommand(cityDir, cfg, info, "", nil, io.Discard)
want := "aimux run codex -- --dangerously-bypass-approvals-and-sandbox -m gpt-5.3-codex-spark resume -c model_reasoning_effort=medium abc-123"
if cmd != want {
t.Fatalf("resume command = %q, want %q", cmd, want)
}
}

func TestBuildResumeCommandAppliesTemplateOverrides(t *testing.T) {
cfg := &config.City{
Workspace: config.Workspace{Name: "test-city"},
Agents: []config.Agent{
{Name: "worker", Provider: "codex-provider"},
},
Providers: map[string]config.ProviderSpec{
"codex-provider": {
Command: "codex",
ResumeFlag: "--resume",
OptionsSchema: []config.ProviderOption{
{
Key: "permission_mode",
Choices: []config.OptionChoice{
{Value: "default", FlagArgs: []string{"--ask-for-approval", "on-request"}},
{Value: "plan", FlagArgs: []string{"--ask-for-approval", "never"}},
},
},
},
},
},
}
info := session.Info{
Template: "worker",
Command: "codex --ask-for-approval on-request",
Provider: "codex-provider",
WorkDir: "/tmp/workdir",
SessionKey: "abc-123",
}

cmd, _ := buildResumeCommand(t.TempDir(), cfg, info, "", map[string]string{
"template_overrides": `{"permission_mode":"plan"}`,
}, io.Discard)
want := "codex --resume abc-123 --ask-for-approval never"
if cmd != want {
t.Fatalf("resume command = %q, want %q", cmd, want)
}
}

func TestBuildResumeCommandAppliesTemplateOverridesToExplicitResumeCommand(t *testing.T) {
cfg := &config.City{
Workspace: config.Workspace{Name: "test-city"},
Agents: []config.Agent{
{Name: "worker", Provider: "codex-provider"},
},
Providers: map[string]config.ProviderSpec{
"codex-provider": {
Command: "codex",
ResumeCommand: "codex resume {{.SessionKey}} --ask-for-approval on-request",
OptionsSchema: []config.ProviderOption{
{
Key: "permission_mode",
Choices: []config.OptionChoice{
{Value: "default", FlagArgs: []string{"--ask-for-approval", "on-request"}},
{Value: "plan", FlagArgs: []string{"--ask-for-approval", "never"}},
},
},
},
},
},
}
info := session.Info{
Template: "worker",
Command: "codex --ask-for-approval on-request",
Provider: "codex-provider",
WorkDir: "/tmp/workdir",
SessionKey: "abc-123",
}

cmd, _ := buildResumeCommand(t.TempDir(), cfg, info, "", map[string]string{
"template_overrides": `{"permission_mode":"plan"}`,
}, io.Discard)
want := "codex resume --ask-for-approval never abc-123"
if cmd != want {
t.Fatalf("resume command = %q, want %q", cmd, want)
}
}

func TestBuildResumeCommandFallsBackToDefaultArgsWhenOverridesInvalid(t *testing.T) {
cfg := &config.City{
Workspace: config.Workspace{Name: "test-city"},
Agents: []config.Agent{
{Name: "worker", Provider: "codex-provider"},
},
Providers: map[string]config.ProviderSpec{
"codex-provider": {
Command: "codex",
Args: []string{"--ask-for-approval", "on-request"},
ResumeFlag: "--resume",
OptionsSchema: []config.ProviderOption{
{
Key: "permission_mode",
Choices: []config.OptionChoice{
{Value: "default", FlagArgs: []string{"--ask-for-approval", "on-request"}},
{Value: "plan", FlagArgs: []string{"--ask-for-approval", "never"}},
},
},
},
},
},
}
info := session.Info{
Template: "worker",
Command: "codex",
Provider: "codex-provider",
WorkDir: "/tmp/workdir",
SessionKey: "abc-123",
}

cmd, _ := buildResumeCommand(t.TempDir(), cfg, info, "", map[string]string{
"template_overrides": `{"permission_mode":"invalid"}`,
}, io.Discard)
want := "codex --resume abc-123 --ask-for-approval on-request"
if cmd != want {
t.Fatalf("resume command = %q, want %q", cmd, want)
}
}

func TestBuildResumeCommandProviderKindSkipsTemplateCollision(t *testing.T) {
cfg := &config.City{
Workspace: config.Workspace{Name: "test-city"},
Expand All @@ -933,13 +1057,80 @@ func TestBuildResumeCommandProviderKindSkipsTemplateCollision(t *testing.T) {
SessionKey: "abc-123",
}

cmd, _ := buildResumeCommand(t.TempDir(), cfg, info, "provider", io.Discard)
cmd, _ := buildResumeCommand(t.TempDir(), cfg, info, "provider", nil, io.Discard)
want := "true provider --resume abc-123"
if cmd != want {
t.Fatalf("resume command = %q, want %q", cmd, want)
}
}

func TestBuildResumeCommandManualProviderMetadataSkipsTemplateCollision(t *testing.T) {
cfg := &config.City{
Workspace: config.Workspace{Name: "test-city"},
Agents: []config.Agent{
{Name: "runner", Provider: "agent-provider"},
},
Providers: map[string]config.ProviderSpec{
"runner": {
Command: "true",
Args: []string{"provider"},
ResumeFlag: "--resume",
},
"agent-provider": {
Command: "true",
Args: []string{"agent"},
ResumeFlag: "--resume",
},
},
}
info := session.Info{
Template: "runner",
Provider: "runner",
Command: "stale",
WorkDir: "/tmp/workdir",
SessionKey: "abc-123",
}

cmd, _ := buildResumeCommand(t.TempDir(), cfg, info, "", map[string]string{
"session_origin": "manual",
}, io.Discard)
want := "true provider --resume abc-123"
if cmd != want {
t.Fatalf("resume command = %q, want %q", cmd, want)
}
}

func TestBuildResumeCommandProviderKindPrefersPersistedProvider(t *testing.T) {
cfg := &config.City{
Workspace: config.Workspace{Name: "test-city"},
Providers: map[string]config.ProviderSpec{
"stored-provider": {
Command: "true",
Args: []string{"stored"},
ResumeFlag: "--resume",
},
"template-provider": {
Command: "true",
Args: []string{"template"},
ResumeFlag: "--resume",
},
},
}
info := session.Info{
Template: "template-provider",
Provider: "stored-provider",
Command: "stale",
WorkDir: "/tmp/workdir",
SessionKey: "abc-123",
}

cmd, _ := buildResumeCommand(t.TempDir(), cfg, info, "provider", nil, io.Discard)
want := "true stored --resume abc-123"
if cmd != want {
t.Fatalf("resume command = %q, want %q", cmd, want)
}
}

func TestSessionReason_FallsThroughToProviderForSleepingAttachment(t *testing.T) {
provider := runtime.NewFake()
if err := provider.Start(context.Background(), "sleeping-worker", runtime.Config{Command: "echo"}); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions cmd/gc/dashboard/web/src/generated/index.ts

Large diffs are not rendered by default.

Loading
Loading