From 1a2c391e44e81bb2dcc97644dd7fda75136a189f Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Thu, 30 Jul 2026 17:11:39 -0700 Subject: [PATCH] fix(goose): wire lifecycle hooks, AGENTS.md context, session support --- CHANGELOG.md | 9 +++ agents/agents_comprehensive_test.go | 10 ++-- agents/goose.go | 51 +++++++++++++--- agents/goose_test.go | 91 +++++++++++++++++++++++++++++ agents/session_test.go | 2 +- 5 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 agents/goose_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b39e2df..a981b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.12] - 2026-07-30 + +### Fixed + +- **Goose lifecycle hooks**: Goose shipped a full hooks system in v1.38 (Open Plugins spec), but `GooseAgent` still reported `Hooks: false` and did not implement `LifecycleEventMapper` — so `AGENT_ENV=goose` was absent from `BuildEventPhaseMap()` and callers had to fall back to scanning every other agent's map to resolve a Goose event. Add `EventPhases()` covering `SessionStart`, `SessionEnd`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, and `Stop`, plus `AgentENVAliases() == ["goose"]`. `PhaseCompact` is deliberately unmapped: Goose fires no compaction event, so mapping one would promise a re-prime that never happens. +- **Goose context files**: `ContextFiles()` returned `[".goose/config.yaml", ".goosehints"]`. `.goose/config.yaml` is configuration, not an instruction file, and the list omitted `AGENTS.md` entirely — which Goose loads *first*, ahead of `.goosehints`. Now returns `["AGENTS.md", ".goosehints"]` in Goose's own load order, so a caller injecting a context marker writes to the file Goose actually reads first. +- **Goose session support**: `SupportsSession()` was `false`. Goose supplies `session_id` on every hook payload, so it now returns `true`, following the OpenCode precedent. `SessionID(env)` still returns empty — Goose exposes no session-ID environment variable, and callers must read it from the hook payload. +- **Goose capabilities**: `Hooks: true`, `MinVersion: "1.38.0"` (the release hooks landed in), and the `ProjectContext` comment now names `AGENTS.md` alongside `.goosehints`. + ## [0.1.11] - 2026-07-23 ### Added diff --git a/agents/agents_comprehensive_test.go b/agents/agents_comprehensive_test.go index 71aea4a..46ddf39 100644 --- a/agents/agents_comprehensive_test.go +++ b/agents/agents_comprehensive_test.go @@ -303,10 +303,10 @@ func allAgentSpecs() []agentSpec { url: "https://github.com/block/goose", supportsXDG: true, projectConfig: "", - contextFiles: []string{".goose/config.yaml", ".goosehints"}, - capabilities: agentx.Capabilities{Hooks: false, MCPServers: true, SystemPrompt: true, ProjectContext: true, CustomCommands: false}, + contextFiles: []string{"AGENTS.md", ".goosehints"}, + capabilities: agentx.Capabilities{Hooks: true, MCPServers: true, SystemPrompt: true, ProjectContext: true, CustomCommands: false, MinVersion: "1.38.0"}, binaryName: "goose", - supportsSession: false, + supportsSession: true, usesHomeConfig: false, configSubdir: "goose", versionCmd: "goose", @@ -317,6 +317,9 @@ func allAgentSpecs() []agentSpec { {"exec path heuristic", map[string]string{"_": "/usr/local/bin/goose"}, true}, {"no env vars", map[string]string{}, false}, }, + isLifecycleAgent: true, + envAliases: []string{"goose"}, + eventPhaseCount: 6, }, { name: "Amp", @@ -936,7 +939,6 @@ func TestNonLifecycleAgents_DoNotImplementMapper(t *testing.T) { {"Cody", NewCodyAgent()}, {"Continue", NewContinueAgent()}, {"CodePuppy", NewCodePuppyAgent()}, - {"Goose", NewGooseAgent()}, {"Codex", NewCodexAgent()}, {"Gemini CLI", NewGeminiAgent()}, } diff --git a/agents/goose.go b/agents/goose.go index 468d7df..81ac69e 100644 --- a/agents/goose.go +++ b/agents/goose.go @@ -74,9 +74,15 @@ func (a *GooseAgent) ProjectConfigPath() string { return "" } -// ContextFiles returns the context/instruction files Goose supports. +// ContextFiles returns the context/instruction files Goose loads into the +// system prompt, in Goose's own load order: AGENTS.md first, then .goosehints. +// Goose resolves both hierarchically from the working directory up to the +// repository root, and also reads the global ~/.config/goose/.goosehints. +// The set is overridable via the CONTEXT_FILE_NAMES environment variable. +// +// Reference: https://block.github.io/goose/docs/guides/context-engineering/using-goosehints func (a *GooseAgent) ContextFiles() []string { - return []string{".goose/config.yaml", ".goosehints"} + return []string{"AGENTS.md", ".goosehints"} } // SupportsXDGConfig returns true as Goose uses ~/.config/goose. @@ -87,12 +93,12 @@ func (a *GooseAgent) SupportsXDGConfig() bool { // Capabilities returns Goose's supported features. func (a *GooseAgent) Capabilities() agentx.Capabilities { return agentx.Capabilities{ - Hooks: false, // CLI-based + Hooks: true, // lifecycle hooks via plugins (goose >= 1.38) MCPServers: true, // supports MCP extensions SystemPrompt: true, // config.yaml - ProjectContext: true, // .goosehints - CustomCommands: false, // TBD - MinVersion: "", + ProjectContext: true, // AGENTS.md, .goosehints + CustomCommands: false, // recipes are not slash commands + MinVersion: "1.38.0", } } @@ -143,7 +149,38 @@ func (a *GooseAgent) IsInstalled(ctx context.Context, env agentx.Environment) (b return false, nil } -func (a *GooseAgent) SupportsSession() bool { return false } +// EventPhases returns Goose's native event-to-phase mapping. +// +// Goose follows the Open Plugins hooks specification. It fires several more +// events than are mapped here (PostToolUseFailure, BeforeReadFile, +// AfterFileEdit, BeforeShellExecution, AfterShellExecution); those have no +// canonical phase equivalent and are deliberately omitted. +// +// Goose has no compaction event, so PhaseCompact is unreachable — context +// injected at session start does not survive a Goose compaction. +// +// Reference: https://block.github.io/goose/docs/guides/context-engineering/hooks +func (a *GooseAgent) EventPhases() agentx.EventPhaseMap { + return agentx.EventPhaseMap{ + agentx.HookEventSessionStart: agentx.PhaseStart, + agentx.HookEventSessionEnd: agentx.PhaseEnd, + agentx.HookEventPreToolUse: agentx.PhaseBeforeTool, + agentx.HookEventPostToolUse: agentx.PhaseAfterTool, + agentx.HookEventUserPromptSubmit: agentx.PhasePrompt, + agentx.HookEventStop: agentx.PhaseStop, + } +} + +// AgentENVAliases returns the AGENT_ENV values that identify Goose. +func (a *GooseAgent) AgentENVAliases() []string { + return []string{"goose"} +} + +// SupportsSession returns true; Goose supplies a session_id on every hook +// payload. There is no session-ID environment variable, so SessionID always +// returns empty and callers must read it from the hook payload instead. +func (a *GooseAgent) SupportsSession() bool { return true } func (a *GooseAgent) SessionID(_ agentx.Environment) string { return "" } var _ agentx.Agent = (*GooseAgent)(nil) +var _ agentx.LifecycleEventMapper = (*GooseAgent)(nil) diff --git a/agents/goose_test.go b/agents/goose_test.go new file mode 100644 index 0000000..13ccf28 --- /dev/null +++ b/agents/goose_test.go @@ -0,0 +1,91 @@ +package agents + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sageox/agentx" +) + +// TestGooseContextFiles pins the load ORDER, not just membership. Goose reads +// AGENTS.md first and .goosehints second, and a local file wins over the global +// ~/.config/goose/.goosehints. Callers that inject a prime marker need the first +// entry to be the file they should write to. +func TestGooseContextFiles(t *testing.T) { + agent := NewGooseAgent() + files := agent.ContextFiles() + + require.Len(t, files, 2) + assert.Equal(t, "AGENTS.md", files[0], "AGENTS.md must come first — Goose loads it before .goosehints") + assert.Equal(t, ".goosehints", files[1]) +} + +func TestGooseCapabilities(t *testing.T) { + agent := NewGooseAgent() + caps := agent.Capabilities() + + assert.True(t, caps.Hooks, "Goose ships lifecycle hooks via the plugin system") + assert.True(t, caps.MCPServers) + assert.True(t, caps.SystemPrompt) + assert.True(t, caps.ProjectContext) + assert.False(t, caps.CustomCommands, "recipes are not slash commands") + assert.Equal(t, "1.38.0", caps.MinVersion, "hooks landed in goose 1.38") +} + +func TestGooseEventPhases(t *testing.T) { + agent := NewGooseAgent() + + mapper, ok := agentx.Agent(agent).(agentx.LifecycleEventMapper) + require.True(t, ok, "Goose must implement LifecycleEventMapper") + + phases := mapper.EventPhases() + + expected := agentx.EventPhaseMap{ + agentx.HookEventSessionStart: agentx.PhaseStart, + agentx.HookEventSessionEnd: agentx.PhaseEnd, + agentx.HookEventPreToolUse: agentx.PhaseBeforeTool, + agentx.HookEventPostToolUse: agentx.PhaseAfterTool, + agentx.HookEventUserPromptSubmit: agentx.PhasePrompt, + agentx.HookEventStop: agentx.PhaseStop, + } + assert.Equal(t, expected, phases) + + // Goose has no compaction event. Anything relying on PhaseCompact to + // re-inject context after a context reset will never fire under Goose — + // that limitation is upstream and cannot be worked around by a caller. + for _, phase := range phases { + assert.NotEqual(t, agentx.PhaseCompact, phase, + "Goose fires no compaction event; mapping one would silently promise re-priming that never happens") + } + + assert.Equal(t, []string{"goose"}, mapper.AgentENVAliases()) +} + +// TestGooseSession documents why SupportsSession is true while SessionID is +// always empty: Goose puts session_id on the hook payload (stdin), never in the +// environment. Callers must read it from the payload. +func TestGooseSession(t *testing.T) { + agent := NewGooseAgent() + + assert.True(t, agent.SupportsSession()) + assert.Equal(t, "", agent.SessionID(agentx.NewMockEnvironment(nil))) + assert.Equal(t, "", agent.SessionID(agentx.NewMockEnvironment(map[string]string{ + "GOOSE_SESSION_ID": "should-be-ignored", + })), "Goose exposes no session-ID env var") +} + +// TestGooseRegisteredInEventPhaseMap guards the reason this mapping exists: +// downstream tools resolve a hook event to a phase by AGENT_ENV alias. Before +// Goose implemented LifecycleEventMapper, "goose" was absent from that map and +// resolution fell through to a scan of every other agent. +func TestGooseRegisteredInEventPhaseMap(t *testing.T) { + byAlias := agentx.BuildEventPhaseMap() + + phases, ok := byAlias["goose"] + require.True(t, ok, "AGENT_ENV=goose must resolve to a phase map") + assert.Equal(t, agentx.PhaseStart, phases[agentx.HookEventSessionStart]) + assert.Equal(t, agentx.PhaseEnd, phases[agentx.HookEventSessionEnd]) + assert.Equal(t, agentx.PhasePrompt, phases[agentx.HookEventUserPromptSubmit]) +} diff --git a/agents/session_test.go b/agents/session_test.go index 7546727..77e6def 100644 --- a/agents/session_test.go +++ b/agents/session_test.go @@ -44,6 +44,7 @@ func TestAgentSessionSupport(t *testing.T) { {"Kiro", NewKiroAgent()}, {"Droid", NewDroidAgent()}, {"OpenCode", NewOpenCodeAgent()}, + {"Goose", NewGooseAgent()}, } for _, tt := range agents { t.Run(tt.name, func(t *testing.T) { @@ -61,7 +62,6 @@ func TestAgentSessionSupport(t *testing.T) { agent agentx.Agent }{ {"Aider", NewAiderAgent()}, - {"Goose", NewGooseAgent()}, {"Cody", NewCodyAgent()}, {"Continue", NewContinueAgent()}, {"CodePuppy", NewCodePuppyAgent()},