feat: enhance Kiro agent integration with custom AO agent prompts (#2444)

* feat: enhance Kiro agent integration with system prompts and agent configuration

* fix(kiro): clear stale model from hooks when configuration is removed
This commit is contained in:
NIKHIL ACHALE 2026-07-06 02:58:08 +05:30 committed by GitHub
parent 71fc914afa
commit d51ddd6130
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 399 additions and 39 deletions

View File

@ -27,12 +27,17 @@ const (
// skips duplicates and uninstall recognizes AO entries by prefix without an // skips duplicates and uninstall recognizes AO entries by prefix without an
// embedded template to diff against. // embedded template to diff against.
kiroHookCommandPrefix = "ao hooks kiro " kiroHookCommandPrefix = "ao hooks kiro "
kiroAgentName = "ao"
kiroAgentDescription = "Agent Orchestrator session instructions"
) )
// kiroHookFile is the on-disk shape of .kiro/agents/ao.json. It is used by // kiroHookFile is the on-disk shape of .kiro/agents/ao.json. It is used by
// tests to decode the written file. Kiro hooks are a map of camelCase event // tests to decode the written file. Kiro hooks are a map of camelCase event
// name to a flat array of {matcher?, command} entries. // name to a flat array of {matcher?, command} entries.
type kiroHookFile struct { type kiroHookFile struct {
Name string `json:"name"`
Prompt *string `json:"prompt"`
Hooks map[string][]kiroHookEntry `json:"hooks"` Hooks map[string][]kiroHookEntry `json:"hooks"`
} }
@ -97,7 +102,7 @@ func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfi
} }
} }
if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil { if err := writeKiroHooks(hooksPath, topLevel, rawHooks, cfg.SystemPrompt, cfg.Config); err != nil {
return fmt.Errorf("kiro.GetAgentHooks: %w", err) return fmt.Errorf("kiro.GetAgentHooks: %w", err)
} }
if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), kiroAgentFileName); err != nil { if err := hookutil.EnsureWorkspaceGitignore(filepath.Dir(hooksPath), kiroAgentFileName); err != nil {
@ -137,7 +142,7 @@ func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error
} }
} }
if err := writeKiroHooks(hooksPath, topLevel, rawHooks); err != nil { if err := writeKiroHooks(hooksPath, topLevel, rawHooks, "", ports.AgentConfig{}); err != nil {
return fmt.Errorf("kiro.UninstallHooks: %w", err) return fmt.Errorf("kiro.UninstallHooks: %w", err)
} }
return nil return nil
@ -210,7 +215,11 @@ func readKiroHooks(hooksPath string) (topLevel, rawHooks map[string]json.RawMess
// writeKiroHooks folds rawHooks back into topLevel and writes the file. An // writeKiroHooks folds rawHooks back into topLevel and writes the file. An
// empty hooks map drops the "hooks" key entirely. // empty hooks map drops the "hooks" key entirely.
func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage) error { func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error {
if err := setKiroAgentDefaults(topLevel, systemPrompt, agentConfig); err != nil {
return err
}
if len(rawHooks) == 0 { if len(rawHooks) == 0 {
delete(topLevel, "hooks") delete(topLevel, "hooks")
} else { } else {
@ -235,6 +244,44 @@ func writeKiroHooks(hooksPath string, topLevel, rawHooks map[string]json.RawMess
return nil return nil
} }
func setKiroAgentDefaults(topLevel map[string]json.RawMessage, systemPrompt string, agentConfig ports.AgentConfig) error {
defaults := map[string]any{
"name": kiroAgentName,
"description": kiroAgentDescription,
"prompt": nil,
"mcpServers": map[string]any{},
"tools": []string{"*"},
"toolAliases": map[string]any{},
"allowedTools": []any{},
"resources": []any{},
"toolsSettings": map[string]any{},
"includeMcpJson": true,
}
if systemPrompt != "" {
defaults["prompt"] = systemPrompt
}
if model := strings.TrimSpace(agentConfig.Model); model != "" {
defaults["model"] = model
} else {
delete(topLevel, "model")
}
for key, value := range defaults {
managedKey := key == "name" || key == "prompt" || key == "model"
if !managedKey {
if _, ok := topLevel[key]; ok {
continue
}
}
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode agent %s: %w", key, err)
}
topLevel[key] = data
}
return nil
}
// groupKiroHooksByEvent groups the managed hook specs by their Kiro event so // groupKiroHooksByEvent groups the managed hook specs by their Kiro event so
// each event's array is rewritten once. // each event's array is rewritten once.
func groupKiroHooksByEvent() map[string][]kiroHookSpec { func groupKiroHooksByEvent() map[string][]kiroHookSpec {

View File

@ -3,9 +3,9 @@
// and reading hook-derived session info. // and reading hook-derived session info.
// //
// Kiro is AWS's agentic coding assistant. Its terminal CLI ships as the // Kiro is AWS's agentic coding assistant. Its terminal CLI ships as the
// `kiro-cli` binary and exposes a non-interactive ("headless") mode via // `kiro-cli` binary. AO launches Kiro with a workspace-local custom agent so
// `kiro-cli chat --no-interactive "<prompt>"`, suitable for AO-driven worker // both worker and orchestrator sessions can use Kiro's normal interactive
// sessions. See https://kiro.dev/docs/cli/headless/ and // approval flow. See https://kiro.dev/docs/cli/headless/ and
// https://kiro.dev/docs/cli/reference/cli-commands/. // https://kiro.dev/docs/cli/reference/cli-commands/.
// //
// Launch delivers the initial prompt as a positional argument after `--` so a // Launch delivers the initial prompt as a positional argument after `--` so a
@ -26,6 +26,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/agentbase"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/binaryutil"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/ports"
) )
@ -58,29 +59,47 @@ func (p *Plugin) Manifest() adapters.Manifest {
} }
} }
// GetLaunchCommand builds the argv to start a new headless Kiro session: // GetLaunchCommand builds the argv to start a new Kiro session:
// `kiro-cli chat --no-interactive [trust flags] -- <prompt>`. // `kiro-cli chat --agent ao [trust flags] [-- <prompt>]`.
// //
// The prompt is passed as a positional argument after `--` so a leading "-" is // The prompt is passed as a positional argument after `--` so a leading "-" is
// not read as a flag. Kiro's --no-interactive mode requires a prompt argument. // not read as a flag. Kiro runs interactively for both workers and orchestrators;
// standing instructions come from the generated custom agent.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) { func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (cmd []string, err error) {
binary, err := p.kiroBinary(ctx) binary, err := p.kiroBinary(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
cmd = []string{binary, "chat", "--no-interactive"} cmd = []string{binary, "chat", "--agent", kiroAgentName}
appendApprovalFlags(&cmd, cfg.Permissions) appendApprovalFlags(&cmd, cfg.Permissions)
if cfg.Prompt != "" { prompt := cfg.Prompt
cmd = append(cmd, "--", cfg.Prompt) if prompt != "" {
cmd = append(cmd, "--", prompt)
} }
return cmd, nil return cmd, nil
} }
// GetRestoreCommand rebuilds the argv that continues an existing Kiro session: // GetPromptDeliveryStrategy reports how Kiro receives the initial task prompt.
// `kiro-cli chat --no-interactive --resume-id <agentSessionId> [trust flags]`. // Orchestrator standing instructions are delivered through the generated
// custom-agent prompt, so no command or post-start prompt injection is needed
// there.
func (p *Plugin) GetPromptDeliveryStrategy(ctx context.Context, cfg ports.LaunchConfig) (ports.PromptDeliveryStrategy, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if cfg.Prompt != "" {
return ports.PromptDeliveryInCommand, nil
}
if cfg.Kind == domain.KindOrchestrator {
return ports.PromptDeliveryCustomAgent, nil
}
return ports.PromptDeliveryInCommand, nil
}
// GetRestoreCommand rebuilds the argv that continues an existing Kiro session.
// ok is false when the hook-derived native session id has not landed yet, so // ok is false when the hook-derived native session id has not landed yet, so
// callers can fall back to fresh launch behavior. // callers can fall back to fresh launch behavior.
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) { func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) (cmd []string, ok bool, err error) {
@ -97,8 +116,7 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)
return nil, false, err return nil, false, err
} }
cmd = make([]string, 0, 8) cmd = []string{binary, "chat", "--agent", kiroAgentName, "--resume-id", agentSessionID}
cmd = append(cmd, binary, "chat", "--no-interactive", "--resume-id", agentSessionID)
appendApprovalFlags(&cmd, cfg.Permissions) appendApprovalFlags(&cmd, cfg.Permissions)
return cmd, true, nil return cmd, true, nil
} }
@ -152,14 +170,14 @@ func (p *Plugin) kiroBinary(ctx context.Context) (string, error) {
return binary, nil return binary, nil
} }
// appendApprovalFlags maps AO's 4 permission modes onto Kiro's tool-trust // appendApprovalFlags maps AO's permission modes onto Kiro's tool-trust flags.
// flags. Default emits no flag so Kiro defers to the user's own configuration // Default emits no flag so Kiro uses its normal interactive approval flow.
// (the interactive per-tool prompt). accept-edits grants the write-capable // accept-edits grants the write-capable built-in tools; auto/bypass grant all
// built-in tools; auto/bypass grant all tools. // tools.
func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) { func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
switch ports.NormalizePermissionMode(permissions) { switch ports.NormalizePermissionMode(permissions) {
case ports.PermissionModeDefault: case ports.PermissionModeDefault:
// No flag: defer to the user's Kiro config / per-tool prompting. // No flag: defer to Kiro's normal interactive approval flow.
case ports.PermissionModeAcceptEdits: case ports.PermissionModeAcceptEdits:
*cmd = append(*cmd, "--trust-tools=fs_read,fs_write") *cmd = append(*cmd, "--trust-tools=fs_read,fs_write")
case ports.PermissionModeAuto: case ports.PermissionModeAuto:

View File

@ -11,6 +11,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters" "github.com/aoagents/agent-orchestrator/backend/internal/adapters"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe" "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/authprobe"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports" "github.com/aoagents/agent-orchestrator/backend/internal/ports"
) )
@ -27,7 +28,7 @@ func TestManifestIDIsKiro(t *testing.T) {
} }
} }
func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) { func TestGetLaunchCommandBuildsInteractiveArgv(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"} plugin := &Plugin{resolvedBinary: "kiro-cli"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{ cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
@ -39,7 +40,8 @@ func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
} }
want := []string{ want := []string{
"kiro-cli", "chat", "--no-interactive", "kiro-cli", "chat",
"--agent", "ao",
"--trust-all-tools", "--trust-all-tools",
"--", "-fix this", "--", "-fix this",
} }
@ -48,6 +50,64 @@ func TestGetLaunchCommandBuildsHeadlessArgv(t *testing.T) {
} }
} }
func TestGetLaunchCommandOrchestratorUsesInteractiveAgent(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Kind: domain.KindOrchestrator,
SystemPrompt: "You are the human-facing coordinator.",
})
if err != nil {
t.Fatal(err)
}
want := []string{
"kiro-cli", "chat",
"--agent", "ao",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetLaunchCommandPromptlessWorkerStaysInteractive(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{})
if err != nil {
t.Fatal(err)
}
want := []string{
"kiro-cli", "chat",
"--agent", "ao",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetLaunchCommandPromptTakesPrecedenceOverSystemPrompt(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Prompt: "fix the failing test",
SystemPrompt: "standing role instructions",
})
if err != nil {
t.Fatal(err)
}
want := []string{
"kiro-cli", "chat",
"--agent", "ao",
"--", "fix the failing test",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("unexpected command\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetLaunchCommandMapsApprovalModes(t *testing.T) { func TestGetLaunchCommandMapsApprovalModes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -124,6 +184,33 @@ func TestGetPromptDeliveryStrategyIsInCommand(t *testing.T) {
} }
} }
func TestGetPromptDeliveryStrategyOrchestratorUsesCustomAgent(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{Kind: domain.KindOrchestrator})
if err != nil {
t.Fatal(err)
}
if got != ports.PromptDeliveryCustomAgent {
t.Fatalf("unexpected strategy: %q", got)
}
}
func TestGetPromptDeliveryStrategyPromptedOrchestratorUsesCommand(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
got, err := plugin.GetPromptDeliveryStrategy(context.Background(), ports.LaunchConfig{
Kind: domain.KindOrchestrator,
Prompt: "do this explicit task",
})
if err != nil {
t.Fatal(err)
}
if got != ports.PromptDeliveryInCommand {
t.Fatalf("unexpected strategy: %q", got)
}
}
func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) { func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
plugin := &Plugin{} plugin := &Plugin{}
@ -182,7 +269,7 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
hooksPath := filepath.Join(hooksDir, kiroAgentFileName) hooksPath := filepath.Join(hooksDir, kiroAgentFileName)
existing := `{"name":"ao","hooks":{"stop":[{"command":"custom stop hook"}]}}` existing := `{"name":"stale","hooks":{"stop":[{"command":"custom stop hook"}]}}`
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil { if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -190,6 +277,7 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
cfg := ports.WorkspaceHookConfig{ cfg := ports.WorkspaceHookConfig{
DataDir: t.TempDir(), DataDir: t.TempDir(),
SessionID: "sess-1", SessionID: "sess-1",
SystemPrompt: "standing AO instructions",
WorkspacePath: workspace, WorkspacePath: workspace,
} }
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil { if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
@ -204,13 +292,23 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// The unmanaged top-level "name" key must be preserved.
var topLevel map[string]json.RawMessage var topLevel map[string]json.RawMessage
if err := json.Unmarshal(data, &topLevel); err != nil { if err := json.Unmarshal(data, &topLevel); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, ok := topLevel["name"]; !ok { var name string
t.Fatalf("unmanaged top-level key 'name' was dropped: %s", data) if err := json.Unmarshal(topLevel["name"], &name); err != nil {
t.Fatalf("decode name from %s: %v", data, err)
}
if name != kiroAgentName {
t.Fatalf("name = %q, want %q", name, kiroAgentName)
}
var prompt string
if err := json.Unmarshal(topLevel["prompt"], &prompt); err != nil {
t.Fatalf("decode prompt from %s: %v", data, err)
}
if prompt != "standing AO instructions" {
t.Fatalf("prompt = %q, want system prompt", prompt)
} }
var config kiroHookFile var config kiroHookFile
@ -232,6 +330,167 @@ func TestGetAgentHooksInstallsKiroHooks(t *testing.T) {
} }
} }
func TestGetAgentHooksCreatesNamedKiroAgentFile(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir()
hooksPath := kiroAgentPath(workspace)
cfg := ports.WorkspaceHookConfig{
DataDir: t.TempDir(),
SessionID: "sess-1",
SystemPrompt: "exact orchestrator system prompt",
WorkspacePath: workspace,
}
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(hooksPath)
if err != nil {
t.Fatal(err)
}
var topLevel map[string]json.RawMessage
if err := json.Unmarshal(data, &topLevel); err != nil {
t.Fatal(err)
}
var name string
if err := json.Unmarshal(topLevel["name"], &name); err != nil {
t.Fatalf("decode name from %s: %v", data, err)
}
if name != kiroAgentName {
t.Fatalf("name = %q, want %q", name, kiroAgentName)
}
var config kiroHookFile
if err := json.Unmarshal(data, &config); err != nil {
t.Fatal(err)
}
if config.Prompt == nil || *config.Prompt != "exact orchestrator system prompt" {
t.Fatalf("prompt = %#v, want exact system prompt", config.Prompt)
}
}
func TestGetAgentHooksWritesConfiguredModel(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir()
hooksPath := kiroAgentPath(workspace)
cfg := ports.WorkspaceHookConfig{
Config: ports.AgentConfig{Model: "claude-sonnet-4-5"},
DataDir: t.TempDir(),
SessionID: "sess-1",
SystemPrompt: "standing AO instructions",
WorkspacePath: workspace,
}
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(hooksPath)
if err != nil {
t.Fatal(err)
}
var topLevel map[string]json.RawMessage
if err := json.Unmarshal(data, &topLevel); err != nil {
t.Fatal(err)
}
var model string
if err := json.Unmarshal(topLevel["model"], &model); err != nil {
t.Fatalf("decode model from %s: %v", data, err)
}
if model != "claude-sonnet-4-5" {
t.Fatalf("model = %q, want configured model", model)
}
}
func TestGetAgentHooksOverwritesStaleConfiguredModel(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir()
hooksPath := kiroAgentPath(workspace)
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
t.Fatal(err)
}
existing := `{"name":"ao","model":"stale-model","tools":["custom"]}`
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
cfg := ports.WorkspaceHookConfig{
Config: ports.AgentConfig{Model: "project-model"},
DataDir: t.TempDir(),
SessionID: "sess-1",
SystemPrompt: "standing AO instructions",
WorkspacePath: workspace,
}
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(hooksPath)
if err != nil {
t.Fatal(err)
}
var topLevel map[string]json.RawMessage
if err := json.Unmarshal(data, &topLevel); err != nil {
t.Fatal(err)
}
var model string
if err := json.Unmarshal(topLevel["model"], &model); err != nil {
t.Fatalf("decode model from %s: %v", data, err)
}
if model != "project-model" {
t.Fatalf("model = %q, want project override", model)
}
var tools []string
if err := json.Unmarshal(topLevel["tools"], &tools); err != nil {
t.Fatalf("decode tools from %s: %v", data, err)
}
if !reflect.DeepEqual(tools, []string{"custom"}) {
t.Fatalf("tools = %#v, want preserved custom tools", tools)
}
}
func TestGetAgentHooksClearsStaleModelWhenConfigRemoved(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir()
hooksPath := kiroAgentPath(workspace)
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o755); err != nil {
t.Fatal(err)
}
existing := `{"name":"ao","model":"stale-model","tools":["custom"]}`
if err := os.WriteFile(hooksPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
cfg := ports.WorkspaceHookConfig{
DataDir: t.TempDir(),
SessionID: "sess-1",
SystemPrompt: "standing AO instructions",
WorkspacePath: workspace,
}
if err := plugin.GetAgentHooks(context.Background(), cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(hooksPath)
if err != nil {
t.Fatal(err)
}
var topLevel map[string]json.RawMessage
if err := json.Unmarshal(data, &topLevel); err != nil {
t.Fatal(err)
}
if _, ok := topLevel["model"]; ok {
t.Fatalf("model still present in %s, want cleared when config has no model", data)
}
var tools []string
if err := json.Unmarshal(topLevel["tools"], &tools); err != nil {
t.Fatalf("decode tools from %s: %v", data, err)
}
if !reflect.DeepEqual(tools, []string{"custom"}) {
t.Fatalf("tools = %#v, want preserved custom tools", tools)
}
}
func TestUninstallHooksRemovesKiroHooks(t *testing.T) { func TestUninstallHooksRemovesKiroHooks(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"} plugin := &Plugin{resolvedBinary: "kiro-cli"}
workspace := t.TempDir() workspace := t.TempDir()
@ -309,7 +568,8 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
t.Fatal("ok = false, want true") t.Fatal("ok = false, want true")
} }
want := []string{ want := []string{
"kiro-cli", "chat", "--no-interactive", "kiro-cli", "chat",
"--agent", "ao",
"--resume-id", "uuid-123", "--resume-id", "uuid-123",
"--trust-all-tools", "--trust-all-tools",
} }
@ -318,6 +578,32 @@ func TestGetRestoreCommandReadsAgentSessionID(t *testing.T) {
} }
} }
func TestGetRestoreCommandOrchestratorUsesInteractiveAgent(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"}
cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
Kind: domain.KindOrchestrator,
Permissions: ports.PermissionModeDefault,
Session: ports.SessionRef{
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "uuid-123"},
},
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if !ok {
t.Fatal("ok = false, want true")
}
want := []string{
"kiro-cli", "chat",
"--agent", "ao",
"--resume-id", "uuid-123",
}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("restore cmd\nwant: %#v\n got: %#v", want, cmd)
}
}
func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) { func TestGetRestoreCommandFalseWithoutAgentSessionID(t *testing.T) {
plugin := &Plugin{resolvedBinary: "kiro-cli"} plugin := &Plugin{resolvedBinary: "kiro-cli"}

View File

@ -130,6 +130,7 @@ const (
type LaunchConfig struct { type LaunchConfig struct {
Config AgentConfig Config AgentConfig
IssueID string IssueID string
Kind domain.SessionKind
Permissions PermissionMode Permissions PermissionMode
Prompt string Prompt string
SessionID string SessionID string
@ -151,12 +152,14 @@ type WorkspaceHookConfig struct {
Config AgentConfig Config AgentConfig
DataDir string DataDir string
SessionID string SessionID string
SystemPrompt string
WorkspacePath string WorkspacePath string
} }
// RestoreConfig carries inputs needed to continue an existing native agent session. // RestoreConfig carries inputs needed to continue an existing native agent session.
type RestoreConfig struct { type RestoreConfig struct {
Config AgentConfig Config AgentConfig
Kind domain.SessionKind
Permissions PermissionMode Permissions PermissionMode
Session SessionRef Session SessionRef
// SystemPrompt carries the session's standing instructions (e.g. the // SystemPrompt carries the session's standing instructions (e.g. the
@ -219,4 +222,5 @@ type PromptDeliveryStrategy string
const ( const (
PromptDeliveryInCommand PromptDeliveryStrategy = "in_command" PromptDeliveryInCommand PromptDeliveryStrategy = "in_command"
PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start" PromptDeliveryAfterStart PromptDeliveryStrategy = "after_start"
PromptDeliveryCustomAgent PromptDeliveryStrategy = "custom_agent"
) )

View File

@ -256,15 +256,16 @@ func (m *Manager) Spawn(ctx context.Context, cfg ports.SpawnConfig) (domain.Sess
m.rollbackSpawnSeedRow(ctx, id) m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness) return domain.SessionRecord{}, fmt.Errorf("spawn %s: no agent adapter for harness %q", id, cfg.Harness)
} }
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil { agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil {
_ = m.workspace.Destroy(ctx, ws) _ = m.workspace.Destroy(ctx, ws)
m.rollbackSpawnSeedRow(ctx, id) m.rollbackSpawnSeedRow(ctx, id)
return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err) return domain.SessionRecord{}, fmt.Errorf("spawn %s: %w", id, err)
} }
agentConfig := effectiveAgentConfig(cfg.Kind, project.Config)
argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{
SessionID: string(id), SessionID: string(id),
WorkspacePath: ws.Path, WorkspacePath: ws.Path,
Kind: cfg.Kind,
Prompt: prompt, Prompt: prompt,
SystemPrompt: systemPrompt, SystemPrompt: systemPrompt,
IssueID: string(cfg.IssueID), IssueID: string(cfg.IssueID),
@ -575,9 +576,6 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
if !ok { if !ok {
return domain.SessionRecord{}, fmt.Errorf("restore %s: no agent adapter for harness %q", id, rec.Harness) return domain.SessionRecord{}, fmt.Errorf("restore %s: no agent adapter for harness %q", id, rec.Harness)
} }
if err := m.prepareWorkspace(ctx, agent, id, ws.Path); err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
// The system prompt is derived, not persisted: recompute it so a restored // The system prompt is derived, not persisted: recompute it so a restored
// session keeps its standing instructions across the relaunch. // session keeps its standing instructions across the relaunch.
systemPrompt, err := m.buildSystemPrompt(ctx, rec.Kind, rec.ProjectID) systemPrompt, err := m.buildSystemPrompt(ctx, rec.Kind, rec.ProjectID)
@ -586,7 +584,11 @@ func (m *Manager) Restore(ctx context.Context, id domain.SessionID) (domain.Sess
} }
// Restore re-applies the project's resolved agent config so a configured // Restore re-applies the project's resolved agent config so a configured
// model/permissions carry across a restore, matching fresh spawn. // model/permissions carry across a restore, matching fresh spawn.
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, effectiveAgentConfig(rec.Kind, project.Config), rec.Kind) agentConfig := effectiveAgentConfig(rec.Kind, project.Config)
if err := m.prepareWorkspace(ctx, agent, id, ws.Path, systemPrompt, agentConfig); err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
}
argv, err := restoreArgv(ctx, agent, id, ws.Path, meta, systemPrompt, agentConfig, rec.Kind)
if err != nil { if err != nil {
return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err) return domain.SessionRecord{}, fmt.Errorf("restore %s: %w", id, err)
} }
@ -1297,11 +1299,13 @@ type preLauncher interface {
// starts the agent: installing the workspace-local activity hooks (so early // starts the agent: installing the workspace-local activity hooks (so early
// startup hooks can update the already-created session row), then any optional // startup hooks can update the already-created session row), then any optional
// PreLaunch step. Shared by Spawn and Restore. // PreLaunch step. Shared by Spawn and Restore.
func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath string) error { func (m *Manager) prepareWorkspace(ctx context.Context, agent ports.Agent, id domain.SessionID, workspacePath, systemPrompt string, agentConfig ports.AgentConfig) error {
if err := agent.GetAgentHooks(ctx, ports.WorkspaceHookConfig{ if err := agent.GetAgentHooks(ctx, ports.WorkspaceHookConfig{
SessionID: string(id), SessionID: string(id),
WorkspacePath: workspacePath, WorkspacePath: workspacePath,
DataDir: m.dataDir, DataDir: m.dataDir,
SystemPrompt: systemPrompt,
Config: agentConfig,
}); err != nil { }); err != nil {
return fmt.Errorf("install hooks: %w", err) return fmt.Errorf("install hooks: %w", err)
} }
@ -1326,7 +1330,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
WorkspacePath: workspacePath, WorkspacePath: workspacePath,
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: meta.AgentSessionID}, Metadata: map[string]string{ports.MetadataKeyAgentSessionID: meta.AgentSessionID},
} }
cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions}) cmd, ok, err := agent.GetRestoreCommand(ctx, ports.RestoreConfig{Session: ref, Kind: kind, SystemPrompt: systemPrompt, Config: agentConfig, Permissions: agentConfig.Permissions})
if err != nil { if err != nil {
return nil, fmt.Errorf("restore command: %w", err) return nil, fmt.Errorf("restore command: %w", err)
} }
@ -1343,6 +1347,7 @@ func restoreArgv(ctx context.Context, agent ports.Agent, id domain.SessionID, wo
argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{ argv, err := agent.GetLaunchCommand(ctx, ports.LaunchConfig{
SessionID: string(id), SessionID: string(id),
WorkspacePath: workspacePath, WorkspacePath: workspacePath,
Kind: kind,
Prompt: meta.Prompt, Prompt: meta.Prompt,
SystemPrompt: systemPrompt, SystemPrompt: systemPrompt,
Config: agentConfig, Config: agentConfig,