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:
parent
71fc914afa
commit
d51ddd6130
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue