Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- internal/agent/.env.sample +5 -0
- internal/agent/accepted_run_test.go +231 -0
- internal/agent/agent.go +2331 -0
- internal/agent/agent_test.go +1353 -0
- internal/agent/agent_tool.go +68 -0
- internal/agent/agentic_fetch_tool.go +206 -0
- internal/agent/aws_sso_refresh.go +158 -0
- internal/agent/aws_sso_refresh_test.go +44 -0
- internal/agent/common_test.go +206 -0
- internal/agent/convert_test.go +94 -0
- internal/agent/coordinator.go +1757 -0
- internal/agent/coordinator_mcp_gate_test.go +115 -0
- internal/agent/coordinator_readiness_test.go +97 -0
- internal/agent/coordinator_test.go +792 -0
- internal/agent/dispatch_cancel_test.go +447 -0
- internal/agent/dispatch_race_test.go +155 -0
- internal/agent/errors.go +10 -0
- internal/agent/event.go +51 -0
- internal/agent/hooked_tool.go +142 -0
- internal/agent/hooked_tool_test.go +147 -0
- internal/agent/loop_detection.go +92 -0
- internal/agent/loop_detection_test.go +205 -0
- internal/agent/opencode_routing_test.go +110 -0
- internal/agent/prism_model_test.go +101 -0
- internal/agent/prompts.go +42 -0
- internal/agent/queued_runid_test.go +181 -0
- internal/agent/request_timeout.go +123 -0
- internal/agent/request_timeout_test.go +286 -0
- internal/agent/run_complete_test.go +322 -0
- internal/agent/run_marker.go +52 -0
- internal/agent/runid.go +33 -0
- internal/agent/usage_fallback.go +176 -0
- internal/agent/usage_fallback_test.go +339 -0
- internal/apigen/docs.html.tmpl +47 -0
- internal/apigen/endpoint.go +168 -0
- internal/apigen/endpoint_test.go +207 -0
- internal/apigen/openapi.go +147 -0
- internal/apigen/render.go +94 -0
- internal/apigen/schema.go +134 -0
- internal/backend/accepted_run_integration_test.go +131 -0
- internal/backend/agent.go +276 -0
- internal/backend/agent_runcomplete_test.go +163 -0
- internal/backend/agent_test.go +164 -0
- internal/backend/backend.go +1137 -0
- internal/backend/backend_skills_test.go +167 -0
- internal/backend/backend_test.go +1784 -0
- internal/backend/config.go +347 -0
- internal/backend/config_test.go +207 -0
- internal/backend/events.go +145 -0
- internal/backend/filetracker.go +37 -0
internal/agent/.env.sample
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CRUSH_ANTHROPIC_API_KEY=
|
| 2 |
+
CRUSH_HYPER_API_KEY=
|
| 3 |
+
CRUSH_OPENAI_API_KEY=
|
| 4 |
+
CRUSH_OPENROUTER_API_KEY=
|
| 5 |
+
CRUSH_ZAI_API_KEY=
|
internal/agent/accepted_run_test.go
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"testing"
|
| 6 |
+
|
| 7 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 8 |
+
"github.com/stretchr/testify/assert"
|
| 9 |
+
"github.com/stretchr/testify/require"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
// newCancelTestAgent builds a DB-backed sessionAgent with no model. The
|
| 13 |
+
// tests here exercise the dispatch/cancel/persist paths, none of which
|
| 14 |
+
// reach agent.Stream, so a model is unnecessary.
|
| 15 |
+
func newCancelTestAgent(t *testing.T) (*sessionAgent, fakeEnv) {
|
| 16 |
+
t.Helper()
|
| 17 |
+
env := testEnv(t)
|
| 18 |
+
sa := NewSessionAgent(SessionAgentOptions{
|
| 19 |
+
Sessions: env.sessions,
|
| 20 |
+
Messages: env.messages,
|
| 21 |
+
}).(*sessionAgent)
|
| 22 |
+
return sa, env
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
func (a *sessionAgent) acceptedCount(sessionID string) int {
|
| 26 |
+
c, _ := a.acceptedRuns.Get(sessionID)
|
| 27 |
+
return c
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
func (a *sessionAgent) hasPendingCancel(sessionID string) bool {
|
| 31 |
+
mark, ok := a.cancelMark.Get(sessionID)
|
| 32 |
+
return ok && mark > 0
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func (a *sessionAgent) pendingCancelMark(sessionID string) uint64 {
|
| 36 |
+
mark, _ := a.cancelMark.Get(sessionID)
|
| 37 |
+
return mark
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
func TestAcceptedRun_CloseIsIdempotent(t *testing.T) {
|
| 41 |
+
t.Parallel()
|
| 42 |
+
sa, _ := newCancelTestAgent(t)
|
| 43 |
+
|
| 44 |
+
accept := sa.BeginAccepted("sid")
|
| 45 |
+
require.Equal(t, "sid", accept.SessionID())
|
| 46 |
+
require.Equal(t, 1, sa.acceptedCount("sid"))
|
| 47 |
+
|
| 48 |
+
accept.Close()
|
| 49 |
+
require.Equal(t, 0, sa.acceptedCount("sid"))
|
| 50 |
+
|
| 51 |
+
// Repeated Close must not underflow the counter.
|
| 52 |
+
accept.Close()
|
| 53 |
+
accept.Close()
|
| 54 |
+
require.Equal(t, 0, sa.acceptedCount("sid"))
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
func TestAcceptedRun_MultipleReservations(t *testing.T) {
|
| 58 |
+
t.Parallel()
|
| 59 |
+
sa, _ := newCancelTestAgent(t)
|
| 60 |
+
|
| 61 |
+
a1 := sa.BeginAccepted("sid")
|
| 62 |
+
a2 := sa.BeginAccepted("sid")
|
| 63 |
+
require.Equal(t, 2, sa.acceptedCount("sid"))
|
| 64 |
+
|
| 65 |
+
a1.Close()
|
| 66 |
+
require.Equal(t, 1, sa.acceptedCount("sid"))
|
| 67 |
+
|
| 68 |
+
a2.Close()
|
| 69 |
+
require.Equal(t, 0, sa.acceptedCount("sid"))
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
func TestAcceptedRun_NilSafe(t *testing.T) {
|
| 73 |
+
t.Parallel()
|
| 74 |
+
var accept *AcceptedRun
|
| 75 |
+
require.Equal(t, "", accept.SessionID())
|
| 76 |
+
// Must not panic.
|
| 77 |
+
accept.Close()
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
func TestCancel_IdleDoesNotRecordPendingCancel(t *testing.T) {
|
| 81 |
+
t.Parallel()
|
| 82 |
+
sa, _ := newCancelTestAgent(t)
|
| 83 |
+
|
| 84 |
+
// No accepted run, no active request: a true no-op.
|
| 85 |
+
sa.Cancel("sid")
|
| 86 |
+
require.False(t, sa.hasPendingCancel("sid"))
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
func TestCancel_AcceptedRecordsPendingCancel(t *testing.T) {
|
| 90 |
+
t.Parallel()
|
| 91 |
+
sa, _ := newCancelTestAgent(t)
|
| 92 |
+
|
| 93 |
+
accept := sa.BeginAccepted("sid")
|
| 94 |
+
defer accept.Close()
|
| 95 |
+
|
| 96 |
+
sa.Cancel("sid")
|
| 97 |
+
require.True(t, sa.hasPendingCancel("sid"))
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
func TestCancel_SecondCancelWhilePendingIsNoOp(t *testing.T) {
|
| 101 |
+
t.Parallel()
|
| 102 |
+
sa, _ := newCancelTestAgent(t)
|
| 103 |
+
|
| 104 |
+
accept := sa.BeginAccepted("sid")
|
| 105 |
+
defer accept.Close()
|
| 106 |
+
|
| 107 |
+
sa.Cancel("sid")
|
| 108 |
+
require.True(t, sa.hasPendingCancel("sid"))
|
| 109 |
+
|
| 110 |
+
// A second cancel while a pending cancel is already recorded must
|
| 111 |
+
// remain a single pending cancel; one Run consumes exactly one.
|
| 112 |
+
sa.Cancel("sid")
|
| 113 |
+
require.True(t, sa.hasPendingCancel("sid"))
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
func TestRun_CancelOnEntryPersistsCanceledTurn(t *testing.T) {
|
| 117 |
+
t.Parallel()
|
| 118 |
+
sa, env := newCancelTestAgent(t)
|
| 119 |
+
|
| 120 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 121 |
+
require.NoError(t, err)
|
| 122 |
+
|
| 123 |
+
accept := sa.BeginAccepted(sess.ID)
|
| 124 |
+
// A cancel arrives in the accepted-but-not-yet-active window.
|
| 125 |
+
sa.Cancel(sess.ID)
|
| 126 |
+
require.True(t, sa.hasPendingCancel(sess.ID))
|
| 127 |
+
|
| 128 |
+
result, err := sa.Run(t.Context(), SessionAgentCall{
|
| 129 |
+
SessionID: sess.ID,
|
| 130 |
+
Prompt: "hello",
|
| 131 |
+
Accepted: accept,
|
| 132 |
+
})
|
| 133 |
+
require.NoError(t, err)
|
| 134 |
+
require.Nil(t, result)
|
| 135 |
+
|
| 136 |
+
// The pending cancel was consumed and the accept released.
|
| 137 |
+
require.False(t, sa.hasPendingCancel(sess.ID))
|
| 138 |
+
require.Equal(t, 0, sa.acceptedCount(sess.ID))
|
| 139 |
+
|
| 140 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 141 |
+
require.NoError(t, err)
|
| 142 |
+
require.Len(t, msgs, 2)
|
| 143 |
+
assert.Equal(t, message.User, msgs[0].Role)
|
| 144 |
+
assert.Equal(t, message.Assistant, msgs[1].Role)
|
| 145 |
+
assert.Equal(t, message.FinishReasonCanceled, msgs[1].FinishReason())
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
func TestPersistCanceledTurn_WritesBothWhenUserMissing(t *testing.T) {
|
| 149 |
+
t.Parallel()
|
| 150 |
+
sa, env := newCancelTestAgent(t)
|
| 151 |
+
|
| 152 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 153 |
+
require.NoError(t, err)
|
| 154 |
+
|
| 155 |
+
err = sa.persistCanceledTurn(t.Context(), SessionAgentCall{
|
| 156 |
+
SessionID: sess.ID,
|
| 157 |
+
Prompt: "hello",
|
| 158 |
+
}, false)
|
| 159 |
+
require.NoError(t, err)
|
| 160 |
+
|
| 161 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 162 |
+
require.NoError(t, err)
|
| 163 |
+
require.Len(t, msgs, 2)
|
| 164 |
+
assert.Equal(t, message.User, msgs[0].Role)
|
| 165 |
+
assert.Equal(t, message.Assistant, msgs[1].Role)
|
| 166 |
+
assert.Equal(t, message.FinishReasonCanceled, msgs[1].FinishReason())
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
func TestPersistCanceledTurn_WritesAssistantOnlyWhenUserCreated(t *testing.T) {
|
| 170 |
+
t.Parallel()
|
| 171 |
+
sa, env := newCancelTestAgent(t)
|
| 172 |
+
|
| 173 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 174 |
+
require.NoError(t, err)
|
| 175 |
+
|
| 176 |
+
// Simulate PrepareStep having already created the user message.
|
| 177 |
+
_, err = sa.createUserMessage(t.Context(), SessionAgentCall{
|
| 178 |
+
SessionID: sess.ID,
|
| 179 |
+
Prompt: "hello",
|
| 180 |
+
})
|
| 181 |
+
require.NoError(t, err)
|
| 182 |
+
|
| 183 |
+
err = sa.persistCanceledTurn(t.Context(), SessionAgentCall{
|
| 184 |
+
SessionID: sess.ID,
|
| 185 |
+
Prompt: "hello",
|
| 186 |
+
}, true)
|
| 187 |
+
require.NoError(t, err)
|
| 188 |
+
|
| 189 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 190 |
+
require.NoError(t, err)
|
| 191 |
+
require.Len(t, msgs, 2)
|
| 192 |
+
assert.Equal(t, message.User, msgs[0].Role)
|
| 193 |
+
assert.Equal(t, message.Assistant, msgs[1].Role)
|
| 194 |
+
assert.Equal(t, message.FinishReasonCanceled, msgs[1].FinishReason())
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
func TestPersistCanceledTurn_SucceedsWithCanceledContext(t *testing.T) {
|
| 198 |
+
t.Parallel()
|
| 199 |
+
sa, env := newCancelTestAgent(t)
|
| 200 |
+
|
| 201 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 202 |
+
require.NoError(t, err)
|
| 203 |
+
|
| 204 |
+
// Simulate workspace shutdown having already canceled the run
|
| 205 |
+
// context. WithoutCancel must let the writes through.
|
| 206 |
+
ctx, cancel := context.WithCancel(t.Context())
|
| 207 |
+
cancel()
|
| 208 |
+
|
| 209 |
+
err = sa.persistCanceledTurn(ctx, SessionAgentCall{
|
| 210 |
+
SessionID: sess.ID,
|
| 211 |
+
Prompt: "hello",
|
| 212 |
+
}, false)
|
| 213 |
+
require.NoError(t, err)
|
| 214 |
+
|
| 215 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 216 |
+
require.NoError(t, err)
|
| 217 |
+
require.Len(t, msgs, 2)
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
func TestClearPendingCancel(t *testing.T) {
|
| 221 |
+
t.Parallel()
|
| 222 |
+
sa, _ := newCancelTestAgent(t)
|
| 223 |
+
|
| 224 |
+
accept := sa.BeginAccepted("sid")
|
| 225 |
+
defer accept.Close()
|
| 226 |
+
sa.Cancel("sid")
|
| 227 |
+
require.True(t, sa.hasPendingCancel("sid"))
|
| 228 |
+
|
| 229 |
+
sa.clearPendingCancel("sid")
|
| 230 |
+
require.False(t, sa.hasPendingCancel("sid"))
|
| 231 |
+
}
|
internal/agent/agent.go
ADDED
|
@@ -0,0 +1,2331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package agent is the core orchestration layer for Crush AI agents.
|
| 2 |
+
//
|
| 3 |
+
// It provides session-based AI agent functionality for managing
|
| 4 |
+
// conversations, tool execution, and message handling. It coordinates
|
| 5 |
+
// interactions between language models, messages, sessions, and tools while
|
| 6 |
+
// handling features like automatic summarization, queuing, and token
|
| 7 |
+
// management.
|
| 8 |
+
package agent
|
| 9 |
+
|
| 10 |
+
import (
|
| 11 |
+
"cmp"
|
| 12 |
+
"context"
|
| 13 |
+
_ "embed"
|
| 14 |
+
"encoding/base64"
|
| 15 |
+
"encoding/json"
|
| 16 |
+
"errors"
|
| 17 |
+
"fmt"
|
| 18 |
+
"log/slog"
|
| 19 |
+
"math"
|
| 20 |
+
"net/http"
|
| 21 |
+
"os"
|
| 22 |
+
"regexp"
|
| 23 |
+
"strconv"
|
| 24 |
+
"strings"
|
| 25 |
+
"sync"
|
| 26 |
+
"sync/atomic"
|
| 27 |
+
"time"
|
| 28 |
+
|
| 29 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 30 |
+
"charm.land/fantasy"
|
| 31 |
+
"charm.land/fantasy/providers/anthropic"
|
| 32 |
+
"charm.land/fantasy/providers/bedrock"
|
| 33 |
+
"charm.land/fantasy/providers/google"
|
| 34 |
+
"charm.land/fantasy/providers/openai"
|
| 35 |
+
"charm.land/fantasy/providers/openrouter"
|
| 36 |
+
"charm.land/fantasy/providers/vercel"
|
| 37 |
+
"charm.land/lipgloss/v2"
|
| 38 |
+
"github.com/charmbracelet/crush/internal/agent/hyper"
|
| 39 |
+
"github.com/charmbracelet/crush/internal/agent/notify"
|
| 40 |
+
"github.com/charmbracelet/crush/internal/agent/tools"
|
| 41 |
+
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
|
| 42 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 43 |
+
"github.com/charmbracelet/crush/internal/csync"
|
| 44 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 45 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 46 |
+
"github.com/charmbracelet/crush/internal/session"
|
| 47 |
+
"github.com/charmbracelet/crush/internal/stringext"
|
| 48 |
+
"github.com/charmbracelet/crush/internal/version"
|
| 49 |
+
"github.com/charmbracelet/x/ansi"
|
| 50 |
+
"github.com/charmbracelet/x/exp/charmtone"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
const (
|
| 54 |
+
DefaultSessionName = "Untitled Session"
|
| 55 |
+
|
| 56 |
+
// Constants for auto-summarization thresholds
|
| 57 |
+
largeContextWindowThreshold = 200_000
|
| 58 |
+
largeContextWindowBuffer = 20_000
|
| 59 |
+
smallContextWindowRatio = 0.2
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
var userAgent = fmt.Sprintf("Charm-Crush/%s (https://charm.land/crush)", version.Version)
|
| 63 |
+
|
| 64 |
+
//go:embed templates/title.md
|
| 65 |
+
var titlePrompt []byte
|
| 66 |
+
|
| 67 |
+
//go:embed templates/summary.md
|
| 68 |
+
var summaryPrompt []byte
|
| 69 |
+
|
| 70 |
+
// Used to remove <think> tags from generated titles.
|
| 71 |
+
var (
|
| 72 |
+
thinkTagRegex = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
| 73 |
+
orphanThinkTagRegex = regexp.MustCompile(`</?think>`)
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
type SessionAgentCall struct {
|
| 77 |
+
SessionID string
|
| 78 |
+
// RunID, when non-empty, is the caller-supplied correlator that
|
| 79 |
+
// gets echoed back on the notify.RunComplete event emitted for
|
| 80 |
+
// this turn. It is preserved when the call is enqueued behind a
|
| 81 |
+
// busy session so the queued turn's terminal event is still
|
| 82 |
+
// recognisable to the original caller. Callers that need a
|
| 83 |
+
// reliable completion contract (e.g. `crush run` against a
|
| 84 |
+
// session that may be busy) MUST set it; SessionID alone is
|
| 85 |
+
// ambiguous when concurrent turns share the same session.
|
| 86 |
+
RunID string
|
| 87 |
+
Prompt string
|
| 88 |
+
ProviderOptions fantasy.ProviderOptions
|
| 89 |
+
Attachments []message.Attachment
|
| 90 |
+
MaxOutputTokens int64
|
| 91 |
+
Temperature *float64
|
| 92 |
+
TopP *float64
|
| 93 |
+
TopK *int64
|
| 94 |
+
FrequencyPenalty *float64
|
| 95 |
+
PresencePenalty *float64
|
| 96 |
+
NonInteractive bool
|
| 97 |
+
// OnComplete, when non-nil, replaces the default RunComplete
|
| 98 |
+
// publish path: the inner Run hands the terminal payload to this
|
| 99 |
+
// callback instead of emitting it on the RunComplete broker. The
|
| 100 |
+
// coordinator uses this hook to coalesce the unauthorized →
|
| 101 |
+
// re-auth → retry chain into a single user-visible terminal
|
| 102 |
+
// event, so non-interactive clients (e.g. `crush run`) don't
|
| 103 |
+
// exit on a stale failed-attempt RunComplete before the
|
| 104 |
+
// successful retry. It is intentionally stripped when queueing
|
| 105 |
+
// a busy-session call (see Run): the originating
|
| 106 |
+
// coordinator.Run has long returned by the time the queued
|
| 107 |
+
// recursion drains, so falling back to the default broker
|
| 108 |
+
// publish keeps the event visible to subscribers.
|
| 109 |
+
OnComplete func(notify.RunComplete)
|
| 110 |
+
// Accepted, when non-nil, is the accept reservation taken by
|
| 111 |
+
// BeginAccepted before the call was dispatched onto a goroutine
|
| 112 |
+
// (the client/server fire-and-forget path). Run consumes it under
|
| 113 |
+
// dispatchMu[SessionID] once the accepted -> (cancel-on-entry |
|
| 114 |
+
// queued | active) transition has been chosen. When nil
|
| 115 |
+
// (in-process / local callers like AppWorkspace), behavior is
|
| 116 |
+
// unchanged and no accept tracking applies.
|
| 117 |
+
Accepted *AcceptedRun
|
| 118 |
+
// acceptSeq carries the accept sequence of the handle that produced
|
| 119 |
+
// this call after it has been enqueued and its Accepted handle
|
| 120 |
+
// stripped. The queue-drain paths compare it against a session's
|
| 121 |
+
// cancel mark so a follow-up queued before a cancel is dropped while
|
| 122 |
+
// one queued after the cancel survives. 0 means untracked (an
|
| 123 |
+
// in-process enqueue with no accept reservation), which the drain
|
| 124 |
+
// paths treat as covered by any present mark, preserving the
|
| 125 |
+
// pre-sequence behavior.
|
| 126 |
+
acceptSeq uint64
|
| 127 |
+
// OnAuthRefresh, when non-nil, is called by fantasy when a stream
|
| 128 |
+
// fails with an authentication error (HTTP 401). The callback should
|
| 129 |
+
// refresh credentials and return nil on success, in which case
|
| 130 |
+
// fantasy retries the stream transparently. Returning an error
|
| 131 |
+
// surfaces the original auth error without retry.
|
| 132 |
+
OnAuthRefresh func(ctx context.Context, err *fantasy.ProviderError) error
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
type SessionAgent interface {
|
| 136 |
+
Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
|
| 137 |
+
BeginAccepted(sessionID string) *AcceptedRun
|
| 138 |
+
SetModels(large Model, small Model)
|
| 139 |
+
SetTools(tools []fantasy.AgentTool)
|
| 140 |
+
SetSystemPrompt(systemPrompt string)
|
| 141 |
+
Cancel(sessionID string)
|
| 142 |
+
CancelAll()
|
| 143 |
+
IsSessionBusy(sessionID string) bool
|
| 144 |
+
IsBusy() bool
|
| 145 |
+
QueuedPrompts(sessionID string) int
|
| 146 |
+
QueuedPromptsList(sessionID string) []string
|
| 147 |
+
ClearQueue(sessionID string)
|
| 148 |
+
Summarize(context.Context, string, fantasy.ProviderOptions, func(context.Context, *fantasy.ProviderError) error) error
|
| 149 |
+
Model() Model
|
| 150 |
+
GenerateTitle(ctx context.Context, sessionID, userPrompt string)
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
type Model struct {
|
| 154 |
+
Model fantasy.LanguageModel
|
| 155 |
+
CatwalkCfg catwalk.Model
|
| 156 |
+
ModelCfg config.SelectedModel
|
| 157 |
+
FlatRate bool
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// activeCancel wraps a context.CancelFunc with a unique pointer identity.
|
| 161 |
+
// The pointer is used for compare-and-delete in the dispatch completion path:
|
| 162 |
+
// when a finishing run's deferred cleanup fires, it must only remove its own
|
| 163 |
+
// entry — not a newer run's entry that was installed in the window between
|
| 164 |
+
// the explicit Del and the function return.
|
| 165 |
+
type activeCancel struct {
|
| 166 |
+
cancel context.CancelFunc
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
type sessionAgent struct {
|
| 170 |
+
largeModel *csync.Value[Model]
|
| 171 |
+
smallModel *csync.Value[Model]
|
| 172 |
+
systemPromptPrefix *csync.Value[string]
|
| 173 |
+
systemPrompt *csync.Value[string]
|
| 174 |
+
tools *csync.Slice[fantasy.AgentTool]
|
| 175 |
+
|
| 176 |
+
isSubAgent bool
|
| 177 |
+
sessions session.Service
|
| 178 |
+
messages message.Service
|
| 179 |
+
disableAutoSummarize bool
|
| 180 |
+
isYolo bool
|
| 181 |
+
notify pubsub.Publisher[notify.Notification]
|
| 182 |
+
runComplete pubsub.Publisher[notify.RunComplete]
|
| 183 |
+
|
| 184 |
+
messageQueue *csync.Map[string, []SessionAgentCall]
|
| 185 |
+
activeRequests *csync.Map[string, *activeCancel]
|
| 186 |
+
|
| 187 |
+
// dispatchMu holds a per-session mutex that serializes the
|
| 188 |
+
// accepted -> (cancel-on-entry | queued | active) transition in
|
| 189 |
+
// Run against a concurrent Cancel. The lock is held only during
|
| 190 |
+
// the brief handoff (no DB or LLM I/O under the lock).
|
| 191 |
+
dispatchMu *csync.Map[string, *sync.Mutex]
|
| 192 |
+
// acceptedRuns counts dispatched-but-not-yet-active runs per
|
| 193 |
+
// session. A counter > 0 means a dispatched prompt is in flight
|
| 194 |
+
// and has not yet completed the dispatch handoff in Run. Only
|
| 195 |
+
// BeginAccepted increments it; only AcceptedRun.Close decrements
|
| 196 |
+
// it.
|
| 197 |
+
acceptedRuns *csync.Map[string, int]
|
| 198 |
+
// cancelMark records, per session, a high-water accept sequence: an
|
| 199 |
+
// accepted handle is canceled by it iff the handle's sequence is at
|
| 200 |
+
// or below the mark. Cancel raises the mark to the latest sequence
|
| 201 |
+
// assigned at cancel time, so a single Cancel covers every prompt
|
| 202 |
+
// accepted-but-not-yet-active then, while a prompt accepted later
|
| 203 |
+
// (higher sequence) is never poisoned. Absent or 0 means no pending
|
| 204 |
+
// cancel. It is only raised by Cancel when acceptedRuns > 0, so an
|
| 205 |
+
// idle Escape never records a mark.
|
| 206 |
+
cancelMark *csync.Map[string, uint64]
|
| 207 |
+
// dispatchMuCreate guards lazy creation of per-session entries in
|
| 208 |
+
// dispatchMu so two goroutines can't race to lock different mutex
|
| 209 |
+
// instances for the same session.
|
| 210 |
+
dispatchMuCreate sync.Mutex
|
| 211 |
+
// acceptedMu serializes increments/decrements of acceptedRuns and
|
| 212 |
+
// the assignment of accept sequence numbers from acceptSeqGen. It
|
| 213 |
+
// is separate from dispatchMu so AcceptedRun.Close (which may run
|
| 214 |
+
// while Run holds dispatchMu for the same session) does not
|
| 215 |
+
// deadlock by re-entering the dispatch lock.
|
| 216 |
+
acceptedMu sync.Mutex
|
| 217 |
+
// acceptSeqGen is the monotonic source of accept sequence numbers.
|
| 218 |
+
// Each BeginAccepted increments it under acceptedMu and stamps the
|
| 219 |
+
// returned handle, so sequences strictly increase in accept order
|
| 220 |
+
// across the agent. Cancel uses its current value as the per-session
|
| 221 |
+
// high-water mark.
|
| 222 |
+
acceptSeqGen uint64
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
type SessionAgentOptions struct {
|
| 226 |
+
LargeModel Model
|
| 227 |
+
SmallModel Model
|
| 228 |
+
SystemPromptPrefix string
|
| 229 |
+
SystemPrompt string
|
| 230 |
+
IsSubAgent bool
|
| 231 |
+
DisableAutoSummarize bool
|
| 232 |
+
IsYolo bool
|
| 233 |
+
Sessions session.Service
|
| 234 |
+
Messages message.Service
|
| 235 |
+
Tools []fantasy.AgentTool
|
| 236 |
+
Notify pubsub.Publisher[notify.Notification]
|
| 237 |
+
RunComplete pubsub.Publisher[notify.RunComplete]
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
func NewSessionAgent(
|
| 241 |
+
opts SessionAgentOptions,
|
| 242 |
+
) SessionAgent {
|
| 243 |
+
return &sessionAgent{
|
| 244 |
+
largeModel: csync.NewValue(opts.LargeModel),
|
| 245 |
+
smallModel: csync.NewValue(opts.SmallModel),
|
| 246 |
+
systemPromptPrefix: csync.NewValue(opts.SystemPromptPrefix),
|
| 247 |
+
systemPrompt: csync.NewValue(opts.SystemPrompt),
|
| 248 |
+
isSubAgent: opts.IsSubAgent,
|
| 249 |
+
sessions: opts.Sessions,
|
| 250 |
+
messages: opts.Messages,
|
| 251 |
+
disableAutoSummarize: opts.DisableAutoSummarize,
|
| 252 |
+
tools: csync.NewSliceFrom(opts.Tools),
|
| 253 |
+
isYolo: opts.IsYolo,
|
| 254 |
+
notify: opts.Notify,
|
| 255 |
+
runComplete: opts.RunComplete,
|
| 256 |
+
messageQueue: csync.NewMap[string, []SessionAgentCall](),
|
| 257 |
+
activeRequests: csync.NewMap[string, *activeCancel](),
|
| 258 |
+
dispatchMu: csync.NewMap[string, *sync.Mutex](),
|
| 259 |
+
acceptedRuns: csync.NewMap[string, int](),
|
| 260 |
+
cancelMark: csync.NewMap[string, uint64](),
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// AcceptedRun owns exactly one accept reservation taken by
|
| 265 |
+
// BeginAccepted. It is the only carrier of accept-state across the
|
| 266 |
+
// backend.runAgent / Coordinator.Run / sessionAgent.Run layers: a
|
| 267 |
+
// counter > 0 means a dispatched prompt is in flight and has not yet
|
| 268 |
+
// completed the dispatch handoff in Run. Close is the only way to
|
| 269 |
+
// release the reservation and is idempotent.
|
| 270 |
+
type AcceptedRun struct {
|
| 271 |
+
agent *sessionAgent
|
| 272 |
+
sessionID string
|
| 273 |
+
// seq is the monotonic accept sequence stamped by BeginAccepted. A
|
| 274 |
+
// cancel covers this handle iff seq is at or below the session's
|
| 275 |
+
// cancel mark, so a handle accepted after a cancel (higher seq) is
|
| 276 |
+
// never poisoned by it.
|
| 277 |
+
seq uint64
|
| 278 |
+
done atomic.Bool
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// Close decrements the accept counter for this reservation. It is safe
|
| 282 |
+
// to call multiple times; only the first call has effect.
|
| 283 |
+
func (r *AcceptedRun) Close() {
|
| 284 |
+
if r == nil {
|
| 285 |
+
return
|
| 286 |
+
}
|
| 287 |
+
if !r.done.CompareAndSwap(false, true) {
|
| 288 |
+
return
|
| 289 |
+
}
|
| 290 |
+
r.agent.endAccepted(r.sessionID)
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
// SessionID exposes the session this reservation is for so the run path
|
| 294 |
+
// can use it without an extra parameter.
|
| 295 |
+
func (r *AcceptedRun) SessionID() string {
|
| 296 |
+
if r == nil {
|
| 297 |
+
return ""
|
| 298 |
+
}
|
| 299 |
+
return r.sessionID
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// BeginAccepted increments the accept counter for sessionID and returns
|
| 303 |
+
// a handle whose Close is the only way to decrement it. It is the only
|
| 304 |
+
// entry point that mutates acceptedRuns.
|
| 305 |
+
func (a *sessionAgent) BeginAccepted(sessionID string) *AcceptedRun {
|
| 306 |
+
a.acceptedMu.Lock()
|
| 307 |
+
defer a.acceptedMu.Unlock()
|
| 308 |
+
count, _ := a.acceptedRuns.Get(sessionID)
|
| 309 |
+
a.acceptedRuns.Set(sessionID, count+1)
|
| 310 |
+
a.acceptSeqGen++
|
| 311 |
+
return &AcceptedRun{agent: a, sessionID: sessionID, seq: a.acceptSeqGen}
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// endAccepted decrements the accept counter for sessionID. It is only
|
| 315 |
+
// called via AcceptedRun.Close. It uses a dedicated lock (not the
|
| 316 |
+
// per-session dispatch mutex) so it can run while Run holds dispatchMu
|
| 317 |
+
// for the same session without deadlocking.
|
| 318 |
+
//
|
| 319 |
+
// When the count reaches zero the session's cancel mark is dropped: no
|
| 320 |
+
// accepted handle remains for it to cover, and any handle accepted later
|
| 321 |
+
// gets a strictly higher sequence that the mark would not match anyway.
|
| 322 |
+
// Handles canceled on entry never reach RunComplete, so this is the only
|
| 323 |
+
// place that clears the mark for an all-canceled batch. Sibling handles
|
| 324 |
+
// covered by the same mark are serialized on the per-session dispatch
|
| 325 |
+
// mutex and read the mark before they Close, so this never clears it out
|
| 326 |
+
// from under a covered handle still waiting to enter Run.
|
| 327 |
+
func (a *sessionAgent) endAccepted(sessionID string) {
|
| 328 |
+
a.acceptedMu.Lock()
|
| 329 |
+
defer a.acceptedMu.Unlock()
|
| 330 |
+
count, ok := a.acceptedRuns.Get(sessionID)
|
| 331 |
+
if !ok || count <= 1 {
|
| 332 |
+
a.acceptedRuns.Del(sessionID)
|
| 333 |
+
a.cancelMark.Del(sessionID)
|
| 334 |
+
return
|
| 335 |
+
}
|
| 336 |
+
a.acceptedRuns.Set(sessionID, count-1)
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
// sessionMu returns the per-session dispatch mutex, creating it on first
|
| 340 |
+
// use. Creation is guarded so concurrent callers always observe the same
|
| 341 |
+
// mutex instance for a given session.
|
| 342 |
+
func (a *sessionAgent) sessionMu(sessionID string) *sync.Mutex {
|
| 343 |
+
if mu, ok := a.dispatchMu.Get(sessionID); ok {
|
| 344 |
+
return mu
|
| 345 |
+
}
|
| 346 |
+
a.dispatchMuCreate.Lock()
|
| 347 |
+
defer a.dispatchMuCreate.Unlock()
|
| 348 |
+
if mu, ok := a.dispatchMu.Get(sessionID); ok {
|
| 349 |
+
return mu
|
| 350 |
+
}
|
| 351 |
+
mu := &sync.Mutex{}
|
| 352 |
+
a.dispatchMu.Set(sessionID, mu)
|
| 353 |
+
return mu
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
// enqueueCall appends call to the session's message queue. The
|
| 357 |
+
// OnComplete hook is stripped: the caller that supplied it (typically
|
| 358 |
+
// coordinator.Run) has its own retry/coalesce scope that ends when it
|
| 359 |
+
// returns, so by the time the queue drains nobody is left to consume the
|
| 360 |
+
// buffered terminal event. The recursive Run falls back to the default
|
| 361 |
+
// broker publish, which is what existing subscribers expect for queued
|
| 362 |
+
// turns.
|
| 363 |
+
func (a *sessionAgent) enqueueCall(call SessionAgentCall) {
|
| 364 |
+
existing, ok := a.messageQueue.Get(call.SessionID)
|
| 365 |
+
if !ok {
|
| 366 |
+
existing = []SessionAgentCall{}
|
| 367 |
+
}
|
| 368 |
+
queued := call
|
| 369 |
+
if call.Accepted != nil {
|
| 370 |
+
// Preserve the accept sequence after the handle is stripped so
|
| 371 |
+
// the queue-drain paths can tell a follow-up queued before a
|
| 372 |
+
// cancel (covered by the mark) from one queued after it.
|
| 373 |
+
queued.acceptSeq = call.Accepted.seq
|
| 374 |
+
}
|
| 375 |
+
queued.OnComplete = nil
|
| 376 |
+
queued.Accepted = nil
|
| 377 |
+
existing = append(existing, queued)
|
| 378 |
+
a.messageQueue.Set(call.SessionID, existing)
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
// drainQueueForStep partitions the session's queued calls for the current
|
| 382 |
+
// streaming step under the per-session dispatch mutex so the filtering is
|
| 383 |
+
// atomic against a concurrent Cancel: canceledBySeq requires the caller to
|
| 384 |
+
// hold that mutex, and evaluating it here (rather than after unlocking)
|
| 385 |
+
// prevents a cancel recorded between the drain and the check from being
|
| 386 |
+
// observed inconsistently.
|
| 387 |
+
//
|
| 388 |
+
// Calls covered by a pending cancel are dropped; the dropped ones that
|
| 389 |
+
// carry a RunID are returned in canceledWithRunID so the caller can
|
| 390 |
+
// publish their terminal cancelled RunComplete (a caller waiting on that
|
| 391 |
+
// RunID, e.g. `crush run`, would otherwise hang). Uncanceled calls without
|
| 392 |
+
// a RunID are returned in fold to be folded into the active turn,
|
| 393 |
+
// preserving the existing follow-up behavior. Uncanceled calls that carry
|
| 394 |
+
// a RunID are left in the queue so each runs as its own turn via the
|
| 395 |
+
// recursive run path and publishes its own RunComplete, giving every
|
| 396 |
+
// RunID-bearing prompt an explicit lifecycle instead of being silently
|
| 397 |
+
// absorbed into another turn. fold is processed by the caller without the
|
| 398 |
+
// lock held.
|
| 399 |
+
func (a *sessionAgent) drainQueueForStep(sessionID string) (fold, canceledWithRunID []SessionAgentCall) {
|
| 400 |
+
dispatchLock := a.sessionMu(sessionID)
|
| 401 |
+
dispatchLock.Lock()
|
| 402 |
+
defer dispatchLock.Unlock()
|
| 403 |
+
queuedCalls, _ := a.messageQueue.Get(sessionID)
|
| 404 |
+
var keep []SessionAgentCall
|
| 405 |
+
for _, queued := range queuedCalls {
|
| 406 |
+
if a.canceledBySeq(sessionID, queued.acceptSeq) {
|
| 407 |
+
if queued.RunID != "" {
|
| 408 |
+
canceledWithRunID = append(canceledWithRunID, queued)
|
| 409 |
+
}
|
| 410 |
+
continue
|
| 411 |
+
}
|
| 412 |
+
if queued.RunID != "" {
|
| 413 |
+
keep = append(keep, queued)
|
| 414 |
+
continue
|
| 415 |
+
}
|
| 416 |
+
fold = append(fold, queued)
|
| 417 |
+
}
|
| 418 |
+
if len(keep) == 0 {
|
| 419 |
+
a.messageQueue.Del(sessionID)
|
| 420 |
+
} else {
|
| 421 |
+
a.messageQueue.Set(sessionID, keep)
|
| 422 |
+
}
|
| 423 |
+
return fold, canceledWithRunID
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
// publishCanceledQueueDrops emits a terminal cancelled RunComplete for
|
| 427 |
+
// every dropped queued call that carries a RunID. A queued prompt removed
|
| 428 |
+
// from the queue without ever running — covered by a pending cancel, or
|
| 429 |
+
// cleared by Cancel/ClearQueue — would otherwise leave a caller blocked on
|
| 430 |
+
// that RunID: `crush run` ignores live message events and exits only on a
|
| 431 |
+
// RunComplete whose RunID matches. Calls without a RunID had no such waiter
|
| 432 |
+
// and are dropped silently as before. A detached, bounded context keeps the
|
| 433 |
+
// must-deliver publish alive even when the run context that triggered the
|
| 434 |
+
// drop is already canceled.
|
| 435 |
+
func (a *sessionAgent) publishCanceledQueueDrops(drops []SessionAgentCall) {
|
| 436 |
+
var hasRunID bool
|
| 437 |
+
for _, d := range drops {
|
| 438 |
+
if d.RunID != "" {
|
| 439 |
+
hasRunID = true
|
| 440 |
+
break
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
if !hasRunID {
|
| 444 |
+
return
|
| 445 |
+
}
|
| 446 |
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
| 447 |
+
defer cancel()
|
| 448 |
+
for _, d := range drops {
|
| 449 |
+
if d.RunID == "" {
|
| 450 |
+
continue
|
| 451 |
+
}
|
| 452 |
+
a.publishRunComplete(ctx, d, notify.RunComplete{
|
| 453 |
+
SessionID: d.SessionID,
|
| 454 |
+
RunID: d.RunID,
|
| 455 |
+
Cancelled: true,
|
| 456 |
+
})
|
| 457 |
+
}
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
// clearQueueAndNotify removes all queued prompts for the session and
|
| 461 |
+
// publishes a terminal cancelled RunComplete for any that carried a RunID,
|
| 462 |
+
// so callers waiting on those RunIDs (e.g. `crush run`) are not left
|
| 463 |
+
// hanging when their queued prompt is discarded without running.
|
| 464 |
+
func (a *sessionAgent) clearQueueAndNotify(sessionID string) {
|
| 465 |
+
queued, ok := a.messageQueue.Get(sessionID)
|
| 466 |
+
a.messageQueue.Del(sessionID)
|
| 467 |
+
if !ok {
|
| 468 |
+
return
|
| 469 |
+
}
|
| 470 |
+
a.publishCanceledQueueDrops(queued)
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
// clearPendingCancel removes any pending-cancel mark for sessionID. It
|
| 474 |
+
// takes the per-session dispatch lock so it is ordered against Cancel
|
| 475 |
+
// and the dispatch handoff.
|
| 476 |
+
func (a *sessionAgent) clearPendingCancel(sessionID string) {
|
| 477 |
+
mu := a.sessionMu(sessionID)
|
| 478 |
+
mu.Lock()
|
| 479 |
+
defer mu.Unlock()
|
| 480 |
+
a.cancelMark.Del(sessionID)
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
// canceledBySeq reports whether an accepted handle or queued call with
|
| 484 |
+
// the given accept sequence is covered by a pending cancel for the
|
| 485 |
+
// session. Callers must hold the session's dispatch mutex. A tracked
|
| 486 |
+
// sequence (seq > 0) is covered only when it is at or below the cancel
|
| 487 |
+
// high-water mark, so a prompt accepted after the cancel (higher seq) is
|
| 488 |
+
// never poisoned. An untracked sequence (seq == 0, an in-process enqueue
|
| 489 |
+
// with no accept reservation) is covered whenever any mark is present,
|
| 490 |
+
// preserving the pre-sequence behavior. The mark is not consumed: it
|
| 491 |
+
// stays so every sibling handle it covers observes the same cancel, and
|
| 492 |
+
// a later handle (higher seq) ignores it regardless.
|
| 493 |
+
func (a *sessionAgent) canceledBySeq(sessionID string, seq uint64) bool {
|
| 494 |
+
mark, ok := a.cancelMark.Get(sessionID)
|
| 495 |
+
if !ok || mark == 0 {
|
| 496 |
+
return false
|
| 497 |
+
}
|
| 498 |
+
return seq == 0 || seq <= mark
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
// persistCanceledTurn writes the user/assistant records for a turn that
|
| 502 |
+
// was canceled before (or just as) streaming would have produced them.
|
| 503 |
+
// It creates the user message only when it was not already created by an
|
| 504 |
+
// earlier createUserMessage call (userMsgCreated), then writes an
|
| 505 |
+
// assistant message with FinishReasonCanceled. Both writes use
|
| 506 |
+
// context.WithoutCancel(ctx) so workspace shutdown (which cancels the run
|
| 507 |
+
// context) can't drop them.
|
| 508 |
+
func (a *sessionAgent) persistCanceledTurn(ctx context.Context, call SessionAgentCall, userMsgCreated bool) error {
|
| 509 |
+
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
| 510 |
+
defer cancel()
|
| 511 |
+
if !userMsgCreated {
|
| 512 |
+
if _, err := a.createUserMessage(writeCtx, call); err != nil {
|
| 513 |
+
return err
|
| 514 |
+
}
|
| 515 |
+
}
|
| 516 |
+
largeModel := a.largeModel.Get()
|
| 517 |
+
assistant, err := a.messages.Create(writeCtx, call.SessionID, message.CreateMessageParams{
|
| 518 |
+
Role: message.Assistant,
|
| 519 |
+
Parts: []message.ContentPart{},
|
| 520 |
+
Model: largeModel.ModelCfg.Model,
|
| 521 |
+
Provider: largeModel.ModelCfg.Provider,
|
| 522 |
+
})
|
| 523 |
+
if err != nil {
|
| 524 |
+
return err
|
| 525 |
+
}
|
| 526 |
+
assistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
|
| 527 |
+
return a.messages.Update(writeCtx, assistant)
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
// publishRunComplete emits the authoritative terminal event for a turn.
|
| 531 |
+
// It honors the per-call OnComplete hook when set (so the coordinator can
|
| 532 |
+
// coalesce retries) and otherwise falls back to the RunComplete broker.
|
| 533 |
+
// ctx is used only for the bounded-blocking must-deliver publish; the
|
| 534 |
+
// terminal payload is supplied by the caller. This is the single emit path
|
| 535 |
+
// shared by the streaming defer and the cancel-on-entry early return so a
|
| 536 |
+
// caller waiting on RunComplete (e.g. `crush run` with a RunID) always
|
| 537 |
+
// observes exactly one terminal event regardless of which Run branch ends
|
| 538 |
+
// the turn.
|
| 539 |
+
func (a *sessionAgent) publishRunComplete(ctx context.Context, call SessionAgentCall, complete notify.RunComplete) {
|
| 540 |
+
if call.OnComplete != nil {
|
| 541 |
+
call.OnComplete(complete)
|
| 542 |
+
return
|
| 543 |
+
}
|
| 544 |
+
if a.runComplete == nil {
|
| 545 |
+
return
|
| 546 |
+
}
|
| 547 |
+
a.runComplete.PublishMustDeliver(ctx, pubsub.UpdatedEvent, complete)
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
// ValidateCall performs the cheap structural validation that
|
| 551 |
+
// sessionAgent.Run requires before a call can be dispatched: a call must
|
| 552 |
+
// carry either a non-empty prompt or a text attachment, and it must name a
|
| 553 |
+
// session. It is exported so callers that accept a run before dispatching it
|
| 554 |
+
// (e.g. backend.SendMessage) can apply the same checks and keep the error
|
| 555 |
+
// contract consistent.
|
| 556 |
+
func ValidateCall(call SessionAgentCall) error {
|
| 557 |
+
if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) {
|
| 558 |
+
return ErrEmptyPrompt
|
| 559 |
+
}
|
| 560 |
+
if call.SessionID == "" {
|
| 561 |
+
return ErrSessionMissing
|
| 562 |
+
}
|
| 563 |
+
return nil
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *fantasy.AgentResult, retErr error) {
|
| 567 |
+
if err := ValidateCall(call); err != nil {
|
| 568 |
+
return nil, err
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
// genCtx/cancel are the run context and its cancel func, created under
|
| 572 |
+
// the per-session dispatch mutex below so a concurrent Cancel can observe
|
| 573 |
+
// the activeRequests entry before the assistant message exists.
|
| 574 |
+
var (
|
| 575 |
+
genCtx context.Context
|
| 576 |
+
cancel context.CancelFunc
|
| 577 |
+
userMsgCreated bool
|
| 578 |
+
)
|
| 579 |
+
|
| 580 |
+
// Serialize the dispatch decision (cancel-on-entry | queued | active)
|
| 581 |
+
// against a concurrent Cancel. Cancel takes the same per-session lock, so
|
| 582 |
+
// every cancel observes at least one of: a cancel mark, an activeRequests
|
| 583 |
+
// entry, or a messageQueue entry it then clears. Holding the lock across
|
| 584 |
+
// the busy check and the active registration also makes them atomic, so
|
| 585 |
+
// two concurrent in-process callers — a burst of channel events, or a
|
| 586 |
+
// channel event racing a typed prompt — cannot both pass the busy check
|
| 587 |
+
// and start two runs on the same session.
|
| 588 |
+
sessMu := a.sessionMu(call.SessionID)
|
| 589 |
+
sessMu.Lock()
|
| 590 |
+
|
| 591 |
+
if call.Accepted != nil && a.canceledBySeq(call.SessionID, call.Accepted.seq) {
|
| 592 |
+
// Cancel-on-entry: a cancel arrived while this accepted run was
|
| 593 |
+
// dispatched but not yet active, and this handle's accept sequence
|
| 594 |
+
// is at or below the session's cancel mark. The mark is left in
|
| 595 |
+
// place so sibling handles it also covers observe the same cancel;
|
| 596 |
+
// release the accept reservation, drop the lock, and persist a
|
| 597 |
+
// canceled turn without entering Stream.
|
| 598 |
+
//
|
| 599 |
+
// This path returns before the streaming defer that publishes
|
| 600 |
+
// RunComplete is installed, so emit the terminal event explicitly.
|
| 601 |
+
// Without it, a caller waiting on RunComplete for this RunID (e.g.
|
| 602 |
+
// `crush run`, which ignores message events and blocks on
|
| 603 |
+
// RunComplete) would hang on an immediately-canceled accepted run.
|
| 604 |
+
call.Accepted.Close()
|
| 605 |
+
sessMu.Unlock()
|
| 606 |
+
complete := notify.RunComplete{
|
| 607 |
+
SessionID: call.SessionID,
|
| 608 |
+
RunID: call.RunID,
|
| 609 |
+
Cancelled: true,
|
| 610 |
+
}
|
| 611 |
+
if err := a.persistCanceledTurn(ctx, call, false); err != nil {
|
| 612 |
+
complete.Error = err.Error()
|
| 613 |
+
a.publishRunComplete(ctx, call, complete)
|
| 614 |
+
return nil, err
|
| 615 |
+
}
|
| 616 |
+
a.publishRunComplete(ctx, call, complete)
|
| 617 |
+
return nil, nil
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
if a.IsSessionBusy(call.SessionID) {
|
| 621 |
+
// Busy: an earlier prompt is active. Queue this call so it is
|
| 622 |
+
// folded into (or sequenced after) the active turn, and release any
|
| 623 |
+
// accept reservation. A Cancel arriving after this point sees the
|
| 624 |
+
// active entry and clears the queue.
|
| 625 |
+
//
|
| 626 |
+
// enqueueCall strips OnComplete: the caller that supplied the hook
|
| 627 |
+
// (typically coordinator.Run) has its own retry/coalesce scope that
|
| 628 |
+
// ends when it returns, so by the time the queue drains nobody is
|
| 629 |
+
// left to consume the buffered terminal event. The queued turn falls
|
| 630 |
+
// back to the default broker publish, which is what existing
|
| 631 |
+
// subscribers expect.
|
| 632 |
+
a.enqueueCall(call)
|
| 633 |
+
if call.Accepted != nil {
|
| 634 |
+
call.Accepted.Close()
|
| 635 |
+
}
|
| 636 |
+
sessMu.Unlock()
|
| 637 |
+
return nil, nil
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
// Idle: become the active run. Register the cancel func before dropping
|
| 641 |
+
// the lock so a Cancel that arrives between here and assistant creation
|
| 642 |
+
// is not lost.
|
| 643 |
+
runCtx := context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
|
| 644 |
+
genCtx, cancel = context.WithCancel(runCtx)
|
| 645 |
+
ac := &activeCancel{cancel: cancel}
|
| 646 |
+
a.activeRequests.Set(call.SessionID, ac)
|
| 647 |
+
if call.Accepted != nil {
|
| 648 |
+
call.Accepted.Close()
|
| 649 |
+
}
|
| 650 |
+
sessMu.Unlock()
|
| 651 |
+
|
| 652 |
+
defer cancel()
|
| 653 |
+
// Conditional cleanup: only remove our entry if it hasn't been replaced
|
| 654 |
+
// by a newer run. Without this guard, the deferred Del fires after a
|
| 655 |
+
// concurrent run registers in the completion window, silently wiping
|
| 656 |
+
// the new run's cancel and breaking cancellation.
|
| 657 |
+
defer a.activeRequests.CompareAndDelete(call.SessionID, ac)
|
| 658 |
+
|
| 659 |
+
// Copy mutable fields under lock to avoid races with SetTools/SetModels.
|
| 660 |
+
agentTools := a.tools.Copy()
|
| 661 |
+
largeModel := a.largeModel.Get()
|
| 662 |
+
systemPrompt := a.systemPrompt.Get()
|
| 663 |
+
promptPrefix := a.systemPromptPrefix.Get()
|
| 664 |
+
var instructions strings.Builder
|
| 665 |
+
|
| 666 |
+
for _, server := range mcp.GetStates() {
|
| 667 |
+
if server.State != mcp.StateConnected {
|
| 668 |
+
continue
|
| 669 |
+
}
|
| 670 |
+
if s := server.Client.InitializeResult().Instructions; s != "" {
|
| 671 |
+
instructions.WriteString(s)
|
| 672 |
+
instructions.WriteString("\n\n")
|
| 673 |
+
}
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
if s := instructions.String(); s != "" {
|
| 677 |
+
systemPrompt += "\n\n<mcp-instructions>\n" + s + "\n</mcp-instructions>"
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
if len(agentTools) > 0 {
|
| 681 |
+
// Add Anthropic caching to the last tool.
|
| 682 |
+
agentTools[len(agentTools)-1].SetProviderOptions(a.getCacheControlOptions())
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
agent := fantasy.NewAgent(
|
| 686 |
+
largeModel.Model,
|
| 687 |
+
fantasy.WithSystemPrompt(systemPrompt),
|
| 688 |
+
fantasy.WithTools(agentTools...),
|
| 689 |
+
fantasy.WithUserAgent(userAgent),
|
| 690 |
+
)
|
| 691 |
+
|
| 692 |
+
sessionLock := sync.Mutex{}
|
| 693 |
+
currentSession, err := a.sessions.Get(ctx, call.SessionID)
|
| 694 |
+
if err != nil {
|
| 695 |
+
return nil, fmt.Errorf("failed to get session: %w", err)
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
msgs, err := a.getSessionMessages(ctx, currentSession)
|
| 699 |
+
if err != nil {
|
| 700 |
+
return nil, fmt.Errorf("failed to get session messages: %w", err)
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
// Generate title from the first real (non-shell) user prompt.
|
| 704 |
+
// can take tens of seconds. Blocking Run on it delays the
|
| 705 |
+
// response to the caller. Use a detached context so the title
|
| 706 |
+
// goroutine survives Run's cancel.
|
| 707 |
+
if !hasUserTextMessage(msgs) {
|
| 708 |
+
titleCtx := context.WithoutCancel(ctx)
|
| 709 |
+
go a.GenerateTitle(titleCtx, call.SessionID, call.Prompt)
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
// Add the user message to the session.
|
| 713 |
+
_, err = a.createUserMessage(ctx, call)
|
| 714 |
+
if err != nil {
|
| 715 |
+
return nil, err
|
| 716 |
+
}
|
| 717 |
+
userMsgCreated = true
|
| 718 |
+
|
| 719 |
+
// Add the session to the context. The run context (genCtx) and its
|
| 720 |
+
// cancel func were already created and registered under the dispatch
|
| 721 |
+
// mutex above for both the accepted and in-process paths.
|
| 722 |
+
ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
|
| 723 |
+
// skipRunComplete is set just before the queued-recursion path so
|
| 724 |
+
// the outer Run doesn't publish a RunComplete that would race
|
| 725 |
+
// with — and be superseded by — the recursive call's own
|
| 726 |
+
// RunComplete (each queued user prompt is its own turn and
|
| 727 |
+
// publishes exactly one terminal event).
|
| 728 |
+
var skipRunComplete bool
|
| 729 |
+
// currentAssistant is declared here so the deferred RunComplete
|
| 730 |
+
// publish below can capture the pointer that PrepareStep will
|
| 731 |
+
// later (re)assign for each streaming step. The final assistant
|
| 732 |
+
// message of the turn is the value reachable through this
|
| 733 |
+
// pointer when the defer runs.
|
| 734 |
+
var currentAssistant *message.Message
|
| 735 |
+
// Drain any debounced message updates before returning. message.Service
|
| 736 |
+
// already flushes synchronously on terminal updates, but a defer here
|
| 737 |
+
// guarantees the contract at every Run exit (success, error, panic
|
| 738 |
+
// recovery upstream) without callers needing to know.
|
| 739 |
+
//
|
| 740 |
+
// After the flush completes — meaning all per-message
|
| 741 |
+
// Publish(UpdatedEvent) calls have fired and been buffered into
|
| 742 |
+
// every subscriber's channel — publish the authoritative
|
| 743 |
+
// RunComplete event for this turn. The flush-then-publish order
|
| 744 |
+
// gives well-behaved clients the best chance of seeing the final
|
| 745 |
+
// message event before RunComplete; the embedded Text field
|
| 746 |
+
// reconciles for clients that observe the events out of order
|
| 747 |
+
// (the pubsub broker fan-in does not serialize publishes from
|
| 748 |
+
// different upstream brokers).
|
| 749 |
+
defer func() {
|
| 750 |
+
// Use a context detached from the run context: workspace
|
| 751 |
+
// shutdown cancels ctx before this goroutine returns, but the
|
| 752 |
+
// buffered streaming deltas must still land before the DB is
|
| 753 |
+
// closed. A short timeout bounds the flush.
|
| 754 |
+
flushCtx, flushCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
| 755 |
+
defer flushCancel()
|
| 756 |
+
if flushErr := a.messages.FlushAll(flushCtx); flushErr != nil {
|
| 757 |
+
slog.Error("Failed to flush pending message updates after run", "error", flushErr)
|
| 758 |
+
}
|
| 759 |
+
if skipRunComplete {
|
| 760 |
+
return
|
| 761 |
+
}
|
| 762 |
+
complete := notify.RunComplete{SessionID: call.SessionID, RunID: call.RunID}
|
| 763 |
+
if currentAssistant != nil {
|
| 764 |
+
complete.MessageID = currentAssistant.ID
|
| 765 |
+
complete.Text = currentAssistant.Content().String()
|
| 766 |
+
}
|
| 767 |
+
if retErr != nil {
|
| 768 |
+
complete.Error = retErr.Error()
|
| 769 |
+
complete.Cancelled = errors.Is(retErr, context.Canceled)
|
| 770 |
+
} else if ctx.Err() != nil {
|
| 771 |
+
complete.Cancelled = true
|
| 772 |
+
}
|
| 773 |
+
// Prefer the per-call hook when supplied so the coordinator
|
| 774 |
+
// can coalesce retries (e.g. unauthorized → re-auth → retry)
|
| 775 |
+
// into a single user-visible terminal event. The fallback
|
| 776 |
+
// must-deliver publish applies bounded-blocking semantics to
|
| 777 |
+
// the authoritative terminal event so a momentarily-full
|
| 778 |
+
// subscriber channel can't silently drop it and hang
|
| 779 |
+
// non-interactive clients waiting on RunComplete.
|
| 780 |
+
a.publishRunComplete(ctx, call, complete)
|
| 781 |
+
}()
|
| 782 |
+
|
| 783 |
+
history, files := a.preparePrompt(msgs, largeModel.CatwalkCfg.SupportsImages, call.Attachments...)
|
| 784 |
+
|
| 785 |
+
startTime := time.Now()
|
| 786 |
+
a.eventPromptSent(call.SessionID)
|
| 787 |
+
|
| 788 |
+
var stepMessages []fantasy.Message
|
| 789 |
+
var shouldSummarize bool
|
| 790 |
+
sanitizedToolCalls := make(map[string]bool)
|
| 791 |
+
// Don't send MaxOutputTokens if 0 — some providers (e.g. LM Studio) reject it
|
| 792 |
+
var maxOutputTokens *int64
|
| 793 |
+
if call.MaxOutputTokens > 0 {
|
| 794 |
+
maxOutputTokens = &call.MaxOutputTokens
|
| 795 |
+
}
|
| 796 |
+
result, err = agent.Stream(genCtx, fantasy.AgentStreamCall{
|
| 797 |
+
Prompt: message.PromptWithTextAttachments(call.Prompt, call.Attachments),
|
| 798 |
+
Files: files,
|
| 799 |
+
Messages: history,
|
| 800 |
+
Headers: sessionHeaders(call.SessionID),
|
| 801 |
+
ProviderOptions: call.ProviderOptions,
|
| 802 |
+
MaxOutputTokens: maxOutputTokens,
|
| 803 |
+
TopP: call.TopP,
|
| 804 |
+
Temperature: call.Temperature,
|
| 805 |
+
PresencePenalty: call.PresencePenalty,
|
| 806 |
+
TopK: call.TopK,
|
| 807 |
+
FrequencyPenalty: call.FrequencyPenalty,
|
| 808 |
+
PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
|
| 809 |
+
prepared.Messages = options.Messages
|
| 810 |
+
for i := range prepared.Messages {
|
| 811 |
+
prepared.Messages[i].ProviderOptions = nil
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
// Use latest tools (updated by SetTools when MCP tools change).
|
| 815 |
+
prepared.Tools = a.tools.Copy()
|
| 816 |
+
|
| 817 |
+
// Drain queued follow-up prompts for this step. Calls covered
|
| 818 |
+
// by a cancel recorded while they sat in the queue are dropped:
|
| 819 |
+
// a cancel that arrived after a prompt was queued must not let
|
| 820 |
+
// it run as part of this step. Coverage is per-call by accept
|
| 821 |
+
// sequence so a follow-up queued after the cancel (higher seq)
|
| 822 |
+
// is not dropped. A dropped prompt carrying a RunID still gets
|
| 823 |
+
// its terminal cancelled RunComplete so a caller waiting on it
|
| 824 |
+
// does not hang. Uncanceled prompts without a RunID are folded
|
| 825 |
+
// into this turn; uncanceled prompts with a RunID are left
|
| 826 |
+
// queued so each runs as its own turn (with its own
|
| 827 |
+
// RunComplete) via the recursive run path below.
|
| 828 |
+
fold, canceledRunIDs := a.drainQueueForStep(call.SessionID)
|
| 829 |
+
a.publishCanceledQueueDrops(canceledRunIDs)
|
| 830 |
+
for _, queued := range fold {
|
| 831 |
+
userMessage, createErr := a.createUserMessage(callContext, queued)
|
| 832 |
+
if createErr != nil {
|
| 833 |
+
return callContext, prepared, createErr
|
| 834 |
+
}
|
| 835 |
+
prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel)
|
| 839 |
+
|
| 840 |
+
lastSystemRoleInx := 0
|
| 841 |
+
systemMessageUpdated := false
|
| 842 |
+
for i, msg := range prepared.Messages {
|
| 843 |
+
// Only add cache control to the last message.
|
| 844 |
+
if msg.Role == fantasy.MessageRoleSystem {
|
| 845 |
+
lastSystemRoleInx = i
|
| 846 |
+
} else if !systemMessageUpdated {
|
| 847 |
+
prepared.Messages[lastSystemRoleInx].ProviderOptions = a.getCacheControlOptions()
|
| 848 |
+
systemMessageUpdated = true
|
| 849 |
+
}
|
| 850 |
+
// Than add cache control to the last 2 messages.
|
| 851 |
+
if i > len(prepared.Messages)-3 {
|
| 852 |
+
prepared.Messages[i].ProviderOptions = a.getCacheControlOptions()
|
| 853 |
+
}
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
if promptPrefix != "" {
|
| 857 |
+
prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
sessionLock.Lock()
|
| 861 |
+
stepMessages = cloneFantasyMessages(prepared.Messages)
|
| 862 |
+
sessionLock.Unlock()
|
| 863 |
+
|
| 864 |
+
var assistantMsg message.Message
|
| 865 |
+
assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
|
| 866 |
+
Role: message.Assistant,
|
| 867 |
+
Parts: []message.ContentPart{},
|
| 868 |
+
Model: largeModel.ModelCfg.Model,
|
| 869 |
+
Provider: largeModel.ModelCfg.Provider,
|
| 870 |
+
})
|
| 871 |
+
if err != nil {
|
| 872 |
+
return callContext, prepared, err
|
| 873 |
+
}
|
| 874 |
+
callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
|
| 875 |
+
callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, largeModel.CatwalkCfg.SupportsImages)
|
| 876 |
+
callContext = context.WithValue(callContext, tools.ModelNameContextKey, largeModel.CatwalkCfg.Name)
|
| 877 |
+
currentAssistant = &assistantMsg
|
| 878 |
+
return callContext, prepared, err
|
| 879 |
+
},
|
| 880 |
+
OnReasoningStart: func(id string, reasoning fantasy.ReasoningContent) error {
|
| 881 |
+
currentAssistant.AppendReasoningContent(reasoning.Text)
|
| 882 |
+
return a.messages.Update(genCtx, *currentAssistant)
|
| 883 |
+
},
|
| 884 |
+
OnReasoningDelta: func(id string, text string) error {
|
| 885 |
+
currentAssistant.AppendReasoningContent(text)
|
| 886 |
+
return a.messages.Update(genCtx, *currentAssistant)
|
| 887 |
+
},
|
| 888 |
+
OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
|
| 889 |
+
// handle anthropic signature
|
| 890 |
+
if anthropicData, ok := reasoning.ProviderMetadata[anthropic.Name]; ok {
|
| 891 |
+
if reasoning, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok {
|
| 892 |
+
currentAssistant.AppendReasoningSignature(reasoning.Signature)
|
| 893 |
+
}
|
| 894 |
+
}
|
| 895 |
+
if googleData, ok := reasoning.ProviderMetadata[google.Name]; ok {
|
| 896 |
+
if reasoning, ok := googleData.(*google.ReasoningMetadata); ok {
|
| 897 |
+
currentAssistant.AppendThoughtSignature(reasoning.Signature, reasoning.ToolID)
|
| 898 |
+
}
|
| 899 |
+
}
|
| 900 |
+
if openaiData, ok := reasoning.ProviderMetadata[openai.Name]; ok {
|
| 901 |
+
if reasoning, ok := openaiData.(*openai.ResponsesReasoningMetadata); ok {
|
| 902 |
+
currentAssistant.SetReasoningResponsesData(reasoning)
|
| 903 |
+
}
|
| 904 |
+
}
|
| 905 |
+
currentAssistant.FinishThinking()
|
| 906 |
+
return a.messages.Update(genCtx, *currentAssistant)
|
| 907 |
+
},
|
| 908 |
+
OnTextDelta: func(id string, text string) error {
|
| 909 |
+
// Strip leading newline from initial text content. This is is
|
| 910 |
+
// particularly important in non-interactive mode where leading
|
| 911 |
+
// newlines are very visible.
|
| 912 |
+
if len(currentAssistant.Parts) == 0 {
|
| 913 |
+
text = strings.TrimPrefix(text, "\n")
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
currentAssistant.AppendContent(text)
|
| 917 |
+
return a.messages.Update(genCtx, *currentAssistant)
|
| 918 |
+
},
|
| 919 |
+
OnToolInputStart: func(id string, toolName string) error {
|
| 920 |
+
toolCall := message.ToolCall{
|
| 921 |
+
ID: id,
|
| 922 |
+
Name: toolName,
|
| 923 |
+
ProviderExecuted: false,
|
| 924 |
+
Finished: false,
|
| 925 |
+
}
|
| 926 |
+
currentAssistant.AddToolCall(toolCall)
|
| 927 |
+
// Use parent ctx instead of genCtx to ensure the update succeeds
|
| 928 |
+
// even if the request is canceled mid-stream
|
| 929 |
+
return a.messages.Update(ctx, *currentAssistant)
|
| 930 |
+
},
|
| 931 |
+
OnRetry: func(err *fantasy.ProviderError, delay time.Duration) {
|
| 932 |
+
slog.Warn("Provider request failed, retrying", providerRetryLogFields(err, delay)...)
|
| 933 |
+
// Reset streamed content so the retried response doesn't
|
| 934 |
+
// concatenate with partial content from the failed attempt.
|
| 935 |
+
// On the final attempt (no more retries), any partial content
|
| 936 |
+
// stays in the message as useful context beneath the error.
|
| 937 |
+
currentAssistant.ResetStreamedContent()
|
| 938 |
+
if updateErr := a.messages.Update(genCtx, *currentAssistant); updateErr != nil {
|
| 939 |
+
slog.Error("Failed to reset message on retry", "error", updateErr)
|
| 940 |
+
}
|
| 941 |
+
},
|
| 942 |
+
OnAuthRefresh: call.OnAuthRefresh,
|
| 943 |
+
ModelProvider: func() fantasy.LanguageModel {
|
| 944 |
+
m := a.largeModel.Get()
|
| 945 |
+
slog.Info("ModelProvider called",
|
| 946 |
+
"provider", m.ModelCfg.Provider,
|
| 947 |
+
"model", m.ModelCfg.Model)
|
| 948 |
+
return m.Model
|
| 949 |
+
},
|
| 950 |
+
OnToolCall: func(tc fantasy.ToolCallContent) error {
|
| 951 |
+
input, wasSanitized := sanitizeToolInput(tc.ToolName, tc.ToolCallID, tc.Input)
|
| 952 |
+
if wasSanitized {
|
| 953 |
+
sanitizedToolCalls[tc.ToolCallID] = true
|
| 954 |
+
}
|
| 955 |
+
toolCall := message.ToolCall{
|
| 956 |
+
ID: tc.ToolCallID,
|
| 957 |
+
Name: tc.ToolName,
|
| 958 |
+
Input: input,
|
| 959 |
+
ProviderExecuted: false,
|
| 960 |
+
Finished: true,
|
| 961 |
+
}
|
| 962 |
+
currentAssistant.AddToolCall(toolCall)
|
| 963 |
+
// Use parent ctx instead of genCtx to ensure the update succeeds
|
| 964 |
+
// even if the request is canceled mid-stream
|
| 965 |
+
return a.messages.Update(ctx, *currentAssistant)
|
| 966 |
+
},
|
| 967 |
+
OnToolResult: func(result fantasy.ToolResultContent) error {
|
| 968 |
+
toolResult := a.convertToToolResult(result)
|
| 969 |
+
if sanitizedToolCalls[result.ToolCallID] {
|
| 970 |
+
toolResult.Content = "Tool call failed: arguments were not valid JSON. Please check your tool call format and try again."
|
| 971 |
+
toolResult.IsError = true
|
| 972 |
+
}
|
| 973 |
+
// Use parent ctx instead of genCtx to ensure the message is created
|
| 974 |
+
// even if the request is canceled mid-stream
|
| 975 |
+
_, createMsgErr := a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
|
| 976 |
+
Role: message.Tool,
|
| 977 |
+
Parts: []message.ContentPart{
|
| 978 |
+
toolResult,
|
| 979 |
+
},
|
| 980 |
+
})
|
| 981 |
+
return createMsgErr
|
| 982 |
+
},
|
| 983 |
+
OnStepFinish: func(stepResult fantasy.StepResult) error {
|
| 984 |
+
for _, w := range stepResult.Warnings {
|
| 985 |
+
slog.Warn("Provider warning", "type", w.Type, "message", w.Message)
|
| 986 |
+
}
|
| 987 |
+
finishReason := message.FinishReasonUnknown
|
| 988 |
+
switch stepResult.FinishReason {
|
| 989 |
+
case fantasy.FinishReasonLength:
|
| 990 |
+
finishReason = message.FinishReasonMaxTokens
|
| 991 |
+
case fantasy.FinishReasonStop:
|
| 992 |
+
finishReason = message.FinishReasonEndTurn
|
| 993 |
+
case fantasy.FinishReasonToolCalls:
|
| 994 |
+
finishReason = message.FinishReasonToolUse
|
| 995 |
+
case fantasy.FinishReasonContentFilter:
|
| 996 |
+
// Provider safety classifier stopped the model
|
| 997 |
+
// (Anthropic stop_reason=refusal, OpenAI content_filter).
|
| 998 |
+
// The TUI owns the display copy; we only persist the
|
| 999 |
+
// reason so the UI can show a REFUSED banner.
|
| 1000 |
+
finishReason = message.FinishReasonContentFilter
|
| 1001 |
+
slog.Warn(
|
| 1002 |
+
"Provider content filter stopped the model",
|
| 1003 |
+
"session_id", call.SessionID,
|
| 1004 |
+
"finish_reason", string(stepResult.FinishReason),
|
| 1005 |
+
)
|
| 1006 |
+
}
|
| 1007 |
+
// If a tool result halted the turn (e.g. a hook halt or a
|
| 1008 |
+
// permission denial), the step ends on FinishReasonToolCalls but
|
| 1009 |
+
// the model will not be called again. Treat it as the end of the
|
| 1010 |
+
// turn so the UI can render the assistant footer.
|
| 1011 |
+
if finishReason == message.FinishReasonToolUse {
|
| 1012 |
+
for _, tr := range stepResult.Content.ToolResults() {
|
| 1013 |
+
if tr.StopTurn {
|
| 1014 |
+
finishReason = message.FinishReasonEndTurn
|
| 1015 |
+
break
|
| 1016 |
+
}
|
| 1017 |
+
}
|
| 1018 |
+
}
|
| 1019 |
+
currentAssistant.AddFinish(finishReason, "", "")
|
| 1020 |
+
currentAssistant.PrismModelID, currentAssistant.PrismModelName = extractPrismModel(stepResult.ProviderMetadata)
|
| 1021 |
+
currentAssistant.PrismHypercreditSavings, currentAssistant.PrismDollarSavings = extractPrismSavings(stepResult.ProviderMetadata)
|
| 1022 |
+
sessionLock.Lock()
|
| 1023 |
+
defer sessionLock.Unlock()
|
| 1024 |
+
|
| 1025 |
+
updatedSession, getSessionErr := a.sessions.Get(ctx, call.SessionID)
|
| 1026 |
+
if getSessionErr != nil {
|
| 1027 |
+
return getSessionErr
|
| 1028 |
+
}
|
| 1029 |
+
usage, estimated := fallbackStepUsage(stepMessages, stepResult)
|
| 1030 |
+
a.updateSessionUsage(largeModel, &updatedSession, usage, a.openrouterCost(stepResult.ProviderMetadata), estimated)
|
| 1031 |
+
extractHyperCredits(stepResult.ProviderMetadata)
|
| 1032 |
+
_, sessionErr := a.sessions.Save(ctx, updatedSession)
|
| 1033 |
+
if sessionErr != nil {
|
| 1034 |
+
return sessionErr
|
| 1035 |
+
}
|
| 1036 |
+
currentSession = updatedSession
|
| 1037 |
+
return a.messages.Update(genCtx, *currentAssistant)
|
| 1038 |
+
},
|
| 1039 |
+
StopWhen: []fantasy.StopCondition{
|
| 1040 |
+
func(_ []fantasy.StepResult) bool {
|
| 1041 |
+
cw := int64(largeModel.CatwalkCfg.ContextWindow)
|
| 1042 |
+
// If context window is unknown (0), skip auto-summarize
|
| 1043 |
+
// to avoid immediately truncating custom/local models.
|
| 1044 |
+
if cw == 0 {
|
| 1045 |
+
return false
|
| 1046 |
+
}
|
| 1047 |
+
tokens := currentSession.CompletionTokens + currentSession.PromptTokens
|
| 1048 |
+
remaining := cw - tokens
|
| 1049 |
+
var threshold int64
|
| 1050 |
+
if cw > largeContextWindowThreshold {
|
| 1051 |
+
threshold = largeContextWindowBuffer
|
| 1052 |
+
} else {
|
| 1053 |
+
threshold = int64(float64(cw) * smallContextWindowRatio)
|
| 1054 |
+
}
|
| 1055 |
+
if (remaining <= threshold) && !a.disableAutoSummarize {
|
| 1056 |
+
shouldSummarize = true
|
| 1057 |
+
return true
|
| 1058 |
+
}
|
| 1059 |
+
return false
|
| 1060 |
+
},
|
| 1061 |
+
func(steps []fantasy.StepResult) bool {
|
| 1062 |
+
return hasRepeatedToolCalls(steps, loopDetectionWindowSize, loopDetectionMaxRepeats)
|
| 1063 |
+
},
|
| 1064 |
+
},
|
| 1065 |
+
})
|
| 1066 |
+
|
| 1067 |
+
a.eventPromptResponded(call.SessionID, time.Since(startTime).Truncate(time.Second))
|
| 1068 |
+
|
| 1069 |
+
if err != nil {
|
| 1070 |
+
isHyper := largeModel.ModelCfg.Provider == hyper.Name
|
| 1071 |
+
isCancelErr := errors.Is(err, context.Canceled)
|
| 1072 |
+
slog.Info("Agent stream returned error",
|
| 1073 |
+
"error", err.Error(),
|
| 1074 |
+
"error_type", fmt.Sprintf("%T", err),
|
| 1075 |
+
"is_hyper", isHyper,
|
| 1076 |
+
"is_cancel", isCancelErr)
|
| 1077 |
+
if currentAssistant == nil {
|
| 1078 |
+
// Cancel-before-assistant-creation window: the run was
|
| 1079 |
+
// canceled after activeRequests.Set but before PrepareStep
|
| 1080 |
+
// created the assistant message. Without this, the turn
|
| 1081 |
+
// would return with no FinishReasonCanceled marker and no
|
| 1082 |
+
// user-visible record. The user message was already created
|
| 1083 |
+
// above, so persistCanceledTurn only writes the assistant
|
| 1084 |
+
// record.
|
| 1085 |
+
if isCancelErr {
|
| 1086 |
+
if persistErr := a.persistCanceledTurn(ctx, call, userMsgCreated); persistErr != nil {
|
| 1087 |
+
return nil, persistErr
|
| 1088 |
+
}
|
| 1089 |
+
}
|
| 1090 |
+
return result, err
|
| 1091 |
+
}
|
| 1092 |
+
// Persist final state with a context detached from the run
|
| 1093 |
+
// context. The run context (ctx) is derived from the
|
| 1094 |
+
// workspace context, which workspace shutdown cancels before
|
| 1095 |
+
// agent goroutines finish; using ctx here would drop the
|
| 1096 |
+
// final assistant state. WithoutCancel keeps the values
|
| 1097 |
+
// (e.g. session ID) while ignoring cancellation, and a short
|
| 1098 |
+
// timeout bounds the cleanup writes.
|
| 1099 |
+
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
| 1100 |
+
defer cleanupCancel()
|
| 1101 |
+
// Ensure we finish thinking on error to close the reasoning state.
|
| 1102 |
+
currentAssistant.FinishThinking()
|
| 1103 |
+
toolCalls := currentAssistant.ToolCalls()
|
| 1104 |
+
// INFO: we use the cleanup context here because the genCtx has been cancelled.
|
| 1105 |
+
msgs, createErr := a.messages.List(cleanupCtx, currentAssistant.SessionID)
|
| 1106 |
+
if createErr != nil {
|
| 1107 |
+
return nil, createErr
|
| 1108 |
+
}
|
| 1109 |
+
for _, tc := range toolCalls {
|
| 1110 |
+
if !tc.Finished {
|
| 1111 |
+
tc.Finished = true
|
| 1112 |
+
tc.Input = "{}"
|
| 1113 |
+
currentAssistant.AddToolCall(tc)
|
| 1114 |
+
updateErr := a.messages.Update(cleanupCtx, *currentAssistant)
|
| 1115 |
+
if updateErr != nil {
|
| 1116 |
+
return nil, updateErr
|
| 1117 |
+
}
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
found := false
|
| 1121 |
+
for _, msg := range msgs {
|
| 1122 |
+
if msg.Role == message.Tool {
|
| 1123 |
+
for _, tr := range msg.ToolResults() {
|
| 1124 |
+
if tr.ToolCallID == tc.ID {
|
| 1125 |
+
found = true
|
| 1126 |
+
break
|
| 1127 |
+
}
|
| 1128 |
+
}
|
| 1129 |
+
}
|
| 1130 |
+
if found {
|
| 1131 |
+
break
|
| 1132 |
+
}
|
| 1133 |
+
}
|
| 1134 |
+
if found {
|
| 1135 |
+
continue
|
| 1136 |
+
}
|
| 1137 |
+
content := "There was an error while executing the tool"
|
| 1138 |
+
if isCancelErr {
|
| 1139 |
+
content = "Error: user cancelled assistant tool calling"
|
| 1140 |
+
}
|
| 1141 |
+
toolResult := message.ToolResult{
|
| 1142 |
+
ToolCallID: tc.ID,
|
| 1143 |
+
Name: tc.Name,
|
| 1144 |
+
Content: content,
|
| 1145 |
+
IsError: true,
|
| 1146 |
+
}
|
| 1147 |
+
_, createErr = a.messages.Create(cleanupCtx, currentAssistant.SessionID, message.CreateMessageParams{
|
| 1148 |
+
Role: message.Tool,
|
| 1149 |
+
Parts: []message.ContentPart{
|
| 1150 |
+
toolResult,
|
| 1151 |
+
},
|
| 1152 |
+
})
|
| 1153 |
+
if createErr != nil {
|
| 1154 |
+
return nil, createErr
|
| 1155 |
+
}
|
| 1156 |
+
}
|
| 1157 |
+
var fantasyErr *fantasy.Error
|
| 1158 |
+
var providerErr *fantasy.ProviderError
|
| 1159 |
+
var requestTimedOutErr *requestTimeoutError
|
| 1160 |
+
const defaultTitle = "Provider Error"
|
| 1161 |
+
linkStyle := lipgloss.NewStyle().Foreground(charmtone.Guac).Underline(true)
|
| 1162 |
+
if isCancelErr {
|
| 1163 |
+
currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
|
| 1164 |
+
} else if errors.As(err, &requestTimedOutErr) {
|
| 1165 |
+
// Checked before the provider branches so a deadline our own
|
| 1166 |
+
// request timeout imposed is never reported as a provider error.
|
| 1167 |
+
currentAssistant.AddFinish(message.FinishReasonError, "Request timed out", requestTimedOutErr.userMessage())
|
| 1168 |
+
} else if isHyper && errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized {
|
| 1169 |
+
currentAssistant.AddFinish(message.FinishReasonError, "Unauthorized", `Please re-authenticate with Hyper. You can also run "crush auth" to re-authenticate.`)
|
| 1170 |
+
} else if errors.As(err, &providerErr) {
|
| 1171 |
+
if providerErr.Message == "The requested model is not supported." {
|
| 1172 |
+
url := "https://github.com/settings/copilot/features"
|
| 1173 |
+
link := linkStyle.Hyperlink(url, "id=copilot").Render(url)
|
| 1174 |
+
currentAssistant.AddFinish(
|
| 1175 |
+
message.FinishReasonError,
|
| 1176 |
+
"Copilot model not enabled",
|
| 1177 |
+
fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", largeModel.CatwalkCfg.Name, link),
|
| 1178 |
+
)
|
| 1179 |
+
} else {
|
| 1180 |
+
currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
|
| 1181 |
+
}
|
| 1182 |
+
} else if errors.As(err, &fantasyErr) {
|
| 1183 |
+
currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message)
|
| 1184 |
+
} else if fantasy.IsTransportError(err) {
|
| 1185 |
+
wrapped := fantasy.NewTransportError(err)
|
| 1186 |
+
currentAssistant.AddFinish(message.FinishReasonError, stringext.Capitalize(wrapped.Title), wrapped.Message)
|
| 1187 |
+
} else {
|
| 1188 |
+
currentAssistant.AddFinish(message.FinishReasonError, defaultTitle, err.Error())
|
| 1189 |
+
}
|
| 1190 |
+
// Note: we use the cleanup context here because the genCtx has been
|
| 1191 |
+
// cancelled.
|
| 1192 |
+
updateErr := a.messages.Update(cleanupCtx, *currentAssistant)
|
| 1193 |
+
if updateErr != nil {
|
| 1194 |
+
return nil, updateErr
|
| 1195 |
+
}
|
| 1196 |
+
return nil, err
|
| 1197 |
+
}
|
| 1198 |
+
|
| 1199 |
+
if shouldSummarize {
|
| 1200 |
+
a.activeRequests.Del(call.SessionID)
|
| 1201 |
+
if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions, call.OnAuthRefresh); summarizeErr != nil {
|
| 1202 |
+
return nil, summarizeErr
|
| 1203 |
+
}
|
| 1204 |
+
// If the agent wasn't done...
|
| 1205 |
+
if len(currentAssistant.ToolCalls()) > 0 {
|
| 1206 |
+
existing, ok := a.messageQueue.Get(call.SessionID)
|
| 1207 |
+
if !ok {
|
| 1208 |
+
existing = []SessionAgentCall{}
|
| 1209 |
+
}
|
| 1210 |
+
call.Prompt = fmt.Sprintf("The previous session was interrupted because it got too long, the initial user request was: `%s`", call.Prompt)
|
| 1211 |
+
existing = append(existing, call)
|
| 1212 |
+
a.messageQueue.Set(call.SessionID, existing)
|
| 1213 |
+
}
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
// Release active request before publishing the notification.
|
| 1217 |
+
// TUI handlers poll IsSessionBusy() and only re-evaluate when a
|
| 1218 |
+
// tea.Msg arrives, so the cleanup must precede the notify or
|
| 1219 |
+
// subscribers see stale busy state at the moment of receipt.
|
| 1220 |
+
a.activeRequests.Del(call.SessionID)
|
| 1221 |
+
cancel()
|
| 1222 |
+
|
| 1223 |
+
// Send notification that agent has finished its turn (skip for
|
| 1224 |
+
// nested/non-interactive sessions).
|
| 1225 |
+
if !call.NonInteractive && a.notify != nil {
|
| 1226 |
+
a.notify.Publish(pubsub.CreatedEvent, notify.Notification{
|
| 1227 |
+
SessionID: call.SessionID,
|
| 1228 |
+
SessionTitle: currentSession.Title,
|
| 1229 |
+
Type: notify.TypeAgentFinished,
|
| 1230 |
+
})
|
| 1231 |
+
}
|
| 1232 |
+
|
| 1233 |
+
// Hand off to the next queued prompt (if any) under dispatchMu so
|
| 1234 |
+
// the transition from this finished run to the queued run is atomic
|
| 1235 |
+
// against a concurrent Cancel. activeRequests for this session was
|
| 1236 |
+
// just deleted above, so without the lock there is a window in
|
| 1237 |
+
// which the session looks idle and a cancel becomes a no-op that
|
| 1238 |
+
// fails to stop the queued prompt. Holding the lock lets us observe
|
| 1239 |
+
// a pending cancel recorded against the session and drop the queue
|
| 1240 |
+
// instead of running it, and (for the recursion) hand a fresh
|
| 1241 |
+
// accept reservation to the dequeued call so acceptedRuns stays > 0
|
| 1242 |
+
// across the recursive Run's own dispatch handoff — keeping the
|
| 1243 |
+
// session observable to Cancel for the entire transition and
|
| 1244 |
+
// closing the dequeue -> re-register window.
|
| 1245 |
+
mu := a.sessionMu(call.SessionID)
|
| 1246 |
+
mu.Lock()
|
| 1247 |
+
queuedMessages, _ := a.messageQueue.Get(call.SessionID)
|
| 1248 |
+
if mark, ok := a.cancelMark.Get(call.SessionID); ok && mark > 0 && len(queuedMessages) > 0 {
|
| 1249 |
+
// A cancel was recorded for this session (e.g. it arrived while
|
| 1250 |
+
// this run was active and follow-ups had been queued). Drop the
|
| 1251 |
+
// queued prompts it covers (accept sequence at or below the
|
| 1252 |
+
// mark, or untracked); keep any queued after the cancel (higher
|
| 1253 |
+
// sequence) so they still run.
|
| 1254 |
+
var kept []SessionAgentCall
|
| 1255 |
+
var canceledRunIDDrops []SessionAgentCall
|
| 1256 |
+
for _, q := range queuedMessages {
|
| 1257 |
+
if q.acceptSeq == 0 || q.acceptSeq <= mark {
|
| 1258 |
+
if q.RunID != "" {
|
| 1259 |
+
canceledRunIDDrops = append(canceledRunIDDrops, q)
|
| 1260 |
+
}
|
| 1261 |
+
continue
|
| 1262 |
+
}
|
| 1263 |
+
kept = append(kept, q)
|
| 1264 |
+
}
|
| 1265 |
+
queuedMessages = kept
|
| 1266 |
+
a.messageQueue.Set(call.SessionID, kept)
|
| 1267 |
+
// A dropped prompt carrying a RunID must still publish its
|
| 1268 |
+
// terminal cancelled RunComplete so a caller waiting on that
|
| 1269 |
+
// RunID does not hang.
|
| 1270 |
+
a.publishCanceledQueueDrops(canceledRunIDDrops)
|
| 1271 |
+
}
|
| 1272 |
+
if len(queuedMessages) == 0 {
|
| 1273 |
+
// No queued work. Clear the cancel mark only when no accepted
|
| 1274 |
+
// run remains in flight that it might still cover; otherwise a
|
| 1275 |
+
// sibling prompt (sequence at or below the mark) waiting to
|
| 1276 |
+
// enter Run would lose its cancellation. When accepted runs are
|
| 1277 |
+
// gone, this also clears a stale mark so it can't catch a
|
| 1278 |
+
// future run.
|
| 1279 |
+
a.messageQueue.Del(call.SessionID)
|
| 1280 |
+
a.acceptedMu.Lock()
|
| 1281 |
+
inFlight, _ := a.acceptedRuns.Get(call.SessionID)
|
| 1282 |
+
a.acceptedMu.Unlock()
|
| 1283 |
+
if inFlight == 0 {
|
| 1284 |
+
a.cancelMark.Del(call.SessionID)
|
| 1285 |
+
}
|
| 1286 |
+
mu.Unlock()
|
| 1287 |
+
return result, err
|
| 1288 |
+
}
|
| 1289 |
+
// There are queued messages, restart the loop. Suppress the outer
|
| 1290 |
+
// defer's emit: it would otherwise observe the recursive Run's retErr
|
| 1291 |
+
// (named-return clobbering through the return below) against this
|
| 1292 |
+
// turn's MessageID/Text and publish a mixed, racing event.
|
| 1293 |
+
skipRunComplete = true
|
| 1294 |
+
// Decide whether this turn still owes its own terminal RunComplete.
|
| 1295 |
+
// Each submitted prompt with a RunID has its own lifecycle, so a turn
|
| 1296 |
+
// that is finished and handing off to a *different* queued prompt must
|
| 1297 |
+
// publish its own RunComplete here — leaving it to the recursive turn
|
| 1298 |
+
// (which carries a different RunID) would hang a caller waiting on
|
| 1299 |
+
// this turn's RunID. The exception is the summarize-continuation path,
|
| 1300 |
+
// which re-queues this same call (same RunID) to resume after a
|
| 1301 |
+
// summary; in that case the eventual terminal turn for this RunID
|
| 1302 |
+
// publishes, so publishing now would double-emit.
|
| 1303 |
+
outerOwesRunComplete := call.RunID != ""
|
| 1304 |
+
if outerOwesRunComplete {
|
| 1305 |
+
for _, q := range queuedMessages {
|
| 1306 |
+
if q.RunID == call.RunID {
|
| 1307 |
+
outerOwesRunComplete = false
|
| 1308 |
+
break
|
| 1309 |
+
}
|
| 1310 |
+
}
|
| 1311 |
+
}
|
| 1312 |
+
firstQueuedMessage := queuedMessages[0]
|
| 1313 |
+
a.messageQueue.Set(call.SessionID, queuedMessages[1:])
|
| 1314 |
+
// Reserve a fresh accept for the dequeued prompt before dropping the
|
| 1315 |
+
// lock so acceptedRuns > 0 across the handoff into the recursive
|
| 1316 |
+
// Run. This closes the window between this dequeue and the recursive
|
| 1317 |
+
// Run registering its activeRequests entry: a cancel arriving in
|
| 1318 |
+
// that window now records a pending cancel (acceptedRuns > 0) that
|
| 1319 |
+
// the recursive Run's accepted path observes as cancel-on-entry.
|
| 1320 |
+
firstQueuedMessage.Accepted = a.BeginAccepted(call.SessionID)
|
| 1321 |
+
mu.Unlock()
|
| 1322 |
+
if outerOwesRunComplete {
|
| 1323 |
+
complete := notify.RunComplete{SessionID: call.SessionID, RunID: call.RunID}
|
| 1324 |
+
if currentAssistant != nil {
|
| 1325 |
+
complete.MessageID = currentAssistant.ID
|
| 1326 |
+
complete.Text = currentAssistant.Content().String()
|
| 1327 |
+
}
|
| 1328 |
+
if ctx.Err() != nil {
|
| 1329 |
+
complete.Cancelled = true
|
| 1330 |
+
}
|
| 1331 |
+
a.publishRunComplete(ctx, call, complete)
|
| 1332 |
+
}
|
| 1333 |
+
return a.Run(ctx, firstQueuedMessage)
|
| 1334 |
+
}
|
| 1335 |
+
|
| 1336 |
+
func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions, onAuthRefresh func(context.Context, *fantasy.ProviderError) error) error {
|
| 1337 |
+
if a.IsSessionBusy(sessionID) {
|
| 1338 |
+
return ErrSessionBusy
|
| 1339 |
+
}
|
| 1340 |
+
|
| 1341 |
+
// Copy mutable fields under lock to avoid races with SetModels.
|
| 1342 |
+
largeModel := a.largeModel.Get()
|
| 1343 |
+
systemPromptPrefix := a.systemPromptPrefix.Get()
|
| 1344 |
+
|
| 1345 |
+
currentSession, err := a.sessions.Get(ctx, sessionID)
|
| 1346 |
+
if err != nil {
|
| 1347 |
+
return fmt.Errorf("failed to get session: %w", err)
|
| 1348 |
+
}
|
| 1349 |
+
msgs, err := a.getSessionMessages(ctx, currentSession)
|
| 1350 |
+
if err != nil {
|
| 1351 |
+
return err
|
| 1352 |
+
}
|
| 1353 |
+
if len(msgs) == 0 {
|
| 1354 |
+
// Nothing to summarize.
|
| 1355 |
+
return nil
|
| 1356 |
+
}
|
| 1357 |
+
|
| 1358 |
+
aiMsgs, _ := a.preparePrompt(msgs, largeModel.CatwalkCfg.SupportsImages)
|
| 1359 |
+
|
| 1360 |
+
genCtx, cancel := context.WithCancel(ctx)
|
| 1361 |
+
ac := &activeCancel{cancel: cancel}
|
| 1362 |
+
a.activeRequests.Set(sessionID, ac)
|
| 1363 |
+
defer a.activeRequests.CompareAndDelete(sessionID, ac)
|
| 1364 |
+
defer cancel()
|
| 1365 |
+
defer func() {
|
| 1366 |
+
if flushErr := a.messages.FlushAll(ctx); flushErr != nil {
|
| 1367 |
+
slog.Error("Failed to flush pending message updates after summarize", "error", flushErr)
|
| 1368 |
+
}
|
| 1369 |
+
}()
|
| 1370 |
+
|
| 1371 |
+
agent := fantasy.NewAgent(
|
| 1372 |
+
largeModel.Model,
|
| 1373 |
+
fantasy.WithSystemPrompt(string(summaryPrompt)),
|
| 1374 |
+
fantasy.WithUserAgent(userAgent),
|
| 1375 |
+
)
|
| 1376 |
+
summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
|
| 1377 |
+
Role: message.Assistant,
|
| 1378 |
+
Model: largeModel.ModelCfg.Model,
|
| 1379 |
+
Provider: largeModel.ModelCfg.Provider,
|
| 1380 |
+
IsSummaryMessage: true,
|
| 1381 |
+
})
|
| 1382 |
+
if err != nil {
|
| 1383 |
+
return err
|
| 1384 |
+
}
|
| 1385 |
+
|
| 1386 |
+
summaryPromptText := buildSummaryPrompt(currentSession.Todos)
|
| 1387 |
+
|
| 1388 |
+
resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
|
| 1389 |
+
Prompt: summaryPromptText,
|
| 1390 |
+
Messages: aiMsgs,
|
| 1391 |
+
Headers: sessionHeaders(sessionID),
|
| 1392 |
+
ProviderOptions: opts,
|
| 1393 |
+
OnAuthRefresh: onAuthRefresh,
|
| 1394 |
+
ModelProvider: func() fantasy.LanguageModel {
|
| 1395 |
+
return a.largeModel.Get().Model
|
| 1396 |
+
},
|
| 1397 |
+
PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
|
| 1398 |
+
prepared.Messages = options.Messages
|
| 1399 |
+
if systemPromptPrefix != "" {
|
| 1400 |
+
prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...)
|
| 1401 |
+
}
|
| 1402 |
+
return callContext, prepared, nil
|
| 1403 |
+
},
|
| 1404 |
+
OnReasoningDelta: func(id string, text string) error {
|
| 1405 |
+
summaryMessage.AppendReasoningContent(text)
|
| 1406 |
+
return a.messages.Update(genCtx, summaryMessage)
|
| 1407 |
+
},
|
| 1408 |
+
OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
|
| 1409 |
+
// Handle anthropic signature.
|
| 1410 |
+
if anthropicData, ok := reasoning.ProviderMetadata["anthropic"]; ok {
|
| 1411 |
+
if signature, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok && signature.Signature != "" {
|
| 1412 |
+
summaryMessage.AppendReasoningSignature(signature.Signature)
|
| 1413 |
+
}
|
| 1414 |
+
}
|
| 1415 |
+
summaryMessage.FinishThinking()
|
| 1416 |
+
return a.messages.Update(genCtx, summaryMessage)
|
| 1417 |
+
},
|
| 1418 |
+
OnTextDelta: func(id, text string) error {
|
| 1419 |
+
summaryMessage.AppendContent(text)
|
| 1420 |
+
return a.messages.Update(genCtx, summaryMessage)
|
| 1421 |
+
},
|
| 1422 |
+
})
|
| 1423 |
+
if err != nil {
|
| 1424 |
+
isCancelErr := errors.Is(err, context.Canceled)
|
| 1425 |
+
if isCancelErr {
|
| 1426 |
+
// User cancelled summarize we need to remove the summary message.
|
| 1427 |
+
deleteErr := a.messages.Delete(ctx, summaryMessage.ID)
|
| 1428 |
+
return deleteErr
|
| 1429 |
+
}
|
| 1430 |
+
// Mark the summary message as finished with an error so the UI
|
| 1431 |
+
// stops spinning.
|
| 1432 |
+
summaryMessage.AddFinish(message.FinishReasonError, "Summarization Error", err.Error())
|
| 1433 |
+
if updateErr := a.messages.Update(ctx, summaryMessage); updateErr != nil {
|
| 1434 |
+
return updateErr
|
| 1435 |
+
}
|
| 1436 |
+
return err
|
| 1437 |
+
}
|
| 1438 |
+
|
| 1439 |
+
summaryMessage.AddFinish(message.FinishReasonEndTurn, "", "")
|
| 1440 |
+
err = a.messages.Update(genCtx, summaryMessage)
|
| 1441 |
+
if err != nil {
|
| 1442 |
+
return err
|
| 1443 |
+
}
|
| 1444 |
+
|
| 1445 |
+
var openrouterCost *float64
|
| 1446 |
+
for _, step := range resp.Steps {
|
| 1447 |
+
stepCost := a.openrouterCost(step.ProviderMetadata)
|
| 1448 |
+
if stepCost != nil {
|
| 1449 |
+
newCost := *stepCost
|
| 1450 |
+
if openrouterCost != nil {
|
| 1451 |
+
newCost += *openrouterCost
|
| 1452 |
+
}
|
| 1453 |
+
openrouterCost = &newCost
|
| 1454 |
+
}
|
| 1455 |
+
extractHyperCredits(step.ProviderMetadata)
|
| 1456 |
+
}
|
| 1457 |
+
|
| 1458 |
+
a.updateSessionUsage(largeModel, ¤tSession, resp.TotalUsage, openrouterCost, false)
|
| 1459 |
+
|
| 1460 |
+
// Just in case, get just the last usage info.
|
| 1461 |
+
usage := resp.Response.Usage
|
| 1462 |
+
currentSession.SummaryMessageID = summaryMessage.ID
|
| 1463 |
+
currentSession.CompletionTokens = summaryCompletionTokens(usage, summaryMessage)
|
| 1464 |
+
currentSession.PromptTokens = 0
|
| 1465 |
+
currentSession.EstimatedUsage = usageIsZero(usage)
|
| 1466 |
+
_, err = a.sessions.Save(genCtx, currentSession)
|
| 1467 |
+
if err != nil {
|
| 1468 |
+
return err
|
| 1469 |
+
}
|
| 1470 |
+
|
| 1471 |
+
// Release the active request before processing queued messages so that
|
| 1472 |
+
// Run() does not see the session as busy.
|
| 1473 |
+
a.activeRequests.Del(sessionID)
|
| 1474 |
+
cancel()
|
| 1475 |
+
|
| 1476 |
+
// Process any messages that were queued while summarizing.
|
| 1477 |
+
queuedMessages, ok := a.messageQueue.Get(sessionID)
|
| 1478 |
+
if !ok || len(queuedMessages) == 0 {
|
| 1479 |
+
return nil
|
| 1480 |
+
}
|
| 1481 |
+
firstQueuedMessage := queuedMessages[0]
|
| 1482 |
+
a.messageQueue.Set(sessionID, queuedMessages[1:])
|
| 1483 |
+
_, qErr := a.Run(ctx, firstQueuedMessage)
|
| 1484 |
+
return qErr
|
| 1485 |
+
}
|
| 1486 |
+
|
| 1487 |
+
func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
|
| 1488 |
+
if t, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_ANTHROPIC_CACHE")); t {
|
| 1489 |
+
return fantasy.ProviderOptions{}
|
| 1490 |
+
}
|
| 1491 |
+
return fantasy.ProviderOptions{
|
| 1492 |
+
anthropic.Name: &anthropic.ProviderCacheControlOptions{
|
| 1493 |
+
CacheControl: anthropic.CacheControl{Type: "ephemeral"},
|
| 1494 |
+
},
|
| 1495 |
+
bedrock.Name: &anthropic.ProviderCacheControlOptions{
|
| 1496 |
+
CacheControl: anthropic.CacheControl{Type: "ephemeral"},
|
| 1497 |
+
},
|
| 1498 |
+
vercel.Name: &anthropic.ProviderCacheControlOptions{
|
| 1499 |
+
CacheControl: anthropic.CacheControl{Type: "ephemeral"},
|
| 1500 |
+
},
|
| 1501 |
+
}
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
+
// sessionHeaders returns the HTTP headers we use for cache affinity on
|
| 1505 |
+
// every LLM request for a given session.
|
| 1506 |
+
//
|
| 1507 |
+
// We use the session hash is used instead of the raw UUID so the header
|
| 1508 |
+
// value is deterministic and opaque.
|
| 1509 |
+
func sessionHeaders(sessionID string) map[string]string {
|
| 1510 |
+
hash := session.HashID(sessionID)
|
| 1511 |
+
return map[string]string{
|
| 1512 |
+
"x-session-id": hash,
|
| 1513 |
+
"x-session-affinity": hash,
|
| 1514 |
+
}
|
| 1515 |
+
}
|
| 1516 |
+
|
| 1517 |
+
func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
|
| 1518 |
+
parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
|
| 1519 |
+
var attachmentParts []message.ContentPart
|
| 1520 |
+
for _, attachment := range call.Attachments {
|
| 1521 |
+
attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
|
| 1522 |
+
}
|
| 1523 |
+
parts = append(parts, attachmentParts...)
|
| 1524 |
+
msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
|
| 1525 |
+
Role: message.User,
|
| 1526 |
+
Parts: parts,
|
| 1527 |
+
})
|
| 1528 |
+
if err != nil {
|
| 1529 |
+
return message.Message{}, fmt.Errorf("failed to create user message: %w", err)
|
| 1530 |
+
}
|
| 1531 |
+
return msg, nil
|
| 1532 |
+
}
|
| 1533 |
+
|
| 1534 |
+
func (a *sessionAgent) preparePrompt(msgs []message.Message, supportsImages bool, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
|
| 1535 |
+
var history []fantasy.Message
|
| 1536 |
+
if !a.isSubAgent {
|
| 1537 |
+
history = append(history, fantasy.NewUserMessage(
|
| 1538 |
+
fmt.Sprintf(
|
| 1539 |
+
"<system_reminder>%s</system_reminder>",
|
| 1540 |
+
`This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware.
|
| 1541 |
+
If you are working on tasks that would benefit from a todo list please use the "todos" tool to create one.
|
| 1542 |
+
If not, please feel free to ignore. Again do not mention this message to the user.`,
|
| 1543 |
+
),
|
| 1544 |
+
))
|
| 1545 |
+
}
|
| 1546 |
+
// Collect all tool call IDs present in assistant messages, then index
|
| 1547 |
+
// every tool result by its call ID. Tool results are re-emitted right
|
| 1548 |
+
// after the assistant message that requested them instead of at their
|
| 1549 |
+
// stored position: messages can be written to a session concurrently
|
| 1550 |
+
// (e.g. resuming while a tool is still running), which interleaves
|
| 1551 |
+
// user messages between a tool call and its result. LLM APIs require
|
| 1552 |
+
// every tool call to be followed by its results before any other
|
| 1553 |
+
// message, and strict-adjacency providers (e.g. Kimi, DeepSeek) reject
|
| 1554 |
+
// the request otherwise, permanently locking the session.
|
| 1555 |
+
knownToolCallIDs := make(map[string]struct{})
|
| 1556 |
+
for _, m := range msgs {
|
| 1557 |
+
if m.Role != message.Assistant {
|
| 1558 |
+
continue
|
| 1559 |
+
}
|
| 1560 |
+
for _, tc := range m.ToolCalls() {
|
| 1561 |
+
knownToolCallIDs[tc.ID] = struct{}{}
|
| 1562 |
+
}
|
| 1563 |
+
}
|
| 1564 |
+
toolResultsByCall := make(map[string][]fantasy.MessagePart)
|
| 1565 |
+
for _, m := range msgs {
|
| 1566 |
+
if m.Role != message.Tool {
|
| 1567 |
+
continue
|
| 1568 |
+
}
|
| 1569 |
+
for _, aiMsg := range m.ToAIMessage() {
|
| 1570 |
+
for _, part := range aiMsg.Content {
|
| 1571 |
+
tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
|
| 1572 |
+
if !ok {
|
| 1573 |
+
// Tool-role ToAIMessage only emits ToolResultParts today;
|
| 1574 |
+
// log so unexpected parts do not vanish silently.
|
| 1575 |
+
slog.Warn(
|
| 1576 |
+
"Dropping unexpected non-tool-result part from tool message",
|
| 1577 |
+
"part_type", fmt.Sprintf("%T", part),
|
| 1578 |
+
)
|
| 1579 |
+
continue
|
| 1580 |
+
}
|
| 1581 |
+
if _, known := knownToolCallIDs[tr.ToolCallID]; !known {
|
| 1582 |
+
slog.Warn(
|
| 1583 |
+
"Dropping orphaned tool result with no matching tool call",
|
| 1584 |
+
"tool_call_id", tr.ToolCallID,
|
| 1585 |
+
)
|
| 1586 |
+
continue
|
| 1587 |
+
}
|
| 1588 |
+
toolResultsByCall[tr.ToolCallID] = append(toolResultsByCall[tr.ToolCallID], part)
|
| 1589 |
+
}
|
| 1590 |
+
}
|
| 1591 |
+
}
|
| 1592 |
+
|
| 1593 |
+
for _, m := range msgs {
|
| 1594 |
+
if len(m.Parts) == 0 {
|
| 1595 |
+
continue
|
| 1596 |
+
}
|
| 1597 |
+
// Assistant message without content or tool calls (cancelled before it returned anything).
|
| 1598 |
+
if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
|
| 1599 |
+
continue
|
| 1600 |
+
}
|
| 1601 |
+
// Tool results are emitted right after their assistant message.
|
| 1602 |
+
if m.Role == message.Tool {
|
| 1603 |
+
continue
|
| 1604 |
+
}
|
| 1605 |
+
aiMsgs := m.ToAIMessage()
|
| 1606 |
+
if !supportsImages {
|
| 1607 |
+
for i := range aiMsgs {
|
| 1608 |
+
if aiMsgs[i].Role == fantasy.MessageRoleUser {
|
| 1609 |
+
aiMsgs[i].Content = filterFileParts(aiMsgs[i].Content)
|
| 1610 |
+
}
|
| 1611 |
+
}
|
| 1612 |
+
}
|
| 1613 |
+
history = append(history, aiMsgs...)
|
| 1614 |
+
|
| 1615 |
+
if m.Role == message.Assistant && len(m.ToolCalls()) > 0 {
|
| 1616 |
+
history = append(history, toolResultsForCalls(m, toolResultsByCall))
|
| 1617 |
+
}
|
| 1618 |
+
}
|
| 1619 |
+
|
| 1620 |
+
var files []fantasy.FilePart
|
| 1621 |
+
for _, attachment := range attachments {
|
| 1622 |
+
if attachment.IsText() {
|
| 1623 |
+
continue
|
| 1624 |
+
}
|
| 1625 |
+
if !supportsImages {
|
| 1626 |
+
continue
|
| 1627 |
+
}
|
| 1628 |
+
files = append(files, fantasy.FilePart{
|
| 1629 |
+
Filename: attachment.FileName,
|
| 1630 |
+
Data: attachment.Content,
|
| 1631 |
+
MediaType: attachment.MimeType,
|
| 1632 |
+
})
|
| 1633 |
+
}
|
| 1634 |
+
|
| 1635 |
+
return history, files
|
| 1636 |
+
}
|
| 1637 |
+
|
| 1638 |
+
// filterFileParts removes fantasy.FilePart entries from a slice of message
|
| 1639 |
+
// parts. Used to strip image attachments from historical user messages when
|
| 1640 |
+
// the current model does not support them.
|
| 1641 |
+
func filterFileParts(parts []fantasy.MessagePart) []fantasy.MessagePart {
|
| 1642 |
+
filtered := make([]fantasy.MessagePart, 0, len(parts))
|
| 1643 |
+
for _, part := range parts {
|
| 1644 |
+
if _, ok := fantasy.AsMessagePart[fantasy.FilePart](part); ok {
|
| 1645 |
+
continue
|
| 1646 |
+
}
|
| 1647 |
+
filtered = append(filtered, part)
|
| 1648 |
+
}
|
| 1649 |
+
return filtered
|
| 1650 |
+
}
|
| 1651 |
+
|
| 1652 |
+
// toolResultsForCalls builds the tool message that must immediately follow
|
| 1653 |
+
// an assistant message with tool calls. LLM APIs require every tool call to
|
| 1654 |
+
// be followed by its results before any other message; strict-adjacency
|
| 1655 |
+
// providers reject the request otherwise. Results are taken from
|
| 1656 |
+
// toolResultsByCall and consumed, so a result stored in a message that also
|
| 1657 |
+
// holds results for calls of other assistant messages is emitted exactly
|
| 1658 |
+
// once, next to the assistant that requested it. Tool calls without any
|
| 1659 |
+
// stored result (e.g. an interrupted session) receive a synthetic error
|
| 1660 |
+
// response so the conversation keeps working.
|
| 1661 |
+
func toolResultsForCalls(m message.Message, toolResultsByCall map[string][]fantasy.MessagePart) fantasy.Message {
|
| 1662 |
+
content := make([]fantasy.MessagePart, 0, len(m.ToolCalls()))
|
| 1663 |
+
for _, tc := range m.ToolCalls() {
|
| 1664 |
+
parts := toolResultsByCall[tc.ID]
|
| 1665 |
+
delete(toolResultsByCall, tc.ID)
|
| 1666 |
+
if len(parts) > 0 {
|
| 1667 |
+
content = append(content, parts...)
|
| 1668 |
+
continue
|
| 1669 |
+
}
|
| 1670 |
+
slog.Warn(
|
| 1671 |
+
"Injecting synthetic tool result for orphaned tool call",
|
| 1672 |
+
"tool_call_id", tc.ID,
|
| 1673 |
+
"tool_name", tc.Name,
|
| 1674 |
+
)
|
| 1675 |
+
content = append(content, fantasy.ToolResultPart{
|
| 1676 |
+
ToolCallID: tc.ID,
|
| 1677 |
+
Output: fantasy.ToolResultOutputContentError{
|
| 1678 |
+
Error: errors.New("tool call was interrupted and did not produce a result, you may retry this call if the result is still needed"),
|
| 1679 |
+
},
|
| 1680 |
+
})
|
| 1681 |
+
}
|
| 1682 |
+
return fantasy.Message{
|
| 1683 |
+
Role: fantasy.MessageRoleTool,
|
| 1684 |
+
Content: content,
|
| 1685 |
+
}
|
| 1686 |
+
}
|
| 1687 |
+
|
| 1688 |
+
func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
|
| 1689 |
+
msgs, err := a.messages.List(ctx, session.ID)
|
| 1690 |
+
if err != nil {
|
| 1691 |
+
return nil, fmt.Errorf("failed to list messages: %w", err)
|
| 1692 |
+
}
|
| 1693 |
+
|
| 1694 |
+
if session.SummaryMessageID != "" {
|
| 1695 |
+
summaryMsgIndex := -1
|
| 1696 |
+
for i, msg := range msgs {
|
| 1697 |
+
if msg.ID == session.SummaryMessageID {
|
| 1698 |
+
summaryMsgIndex = i
|
| 1699 |
+
break
|
| 1700 |
+
}
|
| 1701 |
+
}
|
| 1702 |
+
if summaryMsgIndex != -1 {
|
| 1703 |
+
msgs = msgs[summaryMsgIndex:]
|
| 1704 |
+
msgs[0].Role = message.User
|
| 1705 |
+
}
|
| 1706 |
+
}
|
| 1707 |
+
return msgs, nil
|
| 1708 |
+
}
|
| 1709 |
+
|
| 1710 |
+
// hasUserTextMessage reports whether any user message in msgs contains
|
| 1711 |
+
// text content (as opposed to only shell commands or other non-text parts).
|
| 1712 |
+
func hasUserTextMessage(msgs []message.Message) bool {
|
| 1713 |
+
for _, msg := range msgs {
|
| 1714 |
+
if msg.Role != message.User {
|
| 1715 |
+
continue
|
| 1716 |
+
}
|
| 1717 |
+
for _, part := range msg.Parts {
|
| 1718 |
+
if tc, ok := part.(message.TextContent); ok && tc.Text != "" {
|
| 1719 |
+
return true
|
| 1720 |
+
}
|
| 1721 |
+
}
|
| 1722 |
+
}
|
| 1723 |
+
return false
|
| 1724 |
+
}
|
| 1725 |
+
|
| 1726 |
+
// GenerateTitle generates a session title based on the initial prompt.
|
| 1727 |
+
func (a *sessionAgent) GenerateTitle(ctx context.Context, sessionID string, userPrompt string) {
|
| 1728 |
+
if userPrompt == "" {
|
| 1729 |
+
return
|
| 1730 |
+
}
|
| 1731 |
+
|
| 1732 |
+
// Ensure the session always gets a title even if every path below
|
| 1733 |
+
// fails or the context is cancelled before we finish.
|
| 1734 |
+
var titleSaved bool
|
| 1735 |
+
defer func() {
|
| 1736 |
+
if !titleSaved {
|
| 1737 |
+
fallbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
| 1738 |
+
defer cancel()
|
| 1739 |
+
if err := a.sessions.Rename(fallbackCtx, sessionID, DefaultSessionName); err != nil {
|
| 1740 |
+
slog.Error("Failed to save fallback session title", "error", err)
|
| 1741 |
+
}
|
| 1742 |
+
}
|
| 1743 |
+
}()
|
| 1744 |
+
|
| 1745 |
+
smallModel := a.smallModel.Get()
|
| 1746 |
+
largeModel := a.largeModel.Get()
|
| 1747 |
+
systemPromptPrefix := a.systemPromptPrefix.Get()
|
| 1748 |
+
|
| 1749 |
+
newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent {
|
| 1750 |
+
return fantasy.NewAgent(
|
| 1751 |
+
m,
|
| 1752 |
+
fantasy.WithSystemPrompt(string(p)+"\n /no_think"),
|
| 1753 |
+
fantasy.WithMaxOutputTokens(tok),
|
| 1754 |
+
fantasy.WithUserAgent(userAgent),
|
| 1755 |
+
)
|
| 1756 |
+
}
|
| 1757 |
+
|
| 1758 |
+
streamCall := fantasy.AgentStreamCall{
|
| 1759 |
+
Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
|
| 1760 |
+
Headers: sessionHeaders(sessionID),
|
| 1761 |
+
PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
|
| 1762 |
+
prepared.Messages = opts.Messages
|
| 1763 |
+
if systemPromptPrefix != "" {
|
| 1764 |
+
prepared.Messages = append([]fantasy.Message{
|
| 1765 |
+
fantasy.NewSystemMessage(systemPromptPrefix),
|
| 1766 |
+
}, prepared.Messages...)
|
| 1767 |
+
}
|
| 1768 |
+
return callCtx, prepared, nil
|
| 1769 |
+
},
|
| 1770 |
+
}
|
| 1771 |
+
|
| 1772 |
+
type modelAttempt struct {
|
| 1773 |
+
name string
|
| 1774 |
+
model Model
|
| 1775 |
+
}
|
| 1776 |
+
attempts := []modelAttempt{
|
| 1777 |
+
{"small", smallModel},
|
| 1778 |
+
{"large", largeModel},
|
| 1779 |
+
}
|
| 1780 |
+
|
| 1781 |
+
var resp *fantasy.AgentResult
|
| 1782 |
+
var err error
|
| 1783 |
+
var model Model
|
| 1784 |
+
var success bool
|
| 1785 |
+
for _, attempt := range attempts {
|
| 1786 |
+
tok := int64(40)
|
| 1787 |
+
if attempt.model.CatwalkCfg.CanReason {
|
| 1788 |
+
tok = attempt.model.CatwalkCfg.DefaultMaxTokens
|
| 1789 |
+
}
|
| 1790 |
+
agent := newAgent(attempt.model.Model, titlePrompt, tok)
|
| 1791 |
+
resp, err = agent.Stream(ctx, streamCall)
|
| 1792 |
+
if err == nil && resp.Response.FinishReason != fantasy.FinishReasonLength {
|
| 1793 |
+
model = attempt.model
|
| 1794 |
+
slog.Debug("Generated title with " + attempt.name + " model")
|
| 1795 |
+
success = true
|
| 1796 |
+
break
|
| 1797 |
+
}
|
| 1798 |
+
if err != nil {
|
| 1799 |
+
slog.Error("Error generating title with "+attempt.name+" model; trying next", "err", err)
|
| 1800 |
+
} else {
|
| 1801 |
+
slog.Error("Title generation hit token limit with " + attempt.name + " model; trying next")
|
| 1802 |
+
}
|
| 1803 |
+
}
|
| 1804 |
+
if !success {
|
| 1805 |
+
// The deferred fallback will save the default session name.
|
| 1806 |
+
return
|
| 1807 |
+
}
|
| 1808 |
+
|
| 1809 |
+
// Clean up title.
|
| 1810 |
+
var title string
|
| 1811 |
+
title = strings.ReplaceAll(resp.Response.Content.Text(), "\n", " ")
|
| 1812 |
+
|
| 1813 |
+
// Remove thinking tags if present.
|
| 1814 |
+
title = thinkTagRegex.ReplaceAllString(title, "")
|
| 1815 |
+
title = orphanThinkTagRegex.ReplaceAllString(title, "")
|
| 1816 |
+
|
| 1817 |
+
title = strings.TrimSpace(title)
|
| 1818 |
+
if title == "" {
|
| 1819 |
+
// LLM returned empty content. Use the prompt itself as a
|
| 1820 |
+
// fallback title, truncated to 50 chars, before resorting to
|
| 1821 |
+
// the generic default.
|
| 1822 |
+
fallback := strings.ReplaceAll(userPrompt, "\n", " ")
|
| 1823 |
+
fallback = strings.TrimSpace(fallback)
|
| 1824 |
+
if len(fallback) > 50 {
|
| 1825 |
+
fallback = ansi.Truncate(fallback, 50, "…")
|
| 1826 |
+
}
|
| 1827 |
+
title = cmp.Or(fallback, DefaultSessionName)
|
| 1828 |
+
}
|
| 1829 |
+
|
| 1830 |
+
// Calculate usage and cost.
|
| 1831 |
+
var openrouterCost *float64
|
| 1832 |
+
for _, step := range resp.Steps {
|
| 1833 |
+
stepCost := a.openrouterCost(step.ProviderMetadata)
|
| 1834 |
+
if stepCost != nil {
|
| 1835 |
+
newCost := *stepCost
|
| 1836 |
+
if openrouterCost != nil {
|
| 1837 |
+
newCost += *openrouterCost
|
| 1838 |
+
}
|
| 1839 |
+
openrouterCost = &newCost
|
| 1840 |
+
}
|
| 1841 |
+
extractHyperCredits(step.ProviderMetadata)
|
| 1842 |
+
}
|
| 1843 |
+
|
| 1844 |
+
modelConfig := model.CatwalkCfg
|
| 1845 |
+
cost := modelConfig.CostPer1MInCached/1e6*float64(resp.TotalUsage.CacheCreationTokens) +
|
| 1846 |
+
modelConfig.CostPer1MOutCached/1e6*float64(resp.TotalUsage.CacheReadTokens) +
|
| 1847 |
+
modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
|
| 1848 |
+
modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
|
| 1849 |
+
|
| 1850 |
+
// Use override cost if available (e.g., from OpenRouter).
|
| 1851 |
+
if openrouterCost != nil {
|
| 1852 |
+
cost = *openrouterCost
|
| 1853 |
+
}
|
| 1854 |
+
|
| 1855 |
+
// Skip cost accumulation
|
| 1856 |
+
if model.FlatRate {
|
| 1857 |
+
cost = 0
|
| 1858 |
+
}
|
| 1859 |
+
|
| 1860 |
+
promptTokens := resp.TotalUsage.InputTokens + resp.TotalUsage.CacheCreationTokens
|
| 1861 |
+
completionTokens := resp.TotalUsage.OutputTokens
|
| 1862 |
+
|
| 1863 |
+
// Atomically update only title and usage fields to avoid overriding other
|
| 1864 |
+
// concurrent session updates.
|
| 1865 |
+
saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
|
| 1866 |
+
if saveErr != nil {
|
| 1867 |
+
slog.Error("Failed to save session title and usage", "error", saveErr)
|
| 1868 |
+
return
|
| 1869 |
+
}
|
| 1870 |
+
titleSaved = true
|
| 1871 |
+
}
|
| 1872 |
+
|
| 1873 |
+
func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float64 {
|
| 1874 |
+
openrouterMetadata, ok := metadata[openrouter.Name]
|
| 1875 |
+
if !ok {
|
| 1876 |
+
return nil
|
| 1877 |
+
}
|
| 1878 |
+
|
| 1879 |
+
opts, ok := openrouterMetadata.(*openrouter.ProviderMetadata)
|
| 1880 |
+
if !ok {
|
| 1881 |
+
return nil
|
| 1882 |
+
}
|
| 1883 |
+
return &opts.Usage.Cost
|
| 1884 |
+
}
|
| 1885 |
+
|
| 1886 |
+
// extractHyperCredits reads usage.remaining.hypercredits from OpenAI
|
| 1887 |
+
// provider metadata and stores it for the next FetchCredits call.
|
| 1888 |
+
func extractHyperCredits(metadata fantasy.ProviderMetadata) {
|
| 1889 |
+
openaiMeta, ok := metadata[openai.Name]
|
| 1890 |
+
if !ok {
|
| 1891 |
+
return
|
| 1892 |
+
}
|
| 1893 |
+
pm, ok := openaiMeta.(*openai.ProviderMetadata)
|
| 1894 |
+
if !ok {
|
| 1895 |
+
return
|
| 1896 |
+
}
|
| 1897 |
+
var remaining struct {
|
| 1898 |
+
Hypercredits float64 `json:"hypercredits"`
|
| 1899 |
+
}
|
| 1900 |
+
if pm.ExtraField("remaining", &remaining) && remaining.Hypercredits > 0 {
|
| 1901 |
+
hyper.SetBalance(int(math.Round(remaining.Hypercredits)))
|
| 1902 |
+
}
|
| 1903 |
+
}
|
| 1904 |
+
|
| 1905 |
+
// extractPrismModel returns the ID and name of the model that actually
|
| 1906 |
+
// served the turn, as reported by the Hyper Prism model router headers,
|
| 1907 |
+
// or empty strings when the turn was not routed through a Prism model.
|
| 1908 |
+
func extractPrismModel(metadata fantasy.ProviderMetadata) (modelID, modelName string) {
|
| 1909 |
+
openaiMeta, ok := metadata[openai.Name]
|
| 1910 |
+
if !ok {
|
| 1911 |
+
return "", ""
|
| 1912 |
+
}
|
| 1913 |
+
pm, ok := openaiMeta.(*openai.ProviderMetadata)
|
| 1914 |
+
if !ok {
|
| 1915 |
+
return "", ""
|
| 1916 |
+
}
|
| 1917 |
+
_ = pm.ExtraField(hyper.PrismModelIDField, &modelID)
|
| 1918 |
+
_ = pm.ExtraField(hyper.PrismModelNameField, &modelName)
|
| 1919 |
+
return modelID, modelName
|
| 1920 |
+
}
|
| 1921 |
+
|
| 1922 |
+
// extractPrismSavings returns the hypercredit and dollar savings from
|
| 1923 |
+
// routing the turn through the Hyper Prism model router, as reported by
|
| 1924 |
+
// its savings trailers, or nil when not reported or malformed.
|
| 1925 |
+
func extractPrismSavings(metadata fantasy.ProviderMetadata) (hypercredits, dollars *float64) {
|
| 1926 |
+
openaiMeta, ok := metadata[openai.Name]
|
| 1927 |
+
if !ok {
|
| 1928 |
+
return nil, nil
|
| 1929 |
+
}
|
| 1930 |
+
pm, ok := openaiMeta.(*openai.ProviderMetadata)
|
| 1931 |
+
if !ok {
|
| 1932 |
+
return nil, nil
|
| 1933 |
+
}
|
| 1934 |
+
return extraFieldFloat(pm, hyper.PrismHypercreditSavingsField), extraFieldFloat(pm, hyper.PrismDollarSavingsField)
|
| 1935 |
+
}
|
| 1936 |
+
|
| 1937 |
+
func extraFieldFloat(pm *openai.ProviderMetadata, key string) *float64 {
|
| 1938 |
+
var value string
|
| 1939 |
+
if !pm.ExtraField(key, &value) {
|
| 1940 |
+
return nil
|
| 1941 |
+
}
|
| 1942 |
+
parsed, err := strconv.ParseFloat(value, 64)
|
| 1943 |
+
if err != nil {
|
| 1944 |
+
slog.Warn("Could not parse Prism savings", "key", key, "value", value, "error", err)
|
| 1945 |
+
return nil
|
| 1946 |
+
}
|
| 1947 |
+
return &parsed
|
| 1948 |
+
}
|
| 1949 |
+
|
| 1950 |
+
func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64, estimated bool) {
|
| 1951 |
+
if !usageIsZero(usage) {
|
| 1952 |
+
session.EstimatedUsage = estimated
|
| 1953 |
+
}
|
| 1954 |
+
|
| 1955 |
+
modelConfig := model.CatwalkCfg
|
| 1956 |
+
cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
|
| 1957 |
+
modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
|
| 1958 |
+
modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
|
| 1959 |
+
modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
|
| 1960 |
+
|
| 1961 |
+
if !estimated {
|
| 1962 |
+
a.eventTokensUsed(session.ID, model, usage, cost)
|
| 1963 |
+
}
|
| 1964 |
+
|
| 1965 |
+
if estimated {
|
| 1966 |
+
cost = 0
|
| 1967 |
+
} else {
|
| 1968 |
+
// Use override cost if available (e.g., from OpenRouter).
|
| 1969 |
+
if overrideCost != nil {
|
| 1970 |
+
cost = *overrideCost
|
| 1971 |
+
}
|
| 1972 |
+
|
| 1973 |
+
// Skip cost accumulation
|
| 1974 |
+
if model.FlatRate {
|
| 1975 |
+
cost = 0
|
| 1976 |
+
}
|
| 1977 |
+
}
|
| 1978 |
+
|
| 1979 |
+
session.Cost += cost
|
| 1980 |
+
updateSessionTokenCounters(session, usage)
|
| 1981 |
+
}
|
| 1982 |
+
|
| 1983 |
+
func updateSessionTokenCounters(session *session.Session, usage fantasy.Usage) {
|
| 1984 |
+
if usage.OutputTokens != 0 {
|
| 1985 |
+
session.CompletionTokens = usage.OutputTokens
|
| 1986 |
+
}
|
| 1987 |
+
if promptTokens := usage.InputTokens + usage.CacheReadTokens; promptTokens != 0 {
|
| 1988 |
+
session.PromptTokens = promptTokens
|
| 1989 |
+
}
|
| 1990 |
+
}
|
| 1991 |
+
|
| 1992 |
+
func summaryCompletionTokens(usage fantasy.Usage, summaryMessage message.Message) int64 {
|
| 1993 |
+
if usage.OutputTokens != 0 {
|
| 1994 |
+
return usage.OutputTokens
|
| 1995 |
+
}
|
| 1996 |
+
return approxTokenCount(summaryMessage.Content().Text) + approxTokenCount(summaryMessage.ReasoningContent().String())
|
| 1997 |
+
}
|
| 1998 |
+
|
| 1999 |
+
func (a *sessionAgent) Cancel(sessionID string) {
|
| 2000 |
+
// Serialize against the dispatch handoff in Run so the accepted ->
|
| 2001 |
+
// (cancel-on-entry | queued | active) transition is atomic against
|
| 2002 |
+
// this cancel. Every cancel observes at least one of: an active
|
| 2003 |
+
// request, an accepted run (recorded as a pending cancel), or a
|
| 2004 |
+
// queue entry it then clears. If none of those hold, an idle Escape
|
| 2005 |
+
// is a true no-op and must not poison the next prompt.
|
| 2006 |
+
mu := a.sessionMu(sessionID)
|
| 2007 |
+
mu.Lock()
|
| 2008 |
+
defer mu.Unlock()
|
| 2009 |
+
|
| 2010 |
+
// Cancel regular requests. Don't use Take() here - we need the entry to
|
| 2011 |
+
// remain in activeRequests so IsBusy() returns true until the goroutine
|
| 2012 |
+
// fully completes (including error handling that may access the DB).
|
| 2013 |
+
// The defer in processRequest will clean up the entry.
|
| 2014 |
+
if ac, ok := a.activeRequests.Get(sessionID); ok && ac != nil {
|
| 2015 |
+
slog.Debug("Request cancellation initiated", "session_id", sessionID)
|
| 2016 |
+
ac.cancel()
|
| 2017 |
+
}
|
| 2018 |
+
|
| 2019 |
+
// Also check for summarize requests.
|
| 2020 |
+
if ac, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && ac != nil {
|
| 2021 |
+
slog.Debug("Summarize cancellation initiated", "session_id", sessionID)
|
| 2022 |
+
ac.cancel()
|
| 2023 |
+
}
|
| 2024 |
+
|
| 2025 |
+
// Record a pending cancel only when a dispatched-but-not-yet-active
|
| 2026 |
+
// run exists. This catches runs still in the goroutine scheduler or
|
| 2027 |
+
// about to enter Run's busy-queue branch, while leaving an idle
|
| 2028 |
+
// session untouched. Active and accepted are not mutually exclusive:
|
| 2029 |
+
// when a run is active and a follow-up has been accepted, both the
|
| 2030 |
+
// cancel above and this pending record fire.
|
| 2031 |
+
//
|
| 2032 |
+
// Raise the session's cancel mark to the latest accept sequence
|
| 2033 |
+
// assigned so far. Every prompt currently accepted-but-not-yet-
|
| 2034 |
+
// active has a sequence at or below that value, so one cancel covers
|
| 2035 |
+
// all of them; a prompt accepted after this cancel gets a strictly
|
| 2036 |
+
// higher sequence and is never poisoned. Using max keeps repeated
|
| 2037 |
+
// cancels idempotent while the same prompts are in flight and lets a
|
| 2038 |
+
// later cancel extend coverage to prompts accepted since.
|
| 2039 |
+
a.acceptedMu.Lock()
|
| 2040 |
+
count, ok := a.acceptedRuns.Get(sessionID)
|
| 2041 |
+
mark := a.acceptSeqGen
|
| 2042 |
+
a.acceptedMu.Unlock()
|
| 2043 |
+
if ok && count > 0 {
|
| 2044 |
+
slog.Debug("Recording cancel mark for accepted runs", "session_id", sessionID, "count", count, "mark", mark)
|
| 2045 |
+
existing, _ := a.cancelMark.Get(sessionID)
|
| 2046 |
+
a.cancelMark.Set(sessionID, max(existing, mark))
|
| 2047 |
+
}
|
| 2048 |
+
|
| 2049 |
+
if a.QueuedPrompts(sessionID) > 0 {
|
| 2050 |
+
slog.Debug("Clearing queued prompts", "session_id", sessionID)
|
| 2051 |
+
a.clearQueueAndNotify(sessionID)
|
| 2052 |
+
}
|
| 2053 |
+
}
|
| 2054 |
+
|
| 2055 |
+
func (a *sessionAgent) ClearQueue(sessionID string) {
|
| 2056 |
+
if a.QueuedPrompts(sessionID) > 0 {
|
| 2057 |
+
slog.Debug("Clearing queued prompts", "session_id", sessionID)
|
| 2058 |
+
a.clearQueueAndNotify(sessionID)
|
| 2059 |
+
}
|
| 2060 |
+
}
|
| 2061 |
+
|
| 2062 |
+
func (a *sessionAgent) CancelAll() {
|
| 2063 |
+
if !a.IsBusy() {
|
| 2064 |
+
return
|
| 2065 |
+
}
|
| 2066 |
+
for key := range a.activeRequests.Seq2() {
|
| 2067 |
+
a.Cancel(key) // key is sessionID
|
| 2068 |
+
}
|
| 2069 |
+
|
| 2070 |
+
timeout := time.After(5 * time.Second)
|
| 2071 |
+
for a.IsBusy() {
|
| 2072 |
+
select {
|
| 2073 |
+
case <-timeout:
|
| 2074 |
+
return
|
| 2075 |
+
default:
|
| 2076 |
+
time.Sleep(200 * time.Millisecond)
|
| 2077 |
+
}
|
| 2078 |
+
}
|
| 2079 |
+
}
|
| 2080 |
+
|
| 2081 |
+
func (a *sessionAgent) IsBusy() bool {
|
| 2082 |
+
var busy bool
|
| 2083 |
+
for ac := range a.activeRequests.Seq() {
|
| 2084 |
+
if ac != nil {
|
| 2085 |
+
busy = true
|
| 2086 |
+
break
|
| 2087 |
+
}
|
| 2088 |
+
}
|
| 2089 |
+
return busy
|
| 2090 |
+
}
|
| 2091 |
+
|
| 2092 |
+
func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
|
| 2093 |
+
_, busy := a.activeRequests.Get(sessionID)
|
| 2094 |
+
return busy
|
| 2095 |
+
}
|
| 2096 |
+
|
| 2097 |
+
func (a *sessionAgent) QueuedPrompts(sessionID string) int {
|
| 2098 |
+
l, ok := a.messageQueue.Get(sessionID)
|
| 2099 |
+
if !ok {
|
| 2100 |
+
return 0
|
| 2101 |
+
}
|
| 2102 |
+
return len(l)
|
| 2103 |
+
}
|
| 2104 |
+
|
| 2105 |
+
func (a *sessionAgent) QueuedPromptsList(sessionID string) []string {
|
| 2106 |
+
l, ok := a.messageQueue.Get(sessionID)
|
| 2107 |
+
if !ok {
|
| 2108 |
+
return nil
|
| 2109 |
+
}
|
| 2110 |
+
prompts := make([]string, len(l))
|
| 2111 |
+
for i, call := range l {
|
| 2112 |
+
prompts[i] = call.Prompt
|
| 2113 |
+
}
|
| 2114 |
+
return prompts
|
| 2115 |
+
}
|
| 2116 |
+
|
| 2117 |
+
func (a *sessionAgent) SetModels(large Model, small Model) {
|
| 2118 |
+
a.largeModel.Set(large)
|
| 2119 |
+
a.smallModel.Set(small)
|
| 2120 |
+
}
|
| 2121 |
+
|
| 2122 |
+
func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
|
| 2123 |
+
a.tools.SetSlice(tools)
|
| 2124 |
+
}
|
| 2125 |
+
|
| 2126 |
+
func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
|
| 2127 |
+
a.systemPrompt.Set(systemPrompt)
|
| 2128 |
+
}
|
| 2129 |
+
|
| 2130 |
+
func (a *sessionAgent) Model() Model {
|
| 2131 |
+
return a.largeModel.Get()
|
| 2132 |
+
}
|
| 2133 |
+
|
| 2134 |
+
// convertToToolResult converts a fantasy tool result to a message tool result.
|
| 2135 |
+
func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) message.ToolResult {
|
| 2136 |
+
baseResult := message.ToolResult{
|
| 2137 |
+
ToolCallID: result.ToolCallID,
|
| 2138 |
+
Name: result.ToolName,
|
| 2139 |
+
Metadata: result.ClientMetadata,
|
| 2140 |
+
}
|
| 2141 |
+
|
| 2142 |
+
switch result.Result.GetType() {
|
| 2143 |
+
case fantasy.ToolResultContentTypeText:
|
| 2144 |
+
if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Result); ok {
|
| 2145 |
+
baseResult.Content = r.Text
|
| 2146 |
+
}
|
| 2147 |
+
case fantasy.ToolResultContentTypeError:
|
| 2148 |
+
if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Result); ok {
|
| 2149 |
+
baseResult.Content = r.Error.Error()
|
| 2150 |
+
baseResult.IsError = true
|
| 2151 |
+
}
|
| 2152 |
+
case fantasy.ToolResultContentTypeMedia:
|
| 2153 |
+
if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result.Result); ok {
|
| 2154 |
+
if !stringext.IsValidBase64(r.Data) {
|
| 2155 |
+
slog.Warn(
|
| 2156 |
+
"Tool returned media with invalid base64 data, discarding image",
|
| 2157 |
+
"tool", result.ToolName,
|
| 2158 |
+
"tool_call_id", result.ToolCallID,
|
| 2159 |
+
)
|
| 2160 |
+
baseResult.Content = "Tool returned image data with invalid encoding"
|
| 2161 |
+
baseResult.IsError = true
|
| 2162 |
+
} else {
|
| 2163 |
+
content := r.Text
|
| 2164 |
+
if content == "" {
|
| 2165 |
+
content = fmt.Sprintf("Loaded %s content", r.MediaType)
|
| 2166 |
+
}
|
| 2167 |
+
baseResult.Content = content
|
| 2168 |
+
baseResult.Data = r.Data
|
| 2169 |
+
baseResult.MIMEType = r.MediaType
|
| 2170 |
+
}
|
| 2171 |
+
}
|
| 2172 |
+
}
|
| 2173 |
+
|
| 2174 |
+
return baseResult
|
| 2175 |
+
}
|
| 2176 |
+
|
| 2177 |
+
// workaroundProviderMediaLimitations converts media content in tool results to
|
| 2178 |
+
// user messages for providers that don't natively support images in tool results.
|
| 2179 |
+
//
|
| 2180 |
+
// Problem: OpenAI, Google, OpenRouter, and other OpenAI-compatible providers
|
| 2181 |
+
// don't support sending images/media in tool result messages - they only accept
|
| 2182 |
+
// text in tool results. However, they DO support images in user messages.
|
| 2183 |
+
//
|
| 2184 |
+
// If we send media in tool results to these providers, the API returns an error.
|
| 2185 |
+
//
|
| 2186 |
+
// Solution: For these providers, we:
|
| 2187 |
+
// 1. Replace the media in the tool result with a text placeholder
|
| 2188 |
+
// 2. Inject a user message immediately after with the image as a file attachment
|
| 2189 |
+
// 3. This maintains the tool execution flow while working around API limitations
|
| 2190 |
+
//
|
| 2191 |
+
// Anthropic and Bedrock support images natively in tool results, so we skip
|
| 2192 |
+
// this workaround for them.
|
| 2193 |
+
//
|
| 2194 |
+
// Example transformation:
|
| 2195 |
+
//
|
| 2196 |
+
// BEFORE: [tool result: image data]
|
| 2197 |
+
// AFTER: [tool result: "Image loaded - see attached"], [user: image attachment]
|
| 2198 |
+
func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message, largeModel Model) []fantasy.Message {
|
| 2199 |
+
providerSupportsMedia := largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
|
| 2200 |
+
largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock) ||
|
| 2201 |
+
largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrockEurope)
|
| 2202 |
+
|
| 2203 |
+
if providerSupportsMedia {
|
| 2204 |
+
return messages
|
| 2205 |
+
}
|
| 2206 |
+
|
| 2207 |
+
supportsImages := largeModel.CatwalkCfg.SupportsImages
|
| 2208 |
+
|
| 2209 |
+
convertedMessages := make([]fantasy.Message, 0, len(messages))
|
| 2210 |
+
|
| 2211 |
+
for _, msg := range messages {
|
| 2212 |
+
if msg.Role != fantasy.MessageRoleTool {
|
| 2213 |
+
convertedMessages = append(convertedMessages, msg)
|
| 2214 |
+
continue
|
| 2215 |
+
}
|
| 2216 |
+
|
| 2217 |
+
textParts := make([]fantasy.MessagePart, 0, len(msg.Content))
|
| 2218 |
+
var mediaFiles []fantasy.FilePart
|
| 2219 |
+
|
| 2220 |
+
for _, part := range msg.Content {
|
| 2221 |
+
toolResult, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
|
| 2222 |
+
if !ok {
|
| 2223 |
+
textParts = append(textParts, part)
|
| 2224 |
+
continue
|
| 2225 |
+
}
|
| 2226 |
+
|
| 2227 |
+
if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
|
| 2228 |
+
if !supportsImages {
|
| 2229 |
+
// Model cannot process images. Replace with a text
|
| 2230 |
+
// placeholder and skip creating a synthetic user
|
| 2231 |
+
// message with FilePart, which would brick the
|
| 2232 |
+
// session on text-only models.
|
| 2233 |
+
textParts = append(textParts, fantasy.ToolResultPart{
|
| 2234 |
+
ToolCallID: toolResult.ToolCallID,
|
| 2235 |
+
Output: fantasy.ToolResultOutputContentText{
|
| 2236 |
+
Text: "[Image/media content not supported by this model]",
|
| 2237 |
+
},
|
| 2238 |
+
ProviderOptions: toolResult.ProviderOptions,
|
| 2239 |
+
})
|
| 2240 |
+
continue
|
| 2241 |
+
}
|
| 2242 |
+
|
| 2243 |
+
decoded, err := base64.StdEncoding.DecodeString(media.Data)
|
| 2244 |
+
if err != nil {
|
| 2245 |
+
slog.Warn("Failed to decode media data", "error", err)
|
| 2246 |
+
textParts = append(textParts, part)
|
| 2247 |
+
continue
|
| 2248 |
+
}
|
| 2249 |
+
|
| 2250 |
+
mediaFiles = append(mediaFiles, fantasy.FilePart{
|
| 2251 |
+
Data: decoded,
|
| 2252 |
+
MediaType: media.MediaType,
|
| 2253 |
+
Filename: fmt.Sprintf("tool-result-%s", toolResult.ToolCallID),
|
| 2254 |
+
})
|
| 2255 |
+
|
| 2256 |
+
textParts = append(textParts, fantasy.ToolResultPart{
|
| 2257 |
+
ToolCallID: toolResult.ToolCallID,
|
| 2258 |
+
Output: fantasy.ToolResultOutputContentText{
|
| 2259 |
+
Text: "[Image/media content loaded - see attached file]",
|
| 2260 |
+
},
|
| 2261 |
+
ProviderOptions: toolResult.ProviderOptions,
|
| 2262 |
+
})
|
| 2263 |
+
} else {
|
| 2264 |
+
textParts = append(textParts, part)
|
| 2265 |
+
}
|
| 2266 |
+
}
|
| 2267 |
+
|
| 2268 |
+
convertedMessages = append(convertedMessages, fantasy.Message{
|
| 2269 |
+
Role: fantasy.MessageRoleTool,
|
| 2270 |
+
Content: textParts,
|
| 2271 |
+
})
|
| 2272 |
+
|
| 2273 |
+
if len(mediaFiles) > 0 {
|
| 2274 |
+
convertedMessages = append(convertedMessages, fantasy.NewUserMessage(
|
| 2275 |
+
"Here is the media content from the tool result:",
|
| 2276 |
+
mediaFiles...,
|
| 2277 |
+
))
|
| 2278 |
+
}
|
| 2279 |
+
}
|
| 2280 |
+
|
| 2281 |
+
return convertedMessages
|
| 2282 |
+
}
|
| 2283 |
+
|
| 2284 |
+
// buildSummaryPrompt constructs the prompt text for session summarization.
|
| 2285 |
+
func buildSummaryPrompt(todos []session.Todo) string {
|
| 2286 |
+
var sb strings.Builder
|
| 2287 |
+
sb.WriteString("Provide a detailed summary of our conversation above.")
|
| 2288 |
+
if len(todos) > 0 {
|
| 2289 |
+
sb.WriteString("\n\n## Current Todo List\n\n")
|
| 2290 |
+
for _, t := range todos {
|
| 2291 |
+
fmt.Fprintf(&sb, "- [%s] %s\n", t.Status, t.Content)
|
| 2292 |
+
}
|
| 2293 |
+
sb.WriteString("\nInclude these tasks and their statuses in your summary. ")
|
| 2294 |
+
sb.WriteString("Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks.")
|
| 2295 |
+
}
|
| 2296 |
+
return sb.String()
|
| 2297 |
+
}
|
| 2298 |
+
|
| 2299 |
+
func providerRetryLogFields(err *fantasy.ProviderError, delay time.Duration) []any {
|
| 2300 |
+
fields := []any{
|
| 2301 |
+
"retry_delay", delay.String(),
|
| 2302 |
+
}
|
| 2303 |
+
if err == nil {
|
| 2304 |
+
return fields
|
| 2305 |
+
}
|
| 2306 |
+
fields = append(fields, "status_code", err.StatusCode)
|
| 2307 |
+
if err.Title != "" {
|
| 2308 |
+
fields = append(fields, "title", err.Title)
|
| 2309 |
+
}
|
| 2310 |
+
if err.Message != "" {
|
| 2311 |
+
fields = append(fields, "message", err.Message)
|
| 2312 |
+
}
|
| 2313 |
+
return fields
|
| 2314 |
+
}
|
| 2315 |
+
|
| 2316 |
+
// sanitizeToolInput validates tool call JSON from the provider.
|
| 2317 |
+
// Malformed input is replaced with an empty object to prevent
|
| 2318 |
+
// stuck conversations from truncated or malformed model output.
|
| 2319 |
+
// The second return value indicates whether sanitization occurred.
|
| 2320 |
+
func sanitizeToolInput(toolName, toolCallID, input string) (string, bool) {
|
| 2321 |
+
if !json.Valid([]byte(input)) {
|
| 2322 |
+
slog.Warn(
|
| 2323 |
+
"Malformed tool call JSON from provider, replacing with empty object",
|
| 2324 |
+
"tool", toolName,
|
| 2325 |
+
"id", toolCallID,
|
| 2326 |
+
"input_len", len(input),
|
| 2327 |
+
)
|
| 2328 |
+
return "{}", true
|
| 2329 |
+
}
|
| 2330 |
+
return input, false
|
| 2331 |
+
}
|
internal/agent/agent_test.go
ADDED
|
@@ -0,0 +1,1353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/base64"
|
| 5 |
+
"fmt"
|
| 6 |
+
"log/slog"
|
| 7 |
+
"os"
|
| 8 |
+
"path/filepath"
|
| 9 |
+
"runtime"
|
| 10 |
+
"strings"
|
| 11 |
+
"testing"
|
| 12 |
+
"time"
|
| 13 |
+
|
| 14 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 15 |
+
"charm.land/fantasy"
|
| 16 |
+
"charm.land/x/vcr"
|
| 17 |
+
"github.com/charmbracelet/crush/internal/agent/tools"
|
| 18 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 19 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 20 |
+
"github.com/charmbracelet/crush/internal/session"
|
| 21 |
+
"github.com/stretchr/testify/assert"
|
| 22 |
+
"github.com/stretchr/testify/require"
|
| 23 |
+
|
| 24 |
+
_ "github.com/joho/godotenv/autoload"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
func TestMain(m *testing.M) {
|
| 28 |
+
slog.SetLogLoggerLevel(slog.LevelError)
|
| 29 |
+
m.Run()
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
var modelPairs = []modelPair{
|
| 33 |
+
{"deepseek-v4", hyperBuilder("deepseek-v4-pro-0813"), hyperBuilder("deepseek-v4-flash-0731")},
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func getModels(t *testing.T, r *vcr.Recorder, pair modelPair) (fantasy.LanguageModel, fantasy.LanguageModel) {
|
| 37 |
+
large, err := pair.largeModel(t, r)
|
| 38 |
+
require.NoError(t, err)
|
| 39 |
+
small, err := pair.smallModel(t, r)
|
| 40 |
+
require.NoError(t, err)
|
| 41 |
+
return large, small
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
func setupAgent(t *testing.T, pair modelPair) (SessionAgent, fakeEnv) {
|
| 45 |
+
r := vcr.NewRecorder(t)
|
| 46 |
+
large, small := getModels(t, r, pair)
|
| 47 |
+
env := testEnv(t)
|
| 48 |
+
|
| 49 |
+
createSimpleGoProject(t, env.workingDir)
|
| 50 |
+
agent, err := coderAgent(r, env, large, small)
|
| 51 |
+
require.NoError(t, err)
|
| 52 |
+
return agent, env
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
func TestCoderAgent(t *testing.T) {
|
| 56 |
+
if runtime.GOOS == "windows" {
|
| 57 |
+
t.Skip("skipping on windows for now")
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
for _, pair := range modelPairs {
|
| 61 |
+
t.Run(pair.name, func(t *testing.T) {
|
| 62 |
+
t.Run("simple test", func(t *testing.T) {
|
| 63 |
+
agent, env := setupAgent(t, pair)
|
| 64 |
+
|
| 65 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 66 |
+
require.NoError(t, err)
|
| 67 |
+
|
| 68 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 69 |
+
Prompt: "Hello",
|
| 70 |
+
SessionID: session.ID,
|
| 71 |
+
MaxOutputTokens: 10000,
|
| 72 |
+
})
|
| 73 |
+
require.NoError(t, err)
|
| 74 |
+
assert.NotNil(t, res)
|
| 75 |
+
|
| 76 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 77 |
+
require.NoError(t, err)
|
| 78 |
+
// Should have the agent and user message
|
| 79 |
+
assert.Equal(t, len(msgs), 2)
|
| 80 |
+
})
|
| 81 |
+
t.Run("read a file", func(t *testing.T) {
|
| 82 |
+
agent, env := setupAgent(t, pair)
|
| 83 |
+
|
| 84 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 85 |
+
require.NoError(t, err)
|
| 86 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 87 |
+
Prompt: "Read the go mod",
|
| 88 |
+
SessionID: session.ID,
|
| 89 |
+
MaxOutputTokens: 10000,
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
require.NoError(t, err)
|
| 93 |
+
assert.NotNil(t, res)
|
| 94 |
+
|
| 95 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 96 |
+
require.NoError(t, err)
|
| 97 |
+
foundFile := false
|
| 98 |
+
var tcID string
|
| 99 |
+
out:
|
| 100 |
+
for _, msg := range msgs {
|
| 101 |
+
if msg.Role == message.Assistant {
|
| 102 |
+
for _, tc := range msg.ToolCalls() {
|
| 103 |
+
if tc.Name == tools.ViewToolName {
|
| 104 |
+
tcID = tc.ID
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
if msg.Role == message.Tool {
|
| 109 |
+
for _, tr := range msg.ToolResults() {
|
| 110 |
+
if tr.ToolCallID == tcID {
|
| 111 |
+
if strings.Contains(tr.Content, "module example.com/testproject") {
|
| 112 |
+
foundFile = true
|
| 113 |
+
break out
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
require.True(t, foundFile)
|
| 120 |
+
})
|
| 121 |
+
t.Run("update a file", func(t *testing.T) {
|
| 122 |
+
agent, env := setupAgent(t, pair)
|
| 123 |
+
|
| 124 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 125 |
+
require.NoError(t, err)
|
| 126 |
+
|
| 127 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 128 |
+
Prompt: "update the main.go file by changing the print to say hello from crush",
|
| 129 |
+
SessionID: session.ID,
|
| 130 |
+
MaxOutputTokens: 10000,
|
| 131 |
+
})
|
| 132 |
+
require.NoError(t, err)
|
| 133 |
+
assert.NotNil(t, res)
|
| 134 |
+
|
| 135 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 136 |
+
require.NoError(t, err)
|
| 137 |
+
|
| 138 |
+
foundRead := false
|
| 139 |
+
foundWrite := false
|
| 140 |
+
var readTCID, writeTCID string
|
| 141 |
+
|
| 142 |
+
for _, msg := range msgs {
|
| 143 |
+
if msg.Role == message.Assistant {
|
| 144 |
+
for _, tc := range msg.ToolCalls() {
|
| 145 |
+
if tc.Name == tools.ViewToolName {
|
| 146 |
+
readTCID = tc.ID
|
| 147 |
+
}
|
| 148 |
+
if tc.Name == tools.EditToolName || tc.Name == tools.WriteToolName {
|
| 149 |
+
writeTCID = tc.ID
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
if msg.Role == message.Tool {
|
| 154 |
+
for _, tr := range msg.ToolResults() {
|
| 155 |
+
if tr.ToolCallID == readTCID {
|
| 156 |
+
foundRead = true
|
| 157 |
+
}
|
| 158 |
+
if tr.ToolCallID == writeTCID {
|
| 159 |
+
foundWrite = true
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
require.True(t, foundRead, "Expected to find a read operation")
|
| 166 |
+
require.True(t, foundWrite, "Expected to find a write operation")
|
| 167 |
+
|
| 168 |
+
mainGoPath := filepath.Join(env.workingDir, "main.go")
|
| 169 |
+
content, err := os.ReadFile(mainGoPath)
|
| 170 |
+
require.NoError(t, err)
|
| 171 |
+
require.Contains(t, strings.ToLower(string(content)), "hello from crush")
|
| 172 |
+
})
|
| 173 |
+
t.Run("bash tool", func(t *testing.T) {
|
| 174 |
+
agent, env := setupAgent(t, pair)
|
| 175 |
+
|
| 176 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 177 |
+
require.NoError(t, err)
|
| 178 |
+
|
| 179 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 180 |
+
Prompt: "use bash to create a file named test.txt with content 'hello bash'. do not print its timestamp",
|
| 181 |
+
SessionID: session.ID,
|
| 182 |
+
MaxOutputTokens: 10000,
|
| 183 |
+
})
|
| 184 |
+
require.NoError(t, err)
|
| 185 |
+
assert.NotNil(t, res)
|
| 186 |
+
|
| 187 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 188 |
+
require.NoError(t, err)
|
| 189 |
+
|
| 190 |
+
foundBash := false
|
| 191 |
+
var bashTCID string
|
| 192 |
+
|
| 193 |
+
for _, msg := range msgs {
|
| 194 |
+
if msg.Role == message.Assistant {
|
| 195 |
+
for _, tc := range msg.ToolCalls() {
|
| 196 |
+
if tc.Name == tools.BashToolName {
|
| 197 |
+
bashTCID = tc.ID
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
if msg.Role == message.Tool {
|
| 202 |
+
for _, tr := range msg.ToolResults() {
|
| 203 |
+
if tr.ToolCallID == bashTCID {
|
| 204 |
+
foundBash = true
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
require.True(t, foundBash, "Expected to find a bash operation")
|
| 211 |
+
|
| 212 |
+
testFilePath := filepath.Join(env.workingDir, "test.txt")
|
| 213 |
+
content, err := os.ReadFile(testFilePath)
|
| 214 |
+
require.NoError(t, err)
|
| 215 |
+
require.Contains(t, string(content), "hello bash")
|
| 216 |
+
})
|
| 217 |
+
t.Run("download tool", func(t *testing.T) {
|
| 218 |
+
agent, env := setupAgent(t, pair)
|
| 219 |
+
|
| 220 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 221 |
+
require.NoError(t, err)
|
| 222 |
+
|
| 223 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 224 |
+
Prompt: "download the file from https://example-files.online-convert.com/document/txt/example.txt and save it as example.txt",
|
| 225 |
+
SessionID: session.ID,
|
| 226 |
+
MaxOutputTokens: 10000,
|
| 227 |
+
})
|
| 228 |
+
require.NoError(t, err)
|
| 229 |
+
assert.NotNil(t, res)
|
| 230 |
+
|
| 231 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 232 |
+
require.NoError(t, err)
|
| 233 |
+
|
| 234 |
+
foundDownload := false
|
| 235 |
+
var downloadTCID string
|
| 236 |
+
|
| 237 |
+
for _, msg := range msgs {
|
| 238 |
+
if msg.Role == message.Assistant {
|
| 239 |
+
for _, tc := range msg.ToolCalls() {
|
| 240 |
+
if tc.Name == tools.DownloadToolName {
|
| 241 |
+
downloadTCID = tc.ID
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
if msg.Role == message.Tool {
|
| 246 |
+
for _, tr := range msg.ToolResults() {
|
| 247 |
+
if tr.ToolCallID == downloadTCID {
|
| 248 |
+
foundDownload = true
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
require.True(t, foundDownload, "Expected to find a download operation")
|
| 255 |
+
|
| 256 |
+
examplePath := filepath.Join(env.workingDir, "example.txt")
|
| 257 |
+
_, err = os.Stat(examplePath)
|
| 258 |
+
require.NoError(t, err, "Expected example.txt file to exist")
|
| 259 |
+
})
|
| 260 |
+
t.Run("fetch tool", func(t *testing.T) {
|
| 261 |
+
agent, env := setupAgent(t, pair)
|
| 262 |
+
|
| 263 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 264 |
+
require.NoError(t, err)
|
| 265 |
+
|
| 266 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 267 |
+
Prompt: "fetch the content from https://example-files.online-convert.com/website/html/example.html and tell me if it contains the word 'John Doe'",
|
| 268 |
+
SessionID: session.ID,
|
| 269 |
+
MaxOutputTokens: 10000,
|
| 270 |
+
})
|
| 271 |
+
require.NoError(t, err)
|
| 272 |
+
assert.NotNil(t, res)
|
| 273 |
+
|
| 274 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 275 |
+
require.NoError(t, err)
|
| 276 |
+
|
| 277 |
+
foundFetch := false
|
| 278 |
+
var fetchTCID string
|
| 279 |
+
|
| 280 |
+
for _, msg := range msgs {
|
| 281 |
+
if msg.Role == message.Assistant {
|
| 282 |
+
for _, tc := range msg.ToolCalls() {
|
| 283 |
+
if tc.Name == tools.FetchToolName {
|
| 284 |
+
fetchTCID = tc.ID
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
if msg.Role == message.Tool {
|
| 289 |
+
for _, tr := range msg.ToolResults() {
|
| 290 |
+
if tr.ToolCallID == fetchTCID {
|
| 291 |
+
foundFetch = true
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
require.True(t, foundFetch, "Expected to find a fetch operation")
|
| 298 |
+
})
|
| 299 |
+
t.Run("glob tool", func(t *testing.T) {
|
| 300 |
+
agent, env := setupAgent(t, pair)
|
| 301 |
+
|
| 302 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 303 |
+
require.NoError(t, err)
|
| 304 |
+
|
| 305 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 306 |
+
Prompt: "use glob to find all .go files in the current directory",
|
| 307 |
+
SessionID: session.ID,
|
| 308 |
+
MaxOutputTokens: 10000,
|
| 309 |
+
})
|
| 310 |
+
require.NoError(t, err)
|
| 311 |
+
assert.NotNil(t, res)
|
| 312 |
+
|
| 313 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 314 |
+
require.NoError(t, err)
|
| 315 |
+
|
| 316 |
+
foundGlob := false
|
| 317 |
+
var globTCID string
|
| 318 |
+
|
| 319 |
+
for _, msg := range msgs {
|
| 320 |
+
if msg.Role == message.Assistant {
|
| 321 |
+
for _, tc := range msg.ToolCalls() {
|
| 322 |
+
if tc.Name == tools.GlobToolName {
|
| 323 |
+
globTCID = tc.ID
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
if msg.Role == message.Tool {
|
| 328 |
+
for _, tr := range msg.ToolResults() {
|
| 329 |
+
if tr.ToolCallID == globTCID {
|
| 330 |
+
foundGlob = true
|
| 331 |
+
require.Contains(t, tr.Content, "main.go", "Expected glob to find main.go")
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
require.True(t, foundGlob, "Expected to find a glob operation")
|
| 338 |
+
})
|
| 339 |
+
t.Run("grep tool", func(t *testing.T) {
|
| 340 |
+
agent, env := setupAgent(t, pair)
|
| 341 |
+
|
| 342 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 343 |
+
require.NoError(t, err)
|
| 344 |
+
|
| 345 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 346 |
+
Prompt: "use grep to search for the word 'package' in go files",
|
| 347 |
+
SessionID: session.ID,
|
| 348 |
+
MaxOutputTokens: 10000,
|
| 349 |
+
})
|
| 350 |
+
require.NoError(t, err)
|
| 351 |
+
assert.NotNil(t, res)
|
| 352 |
+
|
| 353 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 354 |
+
require.NoError(t, err)
|
| 355 |
+
|
| 356 |
+
foundGrep := false
|
| 357 |
+
var grepTCID string
|
| 358 |
+
|
| 359 |
+
for _, msg := range msgs {
|
| 360 |
+
if msg.Role == message.Assistant {
|
| 361 |
+
for _, tc := range msg.ToolCalls() {
|
| 362 |
+
if tc.Name == tools.GrepToolName {
|
| 363 |
+
grepTCID = tc.ID
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
if msg.Role == message.Tool {
|
| 368 |
+
for _, tr := range msg.ToolResults() {
|
| 369 |
+
if tr.ToolCallID == grepTCID {
|
| 370 |
+
foundGrep = true
|
| 371 |
+
require.Contains(t, tr.Content, "main.go", "Expected grep to find main.go")
|
| 372 |
+
}
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
require.True(t, foundGrep, "Expected to find a grep operation")
|
| 378 |
+
})
|
| 379 |
+
t.Run("ls tool", func(t *testing.T) {
|
| 380 |
+
agent, env := setupAgent(t, pair)
|
| 381 |
+
|
| 382 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 383 |
+
require.NoError(t, err)
|
| 384 |
+
|
| 385 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 386 |
+
Prompt: "use ls to list the files in the current directory",
|
| 387 |
+
SessionID: session.ID,
|
| 388 |
+
MaxOutputTokens: 10000,
|
| 389 |
+
})
|
| 390 |
+
require.NoError(t, err)
|
| 391 |
+
assert.NotNil(t, res)
|
| 392 |
+
|
| 393 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 394 |
+
require.NoError(t, err)
|
| 395 |
+
|
| 396 |
+
foundLS := false
|
| 397 |
+
var lsTCID string
|
| 398 |
+
|
| 399 |
+
for _, msg := range msgs {
|
| 400 |
+
if msg.Role == message.Assistant {
|
| 401 |
+
for _, tc := range msg.ToolCalls() {
|
| 402 |
+
if tc.Name == tools.LSToolName {
|
| 403 |
+
lsTCID = tc.ID
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
if msg.Role == message.Tool {
|
| 408 |
+
for _, tr := range msg.ToolResults() {
|
| 409 |
+
if tr.ToolCallID == lsTCID {
|
| 410 |
+
foundLS = true
|
| 411 |
+
require.Contains(t, tr.Content, "main.go", "Expected ls to list main.go")
|
| 412 |
+
require.Contains(t, tr.Content, "go.mod", "Expected ls to list go.mod")
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
require.True(t, foundLS, "Expected to find an ls operation")
|
| 419 |
+
})
|
| 420 |
+
t.Run("multiedit tool", func(t *testing.T) {
|
| 421 |
+
agent, env := setupAgent(t, pair)
|
| 422 |
+
|
| 423 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 424 |
+
require.NoError(t, err)
|
| 425 |
+
|
| 426 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 427 |
+
Prompt: "use multiedit to change 'Hello, World!' to 'Hello, Crush!' and add a comment '// Greeting' above the fmt.Println line in main.go",
|
| 428 |
+
SessionID: session.ID,
|
| 429 |
+
MaxOutputTokens: 10000,
|
| 430 |
+
})
|
| 431 |
+
require.NoError(t, err)
|
| 432 |
+
assert.NotNil(t, res)
|
| 433 |
+
|
| 434 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 435 |
+
require.NoError(t, err)
|
| 436 |
+
|
| 437 |
+
foundMultiEdit := false
|
| 438 |
+
var multiEditTCID string
|
| 439 |
+
|
| 440 |
+
for _, msg := range msgs {
|
| 441 |
+
if msg.Role == message.Assistant {
|
| 442 |
+
for _, tc := range msg.ToolCalls() {
|
| 443 |
+
if tc.Name == tools.MultiEditToolName {
|
| 444 |
+
multiEditTCID = tc.ID
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
}
|
| 448 |
+
if msg.Role == message.Tool {
|
| 449 |
+
for _, tr := range msg.ToolResults() {
|
| 450 |
+
if tr.ToolCallID == multiEditTCID {
|
| 451 |
+
foundMultiEdit = true
|
| 452 |
+
}
|
| 453 |
+
}
|
| 454 |
+
}
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
require.True(t, foundMultiEdit, "Expected to find a multiedit operation")
|
| 458 |
+
|
| 459 |
+
mainGoPath := filepath.Join(env.workingDir, "main.go")
|
| 460 |
+
content, err := os.ReadFile(mainGoPath)
|
| 461 |
+
require.NoError(t, err)
|
| 462 |
+
require.Contains(t, string(content), "Hello, Crush!", "Expected file to contain 'Hello, Crush!'")
|
| 463 |
+
})
|
| 464 |
+
t.Run("sourcegraph tool", func(t *testing.T) {
|
| 465 |
+
agent, env := setupAgent(t, pair)
|
| 466 |
+
|
| 467 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 468 |
+
require.NoError(t, err)
|
| 469 |
+
|
| 470 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 471 |
+
Prompt: "use sourcegraph to search for 'func main' in Go repositories",
|
| 472 |
+
SessionID: session.ID,
|
| 473 |
+
MaxOutputTokens: 10000,
|
| 474 |
+
})
|
| 475 |
+
require.NoError(t, err)
|
| 476 |
+
assert.NotNil(t, res)
|
| 477 |
+
|
| 478 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 479 |
+
require.NoError(t, err)
|
| 480 |
+
|
| 481 |
+
foundSourcegraph := false
|
| 482 |
+
var sourcegraphTCID string
|
| 483 |
+
|
| 484 |
+
for _, msg := range msgs {
|
| 485 |
+
if msg.Role == message.Assistant {
|
| 486 |
+
for _, tc := range msg.ToolCalls() {
|
| 487 |
+
if tc.Name == tools.SourcegraphToolName {
|
| 488 |
+
sourcegraphTCID = tc.ID
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
}
|
| 492 |
+
if msg.Role == message.Tool {
|
| 493 |
+
for _, tr := range msg.ToolResults() {
|
| 494 |
+
if tr.ToolCallID == sourcegraphTCID {
|
| 495 |
+
foundSourcegraph = true
|
| 496 |
+
}
|
| 497 |
+
}
|
| 498 |
+
}
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
require.True(t, foundSourcegraph, "Expected to find a sourcegraph operation")
|
| 502 |
+
})
|
| 503 |
+
t.Run("write tool", func(t *testing.T) {
|
| 504 |
+
agent, env := setupAgent(t, pair)
|
| 505 |
+
|
| 506 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 507 |
+
require.NoError(t, err)
|
| 508 |
+
|
| 509 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 510 |
+
Prompt: "use write to create a new file called config.json with content '{\"name\": \"test\", \"version\": \"1.0.0\"}'",
|
| 511 |
+
SessionID: session.ID,
|
| 512 |
+
MaxOutputTokens: 10000,
|
| 513 |
+
})
|
| 514 |
+
require.NoError(t, err)
|
| 515 |
+
assert.NotNil(t, res)
|
| 516 |
+
|
| 517 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 518 |
+
require.NoError(t, err)
|
| 519 |
+
|
| 520 |
+
foundWrite := false
|
| 521 |
+
var writeTCID string
|
| 522 |
+
|
| 523 |
+
for _, msg := range msgs {
|
| 524 |
+
if msg.Role == message.Assistant {
|
| 525 |
+
for _, tc := range msg.ToolCalls() {
|
| 526 |
+
if tc.Name == tools.WriteToolName {
|
| 527 |
+
writeTCID = tc.ID
|
| 528 |
+
}
|
| 529 |
+
}
|
| 530 |
+
}
|
| 531 |
+
if msg.Role == message.Tool {
|
| 532 |
+
for _, tr := range msg.ToolResults() {
|
| 533 |
+
if tr.ToolCallID == writeTCID {
|
| 534 |
+
foundWrite = true
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
require.True(t, foundWrite, "Expected to find a write operation")
|
| 541 |
+
|
| 542 |
+
configPath := filepath.Join(env.workingDir, "config.json")
|
| 543 |
+
content, err := os.ReadFile(configPath)
|
| 544 |
+
require.NoError(t, err)
|
| 545 |
+
require.Contains(t, string(content), "test", "Expected config.json to contain 'test'")
|
| 546 |
+
require.Contains(t, string(content), "1.0.0", "Expected config.json to contain '1.0.0'")
|
| 547 |
+
})
|
| 548 |
+
t.Run("parallel tool calls", func(t *testing.T) {
|
| 549 |
+
agent, env := setupAgent(t, pair)
|
| 550 |
+
|
| 551 |
+
session, err := env.sessions.Create(t.Context(), "New Session")
|
| 552 |
+
require.NoError(t, err)
|
| 553 |
+
|
| 554 |
+
res, err := agent.Run(t.Context(), SessionAgentCall{
|
| 555 |
+
Prompt: "use glob to find all .go files and use ls to list the current directory, it is very important that you run both tool calls in parallel",
|
| 556 |
+
SessionID: session.ID,
|
| 557 |
+
MaxOutputTokens: 10000,
|
| 558 |
+
})
|
| 559 |
+
require.NoError(t, err)
|
| 560 |
+
assert.NotNil(t, res)
|
| 561 |
+
|
| 562 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 563 |
+
require.NoError(t, err)
|
| 564 |
+
|
| 565 |
+
var assistantMsg *message.Message
|
| 566 |
+
var toolMsgs []message.Message
|
| 567 |
+
|
| 568 |
+
for _, msg := range msgs {
|
| 569 |
+
if msg.Role == message.Assistant && len(msg.ToolCalls()) > 0 {
|
| 570 |
+
assistantMsg = &msg
|
| 571 |
+
}
|
| 572 |
+
if msg.Role == message.Tool {
|
| 573 |
+
toolMsgs = append(toolMsgs, msg)
|
| 574 |
+
}
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
require.NotNil(t, assistantMsg, "Expected to find an assistant message with tool calls")
|
| 578 |
+
require.NotNil(t, toolMsgs, "Expected to find a tool message")
|
| 579 |
+
|
| 580 |
+
toolCalls := assistantMsg.ToolCalls()
|
| 581 |
+
require.GreaterOrEqual(t, len(toolCalls), 2, "Expected at least 2 tool calls in parallel")
|
| 582 |
+
|
| 583 |
+
foundGlob := false
|
| 584 |
+
foundLS := false
|
| 585 |
+
var globTCID, lsTCID string
|
| 586 |
+
|
| 587 |
+
for _, tc := range toolCalls {
|
| 588 |
+
if tc.Name == tools.GlobToolName {
|
| 589 |
+
foundGlob = true
|
| 590 |
+
globTCID = tc.ID
|
| 591 |
+
}
|
| 592 |
+
if tc.Name == tools.LSToolName {
|
| 593 |
+
foundLS = true
|
| 594 |
+
lsTCID = tc.ID
|
| 595 |
+
}
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
require.True(t, foundGlob, "Expected to find a glob tool call")
|
| 599 |
+
require.True(t, foundLS, "Expected to find an ls tool call")
|
| 600 |
+
|
| 601 |
+
require.GreaterOrEqual(t, len(toolMsgs), 2, "Expected at least 2 tool results in the same message")
|
| 602 |
+
|
| 603 |
+
foundGlobResult := false
|
| 604 |
+
foundLSResult := false
|
| 605 |
+
|
| 606 |
+
for _, msg := range toolMsgs {
|
| 607 |
+
for _, tr := range msg.ToolResults() {
|
| 608 |
+
if tr.ToolCallID == globTCID {
|
| 609 |
+
foundGlobResult = true
|
| 610 |
+
require.Contains(t, tr.Content, "main.go", "Expected glob result to contain main.go")
|
| 611 |
+
require.False(t, tr.IsError, "Expected glob result to not be an error")
|
| 612 |
+
}
|
| 613 |
+
if tr.ToolCallID == lsTCID {
|
| 614 |
+
foundLSResult = true
|
| 615 |
+
require.Contains(t, tr.Content, "main.go", "Expected ls result to contain main.go")
|
| 616 |
+
require.False(t, tr.IsError, "Expected ls result to not be an error")
|
| 617 |
+
}
|
| 618 |
+
}
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
require.True(t, foundGlobResult, "Expected to find glob tool result")
|
| 622 |
+
require.True(t, foundLSResult, "Expected to find ls tool result")
|
| 623 |
+
})
|
| 624 |
+
})
|
| 625 |
+
}
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
func makeTestTodos(n int) []session.Todo {
|
| 629 |
+
todos := make([]session.Todo, n)
|
| 630 |
+
for i := range n {
|
| 631 |
+
todos[i] = session.Todo{
|
| 632 |
+
Status: session.TodoStatusPending,
|
| 633 |
+
Content: fmt.Sprintf("Task %d: Implement feature with some description that makes it realistic", i),
|
| 634 |
+
}
|
| 635 |
+
}
|
| 636 |
+
return todos
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
func BenchmarkBuildSummaryPrompt(b *testing.B) {
|
| 640 |
+
cases := []struct {
|
| 641 |
+
name string
|
| 642 |
+
numTodos int
|
| 643 |
+
}{
|
| 644 |
+
{"0todos", 0},
|
| 645 |
+
{"5todos", 5},
|
| 646 |
+
{"10todos", 10},
|
| 647 |
+
{"50todos", 50},
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
for _, tc := range cases {
|
| 651 |
+
todos := makeTestTodos(tc.numTodos)
|
| 652 |
+
|
| 653 |
+
b.Run(tc.name, func(b *testing.B) {
|
| 654 |
+
b.ReportAllocs()
|
| 655 |
+
for range b.N {
|
| 656 |
+
_ = buildSummaryPrompt(todos)
|
| 657 |
+
}
|
| 658 |
+
})
|
| 659 |
+
}
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
func TestPreparePrompt_FiltersImageAttachments(t *testing.T) {
|
| 663 |
+
env := testEnv(t)
|
| 664 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 665 |
+
agent := sa.(*sessionAgent)
|
| 666 |
+
|
| 667 |
+
ctx := t.Context()
|
| 668 |
+
sess, err := env.sessions.Create(ctx, "test")
|
| 669 |
+
require.NoError(t, err)
|
| 670 |
+
|
| 671 |
+
// User message with text, a text attachment, and an image attachment.
|
| 672 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 673 |
+
Role: message.User,
|
| 674 |
+
Parts: []message.ContentPart{
|
| 675 |
+
message.TextContent{Text: "hello world"},
|
| 676 |
+
message.BinaryContent{Path: "notes.txt", MIMEType: "text/plain", Data: []byte("important notes")},
|
| 677 |
+
message.BinaryContent{Path: "image.png", MIMEType: "image/png", Data: []byte("fake-image-data")},
|
| 678 |
+
},
|
| 679 |
+
})
|
| 680 |
+
require.NoError(t, err)
|
| 681 |
+
|
| 682 |
+
msgs, err := env.messages.List(ctx, sess.ID)
|
| 683 |
+
require.NoError(t, err)
|
| 684 |
+
|
| 685 |
+
// New-turn image attachment (not yet stored in the DB).
|
| 686 |
+
imageAtt := message.Attachment{
|
| 687 |
+
FileName: "screenshot.png",
|
| 688 |
+
MimeType: "image/png",
|
| 689 |
+
Content: []byte("fake-screenshot"),
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
// When supportsImages is false, image attachments should be stripped
|
| 693 |
+
// from history AND from the files list.
|
| 694 |
+
history, files := agent.preparePrompt(msgs, false, imageAtt)
|
| 695 |
+
// First message is the system reminder, second is the user message.
|
| 696 |
+
require.Len(t, history, 2)
|
| 697 |
+
require.Len(t, history[1].Content, 1)
|
| 698 |
+
text, ok := fantasy.AsMessagePart[fantasy.TextPart](history[1].Content[0])
|
| 699 |
+
require.True(t, ok)
|
| 700 |
+
require.Contains(t, text.Text, "hello world")
|
| 701 |
+
require.Contains(t, text.Text, "important notes")
|
| 702 |
+
require.Empty(t, files, "image files should be excluded when model does not support images")
|
| 703 |
+
|
| 704 |
+
// When supportsImages is true, image attachments should remain in
|
| 705 |
+
// history and be included in the files list.
|
| 706 |
+
history, files = agent.preparePrompt(msgs, true, imageAtt)
|
| 707 |
+
require.Len(t, history, 2)
|
| 708 |
+
require.Len(t, history[1].Content, 2)
|
| 709 |
+
text, ok = fantasy.AsMessagePart[fantasy.TextPart](history[1].Content[0])
|
| 710 |
+
require.True(t, ok)
|
| 711 |
+
require.Contains(t, text.Text, "hello world")
|
| 712 |
+
file, ok := fantasy.AsMessagePart[fantasy.FilePart](history[1].Content[1])
|
| 713 |
+
require.True(t, ok)
|
| 714 |
+
require.Equal(t, "image.png", file.Filename)
|
| 715 |
+
require.Len(t, files, 1, "new-turn image attachment should be included when model supports images")
|
| 716 |
+
require.Equal(t, "screenshot.png", files[0].Filename)
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
func TestCreateUserMessage_RetainsAllAttachments(t *testing.T) {
|
| 720 |
+
env := testEnv(t)
|
| 721 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 722 |
+
agent := sa.(*sessionAgent)
|
| 723 |
+
|
| 724 |
+
ctx := t.Context()
|
| 725 |
+
sess, err := env.sessions.Create(ctx, "test")
|
| 726 |
+
require.NoError(t, err)
|
| 727 |
+
|
| 728 |
+
// Mix of text and image attachments — all should be stored.
|
| 729 |
+
call := SessionAgentCall{
|
| 730 |
+
SessionID: sess.ID,
|
| 731 |
+
Prompt: "look at this image",
|
| 732 |
+
Attachments: []message.Attachment{
|
| 733 |
+
{FileName: "notes.txt", FilePath: "notes.txt", MimeType: "text/plain", Content: []byte("notes")},
|
| 734 |
+
{FileName: "photo.png", FilePath: "photo.png", MimeType: "image/png", Content: []byte("fake-png")},
|
| 735 |
+
},
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
msg, err := agent.createUserMessage(ctx, call)
|
| 739 |
+
require.NoError(t, err)
|
| 740 |
+
|
| 741 |
+
// All attachments should be present as BinaryContent parts.
|
| 742 |
+
binaryParts := msg.BinaryContent()
|
| 743 |
+
require.Len(t, binaryParts, 2, "both text and image attachments should be stored in the user message")
|
| 744 |
+
require.Equal(t, "notes.txt", binaryParts[0].Path)
|
| 745 |
+
require.Equal(t, "text/plain", binaryParts[0].MIMEType)
|
| 746 |
+
require.Equal(t, "photo.png", binaryParts[1].Path)
|
| 747 |
+
require.Equal(t, "image/png", binaryParts[1].MIMEType)
|
| 748 |
+
|
| 749 |
+
// Reload from DB to verify persistence.
|
| 750 |
+
reloaded, err := env.messages.Get(ctx, msg.ID)
|
| 751 |
+
require.NoError(t, err)
|
| 752 |
+
binaryParts = reloaded.BinaryContent()
|
| 753 |
+
require.Len(t, binaryParts, 2, "attachments should survive DB round-trip")
|
| 754 |
+
require.Equal(t, "photo.png", binaryParts[1].Path)
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
func TestPreparePrompt_OrphanedToolUse(t *testing.T) {
|
| 758 |
+
env := testEnv(t)
|
| 759 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 760 |
+
agent := sa.(*sessionAgent)
|
| 761 |
+
|
| 762 |
+
ctx := t.Context()
|
| 763 |
+
sess, err := env.sessions.Create(ctx, "test")
|
| 764 |
+
require.NoError(t, err)
|
| 765 |
+
|
| 766 |
+
// Create a user message.
|
| 767 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 768 |
+
Role: message.User,
|
| 769 |
+
Parts: []message.ContentPart{
|
| 770 |
+
message.TextContent{Text: "hello"},
|
| 771 |
+
},
|
| 772 |
+
})
|
| 773 |
+
require.NoError(t, err)
|
| 774 |
+
|
| 775 |
+
// Create an assistant message with a tool call but no tool result —
|
| 776 |
+
// this simulates a cancelled/interrupted agent tool call.
|
| 777 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 778 |
+
Role: message.Assistant,
|
| 779 |
+
Parts: []message.ContentPart{
|
| 780 |
+
message.TextContent{Text: "let me check"},
|
| 781 |
+
message.ToolCall{
|
| 782 |
+
ID: "call_orphaned_1",
|
| 783 |
+
Name: "agent",
|
| 784 |
+
Input: `{"prompt":"do something"}`,
|
| 785 |
+
Finished: true,
|
| 786 |
+
},
|
| 787 |
+
},
|
| 788 |
+
})
|
| 789 |
+
require.NoError(t, err)
|
| 790 |
+
|
| 791 |
+
// Create the next user message (the one that interrupted the tool call).
|
| 792 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 793 |
+
Role: message.User,
|
| 794 |
+
Parts: []message.ContentPart{
|
| 795 |
+
message.TextContent{Text: "Fix #2"},
|
| 796 |
+
},
|
| 797 |
+
})
|
| 798 |
+
require.NoError(t, err)
|
| 799 |
+
|
| 800 |
+
msgs, err := env.messages.List(ctx, sess.ID)
|
| 801 |
+
require.NoError(t, err)
|
| 802 |
+
|
| 803 |
+
history, _ := agent.preparePrompt(msgs, true)
|
| 804 |
+
|
| 805 |
+
// The history must contain a synthetic tool result for the orphaned call.
|
| 806 |
+
found := false
|
| 807 |
+
for _, msg := range history {
|
| 808 |
+
if msg.Role != fantasy.MessageRoleTool {
|
| 809 |
+
continue
|
| 810 |
+
}
|
| 811 |
+
for _, part := range msg.Content {
|
| 812 |
+
if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
|
| 813 |
+
if tr.ToolCallID == "call_orphaned_1" {
|
| 814 |
+
found = true
|
| 815 |
+
_, isError := tr.Output.(fantasy.ToolResultOutputContentError)
|
| 816 |
+
require.True(t, isError, "orphaned tool result should be an error")
|
| 817 |
+
}
|
| 818 |
+
}
|
| 819 |
+
}
|
| 820 |
+
}
|
| 821 |
+
require.True(t, found, "expected synthetic tool result for orphaned tool call")
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
func TestPreparePrompt_OrphanedToolUseMixed(t *testing.T) {
|
| 825 |
+
env := testEnv(t)
|
| 826 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 827 |
+
agent := sa.(*sessionAgent)
|
| 828 |
+
|
| 829 |
+
ctx := t.Context()
|
| 830 |
+
sess, err := env.sessions.Create(ctx, "test")
|
| 831 |
+
require.NoError(t, err)
|
| 832 |
+
|
| 833 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 834 |
+
Role: message.User,
|
| 835 |
+
Parts: []message.ContentPart{
|
| 836 |
+
message.TextContent{Text: "hello"},
|
| 837 |
+
},
|
| 838 |
+
})
|
| 839 |
+
require.NoError(t, err)
|
| 840 |
+
|
| 841 |
+
// Assistant with 2 tool calls: one has a result, one is orphaned.
|
| 842 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 843 |
+
Role: message.Assistant,
|
| 844 |
+
Parts: []message.ContentPart{
|
| 845 |
+
message.ToolCall{
|
| 846 |
+
ID: "call_ok",
|
| 847 |
+
Name: "view",
|
| 848 |
+
Input: `{"path":"/foo"}`,
|
| 849 |
+
Finished: true,
|
| 850 |
+
},
|
| 851 |
+
message.ToolCall{
|
| 852 |
+
ID: "call_orphaned",
|
| 853 |
+
Name: "agent",
|
| 854 |
+
Input: `{"prompt":"search"}`,
|
| 855 |
+
Finished: true,
|
| 856 |
+
},
|
| 857 |
+
},
|
| 858 |
+
})
|
| 859 |
+
require.NoError(t, err)
|
| 860 |
+
|
| 861 |
+
// Only one tool result — for call_ok.
|
| 862 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 863 |
+
Role: message.Tool,
|
| 864 |
+
Parts: []message.ContentPart{
|
| 865 |
+
message.ToolResult{
|
| 866 |
+
ToolCallID: "call_ok",
|
| 867 |
+
Name: "view",
|
| 868 |
+
Content: "file contents",
|
| 869 |
+
},
|
| 870 |
+
},
|
| 871 |
+
})
|
| 872 |
+
require.NoError(t, err)
|
| 873 |
+
|
| 874 |
+
msgs, err := env.messages.List(ctx, sess.ID)
|
| 875 |
+
require.NoError(t, err)
|
| 876 |
+
|
| 877 |
+
history, _ := agent.preparePrompt(msgs, true)
|
| 878 |
+
|
| 879 |
+
// Should have a synthetic result only for the orphaned call.
|
| 880 |
+
var syntheticCount int
|
| 881 |
+
for _, msg := range history {
|
| 882 |
+
if msg.Role != fantasy.MessageRoleTool {
|
| 883 |
+
continue
|
| 884 |
+
}
|
| 885 |
+
for _, part := range msg.Content {
|
| 886 |
+
if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
|
| 887 |
+
if tr.ToolCallID == "call_orphaned" {
|
| 888 |
+
syntheticCount++
|
| 889 |
+
}
|
| 890 |
+
}
|
| 891 |
+
}
|
| 892 |
+
}
|
| 893 |
+
require.Equal(t, 1, syntheticCount, "expected exactly one synthetic result for the orphaned call")
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
// requireToolCallAdjacency asserts that every assistant message in history
|
| 897 |
+
// with tool calls is immediately followed by a tool message that responds to
|
| 898 |
+
// each of those calls, with no other message in between. This is what
|
| 899 |
+
// strict-adjacency providers (e.g. Kimi, DeepSeek) require.
|
| 900 |
+
func requireToolCallAdjacency(t *testing.T, history []fantasy.Message) {
|
| 901 |
+
t.Helper()
|
| 902 |
+
for i, msg := range history {
|
| 903 |
+
if msg.Role != fantasy.MessageRoleAssistant {
|
| 904 |
+
continue
|
| 905 |
+
}
|
| 906 |
+
var callIDs []string
|
| 907 |
+
for _, part := range msg.Content {
|
| 908 |
+
if tc, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](part); ok {
|
| 909 |
+
callIDs = append(callIDs, tc.ToolCallID)
|
| 910 |
+
}
|
| 911 |
+
}
|
| 912 |
+
if len(callIDs) == 0 {
|
| 913 |
+
continue
|
| 914 |
+
}
|
| 915 |
+
require.Less(t, i+1, len(history), "assistant with tool calls must be followed by a tool message")
|
| 916 |
+
next := history[i+1]
|
| 917 |
+
require.Equal(t, fantasy.MessageRoleTool, next.Role,
|
| 918 |
+
"assistant with tool calls %v must be immediately followed by a tool message, got %q", callIDs, next.Role)
|
| 919 |
+
responded := make(map[string]bool, len(callIDs))
|
| 920 |
+
for _, part := range next.Content {
|
| 921 |
+
if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
|
| 922 |
+
responded[tr.ToolCallID] = true
|
| 923 |
+
}
|
| 924 |
+
}
|
| 925 |
+
for _, id := range callIDs {
|
| 926 |
+
require.True(t, responded[id],
|
| 927 |
+
"tool result for call %q must immediately follow its assistant message", id)
|
| 928 |
+
}
|
| 929 |
+
}
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
func TestPreparePrompt_NonAdjacentToolResults(t *testing.T) {
|
| 933 |
+
// A user message written between an assistant's tool call and its
|
| 934 |
+
// result (e.g. resuming while a tool is still running) must not end up
|
| 935 |
+
// between the two in the built history.
|
| 936 |
+
env := testEnv(t)
|
| 937 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 938 |
+
agent := sa.(*sessionAgent)
|
| 939 |
+
|
| 940 |
+
ctx := t.Context()
|
| 941 |
+
sess, err := env.sessions.Create(ctx, "test")
|
| 942 |
+
require.NoError(t, err)
|
| 943 |
+
|
| 944 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 945 |
+
Role: message.User,
|
| 946 |
+
Parts: []message.ContentPart{
|
| 947 |
+
message.TextContent{Text: "run commands"},
|
| 948 |
+
},
|
| 949 |
+
})
|
| 950 |
+
require.NoError(t, err)
|
| 951 |
+
|
| 952 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 953 |
+
Role: message.Assistant,
|
| 954 |
+
Parts: []message.ContentPart{
|
| 955 |
+
message.ToolCall{
|
| 956 |
+
ID: "call_A",
|
| 957 |
+
Name: "bash",
|
| 958 |
+
Input: `{"command":"date"}`,
|
| 959 |
+
Finished: true,
|
| 960 |
+
},
|
| 961 |
+
message.ToolCall{
|
| 962 |
+
ID: "call_B",
|
| 963 |
+
Name: "bash",
|
| 964 |
+
Input: `{"command":"uptime"}`,
|
| 965 |
+
Finished: true,
|
| 966 |
+
},
|
| 967 |
+
},
|
| 968 |
+
})
|
| 969 |
+
require.NoError(t, err)
|
| 970 |
+
|
| 971 |
+
// Interleaved user message written while the tools were still running.
|
| 972 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 973 |
+
Role: message.User,
|
| 974 |
+
Parts: []message.ContentPart{
|
| 975 |
+
message.TextContent{Text: "are we done?"},
|
| 976 |
+
},
|
| 977 |
+
})
|
| 978 |
+
require.NoError(t, err)
|
| 979 |
+
|
| 980 |
+
// Results arrive late, after the interleaved user message.
|
| 981 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 982 |
+
Role: message.Tool,
|
| 983 |
+
Parts: []message.ContentPart{
|
| 984 |
+
message.ToolResult{
|
| 985 |
+
ToolCallID: "call_A",
|
| 986 |
+
Name: "bash",
|
| 987 |
+
Content: "Fri May 2 21:00:00 UTC 2026",
|
| 988 |
+
},
|
| 989 |
+
},
|
| 990 |
+
})
|
| 991 |
+
require.NoError(t, err)
|
| 992 |
+
|
| 993 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 994 |
+
Role: message.Tool,
|
| 995 |
+
Parts: []message.ContentPart{
|
| 996 |
+
message.ToolResult{
|
| 997 |
+
ToolCallID: "call_B",
|
| 998 |
+
Name: "bash",
|
| 999 |
+
Content: "21:00 up 3 days",
|
| 1000 |
+
},
|
| 1001 |
+
},
|
| 1002 |
+
})
|
| 1003 |
+
require.NoError(t, err)
|
| 1004 |
+
|
| 1005 |
+
msgs, err := env.messages.List(ctx, sess.ID)
|
| 1006 |
+
require.NoError(t, err)
|
| 1007 |
+
|
| 1008 |
+
require.Equal(t, message.User, msgs[2].Role, "interleaved user should be between assistant and results in DB order")
|
| 1009 |
+
|
| 1010 |
+
history, _ := agent.preparePrompt(msgs, false)
|
| 1011 |
+
|
| 1012 |
+
requireToolCallAdjacency(t, history)
|
| 1013 |
+
|
| 1014 |
+
// The interleaved user message must still be present, after the results.
|
| 1015 |
+
var foundInterleaved bool
|
| 1016 |
+
for _, msg := range history {
|
| 1017 |
+
if msg.Role != fantasy.MessageRoleUser {
|
| 1018 |
+
continue
|
| 1019 |
+
}
|
| 1020 |
+
for _, part := range msg.Content {
|
| 1021 |
+
if text, ok := fantasy.AsMessagePart[fantasy.TextPart](part); ok && text.Text == "are we done?" {
|
| 1022 |
+
foundInterleaved = true
|
| 1023 |
+
}
|
| 1024 |
+
}
|
| 1025 |
+
}
|
| 1026 |
+
require.True(t, foundInterleaved, "interleaved user message must not be dropped")
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
func TestPreparePrompt_ResultBeforeAssistant(t *testing.T) {
|
| 1030 |
+
// A tool result written before its assistant message (e.g. concurrent
|
| 1031 |
+
// writes) must be emitted after the assistant, exactly once, not at its
|
| 1032 |
+
// stored position.
|
| 1033 |
+
env := testEnv(t)
|
| 1034 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 1035 |
+
agent := sa.(*sessionAgent)
|
| 1036 |
+
|
| 1037 |
+
ctx := t.Context()
|
| 1038 |
+
sess, err := env.sessions.Create(ctx, "test")
|
| 1039 |
+
require.NoError(t, err)
|
| 1040 |
+
|
| 1041 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 1042 |
+
Role: message.Tool,
|
| 1043 |
+
Parts: []message.ContentPart{
|
| 1044 |
+
message.ToolResult{ToolCallID: "call_X", Name: "bash", Content: "result"},
|
| 1045 |
+
},
|
| 1046 |
+
})
|
| 1047 |
+
require.NoError(t, err)
|
| 1048 |
+
|
| 1049 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 1050 |
+
Role: message.Assistant,
|
| 1051 |
+
Parts: []message.ContentPart{
|
| 1052 |
+
message.ToolCall{ID: "call_X", Name: "bash", Input: `{}`, Finished: true},
|
| 1053 |
+
},
|
| 1054 |
+
})
|
| 1055 |
+
require.NoError(t, err)
|
| 1056 |
+
|
| 1057 |
+
msgs, err := env.messages.List(ctx, sess.ID)
|
| 1058 |
+
require.NoError(t, err)
|
| 1059 |
+
|
| 1060 |
+
history, _ := agent.preparePrompt(msgs, false)
|
| 1061 |
+
|
| 1062 |
+
requireToolCallAdjacency(t, history)
|
| 1063 |
+
|
| 1064 |
+
resultCount := 0
|
| 1065 |
+
for _, msg := range history {
|
| 1066 |
+
if msg.Role != fantasy.MessageRoleTool {
|
| 1067 |
+
continue
|
| 1068 |
+
}
|
| 1069 |
+
for _, part := range msg.Content {
|
| 1070 |
+
if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok && tr.ToolCallID == "call_X" {
|
| 1071 |
+
resultCount++
|
| 1072 |
+
}
|
| 1073 |
+
}
|
| 1074 |
+
}
|
| 1075 |
+
require.Equal(t, 1, resultCount, "result must be emitted exactly once")
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
+
func TestPreparePrompt_BundledResultsAcrossAssistants(t *testing.T) {
|
| 1079 |
+
// A single tool message can hold results for calls issued by different
|
| 1080 |
+
// assistant messages. Each assistant must be followed by its own
|
| 1081 |
+
// results, and no result may be emitted twice.
|
| 1082 |
+
env := testEnv(t)
|
| 1083 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 1084 |
+
agent := sa.(*sessionAgent)
|
| 1085 |
+
|
| 1086 |
+
ctx := t.Context()
|
| 1087 |
+
sess, err := env.sessions.Create(ctx, "test")
|
| 1088 |
+
require.NoError(t, err)
|
| 1089 |
+
|
| 1090 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 1091 |
+
Role: message.Assistant,
|
| 1092 |
+
Parts: []message.ContentPart{
|
| 1093 |
+
message.ToolCall{ID: "call_1", Name: "bash", Input: `{}`, Finished: true},
|
| 1094 |
+
},
|
| 1095 |
+
})
|
| 1096 |
+
require.NoError(t, err)
|
| 1097 |
+
|
| 1098 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 1099 |
+
Role: message.User,
|
| 1100 |
+
Parts: []message.ContentPart{
|
| 1101 |
+
message.TextContent{Text: "and now?"},
|
| 1102 |
+
},
|
| 1103 |
+
})
|
| 1104 |
+
require.NoError(t, err)
|
| 1105 |
+
|
| 1106 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 1107 |
+
Role: message.Assistant,
|
| 1108 |
+
Parts: []message.ContentPart{
|
| 1109 |
+
message.ToolCall{ID: "call_2", Name: "view", Input: `{"path":"/foo"}`, Finished: true},
|
| 1110 |
+
},
|
| 1111 |
+
})
|
| 1112 |
+
require.NoError(t, err)
|
| 1113 |
+
|
| 1114 |
+
// Both results land in the same tool message, out of order.
|
| 1115 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 1116 |
+
Role: message.Tool,
|
| 1117 |
+
Parts: []message.ContentPart{
|
| 1118 |
+
message.ToolResult{ToolCallID: "call_2", Name: "view", Content: "file contents"},
|
| 1119 |
+
message.ToolResult{ToolCallID: "call_1", Name: "bash", Content: "output"},
|
| 1120 |
+
},
|
| 1121 |
+
})
|
| 1122 |
+
require.NoError(t, err)
|
| 1123 |
+
|
| 1124 |
+
msgs, err := env.messages.List(ctx, sess.ID)
|
| 1125 |
+
require.NoError(t, err)
|
| 1126 |
+
|
| 1127 |
+
history, _ := agent.preparePrompt(msgs, false)
|
| 1128 |
+
|
| 1129 |
+
requireToolCallAdjacency(t, history)
|
| 1130 |
+
|
| 1131 |
+
counts := make(map[string]int)
|
| 1132 |
+
for _, msg := range history {
|
| 1133 |
+
if msg.Role != fantasy.MessageRoleTool {
|
| 1134 |
+
continue
|
| 1135 |
+
}
|
| 1136 |
+
for _, part := range msg.Content {
|
| 1137 |
+
if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
|
| 1138 |
+
counts[tr.ToolCallID]++
|
| 1139 |
+
}
|
| 1140 |
+
}
|
| 1141 |
+
}
|
| 1142 |
+
require.Equal(t, map[string]int{"call_1": 1, "call_2": 1}, counts,
|
| 1143 |
+
"each result must be emitted exactly once, next to its assistant")
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
func TestPreparePrompt_DropsOrphanedToolResults(t *testing.T) {
|
| 1147 |
+
// A tool result whose call is not in the history (e.g. the assistant
|
| 1148 |
+
// message was cut off by a session summary) must be dropped instead of
|
| 1149 |
+
// producing an unanswerable tool message.
|
| 1150 |
+
env := testEnv(t)
|
| 1151 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 1152 |
+
agent := sa.(*sessionAgent)
|
| 1153 |
+
|
| 1154 |
+
ctx := t.Context()
|
| 1155 |
+
sess, err := env.sessions.Create(ctx, "test")
|
| 1156 |
+
require.NoError(t, err)
|
| 1157 |
+
|
| 1158 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 1159 |
+
Role: message.Tool,
|
| 1160 |
+
Parts: []message.ContentPart{
|
| 1161 |
+
message.ToolResult{ToolCallID: "call_gone", Name: "bash", Content: "output"},
|
| 1162 |
+
},
|
| 1163 |
+
})
|
| 1164 |
+
require.NoError(t, err)
|
| 1165 |
+
|
| 1166 |
+
_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
|
| 1167 |
+
Role: message.User,
|
| 1168 |
+
Parts: []message.ContentPart{
|
| 1169 |
+
message.TextContent{Text: "hello"},
|
| 1170 |
+
},
|
| 1171 |
+
})
|
| 1172 |
+
require.NoError(t, err)
|
| 1173 |
+
|
| 1174 |
+
msgs, err := env.messages.List(ctx, sess.ID)
|
| 1175 |
+
require.NoError(t, err)
|
| 1176 |
+
|
| 1177 |
+
history, _ := agent.preparePrompt(msgs, false)
|
| 1178 |
+
|
| 1179 |
+
for _, msg := range history {
|
| 1180 |
+
require.NotEqual(t, fantasy.MessageRoleTool, msg.Role, "orphaned tool results must be dropped")
|
| 1181 |
+
}
|
| 1182 |
+
}
|
| 1183 |
+
|
| 1184 |
+
func TestWorkaroundProviderMediaLimitations_TextOnlyModel(t *testing.T) {
|
| 1185 |
+
env := testEnv(t)
|
| 1186 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 1187 |
+
agent := sa.(*sessionAgent)
|
| 1188 |
+
|
| 1189 |
+
pngBase64 := base64.StdEncoding.EncodeToString([]byte("fake-png-data"))
|
| 1190 |
+
|
| 1191 |
+
messages := []fantasy.Message{
|
| 1192 |
+
{
|
| 1193 |
+
Role: fantasy.MessageRoleTool,
|
| 1194 |
+
Content: []fantasy.MessagePart{
|
| 1195 |
+
fantasy.ToolResultPart{
|
| 1196 |
+
ToolCallID: "call_1",
|
| 1197 |
+
Output: fantasy.ToolResultOutputContentMedia{
|
| 1198 |
+
Data: pngBase64,
|
| 1199 |
+
MediaType: "image/png",
|
| 1200 |
+
},
|
| 1201 |
+
},
|
| 1202 |
+
},
|
| 1203 |
+
},
|
| 1204 |
+
}
|
| 1205 |
+
|
| 1206 |
+
// Non-Anthropic provider, no image support — should replace media with
|
| 1207 |
+
// a text placeholder and not create a synthetic user message.
|
| 1208 |
+
largeModel := Model{
|
| 1209 |
+
ModelCfg: config.SelectedModel{Provider: "openai"},
|
| 1210 |
+
CatwalkCfg: catwalk.Model{
|
| 1211 |
+
SupportsImages: false,
|
| 1212 |
+
},
|
| 1213 |
+
}
|
| 1214 |
+
|
| 1215 |
+
result := agent.workaroundProviderMediaLimitations(messages, largeModel)
|
| 1216 |
+
|
| 1217 |
+
// Should produce exactly one message: the tool message with a text
|
| 1218 |
+
// placeholder. No synthetic user message with FilePart.
|
| 1219 |
+
require.Len(t, result, 1)
|
| 1220 |
+
require.Equal(t, fantasy.MessageRoleTool, result[0].Role)
|
| 1221 |
+
|
| 1222 |
+
tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](result[0].Content[0])
|
| 1223 |
+
require.True(t, ok)
|
| 1224 |
+
_, ok = fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](tr.Output)
|
| 1225 |
+
require.True(t, ok)
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
func TestWorkaroundProviderMediaLimitations_VisionModel(t *testing.T) {
|
| 1229 |
+
env := testEnv(t)
|
| 1230 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 1231 |
+
agent := sa.(*sessionAgent)
|
| 1232 |
+
|
| 1233 |
+
pngBase64 := base64.StdEncoding.EncodeToString([]byte("fake-png-data"))
|
| 1234 |
+
|
| 1235 |
+
messages := []fantasy.Message{
|
| 1236 |
+
{
|
| 1237 |
+
Role: fantasy.MessageRoleTool,
|
| 1238 |
+
Content: []fantasy.MessagePart{
|
| 1239 |
+
fantasy.ToolResultPart{
|
| 1240 |
+
ToolCallID: "call_1",
|
| 1241 |
+
Output: fantasy.ToolResultOutputContentMedia{
|
| 1242 |
+
Data: pngBase64,
|
| 1243 |
+
MediaType: "image/png",
|
| 1244 |
+
},
|
| 1245 |
+
},
|
| 1246 |
+
},
|
| 1247 |
+
},
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
+
// Non-Anthropic provider, image support — should create a synthetic
|
| 1251 |
+
// user message with FilePart.
|
| 1252 |
+
largeModel := Model{
|
| 1253 |
+
ModelCfg: config.SelectedModel{Provider: "openai"},
|
| 1254 |
+
CatwalkCfg: catwalk.Model{
|
| 1255 |
+
SupportsImages: true,
|
| 1256 |
+
},
|
| 1257 |
+
}
|
| 1258 |
+
|
| 1259 |
+
result := agent.workaroundProviderMediaLimitations(messages, largeModel)
|
| 1260 |
+
|
| 1261 |
+
// Should produce two messages: tool message with placeholder text,
|
| 1262 |
+
// and synthetic user message with FilePart.
|
| 1263 |
+
require.Len(t, result, 2)
|
| 1264 |
+
require.Equal(t, fantasy.MessageRoleTool, result[0].Role)
|
| 1265 |
+
require.Equal(t, fantasy.MessageRoleUser, result[1].Role)
|
| 1266 |
+
|
| 1267 |
+
// The tool message should have text placeholder.
|
| 1268 |
+
tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](result[0].Content[0])
|
| 1269 |
+
require.True(t, ok)
|
| 1270 |
+
textOutput, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](tr.Output)
|
| 1271 |
+
require.True(t, ok)
|
| 1272 |
+
require.Contains(t, textOutput.Text, "see attached file")
|
| 1273 |
+
|
| 1274 |
+
// The synthetic user message should contain a TextPart and a FilePart.
|
| 1275 |
+
require.Len(t, result[1].Content, 2)
|
| 1276 |
+
file, ok := fantasy.AsMessagePart[fantasy.FilePart](result[1].Content[1])
|
| 1277 |
+
require.True(t, ok)
|
| 1278 |
+
require.Equal(t, "image/png", file.MediaType)
|
| 1279 |
+
}
|
| 1280 |
+
|
| 1281 |
+
func TestWorkaroundProviderMediaLimitations_AnthropicProvider(t *testing.T) {
|
| 1282 |
+
env := testEnv(t)
|
| 1283 |
+
sa := testSessionAgent(env, nil, nil, "test prompt")
|
| 1284 |
+
agent := sa.(*sessionAgent)
|
| 1285 |
+
|
| 1286 |
+
pngBase64 := base64.StdEncoding.EncodeToString([]byte("fake-png-data"))
|
| 1287 |
+
|
| 1288 |
+
messages := []fantasy.Message{
|
| 1289 |
+
{
|
| 1290 |
+
Role: fantasy.MessageRoleTool,
|
| 1291 |
+
Content: []fantasy.MessagePart{
|
| 1292 |
+
fantasy.ToolResultPart{
|
| 1293 |
+
ToolCallID: "call_1",
|
| 1294 |
+
Output: fantasy.ToolResultOutputContentMedia{
|
| 1295 |
+
Data: pngBase64,
|
| 1296 |
+
MediaType: "image/png",
|
| 1297 |
+
},
|
| 1298 |
+
},
|
| 1299 |
+
},
|
| 1300 |
+
},
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
// Anthropic provider — should return messages unchanged regardless of
|
| 1304 |
+
// SupportsImages, since Anthropic handles media in tool results natively.
|
| 1305 |
+
largeModel := Model{
|
| 1306 |
+
ModelCfg: config.SelectedModel{Provider: string(catwalk.InferenceProviderAnthropic)},
|
| 1307 |
+
CatwalkCfg: catwalk.Model{
|
| 1308 |
+
SupportsImages: true,
|
| 1309 |
+
},
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
result := agent.workaroundProviderMediaLimitations(messages, largeModel)
|
| 1313 |
+
require.Len(t, result, 1)
|
| 1314 |
+
require.Equal(t, fantasy.MessageRoleTool, result[0].Role)
|
| 1315 |
+
|
| 1316 |
+
// The media should still be in the tool result, untouched.
|
| 1317 |
+
tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](result[0].Content[0])
|
| 1318 |
+
require.True(t, ok)
|
| 1319 |
+
media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](tr.Output)
|
| 1320 |
+
require.True(t, ok)
|
| 1321 |
+
require.Equal(t, "image/png", media.MediaType)
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
func TestProviderRetryLogFields(t *testing.T) {
|
| 1325 |
+
t.Run("nil provider error", func(t *testing.T) {
|
| 1326 |
+
fields := providerRetryLogFields(nil, 2*time.Second)
|
| 1327 |
+
require.Equal(t, []any{"retry_delay", "2s"}, fields)
|
| 1328 |
+
})
|
| 1329 |
+
|
| 1330 |
+
t.Run("provider error with title and message", func(t *testing.T) {
|
| 1331 |
+
fields := providerRetryLogFields(&fantasy.ProviderError{
|
| 1332 |
+
StatusCode: 429,
|
| 1333 |
+
Title: "rate limit",
|
| 1334 |
+
Message: "too many requests",
|
| 1335 |
+
}, 1500*time.Millisecond)
|
| 1336 |
+
require.Equal(t, []any{
|
| 1337 |
+
"retry_delay", "1.5s",
|
| 1338 |
+
"status_code", 429,
|
| 1339 |
+
"title", "rate limit",
|
| 1340 |
+
"message", "too many requests",
|
| 1341 |
+
}, fields)
|
| 1342 |
+
})
|
| 1343 |
+
|
| 1344 |
+
t.Run("provider error without optional strings", func(t *testing.T) {
|
| 1345 |
+
fields := providerRetryLogFields(&fantasy.ProviderError{
|
| 1346 |
+
StatusCode: 503,
|
| 1347 |
+
}, time.Second)
|
| 1348 |
+
require.Equal(t, []any{
|
| 1349 |
+
"retry_delay", "1s",
|
| 1350 |
+
"status_code", 503,
|
| 1351 |
+
}, fields)
|
| 1352 |
+
})
|
| 1353 |
+
}
|
internal/agent/agent_tool.go
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
_ "embed"
|
| 6 |
+
"errors"
|
| 7 |
+
|
| 8 |
+
"charm.land/fantasy"
|
| 9 |
+
|
| 10 |
+
"github.com/charmbracelet/crush/internal/agent/prompt"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/agent/tools"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
//go:embed templates/agent_tool.md
|
| 16 |
+
var agentToolDescription string
|
| 17 |
+
|
| 18 |
+
type AgentParams struct {
|
| 19 |
+
Prompt string `json:"prompt" description:"The task for the agent to perform"`
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const (
|
| 23 |
+
AgentToolName = "agent"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) {
|
| 27 |
+
agentCfg, ok := c.cfg.Config().Agents[config.AgentTask]
|
| 28 |
+
if !ok {
|
| 29 |
+
return nil, errors.New("task agent not configured")
|
| 30 |
+
}
|
| 31 |
+
prompt, err := taskPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
|
| 32 |
+
if err != nil {
|
| 33 |
+
return nil, err
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
agent, err := c.buildAgent(ctx, prompt, agentCfg, true)
|
| 37 |
+
if err != nil {
|
| 38 |
+
return nil, err
|
| 39 |
+
}
|
| 40 |
+
return fantasy.NewParallelAgentTool(
|
| 41 |
+
AgentToolName,
|
| 42 |
+
agentToolDescription,
|
| 43 |
+
func(ctx context.Context, params AgentParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
| 44 |
+
if params.Prompt == "" {
|
| 45 |
+
return fantasy.NewTextErrorResponse("prompt is required"), nil
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
sessionID := tools.GetSessionFromContext(ctx)
|
| 49 |
+
if sessionID == "" {
|
| 50 |
+
return fantasy.ToolResponse{}, errors.New("session id missing from context")
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
agentMessageID := tools.GetMessageFromContext(ctx)
|
| 54 |
+
if agentMessageID == "" {
|
| 55 |
+
return fantasy.ToolResponse{}, errors.New("agent message id missing from context")
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return c.runSubAgent(ctx, subAgentParams{
|
| 59 |
+
Agent: agent,
|
| 60 |
+
SessionID: sessionID,
|
| 61 |
+
AgentMessageID: agentMessageID,
|
| 62 |
+
ToolCallID: call.ID,
|
| 63 |
+
Prompt: params.Prompt,
|
| 64 |
+
SessionTitle: "New Agent Session",
|
| 65 |
+
})
|
| 66 |
+
},
|
| 67 |
+
), nil
|
| 68 |
+
}
|
internal/agent/agentic_fetch_tool.go
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
_ "embed"
|
| 6 |
+
"errors"
|
| 7 |
+
"fmt"
|
| 8 |
+
"net/http"
|
| 9 |
+
"os"
|
| 10 |
+
"time"
|
| 11 |
+
|
| 12 |
+
"charm.land/fantasy"
|
| 13 |
+
|
| 14 |
+
"github.com/charmbracelet/crush/internal/agent/prompt"
|
| 15 |
+
"github.com/charmbracelet/crush/internal/agent/tools"
|
| 16 |
+
"github.com/charmbracelet/crush/internal/permission"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
//go:embed templates/agentic_fetch.md
|
| 20 |
+
var agenticFetchToolDescription string
|
| 21 |
+
|
| 22 |
+
// agenticFetchValidationResult holds the validated parameters from the tool call context.
|
| 23 |
+
type agenticFetchValidationResult struct {
|
| 24 |
+
SessionID string
|
| 25 |
+
AgentMessageID string
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// validateAgenticFetchParams validates the tool call parameters and extracts required context values.
|
| 29 |
+
func validateAgenticFetchParams(ctx context.Context, params tools.AgenticFetchParams) (agenticFetchValidationResult, error) {
|
| 30 |
+
if params.Prompt == "" {
|
| 31 |
+
return agenticFetchValidationResult{}, errors.New("prompt is required")
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
sessionID := tools.GetSessionFromContext(ctx)
|
| 35 |
+
if sessionID == "" {
|
| 36 |
+
return agenticFetchValidationResult{}, errors.New("session id missing from context")
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
agentMessageID := tools.GetMessageFromContext(ctx)
|
| 40 |
+
if agentMessageID == "" {
|
| 41 |
+
return agenticFetchValidationResult{}, errors.New("agent message id missing from context")
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
return agenticFetchValidationResult{
|
| 45 |
+
SessionID: sessionID,
|
| 46 |
+
AgentMessageID: agentMessageID,
|
| 47 |
+
}, nil
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
//go:embed templates/agentic_fetch_prompt.md.tpl
|
| 51 |
+
var agenticFetchPromptTmpl []byte
|
| 52 |
+
|
| 53 |
+
func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (fantasy.AgentTool, error) {
|
| 54 |
+
if client == nil {
|
| 55 |
+
transport := http.DefaultTransport.(*http.Transport).Clone()
|
| 56 |
+
transport.MaxIdleConns = 100
|
| 57 |
+
transport.MaxIdleConnsPerHost = 10
|
| 58 |
+
transport.IdleConnTimeout = 90 * time.Second
|
| 59 |
+
|
| 60 |
+
client = &http.Client{
|
| 61 |
+
Timeout: 30 * time.Second,
|
| 62 |
+
Transport: transport,
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return fantasy.NewParallelAgentTool(
|
| 67 |
+
tools.AgenticFetchToolName,
|
| 68 |
+
agenticFetchToolDescription,
|
| 69 |
+
func(ctx context.Context, params tools.AgenticFetchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
| 70 |
+
validationResult, err := validateAgenticFetchParams(ctx, params)
|
| 71 |
+
if err != nil {
|
| 72 |
+
return fantasy.NewTextErrorResponse(err.Error()), nil
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Determine description based on mode.
|
| 76 |
+
var description string
|
| 77 |
+
if params.URL != "" {
|
| 78 |
+
description = fmt.Sprintf("Fetch and analyze content from URL: %s", params.URL)
|
| 79 |
+
} else {
|
| 80 |
+
description = "Search the web and analyze results"
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
p, err := c.permissions.Request(
|
| 84 |
+
ctx,
|
| 85 |
+
permission.CreatePermissionRequest{
|
| 86 |
+
SessionID: validationResult.SessionID,
|
| 87 |
+
Path: c.cfg.WorkingDir(),
|
| 88 |
+
ToolCallID: call.ID,
|
| 89 |
+
ToolName: tools.AgenticFetchToolName,
|
| 90 |
+
Action: "fetch",
|
| 91 |
+
Description: description,
|
| 92 |
+
Params: tools.AgenticFetchPermissionsParams(params),
|
| 93 |
+
},
|
| 94 |
+
)
|
| 95 |
+
if err != nil {
|
| 96 |
+
return fantasy.ToolResponse{}, err
|
| 97 |
+
}
|
| 98 |
+
if !p {
|
| 99 |
+
return tools.NewPermissionDeniedResponse(), nil
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
tmpDir, err := os.MkdirTemp(c.cfg.Config().Options.DataDirectory, "crush-fetch-*")
|
| 103 |
+
if err != nil {
|
| 104 |
+
return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
|
| 105 |
+
}
|
| 106 |
+
defer os.RemoveAll(tmpDir)
|
| 107 |
+
|
| 108 |
+
var fullPrompt string
|
| 109 |
+
|
| 110 |
+
if params.URL != "" {
|
| 111 |
+
// URL mode: fetch the URL content first.
|
| 112 |
+
content, err := tools.FetchURLAndConvert(ctx, client, params.URL)
|
| 113 |
+
if err != nil {
|
| 114 |
+
return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to fetch URL: %s", err)), nil
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
hasLargeContent := len(content) > tools.LargeContentThreshold
|
| 118 |
+
|
| 119 |
+
if hasLargeContent {
|
| 120 |
+
tempFile, err := os.CreateTemp(tmpDir, "page-*.md")
|
| 121 |
+
if err != nil {
|
| 122 |
+
return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary file: %s", err)), nil
|
| 123 |
+
}
|
| 124 |
+
tempFilePath := tempFile.Name()
|
| 125 |
+
|
| 126 |
+
if _, err := tempFile.WriteString(content); err != nil {
|
| 127 |
+
tempFile.Close()
|
| 128 |
+
return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to write content to file: %s", err)), nil
|
| 129 |
+
}
|
| 130 |
+
tempFile.Close()
|
| 131 |
+
|
| 132 |
+
fullPrompt = fmt.Sprintf("%s\n\nThe web page from %s has been saved to: %s\n\nUse the view and grep tools to analyze this file and extract the requested information.", params.Prompt, params.URL, tempFilePath)
|
| 133 |
+
} else {
|
| 134 |
+
fullPrompt = fmt.Sprintf("%s\n\nWeb page URL: %s\n\n<webpage_content>\n%s\n</webpage_content>", params.Prompt, params.URL, content)
|
| 135 |
+
}
|
| 136 |
+
} else {
|
| 137 |
+
// Search mode: let the sub-agent search and fetch as needed.
|
| 138 |
+
fullPrompt = fmt.Sprintf("%s\n\nUse the web_search tool to find relevant information. Break down the question into smaller, focused searches if needed. After searching, use web_fetch to get detailed content from the most relevant results.", params.Prompt)
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
promptOpts := []prompt.Option{
|
| 142 |
+
prompt.WithWorkingDir(tmpDir),
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
promptTemplate, err := prompt.NewPrompt("agentic_fetch", string(agenticFetchPromptTmpl), promptOpts...)
|
| 146 |
+
if err != nil {
|
| 147 |
+
return fantasy.ToolResponse{}, fmt.Errorf("error creating prompt: %s", err)
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
_, small, err := c.buildAgentModels(ctx, true)
|
| 151 |
+
if err != nil {
|
| 152 |
+
return fantasy.ToolResponse{}, fmt.Errorf("error building models: %s", err)
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
systemPrompt, err := promptTemplate.Build(ctx, small.Model.Provider(), small.Model.Model(), c.cfg)
|
| 156 |
+
if err != nil {
|
| 157 |
+
return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
smallProviderCfg, ok := c.cfg.Config().Providers.Get(small.ModelCfg.Provider)
|
| 161 |
+
if !ok {
|
| 162 |
+
return fantasy.ToolResponse{}, errors.New("small model provider not configured")
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
webFetchTool := tools.NewWebFetchTool(tmpDir, client)
|
| 166 |
+
webSearchTool := tools.NewWebSearchTool(client)
|
| 167 |
+
fetchTools := []fantasy.AgentTool{
|
| 168 |
+
webFetchTool,
|
| 169 |
+
webSearchTool,
|
| 170 |
+
tools.NewGlobTool(tmpDir, c.cfg.Config().Tools.Glob),
|
| 171 |
+
tools.NewGrepTool(tmpDir, c.cfg.Config().Tools.Grep),
|
| 172 |
+
tools.NewSourcegraphTool(client),
|
| 173 |
+
tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, nil, tmpDir),
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// Sub-agent tools run without hook interception. The top-level
|
| 177 |
+
// `agentic_fetch` call itself is already wrapped from the coder's
|
| 178 |
+
// side; firing hooks again for every inner tool call would run
|
| 179 |
+
// the user's hooks N times per delegated turn.
|
| 180 |
+
|
| 181 |
+
agent := NewSessionAgent(SessionAgentOptions{
|
| 182 |
+
LargeModel: small, // Use small model for both (fetch doesn't need large)
|
| 183 |
+
SmallModel: small,
|
| 184 |
+
SystemPromptPrefix: smallProviderCfg.SystemPromptPrefix,
|
| 185 |
+
SystemPrompt: systemPrompt,
|
| 186 |
+
DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
|
| 187 |
+
IsYolo: c.permissions.SkipRequests(),
|
| 188 |
+
Sessions: c.sessions,
|
| 189 |
+
Messages: c.messages,
|
| 190 |
+
Tools: fetchTools,
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
return c.runSubAgent(ctx, subAgentParams{
|
| 194 |
+
Agent: agent,
|
| 195 |
+
SessionID: validationResult.SessionID,
|
| 196 |
+
AgentMessageID: validationResult.AgentMessageID,
|
| 197 |
+
ToolCallID: call.ID,
|
| 198 |
+
Prompt: fullPrompt,
|
| 199 |
+
SessionTitle: "Fetch Analysis",
|
| 200 |
+
SessionSetup: func(sessionID string) {
|
| 201 |
+
c.permissions.AutoApproveSession(sessionID)
|
| 202 |
+
},
|
| 203 |
+
})
|
| 204 |
+
},
|
| 205 |
+
), nil
|
| 206 |
+
}
|
internal/agent/aws_sso_refresh.go
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bufio"
|
| 5 |
+
"bytes"
|
| 6 |
+
"context"
|
| 7 |
+
"fmt"
|
| 8 |
+
"io"
|
| 9 |
+
"log/slog"
|
| 10 |
+
"os/exec"
|
| 11 |
+
"regexp"
|
| 12 |
+
"strings"
|
| 13 |
+
"sync"
|
| 14 |
+
"time"
|
| 15 |
+
|
| 16 |
+
"github.com/charmbracelet/crush/internal/agent/notify"
|
| 17 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 18 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
// awsSSORefreshTimeout bounds how long the AWS SSO refresh command may run.
|
| 22 |
+
// Browser-based SSO needs time, so it is generous, and it runs on a context
|
| 23 |
+
// detached from the agent turn so a cancelled turn doesn't abort the login.
|
| 24 |
+
const awsSSORefreshTimeout = 5 * time.Minute
|
| 25 |
+
|
| 26 |
+
// awsSSOURLRe matches the https verification URL that `aws sso login` and
|
| 27 |
+
// related commands print to stdout or stderr.
|
| 28 |
+
var awsSSOURLRe = regexp.MustCompile(`https://[^\s]+`)
|
| 29 |
+
|
| 30 |
+
// extractAWSSSOURL returns the first HTTPS URL in the given command output
|
| 31 |
+
// line, or empty if none is present.
|
| 32 |
+
func extractAWSSSOURL(line string) string {
|
| 33 |
+
return awsSSOURLRe.FindString(line)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// refreshAWSCredentials runs the provider's configured AWS SSO refresh
|
| 37 |
+
// command (e.g. "aws sso login") on the machine that makes the Bedrock
|
| 38 |
+
// calls, streaming the verification URL to the UI for display, then rebuilds
|
| 39 |
+
// models so the AWS SDK re-reads the refreshed credentials. It returns nil to
|
| 40 |
+
// signal that the failed request should be retried.
|
| 41 |
+
//
|
| 42 |
+
// The command runs here, in the coordinator, rather than in the UI dialog so
|
| 43 |
+
// the refreshed credentials land where the model calls are made. This is
|
| 44 |
+
// correct in both single-process and client/server deployments.
|
| 45 |
+
func (c *coordinator) refreshAWSCredentials(ctx context.Context, providerCfg config.ProviderConfig) error {
|
| 46 |
+
if c.notify == nil {
|
| 47 |
+
return errNoInteractiveAuth
|
| 48 |
+
}
|
| 49 |
+
slog.Info("AWS credentials expired, running refresh command",
|
| 50 |
+
"provider", providerCfg.ID, "command", providerCfg.AWSAuthRefresh)
|
| 51 |
+
|
| 52 |
+
// Open the dialog immediately so the user sees progress even before the
|
| 53 |
+
// command prints its verification URL.
|
| 54 |
+
c.notify.Publish(pubsub.CreatedEvent, notify.Notification{
|
| 55 |
+
Type: notify.TypeAWSSSOAuth,
|
| 56 |
+
ProviderID: providerCfg.ID,
|
| 57 |
+
AWSSOCommand: providerCfg.AWSAuthRefresh,
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
// Detach from the turn's context (with a generous timeout) so cancelling
|
| 61 |
+
// the turn doesn't kill an in-progress browser login.
|
| 62 |
+
runCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), awsSSORefreshTimeout)
|
| 63 |
+
defer cancel()
|
| 64 |
+
|
| 65 |
+
runErr := c.runAWSAuthRefresh(runCtx, providerCfg)
|
| 66 |
+
|
| 67 |
+
result := notify.Notification{Type: notify.TypeAWSSSOAuthResult, ProviderID: providerCfg.ID}
|
| 68 |
+
if runErr != nil {
|
| 69 |
+
result.Message = runErr.Error()
|
| 70 |
+
}
|
| 71 |
+
c.notify.Publish(pubsub.CreatedEvent, result)
|
| 72 |
+
|
| 73 |
+
if runErr != nil {
|
| 74 |
+
slog.Error("AWS SSO refresh command failed", "provider", providerCfg.ID, "error", runErr)
|
| 75 |
+
return runErr
|
| 76 |
+
}
|
| 77 |
+
// If the turn's context was cancelled while the command ran, fantasy's
|
| 78 |
+
// retry would fail immediately, so surface the cancellation instead.
|
| 79 |
+
if ctx.Err() != nil {
|
| 80 |
+
return ctx.Err()
|
| 81 |
+
}
|
| 82 |
+
// Rebuild models so the AWS SDK credential chain re-reads the refreshed
|
| 83 |
+
// SSO cache on the next attempt.
|
| 84 |
+
if err := c.UpdateModels(runCtx); err != nil {
|
| 85 |
+
slog.Error("Failed to update models after AWS SSO refresh", "provider", providerCfg.ID, "error", err)
|
| 86 |
+
return err
|
| 87 |
+
}
|
| 88 |
+
slog.Info("AWS SSO refresh complete, retrying request", "provider", providerCfg.ID)
|
| 89 |
+
return nil
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// runAWSAuthRefresh executes the refresh command, publishing the SSO
|
| 93 |
+
// verification URL to the UI as soon as it appears in the output and
|
| 94 |
+
// returning any failure with captured stderr for context.
|
| 95 |
+
func (c *coordinator) runAWSAuthRefresh(ctx context.Context, providerCfg config.ProviderConfig) error {
|
| 96 |
+
cmd := exec.CommandContext(ctx, "sh", "-c", providerCfg.AWSAuthRefresh)
|
| 97 |
+
cmd.Dir = c.cfg.WorkingDir()
|
| 98 |
+
|
| 99 |
+
stdout, err := cmd.StdoutPipe()
|
| 100 |
+
if err != nil {
|
| 101 |
+
return err
|
| 102 |
+
}
|
| 103 |
+
stderrPipe, err := cmd.StderrPipe()
|
| 104 |
+
if err != nil {
|
| 105 |
+
return err
|
| 106 |
+
}
|
| 107 |
+
if err := cmd.Start(); err != nil {
|
| 108 |
+
return err
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Drain stdout and stderr concurrently so a command that fills one pipe
|
| 112 |
+
// buffer before closing the other can't deadlock. Both are scanned for
|
| 113 |
+
// the verification URL; stderr is also captured for error detail.
|
| 114 |
+
var (
|
| 115 |
+
stderrBuf bytes.Buffer
|
| 116 |
+
mu sync.Mutex // Guards the single-shot URL publish across goroutines.
|
| 117 |
+
urlSent bool
|
| 118 |
+
)
|
| 119 |
+
publishURL := func(line string) {
|
| 120 |
+
mu.Lock()
|
| 121 |
+
defer mu.Unlock()
|
| 122 |
+
if urlSent {
|
| 123 |
+
return
|
| 124 |
+
}
|
| 125 |
+
if url := extractAWSSSOURL(line); url != "" {
|
| 126 |
+
urlSent = true
|
| 127 |
+
// Second phase of the two-part publish: the dialog is already
|
| 128 |
+
// open from refreshAWSCredentials; this fills in the URL on it.
|
| 129 |
+
c.notify.Publish(pubsub.CreatedEvent, notify.Notification{
|
| 130 |
+
Type: notify.TypeAWSSSOAuth,
|
| 131 |
+
ProviderID: providerCfg.ID,
|
| 132 |
+
AWSSOCommand: providerCfg.AWSAuthRefresh,
|
| 133 |
+
AWSSOURL: url,
|
| 134 |
+
})
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
var wg sync.WaitGroup
|
| 139 |
+
scan := func(r io.Reader) {
|
| 140 |
+
defer wg.Done()
|
| 141 |
+
scanner := bufio.NewScanner(r)
|
| 142 |
+
for scanner.Scan() {
|
| 143 |
+
publishURL(scanner.Text())
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
wg.Add(2)
|
| 147 |
+
go scan(stdout)
|
| 148 |
+
go scan(io.TeeReader(stderrPipe, &stderrBuf))
|
| 149 |
+
wg.Wait()
|
| 150 |
+
|
| 151 |
+
if err := cmd.Wait(); err != nil {
|
| 152 |
+
if stderr := strings.TrimSpace(stderrBuf.String()); stderr != "" {
|
| 153 |
+
return fmt.Errorf("%w: %s", err, stderr)
|
| 154 |
+
}
|
| 155 |
+
return err
|
| 156 |
+
}
|
| 157 |
+
return nil
|
| 158 |
+
}
|
internal/agent/aws_sso_refresh_test.go
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import "testing"
|
| 4 |
+
|
| 5 |
+
func TestExtractAWSSSOURL(t *testing.T) {
|
| 6 |
+
t.Parallel()
|
| 7 |
+
|
| 8 |
+
tests := []struct {
|
| 9 |
+
name string
|
| 10 |
+
output string
|
| 11 |
+
want string
|
| 12 |
+
}{
|
| 13 |
+
{
|
| 14 |
+
name: "standard aws sso login output",
|
| 15 |
+
output: `If the browser does not open or you wish to use a different device to authorize this request, open the following URL:
|
| 16 |
+
https://device.sso.us-east-1.amazonaws.com/?user_code=ABCD-EFGH`,
|
| 17 |
+
want: "https://device.sso.us-east-1.amazonaws.com/?user_code=ABCD-EFGH",
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
name: "url only",
|
| 21 |
+
output: "https://device.sso.eu-west-1.amazonaws.com/?user_code=XXXX-YYYY",
|
| 22 |
+
want: "https://device.sso.eu-west-1.amazonaws.com/?user_code=XXXX-YYYY",
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
name: "no url",
|
| 26 |
+
output: "SSO session expired. Please run aws sso login.",
|
| 27 |
+
want: "",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
name: "empty output",
|
| 31 |
+
output: "",
|
| 32 |
+
want: "",
|
| 33 |
+
},
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
for _, tt := range tests {
|
| 37 |
+
t.Run(tt.name, func(t *testing.T) {
|
| 38 |
+
t.Parallel()
|
| 39 |
+
if got := extractAWSSSOURL(tt.output); got != tt.want {
|
| 40 |
+
t.Errorf("extractAWSSSOURL() = %q, want %q", got, tt.want)
|
| 41 |
+
}
|
| 42 |
+
})
|
| 43 |
+
}
|
| 44 |
+
}
|
internal/agent/common_test.go
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"net/http"
|
| 6 |
+
"os"
|
| 7 |
+
"path/filepath"
|
| 8 |
+
"testing"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 12 |
+
"charm.land/fantasy"
|
| 13 |
+
"charm.land/fantasy/providers/openaicompat"
|
| 14 |
+
"charm.land/x/vcr"
|
| 15 |
+
"github.com/charmbracelet/crush/internal/agent/prompt"
|
| 16 |
+
"github.com/charmbracelet/crush/internal/agent/tools"
|
| 17 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 18 |
+
"github.com/charmbracelet/crush/internal/csync"
|
| 19 |
+
"github.com/charmbracelet/crush/internal/db"
|
| 20 |
+
"github.com/charmbracelet/crush/internal/filetracker"
|
| 21 |
+
"github.com/charmbracelet/crush/internal/history"
|
| 22 |
+
"github.com/charmbracelet/crush/internal/lsp"
|
| 23 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 24 |
+
"github.com/charmbracelet/crush/internal/permission"
|
| 25 |
+
"github.com/charmbracelet/crush/internal/session"
|
| 26 |
+
"github.com/stretchr/testify/require"
|
| 27 |
+
|
| 28 |
+
_ "github.com/joho/godotenv/autoload"
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
// fakeEnv is an environment for testing.
|
| 32 |
+
type fakeEnv struct {
|
| 33 |
+
workingDir string
|
| 34 |
+
sessions session.Service
|
| 35 |
+
messages message.Service
|
| 36 |
+
permissions permission.Service
|
| 37 |
+
history history.Service
|
| 38 |
+
filetracker *filetracker.Service
|
| 39 |
+
lspClients *csync.Map[string, *lsp.Client]
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
type builderFunc func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error)
|
| 43 |
+
|
| 44 |
+
type modelPair struct {
|
| 45 |
+
name string
|
| 46 |
+
largeModel builderFunc
|
| 47 |
+
smallModel builderFunc
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
func hyperBuilder(model string) builderFunc {
|
| 51 |
+
return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
|
| 52 |
+
provider, err := openaicompat.New(
|
| 53 |
+
openaicompat.WithBaseURL("https://hyper.charm.land/v1"),
|
| 54 |
+
openaicompat.WithAPIKey(os.Getenv("CRUSH_HYPER_API_KEY")),
|
| 55 |
+
openaicompat.WithHTTPClient(&http.Client{Transport: r}),
|
| 56 |
+
)
|
| 57 |
+
if err != nil {
|
| 58 |
+
return nil, err
|
| 59 |
+
}
|
| 60 |
+
return provider.LanguageModel(t.Context(), model)
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
func testEnv(t *testing.T) fakeEnv {
|
| 65 |
+
workingDir := filepath.Join("/tmp/crush-test/", t.Name())
|
| 66 |
+
os.RemoveAll(workingDir)
|
| 67 |
+
|
| 68 |
+
err := os.MkdirAll(workingDir, 0o755)
|
| 69 |
+
require.NoError(t, err)
|
| 70 |
+
|
| 71 |
+
conn, err := db.Connect(t.Context(), t.TempDir())
|
| 72 |
+
require.NoError(t, err)
|
| 73 |
+
|
| 74 |
+
q := db.New(conn)
|
| 75 |
+
sessions := session.NewService(q, conn)
|
| 76 |
+
messages := message.NewService(q)
|
| 77 |
+
|
| 78 |
+
permissions := permission.NewPermissionService(workingDir, true, []string{})
|
| 79 |
+
history := history.NewService(q, conn)
|
| 80 |
+
filetrackerService := filetracker.NewService(q)
|
| 81 |
+
lspClients := csync.NewMap[string, *lsp.Client]()
|
| 82 |
+
|
| 83 |
+
t.Cleanup(func() {
|
| 84 |
+
conn.Close()
|
| 85 |
+
os.RemoveAll(workingDir)
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
return fakeEnv{
|
| 89 |
+
workingDir,
|
| 90 |
+
sessions,
|
| 91 |
+
messages,
|
| 92 |
+
permissions,
|
| 93 |
+
history,
|
| 94 |
+
&filetrackerService,
|
| 95 |
+
lspClients,
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPrompt string, tools ...fantasy.AgentTool) SessionAgent {
|
| 100 |
+
largeModel := Model{
|
| 101 |
+
Model: large,
|
| 102 |
+
CatwalkCfg: catwalk.Model{
|
| 103 |
+
ContextWindow: 200000,
|
| 104 |
+
DefaultMaxTokens: 10000,
|
| 105 |
+
},
|
| 106 |
+
}
|
| 107 |
+
smallModel := Model{
|
| 108 |
+
Model: small,
|
| 109 |
+
CatwalkCfg: catwalk.Model{
|
| 110 |
+
ContextWindow: 200000,
|
| 111 |
+
DefaultMaxTokens: 10000,
|
| 112 |
+
},
|
| 113 |
+
}
|
| 114 |
+
agent := NewSessionAgent(SessionAgentOptions{
|
| 115 |
+
LargeModel: largeModel,
|
| 116 |
+
SmallModel: smallModel,
|
| 117 |
+
SystemPrompt: systemPrompt,
|
| 118 |
+
IsYolo: true,
|
| 119 |
+
Sessions: env.sessions,
|
| 120 |
+
Messages: env.messages,
|
| 121 |
+
Tools: tools,
|
| 122 |
+
})
|
| 123 |
+
return agent
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel) (SessionAgent, error) {
|
| 127 |
+
fixedTime := func() time.Time {
|
| 128 |
+
t, _ := time.Parse("1/2/2006", "1/1/2025")
|
| 129 |
+
return t
|
| 130 |
+
}
|
| 131 |
+
prompt, err := coderPrompt(
|
| 132 |
+
prompt.WithTimeFunc(fixedTime),
|
| 133 |
+
prompt.WithPlatform("linux"),
|
| 134 |
+
prompt.WithWorkingDir(filepath.ToSlash(env.workingDir)),
|
| 135 |
+
)
|
| 136 |
+
if err != nil {
|
| 137 |
+
return nil, err
|
| 138 |
+
}
|
| 139 |
+
cfg, err := config.Init(env.workingDir, "", false)
|
| 140 |
+
if err != nil {
|
| 141 |
+
return nil, err
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// NOTE(@andreynering): Set a fixed config to ensure cassettes match
|
| 145 |
+
// independently of user config on `$HOME/.config/crush/crush.json`.
|
| 146 |
+
cfg.Config().Options.Attribution = &config.Attribution{
|
| 147 |
+
TrailerStyle: "co-authored-by",
|
| 148 |
+
GeneratedWith: true,
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// Clear some fields to avoid issues with VCR cassette matching.
|
| 152 |
+
cfg.Config().Options.SkillsPaths = nil
|
| 153 |
+
cfg.Config().Options.DisabledSkills = []string{"crush-config"}
|
| 154 |
+
cfg.Config().Options.ContextPaths = nil
|
| 155 |
+
cfg.Config().Options.GlobalContextPaths = nil
|
| 156 |
+
cfg.Config().LSP = nil
|
| 157 |
+
|
| 158 |
+
systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg)
|
| 159 |
+
if err != nil {
|
| 160 |
+
return nil, err
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// Get the model name for the bash tool
|
| 164 |
+
modelName := large.Model() // fallback to ID if Name not available
|
| 165 |
+
if model := cfg.Config().GetModel(large.Provider(), large.Model()); model != nil {
|
| 166 |
+
modelName = model.Name
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
allTools := []fantasy.AgentTool{
|
| 170 |
+
tools.NewBashTool(env.permissions, env.workingDir, cfg.Config().Options.Attribution, modelName),
|
| 171 |
+
tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
|
| 172 |
+
tools.NewEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
|
| 173 |
+
tools.NewMultiEditTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
|
| 174 |
+
tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
|
| 175 |
+
tools.NewGlobTool(env.workingDir, cfg.Config().Tools.Glob),
|
| 176 |
+
tools.NewGrepTool(env.workingDir, cfg.Config().Tools.Grep),
|
| 177 |
+
tools.NewLsTool(env.permissions, env.workingDir, cfg.Config().Tools.Ls),
|
| 178 |
+
tools.NewSourcegraphTool(r.GetDefaultClient()),
|
| 179 |
+
tools.NewViewTool(nil, env.permissions, *env.filetracker, nil, env.workingDir),
|
| 180 |
+
tools.NewWriteTool(nil, env.permissions, env.history, *env.filetracker, env.workingDir),
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
return testSessionAgent(env, large, small, systemPrompt, allTools...), nil
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// createSimpleGoProject creates a simple Go project structure in the given directory.
|
| 187 |
+
// It creates a go.mod file and a main.go file with a basic hello world program.
|
| 188 |
+
func createSimpleGoProject(t *testing.T, dir string) {
|
| 189 |
+
goMod := `module example.com/testproject
|
| 190 |
+
|
| 191 |
+
go 1.23
|
| 192 |
+
`
|
| 193 |
+
err := os.WriteFile(dir+"/go.mod", []byte(goMod), 0o644)
|
| 194 |
+
require.NoError(t, err)
|
| 195 |
+
|
| 196 |
+
mainGo := `package main
|
| 197 |
+
|
| 198 |
+
import "fmt"
|
| 199 |
+
|
| 200 |
+
func main() {
|
| 201 |
+
fmt.Println("Hello, World!")
|
| 202 |
+
}
|
| 203 |
+
`
|
| 204 |
+
err = os.WriteFile(dir+"/main.go", []byte(mainGo), 0o644)
|
| 205 |
+
require.NoError(t, err)
|
| 206 |
+
}
|
internal/agent/convert_test.go
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/base64"
|
| 5 |
+
"testing"
|
| 6 |
+
|
| 7 |
+
"charm.land/fantasy"
|
| 8 |
+
"github.com/stretchr/testify/require"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func TestConvertToToolResult_InvalidBase64(t *testing.T) {
|
| 12 |
+
t.Parallel()
|
| 13 |
+
|
| 14 |
+
a := &sessionAgent{}
|
| 15 |
+
result := fantasy.ToolResultContent{
|
| 16 |
+
ToolCallID: "call_123",
|
| 17 |
+
ToolName: "test_tool",
|
| 18 |
+
Result: fantasy.ToolResultOutputContentMedia{
|
| 19 |
+
Data: "abc\x80def",
|
| 20 |
+
MediaType: "image/png",
|
| 21 |
+
},
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
tr := a.convertToToolResult(result)
|
| 25 |
+
require.True(t, tr.IsError)
|
| 26 |
+
require.Empty(t, tr.Data)
|
| 27 |
+
require.Contains(t, tr.Content, "invalid encoding")
|
| 28 |
+
require.Equal(t, "call_123", tr.ToolCallID)
|
| 29 |
+
require.Equal(t, "test_tool", tr.Name)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
func TestConvertToToolResult_ValidMedia(t *testing.T) {
|
| 33 |
+
t.Parallel()
|
| 34 |
+
|
| 35 |
+
a := &sessionAgent{}
|
| 36 |
+
validData := base64.StdEncoding.EncodeToString([]byte("test image data"))
|
| 37 |
+
|
| 38 |
+
result := fantasy.ToolResultContent{
|
| 39 |
+
ToolCallID: "call_456",
|
| 40 |
+
ToolName: "screenshot",
|
| 41 |
+
Result: fantasy.ToolResultOutputContentMedia{
|
| 42 |
+
Data: validData,
|
| 43 |
+
MediaType: "image/png",
|
| 44 |
+
Text: "Screenshot captured",
|
| 45 |
+
},
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
tr := a.convertToToolResult(result)
|
| 49 |
+
require.False(t, tr.IsError)
|
| 50 |
+
require.Equal(t, validData, tr.Data)
|
| 51 |
+
require.Equal(t, "image/png", tr.MIMEType)
|
| 52 |
+
require.Equal(t, "Screenshot captured", tr.Content)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
func TestConvertToToolResult_ValidMediaNoText(t *testing.T) {
|
| 56 |
+
t.Parallel()
|
| 57 |
+
|
| 58 |
+
a := &sessionAgent{}
|
| 59 |
+
validData := base64.StdEncoding.EncodeToString([]byte("test image data"))
|
| 60 |
+
|
| 61 |
+
result := fantasy.ToolResultContent{
|
| 62 |
+
ToolCallID: "call_789",
|
| 63 |
+
ToolName: "view",
|
| 64 |
+
Result: fantasy.ToolResultOutputContentMedia{
|
| 65 |
+
Data: validData,
|
| 66 |
+
MediaType: "image/jpeg",
|
| 67 |
+
},
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
tr := a.convertToToolResult(result)
|
| 71 |
+
require.False(t, tr.IsError)
|
| 72 |
+
require.Equal(t, validData, tr.Data)
|
| 73 |
+
require.Equal(t, "image/jpeg", tr.MIMEType)
|
| 74 |
+
require.Equal(t, "Loaded image/jpeg content", tr.Content)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
func TestConvertToToolResult_ASCIIButInvalidBase64(t *testing.T) {
|
| 78 |
+
t.Parallel()
|
| 79 |
+
|
| 80 |
+
a := &sessionAgent{}
|
| 81 |
+
result := fantasy.ToolResultContent{
|
| 82 |
+
ToolCallID: "call_abc",
|
| 83 |
+
ToolName: "mcp_tool",
|
| 84 |
+
Result: fantasy.ToolResultOutputContentMedia{
|
| 85 |
+
Data: "not-valid-base64!!!",
|
| 86 |
+
MediaType: "image/png",
|
| 87 |
+
},
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
tr := a.convertToToolResult(result)
|
| 91 |
+
require.True(t, tr.IsError)
|
| 92 |
+
require.Empty(t, tr.Data)
|
| 93 |
+
require.Contains(t, tr.Content, "invalid encoding")
|
| 94 |
+
}
|
internal/agent/coordinator.go
ADDED
|
@@ -0,0 +1,1757 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"cmp"
|
| 6 |
+
"context"
|
| 7 |
+
"encoding/json"
|
| 8 |
+
"errors"
|
| 9 |
+
"fmt"
|
| 10 |
+
"io"
|
| 11 |
+
"log/slog"
|
| 12 |
+
"maps"
|
| 13 |
+
"net/http"
|
| 14 |
+
"os"
|
| 15 |
+
"path/filepath"
|
| 16 |
+
"slices"
|
| 17 |
+
"strings"
|
| 18 |
+
"time"
|
| 19 |
+
|
| 20 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 21 |
+
"charm.land/fantasy"
|
| 22 |
+
"github.com/charmbracelet/crush/internal/agent/hyper"
|
| 23 |
+
"github.com/charmbracelet/crush/internal/agent/notify"
|
| 24 |
+
"github.com/charmbracelet/crush/internal/agent/prompt"
|
| 25 |
+
"github.com/charmbracelet/crush/internal/agent/tools"
|
| 26 |
+
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
|
| 27 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 28 |
+
"github.com/charmbracelet/crush/internal/discover"
|
| 29 |
+
"github.com/charmbracelet/crush/internal/event"
|
| 30 |
+
"github.com/charmbracelet/crush/internal/filetracker"
|
| 31 |
+
"github.com/charmbracelet/crush/internal/history"
|
| 32 |
+
"github.com/charmbracelet/crush/internal/hooks"
|
| 33 |
+
"github.com/charmbracelet/crush/internal/log"
|
| 34 |
+
"github.com/charmbracelet/crush/internal/lsp"
|
| 35 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 36 |
+
"github.com/charmbracelet/crush/internal/oauth"
|
| 37 |
+
"github.com/charmbracelet/crush/internal/oauth/copilot"
|
| 38 |
+
openaioauth "github.com/charmbracelet/crush/internal/oauth/openai"
|
| 39 |
+
"github.com/charmbracelet/crush/internal/permission"
|
| 40 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 41 |
+
"github.com/charmbracelet/crush/internal/question"
|
| 42 |
+
"github.com/charmbracelet/crush/internal/session"
|
| 43 |
+
"github.com/charmbracelet/crush/internal/skills"
|
| 44 |
+
"golang.org/x/sync/errgroup"
|
| 45 |
+
|
| 46 |
+
"charm.land/fantasy/providers/anthropic"
|
| 47 |
+
"charm.land/fantasy/providers/azure"
|
| 48 |
+
"charm.land/fantasy/providers/bedrock"
|
| 49 |
+
"charm.land/fantasy/providers/google"
|
| 50 |
+
"charm.land/fantasy/providers/openai"
|
| 51 |
+
"charm.land/fantasy/providers/openaicompat"
|
| 52 |
+
"charm.land/fantasy/providers/openrouter"
|
| 53 |
+
"charm.land/fantasy/providers/vercel"
|
| 54 |
+
openaisdk "github.com/openai/openai-go/v3/option"
|
| 55 |
+
"github.com/qjebbs/go-jsons"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
// Coordinator errors.
|
| 59 |
+
var (
|
| 60 |
+
errCoderAgentNotConfigured = errors.New("coder agent not configured")
|
| 61 |
+
errModelProviderNotConfigured = errors.New("model provider not configured")
|
| 62 |
+
errLargeModelNotSelected = errors.New("large model not selected")
|
| 63 |
+
errSmallModelNotSelected = errors.New("small model not selected")
|
| 64 |
+
errLargeModelProviderNotConfigured = errors.New("large model provider not configured")
|
| 65 |
+
errSmallModelProviderNotConfigured = errors.New("small model provider not configured")
|
| 66 |
+
errLargeModelNotFound = errors.New("large model not found in provider config")
|
| 67 |
+
errSmallModelNotFound = errors.New("small model not found in provider config")
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
// Copilot models that use the Responses API instead of Chat Completions.
|
| 71 |
+
var copilotResponsesModels = map[string]bool{
|
| 72 |
+
"gpt-5.2": true,
|
| 73 |
+
"gpt-5.2-codex": true,
|
| 74 |
+
"gpt-5.3-codex": true,
|
| 75 |
+
"gpt-5.4": true,
|
| 76 |
+
"gpt-5.4-mini": true,
|
| 77 |
+
"gpt-5.5": true,
|
| 78 |
+
"gpt-5-mini": true,
|
| 79 |
+
"gpt-5.6-luna": true,
|
| 80 |
+
"gpt-5.6-terra": true,
|
| 81 |
+
"gpt-5.6-sol": true,
|
| 82 |
+
"gpt-6-astra": true,
|
| 83 |
+
"grok-4.5": true,
|
| 84 |
+
"grok-4.6": true,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// OpenCode models that use the Anthropic Messages API instead of Chat
|
| 88 |
+
// Completions. Which endpoint serves each model differs per provider, see
|
| 89 |
+
// https://opencode.ai/docs/zen and https://opencode.ai/docs/go.
|
| 90 |
+
func isOpenCodeMessagesModel(providerID, modelID string) bool {
|
| 91 |
+
switch providerID {
|
| 92 |
+
case string(catwalk.InferenceProviderOpenCodeGo):
|
| 93 |
+
return strings.HasPrefix(modelID, "minimax-") ||
|
| 94 |
+
strings.HasPrefix(modelID, "qwen3.6-") ||
|
| 95 |
+
strings.HasPrefix(modelID, "qwen3.7-") ||
|
| 96 |
+
strings.HasPrefix(modelID, "qwen3.8-")
|
| 97 |
+
case string(catwalk.InferenceProviderOpenCodeZen):
|
| 98 |
+
return strings.HasPrefix(modelID, "claude-") ||
|
| 99 |
+
strings.HasPrefix(modelID, "qwen3.5-") ||
|
| 100 |
+
strings.HasPrefix(modelID, "qwen3.6-") ||
|
| 101 |
+
strings.HasPrefix(modelID, "qwen3.7-") ||
|
| 102 |
+
strings.HasPrefix(modelID, "qwen3.8-")
|
| 103 |
+
}
|
| 104 |
+
return false
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// OpenCode models that use the OpenAI Responses API instead of Chat
|
| 108 |
+
// Completions. See https://opencode.ai/docs/zen and https://opencode.ai/docs/go.
|
| 109 |
+
func isOpenCodeResponsesModel(modelID string) bool {
|
| 110 |
+
return strings.HasPrefix(modelID, "gpt-") ||
|
| 111 |
+
strings.HasPrefix(modelID, "grok-") ||
|
| 112 |
+
strings.HasPrefix(modelID, "muse-spark-")
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
type Coordinator interface {
|
| 116 |
+
// INFO: (kujtim) this is not used yet we will use this when we have multiple agents
|
| 117 |
+
// SetMainAgent(string)
|
| 118 |
+
Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
|
| 119 |
+
// RunAccepted runs a call that was already accepted via
|
| 120 |
+
// BeginAccepted on the fire-and-forget dispatch path. The handle is
|
| 121 |
+
// the only carrier of accept-state across the backend.runAgent /
|
| 122 |
+
// Coordinator / sessionAgent.Run layers: it reaches
|
| 123 |
+
// sessionAgent.Run as SessionAgentCall.Accepted, where it is
|
| 124 |
+
// consumed under dispatchMu once the accepted -> (cancel-on-entry |
|
| 125 |
+
// queued | active) transition is chosen.
|
| 126 |
+
RunAccepted(ctx context.Context, accept *AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error)
|
| 127 |
+
BeginAccepted(sessionID string) *AcceptedRun
|
| 128 |
+
Cancel(sessionID string)
|
| 129 |
+
CancelAll()
|
| 130 |
+
IsSessionBusy(sessionID string) bool
|
| 131 |
+
IsBusy() bool
|
| 132 |
+
QueuedPrompts(sessionID string) int
|
| 133 |
+
QueuedPromptsList(sessionID string) []string
|
| 134 |
+
ClearQueue(sessionID string)
|
| 135 |
+
Summarize(context.Context, string) error
|
| 136 |
+
Model() Model
|
| 137 |
+
UpdateModels(ctx context.Context) error
|
| 138 |
+
GenerateTitle(ctx context.Context, sessionID, prompt string)
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
type coordinator struct {
|
| 142 |
+
cfg *config.ConfigStore
|
| 143 |
+
sessions session.Service
|
| 144 |
+
messages message.Service
|
| 145 |
+
permissions permission.Service
|
| 146 |
+
questions question.Service
|
| 147 |
+
history history.Service
|
| 148 |
+
filetracker filetracker.Service
|
| 149 |
+
lspManager *lsp.Manager
|
| 150 |
+
notify pubsub.Publisher[notify.Notification]
|
| 151 |
+
runComplete pubsub.Publisher[notify.RunComplete]
|
| 152 |
+
interactive bool
|
| 153 |
+
|
| 154 |
+
currentAgent SessionAgent
|
| 155 |
+
agents map[string]SessionAgent
|
| 156 |
+
|
| 157 |
+
// Skills discovery results (session-start snapshot).
|
| 158 |
+
allSkills []*skills.Skill // Pre-filter: all discovered after dedup.
|
| 159 |
+
activeSkills []*skills.Skill // Post-filter: active skills only.
|
| 160 |
+
skillTracker *skills.Tracker
|
| 161 |
+
|
| 162 |
+
readyWg errgroup.Group
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
// CoordinatorOptions holds the dependencies for NewCoordinator. Using a
|
| 166 |
+
// struct keeps the constructor self-documenting and avoids a long
|
| 167 |
+
// positional parameter list.
|
| 168 |
+
type CoordinatorOptions struct {
|
| 169 |
+
Config *config.ConfigStore
|
| 170 |
+
Sessions session.Service
|
| 171 |
+
Messages message.Service
|
| 172 |
+
Permissions permission.Service
|
| 173 |
+
Questions question.Service
|
| 174 |
+
History history.Service
|
| 175 |
+
FileTracker filetracker.Service
|
| 176 |
+
LSPManager *lsp.Manager
|
| 177 |
+
Notify pubsub.Publisher[notify.Notification]
|
| 178 |
+
RunComplete pubsub.Publisher[notify.RunComplete]
|
| 179 |
+
Skills *skills.Manager
|
| 180 |
+
Interactive bool
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
func NewCoordinator(ctx context.Context, opts CoordinatorOptions) (Coordinator, error) {
|
| 184 |
+
// Skills are pre-discovered by the caller (see app.New /
|
| 185 |
+
// backend.CreateWorkspace) and passed in via the manager. If no
|
| 186 |
+
// manager was provided (legacy callers), fall back to an in-line
|
| 187 |
+
// discovery so the coordinator still works.
|
| 188 |
+
var allSkills, activeSkills []*skills.Skill
|
| 189 |
+
if opts.Skills != nil {
|
| 190 |
+
allSkills = opts.Skills.AllSkills()
|
| 191 |
+
activeSkills = opts.Skills.ActiveSkills()
|
| 192 |
+
} else {
|
| 193 |
+
allSkills, activeSkills = discoverSkills(opts.Config)
|
| 194 |
+
}
|
| 195 |
+
skillTracker := skills.NewTracker(activeSkills)
|
| 196 |
+
|
| 197 |
+
c := &coordinator{
|
| 198 |
+
cfg: opts.Config,
|
| 199 |
+
sessions: opts.Sessions,
|
| 200 |
+
messages: opts.Messages,
|
| 201 |
+
permissions: opts.Permissions,
|
| 202 |
+
questions: opts.Questions,
|
| 203 |
+
history: opts.History,
|
| 204 |
+
filetracker: opts.FileTracker,
|
| 205 |
+
lspManager: opts.LSPManager,
|
| 206 |
+
notify: opts.Notify,
|
| 207 |
+
runComplete: opts.RunComplete,
|
| 208 |
+
agents: make(map[string]SessionAgent),
|
| 209 |
+
allSkills: allSkills,
|
| 210 |
+
activeSkills: activeSkills,
|
| 211 |
+
skillTracker: skillTracker,
|
| 212 |
+
interactive: opts.Interactive,
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
agentCfg, ok := opts.Config.Config().Agents[config.AgentCoder]
|
| 216 |
+
if !ok {
|
| 217 |
+
return nil, errCoderAgentNotConfigured
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// TODO: make this dynamic when we support multiple agents
|
| 221 |
+
prompt, err := coderPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
|
| 222 |
+
if err != nil {
|
| 223 |
+
return nil, err
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
agent, err := c.buildAgent(ctx, prompt, agentCfg, false)
|
| 227 |
+
if err != nil {
|
| 228 |
+
return nil, err
|
| 229 |
+
}
|
| 230 |
+
c.currentAgent = agent
|
| 231 |
+
c.agents[config.AgentCoder] = agent
|
| 232 |
+
return c, nil
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
// Run implements Coordinator.
|
| 236 |
+
func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
|
| 237 |
+
return c.run(ctx, nil, sessionID, prompt, attachments...)
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
// RunAccepted implements Coordinator.
|
| 241 |
+
func (c *coordinator) RunAccepted(ctx context.Context, accept *AcceptedRun, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
|
| 242 |
+
return c.run(ctx, accept, sessionID, prompt, attachments...)
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
// run is the shared implementation behind Run and RunAccepted. When
|
| 246 |
+
// accept is non-nil it is threaded onto the SessionAgentCall as
|
| 247 |
+
// Accepted so sessionAgent.Run can consume the accept reservation under
|
| 248 |
+
// dispatchMu; when nil (the in-process/local path) no accept tracking
|
| 249 |
+
// applies.
|
| 250 |
+
func (c *coordinator) run(ctx context.Context, accept *AcceptedRun, sessionID string, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
|
| 251 |
+
if err := c.readyWg.Wait(); err != nil {
|
| 252 |
+
return nil, err
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// MCP servers connect asynchronously (see mcp.Initialize).
|
| 256 |
+
//
|
| 257 |
+
// Interactive runs never wait for that to finish: the tool list below
|
| 258 |
+
// is built from whatever is registered right now, servers still
|
| 259 |
+
// connecting are simply absent from this run's palette, and they are
|
| 260 |
+
// picked up by later runs once they register and publish
|
| 261 |
+
// EventToolsListChanged. Blocking here froze the TUI for the duration
|
| 262 |
+
// of the slowest server's connect timeout whenever a prompt was sent
|
| 263 |
+
// before initialization finished — most visibly on the first message.
|
| 264 |
+
//
|
| 265 |
+
// Non-interactive runs get a single shot at the tool palette, so they
|
| 266 |
+
// do wait for initialization to settle — but bounded by InitWaitBudget
|
| 267 |
+
// rather than each server's connect timeout, so a server wedged
|
| 268 |
+
// mid-handshake cannot stall a headless run for minutes. Past the
|
| 269 |
+
// budget the turn proceeds without the stragglers; their tools simply
|
| 270 |
+
// stay absent from this run.
|
| 271 |
+
if !c.interactive {
|
| 272 |
+
if err := mcp.WaitForInitBudget(ctx, mcp.InitWaitBudget); err != nil {
|
| 273 |
+
return nil, fmt.Errorf("failed to wait for MCP initialization: %w", err)
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
// refresh models before each run
|
| 278 |
+
if err := c.UpdateModels(ctx); err != nil {
|
| 279 |
+
return nil, fmt.Errorf("failed to update models: %w", err)
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
model := c.currentAgent.Model()
|
| 283 |
+
maxTokens := model.CatwalkCfg.DefaultMaxTokens
|
| 284 |
+
if model.ModelCfg.MaxTokens != 0 {
|
| 285 |
+
maxTokens = model.ModelCfg.MaxTokens
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
|
| 289 |
+
if !ok {
|
| 290 |
+
return nil, errModelProviderNotConfigured
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
mergedOptions, temp, topP, topK, freqPenalty, presPenalty := mergeCallOptions(model, providerCfg)
|
| 294 |
+
|
| 295 |
+
if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
|
| 296 |
+
// NOTE(@andreynering): We don't return here because the event handling to ask the user to reauthenticate
|
| 297 |
+
// depends on the flow below. If refresh fails, proceed with the token we have.
|
| 298 |
+
slog.Error("Failed to refresh OAuth2 token. Proceeding with existing token.", "error", err)
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// Coalesce per-attempt RunComplete payloads so only the final
|
| 302 |
+
// outcome reaches subscribers. Without this, the first attempt's
|
| 303 |
+
// failed RunComplete (unauthorized) would race ahead of the
|
| 304 |
+
// retry's success, and `crush run` would exit on the stale error
|
| 305 |
+
// before ever seeing the retry result. Each attempt's
|
| 306 |
+
// SessionAgentCall.OnComplete hook overwrites latest; we publish
|
| 307 |
+
// exactly once after retries resolve, via PublishMustDeliver, so
|
| 308 |
+
// a momentarily-full subscriber buffer can't silently drop the
|
| 309 |
+
// terminal event.
|
| 310 |
+
var (
|
| 311 |
+
latest notify.RunComplete
|
| 312 |
+
hasLatest bool
|
| 313 |
+
)
|
| 314 |
+
onComplete := func(rc notify.RunComplete) {
|
| 315 |
+
latest = rc
|
| 316 |
+
hasLatest = true
|
| 317 |
+
}
|
| 318 |
+
// Propagate the caller-supplied RunID (set via agent.WithRunID
|
| 319 |
+
// at the HTTP boundary in backend.SendMessage) onto the
|
| 320 |
+
// SessionAgentCall so the terminal RunComplete event echoes it
|
| 321 |
+
// back. Both attempts in the retry chain reuse the same RunID;
|
| 322 |
+
// the coalesce closure publishes the final outcome under that
|
| 323 |
+
// same correlator.
|
| 324 |
+
runID := RunIDFromContext(ctx)
|
| 325 |
+
run := func() (*fantasy.AgentResult, error) {
|
| 326 |
+
return c.currentAgent.Run(ctx, SessionAgentCall{
|
| 327 |
+
SessionID: sessionID,
|
| 328 |
+
RunID: runID,
|
| 329 |
+
Prompt: prompt,
|
| 330 |
+
Attachments: attachments,
|
| 331 |
+
MaxOutputTokens: maxTokens,
|
| 332 |
+
ProviderOptions: mergedOptions,
|
| 333 |
+
Temperature: temp,
|
| 334 |
+
TopP: topP,
|
| 335 |
+
TopK: callTopK(providerCfg, topK),
|
| 336 |
+
FrequencyPenalty: freqPenalty,
|
| 337 |
+
PresencePenalty: presPenalty,
|
| 338 |
+
OnComplete: onComplete,
|
| 339 |
+
Accepted: accept,
|
| 340 |
+
OnAuthRefresh: c.makeAuthRefreshCallback(providerCfg),
|
| 341 |
+
})
|
| 342 |
+
}
|
| 343 |
+
beforeLoaded := c.skillTracker.LoadedNames()
|
| 344 |
+
result, originalErr := run()
|
| 345 |
+
logTurnSkillUsage(sessionID, prompt, c.activeSkills, c.skillTracker, beforeLoaded)
|
| 346 |
+
|
| 347 |
+
// Notify only if still unauthorized after retry — a successful
|
| 348 |
+
// retry means the user doesn't need to re-authenticate. AWS SSO is
|
| 349 |
+
// handled transparently inside OnAuthRefresh, so it needs no post-run
|
| 350 |
+
// notification here.
|
| 351 |
+
if originalErr != nil && isUnauthorized(originalErr) && c.notify != nil && model.ModelCfg.Provider == hyper.Name {
|
| 352 |
+
c.notify.Publish(pubsub.CreatedEvent, notify.Notification{
|
| 353 |
+
Type: notify.TypeReAuthenticate,
|
| 354 |
+
ProviderID: model.ModelCfg.Provider,
|
| 355 |
+
})
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
if hasLatest && c.runComplete != nil {
|
| 359 |
+
c.runComplete.PublishMustDeliver(ctx, pubsub.UpdatedEvent, latest)
|
| 360 |
+
// Signal to the dispatcher (backend.runAgent) that the
|
| 361 |
+
// authoritative terminal RunComplete for this run was already
|
| 362 |
+
// emitted, so it does not publish a duplicate fallback for the
|
| 363 |
+
// error it is about to receive.
|
| 364 |
+
MarkRunCompletePublished(ctx)
|
| 365 |
+
}
|
| 366 |
+
return result, originalErr
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
// effectiveReasoningEffort returns the reasoning effort to apply for provider calls.
|
| 370 |
+
// It prefers the user-selected effort when valid, otherwise the model default when
|
| 371 |
+
// valid, and finally falls back to the first configured reasoning level.
|
| 372 |
+
func effectiveReasoningEffort(model Model) string {
|
| 373 |
+
if !model.CatwalkCfg.CanReason {
|
| 374 |
+
return ""
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
if effort := model.ModelCfg.ReasoningEffort; effort != "" && slices.Contains(model.CatwalkCfg.ReasoningLevels, effort) {
|
| 378 |
+
return effort
|
| 379 |
+
}
|
| 380 |
+
if effort := model.CatwalkCfg.DefaultReasoningEffort; effort != "" && slices.Contains(model.CatwalkCfg.ReasoningLevels, effort) {
|
| 381 |
+
return effort
|
| 382 |
+
}
|
| 383 |
+
if len(model.CatwalkCfg.ReasoningLevels) > 0 {
|
| 384 |
+
return model.CatwalkCfg.ReasoningLevels[0]
|
| 385 |
+
}
|
| 386 |
+
return ""
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
|
| 390 |
+
options := fantasy.ProviderOptions{}
|
| 391 |
+
|
| 392 |
+
cfgOpts := []byte("{}")
|
| 393 |
+
providerCfgOpts := []byte("{}")
|
| 394 |
+
catwalkOpts := []byte("{}")
|
| 395 |
+
|
| 396 |
+
if model.ModelCfg.ProviderOptions != nil {
|
| 397 |
+
data, err := json.Marshal(model.ModelCfg.ProviderOptions)
|
| 398 |
+
if err == nil {
|
| 399 |
+
cfgOpts = data
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
if providerCfg.ProviderOptions != nil {
|
| 404 |
+
data, err := json.Marshal(providerCfg.ProviderOptions)
|
| 405 |
+
if err == nil {
|
| 406 |
+
providerCfgOpts = data
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
if model.CatwalkCfg.Options.ProviderOptions != nil {
|
| 411 |
+
data, err := json.Marshal(model.CatwalkCfg.Options.ProviderOptions)
|
| 412 |
+
if err == nil {
|
| 413 |
+
catwalkOpts = data
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
readers := []io.Reader{
|
| 418 |
+
bytes.NewReader(catwalkOpts),
|
| 419 |
+
bytes.NewReader(providerCfgOpts),
|
| 420 |
+
bytes.NewReader(cfgOpts),
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
got, err := jsons.Merge(readers)
|
| 424 |
+
if err != nil {
|
| 425 |
+
slog.Error("Could not merge call config", "err", err)
|
| 426 |
+
return options
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
mergedOptions := make(map[string]any)
|
| 430 |
+
|
| 431 |
+
err = json.Unmarshal([]byte(got), &mergedOptions)
|
| 432 |
+
if err != nil {
|
| 433 |
+
slog.Error("Could not create config for call", "err", err)
|
| 434 |
+
return options
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
reasoningEffort := effectiveReasoningEffort(model)
|
| 438 |
+
shouldSetEffort := model.CatwalkCfg.CanReason &&
|
| 439 |
+
reasoningEffort != "" &&
|
| 440 |
+
slices.Contains(model.CatwalkCfg.ReasoningLevels, reasoningEffort)
|
| 441 |
+
|
| 442 |
+
switch providerCfg.Type {
|
| 443 |
+
case openai.Name, azure.Name:
|
| 444 |
+
_, hasReasoningEffort := mergedOptions["reasoning_effort"]
|
| 445 |
+
if !hasReasoningEffort && shouldSetEffort {
|
| 446 |
+
mergedOptions["reasoning_effort"] = reasoningEffort
|
| 447 |
+
}
|
| 448 |
+
if openai.IsResponsesModel(model.CatwalkCfg.ID) {
|
| 449 |
+
if openai.IsResponsesReasoningModel(model.CatwalkCfg.ID) {
|
| 450 |
+
mergedOptions["reasoning_summary"] = "auto"
|
| 451 |
+
mergedOptions["include"] = []openai.IncludeType{openai.IncludeReasoningEncryptedContent}
|
| 452 |
+
}
|
| 453 |
+
parsed, err := openai.ParseResponsesOptions(mergedOptions)
|
| 454 |
+
if err == nil {
|
| 455 |
+
options[openai.Name] = parsed
|
| 456 |
+
}
|
| 457 |
+
} else {
|
| 458 |
+
parsed, err := openai.ParseOptions(mergedOptions)
|
| 459 |
+
if err == nil {
|
| 460 |
+
options[openai.Name] = parsed
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
case anthropic.Name, bedrock.Name:
|
| 465 |
+
var (
|
| 466 |
+
_, hasEffort = mergedOptions["effort"]
|
| 467 |
+
_, hasThink = mergedOptions["thinking"]
|
| 468 |
+
extraBody = make(map[string]any)
|
| 469 |
+
)
|
| 470 |
+
|
| 471 |
+
switch providerCfg.ID {
|
| 472 |
+
case string(catwalk.InferenceProviderAlibabaSingapore), string(catwalk.InferenceProviderAlibabaUS):
|
| 473 |
+
switch {
|
| 474 |
+
case !hasEffort && shouldSetEffort:
|
| 475 |
+
extraBody["reasoning_effort"] = reasoningEffort
|
| 476 |
+
case !hasThink && model.CatwalkCfg.CanReason:
|
| 477 |
+
if model.ModelCfg.Think {
|
| 478 |
+
extraBody["thinking"] = map[string]any{"type": "enabled"}
|
| 479 |
+
} else {
|
| 480 |
+
extraBody["thinking"] = map[string]any{"type": "disabled"}
|
| 481 |
+
}
|
| 482 |
+
}
|
| 483 |
+
mergedOptions["extra_body"] = extraBody
|
| 484 |
+
|
| 485 |
+
default:
|
| 486 |
+
switch {
|
| 487 |
+
case !hasEffort && shouldSetEffort:
|
| 488 |
+
mergedOptions["effort"] = reasoningEffort
|
| 489 |
+
case !hasThink && model.ModelCfg.Think:
|
| 490 |
+
mergedOptions["thinking"] = map[string]any{"budget_tokens": 2000}
|
| 491 |
+
}
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
parsed, err := anthropic.ParseOptions(mergedOptions)
|
| 495 |
+
if err == nil {
|
| 496 |
+
options[anthropic.Name] = parsed
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
case openrouter.Name:
|
| 500 |
+
_, hasReasoning := mergedOptions["reasoning"]
|
| 501 |
+
if !hasReasoning && shouldSetEffort {
|
| 502 |
+
mergedOptions["reasoning"] = map[string]any{
|
| 503 |
+
"enabled": true,
|
| 504 |
+
"effort": reasoningEffort,
|
| 505 |
+
}
|
| 506 |
+
}
|
| 507 |
+
parsed, err := openrouter.ParseOptions(mergedOptions)
|
| 508 |
+
if err == nil {
|
| 509 |
+
options[openrouter.Name] = parsed
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
case vercel.Name:
|
| 513 |
+
_, hasReasoning := mergedOptions["reasoning"]
|
| 514 |
+
if !hasReasoning && shouldSetEffort {
|
| 515 |
+
mergedOptions["reasoning"] = map[string]any{
|
| 516 |
+
"enabled": true,
|
| 517 |
+
"effort": reasoningEffort,
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
parsed, err := vercel.ParseOptions(mergedOptions)
|
| 521 |
+
if err == nil {
|
| 522 |
+
options[vercel.Name] = parsed
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
case google.Name:
|
| 526 |
+
_, hasReasoning := mergedOptions["thinking_config"]
|
| 527 |
+
if !hasReasoning {
|
| 528 |
+
if strings.HasPrefix(model.CatwalkCfg.ID, "gemini-2") {
|
| 529 |
+
mergedOptions["thinking_config"] = map[string]any{
|
| 530 |
+
"thinking_budget": 2000,
|
| 531 |
+
"include_thoughts": true,
|
| 532 |
+
}
|
| 533 |
+
} else {
|
| 534 |
+
mergedOptions["thinking_config"] = map[string]any{
|
| 535 |
+
"thinking_level": reasoningEffort,
|
| 536 |
+
"include_thoughts": true,
|
| 537 |
+
}
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
parsed, err := google.ParseOptions(mergedOptions)
|
| 541 |
+
if err == nil {
|
| 542 |
+
options[google.Name] = parsed
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
case openaicompat.Name, hyper.Name:
|
| 546 |
+
extraBody := make(map[string]any)
|
| 547 |
+
|
| 548 |
+
_, hasReasoningEffort := mergedOptions["reasoning_effort"]
|
| 549 |
+
if !hasReasoningEffort && shouldSetEffort {
|
| 550 |
+
switch providerCfg.ID {
|
| 551 |
+
case string(catwalk.InferenceProviderIoNet):
|
| 552 |
+
extraBody["reasoning"] = map[string]string{"effort": reasoningEffort}
|
| 553 |
+
case string(catwalk.InferenceProviderOpenCodeGo), string(catwalk.InferenceProviderOpenCodeZen):
|
| 554 |
+
// MiniMax models use the "thinking" parameter instead of
|
| 555 |
+
// "reasoning_effort". Other models on these providers still
|
| 556 |
+
// use the standard field.
|
| 557 |
+
if !strings.HasPrefix(strings.ToLower(model.CatwalkCfg.ID), "minimax") {
|
| 558 |
+
mergedOptions["reasoning_effort"] = reasoningEffort
|
| 559 |
+
}
|
| 560 |
+
default:
|
| 561 |
+
mergedOptions["reasoning_effort"] = reasoningEffort
|
| 562 |
+
}
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
// "reasoning effort" is a standard OpenAI field, but "thinking" is not.
|
| 566 |
+
// Setting it in the right way for each provider.
|
| 567 |
+
// TODO: Abstract this in Fantasy somehow?
|
| 568 |
+
// TODO: Allow custom providers to specify how to set this?
|
| 569 |
+
switch providerCfg.ID {
|
| 570 |
+
case hyper.Name:
|
| 571 |
+
extraBody["thinking"] = model.ModelCfg.Think
|
| 572 |
+
case string(catwalk.InferenceProviderIoNet):
|
| 573 |
+
if _, ok := extraBody["reasoning"]; !ok && model.CatwalkCfg.CanReason {
|
| 574 |
+
if model.ModelCfg.Think {
|
| 575 |
+
extraBody["reasoning"] = map[string]string{"effort": "medium"}
|
| 576 |
+
} else {
|
| 577 |
+
extraBody["reasoning"] = map[string]string{"effort": "none"}
|
| 578 |
+
}
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
case string(catwalk.InferenceProviderZAI), string(catwalk.InferenceProviderDeepSeek):
|
| 582 |
+
if model.ModelCfg.Think || reasoningEffort != "" {
|
| 583 |
+
extraBody["thinking"] = map[string]any{"type": "enabled"}
|
| 584 |
+
} else {
|
| 585 |
+
extraBody["thinking"] = map[string]any{"type": "disabled"}
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
case string(catwalk.InferenceProviderFireworks):
|
| 589 |
+
// NOTE: Fireworks break if we set both `reasoning_effort` and `thinking`.
|
| 590 |
+
if reasoningEffort == "" {
|
| 591 |
+
if model.ModelCfg.Think {
|
| 592 |
+
extraBody["thinking"] = map[string]any{"type": "enabled"}
|
| 593 |
+
} else {
|
| 594 |
+
extraBody["thinking"] = map[string]any{"type": "disabled"}
|
| 595 |
+
}
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
case string(catwalk.InferenceProviderBaseten):
|
| 599 |
+
extraBody["chat_template_args"] = map[string]any{
|
| 600 |
+
"enable_thinking": model.ModelCfg.Think || reasoningEffort != "" && reasoningEffort != "none",
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
case string(catwalk.InferenceProviderOpenCodeGo), string(catwalk.InferenceProviderOpenCodeZen):
|
| 604 |
+
// MiniMax M3 uses the "thinking" parameter to control reasoning.
|
| 605 |
+
// "reasoning_split" must be true so thinking content is returned
|
| 606 |
+
// in the "reasoning_content" field instead of inline in "content".
|
| 607 |
+
if strings.HasPrefix(strings.ToLower(model.CatwalkCfg.ID), "minimax") {
|
| 608 |
+
if model.CatwalkCfg.CanReason && (model.ModelCfg.Think || reasoningEffort != "") {
|
| 609 |
+
extraBody["thinking"] = map[string]any{"type": "adaptive"}
|
| 610 |
+
extraBody["reasoning_split"] = true
|
| 611 |
+
} else {
|
| 612 |
+
extraBody["thinking"] = map[string]any{"type": "disabled"}
|
| 613 |
+
}
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
case string(catwalk.InferenceProviderAlibabaSingapore), string(catwalk.InferenceProviderAlibabaUS):
|
| 617 |
+
if model.CatwalkCfg.CanReason && !shouldSetEffort {
|
| 618 |
+
extraBody["enable_thinking"] = model.ModelCfg.Think
|
| 619 |
+
}
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
mergedOptions["extra_body"] = extraBody
|
| 623 |
+
|
| 624 |
+
parsed, err := openaicompat.ParseOptions(mergedOptions)
|
| 625 |
+
if err == nil {
|
| 626 |
+
options[openaicompat.Name] = parsed
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
default:
|
| 630 |
+
// Known custom providers (litellm, llamacpp, lmstudio, ollama,
|
| 631 |
+
// omlx) are openai-compat under the hood.
|
| 632 |
+
if discover.IsKnownCustomProvider(string(providerCfg.Type)) {
|
| 633 |
+
// Set "top_k" under "extra_body", as it is not part of the OpenAI protocol
|
| 634 |
+
// and will be explicitly omitted by Fantasy downstream.
|
| 635 |
+
topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
|
| 636 |
+
if topK != nil {
|
| 637 |
+
extraBody, hasExtraBody := mergedOptions["extra_body"].(map[string]any)
|
| 638 |
+
if !hasExtraBody {
|
| 639 |
+
extraBody = make(map[string]any)
|
| 640 |
+
mergedOptions["extra_body"] = extraBody
|
| 641 |
+
}
|
| 642 |
+
if _, hasTopK := extraBody["top_k"]; !hasTopK {
|
| 643 |
+
extraBody["top_k"] = *topK
|
| 644 |
+
}
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
_, hasReasoningEffort := mergedOptions["reasoning_effort"]
|
| 648 |
+
if !hasReasoningEffort && shouldSetEffort {
|
| 649 |
+
mergedOptions["reasoning_effort"] = reasoningEffort
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
parsed, err := openaicompat.ParseOptions(mergedOptions)
|
| 653 |
+
if err == nil {
|
| 654 |
+
options[openaicompat.Name] = parsed
|
| 655 |
+
} else {
|
| 656 |
+
if topK != nil {
|
| 657 |
+
slog.Warn(
|
| 658 |
+
"Failed to parse provider_options, falling back to top_k only",
|
| 659 |
+
"provider", providerCfg.ID,
|
| 660 |
+
"error", err,
|
| 661 |
+
)
|
| 662 |
+
|
| 663 |
+
fallbackMergeOptions := map[string]any{
|
| 664 |
+
"extra_body": map[string]any{"top_k": *topK},
|
| 665 |
+
}
|
| 666 |
+
parsed, err := openaicompat.ParseOptions(fallbackMergeOptions)
|
| 667 |
+
if err == nil {
|
| 668 |
+
options[openaicompat.Name] = parsed
|
| 669 |
+
} else {
|
| 670 |
+
slog.Warn(
|
| 671 |
+
"Failed to parse fallback provider options, this should never happen",
|
| 672 |
+
"provider", providerCfg.ID,
|
| 673 |
+
"error", err,
|
| 674 |
+
)
|
| 675 |
+
}
|
| 676 |
+
}
|
| 677 |
+
}
|
| 678 |
+
}
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
return options
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
func mergeCallOptions(model Model, cfg config.ProviderConfig) (fantasy.ProviderOptions, *float64, *float64, *int64, *float64, *float64) {
|
| 685 |
+
modelOptions := getProviderOptions(model, cfg)
|
| 686 |
+
temp := cmp.Or(model.ModelCfg.Temperature, model.CatwalkCfg.Options.Temperature)
|
| 687 |
+
topP := cmp.Or(model.ModelCfg.TopP, model.CatwalkCfg.Options.TopP)
|
| 688 |
+
topK := cmp.Or(model.ModelCfg.TopK, model.CatwalkCfg.Options.TopK)
|
| 689 |
+
freqPenalty := cmp.Or(model.ModelCfg.FrequencyPenalty, model.CatwalkCfg.Options.FrequencyPenalty)
|
| 690 |
+
presPenalty := cmp.Or(model.ModelCfg.PresencePenalty, model.CatwalkCfg.Options.PresencePenalty)
|
| 691 |
+
return modelOptions, temp, topP, topK, freqPenalty, presPenalty
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, agent config.Agent, isSubAgent bool) (SessionAgent, error) {
|
| 695 |
+
large, small, err := c.buildAgentModels(ctx, isSubAgent)
|
| 696 |
+
if err != nil {
|
| 697 |
+
return nil, err
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
largeProviderCfg, _ := c.cfg.Config().Providers.Get(large.ModelCfg.Provider)
|
| 701 |
+
result := NewSessionAgent(SessionAgentOptions{
|
| 702 |
+
LargeModel: large,
|
| 703 |
+
SmallModel: small,
|
| 704 |
+
SystemPromptPrefix: largeProviderCfg.SystemPromptPrefix,
|
| 705 |
+
SystemPrompt: "",
|
| 706 |
+
IsSubAgent: isSubAgent,
|
| 707 |
+
DisableAutoSummarize: c.cfg.Config().Options.DisableAutoSummarize,
|
| 708 |
+
IsYolo: c.permissions.SkipRequests(),
|
| 709 |
+
Sessions: c.sessions,
|
| 710 |
+
Messages: c.messages,
|
| 711 |
+
Tools: nil,
|
| 712 |
+
Notify: c.notify,
|
| 713 |
+
RunComplete: c.runComplete,
|
| 714 |
+
})
|
| 715 |
+
|
| 716 |
+
// The readiness goroutines below perform one-time setup — building the
|
| 717 |
+
// system prompt and the initial tool list — whose results the
|
| 718 |
+
// coordinator needs for its whole lifetime, so they must survive the
|
| 719 |
+
// caller's context being canceled. Several entry points build an agent
|
| 720 |
+
// from a short-lived HTTP request context: the server's
|
| 721 |
+
// InitAgent/UpdateAgent handlers, and UpdateModels -> buildTools ->
|
| 722 |
+
// agentTool -> buildAgent for the sub-agent. The tool-list build reads
|
| 723 |
+
// the MCP registry as it stands; servers still connecting are picked up
|
| 724 |
+
// by later runs. WithoutCancel drops cancellation while keeping context
|
| 725 |
+
// values; the work is local and always completes.
|
| 726 |
+
initCtx := context.WithoutCancel(ctx)
|
| 727 |
+
|
| 728 |
+
c.readyWg.Go(func() error {
|
| 729 |
+
systemPrompt, err := prompt.Build(initCtx, large.Model.Provider(), large.Model.Model(), c.cfg)
|
| 730 |
+
if err != nil {
|
| 731 |
+
return err
|
| 732 |
+
}
|
| 733 |
+
result.SetSystemPrompt(systemPrompt)
|
| 734 |
+
return nil
|
| 735 |
+
})
|
| 736 |
+
|
| 737 |
+
c.readyWg.Go(func() error {
|
| 738 |
+
tools, err := c.buildTools(initCtx, agent, isSubAgent)
|
| 739 |
+
if err != nil {
|
| 740 |
+
return err
|
| 741 |
+
}
|
| 742 |
+
result.SetTools(tools)
|
| 743 |
+
return nil
|
| 744 |
+
})
|
| 745 |
+
|
| 746 |
+
return result, nil
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
func (c *coordinator) buildTools(ctx context.Context, agent config.Agent, isSubAgent bool) ([]fantasy.AgentTool, error) {
|
| 750 |
+
var allTools []fantasy.AgentTool
|
| 751 |
+
if slices.Contains(agent.AllowedTools, AgentToolName) {
|
| 752 |
+
agentTool, err := c.agentTool(ctx)
|
| 753 |
+
if err != nil {
|
| 754 |
+
return nil, err
|
| 755 |
+
}
|
| 756 |
+
allTools = append(allTools, agentTool)
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
if slices.Contains(agent.AllowedTools, tools.AgenticFetchToolName) {
|
| 760 |
+
agenticFetchTool, err := c.agenticFetchTool(ctx, nil)
|
| 761 |
+
if err != nil {
|
| 762 |
+
return nil, err
|
| 763 |
+
}
|
| 764 |
+
allTools = append(allTools, agenticFetchTool)
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
// Get the model name for the agent
|
| 768 |
+
modelID := ""
|
| 769 |
+
if modelCfg, ok := c.cfg.Config().Models[agent.Model]; ok {
|
| 770 |
+
if model := c.cfg.Config().GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
|
| 771 |
+
modelID = model.ID
|
| 772 |
+
}
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
logFile := filepath.Join(c.cfg.Config().Options.DataDirectory, "logs", "crush.log")
|
| 776 |
+
|
| 777 |
+
// Build hook runner if PreToolUse hooks are configured.
|
| 778 |
+
var hookRunner *hooks.Runner
|
| 779 |
+
if preToolHooks := c.cfg.Config().Hooks[hooks.EventPreToolUse]; len(preToolHooks) > 0 {
|
| 780 |
+
hookRunner = hooks.NewRunner(preToolHooks, c.cfg.WorkingDir(), c.cfg.WorkingDir())
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
allTools = append(
|
| 784 |
+
allTools,
|
| 785 |
+
tools.NewBashTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Options.Attribution, modelID),
|
| 786 |
+
tools.NewCrushInfoTool(c.cfg, c.lspManager, c.allSkills, c.activeSkills, c.skillTracker),
|
| 787 |
+
tools.NewCrushLogsTool(logFile),
|
| 788 |
+
tools.NewJobOutputTool(),
|
| 789 |
+
tools.NewJobKillTool(),
|
| 790 |
+
tools.NewDownloadTool(c.permissions, c.cfg.WorkingDir(), nil),
|
| 791 |
+
tools.NewEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
|
| 792 |
+
tools.NewMultiEditTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
|
| 793 |
+
tools.NewFetchTool(c.permissions, c.cfg.WorkingDir(), nil),
|
| 794 |
+
tools.NewGlobTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Glob),
|
| 795 |
+
tools.NewGrepTool(c.cfg.WorkingDir(), c.cfg.Config().Tools.Grep),
|
| 796 |
+
tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls),
|
| 797 |
+
tools.NewSourcegraphTool(nil),
|
| 798 |
+
tools.NewTodosTool(c.sessions),
|
| 799 |
+
tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.skillTracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...),
|
| 800 |
+
tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
// Question tool is interactive-only and not available to sub-agents.
|
| 804 |
+
if !isSubAgent && c.interactive {
|
| 805 |
+
allTools = append(allTools, tools.NewQuestionTool(c.questions))
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
// Add LSP tools if user has configured LSPs or auto_lsp is enabled (nil or true).
|
| 809 |
+
if len(c.cfg.Config().LSP) > 0 || c.cfg.Config().Options.AutoLSP == nil || *c.cfg.Config().Options.AutoLSP {
|
| 810 |
+
allTools = append(
|
| 811 |
+
allTools,
|
| 812 |
+
tools.NewDiagnosticsTool(c.lspManager),
|
| 813 |
+
tools.NewReferencesTool(c.lspManager),
|
| 814 |
+
tools.NewLSPRestartTool(c.lspManager),
|
| 815 |
+
tools.NewSymbolsTool(c.lspManager),
|
| 816 |
+
tools.NewDefinitionTool(c.lspManager),
|
| 817 |
+
tools.NewCallHierarchyTool(c.lspManager),
|
| 818 |
+
tools.NewRenameTool(c.lspManager, c.permissions, c.history, c.filetracker),
|
| 819 |
+
tools.NewReplaceSymbolTool(c.lspManager, c.permissions, c.history, c.filetracker),
|
| 820 |
+
)
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
if len(c.cfg.Config().MCP) > 0 {
|
| 824 |
+
allTools = append(
|
| 825 |
+
allTools,
|
| 826 |
+
tools.NewListMCPResourcesTool(c.cfg, c.permissions),
|
| 827 |
+
tools.NewReadMCPResourceTool(c.cfg, c.permissions),
|
| 828 |
+
)
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
var filteredTools []fantasy.AgentTool
|
| 832 |
+
for _, tool := range allTools {
|
| 833 |
+
if slices.Contains(agent.AllowedTools, tool.Info().Name) {
|
| 834 |
+
filteredTools = append(filteredTools, tool)
|
| 835 |
+
}
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
for _, tool := range tools.GetMCPTools(c.permissions, c.cfg, c.cfg.WorkingDir()) {
|
| 839 |
+
if agent.AllowedMCP == nil {
|
| 840 |
+
// No MCP restrictions
|
| 841 |
+
filteredTools = append(filteredTools, tool)
|
| 842 |
+
continue
|
| 843 |
+
}
|
| 844 |
+
if len(agent.AllowedMCP) == 0 {
|
| 845 |
+
// No MCPs allowed
|
| 846 |
+
slog.Debug("No MCPs allowed", "tool", tool.Name(), "agent", agent.Name)
|
| 847 |
+
break
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
for mcp, tools := range agent.AllowedMCP {
|
| 851 |
+
if mcp != tool.MCP() {
|
| 852 |
+
continue
|
| 853 |
+
}
|
| 854 |
+
if len(tools) == 0 || slices.Contains(tools, tool.MCPToolName()) {
|
| 855 |
+
filteredTools = append(filteredTools, tool)
|
| 856 |
+
break
|
| 857 |
+
}
|
| 858 |
+
slog.Debug("MCP not allowed", "tool", tool.Name(), "agent", agent.Name)
|
| 859 |
+
}
|
| 860 |
+
}
|
| 861 |
+
slices.SortFunc(filteredTools, func(a, b fantasy.AgentTool) int {
|
| 862 |
+
return strings.Compare(a.Info().Name, b.Info().Name)
|
| 863 |
+
})
|
| 864 |
+
|
| 865 |
+
// Wrap tools with hook interception for the top-level agent only.
|
| 866 |
+
// Sub-agents (the `agent` task tool, `agentic_fetch`, etc.) run
|
| 867 |
+
// without hook interception to avoid firing the user's hook N times
|
| 868 |
+
// per delegated turn. The top-level invocation of the sub-agent tool
|
| 869 |
+
// itself is still wrapped from the coder's side.
|
| 870 |
+
filteredTools = wrapToolsWithHooks(filteredTools, hookRunner, isSubAgent)
|
| 871 |
+
|
| 872 |
+
return filteredTools, nil
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
// TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
|
| 876 |
+
func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
|
| 877 |
+
largeModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeLarge]
|
| 878 |
+
if !ok {
|
| 879 |
+
return Model{}, Model{}, errLargeModelNotSelected
|
| 880 |
+
}
|
| 881 |
+
smallModelCfg, ok := c.cfg.Config().Models[config.SelectedModelTypeSmall]
|
| 882 |
+
if !ok {
|
| 883 |
+
return Model{}, Model{}, errSmallModelNotSelected
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
largeProviderCfg, ok := c.cfg.Config().Providers.Get(largeModelCfg.Provider)
|
| 887 |
+
if !ok {
|
| 888 |
+
return Model{}, Model{}, errLargeModelProviderNotConfigured
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
largeProvider, err := c.buildProvider(largeProviderCfg, largeModelCfg, isSubAgent)
|
| 892 |
+
if err != nil {
|
| 893 |
+
return Model{}, Model{}, err
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
smallProviderCfg, ok := c.cfg.Config().Providers.Get(smallModelCfg.Provider)
|
| 897 |
+
if !ok {
|
| 898 |
+
return Model{}, Model{}, errSmallModelProviderNotConfigured
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
smallProvider, err := c.buildProvider(smallProviderCfg, smallModelCfg, true)
|
| 902 |
+
if err != nil {
|
| 903 |
+
return Model{}, Model{}, err
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
var largeCatwalkModel *catwalk.Model
|
| 907 |
+
var smallCatwalkModel *catwalk.Model
|
| 908 |
+
|
| 909 |
+
for _, m := range largeProviderCfg.Models {
|
| 910 |
+
if m.ID == largeModelCfg.Model {
|
| 911 |
+
largeCatwalkModel = &m
|
| 912 |
+
}
|
| 913 |
+
}
|
| 914 |
+
for _, m := range smallProviderCfg.Models {
|
| 915 |
+
if m.ID == smallModelCfg.Model {
|
| 916 |
+
smallCatwalkModel = &m
|
| 917 |
+
}
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
if largeCatwalkModel == nil {
|
| 921 |
+
return Model{}, Model{}, errLargeModelNotFound
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
+
if smallCatwalkModel == nil {
|
| 925 |
+
return Model{}, Model{}, errSmallModelNotFound
|
| 926 |
+
}
|
| 927 |
+
|
| 928 |
+
largeModelID := largeModelCfg.Model
|
| 929 |
+
smallModelID := smallModelCfg.Model
|
| 930 |
+
|
| 931 |
+
if largeModelCfg.Provider == openrouter.Name && isExactoSupported(largeModelID) {
|
| 932 |
+
largeModelID += ":exacto"
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
if smallModelCfg.Provider == openrouter.Name && isExactoSupported(smallModelID) {
|
| 936 |
+
smallModelID += ":exacto"
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
largeModel, err := largeProvider.LanguageModel(ctx, largeModelID)
|
| 940 |
+
if err != nil {
|
| 941 |
+
return Model{}, Model{}, err
|
| 942 |
+
}
|
| 943 |
+
smallModel, err := smallProvider.LanguageModel(ctx, smallModelID)
|
| 944 |
+
if err != nil {
|
| 945 |
+
return Model{}, Model{}, err
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
// Bound each request with the configured timeout so unreachable or hung
|
| 949 |
+
// providers fail instead of blocking a session forever. The wrapper is
|
| 950 |
+
// applied per request, so retries get a fresh budget each attempt.
|
| 951 |
+
requestTimeout := c.cfg.Config().Options.GetRequestTimeout()
|
| 952 |
+
largeModel = newRequestTimeoutModel(largeModel, requestTimeout)
|
| 953 |
+
smallModel = newRequestTimeoutModel(smallModel, requestTimeout)
|
| 954 |
+
|
| 955 |
+
return Model{
|
| 956 |
+
Model: largeModel,
|
| 957 |
+
CatwalkCfg: *largeCatwalkModel,
|
| 958 |
+
ModelCfg: largeModelCfg,
|
| 959 |
+
FlatRate: largeProviderCfg.FlatRate,
|
| 960 |
+
}, Model{
|
| 961 |
+
Model: smallModel,
|
| 962 |
+
CatwalkCfg: *smallCatwalkModel,
|
| 963 |
+
ModelCfg: smallModelCfg,
|
| 964 |
+
FlatRate: smallProviderCfg.FlatRate,
|
| 965 |
+
}, nil
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
func (c *coordinator) buildAnthropicProvider(baseURL, apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
|
| 969 |
+
var opts []anthropic.Option
|
| 970 |
+
|
| 971 |
+
switch {
|
| 972 |
+
case strings.HasPrefix(apiKey, "Bearer "):
|
| 973 |
+
// NOTE: Prevent the SDK from picking up the API key from env.
|
| 974 |
+
os.Setenv("ANTHROPIC_API_KEY", "")
|
| 975 |
+
headers["Authorization"] = apiKey
|
| 976 |
+
case providerID == string(catwalk.InferenceProviderMiniMax) || providerID == string(catwalk.InferenceProviderMiniMaxChina):
|
| 977 |
+
// NOTE: Prevent the SDK from picking up the API key from env.
|
| 978 |
+
os.Setenv("ANTHROPIC_API_KEY", "")
|
| 979 |
+
headers["Authorization"] = "Bearer " + apiKey
|
| 980 |
+
case apiKey != "":
|
| 981 |
+
// X-Api-Key header
|
| 982 |
+
opts = append(opts, anthropic.WithAPIKey(apiKey))
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
if len(headers) > 0 {
|
| 986 |
+
opts = append(opts, anthropic.WithHeaders(headers))
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
if baseURL != "" {
|
| 990 |
+
opts = append(opts, anthropic.WithBaseURL(baseURL))
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
if c.cfg.Config().Options.Debug {
|
| 994 |
+
httpClient := log.NewHTTPClient()
|
| 995 |
+
opts = append(opts, anthropic.WithHTTPClient(httpClient))
|
| 996 |
+
}
|
| 997 |
+
return anthropic.New(opts...)
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
func (c *coordinator) buildOpenaiProvider(baseURL, apiKey string, headers map[string]string, token *oauth.Token) (fantasy.Provider, error) {
|
| 1001 |
+
opts := []openai.Option{
|
| 1002 |
+
openai.WithAPIKey(apiKey),
|
| 1003 |
+
openai.WithUseResponsesAPI(),
|
| 1004 |
+
}
|
| 1005 |
+
var httpClient *http.Client
|
| 1006 |
+
if c.cfg.Config().Options.Debug {
|
| 1007 |
+
httpClient = log.NewHTTPClient()
|
| 1008 |
+
}
|
| 1009 |
+
if token != nil {
|
| 1010 |
+
// ChatGPT OAuth: requests go through the Codex backend, which
|
| 1011 |
+
// expects account headers and rejects some request fields, so
|
| 1012 |
+
// they pass through the Codex transport.
|
| 1013 |
+
if httpClient == nil {
|
| 1014 |
+
httpClient = &http.Client{}
|
| 1015 |
+
}
|
| 1016 |
+
httpClient.Transport = &openaioauth.Transport{
|
| 1017 |
+
Base: httpClient.Transport,
|
| 1018 |
+
Token: token,
|
| 1019 |
+
}
|
| 1020 |
+
}
|
| 1021 |
+
if httpClient != nil {
|
| 1022 |
+
opts = append(opts, openai.WithHTTPClient(httpClient))
|
| 1023 |
+
}
|
| 1024 |
+
if len(headers) > 0 {
|
| 1025 |
+
opts = append(opts, openai.WithHeaders(headers))
|
| 1026 |
+
}
|
| 1027 |
+
if baseURL != "" {
|
| 1028 |
+
opts = append(opts, openai.WithBaseURL(baseURL))
|
| 1029 |
+
}
|
| 1030 |
+
return openai.New(opts...)
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
func (c *coordinator) buildOpenrouterProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
|
| 1034 |
+
opts := []openrouter.Option{
|
| 1035 |
+
openrouter.WithAPIKey(apiKey),
|
| 1036 |
+
}
|
| 1037 |
+
if c.cfg.Config().Options.Debug {
|
| 1038 |
+
httpClient := log.NewHTTPClient()
|
| 1039 |
+
opts = append(opts, openrouter.WithHTTPClient(httpClient))
|
| 1040 |
+
}
|
| 1041 |
+
if len(headers) > 0 {
|
| 1042 |
+
opts = append(opts, openrouter.WithHeaders(headers))
|
| 1043 |
+
}
|
| 1044 |
+
return openrouter.New(opts...)
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
func (c *coordinator) buildVercelProvider(_, apiKey string, headers map[string]string) (fantasy.Provider, error) {
|
| 1048 |
+
opts := []vercel.Option{
|
| 1049 |
+
vercel.WithAPIKey(apiKey),
|
| 1050 |
+
}
|
| 1051 |
+
if c.cfg.Config().Options.Debug {
|
| 1052 |
+
httpClient := log.NewHTTPClient()
|
| 1053 |
+
opts = append(opts, vercel.WithHTTPClient(httpClient))
|
| 1054 |
+
}
|
| 1055 |
+
if len(headers) > 0 {
|
| 1056 |
+
opts = append(opts, vercel.WithHeaders(headers))
|
| 1057 |
+
}
|
| 1058 |
+
return vercel.New(opts...)
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
func (c *coordinator) buildOpenaiCompatProvider(baseURL, apiKey string, headers map[string]string, extraBody map[string]any, providerID string, isSubAgent bool) (fantasy.Provider, error) {
|
| 1062 |
+
opts := []openaicompat.Option{
|
| 1063 |
+
openaicompat.WithBaseURL(baseURL),
|
| 1064 |
+
openaicompat.WithAPIKey(apiKey),
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
// Set HTTP client based on provider and debug mode.
|
| 1068 |
+
var httpClient *http.Client
|
| 1069 |
+
switch providerID {
|
| 1070 |
+
case string(catwalk.InferenceProviderCopilot):
|
| 1071 |
+
opts = append(
|
| 1072 |
+
opts,
|
| 1073 |
+
openaicompat.WithUseResponsesAPI(),
|
| 1074 |
+
openaicompat.WithResponsesAPIFunc(func(modelID string) bool {
|
| 1075 |
+
return copilotResponsesModels[modelID]
|
| 1076 |
+
}),
|
| 1077 |
+
)
|
| 1078 |
+
httpClient = copilot.NewClient(isSubAgent, c.cfg.Config().Options.Debug)
|
| 1079 |
+
|
| 1080 |
+
case string(catwalk.InferenceProviderOpenCodeGo), string(catwalk.InferenceProviderOpenCodeZen):
|
| 1081 |
+
opts = append(
|
| 1082 |
+
opts,
|
| 1083 |
+
openaicompat.WithUseResponsesAPI(),
|
| 1084 |
+
openaicompat.WithResponsesAPIFunc(isOpenCodeResponsesModel),
|
| 1085 |
+
)
|
| 1086 |
+
|
| 1087 |
+
case hyper.Name:
|
| 1088 |
+
// Hyper may route requests through a Prism model; capture the
|
| 1089 |
+
// router headers so the UI can show which model answered.
|
| 1090 |
+
opts = append(
|
| 1091 |
+
opts,
|
| 1092 |
+
openaicompat.WithLanguageModelOptions(
|
| 1093 |
+
openai.WithLanguageModelHeaderFunc(hyper.HeaderFunc),
|
| 1094 |
+
),
|
| 1095 |
+
)
|
| 1096 |
+
}
|
| 1097 |
+
if httpClient == nil && c.cfg.Config().Options.Debug {
|
| 1098 |
+
httpClient = log.NewHTTPClient()
|
| 1099 |
+
}
|
| 1100 |
+
if httpClient != nil {
|
| 1101 |
+
opts = append(opts, openaicompat.WithHTTPClient(httpClient))
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
if len(headers) > 0 {
|
| 1105 |
+
opts = append(opts, openaicompat.WithHeaders(headers))
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
for extraKey, extraValue := range extraBody {
|
| 1109 |
+
opts = append(opts, openaicompat.WithSDKOptions(openaisdk.WithJSONSet(extraKey, extraValue)))
|
| 1110 |
+
}
|
| 1111 |
+
|
| 1112 |
+
return openaicompat.New(opts...)
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
func (c *coordinator) buildAzureProvider(baseURL, apiKey string, headers map[string]string, options map[string]string) (fantasy.Provider, error) {
|
| 1116 |
+
opts := []azure.Option{
|
| 1117 |
+
azure.WithBaseURL(baseURL),
|
| 1118 |
+
azure.WithAPIKey(apiKey),
|
| 1119 |
+
azure.WithUseResponsesAPI(),
|
| 1120 |
+
}
|
| 1121 |
+
if c.cfg.Config().Options.Debug {
|
| 1122 |
+
httpClient := log.NewHTTPClient()
|
| 1123 |
+
opts = append(opts, azure.WithHTTPClient(httpClient))
|
| 1124 |
+
}
|
| 1125 |
+
if options == nil {
|
| 1126 |
+
options = make(map[string]string)
|
| 1127 |
+
}
|
| 1128 |
+
if apiVersion, ok := options["apiVersion"]; ok {
|
| 1129 |
+
opts = append(opts, azure.WithAPIVersion(apiVersion))
|
| 1130 |
+
}
|
| 1131 |
+
if len(headers) > 0 {
|
| 1132 |
+
opts = append(opts, azure.WithHeaders(headers))
|
| 1133 |
+
}
|
| 1134 |
+
|
| 1135 |
+
return azure.New(opts...)
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
func (c *coordinator) buildBedrockProvider(apiKey string, headers map[string]string, providerID string) (fantasy.Provider, error) {
|
| 1139 |
+
var opts []bedrock.Option
|
| 1140 |
+
if c.cfg.Config().Options.Debug {
|
| 1141 |
+
httpClient := log.NewHTTPClient()
|
| 1142 |
+
opts = append(opts, bedrock.WithHTTPClient(httpClient))
|
| 1143 |
+
}
|
| 1144 |
+
if len(headers) > 0 {
|
| 1145 |
+
opts = append(opts, bedrock.WithHeaders(headers))
|
| 1146 |
+
}
|
| 1147 |
+
|
| 1148 |
+
switch {
|
| 1149 |
+
case apiKey != "":
|
| 1150 |
+
opts = append(opts, bedrock.WithAPIKey(apiKey))
|
| 1151 |
+
case os.Getenv("AWS_BEARER_TOKEN_BEDROCK") != "":
|
| 1152 |
+
opts = append(opts, bedrock.WithAPIKey(os.Getenv("AWS_BEARER_TOKEN_BEDROCK")))
|
| 1153 |
+
default:
|
| 1154 |
+
// Skip, let the SDK do authentication.
|
| 1155 |
+
}
|
| 1156 |
+
|
| 1157 |
+
switch providerID {
|
| 1158 |
+
case string(catwalk.InferenceProviderBedrockEurope):
|
| 1159 |
+
opts = append(opts, bedrock.WithRegion("eu-west-1"))
|
| 1160 |
+
default:
|
| 1161 |
+
opts = append(opts, bedrock.WithRegion("us-east-1"))
|
| 1162 |
+
}
|
| 1163 |
+
|
| 1164 |
+
return bedrock.New(opts...)
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
func (c *coordinator) buildGoogleProvider(baseURL, apiKey string, headers map[string]string) (fantasy.Provider, error) {
|
| 1168 |
+
opts := []google.Option{
|
| 1169 |
+
google.WithBaseURL(baseURL),
|
| 1170 |
+
google.WithGeminiAPIKey(apiKey),
|
| 1171 |
+
}
|
| 1172 |
+
if c.cfg.Config().Options.Debug {
|
| 1173 |
+
httpClient := log.NewHTTPClient()
|
| 1174 |
+
opts = append(opts, google.WithHTTPClient(httpClient))
|
| 1175 |
+
}
|
| 1176 |
+
if len(headers) > 0 {
|
| 1177 |
+
opts = append(opts, google.WithHeaders(headers))
|
| 1178 |
+
}
|
| 1179 |
+
return google.New(opts...)
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
func (c *coordinator) buildGoogleVertexProvider(headers map[string]string, options map[string]string) (fantasy.Provider, error) {
|
| 1183 |
+
opts := []google.Option{}
|
| 1184 |
+
if c.cfg.Config().Options.Debug {
|
| 1185 |
+
httpClient := log.NewHTTPClient()
|
| 1186 |
+
opts = append(opts, google.WithHTTPClient(httpClient))
|
| 1187 |
+
}
|
| 1188 |
+
if len(headers) > 0 {
|
| 1189 |
+
opts = append(opts, google.WithHeaders(headers))
|
| 1190 |
+
}
|
| 1191 |
+
|
| 1192 |
+
project := options["project"]
|
| 1193 |
+
location := options["location"]
|
| 1194 |
+
|
| 1195 |
+
opts = append(opts, google.WithVertex(project, location))
|
| 1196 |
+
|
| 1197 |
+
return google.New(opts...)
|
| 1198 |
+
}
|
| 1199 |
+
|
| 1200 |
+
func (c *coordinator) isAnthropicThinking(model config.SelectedModel) bool {
|
| 1201 |
+
if model.Think {
|
| 1202 |
+
return true
|
| 1203 |
+
}
|
| 1204 |
+
opts, err := anthropic.ParseOptions(model.ProviderOptions)
|
| 1205 |
+
return err == nil && opts.Thinking != nil
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model config.SelectedModel, isSubAgent bool) (fantasy.Provider, error) {
|
| 1209 |
+
headers := maps.Clone(providerCfg.ExtraHeaders)
|
| 1210 |
+
if headers == nil {
|
| 1211 |
+
headers = make(map[string]string)
|
| 1212 |
+
}
|
| 1213 |
+
|
| 1214 |
+
// handle special headers for anthropic
|
| 1215 |
+
if providerCfg.Type == anthropic.Name && c.isAnthropicThinking(model) {
|
| 1216 |
+
if v, ok := headers["anthropic-beta"]; ok {
|
| 1217 |
+
headers["anthropic-beta"] = v + ",interleaved-thinking-2025-05-14"
|
| 1218 |
+
} else {
|
| 1219 |
+
headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
|
| 1220 |
+
}
|
| 1221 |
+
}
|
| 1222 |
+
|
| 1223 |
+
apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
|
| 1224 |
+
baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
|
| 1225 |
+
|
| 1226 |
+
switch providerCfg.ID {
|
| 1227 |
+
case string(catwalk.InferenceProviderOpenCodeGo), string(catwalk.InferenceProviderOpenCodeZen):
|
| 1228 |
+
if isOpenCodeMessagesModel(providerCfg.ID, model.Model) {
|
| 1229 |
+
baseURL = strings.TrimSuffix(baseURL, "/v1")
|
| 1230 |
+
return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
|
| 1231 |
+
}
|
| 1232 |
+
}
|
| 1233 |
+
|
| 1234 |
+
switch providerCfg.Type {
|
| 1235 |
+
case openai.Name:
|
| 1236 |
+
// A ChatGPT login is the provider's single credential: every
|
| 1237 |
+
// request goes through the Codex backend with the OAuth token.
|
| 1238 |
+
token := providerCfg.OAuthToken
|
| 1239 |
+
if token != nil {
|
| 1240 |
+
baseURL = openaioauth.CodexBaseURL
|
| 1241 |
+
apiKey = token.AccessToken
|
| 1242 |
+
headers["originator"] = "crush"
|
| 1243 |
+
if token.AccountID != "" {
|
| 1244 |
+
headers["chatgpt-account-id"] = token.AccountID
|
| 1245 |
+
}
|
| 1246 |
+
}
|
| 1247 |
+
return c.buildOpenaiProvider(baseURL, apiKey, headers, token)
|
| 1248 |
+
case anthropic.Name:
|
| 1249 |
+
return c.buildAnthropicProvider(baseURL, apiKey, headers, providerCfg.ID)
|
| 1250 |
+
case openrouter.Name:
|
| 1251 |
+
return c.buildOpenrouterProvider(baseURL, apiKey, headers)
|
| 1252 |
+
case vercel.Name:
|
| 1253 |
+
return c.buildVercelProvider(baseURL, apiKey, headers)
|
| 1254 |
+
case azure.Name:
|
| 1255 |
+
return c.buildAzureProvider(baseURL, apiKey, headers, providerCfg.ExtraParams)
|
| 1256 |
+
case bedrock.Name:
|
| 1257 |
+
return c.buildBedrockProvider(apiKey, headers, providerCfg.ID)
|
| 1258 |
+
case google.Name:
|
| 1259 |
+
return c.buildGoogleProvider(baseURL, apiKey, headers)
|
| 1260 |
+
case "google-vertex":
|
| 1261 |
+
return c.buildGoogleVertexProvider(headers, providerCfg.ExtraParams)
|
| 1262 |
+
case openaicompat.Name, hyper.Name:
|
| 1263 |
+
switch providerCfg.ID {
|
| 1264 |
+
case hyper.Name:
|
| 1265 |
+
baseURL = hyper.BaseURL() + "/v1"
|
| 1266 |
+
headers["x-crush-id"] = event.GetID()
|
| 1267 |
+
case string(catwalk.InferenceProviderZAI):
|
| 1268 |
+
if providerCfg.ExtraBody == nil {
|
| 1269 |
+
providerCfg.ExtraBody = map[string]any{}
|
| 1270 |
+
}
|
| 1271 |
+
providerCfg.ExtraBody["tool_stream"] = true
|
| 1272 |
+
}
|
| 1273 |
+
return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
|
| 1274 |
+
default:
|
| 1275 |
+
// Known custom providers (litellm, llamacpp, lmstudio, ollama,
|
| 1276 |
+
// omlx) are openai-compat under the hood.
|
| 1277 |
+
if discover.IsKnownCustomProvider(string(providerCfg.Type)) {
|
| 1278 |
+
return c.buildOpenaiCompatProvider(baseURL, apiKey, headers, providerCfg.ExtraBody, providerCfg.ID, isSubAgent)
|
| 1279 |
+
}
|
| 1280 |
+
return nil, fmt.Errorf("provider type not supported: %q", providerCfg.Type)
|
| 1281 |
+
}
|
| 1282 |
+
}
|
| 1283 |
+
|
| 1284 |
+
func isExactoSupported(modelID string) bool {
|
| 1285 |
+
supportedModels := []string{
|
| 1286 |
+
"moonshotai/kimi-k2-0905",
|
| 1287 |
+
"deepseek/deepseek-v3.1-terminus",
|
| 1288 |
+
"z-ai/glm-4.6",
|
| 1289 |
+
"openai/gpt-oss-120b",
|
| 1290 |
+
"qwen/qwen3-coder",
|
| 1291 |
+
}
|
| 1292 |
+
return slices.Contains(supportedModels, modelID)
|
| 1293 |
+
}
|
| 1294 |
+
|
| 1295 |
+
// BeginAccepted reserves an accept slot for sessionID on the active
|
| 1296 |
+
// agent and returns the ownership handle. It is the fire-and-forget
|
| 1297 |
+
// dispatch path's only way to mark a run as accepted-but-not-yet-active
|
| 1298 |
+
// so a cancel arriving before the run registers in activeRequests is not
|
| 1299 |
+
// lost.
|
| 1300 |
+
func (c *coordinator) BeginAccepted(sessionID string) *AcceptedRun {
|
| 1301 |
+
return c.currentAgent.BeginAccepted(sessionID)
|
| 1302 |
+
}
|
| 1303 |
+
|
| 1304 |
+
func (c *coordinator) Cancel(sessionID string) {
|
| 1305 |
+
c.currentAgent.Cancel(sessionID)
|
| 1306 |
+
}
|
| 1307 |
+
|
| 1308 |
+
func (c *coordinator) CancelAll() {
|
| 1309 |
+
c.currentAgent.CancelAll()
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
func (c *coordinator) ClearQueue(sessionID string) {
|
| 1313 |
+
c.currentAgent.ClearQueue(sessionID)
|
| 1314 |
+
}
|
| 1315 |
+
|
| 1316 |
+
func (c *coordinator) IsBusy() bool {
|
| 1317 |
+
return c.currentAgent.IsBusy()
|
| 1318 |
+
}
|
| 1319 |
+
|
| 1320 |
+
func (c *coordinator) IsSessionBusy(sessionID string) bool {
|
| 1321 |
+
return c.currentAgent.IsSessionBusy(sessionID)
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
func (c *coordinator) Model() Model {
|
| 1325 |
+
return c.currentAgent.Model()
|
| 1326 |
+
}
|
| 1327 |
+
|
| 1328 |
+
func (c *coordinator) UpdateModels(ctx context.Context) error {
|
| 1329 |
+
// A ChatGPT login without its model catalog — the fetch at login
|
| 1330 |
+
// failed, or the credentials predate it — would leave the models
|
| 1331 |
+
// dialog's ChatGPT section empty. Fill it in lazily; the guard makes
|
| 1332 |
+
// this a no-op once the catalog exists.
|
| 1333 |
+
c.cfg.RefetchOpenAIChatGPTModels(ctx)
|
| 1334 |
+
|
| 1335 |
+
// build the models again so we make sure we get the latest config
|
| 1336 |
+
large, small, err := c.buildAgentModels(ctx, false)
|
| 1337 |
+
if err != nil {
|
| 1338 |
+
return err
|
| 1339 |
+
}
|
| 1340 |
+
c.currentAgent.SetModels(large, small)
|
| 1341 |
+
|
| 1342 |
+
agentCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
|
| 1343 |
+
if !ok {
|
| 1344 |
+
return errCoderAgentNotConfigured
|
| 1345 |
+
}
|
| 1346 |
+
|
| 1347 |
+
tools, err := c.buildTools(ctx, agentCfg, false)
|
| 1348 |
+
if err != nil {
|
| 1349 |
+
return err
|
| 1350 |
+
}
|
| 1351 |
+
c.currentAgent.SetTools(tools)
|
| 1352 |
+
return nil
|
| 1353 |
+
}
|
| 1354 |
+
|
| 1355 |
+
func (c *coordinator) QueuedPrompts(sessionID string) int {
|
| 1356 |
+
return c.currentAgent.QueuedPrompts(sessionID)
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
func (c *coordinator) QueuedPromptsList(sessionID string) []string {
|
| 1360 |
+
return c.currentAgent.QueuedPromptsList(sessionID)
|
| 1361 |
+
}
|
| 1362 |
+
|
| 1363 |
+
func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
|
| 1364 |
+
providerCfg, ok := c.cfg.Config().Providers.Get(c.currentAgent.Model().ModelCfg.Provider)
|
| 1365 |
+
if !ok {
|
| 1366 |
+
return errModelProviderNotConfigured
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
if err := c.refreshTokenIfExpired(ctx, providerCfg); err != nil {
|
| 1370 |
+
slog.Error("Failed to refresh OAuth2 token before summarize. Proceeding with existing token.", "error", err)
|
| 1371 |
+
}
|
| 1372 |
+
|
| 1373 |
+
// Auth failures during summarize flow through fantasy's OnAuthRefresh,
|
| 1374 |
+
// the same path used by regular turns.
|
| 1375 |
+
return c.currentAgent.Summarize(ctx, sessionID, getProviderOptions(c.currentAgent.Model(), providerCfg), c.makeAuthRefreshCallback(providerCfg))
|
| 1376 |
+
}
|
| 1377 |
+
|
| 1378 |
+
// GenerateTitle generates a session title using the current agent.
|
| 1379 |
+
func (c *coordinator) GenerateTitle(ctx context.Context, sessionID, prompt string) {
|
| 1380 |
+
if c.currentAgent == nil {
|
| 1381 |
+
return
|
| 1382 |
+
}
|
| 1383 |
+
c.currentAgent.GenerateTitle(ctx, sessionID, prompt)
|
| 1384 |
+
}
|
| 1385 |
+
|
| 1386 |
+
// refreshTokenIfExpired proactively refreshes the OAuth token if it has expired.
|
| 1387 |
+
func (c *coordinator) refreshTokenIfExpired(ctx context.Context, providerCfg config.ProviderConfig) error {
|
| 1388 |
+
if providerCfg.OAuthToken == nil || !providerCfg.OAuthToken.IsExpired() {
|
| 1389 |
+
return nil
|
| 1390 |
+
}
|
| 1391 |
+
slog.Debug("Token needs to be refreshed", "provider", providerCfg.ID)
|
| 1392 |
+
return c.refreshOAuth2Token(ctx, providerCfg)
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
// retryAfterUnauthorized attempts to refresh credentials after an auth error
|
| 1396 |
+
// and returns nil if the request should be retried. For OAuth providers whose
|
| 1397 |
+
// refresh token is revoked, and for Bedrock providers whose AWS SSO session
|
| 1398 |
+
// has expired, it triggers interactive re-authentication and blocks until the
|
| 1399 |
+
// user completes it (or the context is cancelled).
|
| 1400 |
+
func (c *coordinator) retryAfterUnauthorized(ctx context.Context, providerCfg config.ProviderConfig) error {
|
| 1401 |
+
switch {
|
| 1402 |
+
case providerCfg.OAuthToken != nil:
|
| 1403 |
+
slog.Debug("Received 401. Refreshing token and retrying", "provider", providerCfg.ID)
|
| 1404 |
+
if err := c.refreshOAuth2Token(ctx, providerCfg); err != nil {
|
| 1405 |
+
// If the refresh token was revoked, trigger interactive
|
| 1406 |
+
// re-auth and wait for the user to complete it.
|
| 1407 |
+
var exchangeErr *oauth.TokenExchangeError
|
| 1408 |
+
if c.notify != nil && errors.As(err, &exchangeErr) && exchangeErr.IsRefreshTokenRevoked() {
|
| 1409 |
+
slog.Info("Refresh token revoked, waiting for re-authentication", "provider", providerCfg.ID)
|
| 1410 |
+
c.notify.Publish(pubsub.CreatedEvent, notify.Notification{
|
| 1411 |
+
Type: notify.TypeReAuthenticate,
|
| 1412 |
+
ProviderID: providerCfg.ID,
|
| 1413 |
+
})
|
| 1414 |
+
return c.waitForInteractiveReauth(ctx, providerCfg.ID)
|
| 1415 |
+
}
|
| 1416 |
+
return err
|
| 1417 |
+
}
|
| 1418 |
+
return nil
|
| 1419 |
+
case providerCfg.AWSAuthRefresh != "":
|
| 1420 |
+
return c.refreshAWSCredentials(ctx, providerCfg)
|
| 1421 |
+
case strings.Contains(providerCfg.APIKeyTemplate, "$"):
|
| 1422 |
+
slog.Debug("Received 401. Refreshing API Key template and retrying", "provider", providerCfg.ID)
|
| 1423 |
+
return c.refreshApiKeyTemplate(ctx, providerCfg)
|
| 1424 |
+
default:
|
| 1425 |
+
return nil
|
| 1426 |
+
}
|
| 1427 |
+
}
|
| 1428 |
+
|
| 1429 |
+
// errNoInteractiveAuth is returned by an OnAuthRefresh callback when a
|
| 1430 |
+
// provider needs interactive re-authentication but no notifier is available
|
| 1431 |
+
// to drive it (e.g. headless runs). Returning it surfaces the original auth
|
| 1432 |
+
// error rather than retrying.
|
| 1433 |
+
var errNoInteractiveAuth = errors.New("interactive authentication unavailable")
|
| 1434 |
+
|
| 1435 |
+
// waitForInteractiveReauth blocks until interactive re-authentication for the
|
| 1436 |
+
// provider completes (signalled via SignalAuthComplete) or the context is
|
| 1437 |
+
// cancelled, then rebuilds models so the next attempt picks up fresh
|
| 1438 |
+
// credentials. Returns nil when the caller should retry.
|
| 1439 |
+
func (c *coordinator) waitForInteractiveReauth(ctx context.Context, providerID string) error {
|
| 1440 |
+
// Use a detached context with a generous timeout so the wait survives
|
| 1441 |
+
// agent run cancellation. The user needs time to complete browser-based
|
| 1442 |
+
// authentication.
|
| 1443 |
+
waitCtx, waitCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
| 1444 |
+
defer waitCancel()
|
| 1445 |
+
slog.Info("Blocking on WaitForTokenChange", "provider", providerID)
|
| 1446 |
+
if waitErr := c.cfg.WaitForTokenChange(waitCtx, providerID); waitErr != nil {
|
| 1447 |
+
slog.Info("WaitForTokenChange returned error", "provider", providerID, "error", waitErr)
|
| 1448 |
+
return waitErr
|
| 1449 |
+
}
|
| 1450 |
+
// If the original context was cancelled during the wait, fantasy's retry
|
| 1451 |
+
// would fail immediately, so surface the cancellation instead.
|
| 1452 |
+
if ctx.Err() != nil {
|
| 1453 |
+
slog.Warn("Original context cancelled during auth wait, cannot retry",
|
| 1454 |
+
"provider", providerID, "ctx_err", ctx.Err())
|
| 1455 |
+
return ctx.Err()
|
| 1456 |
+
}
|
| 1457 |
+
// Rebuild models so ModelProvider picks up the fresh credentials.
|
| 1458 |
+
if updateErr := c.UpdateModels(waitCtx); updateErr != nil {
|
| 1459 |
+
slog.Error("Failed to update models after re-authentication", "error", updateErr)
|
| 1460 |
+
return updateErr
|
| 1461 |
+
}
|
| 1462 |
+
slog.Info("Models updated, returning nil to retry", "provider", providerID)
|
| 1463 |
+
return nil
|
| 1464 |
+
}
|
| 1465 |
+
|
| 1466 |
+
// isUnauthorized reports whether err is an HTTP 401 from a provider.
|
| 1467 |
+
func isUnauthorized(err error) bool {
|
| 1468 |
+
var providerErr *fantasy.ProviderError
|
| 1469 |
+
return errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized
|
| 1470 |
+
}
|
| 1471 |
+
|
| 1472 |
+
// makeAuthRefreshCallback returns an OnAuthRefresh callback for fantasy that
|
| 1473 |
+
// delegates to the coordinator's existing credential refresh logic. Returns
|
| 1474 |
+
// nil if no refresh mechanism is configured for the provider.
|
| 1475 |
+
func (c *coordinator) makeAuthRefreshCallback(providerCfg config.ProviderConfig) func(context.Context, *fantasy.ProviderError) error {
|
| 1476 |
+
if providerCfg.OAuthToken == nil &&
|
| 1477 |
+
!strings.Contains(providerCfg.APIKeyTemplate, "$") &&
|
| 1478 |
+
providerCfg.AWSAuthRefresh == "" {
|
| 1479 |
+
return nil
|
| 1480 |
+
}
|
| 1481 |
+
return func(ctx context.Context, _ *fantasy.ProviderError) error {
|
| 1482 |
+
return c.retryAfterUnauthorized(ctx, providerCfg)
|
| 1483 |
+
}
|
| 1484 |
+
}
|
| 1485 |
+
|
| 1486 |
+
func (c *coordinator) refreshOAuth2Token(ctx context.Context, providerCfg config.ProviderConfig) error {
|
| 1487 |
+
if err := c.cfg.RefreshOAuthToken(ctx, config.ScopeGlobal, providerCfg.ID); err != nil {
|
| 1488 |
+
slog.Error("Failed to refresh OAuth token after 401 error", "provider", providerCfg.ID, "error", err)
|
| 1489 |
+
return err
|
| 1490 |
+
}
|
| 1491 |
+
if err := c.UpdateModels(ctx); err != nil {
|
| 1492 |
+
return err
|
| 1493 |
+
}
|
| 1494 |
+
return nil
|
| 1495 |
+
}
|
| 1496 |
+
|
| 1497 |
+
func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg config.ProviderConfig) error {
|
| 1498 |
+
newAPIKey, err := c.cfg.Resolve(providerCfg.APIKeyTemplate)
|
| 1499 |
+
if err != nil {
|
| 1500 |
+
slog.Error("Failed to re-resolve API key after 401 error", "provider", providerCfg.ID, "error", err)
|
| 1501 |
+
return err
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
+
providerCfg.APIKey = newAPIKey
|
| 1505 |
+
c.cfg.Config().Providers.Set(providerCfg.ID, providerCfg)
|
| 1506 |
+
|
| 1507 |
+
if err := c.UpdateModels(ctx); err != nil {
|
| 1508 |
+
return err
|
| 1509 |
+
}
|
| 1510 |
+
return nil
|
| 1511 |
+
}
|
| 1512 |
+
|
| 1513 |
+
// subAgentParams holds the parameters for running a sub-agent.
|
| 1514 |
+
type subAgentParams struct {
|
| 1515 |
+
Agent SessionAgent
|
| 1516 |
+
SessionID string
|
| 1517 |
+
AgentMessageID string
|
| 1518 |
+
ToolCallID string
|
| 1519 |
+
Prompt string
|
| 1520 |
+
SessionTitle string
|
| 1521 |
+
// SessionSetup is an optional callback invoked after session creation
|
| 1522 |
+
// but before agent execution, for custom session configuration.
|
| 1523 |
+
SessionSetup func(sessionID string)
|
| 1524 |
+
}
|
| 1525 |
+
|
| 1526 |
+
// callTopK returns topK for use on fantasy.Call.TopK, suppressing it for
|
| 1527 |
+
// known custom providers: getProviderOptions already carries top_k for
|
| 1528 |
+
// them via extra_body, and passing it here too makes Fantasy emit a
|
| 1529 |
+
// spurious "top_k unsupported" warning for every turn.
|
| 1530 |
+
func callTopK(providerCfg config.ProviderConfig, topK *int64) *int64 {
|
| 1531 |
+
if discover.IsKnownCustomProvider(string(providerCfg.Type)) {
|
| 1532 |
+
return nil
|
| 1533 |
+
}
|
| 1534 |
+
return topK
|
| 1535 |
+
}
|
| 1536 |
+
|
| 1537 |
+
// runSubAgent runs a sub-agent and handles session management and cost accumulation.
|
| 1538 |
+
// It creates a sub-session, runs the agent with the given prompt, and propagates
|
| 1539 |
+
// the cost to the parent session.
|
| 1540 |
+
func (c *coordinator) runSubAgent(ctx context.Context, params subAgentParams) (fantasy.ToolResponse, error) {
|
| 1541 |
+
// Create sub-session
|
| 1542 |
+
agentToolSessionID := c.sessions.CreateAgentToolSessionID(params.AgentMessageID, params.ToolCallID)
|
| 1543 |
+
session, err := c.sessions.CreateTaskSession(ctx, agentToolSessionID, params.SessionID, params.SessionTitle)
|
| 1544 |
+
if err != nil {
|
| 1545 |
+
return fantasy.ToolResponse{}, fmt.Errorf("create session: %w", err)
|
| 1546 |
+
}
|
| 1547 |
+
|
| 1548 |
+
// Call session setup function if provided
|
| 1549 |
+
if params.SessionSetup != nil {
|
| 1550 |
+
params.SessionSetup(session.ID)
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
// Get model configuration
|
| 1554 |
+
model := params.Agent.Model()
|
| 1555 |
+
maxTokens := model.CatwalkCfg.DefaultMaxTokens
|
| 1556 |
+
if model.ModelCfg.MaxTokens != 0 {
|
| 1557 |
+
maxTokens = model.ModelCfg.MaxTokens
|
| 1558 |
+
}
|
| 1559 |
+
|
| 1560 |
+
providerCfg, ok := c.cfg.Config().Providers.Get(model.ModelCfg.Provider)
|
| 1561 |
+
if !ok {
|
| 1562 |
+
return fantasy.ToolResponse{}, errModelProviderNotConfigured
|
| 1563 |
+
}
|
| 1564 |
+
|
| 1565 |
+
// Run the agent
|
| 1566 |
+
run := func() (*fantasy.AgentResult, error) {
|
| 1567 |
+
return params.Agent.Run(ctx, SessionAgentCall{
|
| 1568 |
+
SessionID: session.ID,
|
| 1569 |
+
Prompt: params.Prompt,
|
| 1570 |
+
MaxOutputTokens: maxTokens,
|
| 1571 |
+
ProviderOptions: getProviderOptions(model, providerCfg),
|
| 1572 |
+
Temperature: model.ModelCfg.Temperature,
|
| 1573 |
+
TopP: model.ModelCfg.TopP,
|
| 1574 |
+
TopK: callTopK(providerCfg, model.ModelCfg.TopK),
|
| 1575 |
+
FrequencyPenalty: model.ModelCfg.FrequencyPenalty,
|
| 1576 |
+
PresencePenalty: model.ModelCfg.PresencePenalty,
|
| 1577 |
+
NonInteractive: true,
|
| 1578 |
+
OnAuthRefresh: c.makeAuthRefreshCallback(providerCfg),
|
| 1579 |
+
})
|
| 1580 |
+
}
|
| 1581 |
+
result, err := run()
|
| 1582 |
+
// Notify only if still unauthorized after retry. AWS SSO is handled
|
| 1583 |
+
// transparently inside OnAuthRefresh, so it needs no post-run notice.
|
| 1584 |
+
if err != nil && isUnauthorized(err) && c.notify != nil && model.ModelCfg.Provider == hyper.Name {
|
| 1585 |
+
c.notify.Publish(pubsub.CreatedEvent, notify.Notification{
|
| 1586 |
+
Type: notify.TypeReAuthenticate,
|
| 1587 |
+
ProviderID: model.ModelCfg.Provider,
|
| 1588 |
+
})
|
| 1589 |
+
}
|
| 1590 |
+
if err != nil {
|
| 1591 |
+
return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to generate response: %s", err)), nil
|
| 1592 |
+
}
|
| 1593 |
+
|
| 1594 |
+
// Update parent session cost on a best-effort basis. A failure here must
|
| 1595 |
+
// not discard the sub-agent output that was already produced.
|
| 1596 |
+
if err := c.updateParentSessionCost(ctx, session.ID, params.SessionID); err != nil {
|
| 1597 |
+
slog.Warn(
|
| 1598 |
+
"Failed to update parent session cost",
|
| 1599 |
+
"child_session", session.ID,
|
| 1600 |
+
"parent_session", params.SessionID,
|
| 1601 |
+
"error", err,
|
| 1602 |
+
)
|
| 1603 |
+
}
|
| 1604 |
+
|
| 1605 |
+
output := subAgentOutput(result)
|
| 1606 |
+
if output == "" {
|
| 1607 |
+
return fantasy.NewTextErrorResponse("Sub-agent completed but produced no text output."), nil
|
| 1608 |
+
}
|
| 1609 |
+
return fantasy.NewTextResponse(output), nil
|
| 1610 |
+
}
|
| 1611 |
+
|
| 1612 |
+
func subAgentOutput(result *fantasy.AgentResult) string {
|
| 1613 |
+
if result == nil {
|
| 1614 |
+
return ""
|
| 1615 |
+
}
|
| 1616 |
+
return result.Response.Content.Text()
|
| 1617 |
+
}
|
| 1618 |
+
|
| 1619 |
+
// updateParentSessionCost accumulates the cost from a child session to its parent session.
|
| 1620 |
+
func (c *coordinator) updateParentSessionCost(ctx context.Context, childSessionID, parentSessionID string) error {
|
| 1621 |
+
childSession, err := c.sessions.Get(ctx, childSessionID)
|
| 1622 |
+
if err != nil {
|
| 1623 |
+
return fmt.Errorf("get child session: %w", err)
|
| 1624 |
+
}
|
| 1625 |
+
|
| 1626 |
+
parentSession, err := c.sessions.Get(ctx, parentSessionID)
|
| 1627 |
+
if err != nil {
|
| 1628 |
+
return fmt.Errorf("get parent session: %w", err)
|
| 1629 |
+
}
|
| 1630 |
+
|
| 1631 |
+
parentSession.Cost += childSession.Cost
|
| 1632 |
+
|
| 1633 |
+
if _, err := c.sessions.Save(ctx, parentSession); err != nil {
|
| 1634 |
+
return fmt.Errorf("save parent session: %w", err)
|
| 1635 |
+
}
|
| 1636 |
+
|
| 1637 |
+
return nil
|
| 1638 |
+
}
|
| 1639 |
+
|
| 1640 |
+
// discoverSkills is a thin fallback wrapper used only when no
|
| 1641 |
+
// skills.Manager has been threaded through to the coordinator. All
|
| 1642 |
+
// production call sites (backend.CreateWorkspace, setupLocalWorkspace)
|
| 1643 |
+
// run discovery in advance and pass the results via the manager;
|
| 1644 |
+
// reaching this path means a caller bypassed both. It deliberately does
|
| 1645 |
+
// NOT publish to the package-level broker — there are no subscribers in
|
| 1646 |
+
// that case, so doing so would be misleading without delivering the
|
| 1647 |
+
// snapshot anywhere useful.
|
| 1648 |
+
func discoverSkills(cfg *config.ConfigStore) (allSkills, activeSkills []*skills.Skill) {
|
| 1649 |
+
opts := cfg.Config().Options
|
| 1650 |
+
var paths, disabled []string
|
| 1651 |
+
if opts != nil {
|
| 1652 |
+
paths = opts.SkillsPaths
|
| 1653 |
+
disabled = opts.DisabledSkills
|
| 1654 |
+
}
|
| 1655 |
+
var resolver func(string) (string, error)
|
| 1656 |
+
if r := cfg.Resolver(); r != nil {
|
| 1657 |
+
resolver = r.ResolveValue
|
| 1658 |
+
}
|
| 1659 |
+
allSkills, activeSkills, states := skills.DiscoverFromConfig(skills.DiscoveryConfig{
|
| 1660 |
+
SkillsPaths: paths,
|
| 1661 |
+
DisabledSkills: disabled,
|
| 1662 |
+
Resolver: resolver,
|
| 1663 |
+
})
|
| 1664 |
+
logDiscoveryStats(states, paths, allSkills, activeSkills, disabled)
|
| 1665 |
+
return allSkills, activeSkills
|
| 1666 |
+
}
|
| 1667 |
+
|
| 1668 |
+
// logTurnSkillUsage emits a per-turn diagnostic line showing which skills
|
| 1669 |
+
// (if any) were loaded during this turn and which looked relevant based on
|
| 1670 |
+
// a cheap keyword match against the user prompt. The goal is to surface
|
| 1671 |
+
// "should-have-loaded but didn't" situations for later analysis.
|
| 1672 |
+
//
|
| 1673 |
+
// Logged at Info level under component=skills; heavy fields are elided when
|
| 1674 |
+
// there is nothing interesting to report.
|
| 1675 |
+
func logTurnSkillUsage(
|
| 1676 |
+
sessionID string,
|
| 1677 |
+
prompt string,
|
| 1678 |
+
activeSkills []*skills.Skill,
|
| 1679 |
+
tracker *skills.Tracker,
|
| 1680 |
+
before []string,
|
| 1681 |
+
) {
|
| 1682 |
+
if tracker == nil || len(activeSkills) == 0 {
|
| 1683 |
+
return
|
| 1684 |
+
}
|
| 1685 |
+
|
| 1686 |
+
after := tracker.LoadedNames()
|
| 1687 |
+
|
| 1688 |
+
beforeSet := make(map[string]bool, len(before))
|
| 1689 |
+
for _, n := range before {
|
| 1690 |
+
beforeSet[n] = true
|
| 1691 |
+
}
|
| 1692 |
+
var loadedThisTurn []string
|
| 1693 |
+
for _, n := range after {
|
| 1694 |
+
if !beforeSet[n] {
|
| 1695 |
+
loadedThisTurn = append(loadedThisTurn, n)
|
| 1696 |
+
}
|
| 1697 |
+
}
|
| 1698 |
+
|
| 1699 |
+
slog.Info(
|
| 1700 |
+
"Skill turn summary",
|
| 1701 |
+
"component", "skills",
|
| 1702 |
+
"session_id", sessionID,
|
| 1703 |
+
"prompt_len", len(prompt),
|
| 1704 |
+
"active_total", len(activeSkills),
|
| 1705 |
+
"loaded_total", len(after),
|
| 1706 |
+
"loaded_this_turn", loadedThisTurn,
|
| 1707 |
+
)
|
| 1708 |
+
}
|
| 1709 |
+
|
| 1710 |
+
// logDiscoveryStats emits a single structured log line summarising skill
|
| 1711 |
+
// discovery for the current session. It is intentionally low-volume: one
|
| 1712 |
+
// line per session start. Builtin vs user counts are derived from the
|
| 1713 |
+
// SkillState.Path — builtin states use the "builtin/" embed prefix.
|
| 1714 |
+
func logDiscoveryStats(
|
| 1715 |
+
states []*skills.SkillState,
|
| 1716 |
+
userPaths []string,
|
| 1717 |
+
allSkills, activeSkills []*skills.Skill,
|
| 1718 |
+
disabled []string,
|
| 1719 |
+
) {
|
| 1720 |
+
var builtinOK, builtinErr, userOK, userErr int
|
| 1721 |
+
for _, s := range states {
|
| 1722 |
+
isBuiltin := strings.HasPrefix(s.Path, "builtin/")
|
| 1723 |
+
switch {
|
| 1724 |
+
case isBuiltin && s.State == skills.StateNormal:
|
| 1725 |
+
builtinOK++
|
| 1726 |
+
case isBuiltin && s.State == skills.StateError:
|
| 1727 |
+
builtinErr++
|
| 1728 |
+
case !isBuiltin && s.State == skills.StateNormal:
|
| 1729 |
+
userOK++
|
| 1730 |
+
case !isBuiltin && s.State == skills.StateError:
|
| 1731 |
+
userErr++
|
| 1732 |
+
}
|
| 1733 |
+
}
|
| 1734 |
+
|
| 1735 |
+
activeNames := make([]string, 0, len(activeSkills))
|
| 1736 |
+
for _, s := range activeSkills {
|
| 1737 |
+
activeNames = append(activeNames, s.Name)
|
| 1738 |
+
}
|
| 1739 |
+
|
| 1740 |
+
xml := skills.ToPromptXML(activeSkills)
|
| 1741 |
+
|
| 1742 |
+
slog.Info(
|
| 1743 |
+
"Skill discovery complete",
|
| 1744 |
+
"component", "skills",
|
| 1745 |
+
"builtin_ok", builtinOK,
|
| 1746 |
+
"builtin_errors", builtinErr,
|
| 1747 |
+
"user_ok", userOK,
|
| 1748 |
+
"user_errors", userErr,
|
| 1749 |
+
"user_paths", len(userPaths),
|
| 1750 |
+
"deduped_total", len(allSkills),
|
| 1751 |
+
"active", len(activeSkills),
|
| 1752 |
+
"disabled", len(disabled),
|
| 1753 |
+
"prompt_bytes", len(xml),
|
| 1754 |
+
"prompt_tok_est", skills.ApproxTokenCount(xml),
|
| 1755 |
+
"active_names", activeNames,
|
| 1756 |
+
)
|
| 1757 |
+
}
|
internal/agent/coordinator_mcp_gate_test.go
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"os"
|
| 6 |
+
"path/filepath"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"github.com/charmbracelet/crush/internal/agent/prompt"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 13 |
+
"github.com/stretchr/testify/require"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// newGateTestCoordinator builds a minimal coordinator against a hermetic
|
| 17 |
+
// config: one openai-typed provider pointed at a closed port, with large and
|
| 18 |
+
// small models selected so model resolution and the system-prompt build both
|
| 19 |
+
// succeed without any network access.
|
| 20 |
+
func newGateTestCoordinator(t *testing.T, interactive bool) *coordinator {
|
| 21 |
+
t.Helper()
|
| 22 |
+
|
| 23 |
+
env := testEnv(t)
|
| 24 |
+
|
| 25 |
+
crushJSON := `{
|
| 26 |
+
"options": {"disable_default_providers": true, "disable_provider_auto_update": true},
|
| 27 |
+
"providers": {"mock": {"id": "mock", "name": "Mock", "type": "openai",
|
| 28 |
+
"base_url": "http://127.0.0.1:9/v1", "api_key": "test-key",
|
| 29 |
+
"models": [{"id": "mock-model", "name": "Mock", "context_window": 8192, "default_max_tokens": 128}]}},
|
| 30 |
+
"models": {"large": {"provider": "mock", "model": "mock-model"},
|
| 31 |
+
"small": {"provider": "mock", "model": "mock-model"}}
|
| 32 |
+
}`
|
| 33 |
+
require.NoError(t, os.WriteFile(filepath.Join(env.workingDir, "crush.json"), []byte(crushJSON), 0o644))
|
| 34 |
+
|
| 35 |
+
cfg, err := config.Init(env.workingDir, "", false)
|
| 36 |
+
require.NoError(t, err)
|
| 37 |
+
cfg.SetupAgents()
|
| 38 |
+
|
| 39 |
+
coord := &coordinator{
|
| 40 |
+
cfg: cfg,
|
| 41 |
+
sessions: env.sessions,
|
| 42 |
+
messages: env.messages,
|
| 43 |
+
permissions: env.permissions,
|
| 44 |
+
history: env.history,
|
| 45 |
+
filetracker: *env.filetracker,
|
| 46 |
+
agents: make(map[string]SessionAgent),
|
| 47 |
+
interactive: interactive,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
p, err := coderPrompt(prompt.WithWorkingDir(env.workingDir))
|
| 51 |
+
require.NoError(t, err)
|
| 52 |
+
agentCfg := cfg.Config().Agents[config.AgentCoder]
|
| 53 |
+
|
| 54 |
+
agent, err := coord.buildAgent(context.Background(), p, agentCfg, false)
|
| 55 |
+
require.NoError(t, err)
|
| 56 |
+
coord.currentAgent = agent
|
| 57 |
+
coord.agents[config.AgentCoder] = agent
|
| 58 |
+
|
| 59 |
+
return coord
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// TestRunWaitsForMCPOnlyWhenNonInteractive pins the split behavior for
|
| 63 |
+
// in-flight MCP initialization.
|
| 64 |
+
//
|
| 65 |
+
// MCP servers connect asynchronously. Interactive runs must not wait for them:
|
| 66 |
+
// blocking the send path meant a slow stdio server (e.g. Python via uv) froze
|
| 67 |
+
// the TUI for the length of its connect timeout, most visibly on the first
|
| 68 |
+
// message of a session. Tools from late servers simply miss that run's palette
|
| 69 |
+
// and show up on the next one.
|
| 70 |
+
//
|
| 71 |
+
// Non-interactive runs (`crush run`, both local and client/server) get a single
|
| 72 |
+
// shot at the palette, so they still wait for initialization to settle.
|
| 73 |
+
func TestRunWaitsForMCPOnlyWhenNonInteractive(t *testing.T) {
|
| 74 |
+
t.Run("non-interactive waits", func(t *testing.T) {
|
| 75 |
+
coord := newGateTestCoordinator(t, false)
|
| 76 |
+
|
| 77 |
+
// Arm the gate and never complete initialization, standing in for an
|
| 78 |
+
// MCP server that is still connecting.
|
| 79 |
+
mcp.ArmInit()
|
| 80 |
+
t.Cleanup(mcp.DisarmInit)
|
| 81 |
+
|
| 82 |
+
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
| 83 |
+
defer cancel()
|
| 84 |
+
|
| 85 |
+
_, err := coord.run(ctx, nil, "test-session", "hello")
|
| 86 |
+
require.ErrorContains(t, err, "MCP initialization",
|
| 87 |
+
"non-interactive run must block on MCP initialization")
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
t.Run("interactive does not wait", func(t *testing.T) {
|
| 91 |
+
coord := newGateTestCoordinator(t, true)
|
| 92 |
+
|
| 93 |
+
mcp.ArmInit()
|
| 94 |
+
t.Cleanup(mcp.DisarmInit)
|
| 95 |
+
|
| 96 |
+
done := make(chan error, 1)
|
| 97 |
+
go func() {
|
| 98 |
+
_, err := coord.run(context.Background(), nil, "test-session", "hello")
|
| 99 |
+
done <- err
|
| 100 |
+
}()
|
| 101 |
+
|
| 102 |
+
select {
|
| 103 |
+
case err := <-done:
|
| 104 |
+
// The run fails for unrelated reasons (no such session, closed
|
| 105 |
+
// provider port); all that matters is that it got past the gate
|
| 106 |
+
// instead of parking on MCP initialization.
|
| 107 |
+
if err != nil {
|
| 108 |
+
require.NotContains(t, err.Error(), "MCP initialization",
|
| 109 |
+
"interactive run must not block on MCP initialization")
|
| 110 |
+
}
|
| 111 |
+
case <-time.After(10 * time.Second):
|
| 112 |
+
t.Fatal("interactive run blocked; it must not wait for MCP initialization")
|
| 113 |
+
}
|
| 114 |
+
})
|
| 115 |
+
}
|
internal/agent/coordinator_readiness_test.go
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"os"
|
| 6 |
+
"path/filepath"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"github.com/charmbracelet/crush/internal/agent/prompt"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 13 |
+
"github.com/stretchr/testify/require"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// TestBuildAgentReadinessSurvivesCallerCancellation is a regression test for
|
| 17 |
+
// the CRUSH_CLIENT_SERVER=1 "new session hangs" bug.
|
| 18 |
+
//
|
| 19 |
+
// buildAgent starts readiness goroutines that build the system prompt and the
|
| 20 |
+
// initial tool list. Several server entry points build an agent from a
|
| 21 |
+
// short-lived HTTP request context — the InitAgent/UpdateAgent handlers, and
|
| 22 |
+
// the sub-agent build reached through UpdateModels -> buildTools -> agentTool.
|
| 23 |
+
// When that request context was canceled the moment the handler returned, the
|
| 24 |
+
// readyWg errgroup recorded context.Canceled and every later coordinator.run
|
| 25 |
+
// failed at readyWg.Wait() before emitting anything — the session hung with
|
| 26 |
+
// no visible LLM response. (This was made worse while the tool-list goroutine
|
| 27 |
+
// also blocked in mcp.WaitForInit, which kept it parked long enough to
|
| 28 |
+
// observe the cancellation; the readiness work no longer waits on MCP init —
|
| 29 |
+
// see coordinator.run — but the cancellation detachment still matters.)
|
| 30 |
+
//
|
| 31 |
+
// The fix detaches the readiness work from the caller context via
|
| 32 |
+
// context.WithoutCancel, so canceling the context that triggered the build no
|
| 33 |
+
// longer poisons readyWg. Here we build an agent with a cancelable context,
|
| 34 |
+
// cancel it, and require that readyWg still completes cleanly.
|
| 35 |
+
func TestBuildAgentReadinessSurvivesCallerCancellation(t *testing.T) {
|
| 36 |
+
env := testEnv(t)
|
| 37 |
+
|
| 38 |
+
// Minimal hermetic config: one openai-typed provider with selected large
|
| 39 |
+
// and small models so buildAgentModels and the system-prompt build both
|
| 40 |
+
// succeed. No MCP servers are configured, so initialization would complete
|
| 41 |
+
// instantly if we let it — we arm the gate anyway to prove the readiness
|
| 42 |
+
// goroutines no longer block on it.
|
| 43 |
+
crushJSON := `{
|
| 44 |
+
"options": {"disable_default_providers": true, "disable_provider_auto_update": true},
|
| 45 |
+
"providers": {"mock": {"id": "mock", "name": "Mock", "type": "openai",
|
| 46 |
+
"base_url": "http://127.0.0.1:9/v1", "api_key": "test-key",
|
| 47 |
+
"models": [{"id": "mock-model", "name": "Mock", "context_window": 8192, "default_max_tokens": 128}]}},
|
| 48 |
+
"models": {"large": {"provider": "mock", "model": "mock-model"},
|
| 49 |
+
"small": {"provider": "mock", "model": "mock-model"}}
|
| 50 |
+
}`
|
| 51 |
+
require.NoError(t, os.WriteFile(filepath.Join(env.workingDir, "crush.json"), []byte(crushJSON), 0o644))
|
| 52 |
+
|
| 53 |
+
cfg, err := config.Init(env.workingDir, "", false)
|
| 54 |
+
require.NoError(t, err)
|
| 55 |
+
cfg.SetupAgents()
|
| 56 |
+
|
| 57 |
+
coord := &coordinator{
|
| 58 |
+
cfg: cfg,
|
| 59 |
+
sessions: env.sessions,
|
| 60 |
+
messages: env.messages,
|
| 61 |
+
permissions: env.permissions,
|
| 62 |
+
history: env.history,
|
| 63 |
+
filetracker: *env.filetracker,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// Arm the MCP init gate. We never complete init; the readiness goroutines
|
| 67 |
+
// must not care, since they build the tool list from the registry as it
|
| 68 |
+
// stands rather than waiting for initialization to finish.
|
| 69 |
+
mcp.ArmInit()
|
| 70 |
+
t.Cleanup(mcp.DisarmInit)
|
| 71 |
+
|
| 72 |
+
p, err := coderPrompt(prompt.WithWorkingDir(env.workingDir))
|
| 73 |
+
require.NoError(t, err)
|
| 74 |
+
agentCfg := cfg.Config().Agents[config.AgentCoder]
|
| 75 |
+
|
| 76 |
+
ctx, cancel := context.WithCancel(context.Background())
|
| 77 |
+
_, err = coord.buildAgent(ctx, p, agentCfg, false)
|
| 78 |
+
require.NoError(t, err)
|
| 79 |
+
|
| 80 |
+
// The caller goes away, mirroring an HTTP handler returning and canceling
|
| 81 |
+
// its request context.
|
| 82 |
+
cancel()
|
| 83 |
+
|
| 84 |
+
done := make(chan error, 1)
|
| 85 |
+
go func() { done <- coord.readyWg.Wait() }()
|
| 86 |
+
|
| 87 |
+
select {
|
| 88 |
+
case err := <-done:
|
| 89 |
+
// context.Canceled is the regression: the caller's cancellation
|
| 90 |
+
// leaked into the readiness work and poisoned the errgroup.
|
| 91 |
+
require.NotErrorIs(t, err, context.Canceled,
|
| 92 |
+
"readyWg was poisoned by caller cancellation (client/server new-session hang regression)")
|
| 93 |
+
require.NoError(t, err, "unexpected buildAgent readiness error")
|
| 94 |
+
case <-time.After(2 * time.Second):
|
| 95 |
+
t.Fatal("readyWg did not complete; the readiness goroutines must not block on MCP init")
|
| 96 |
+
}
|
| 97 |
+
}
|
internal/agent/coordinator_test.go
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"fmt"
|
| 7 |
+
"net/http"
|
| 8 |
+
"testing"
|
| 9 |
+
|
| 10 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 11 |
+
"charm.land/fantasy"
|
| 12 |
+
"charm.land/fantasy/providers/anthropic"
|
| 13 |
+
"charm.land/fantasy/providers/bedrock"
|
| 14 |
+
"charm.land/fantasy/providers/openaicompat"
|
| 15 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 16 |
+
"github.com/charmbracelet/crush/internal/discover"
|
| 17 |
+
"github.com/stretchr/testify/assert"
|
| 18 |
+
"github.com/stretchr/testify/require"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
// mockSessionAgent is a minimal mock for the SessionAgent interface.
|
| 22 |
+
type mockSessionAgent struct {
|
| 23 |
+
model Model
|
| 24 |
+
runFunc func(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error)
|
| 25 |
+
cancelled []string
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func (m *mockSessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
|
| 29 |
+
return m.runFunc(ctx, call)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
func (m *mockSessionAgent) BeginAccepted(sessionID string) *AcceptedRun {
|
| 33 |
+
return &AcceptedRun{sessionID: sessionID}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func (m *mockSessionAgent) Model() Model { return m.model }
|
| 37 |
+
func (m *mockSessionAgent) SetModels(large, small Model) {}
|
| 38 |
+
func (m *mockSessionAgent) SetTools(tools []fantasy.AgentTool) {}
|
| 39 |
+
func (m *mockSessionAgent) SetSystemPrompt(systemPrompt string) {}
|
| 40 |
+
func (m *mockSessionAgent) Cancel(sessionID string) {
|
| 41 |
+
m.cancelled = append(m.cancelled, sessionID)
|
| 42 |
+
}
|
| 43 |
+
func (m *mockSessionAgent) CancelAll() {}
|
| 44 |
+
func (m *mockSessionAgent) IsSessionBusy(sessionID string) bool { return false }
|
| 45 |
+
func (m *mockSessionAgent) IsBusy() bool { return false }
|
| 46 |
+
func (m *mockSessionAgent) QueuedPrompts(sessionID string) int { return 0 }
|
| 47 |
+
func (m *mockSessionAgent) QueuedPromptsList(sessionID string) []string { return nil }
|
| 48 |
+
func (m *mockSessionAgent) ClearQueue(sessionID string) {}
|
| 49 |
+
func (m *mockSessionAgent) Summarize(context.Context, string, fantasy.ProviderOptions, func(context.Context, *fantasy.ProviderError) error) error {
|
| 50 |
+
return nil
|
| 51 |
+
}
|
| 52 |
+
func (m *mockSessionAgent) GenerateTitle(context.Context, string, string) {}
|
| 53 |
+
|
| 54 |
+
// newTestCoordinator creates a minimal coordinator for unit testing runSubAgent.
|
| 55 |
+
func newTestCoordinator(t *testing.T, env fakeEnv, providerID string, providerCfg config.ProviderConfig) *coordinator {
|
| 56 |
+
cfg, err := config.Init(env.workingDir, "", false)
|
| 57 |
+
require.NoError(t, err)
|
| 58 |
+
cfg.Config().Providers.Set(providerID, providerCfg)
|
| 59 |
+
return &coordinator{
|
| 60 |
+
cfg: cfg,
|
| 61 |
+
sessions: env.sessions,
|
| 62 |
+
messages: env.messages,
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// newMockAgent creates a mockSessionAgent with the given provider and run function.
|
| 67 |
+
func newMockAgent(providerID string, maxTokens int64, runFunc func(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)) *mockSessionAgent {
|
| 68 |
+
return &mockSessionAgent{
|
| 69 |
+
model: Model{
|
| 70 |
+
CatwalkCfg: catwalk.Model{
|
| 71 |
+
DefaultMaxTokens: maxTokens,
|
| 72 |
+
},
|
| 73 |
+
ModelCfg: config.SelectedModel{
|
| 74 |
+
Provider: providerID,
|
| 75 |
+
},
|
| 76 |
+
},
|
| 77 |
+
runFunc: runFunc,
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// agentResultWithText creates a minimal AgentResult with the given text response.
|
| 82 |
+
func agentResultWithText(text string) *fantasy.AgentResult {
|
| 83 |
+
return &fantasy.AgentResult{
|
| 84 |
+
Response: fantasy.Response{
|
| 85 |
+
Content: fantasy.ResponseContent{
|
| 86 |
+
fantasy.TextContent{Text: text},
|
| 87 |
+
},
|
| 88 |
+
},
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
func TestCopilotResponsesModels(t *testing.T) {
|
| 93 |
+
t.Parallel()
|
| 94 |
+
|
| 95 |
+
for _, modelID := range []string{"gpt-6-astra", "grok-4.5", "grok-4.6"} {
|
| 96 |
+
assert.True(t, copilotResponsesModels[modelID], modelID)
|
| 97 |
+
}
|
| 98 |
+
assert.False(t, copilotResponsesModels["gpt-4.1"])
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
func TestRunSubAgent(t *testing.T) {
|
| 102 |
+
const providerID = "test-provider"
|
| 103 |
+
providerCfg := config.ProviderConfig{ID: providerID}
|
| 104 |
+
|
| 105 |
+
t.Run("happy path", func(t *testing.T) {
|
| 106 |
+
env := testEnv(t)
|
| 107 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 108 |
+
|
| 109 |
+
parentSession, err := env.sessions.Create(t.Context(), "Parent")
|
| 110 |
+
require.NoError(t, err)
|
| 111 |
+
|
| 112 |
+
agent := newMockAgent(providerID, 4096, func(_ context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
|
| 113 |
+
assert.Equal(t, "do something", call.Prompt)
|
| 114 |
+
assert.Equal(t, int64(4096), call.MaxOutputTokens)
|
| 115 |
+
return agentResultWithText("done"), nil
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
resp, err := coord.runSubAgent(t.Context(), subAgentParams{
|
| 119 |
+
Agent: agent,
|
| 120 |
+
SessionID: parentSession.ID,
|
| 121 |
+
AgentMessageID: "msg-1",
|
| 122 |
+
ToolCallID: "call-1",
|
| 123 |
+
Prompt: "do something",
|
| 124 |
+
SessionTitle: "Test Session",
|
| 125 |
+
})
|
| 126 |
+
require.NoError(t, err)
|
| 127 |
+
assert.Equal(t, "done", resp.Content)
|
| 128 |
+
assert.False(t, resp.IsError)
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
t.Run("cost update failure preserves output", func(t *testing.T) {
|
| 132 |
+
env := testEnv(t)
|
| 133 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 134 |
+
|
| 135 |
+
agent := newMockAgent(providerID, 4096, func(_ context.Context, _ SessionAgentCall) (*fantasy.AgentResult, error) {
|
| 136 |
+
return agentResultWithText("output before cost failure"), nil
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
resp, err := coord.runSubAgent(t.Context(), subAgentParams{
|
| 140 |
+
Agent: agent,
|
| 141 |
+
SessionID: "missing-parent-session",
|
| 142 |
+
AgentMessageID: "msg-1",
|
| 143 |
+
ToolCallID: "call-1",
|
| 144 |
+
Prompt: "test",
|
| 145 |
+
SessionTitle: "Test",
|
| 146 |
+
})
|
| 147 |
+
require.NoError(t, err)
|
| 148 |
+
assert.False(t, resp.IsError)
|
| 149 |
+
assert.Equal(t, "output before cost failure", resp.Content)
|
| 150 |
+
})
|
| 151 |
+
|
| 152 |
+
t.Run("response with text returns it", func(t *testing.T) {
|
| 153 |
+
env := testEnv(t)
|
| 154 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 155 |
+
|
| 156 |
+
parentSession, err := env.sessions.Create(t.Context(), "Parent")
|
| 157 |
+
require.NoError(t, err)
|
| 158 |
+
|
| 159 |
+
agent := newMockAgent(providerID, 4096, func(_ context.Context, _ SessionAgentCall) (*fantasy.AgentResult, error) {
|
| 160 |
+
return agentResultWithText("the answer"), nil
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
resp, err := coord.runSubAgent(t.Context(), subAgentParams{
|
| 164 |
+
Agent: agent,
|
| 165 |
+
SessionID: parentSession.ID,
|
| 166 |
+
AgentMessageID: "msg-1",
|
| 167 |
+
ToolCallID: "call-1",
|
| 168 |
+
Prompt: "test",
|
| 169 |
+
SessionTitle: "Test",
|
| 170 |
+
})
|
| 171 |
+
require.NoError(t, err)
|
| 172 |
+
assert.False(t, resp.IsError)
|
| 173 |
+
assert.Equal(t, "the answer", resp.Content)
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
t.Run("nil result returns error response", func(t *testing.T) {
|
| 177 |
+
env := testEnv(t)
|
| 178 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 179 |
+
|
| 180 |
+
parentSession, err := env.sessions.Create(t.Context(), "Parent")
|
| 181 |
+
require.NoError(t, err)
|
| 182 |
+
|
| 183 |
+
agent := newMockAgent(providerID, 4096, func(_ context.Context, _ SessionAgentCall) (*fantasy.AgentResult, error) {
|
| 184 |
+
return nil, nil
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
resp, err := coord.runSubAgent(t.Context(), subAgentParams{
|
| 188 |
+
Agent: agent,
|
| 189 |
+
SessionID: parentSession.ID,
|
| 190 |
+
AgentMessageID: "msg-1",
|
| 191 |
+
ToolCallID: "call-1",
|
| 192 |
+
Prompt: "test",
|
| 193 |
+
SessionTitle: "Test",
|
| 194 |
+
})
|
| 195 |
+
require.NoError(t, err)
|
| 196 |
+
assert.True(t, resp.IsError)
|
| 197 |
+
assert.Equal(t, "Sub-agent completed but produced no text output.", resp.Content)
|
| 198 |
+
})
|
| 199 |
+
|
| 200 |
+
t.Run("empty result returns error response", func(t *testing.T) {
|
| 201 |
+
env := testEnv(t)
|
| 202 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 203 |
+
|
| 204 |
+
parentSession, err := env.sessions.Create(t.Context(), "Parent")
|
| 205 |
+
require.NoError(t, err)
|
| 206 |
+
|
| 207 |
+
agent := newMockAgent(providerID, 4096, func(_ context.Context, _ SessionAgentCall) (*fantasy.AgentResult, error) {
|
| 208 |
+
return &fantasy.AgentResult{}, nil
|
| 209 |
+
})
|
| 210 |
+
|
| 211 |
+
resp, err := coord.runSubAgent(t.Context(), subAgentParams{
|
| 212 |
+
Agent: agent,
|
| 213 |
+
SessionID: parentSession.ID,
|
| 214 |
+
AgentMessageID: "msg-1",
|
| 215 |
+
ToolCallID: "call-1",
|
| 216 |
+
Prompt: "test",
|
| 217 |
+
SessionTitle: "Test",
|
| 218 |
+
})
|
| 219 |
+
require.NoError(t, err)
|
| 220 |
+
assert.True(t, resp.IsError)
|
| 221 |
+
assert.Equal(t, "Sub-agent completed but produced no text output.", resp.Content)
|
| 222 |
+
})
|
| 223 |
+
|
| 224 |
+
t.Run("ModelCfg.MaxTokens overrides default", func(t *testing.T) {
|
| 225 |
+
env := testEnv(t)
|
| 226 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 227 |
+
|
| 228 |
+
parentSession, err := env.sessions.Create(t.Context(), "Parent")
|
| 229 |
+
require.NoError(t, err)
|
| 230 |
+
|
| 231 |
+
agent := &mockSessionAgent{
|
| 232 |
+
model: Model{
|
| 233 |
+
CatwalkCfg: catwalk.Model{
|
| 234 |
+
DefaultMaxTokens: 4096,
|
| 235 |
+
},
|
| 236 |
+
ModelCfg: config.SelectedModel{
|
| 237 |
+
Provider: providerID,
|
| 238 |
+
MaxTokens: 8192,
|
| 239 |
+
},
|
| 240 |
+
},
|
| 241 |
+
runFunc: func(_ context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
|
| 242 |
+
assert.Equal(t, int64(8192), call.MaxOutputTokens)
|
| 243 |
+
return agentResultWithText("ok"), nil
|
| 244 |
+
},
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
resp, err := coord.runSubAgent(t.Context(), subAgentParams{
|
| 248 |
+
Agent: agent,
|
| 249 |
+
SessionID: parentSession.ID,
|
| 250 |
+
AgentMessageID: "msg-1",
|
| 251 |
+
ToolCallID: "call-1",
|
| 252 |
+
Prompt: "test",
|
| 253 |
+
SessionTitle: "Test",
|
| 254 |
+
})
|
| 255 |
+
require.NoError(t, err)
|
| 256 |
+
assert.Equal(t, "ok", resp.Content)
|
| 257 |
+
})
|
| 258 |
+
|
| 259 |
+
t.Run("session creation failure with canceled context", func(t *testing.T) {
|
| 260 |
+
env := testEnv(t)
|
| 261 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 262 |
+
|
| 263 |
+
parentSession, err := env.sessions.Create(t.Context(), "Parent")
|
| 264 |
+
require.NoError(t, err)
|
| 265 |
+
|
| 266 |
+
agent := newMockAgent(providerID, 4096, nil)
|
| 267 |
+
|
| 268 |
+
// Use a canceled context to trigger CreateTaskSession failure.
|
| 269 |
+
ctx, cancel := context.WithCancel(t.Context())
|
| 270 |
+
cancel()
|
| 271 |
+
|
| 272 |
+
_, err = coord.runSubAgent(ctx, subAgentParams{
|
| 273 |
+
Agent: agent,
|
| 274 |
+
SessionID: parentSession.ID,
|
| 275 |
+
AgentMessageID: "msg-1",
|
| 276 |
+
ToolCallID: "call-1",
|
| 277 |
+
Prompt: "test",
|
| 278 |
+
SessionTitle: "Test",
|
| 279 |
+
})
|
| 280 |
+
require.Error(t, err)
|
| 281 |
+
})
|
| 282 |
+
|
| 283 |
+
t.Run("provider not configured", func(t *testing.T) {
|
| 284 |
+
env := testEnv(t)
|
| 285 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 286 |
+
|
| 287 |
+
parentSession, err := env.sessions.Create(t.Context(), "Parent")
|
| 288 |
+
require.NoError(t, err)
|
| 289 |
+
|
| 290 |
+
// Agent references a provider that doesn't exist in config.
|
| 291 |
+
agent := newMockAgent("unknown-provider", 4096, nil)
|
| 292 |
+
|
| 293 |
+
_, err = coord.runSubAgent(t.Context(), subAgentParams{
|
| 294 |
+
Agent: agent,
|
| 295 |
+
SessionID: parentSession.ID,
|
| 296 |
+
AgentMessageID: "msg-1",
|
| 297 |
+
ToolCallID: "call-1",
|
| 298 |
+
Prompt: "test",
|
| 299 |
+
SessionTitle: "Test",
|
| 300 |
+
})
|
| 301 |
+
require.Error(t, err)
|
| 302 |
+
assert.Contains(t, err.Error(), "model provider not configured")
|
| 303 |
+
})
|
| 304 |
+
|
| 305 |
+
t.Run("agent run error returns error response", func(t *testing.T) {
|
| 306 |
+
env := testEnv(t)
|
| 307 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 308 |
+
|
| 309 |
+
parentSession, err := env.sessions.Create(t.Context(), "Parent")
|
| 310 |
+
require.NoError(t, err)
|
| 311 |
+
|
| 312 |
+
agent := newMockAgent(providerID, 4096, func(_ context.Context, _ SessionAgentCall) (*fantasy.AgentResult, error) {
|
| 313 |
+
return nil, errors.New("provider request failed")
|
| 314 |
+
})
|
| 315 |
+
|
| 316 |
+
resp, err := coord.runSubAgent(t.Context(), subAgentParams{
|
| 317 |
+
Agent: agent,
|
| 318 |
+
SessionID: parentSession.ID,
|
| 319 |
+
AgentMessageID: "msg-1",
|
| 320 |
+
ToolCallID: "call-1",
|
| 321 |
+
Prompt: "test",
|
| 322 |
+
SessionTitle: "Test",
|
| 323 |
+
})
|
| 324 |
+
// runSubAgent returns (errorResponse, nil) when agent.Run fails — not a Go error.
|
| 325 |
+
require.NoError(t, err)
|
| 326 |
+
assert.True(t, resp.IsError)
|
| 327 |
+
assert.Equal(t, "Failed to generate response: provider request failed", resp.Content)
|
| 328 |
+
})
|
| 329 |
+
|
| 330 |
+
t.Run("session setup callback is invoked", func(t *testing.T) {
|
| 331 |
+
env := testEnv(t)
|
| 332 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 333 |
+
|
| 334 |
+
parentSession, err := env.sessions.Create(t.Context(), "Parent")
|
| 335 |
+
require.NoError(t, err)
|
| 336 |
+
|
| 337 |
+
var setupCalledWith string
|
| 338 |
+
agent := newMockAgent(providerID, 4096, func(_ context.Context, _ SessionAgentCall) (*fantasy.AgentResult, error) {
|
| 339 |
+
return agentResultWithText("ok"), nil
|
| 340 |
+
})
|
| 341 |
+
|
| 342 |
+
_, err = coord.runSubAgent(t.Context(), subAgentParams{
|
| 343 |
+
Agent: agent,
|
| 344 |
+
SessionID: parentSession.ID,
|
| 345 |
+
AgentMessageID: "msg-1",
|
| 346 |
+
ToolCallID: "call-1",
|
| 347 |
+
Prompt: "test",
|
| 348 |
+
SessionTitle: "Test",
|
| 349 |
+
SessionSetup: func(sessionID string) {
|
| 350 |
+
setupCalledWith = sessionID
|
| 351 |
+
},
|
| 352 |
+
})
|
| 353 |
+
require.NoError(t, err)
|
| 354 |
+
assert.NotEmpty(t, setupCalledWith, "SessionSetup should have been called")
|
| 355 |
+
})
|
| 356 |
+
|
| 357 |
+
t.Run("cost propagation to parent session", func(t *testing.T) {
|
| 358 |
+
env := testEnv(t)
|
| 359 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 360 |
+
|
| 361 |
+
parentSession, err := env.sessions.Create(t.Context(), "Parent")
|
| 362 |
+
require.NoError(t, err)
|
| 363 |
+
|
| 364 |
+
agent := newMockAgent(providerID, 4096, func(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
|
| 365 |
+
// Simulate the agent incurring cost by updating the child session.
|
| 366 |
+
childSession, err := env.sessions.Get(ctx, call.SessionID)
|
| 367 |
+
if err != nil {
|
| 368 |
+
return nil, err
|
| 369 |
+
}
|
| 370 |
+
childSession.Cost = 0.05
|
| 371 |
+
_, err = env.sessions.Save(ctx, childSession)
|
| 372 |
+
if err != nil {
|
| 373 |
+
return nil, err
|
| 374 |
+
}
|
| 375 |
+
return agentResultWithText("ok"), nil
|
| 376 |
+
})
|
| 377 |
+
|
| 378 |
+
_, err = coord.runSubAgent(t.Context(), subAgentParams{
|
| 379 |
+
Agent: agent,
|
| 380 |
+
SessionID: parentSession.ID,
|
| 381 |
+
AgentMessageID: "msg-1",
|
| 382 |
+
ToolCallID: "call-1",
|
| 383 |
+
Prompt: "test",
|
| 384 |
+
SessionTitle: "Test",
|
| 385 |
+
})
|
| 386 |
+
require.NoError(t, err)
|
| 387 |
+
|
| 388 |
+
updated, err := env.sessions.Get(t.Context(), parentSession.ID)
|
| 389 |
+
require.NoError(t, err)
|
| 390 |
+
assert.InDelta(t, 0.05, updated.Cost, 1e-9)
|
| 391 |
+
})
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
func TestUpdateParentSessionCost(t *testing.T) {
|
| 395 |
+
t.Run("accumulates cost correctly", func(t *testing.T) {
|
| 396 |
+
env := testEnv(t)
|
| 397 |
+
cfg, err := config.Init(env.workingDir, "", false)
|
| 398 |
+
require.NoError(t, err)
|
| 399 |
+
coord := &coordinator{cfg: cfg, sessions: env.sessions}
|
| 400 |
+
|
| 401 |
+
parent, err := env.sessions.Create(t.Context(), "Parent")
|
| 402 |
+
require.NoError(t, err)
|
| 403 |
+
|
| 404 |
+
child, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child")
|
| 405 |
+
require.NoError(t, err)
|
| 406 |
+
|
| 407 |
+
// Set child cost.
|
| 408 |
+
child.Cost = 0.10
|
| 409 |
+
_, err = env.sessions.Save(t.Context(), child)
|
| 410 |
+
require.NoError(t, err)
|
| 411 |
+
|
| 412 |
+
err = coord.updateParentSessionCost(t.Context(), child.ID, parent.ID)
|
| 413 |
+
require.NoError(t, err)
|
| 414 |
+
|
| 415 |
+
updated, err := env.sessions.Get(t.Context(), parent.ID)
|
| 416 |
+
require.NoError(t, err)
|
| 417 |
+
assert.InDelta(t, 0.10, updated.Cost, 1e-9)
|
| 418 |
+
})
|
| 419 |
+
|
| 420 |
+
t.Run("accumulates multiple child costs", func(t *testing.T) {
|
| 421 |
+
env := testEnv(t)
|
| 422 |
+
cfg, err := config.Init(env.workingDir, "", false)
|
| 423 |
+
require.NoError(t, err)
|
| 424 |
+
coord := &coordinator{cfg: cfg, sessions: env.sessions}
|
| 425 |
+
|
| 426 |
+
parent, err := env.sessions.Create(t.Context(), "Parent")
|
| 427 |
+
require.NoError(t, err)
|
| 428 |
+
|
| 429 |
+
child1, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child1")
|
| 430 |
+
require.NoError(t, err)
|
| 431 |
+
child1.Cost = 0.05
|
| 432 |
+
_, err = env.sessions.Save(t.Context(), child1)
|
| 433 |
+
require.NoError(t, err)
|
| 434 |
+
|
| 435 |
+
child2, err := env.sessions.CreateTaskSession(t.Context(), "tool-2", parent.ID, "Child2")
|
| 436 |
+
require.NoError(t, err)
|
| 437 |
+
child2.Cost = 0.03
|
| 438 |
+
_, err = env.sessions.Save(t.Context(), child2)
|
| 439 |
+
require.NoError(t, err)
|
| 440 |
+
|
| 441 |
+
err = coord.updateParentSessionCost(t.Context(), child1.ID, parent.ID)
|
| 442 |
+
require.NoError(t, err)
|
| 443 |
+
err = coord.updateParentSessionCost(t.Context(), child2.ID, parent.ID)
|
| 444 |
+
require.NoError(t, err)
|
| 445 |
+
|
| 446 |
+
updated, err := env.sessions.Get(t.Context(), parent.ID)
|
| 447 |
+
require.NoError(t, err)
|
| 448 |
+
assert.InDelta(t, 0.08, updated.Cost, 1e-9)
|
| 449 |
+
})
|
| 450 |
+
|
| 451 |
+
t.Run("child session not found", func(t *testing.T) {
|
| 452 |
+
env := testEnv(t)
|
| 453 |
+
cfg, err := config.Init(env.workingDir, "", false)
|
| 454 |
+
require.NoError(t, err)
|
| 455 |
+
coord := &coordinator{cfg: cfg, sessions: env.sessions}
|
| 456 |
+
|
| 457 |
+
parent, err := env.sessions.Create(t.Context(), "Parent")
|
| 458 |
+
require.NoError(t, err)
|
| 459 |
+
|
| 460 |
+
err = coord.updateParentSessionCost(t.Context(), "non-existent", parent.ID)
|
| 461 |
+
require.Error(t, err)
|
| 462 |
+
assert.Contains(t, err.Error(), "get child session")
|
| 463 |
+
})
|
| 464 |
+
|
| 465 |
+
t.Run("parent session not found", func(t *testing.T) {
|
| 466 |
+
env := testEnv(t)
|
| 467 |
+
cfg, err := config.Init(env.workingDir, "", false)
|
| 468 |
+
require.NoError(t, err)
|
| 469 |
+
coord := &coordinator{cfg: cfg, sessions: env.sessions}
|
| 470 |
+
|
| 471 |
+
parent, err := env.sessions.Create(t.Context(), "Parent")
|
| 472 |
+
require.NoError(t, err)
|
| 473 |
+
child, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child")
|
| 474 |
+
require.NoError(t, err)
|
| 475 |
+
|
| 476 |
+
err = coord.updateParentSessionCost(t.Context(), child.ID, "non-existent")
|
| 477 |
+
require.Error(t, err)
|
| 478 |
+
assert.Contains(t, err.Error(), "get parent session")
|
| 479 |
+
})
|
| 480 |
+
|
| 481 |
+
t.Run("zero cost handled correctly", func(t *testing.T) {
|
| 482 |
+
env := testEnv(t)
|
| 483 |
+
cfg, err := config.Init(env.workingDir, "", false)
|
| 484 |
+
require.NoError(t, err)
|
| 485 |
+
coord := &coordinator{cfg: cfg, sessions: env.sessions}
|
| 486 |
+
|
| 487 |
+
parent, err := env.sessions.Create(t.Context(), "Parent")
|
| 488 |
+
require.NoError(t, err)
|
| 489 |
+
child, err := env.sessions.CreateTaskSession(t.Context(), "tool-1", parent.ID, "Child")
|
| 490 |
+
require.NoError(t, err)
|
| 491 |
+
|
| 492 |
+
err = coord.updateParentSessionCost(t.Context(), child.ID, parent.ID)
|
| 493 |
+
require.NoError(t, err)
|
| 494 |
+
|
| 495 |
+
updated, err := env.sessions.Get(t.Context(), parent.ID)
|
| 496 |
+
require.NoError(t, err)
|
| 497 |
+
assert.InDelta(t, 0.0, updated.Cost, 1e-9)
|
| 498 |
+
})
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
func TestGetProviderOptionsReasoningEffort(t *testing.T) {
|
| 502 |
+
// Bedrock is Fantasy's Anthropic under a different provider name; options
|
| 503 |
+
// must land under anthropic.Name so the Anthropic language model picks them up.
|
| 504 |
+
tests := []struct {
|
| 505 |
+
name string
|
| 506 |
+
providerType catwalk.Type
|
| 507 |
+
}{
|
| 508 |
+
{"anthropic honors reasoning_effort", catwalk.Type(anthropic.Name)},
|
| 509 |
+
{"bedrock honors reasoning_effort", catwalk.Type(bedrock.Name)},
|
| 510 |
+
}
|
| 511 |
+
for _, tc := range tests {
|
| 512 |
+
t.Run(tc.name, func(t *testing.T) {
|
| 513 |
+
model := Model{
|
| 514 |
+
CatwalkCfg: catwalk.Model{
|
| 515 |
+
ID: "claude-opus-4-7",
|
| 516 |
+
CanReason: true,
|
| 517 |
+
ReasoningLevels: []string{"max"},
|
| 518 |
+
},
|
| 519 |
+
ModelCfg: config.SelectedModel{
|
| 520 |
+
Provider: "test",
|
| 521 |
+
ReasoningEffort: "max",
|
| 522 |
+
},
|
| 523 |
+
}
|
| 524 |
+
providerCfg := config.ProviderConfig{ID: "test", Type: tc.providerType}
|
| 525 |
+
|
| 526 |
+
opts := getProviderOptions(model, providerCfg)
|
| 527 |
+
|
| 528 |
+
raw, ok := opts[anthropic.Name]
|
| 529 |
+
require.True(t, ok, "options should be keyed under anthropic.Name for type %q", tc.providerType)
|
| 530 |
+
parsed, ok := raw.(*anthropic.ProviderOptions)
|
| 531 |
+
require.True(t, ok)
|
| 532 |
+
require.NotNil(t, parsed.Effort)
|
| 533 |
+
assert.Equal(t, anthropic.Effort("max"), *parsed.Effort)
|
| 534 |
+
})
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
func TestIsUnauthorized(t *testing.T) {
|
| 539 |
+
t.Run("nil error", func(t *testing.T) {
|
| 540 |
+
assert.False(t, isUnauthorized(nil))
|
| 541 |
+
})
|
| 542 |
+
|
| 543 |
+
t.Run("non-provider error", func(t *testing.T) {
|
| 544 |
+
assert.False(t, isUnauthorized(errors.New("something broke")))
|
| 545 |
+
})
|
| 546 |
+
|
| 547 |
+
t.Run("provider error with 401", func(t *testing.T) {
|
| 548 |
+
err := &fantasy.ProviderError{StatusCode: http.StatusUnauthorized, Message: "unauthorized"}
|
| 549 |
+
assert.True(t, isUnauthorized(err))
|
| 550 |
+
})
|
| 551 |
+
|
| 552 |
+
t.Run("provider error with non-401", func(t *testing.T) {
|
| 553 |
+
err := &fantasy.ProviderError{StatusCode: http.StatusForbidden, Message: "forbidden"}
|
| 554 |
+
assert.False(t, isUnauthorized(err))
|
| 555 |
+
})
|
| 556 |
+
|
| 557 |
+
t.Run("wrapped provider error with 401", func(t *testing.T) {
|
| 558 |
+
inner := &fantasy.ProviderError{StatusCode: http.StatusUnauthorized, Message: "expired"}
|
| 559 |
+
err := fmt.Errorf("request failed: %w", inner)
|
| 560 |
+
assert.True(t, isUnauthorized(err))
|
| 561 |
+
})
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
func TestGetProviderOptionsReasoningEffortCustomProvider(t *testing.T) {
|
| 565 |
+
// Custom local providers (lmstudio, ollama, omlx, litellm, llamacpp)
|
| 566 |
+
// go through the OpenAI-compat client and must receive the selected
|
| 567 |
+
// reasoning effort like any other OpenAI-compatible provider.
|
| 568 |
+
for _, providerType := range discover.RegisteredProviderTypes() {
|
| 569 |
+
t.Run(providerType, func(t *testing.T) {
|
| 570 |
+
model := Model{
|
| 571 |
+
CatwalkCfg: catwalk.Model{
|
| 572 |
+
ID: "qwen/qwen3-8b",
|
| 573 |
+
CanReason: true,
|
| 574 |
+
ReasoningLevels: []string{"low", "medium", "high"},
|
| 575 |
+
},
|
| 576 |
+
ModelCfg: config.SelectedModel{
|
| 577 |
+
Provider: "local",
|
| 578 |
+
Model: "qwen/qwen3-8b",
|
| 579 |
+
ReasoningEffort: "high",
|
| 580 |
+
},
|
| 581 |
+
}
|
| 582 |
+
providerCfg := config.ProviderConfig{ID: "local", Type: catwalk.Type(providerType)}
|
| 583 |
+
|
| 584 |
+
opts := getProviderOptions(model, providerCfg)
|
| 585 |
+
|
| 586 |
+
raw, ok := opts[openaicompat.Name]
|
| 587 |
+
require.True(t, ok, "options should be keyed under openaicompat.Name for type %q", providerType)
|
| 588 |
+
parsed, ok := raw.(*openaicompat.ProviderOptions)
|
| 589 |
+
require.True(t, ok)
|
| 590 |
+
require.NotNil(t, parsed.ReasoningEffort)
|
| 591 |
+
assert.Equal(t, "high", string(*parsed.ReasoningEffort))
|
| 592 |
+
})
|
| 593 |
+
}
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
func TestGetProviderOptionsReasoningEffortFallback(t *testing.T) {
|
| 597 |
+
model := Model{
|
| 598 |
+
CatwalkCfg: catwalk.Model{
|
| 599 |
+
ID: "glm-5.2",
|
| 600 |
+
CanReason: true,
|
| 601 |
+
ReasoningLevels: []string{"high", "max"},
|
| 602 |
+
},
|
| 603 |
+
ModelCfg: config.SelectedModel{
|
| 604 |
+
Provider: "zai",
|
| 605 |
+
},
|
| 606 |
+
}
|
| 607 |
+
providerCfg := config.ProviderConfig{
|
| 608 |
+
ID: string(catwalk.InferenceProviderZAI),
|
| 609 |
+
Type: openaicompat.Name,
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
opts := getProviderOptions(model, providerCfg)
|
| 613 |
+
|
| 614 |
+
raw, ok := opts[openaicompat.Name]
|
| 615 |
+
require.True(t, ok)
|
| 616 |
+
parsed, ok := raw.(*openaicompat.ProviderOptions)
|
| 617 |
+
require.True(t, ok)
|
| 618 |
+
require.NotNil(t, parsed.ReasoningEffort)
|
| 619 |
+
assert.Equal(t, "high", string(*parsed.ReasoningEffort))
|
| 620 |
+
|
| 621 |
+
thinking, ok := parsed.ExtraBody["thinking"].(map[string]any)
|
| 622 |
+
require.True(t, ok)
|
| 623 |
+
assert.Equal(t, "enabled", thinking["type"])
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
func TestGetProviderOptionsTopKExtraBody(t *testing.T) {
|
| 627 |
+
// "ollama" has a registered discover.Enricher, so it is treated as a
|
| 628 |
+
// known custom provider speaking openai-compat.
|
| 629 |
+
knownCustomProviderCfg := config.ProviderConfig{ID: "ollama", Type: "ollama"}
|
| 630 |
+
|
| 631 |
+
t.Run("model top_k is injected into extra_body for known custom providers", func(t *testing.T) {
|
| 632 |
+
model := Model{
|
| 633 |
+
CatwalkCfg: catwalk.Model{ID: "llama3"},
|
| 634 |
+
ModelCfg: config.SelectedModel{Provider: "ollama", TopK: ptr(int64(40))},
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
opts := getProviderOptions(model, knownCustomProviderCfg)
|
| 638 |
+
|
| 639 |
+
raw, ok := opts[openaicompat.Name]
|
| 640 |
+
require.True(t, ok)
|
| 641 |
+
parsed, ok := raw.(*openaicompat.ProviderOptions)
|
| 642 |
+
require.True(t, ok)
|
| 643 |
+
topK, ok := parsed.ExtraBody["top_k"].(int64)
|
| 644 |
+
require.True(t, ok)
|
| 645 |
+
assert.Equal(t, int64(40), topK)
|
| 646 |
+
})
|
| 647 |
+
|
| 648 |
+
t.Run("falls back to catwalk top_k when the model config has none", func(t *testing.T) {
|
| 649 |
+
model := Model{
|
| 650 |
+
CatwalkCfg: catwalk.Model{
|
| 651 |
+
ID: "llama3",
|
| 652 |
+
Options: catwalk.ModelOptions{TopK: ptr(int64(64))},
|
| 653 |
+
},
|
| 654 |
+
ModelCfg: config.SelectedModel{Provider: "ollama"},
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
opts := getProviderOptions(model, knownCustomProviderCfg)
|
| 658 |
+
|
| 659 |
+
raw, ok := opts[openaicompat.Name]
|
| 660 |
+
require.True(t, ok)
|
| 661 |
+
parsed, ok := raw.(*openaicompat.ProviderOptions)
|
| 662 |
+
require.True(t, ok)
|
| 663 |
+
topK, ok := parsed.ExtraBody["top_k"].(int64)
|
| 664 |
+
require.True(t, ok)
|
| 665 |
+
assert.Equal(t, int64(64), topK)
|
| 666 |
+
})
|
| 667 |
+
|
| 668 |
+
t.Run("does not set extra_body when no top_k is configured anywhere", func(t *testing.T) {
|
| 669 |
+
model := Model{
|
| 670 |
+
CatwalkCfg: catwalk.Model{ID: "llama3"},
|
| 671 |
+
ModelCfg: config.SelectedModel{Provider: "ollama"},
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
opts := getProviderOptions(model, knownCustomProviderCfg)
|
| 675 |
+
|
| 676 |
+
raw, ok := opts[openaicompat.Name]
|
| 677 |
+
require.True(t, ok)
|
| 678 |
+
parsed, ok := raw.(*openaicompat.ProviderOptions)
|
| 679 |
+
require.True(t, ok)
|
| 680 |
+
_, hasTopK := parsed.ExtraBody["top_k"]
|
| 681 |
+
assert.False(t, hasTopK)
|
| 682 |
+
})
|
| 683 |
+
|
| 684 |
+
t.Run("does not overwrite an explicitly configured extra_body.top_k", func(t *testing.T) {
|
| 685 |
+
model := Model{
|
| 686 |
+
CatwalkCfg: catwalk.Model{ID: "llama3"},
|
| 687 |
+
ModelCfg: config.SelectedModel{
|
| 688 |
+
Provider: "ollama",
|
| 689 |
+
TopK: ptr(int64(40)),
|
| 690 |
+
ProviderOptions: map[string]any{
|
| 691 |
+
"extra_body": map[string]any{"top_k": 7},
|
| 692 |
+
},
|
| 693 |
+
},
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
opts := getProviderOptions(model, knownCustomProviderCfg)
|
| 697 |
+
|
| 698 |
+
raw, ok := opts[openaicompat.Name]
|
| 699 |
+
require.True(t, ok)
|
| 700 |
+
parsed, ok := raw.(*openaicompat.ProviderOptions)
|
| 701 |
+
require.True(t, ok)
|
| 702 |
+
assert.EqualValues(t, 7, parsed.ExtraBody["top_k"])
|
| 703 |
+
})
|
| 704 |
+
|
| 705 |
+
t.Run("is not injected for providers outside the known-custom-provider default branch", func(t *testing.T) {
|
| 706 |
+
model := Model{
|
| 707 |
+
CatwalkCfg: catwalk.Model{ID: "glm-5.2"},
|
| 708 |
+
ModelCfg: config.SelectedModel{Provider: "zai", TopK: ptr(int64(40))},
|
| 709 |
+
}
|
| 710 |
+
providerCfg := config.ProviderConfig{ID: string(catwalk.InferenceProviderZAI), Type: openaicompat.Name}
|
| 711 |
+
|
| 712 |
+
opts := getProviderOptions(model, providerCfg)
|
| 713 |
+
|
| 714 |
+
raw, ok := opts[openaicompat.Name]
|
| 715 |
+
require.True(t, ok)
|
| 716 |
+
parsed, ok := raw.(*openaicompat.ProviderOptions)
|
| 717 |
+
require.True(t, ok)
|
| 718 |
+
_, hasTopK := parsed.ExtraBody["top_k"]
|
| 719 |
+
assert.False(t, hasTopK)
|
| 720 |
+
})
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
func TestGetProviderOptionsMalformedFallback(t *testing.T) {
|
| 724 |
+
model := Model{
|
| 725 |
+
CatwalkCfg: catwalk.Model{ID: "llama3"},
|
| 726 |
+
ModelCfg: config.SelectedModel{
|
| 727 |
+
Provider: "ollama",
|
| 728 |
+
TopK: ptr(int64(40)),
|
| 729 |
+
ProviderOptions: map[string]any{"user": 5.0},
|
| 730 |
+
},
|
| 731 |
+
}
|
| 732 |
+
providerCfg := config.ProviderConfig{ID: "test", Type: "ollama"}
|
| 733 |
+
|
| 734 |
+
opts := getProviderOptions(model, providerCfg)
|
| 735 |
+
|
| 736 |
+
raw, ok := opts[openaicompat.Name]
|
| 737 |
+
require.True(t, ok, "malformed provider_options should still fall back to top_k")
|
| 738 |
+
parsed, ok := raw.(*openaicompat.ProviderOptions)
|
| 739 |
+
require.True(t, ok)
|
| 740 |
+
|
| 741 |
+
// The malformed fields are dropped; only the injected top_k survives.
|
| 742 |
+
assert.Nil(t, parsed.User)
|
| 743 |
+
assert.Nil(t, parsed.ReasoningEffort)
|
| 744 |
+
require.Len(t, parsed.ExtraBody, 1)
|
| 745 |
+
topK, ok := parsed.ExtraBody["top_k"].(int64)
|
| 746 |
+
require.True(t, ok)
|
| 747 |
+
assert.Equal(t, int64(40), topK)
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
func TestCallTopK(t *testing.T) {
|
| 751 |
+
knownCustomProviderCfg := config.ProviderConfig{ID: "ollama", Type: "ollama"}
|
| 752 |
+
|
| 753 |
+
tests := []struct {
|
| 754 |
+
name string
|
| 755 |
+
providerCfg config.ProviderConfig
|
| 756 |
+
want *int64
|
| 757 |
+
}{
|
| 758 |
+
{
|
| 759 |
+
name: "suppressed for known custom providers",
|
| 760 |
+
providerCfg: knownCustomProviderCfg,
|
| 761 |
+
want: nil,
|
| 762 |
+
},
|
| 763 |
+
{
|
| 764 |
+
name: "passed through for openai-compat hosted providers",
|
| 765 |
+
providerCfg: config.ProviderConfig{ID: "zai", Type: openaicompat.Name},
|
| 766 |
+
want: ptr(int64(40)),
|
| 767 |
+
},
|
| 768 |
+
{
|
| 769 |
+
name: "passed through for anthropic",
|
| 770 |
+
providerCfg: config.ProviderConfig{ID: "anthropic", Type: catwalk.Type(anthropic.Name)},
|
| 771 |
+
want: ptr(int64(40)),
|
| 772 |
+
},
|
| 773 |
+
}
|
| 774 |
+
for _, tc := range tests {
|
| 775 |
+
t.Run(tc.name, func(t *testing.T) {
|
| 776 |
+
got := callTopK(tc.providerCfg, ptr(int64(40)))
|
| 777 |
+
if tc.want == nil {
|
| 778 |
+
assert.Nil(t, got)
|
| 779 |
+
return
|
| 780 |
+
}
|
| 781 |
+
require.NotNil(t, got)
|
| 782 |
+
assert.Equal(t, *tc.want, *got)
|
| 783 |
+
})
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
t.Run("nil input stays nil regardless of provider", func(t *testing.T) {
|
| 787 |
+
assert.Nil(t, callTopK(knownCustomProviderCfg, nil))
|
| 788 |
+
assert.Nil(t, callTopK(config.ProviderConfig{Type: openaicompat.Name}, nil))
|
| 789 |
+
})
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
func ptr[T any](v T) *T { return &v }
|
internal/agent/dispatch_cancel_test.go
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"sync/atomic"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"charm.land/fantasy"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/agent/notify"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 13 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 14 |
+
"github.com/stretchr/testify/assert"
|
| 15 |
+
"github.com/stretchr/testify/require"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
// finishStreamModel is a minimal fantasy.LanguageModel that streams a
|
| 19 |
+
// single text part followed by a normal (FinishReasonStop) finish. It
|
| 20 |
+
// is enough to drive sessionAgent.Run through PrepareStep and a clean
|
| 21 |
+
// completion without a recorded provider cassette.
|
| 22 |
+
type finishStreamModel struct {
|
| 23 |
+
text string
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
func (m *finishStreamModel) Provider() string { return "fake" }
|
| 27 |
+
func (m *finishStreamModel) Model() string { return "fake-model" }
|
| 28 |
+
|
| 29 |
+
func (m *finishStreamModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
| 30 |
+
return &fantasy.Response{
|
| 31 |
+
Content: fantasy.ResponseContent{fantasy.TextContent{Text: m.text}},
|
| 32 |
+
FinishReason: fantasy.FinishReasonStop,
|
| 33 |
+
}, nil
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func (m *finishStreamModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
| 37 |
+
text := m.text
|
| 38 |
+
return func(yield func(fantasy.StreamPart) bool) {
|
| 39 |
+
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "1"}) {
|
| 40 |
+
return
|
| 41 |
+
}
|
| 42 |
+
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "1", Delta: text}) {
|
| 43 |
+
return
|
| 44 |
+
}
|
| 45 |
+
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "1"}) {
|
| 46 |
+
return
|
| 47 |
+
}
|
| 48 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop})
|
| 49 |
+
}, nil
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
func (m *finishStreamModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
| 53 |
+
return nil, errors.New("not implemented")
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func (m *finishStreamModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
| 57 |
+
return nil, errors.New("not implemented")
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
func newStreamTestAgent(t *testing.T) (*sessionAgent, fakeEnv) {
|
| 61 |
+
t.Helper()
|
| 62 |
+
env := testEnv(t)
|
| 63 |
+
model := &finishStreamModel{text: "done"}
|
| 64 |
+
sa := testSessionAgent(env, model, model, "system").(*sessionAgent)
|
| 65 |
+
return sa, env
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// TestCancel_ActiveAndAcceptedFiresBothBranches covers the case where a
|
| 69 |
+
// session is actively running (activeRequests set) AND a follow-up has
|
| 70 |
+
// been accepted (acceptedRuns > 0). A single Cancel must fire both: it
|
| 71 |
+
// invokes the active cancel func and records a pending cancel for the
|
| 72 |
+
// accepted follow-up.
|
| 73 |
+
func TestCancel_ActiveAndAcceptedFiresBothBranches(t *testing.T) {
|
| 74 |
+
t.Parallel()
|
| 75 |
+
sa, _ := newCancelTestAgent(t)
|
| 76 |
+
|
| 77 |
+
const sid = "sid"
|
| 78 |
+
var activeCanceled atomic.Bool
|
| 79 |
+
sa.activeRequests.Set(sid, &activeCancel{cancel: func() { activeCanceled.Store(true) }})
|
| 80 |
+
|
| 81 |
+
accept := sa.BeginAccepted(sid)
|
| 82 |
+
defer accept.Close()
|
| 83 |
+
|
| 84 |
+
sa.Cancel(sid)
|
| 85 |
+
|
| 86 |
+
require.True(t, activeCanceled.Load(), "active cancel func must fire")
|
| 87 |
+
require.True(t, sa.hasPendingCancel(sid), "accepted follow-up must record a pending cancel")
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// TestRun_BusyWithPendingCancelTakesCancelOnEntry covers the busy-queue
|
| 91 |
+
// branch consulting pendingCancels: when the session is busy AND a
|
| 92 |
+
// cancel has been recorded for an accepted follow-up, Run must take the
|
| 93 |
+
// cancel-on-entry path (persist a canceled turn) instead of enqueueing
|
| 94 |
+
// the call behind the active run.
|
| 95 |
+
func TestRun_BusyWithPendingCancelTakesCancelOnEntry(t *testing.T) {
|
| 96 |
+
t.Parallel()
|
| 97 |
+
sa, env := newCancelTestAgent(t)
|
| 98 |
+
|
| 99 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 100 |
+
require.NoError(t, err)
|
| 101 |
+
|
| 102 |
+
// Make the session look busy: an earlier prompt is active.
|
| 103 |
+
sa.activeRequests.Set(sess.ID, &activeCancel{cancel: func() {}})
|
| 104 |
+
|
| 105 |
+
accept := sa.BeginAccepted(sess.ID)
|
| 106 |
+
// A cancel arrives while this follow-up is accepted-but-not-active.
|
| 107 |
+
sa.Cancel(sess.ID)
|
| 108 |
+
require.True(t, sa.hasPendingCancel(sess.ID))
|
| 109 |
+
|
| 110 |
+
result, err := sa.Run(t.Context(), SessionAgentCall{
|
| 111 |
+
SessionID: sess.ID,
|
| 112 |
+
Prompt: "follow-up",
|
| 113 |
+
Accepted: accept,
|
| 114 |
+
})
|
| 115 |
+
require.NoError(t, err)
|
| 116 |
+
require.Nil(t, result)
|
| 117 |
+
|
| 118 |
+
// The follow-up was canceled on entry, not enqueued.
|
| 119 |
+
require.Equal(t, 0, sa.QueuedPrompts(sess.ID),
|
| 120 |
+
"cancel-on-entry must not enqueue the follow-up behind the active run")
|
| 121 |
+
require.False(t, sa.hasPendingCancel(sess.ID), "pending cancel must be consumed")
|
| 122 |
+
require.Equal(t, 0, sa.acceptedCount(sess.ID), "accept reservation must be released")
|
| 123 |
+
|
| 124 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 125 |
+
require.NoError(t, err)
|
| 126 |
+
require.Len(t, msgs, 2)
|
| 127 |
+
assert.Equal(t, message.User, msgs[0].Role)
|
| 128 |
+
assert.Equal(t, message.Assistant, msgs[1].Role)
|
| 129 |
+
assert.Equal(t, message.FinishReasonCanceled, msgs[1].FinishReason())
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// TestRun_PrepareStepDrainSkipsQueuedOnPendingCancel verifies that the
|
| 133 |
+
// queue drain inside PrepareStep skips queued follow-up prompts when a
|
| 134 |
+
// cancel has been recorded for the session: the queued prompt must not
|
| 135 |
+
// be folded into the active turn as an extra user message.
|
| 136 |
+
func TestRun_PrepareStepDrainSkipsQueuedOnPendingCancel(t *testing.T) {
|
| 137 |
+
t.Parallel()
|
| 138 |
+
sa, env := newStreamTestAgent(t)
|
| 139 |
+
|
| 140 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 141 |
+
require.NoError(t, err)
|
| 142 |
+
|
| 143 |
+
// A follow-up prompt sits queued for the session.
|
| 144 |
+
sa.enqueueCall(SessionAgentCall{SessionID: sess.ID, Prompt: "queued-followup"})
|
| 145 |
+
// A cancel was recorded for the session while it sat in the queue.
|
| 146 |
+
sa.cancelMark.Set(sess.ID, 1)
|
| 147 |
+
|
| 148 |
+
result, err := sa.Run(t.Context(), SessionAgentCall{
|
| 149 |
+
SessionID: sess.ID,
|
| 150 |
+
Prompt: "main",
|
| 151 |
+
})
|
| 152 |
+
require.NoError(t, err)
|
| 153 |
+
require.NotNil(t, result)
|
| 154 |
+
|
| 155 |
+
// Only the main prompt produced a user message; the queued
|
| 156 |
+
// follow-up was skipped, not folded into the turn.
|
| 157 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 158 |
+
require.NoError(t, err)
|
| 159 |
+
var userMsgs []message.Message
|
| 160 |
+
for _, m := range msgs {
|
| 161 |
+
if m.Role == message.User {
|
| 162 |
+
userMsgs = append(userMsgs, m)
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
require.Len(t, userMsgs, 1, "queued follow-up must not create a user message")
|
| 166 |
+
assert.Equal(t, "main", userMsgs[0].Content().String())
|
| 167 |
+
|
| 168 |
+
// The queue was drained and the pending cancel consumed.
|
| 169 |
+
require.Equal(t, 0, sa.QueuedPrompts(sess.ID))
|
| 170 |
+
require.False(t, sa.hasPendingCancel(sess.ID))
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// TestRun_NormalCompletionClearsStalePendingCancel verifies that a Run
|
| 174 |
+
// which completes normally clears any stale pending-cancel entry for the
|
| 175 |
+
// session, so it cannot catch a future run.
|
| 176 |
+
func TestRun_NormalCompletionClearsStalePendingCancel(t *testing.T) {
|
| 177 |
+
t.Parallel()
|
| 178 |
+
sa, env := newStreamTestAgent(t)
|
| 179 |
+
|
| 180 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 181 |
+
require.NoError(t, err)
|
| 182 |
+
|
| 183 |
+
// A stale cancel mark lingers (no queued work, no accepted run).
|
| 184 |
+
sa.cancelMark.Set(sess.ID, 1)
|
| 185 |
+
|
| 186 |
+
result, err := sa.Run(t.Context(), SessionAgentCall{
|
| 187 |
+
SessionID: sess.ID,
|
| 188 |
+
Prompt: "main",
|
| 189 |
+
})
|
| 190 |
+
require.NoError(t, err)
|
| 191 |
+
require.NotNil(t, result)
|
| 192 |
+
|
| 193 |
+
require.False(t, sa.hasPendingCancel(sess.ID),
|
| 194 |
+
"normal completion must clear the stale pending cancel")
|
| 195 |
+
|
| 196 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 197 |
+
require.NoError(t, err)
|
| 198 |
+
require.Len(t, msgs, 2)
|
| 199 |
+
assert.Equal(t, message.Assistant, msgs[1].Role)
|
| 200 |
+
assert.Equal(t, message.FinishReasonEndTurn, msgs[1].FinishReason())
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// newCancelTestAgentWithRunComplete builds a DB-backed sessionAgent wired
|
| 204 |
+
// to a RunComplete broker so tests can observe the terminal event a
|
| 205 |
+
// RunID-bearing caller (e.g. `crush run`) blocks on.
|
| 206 |
+
func newCancelTestAgentWithRunComplete(t *testing.T) (*sessionAgent, fakeEnv, *pubsub.Broker[notify.RunComplete]) {
|
| 207 |
+
t.Helper()
|
| 208 |
+
env := testEnv(t)
|
| 209 |
+
broker := pubsub.NewBroker[notify.RunComplete]()
|
| 210 |
+
t.Cleanup(broker.Shutdown)
|
| 211 |
+
sa := NewSessionAgent(SessionAgentOptions{
|
| 212 |
+
Sessions: env.sessions,
|
| 213 |
+
Messages: env.messages,
|
| 214 |
+
RunComplete: broker,
|
| 215 |
+
}).(*sessionAgent)
|
| 216 |
+
return sa, env, broker
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// TestRun_CancelOnEntryPublishesRunComplete covers the first review
|
| 220 |
+
// finding: the cancel-on-entry path returned before the streaming defer
|
| 221 |
+
// that publishes RunComplete was installed. A caller that dispatches a
|
| 222 |
+
// run with a RunID and blocks on RunComplete (ignoring message events,
|
| 223 |
+
// like `crush run`) would hang on an immediately-canceled accepted run.
|
| 224 |
+
// The cancel-on-entry path must publish a terminal RunComplete carrying
|
| 225 |
+
// the originating RunID.
|
| 226 |
+
func TestRun_CancelOnEntryPublishesRunComplete(t *testing.T) {
|
| 227 |
+
t.Parallel()
|
| 228 |
+
sa, env, broker := newCancelTestAgentWithRunComplete(t)
|
| 229 |
+
|
| 230 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 231 |
+
require.NoError(t, err)
|
| 232 |
+
|
| 233 |
+
ctx, cancel := context.WithCancel(t.Context())
|
| 234 |
+
defer cancel()
|
| 235 |
+
ch := broker.Subscribe(ctx)
|
| 236 |
+
|
| 237 |
+
accept := sa.BeginAccepted(sess.ID)
|
| 238 |
+
// A cancel arrives in the accepted-but-not-yet-active window.
|
| 239 |
+
sa.Cancel(sess.ID)
|
| 240 |
+
require.True(t, sa.hasPendingCancel(sess.ID))
|
| 241 |
+
|
| 242 |
+
result, err := sa.Run(t.Context(), SessionAgentCall{
|
| 243 |
+
SessionID: sess.ID,
|
| 244 |
+
RunID: "run-cancel-on-entry",
|
| 245 |
+
Prompt: "hello",
|
| 246 |
+
Accepted: accept,
|
| 247 |
+
})
|
| 248 |
+
require.NoError(t, err)
|
| 249 |
+
require.Nil(t, result)
|
| 250 |
+
|
| 251 |
+
select {
|
| 252 |
+
case got := <-ch:
|
| 253 |
+
assert.Equal(t, "run-cancel-on-entry", got.Payload.RunID,
|
| 254 |
+
"RunComplete must echo the originating RunID")
|
| 255 |
+
assert.Equal(t, sess.ID, got.Payload.SessionID)
|
| 256 |
+
assert.True(t, got.Payload.Cancelled,
|
| 257 |
+
"cancel-on-entry RunComplete must be marked cancelled")
|
| 258 |
+
case <-time.After(2 * time.Second):
|
| 259 |
+
t.Fatal("cancel-on-entry must publish RunComplete; a RunID caller would hang otherwise")
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// TestCancel_TwoAcceptedBothObserveCancellation covers the second review
|
| 264 |
+
// finding: a single cancel with two accepted-not-yet-active prompts must
|
| 265 |
+
// cancel both. The cancel raises the session's high-water mark to the
|
| 266 |
+
// latest accept sequence, so every prompt accepted-but-not-yet-active at
|
| 267 |
+
// cancel time is covered and both take the cancel-on-entry path.
|
| 268 |
+
func TestCancel_TwoAcceptedBothObserveCancellation(t *testing.T) {
|
| 269 |
+
t.Parallel()
|
| 270 |
+
sa, env := newCancelTestAgent(t)
|
| 271 |
+
|
| 272 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 273 |
+
require.NoError(t, err)
|
| 274 |
+
|
| 275 |
+
// Two prompts are accepted-but-not-yet-active for the same session.
|
| 276 |
+
accept1 := sa.BeginAccepted(sess.ID)
|
| 277 |
+
accept2 := sa.BeginAccepted(sess.ID)
|
| 278 |
+
require.Equal(t, 2, sa.acceptedCount(sess.ID))
|
| 279 |
+
|
| 280 |
+
// A single cancel arrives before either becomes active.
|
| 281 |
+
sa.Cancel(sess.ID)
|
| 282 |
+
require.Equal(t, accept2.seq, sa.pendingCancelMark(sess.ID),
|
| 283 |
+
"one cancel must mark every currently-accepted prompt as canceled")
|
| 284 |
+
require.GreaterOrEqual(t, sa.pendingCancelMark(sess.ID), accept1.seq,
|
| 285 |
+
"the mark must cover the earlier accepted prompt too")
|
| 286 |
+
|
| 287 |
+
// Both prompts enter Run; each must take cancel-on-entry, not run.
|
| 288 |
+
r1, err := sa.Run(t.Context(), SessionAgentCall{
|
| 289 |
+
SessionID: sess.ID,
|
| 290 |
+
Prompt: "first",
|
| 291 |
+
Accepted: accept1,
|
| 292 |
+
})
|
| 293 |
+
require.NoError(t, err)
|
| 294 |
+
require.Nil(t, r1)
|
| 295 |
+
|
| 296 |
+
r2, err := sa.Run(t.Context(), SessionAgentCall{
|
| 297 |
+
SessionID: sess.ID,
|
| 298 |
+
Prompt: "second",
|
| 299 |
+
Accepted: accept2,
|
| 300 |
+
})
|
| 301 |
+
require.NoError(t, err)
|
| 302 |
+
require.Nil(t, r2)
|
| 303 |
+
|
| 304 |
+
require.False(t, sa.hasPendingCancel(sess.ID),
|
| 305 |
+
"both reserved units must be consumed")
|
| 306 |
+
require.Equal(t, 0, sa.acceptedCount(sess.ID),
|
| 307 |
+
"both accept reservations must be released")
|
| 308 |
+
|
| 309 |
+
// Each canceled-on-entry turn writes a user + canceled assistant
|
| 310 |
+
// message, and neither prompt was enqueued to run normally.
|
| 311 |
+
require.Equal(t, 0, sa.QueuedPrompts(sess.ID),
|
| 312 |
+
"neither accepted prompt may be enqueued to run normally")
|
| 313 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 314 |
+
require.NoError(t, err)
|
| 315 |
+
require.Len(t, msgs, 4, "two canceled turns produce two user + two assistant messages")
|
| 316 |
+
var canceled int
|
| 317 |
+
for _, m := range msgs {
|
| 318 |
+
if m.Role == message.Assistant {
|
| 319 |
+
assert.Equal(t, message.FinishReasonCanceled, m.FinishReason())
|
| 320 |
+
canceled++
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
require.Equal(t, 2, canceled, "both turns must finish canceled")
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
// TestRun_IdleCancelDoesNotPoisonNextPrompt covers the idle-cancel
|
| 327 |
+
// no-op guarantee end-to-end: an Escape on an idle session must not
|
| 328 |
+
// record a pending cancel that leaks into the next accepted prompt, which
|
| 329 |
+
// must run normally to completion.
|
| 330 |
+
func TestRun_IdleCancelDoesNotPoisonNextPrompt(t *testing.T) {
|
| 331 |
+
t.Parallel()
|
| 332 |
+
sa, env := newStreamTestAgent(t)
|
| 333 |
+
|
| 334 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 335 |
+
require.NoError(t, err)
|
| 336 |
+
|
| 337 |
+
// Idle Escape: no accepted run, no active request.
|
| 338 |
+
sa.Cancel(sess.ID)
|
| 339 |
+
require.False(t, sa.hasPendingCancel(sess.ID),
|
| 340 |
+
"idle cancel must not record a pending cancel")
|
| 341 |
+
|
| 342 |
+
// The next accepted prompt must run normally, not cancel on entry.
|
| 343 |
+
accept := sa.BeginAccepted(sess.ID)
|
| 344 |
+
result, err := sa.Run(t.Context(), SessionAgentCall{
|
| 345 |
+
SessionID: sess.ID,
|
| 346 |
+
Prompt: "next",
|
| 347 |
+
Accepted: accept,
|
| 348 |
+
})
|
| 349 |
+
require.NoError(t, err)
|
| 350 |
+
require.NotNil(t, result, "next prompt must run normally after an idle cancel")
|
| 351 |
+
|
| 352 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 353 |
+
require.NoError(t, err)
|
| 354 |
+
require.Len(t, msgs, 2)
|
| 355 |
+
assert.Equal(t, message.User, msgs[0].Role)
|
| 356 |
+
assert.Equal(t, message.Assistant, msgs[1].Role)
|
| 357 |
+
assert.Equal(t, message.FinishReasonEndTurn, msgs[1].FinishReason(),
|
| 358 |
+
"the prompt must finish normally, not canceled")
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// TestCancel_AcceptedAfterCancelIsNotPoisoned is the regression test for
|
| 362 |
+
// the reviewer's finding: a counted session-level pending cancel let a
|
| 363 |
+
// prompt accepted after the cancel enter Run first and consume a unit
|
| 364 |
+
// reserved for the earlier prompts. With a sequence high-water mark, a
|
| 365 |
+
// single cancel covers exactly the prompts accepted-but-not-yet-active at
|
| 366 |
+
// cancel time (A and B); a prompt accepted afterwards (C) gets a higher
|
| 367 |
+
// sequence and must run normally without consuming A or B's cancellation.
|
| 368 |
+
// C is run first to prove it neither cancels nor drains the mark, then A
|
| 369 |
+
// and B are run and must both cancel on entry.
|
| 370 |
+
func TestCancel_AcceptedAfterCancelIsNotPoisoned(t *testing.T) {
|
| 371 |
+
t.Parallel()
|
| 372 |
+
sa, env := newStreamTestAgent(t)
|
| 373 |
+
|
| 374 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 375 |
+
require.NoError(t, err)
|
| 376 |
+
|
| 377 |
+
// A and B are accepted-but-not-yet-active.
|
| 378 |
+
acceptA := sa.BeginAccepted(sess.ID)
|
| 379 |
+
acceptB := sa.BeginAccepted(sess.ID)
|
| 380 |
+
|
| 381 |
+
// One cancel arrives covering both A and B.
|
| 382 |
+
sa.Cancel(sess.ID)
|
| 383 |
+
require.True(t, sa.hasPendingCancel(sess.ID))
|
| 384 |
+
require.Equal(t, acceptB.seq, sa.pendingCancelMark(sess.ID),
|
| 385 |
+
"the mark must cover every prompt accepted before the cancel")
|
| 386 |
+
|
| 387 |
+
// C is accepted AFTER the cancel; its sequence is above the mark.
|
| 388 |
+
acceptC := sa.BeginAccepted(sess.ID)
|
| 389 |
+
require.Greater(t, acceptC.seq, sa.pendingCancelMark(sess.ID),
|
| 390 |
+
"a prompt accepted after the cancel must not be covered by the mark")
|
| 391 |
+
|
| 392 |
+
// Run C first. It must run normally to completion and must not
|
| 393 |
+
// consume or clear the cancellation reserved for A and B.
|
| 394 |
+
rc, err := sa.Run(t.Context(), SessionAgentCall{
|
| 395 |
+
SessionID: sess.ID,
|
| 396 |
+
Prompt: "C",
|
| 397 |
+
Accepted: acceptC,
|
| 398 |
+
})
|
| 399 |
+
require.NoError(t, err)
|
| 400 |
+
require.NotNil(t, rc, "C was accepted after the cancel and must run normally")
|
| 401 |
+
require.True(t, sa.hasPendingCancel(sess.ID),
|
| 402 |
+
"running C must not drain the cancellation reserved for A and B")
|
| 403 |
+
|
| 404 |
+
// Now A and B run. Both were accepted before the cancel and must
|
| 405 |
+
// take the cancel-on-entry path.
|
| 406 |
+
ra, err := sa.Run(t.Context(), SessionAgentCall{
|
| 407 |
+
SessionID: sess.ID,
|
| 408 |
+
Prompt: "A",
|
| 409 |
+
Accepted: acceptA,
|
| 410 |
+
})
|
| 411 |
+
require.NoError(t, err)
|
| 412 |
+
require.Nil(t, ra, "A must cancel on entry, not run")
|
| 413 |
+
|
| 414 |
+
rb, err := sa.Run(t.Context(), SessionAgentCall{
|
| 415 |
+
SessionID: sess.ID,
|
| 416 |
+
Prompt: "B",
|
| 417 |
+
Accepted: acceptB,
|
| 418 |
+
})
|
| 419 |
+
require.NoError(t, err)
|
| 420 |
+
require.Nil(t, rb, "B must cancel on entry, not run")
|
| 421 |
+
|
| 422 |
+
require.False(t, sa.hasPendingCancel(sess.ID),
|
| 423 |
+
"the mark clears once all covered handles are resolved")
|
| 424 |
+
require.Equal(t, 0, sa.acceptedCount(sess.ID))
|
| 425 |
+
require.Equal(t, 0, sa.QueuedPrompts(sess.ID),
|
| 426 |
+
"neither A nor B may be enqueued to run normally")
|
| 427 |
+
|
| 428 |
+
// C produced a normal turn; A and B each produced a canceled turn.
|
| 429 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 430 |
+
require.NoError(t, err)
|
| 431 |
+
require.Len(t, msgs, 6, "C normal + A canceled + B canceled = 3 user + 3 assistant")
|
| 432 |
+
|
| 433 |
+
var normal, canceled int
|
| 434 |
+
for _, m := range msgs {
|
| 435 |
+
if m.Role != message.Assistant {
|
| 436 |
+
continue
|
| 437 |
+
}
|
| 438 |
+
switch m.FinishReason() {
|
| 439 |
+
case message.FinishReasonEndTurn:
|
| 440 |
+
normal++
|
| 441 |
+
case message.FinishReasonCanceled:
|
| 442 |
+
canceled++
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
require.Equal(t, 1, normal, "only C finished normally")
|
| 446 |
+
require.Equal(t, 2, canceled, "both A and B finished canceled")
|
| 447 |
+
}
|
internal/agent/dispatch_race_test.go
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"sync"
|
| 7 |
+
"sync/atomic"
|
| 8 |
+
"testing"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"charm.land/fantasy"
|
| 12 |
+
"github.com/stretchr/testify/require"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// concurrencyProbeModel records the maximum number of Stream iterators in
|
| 16 |
+
// flight at once. Each stream blocks on release, so a second dispatch that
|
| 17 |
+
// wrongly started a concurrent run for the same session is observable as an
|
| 18 |
+
// in-flight count above one.
|
| 19 |
+
type concurrencyProbeModel struct {
|
| 20 |
+
inFlight atomic.Int32
|
| 21 |
+
maxSeen atomic.Int32
|
| 22 |
+
entered chan struct{}
|
| 23 |
+
release chan struct{}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
func (m *concurrencyProbeModel) Provider() string { return "fake" }
|
| 27 |
+
func (m *concurrencyProbeModel) Model() string { return "fake-model" }
|
| 28 |
+
|
| 29 |
+
func (m *concurrencyProbeModel) Generate(context.Context, fantasy.Call) (*fantasy.Response, error) {
|
| 30 |
+
return &fantasy.Response{
|
| 31 |
+
Content: fantasy.ResponseContent{fantasy.TextContent{Text: "done"}},
|
| 32 |
+
FinishReason: fantasy.FinishReasonStop,
|
| 33 |
+
}, nil
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func (m *concurrencyProbeModel) Stream(context.Context, fantasy.Call) (fantasy.StreamResponse, error) {
|
| 37 |
+
return func(yield func(fantasy.StreamPart) bool) {
|
| 38 |
+
cur := m.inFlight.Add(1)
|
| 39 |
+
for {
|
| 40 |
+
mx := m.maxSeen.Load()
|
| 41 |
+
if cur <= mx || m.maxSeen.CompareAndSwap(mx, cur) {
|
| 42 |
+
break
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
// Signal that a stream is in flight (non-blocking), then hold here
|
| 46 |
+
// so a racing second dispatch would be caught by maxSeen.
|
| 47 |
+
select {
|
| 48 |
+
case m.entered <- struct{}{}:
|
| 49 |
+
default:
|
| 50 |
+
}
|
| 51 |
+
<-m.release
|
| 52 |
+
m.inFlight.Add(-1)
|
| 53 |
+
|
| 54 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "1"})
|
| 55 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "1", Delta: "done"})
|
| 56 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "1"})
|
| 57 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop})
|
| 58 |
+
}, nil
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func (m *concurrencyProbeModel) GenerateObject(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
| 62 |
+
return nil, errors.New("not implemented")
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
func (m *concurrencyProbeModel) StreamObject(context.Context, fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
| 66 |
+
return nil, errors.New("not implemented")
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// fastModel is a non-blocking model used as the small model in concurrency
|
| 70 |
+
// tests. GenerateTitle runs on the small model; if it shares the probe model,
|
| 71 |
+
// its Stream call races into inFlight/maxSeen and produces spurious failures.
|
| 72 |
+
type fastModel struct{}
|
| 73 |
+
|
| 74 |
+
func (fastModel) Provider() string { return "fake" }
|
| 75 |
+
func (fastModel) Model() string { return "fake-model" }
|
| 76 |
+
|
| 77 |
+
func (fastModel) Generate(context.Context, fantasy.Call) (*fantasy.Response, error) {
|
| 78 |
+
return &fantasy.Response{
|
| 79 |
+
Content: fantasy.ResponseContent{fantasy.TextContent{Text: "title"}},
|
| 80 |
+
FinishReason: fantasy.FinishReasonStop,
|
| 81 |
+
}, nil
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
func (fastModel) Stream(context.Context, fantasy.Call) (fantasy.StreamResponse, error) {
|
| 85 |
+
return func(yield func(fantasy.StreamPart) bool) {
|
| 86 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "1"})
|
| 87 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "1", Delta: "title"})
|
| 88 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "1"})
|
| 89 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop})
|
| 90 |
+
}, nil
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
func (fastModel) GenerateObject(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
| 94 |
+
return nil, errors.New("not implemented")
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
func (fastModel) StreamObject(context.Context, fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
| 98 |
+
return nil, errors.New("not implemented")
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// TestRun_ConcurrentInProcessDispatchStartsOneRun fires a burst of concurrent
|
| 102 |
+
// in-process Run calls (the path channel events use) at an idle session. Only
|
| 103 |
+
// one may become the active run; the rest must queue behind it. Before the
|
| 104 |
+
// dispatch decision was serialized under the per-session mutex, two callers
|
| 105 |
+
// could both pass the busy check and start two runs on the same session — this
|
| 106 |
+
// test catches that regression (maxSeen would exceed one).
|
| 107 |
+
//
|
| 108 |
+
// fastModel is used as the small model so GenerateTitle (which runs on the
|
| 109 |
+
// small model) does not race into the probe's inFlight/maxSeen counters. The
|
| 110 |
+
// queue count is not asserted because PrepareStep drains queued prompts into
|
| 111 |
+
// the active step before the model's Stream is called — by the time "entered"
|
| 112 |
+
// fires the queue is already empty by design.
|
| 113 |
+
func TestRun_ConcurrentInProcessDispatchStartsOneRun(t *testing.T) {
|
| 114 |
+
t.Parallel()
|
| 115 |
+
env := testEnv(t)
|
| 116 |
+
model := &concurrencyProbeModel{
|
| 117 |
+
entered: make(chan struct{}, 1),
|
| 118 |
+
release: make(chan struct{}),
|
| 119 |
+
}
|
| 120 |
+
sa := testSessionAgent(env, model, fastModel{}, "system").(*sessionAgent)
|
| 121 |
+
|
| 122 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 123 |
+
require.NoError(t, err)
|
| 124 |
+
|
| 125 |
+
const n = 8
|
| 126 |
+
var wg sync.WaitGroup
|
| 127 |
+
for range n {
|
| 128 |
+
wg.Add(1)
|
| 129 |
+
go func() {
|
| 130 |
+
defer wg.Done()
|
| 131 |
+
_, _ = sa.Run(t.Context(), SessionAgentCall{
|
| 132 |
+
SessionID: sess.ID,
|
| 133 |
+
Prompt: "event",
|
| 134 |
+
})
|
| 135 |
+
}()
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Wait until the active run's Stream is in flight (blocked on release).
|
| 139 |
+
select {
|
| 140 |
+
case <-model.entered:
|
| 141 |
+
case <-time.After(5 * time.Second):
|
| 142 |
+
close(model.release)
|
| 143 |
+
wg.Wait()
|
| 144 |
+
t.Fatal("no run became active")
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// Every other dispatch must have either queued (and been folded into the
|
| 148 |
+
// active step by PrepareStep) or never started its own Stream. Either way,
|
| 149 |
+
// at most one Stream may be in flight and the high-water mark must be one.
|
| 150 |
+
require.Equal(t, int32(1), model.inFlight.Load(), "exactly one run may be active")
|
| 151 |
+
require.Equal(t, int32(1), model.maxSeen.Load(), "no two runs may stream concurrently for one session")
|
| 152 |
+
|
| 153 |
+
close(model.release)
|
| 154 |
+
wg.Wait()
|
| 155 |
+
}
|
internal/agent/errors.go
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import "errors"
|
| 4 |
+
|
| 5 |
+
var (
|
| 6 |
+
ErrRequestCancelled = errors.New("request canceled by user")
|
| 7 |
+
ErrSessionBusy = errors.New("session is currently processing another request")
|
| 8 |
+
ErrEmptyPrompt = errors.New("prompt is empty")
|
| 9 |
+
ErrSessionMissing = errors.New("session id is missing")
|
| 10 |
+
)
|
internal/agent/event.go
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"time"
|
| 5 |
+
|
| 6 |
+
"charm.land/fantasy"
|
| 7 |
+
"github.com/charmbracelet/crush/internal/event"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
func (a *sessionAgent) eventPromptSent(sessionID string) {
|
| 11 |
+
event.PromptSent(
|
| 12 |
+
a.eventCommon(sessionID, a.largeModel.Get())...,
|
| 13 |
+
)
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
func (a *sessionAgent) eventPromptResponded(sessionID string, duration time.Duration) {
|
| 17 |
+
event.PromptResponded(
|
| 18 |
+
append(
|
| 19 |
+
a.eventCommon(sessionID, a.largeModel.Get()),
|
| 20 |
+
"prompt duration pretty", duration.String(),
|
| 21 |
+
"prompt duration in seconds", int64(duration.Seconds()),
|
| 22 |
+
)...,
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
func (a *sessionAgent) eventTokensUsed(sessionID string, model Model, usage fantasy.Usage, cost float64) {
|
| 27 |
+
event.TokensUsed(
|
| 28 |
+
append(
|
| 29 |
+
a.eventCommon(sessionID, model),
|
| 30 |
+
"input tokens", usage.InputTokens,
|
| 31 |
+
"output tokens", usage.OutputTokens,
|
| 32 |
+
"cache read tokens", usage.CacheReadTokens,
|
| 33 |
+
"cache creation tokens", usage.CacheCreationTokens,
|
| 34 |
+
"total tokens", usage.InputTokens+usage.OutputTokens+usage.CacheReadTokens+usage.CacheCreationTokens,
|
| 35 |
+
"cost", cost,
|
| 36 |
+
)...,
|
| 37 |
+
)
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
func (a *sessionAgent) eventCommon(sessionID string, model Model) []any {
|
| 41 |
+
m := model.ModelCfg
|
| 42 |
+
|
| 43 |
+
return []any{
|
| 44 |
+
"session id", sessionID,
|
| 45 |
+
"provider", m.Provider,
|
| 46 |
+
"model", m.Model,
|
| 47 |
+
"reasoning effort", m.ReasoningEffort,
|
| 48 |
+
"thinking mode", m.Think,
|
| 49 |
+
"yolo mode", a.isYolo,
|
| 50 |
+
}
|
| 51 |
+
}
|
internal/agent/hooked_tool.go
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"fmt"
|
| 7 |
+
"log/slog"
|
| 8 |
+
|
| 9 |
+
"charm.land/fantasy"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/agent/tools"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/hooks"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/permission"
|
| 13 |
+
"github.com/tidwall/sjson"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// hookedTool wraps a fantasy.AgentTool to run PreToolUse hooks before
|
| 17 |
+
// delegating to the inner tool.
|
| 18 |
+
type hookedTool struct {
|
| 19 |
+
inner fantasy.AgentTool
|
| 20 |
+
runner *hooks.Runner
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func newHookedTool(inner fantasy.AgentTool, runner *hooks.Runner) *hookedTool {
|
| 24 |
+
return &hookedTool{inner: inner, runner: runner}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// wrapToolsWithHooks returns a tool slice with each entry wrapped in a
|
| 28 |
+
// hookedTool. Returns the original slice unchanged when runner is nil or
|
| 29 |
+
// when isSubAgent is true — sub-agents never fire hooks, the top-level
|
| 30 |
+
// invocation of the sub-agent tool itself is wrapped on the caller's side.
|
| 31 |
+
func wrapToolsWithHooks(tools []fantasy.AgentTool, runner *hooks.Runner, isSubAgent bool) []fantasy.AgentTool {
|
| 32 |
+
if runner == nil || isSubAgent {
|
| 33 |
+
return tools
|
| 34 |
+
}
|
| 35 |
+
out := make([]fantasy.AgentTool, len(tools))
|
| 36 |
+
for i, tool := range tools {
|
| 37 |
+
out[i] = newHookedTool(tool, runner)
|
| 38 |
+
}
|
| 39 |
+
return out
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
func (h *hookedTool) Info() fantasy.ToolInfo {
|
| 43 |
+
return h.inner.Info()
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
func (h *hookedTool) ProviderOptions() fantasy.ProviderOptions {
|
| 47 |
+
return h.inner.ProviderOptions()
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
func (h *hookedTool) SetProviderOptions(opts fantasy.ProviderOptions) {
|
| 51 |
+
h.inner.SetProviderOptions(opts)
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
func (h *hookedTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
| 55 |
+
sessionID := tools.GetSessionFromContext(ctx)
|
| 56 |
+
result, err := h.runner.Run(ctx, hooks.EventPreToolUse, sessionID, call.Name, call.Input)
|
| 57 |
+
if err != nil {
|
| 58 |
+
slog.Warn("Hook execution error, proceeding with tool call",
|
| 59 |
+
"tool", call.Name, "error", err)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if result.Decision == hooks.DecisionDeny || result.Halt {
|
| 63 |
+
reason := fmt.Sprintf("Tool call blocked by hook. Reason: %s", result.Reason)
|
| 64 |
+
if result.Halt {
|
| 65 |
+
reason = fmt.Sprintf("Turn halted by hook. Reason: %s", result.Reason)
|
| 66 |
+
}
|
| 67 |
+
resp := fantasy.NewTextErrorResponse(reason)
|
| 68 |
+
// Halt ends the whole turn; a plain deny only blocks this tool
|
| 69 |
+
// call so the model can see the error and try something else.
|
| 70 |
+
resp.StopTurn = result.Halt
|
| 71 |
+
resp.Metadata = hookMetadataJSON(result)
|
| 72 |
+
return resp, nil
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
if result.UpdatedInput != "" {
|
| 76 |
+
call.Input = result.UpdatedInput
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// An explicit allow from a hook pre-approves the permission prompt for
|
| 80 |
+
// this tool call. Deny is already handled above; silence falls through
|
| 81 |
+
// to the normal permission flow.
|
| 82 |
+
if result.Decision == hooks.DecisionAllow {
|
| 83 |
+
ctx = permission.WithHookApproval(ctx, call.ID)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
resp, err := h.inner.Run(ctx, call)
|
| 87 |
+
if err != nil {
|
| 88 |
+
return resp, err
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if result.Context != "" {
|
| 92 |
+
if resp.Content != "" {
|
| 93 |
+
resp.Content += "\n"
|
| 94 |
+
}
|
| 95 |
+
resp.Content += result.Context
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
resp.Metadata = mergeHookMetadata(resp.Metadata, result)
|
| 99 |
+
return resp, nil
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// buildHookMetadata creates a HookMetadata from an AggregateResult.
|
| 103 |
+
func buildHookMetadata(result hooks.AggregateResult) hooks.HookMetadata {
|
| 104 |
+
return hooks.HookMetadata{
|
| 105 |
+
HookCount: result.HookCount,
|
| 106 |
+
Decision: result.Decision.String(),
|
| 107 |
+
Halt: result.Halt,
|
| 108 |
+
Reason: result.Reason,
|
| 109 |
+
InputRewrite: result.UpdatedInput != "",
|
| 110 |
+
Hooks: result.Hooks,
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// hookMetadataJSON builds a JSON string containing only the hook metadata.
|
| 115 |
+
func hookMetadataJSON(result hooks.AggregateResult) string {
|
| 116 |
+
meta := buildHookMetadata(result)
|
| 117 |
+
data, err := json.Marshal(meta)
|
| 118 |
+
if err != nil {
|
| 119 |
+
return ""
|
| 120 |
+
}
|
| 121 |
+
return `{"hook":` + string(data) + `}`
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// mergeHookMetadata injects hook metadata into existing tool metadata.
|
| 125 |
+
func mergeHookMetadata(existing string, result hooks.AggregateResult) string {
|
| 126 |
+
if result.HookCount == 0 {
|
| 127 |
+
return existing
|
| 128 |
+
}
|
| 129 |
+
meta := buildHookMetadata(result)
|
| 130 |
+
data, err := json.Marshal(meta)
|
| 131 |
+
if err != nil {
|
| 132 |
+
return existing
|
| 133 |
+
}
|
| 134 |
+
if existing == "" {
|
| 135 |
+
existing = "{}"
|
| 136 |
+
}
|
| 137 |
+
merged, err := sjson.SetRaw(existing, "hook", string(data))
|
| 138 |
+
if err != nil {
|
| 139 |
+
return existing
|
| 140 |
+
}
|
| 141 |
+
return merged
|
| 142 |
+
}
|
internal/agent/hooked_tool_test.go
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"testing"
|
| 6 |
+
|
| 7 |
+
"charm.land/fantasy"
|
| 8 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 9 |
+
"github.com/charmbracelet/crush/internal/hooks"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/permission"
|
| 11 |
+
"github.com/stretchr/testify/require"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
// fakeTool records the context it was invoked with so tests can assert on
|
| 15 |
+
// values stamped onto it by the hookedTool decorator.
|
| 16 |
+
type fakeTool struct {
|
| 17 |
+
name string
|
| 18 |
+
called bool
|
| 19 |
+
gotCtx context.Context
|
| 20 |
+
resp fantasy.ToolResponse
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func (f *fakeTool) Info() fantasy.ToolInfo {
|
| 24 |
+
return fantasy.ToolInfo{Name: f.name}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
func (f *fakeTool) Run(ctx context.Context, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
| 28 |
+
f.called = true
|
| 29 |
+
f.gotCtx = ctx
|
| 30 |
+
return f.resp, nil
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
func (f *fakeTool) ProviderOptions() fantasy.ProviderOptions { return nil }
|
| 34 |
+
func (f *fakeTool) SetProviderOptions(_ fantasy.ProviderOptions) {}
|
| 35 |
+
|
| 36 |
+
// newRunner builds a hooks.Runner from a single HookConfig, running the
|
| 37 |
+
// config-loader path that compiles the matcher regex.
|
| 38 |
+
func newRunner(t *testing.T, cmd string) *hooks.Runner {
|
| 39 |
+
t.Helper()
|
| 40 |
+
cfg := &config.Config{
|
| 41 |
+
Hooks: map[string][]config.HookConfig{
|
| 42 |
+
hooks.EventPreToolUse: {{Command: cmd}},
|
| 43 |
+
},
|
| 44 |
+
}
|
| 45 |
+
require.NoError(t, cfg.ValidateHooks())
|
| 46 |
+
return hooks.NewRunner(cfg.Hooks[hooks.EventPreToolUse], t.TempDir(), t.TempDir())
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
func TestHookedTool_AllowStampsHookApproval(t *testing.T) {
|
| 50 |
+
t.Parallel()
|
| 51 |
+
|
| 52 |
+
inner := &fakeTool{name: "view", resp: fantasy.NewTextResponse("ok")}
|
| 53 |
+
runner := newRunner(t, `echo '{"decision":"allow"}'`)
|
| 54 |
+
tool := newHookedTool(inner, runner)
|
| 55 |
+
|
| 56 |
+
_, err := tool.Run(t.Context(), fantasy.ToolCall{ID: "call-1", Name: "view"})
|
| 57 |
+
require.NoError(t, err)
|
| 58 |
+
require.True(t, inner.called, "inner tool should have run")
|
| 59 |
+
|
| 60 |
+
// The inner tool's permission service can now treat call-1 as pre-approved.
|
| 61 |
+
svc := permission.NewPermissionService(t.TempDir(), false, nil)
|
| 62 |
+
granted, err := svc.Request(inner.gotCtx, permission.CreatePermissionRequest{
|
| 63 |
+
SessionID: "s1",
|
| 64 |
+
ToolCallID: "call-1",
|
| 65 |
+
ToolName: "view",
|
| 66 |
+
Action: "read",
|
| 67 |
+
Path: t.TempDir(),
|
| 68 |
+
})
|
| 69 |
+
require.NoError(t, err)
|
| 70 |
+
require.True(t, granted, "hook allow should bypass the permission prompt")
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
func TestHookedTool_SilentDoesNotStampApproval(t *testing.T) {
|
| 74 |
+
t.Parallel()
|
| 75 |
+
|
| 76 |
+
inner := &fakeTool{name: "view", resp: fantasy.NewTextResponse("ok")}
|
| 77 |
+
runner := newRunner(t, `exit 0`) // no stdout, no decision
|
| 78 |
+
tool := newHookedTool(inner, runner)
|
| 79 |
+
|
| 80 |
+
_, err := tool.Run(t.Context(), fantasy.ToolCall{ID: "call-2", Name: "view"})
|
| 81 |
+
require.NoError(t, err)
|
| 82 |
+
require.True(t, inner.called)
|
| 83 |
+
|
| 84 |
+
// With no hook opinion, a fresh permission request has nothing stamped
|
| 85 |
+
// and must fall through to the normal flow. We verify by checking that
|
| 86 |
+
// the context does not look pre-approved for this call ID: sending a
|
| 87 |
+
// request that no subscriber resolves will block until cancelled.
|
| 88 |
+
svc := permission.NewPermissionService(t.TempDir(), false, nil)
|
| 89 |
+
ctx, cancel := context.WithCancel(inner.gotCtx)
|
| 90 |
+
cancel()
|
| 91 |
+
granted, err := svc.Request(ctx, permission.CreatePermissionRequest{
|
| 92 |
+
SessionID: "s1",
|
| 93 |
+
ToolCallID: "call-2",
|
| 94 |
+
ToolName: "view",
|
| 95 |
+
Action: "read",
|
| 96 |
+
Path: t.TempDir(),
|
| 97 |
+
})
|
| 98 |
+
require.Error(t, err, "no approval stamped => request should reach the prompt path")
|
| 99 |
+
require.False(t, granted)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
func TestHookedTool_DenySkipsInnerTool(t *testing.T) {
|
| 103 |
+
t.Parallel()
|
| 104 |
+
|
| 105 |
+
inner := &fakeTool{name: "bash"}
|
| 106 |
+
runner := newRunner(t, `echo "blocked" >&2; exit 2`)
|
| 107 |
+
tool := newHookedTool(inner, runner)
|
| 108 |
+
|
| 109 |
+
resp, err := tool.Run(t.Context(), fantasy.ToolCall{ID: "call-3", Name: "bash"})
|
| 110 |
+
require.NoError(t, err)
|
| 111 |
+
require.False(t, inner.called, "denied call must not reach the inner tool")
|
| 112 |
+
require.True(t, resp.IsError)
|
| 113 |
+
require.Contains(t, resp.Content, "blocked")
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
func TestWrapToolsWithHooks(t *testing.T) {
|
| 117 |
+
t.Parallel()
|
| 118 |
+
|
| 119 |
+
runner := newRunner(t, `exit 0`)
|
| 120 |
+
inputs := []fantasy.AgentTool{&fakeTool{name: "a"}, &fakeTool{name: "b"}}
|
| 121 |
+
|
| 122 |
+
t.Run("top-level agent wraps every tool", func(t *testing.T) {
|
| 123 |
+
t.Parallel()
|
| 124 |
+
out := wrapToolsWithHooks(inputs, runner, false)
|
| 125 |
+
require.Len(t, out, len(inputs))
|
| 126 |
+
for i, tool := range out {
|
| 127 |
+
_, ok := tool.(*hookedTool)
|
| 128 |
+
require.Truef(t, ok, "tool %d should be a *hookedTool", i)
|
| 129 |
+
}
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
t.Run("sub-agent skips the wrap", func(t *testing.T) {
|
| 133 |
+
t.Parallel()
|
| 134 |
+
out := wrapToolsWithHooks(inputs, runner, true)
|
| 135 |
+
require.Equal(t, inputs, out, "sub-agent tools should be returned unwrapped")
|
| 136 |
+
for _, tool := range out {
|
| 137 |
+
_, isHooked := tool.(*hookedTool)
|
| 138 |
+
require.False(t, isHooked, "sub-agent tool should not be wrapped")
|
| 139 |
+
}
|
| 140 |
+
})
|
| 141 |
+
|
| 142 |
+
t.Run("nil runner skips the wrap for both agent kinds", func(t *testing.T) {
|
| 143 |
+
t.Parallel()
|
| 144 |
+
require.Equal(t, inputs, wrapToolsWithHooks(inputs, nil, false))
|
| 145 |
+
require.Equal(t, inputs, wrapToolsWithHooks(inputs, nil, true))
|
| 146 |
+
})
|
| 147 |
+
}
|
internal/agent/loop_detection.go
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"crypto/sha256"
|
| 5 |
+
"encoding/hex"
|
| 6 |
+
"io"
|
| 7 |
+
|
| 8 |
+
"charm.land/fantasy"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
const (
|
| 12 |
+
loopDetectionWindowSize = 10
|
| 13 |
+
loopDetectionMaxRepeats = 5
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// hasRepeatedToolCalls checks whether the agent is stuck in a loop by looking
|
| 17 |
+
// at recent steps. It examines the last windowSize steps and returns true if
|
| 18 |
+
// any tool-call signature appears more than maxRepeats times.
|
| 19 |
+
func hasRepeatedToolCalls(steps []fantasy.StepResult, windowSize, maxRepeats int) bool {
|
| 20 |
+
if len(steps) < windowSize {
|
| 21 |
+
return false
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
window := steps[len(steps)-windowSize:]
|
| 25 |
+
counts := make(map[string]int)
|
| 26 |
+
|
| 27 |
+
for _, step := range window {
|
| 28 |
+
sig := getToolInteractionSignature(step.Content)
|
| 29 |
+
if sig == "" {
|
| 30 |
+
continue
|
| 31 |
+
}
|
| 32 |
+
counts[sig]++
|
| 33 |
+
if counts[sig] > maxRepeats {
|
| 34 |
+
return true
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return false
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// getToolInteractionSignature computes a hash signature for the tool
|
| 42 |
+
// interactions in a single step's content. It pairs tool calls with their
|
| 43 |
+
// results (matched by ToolCallID) and returns a hex-encoded SHA-256 hash.
|
| 44 |
+
// If the step contains no tool calls, it returns "".
|
| 45 |
+
func getToolInteractionSignature(content fantasy.ResponseContent) string {
|
| 46 |
+
toolCalls := content.ToolCalls()
|
| 47 |
+
if len(toolCalls) == 0 {
|
| 48 |
+
return ""
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Index tool results by their ToolCallID for fast lookup.
|
| 52 |
+
resultsByID := make(map[string]fantasy.ToolResultContent)
|
| 53 |
+
for _, tr := range content.ToolResults() {
|
| 54 |
+
resultsByID[tr.ToolCallID] = tr
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
h := sha256.New()
|
| 58 |
+
for _, tc := range toolCalls {
|
| 59 |
+
output := ""
|
| 60 |
+
if tr, ok := resultsByID[tc.ToolCallID]; ok {
|
| 61 |
+
output = toolResultOutputString(tr.Result)
|
| 62 |
+
}
|
| 63 |
+
io.WriteString(h, tc.ToolName)
|
| 64 |
+
io.WriteString(h, "\x00")
|
| 65 |
+
io.WriteString(h, tc.Input)
|
| 66 |
+
io.WriteString(h, "\x00")
|
| 67 |
+
io.WriteString(h, output)
|
| 68 |
+
io.WriteString(h, "\x00")
|
| 69 |
+
}
|
| 70 |
+
return hex.EncodeToString(h.Sum(nil))
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// toolResultOutputString converts a ToolResultOutputContent to a stable string
|
| 74 |
+
// representation for signature comparison.
|
| 75 |
+
func toolResultOutputString(result fantasy.ToolResultOutputContent) string {
|
| 76 |
+
if result == nil {
|
| 77 |
+
return ""
|
| 78 |
+
}
|
| 79 |
+
if text, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result); ok {
|
| 80 |
+
return text.Text
|
| 81 |
+
}
|
| 82 |
+
if errResult, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result); ok {
|
| 83 |
+
if errResult.Error != nil {
|
| 84 |
+
return errResult.Error.Error()
|
| 85 |
+
}
|
| 86 |
+
return ""
|
| 87 |
+
}
|
| 88 |
+
if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result); ok {
|
| 89 |
+
return media.Data
|
| 90 |
+
}
|
| 91 |
+
return ""
|
| 92 |
+
}
|
internal/agent/loop_detection_test.go
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"testing"
|
| 6 |
+
|
| 7 |
+
"charm.land/fantasy"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
// makeStep creates a StepResult with the given tool calls and results in its Content.
|
| 11 |
+
func makeStep(calls []fantasy.ToolCallContent, results []fantasy.ToolResultContent) fantasy.StepResult {
|
| 12 |
+
var content fantasy.ResponseContent
|
| 13 |
+
for _, c := range calls {
|
| 14 |
+
content = append(content, c)
|
| 15 |
+
}
|
| 16 |
+
for _, r := range results {
|
| 17 |
+
content = append(content, r)
|
| 18 |
+
}
|
| 19 |
+
return fantasy.StepResult{
|
| 20 |
+
Response: fantasy.Response{
|
| 21 |
+
Content: content,
|
| 22 |
+
},
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// makeToolStep creates a step with a single tool call and matching text result.
|
| 27 |
+
func makeToolStep(name, input, output string) fantasy.StepResult {
|
| 28 |
+
callID := fmt.Sprintf("call_%s_%s", name, input)
|
| 29 |
+
return makeStep(
|
| 30 |
+
[]fantasy.ToolCallContent{
|
| 31 |
+
{ToolCallID: callID, ToolName: name, Input: input},
|
| 32 |
+
},
|
| 33 |
+
[]fantasy.ToolResultContent{
|
| 34 |
+
{ToolCallID: callID, ToolName: name, Result: fantasy.ToolResultOutputContentText{Text: output}},
|
| 35 |
+
},
|
| 36 |
+
)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// makeEmptyStep creates a step with no tool calls (e.g. a text-only response).
|
| 40 |
+
func makeEmptyStep() fantasy.StepResult {
|
| 41 |
+
return fantasy.StepResult{
|
| 42 |
+
Response: fantasy.Response{
|
| 43 |
+
Content: fantasy.ResponseContent{
|
| 44 |
+
fantasy.TextContent{Text: "thinking..."},
|
| 45 |
+
},
|
| 46 |
+
},
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
func TestHasRepeatedToolCalls(t *testing.T) {
|
| 51 |
+
t.Run("no steps", func(t *testing.T) {
|
| 52 |
+
result := hasRepeatedToolCalls(nil, 10, 5)
|
| 53 |
+
if result {
|
| 54 |
+
t.Error("expected false for empty steps")
|
| 55 |
+
}
|
| 56 |
+
})
|
| 57 |
+
|
| 58 |
+
t.Run("fewer steps than window", func(t *testing.T) {
|
| 59 |
+
steps := make([]fantasy.StepResult, 5)
|
| 60 |
+
for i := range steps {
|
| 61 |
+
steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
|
| 62 |
+
}
|
| 63 |
+
result := hasRepeatedToolCalls(steps, 10, 5)
|
| 64 |
+
if result {
|
| 65 |
+
t.Error("expected false when fewer steps than window size")
|
| 66 |
+
}
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
t.Run("all different signatures", func(t *testing.T) {
|
| 70 |
+
steps := make([]fantasy.StepResult, 10)
|
| 71 |
+
for i := range steps {
|
| 72 |
+
steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
|
| 73 |
+
}
|
| 74 |
+
result := hasRepeatedToolCalls(steps, 10, 5)
|
| 75 |
+
if result {
|
| 76 |
+
t.Error("expected false when all signatures are different")
|
| 77 |
+
}
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
t.Run("exact repeat at threshold not detected", func(t *testing.T) {
|
| 81 |
+
// maxRepeats=5 means > 5 is needed, so exactly 5 should return false
|
| 82 |
+
steps := make([]fantasy.StepResult, 10)
|
| 83 |
+
for i := range 5 {
|
| 84 |
+
steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
|
| 85 |
+
}
|
| 86 |
+
for i := 5; i < 10; i++ {
|
| 87 |
+
steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
|
| 88 |
+
}
|
| 89 |
+
result := hasRepeatedToolCalls(steps, 10, 5)
|
| 90 |
+
if result {
|
| 91 |
+
t.Error("expected false when count equals maxRepeats (threshold is >)")
|
| 92 |
+
}
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
t.Run("loop detected", func(t *testing.T) {
|
| 96 |
+
// 6 identical steps in a window of 10 with maxRepeats=5 → detected
|
| 97 |
+
steps := make([]fantasy.StepResult, 10)
|
| 98 |
+
for i := range 6 {
|
| 99 |
+
steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
|
| 100 |
+
}
|
| 101 |
+
for i := 6; i < 10; i++ {
|
| 102 |
+
steps[i] = makeToolStep("tool", fmt.Sprintf(`{"i":%d}`, i), fmt.Sprintf("result-%d", i))
|
| 103 |
+
}
|
| 104 |
+
result := hasRepeatedToolCalls(steps, 10, 5)
|
| 105 |
+
if !result {
|
| 106 |
+
t.Error("expected true when same signature appears more than maxRepeats times")
|
| 107 |
+
}
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
t.Run("steps without tool calls are skipped", func(t *testing.T) {
|
| 111 |
+
// Mix of tool steps and empty steps — empty ones should not affect counts
|
| 112 |
+
steps := make([]fantasy.StepResult, 10)
|
| 113 |
+
for i := range 4 {
|
| 114 |
+
steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content")
|
| 115 |
+
}
|
| 116 |
+
for i := 4; i < 8; i++ {
|
| 117 |
+
steps[i] = makeEmptyStep()
|
| 118 |
+
}
|
| 119 |
+
for i := 8; i < 10; i++ {
|
| 120 |
+
steps[i] = makeToolStep("write", `{"file":"b.go"}`, "ok")
|
| 121 |
+
}
|
| 122 |
+
result := hasRepeatedToolCalls(steps, 10, 5)
|
| 123 |
+
if result {
|
| 124 |
+
t.Error("expected false: only 4 repeated tool calls, empty steps should be skipped")
|
| 125 |
+
}
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
t.Run("multiple different patterns alternating", func(t *testing.T) {
|
| 129 |
+
// Two patterns alternating: each appears 5 times — not above threshold
|
| 130 |
+
steps := make([]fantasy.StepResult, 10)
|
| 131 |
+
for i := range steps {
|
| 132 |
+
if i%2 == 0 {
|
| 133 |
+
steps[i] = makeToolStep("read", `{"file":"a.go"}`, "content-a")
|
| 134 |
+
} else {
|
| 135 |
+
steps[i] = makeToolStep("write", `{"file":"b.go"}`, "content-b")
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
result := hasRepeatedToolCalls(steps, 10, 5)
|
| 139 |
+
if result {
|
| 140 |
+
t.Error("expected false: two patterns each appearing 5 times (not > 5)")
|
| 141 |
+
}
|
| 142 |
+
})
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
func TestGetToolInteractionSignature(t *testing.T) {
|
| 146 |
+
t.Run("empty content returns empty string", func(t *testing.T) {
|
| 147 |
+
sig := getToolInteractionSignature(fantasy.ResponseContent{})
|
| 148 |
+
if sig != "" {
|
| 149 |
+
t.Errorf("expected empty string, got %q", sig)
|
| 150 |
+
}
|
| 151 |
+
})
|
| 152 |
+
|
| 153 |
+
t.Run("text only content returns empty string", func(t *testing.T) {
|
| 154 |
+
content := fantasy.ResponseContent{
|
| 155 |
+
fantasy.TextContent{Text: "hello"},
|
| 156 |
+
}
|
| 157 |
+
sig := getToolInteractionSignature(content)
|
| 158 |
+
if sig != "" {
|
| 159 |
+
t.Errorf("expected empty string, got %q", sig)
|
| 160 |
+
}
|
| 161 |
+
})
|
| 162 |
+
|
| 163 |
+
t.Run("tool call with result produces signature", func(t *testing.T) {
|
| 164 |
+
content := fantasy.ResponseContent{
|
| 165 |
+
fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
|
| 166 |
+
fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
|
| 167 |
+
}
|
| 168 |
+
sig := getToolInteractionSignature(content)
|
| 169 |
+
if sig == "" {
|
| 170 |
+
t.Error("expected non-empty signature")
|
| 171 |
+
}
|
| 172 |
+
})
|
| 173 |
+
|
| 174 |
+
t.Run("same interactions produce same signature", func(t *testing.T) {
|
| 175 |
+
content1 := fantasy.ResponseContent{
|
| 176 |
+
fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
|
| 177 |
+
fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
|
| 178 |
+
}
|
| 179 |
+
content2 := fantasy.ResponseContent{
|
| 180 |
+
fantasy.ToolCallContent{ToolCallID: "2", ToolName: "read", Input: `{"file":"a.go"}`},
|
| 181 |
+
fantasy.ToolResultContent{ToolCallID: "2", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
|
| 182 |
+
}
|
| 183 |
+
sig1 := getToolInteractionSignature(content1)
|
| 184 |
+
sig2 := getToolInteractionSignature(content2)
|
| 185 |
+
if sig1 != sig2 {
|
| 186 |
+
t.Errorf("expected same signature for same interactions, got %q and %q", sig1, sig2)
|
| 187 |
+
}
|
| 188 |
+
})
|
| 189 |
+
|
| 190 |
+
t.Run("different inputs produce different signatures", func(t *testing.T) {
|
| 191 |
+
content1 := fantasy.ResponseContent{
|
| 192 |
+
fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"a.go"}`},
|
| 193 |
+
fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
|
| 194 |
+
}
|
| 195 |
+
content2 := fantasy.ResponseContent{
|
| 196 |
+
fantasy.ToolCallContent{ToolCallID: "1", ToolName: "read", Input: `{"file":"b.go"}`},
|
| 197 |
+
fantasy.ToolResultContent{ToolCallID: "1", ToolName: "read", Result: fantasy.ToolResultOutputContentText{Text: "content"}},
|
| 198 |
+
}
|
| 199 |
+
sig1 := getToolInteractionSignature(content1)
|
| 200 |
+
sig2 := getToolInteractionSignature(content2)
|
| 201 |
+
if sig1 == sig2 {
|
| 202 |
+
t.Error("expected different signatures for different inputs")
|
| 203 |
+
}
|
| 204 |
+
})
|
| 205 |
+
}
|
internal/agent/opencode_routing_test.go
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"testing"
|
| 5 |
+
|
| 6 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 7 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 8 |
+
"github.com/stretchr/testify/require"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func TestIsOpenCodeMessagesModel(t *testing.T) {
|
| 12 |
+
t.Parallel()
|
| 13 |
+
|
| 14 |
+
tests := []struct {
|
| 15 |
+
provider string
|
| 16 |
+
model string
|
| 17 |
+
expected bool
|
| 18 |
+
}{
|
| 19 |
+
{"opencode-zen", "qwen3.7-max", true},
|
| 20 |
+
{"opencode-zen", "qwen3.5-plus", true},
|
| 21 |
+
{"opencode-zen", "qwen3.6-plus", true},
|
| 22 |
+
{"opencode-zen", "qwen3.7-plus", true},
|
| 23 |
+
{"opencode-zen", "claude-opus-4-5", true},
|
| 24 |
+
{"opencode-zen", "claude-sonnet-5", true},
|
| 25 |
+
{"opencode-zen", "muse-spark-1.3-contributor-free", false},
|
| 26 |
+
{"opencode-zen", "gpt-5.6-luna", false},
|
| 27 |
+
{"opencode-zen", "grok-4.5", false},
|
| 28 |
+
{"opencode-zen", "minimax-m3", false},
|
| 29 |
+
{"opencode-zen", "kimi-k3", false},
|
| 30 |
+
{"opencode-zen", "big-pickle", false},
|
| 31 |
+
{"opencode-go", "minimax-m2.7", true},
|
| 32 |
+
{"opencode-go", "minimax-m3", true},
|
| 33 |
+
{"opencode-go", "qwen3.7-max", true},
|
| 34 |
+
{"opencode-go", "qwen3.7-plus", true},
|
| 35 |
+
{"opencode-go", "qwen3.6-plus", true},
|
| 36 |
+
{"opencode-go", "qwen3.8-flash", true},
|
| 37 |
+
{"opencode-go", "qwen3.8-max", true},
|
| 38 |
+
{"opencode-go", "muse-spark-1.3-contributor", false},
|
| 39 |
+
{"opencode-go", "gpt-5.6-luna", false},
|
| 40 |
+
{"opencode-go", "glm-5.3", false},
|
| 41 |
+
{"opencode-go", "kimi-k3", false},
|
| 42 |
+
{"opencode-go", "longcat-2.0", false},
|
| 43 |
+
{"opencode-go", "ox-alpha-free", false},
|
| 44 |
+
{"opencode-go", "minimax", false},
|
| 45 |
+
{"other", "claude-opus-4-5", false},
|
| 46 |
+
{"other", "qwen3.7-max", false},
|
| 47 |
+
}
|
| 48 |
+
for _, tt := range tests {
|
| 49 |
+
require.Equal(t, tt.expected, isOpenCodeMessagesModel(tt.provider, tt.model), "%s/%s", tt.provider, tt.model)
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
func TestIsOpenCodeResponsesModel(t *testing.T) {
|
| 54 |
+
t.Parallel()
|
| 55 |
+
|
| 56 |
+
tests := []struct {
|
| 57 |
+
model string
|
| 58 |
+
expected bool
|
| 59 |
+
}{
|
| 60 |
+
{"muse-spark-1.3-contributor-free", true},
|
| 61 |
+
{"muse-spark-1.2", true},
|
| 62 |
+
{"grok-4.5", true},
|
| 63 |
+
{"grok-4.6", true},
|
| 64 |
+
{"grok-build-0.1", true},
|
| 65 |
+
{"gpt-5.6-luna", true},
|
| 66 |
+
{"gpt-5.5", true},
|
| 67 |
+
{"gpt-5.3-codex", true},
|
| 68 |
+
{"minimax-m3", false},
|
| 69 |
+
{"qwen3.7-max", false},
|
| 70 |
+
{"kimi-k3", false},
|
| 71 |
+
{"glm-5.3", false},
|
| 72 |
+
{"big-pickle", false},
|
| 73 |
+
{"ox-alpha-free", false},
|
| 74 |
+
{"hy3", false},
|
| 75 |
+
{"longcat-2.0", false},
|
| 76 |
+
}
|
| 77 |
+
for _, tt := range tests {
|
| 78 |
+
require.Equal(t, tt.expected, isOpenCodeResponsesModel(tt.model), tt.model)
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
func TestBuildProviderOpenCodeRouting(t *testing.T) {
|
| 83 |
+
t.Parallel()
|
| 84 |
+
|
| 85 |
+
for _, providerID := range []string{
|
| 86 |
+
string(catwalk.InferenceProviderOpenCodeZen),
|
| 87 |
+
string(catwalk.InferenceProviderOpenCodeGo),
|
| 88 |
+
} {
|
| 89 |
+
t.Run(providerID, func(t *testing.T) {
|
| 90 |
+
t.Parallel()
|
| 91 |
+
env := testEnv(t)
|
| 92 |
+
providerCfg := config.ProviderConfig{
|
| 93 |
+
ID: providerID,
|
| 94 |
+
BaseURL: "https://opencode.ai/zen/v1",
|
| 95 |
+
Type: catwalk.TypeOpenAICompat,
|
| 96 |
+
APIKey: "$OPENCODE_API_KEY",
|
| 97 |
+
}
|
| 98 |
+
coord := newTestCoordinator(t, env, providerID, providerCfg)
|
| 99 |
+
|
| 100 |
+
for _, modelID := range []string{"kimi-k3", "muse-spark-1.3-contributor-free", "grok-4.6", "gpt-5.6-luna"} {
|
| 101 |
+
provider, err := coord.buildProvider(providerCfg, config.SelectedModel{
|
| 102 |
+
Model: modelID,
|
| 103 |
+
Provider: providerID,
|
| 104 |
+
}, false)
|
| 105 |
+
require.NoError(t, err, modelID)
|
| 106 |
+
require.NotNil(t, provider, modelID)
|
| 107 |
+
}
|
| 108 |
+
})
|
| 109 |
+
}
|
| 110 |
+
}
|
internal/agent/prism_model_test.go
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"net/http"
|
| 6 |
+
"testing"
|
| 7 |
+
|
| 8 |
+
"charm.land/fantasy"
|
| 9 |
+
"github.com/charmbracelet/crush/internal/agent/hyper"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 11 |
+
|
| 12 |
+
"charm.land/fantasy/providers/openai"
|
| 13 |
+
"github.com/stretchr/testify/require"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
func TestExtractPrismModel(t *testing.T) {
|
| 17 |
+
t.Parallel()
|
| 18 |
+
|
| 19 |
+
prismMetadata := func(header http.Header) fantasy.ProviderMetadata {
|
| 20 |
+
metadata := &openai.ProviderMetadata{}
|
| 21 |
+
hyper.HeaderFunc(header, metadata)
|
| 22 |
+
return fantasy.ProviderMetadata{openai.Name: metadata}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
t.Run("returns the routed model ID and name", func(t *testing.T) {
|
| 26 |
+
t.Parallel()
|
| 27 |
+
header := http.Header{}
|
| 28 |
+
header.Set(hyper.PrismModelIDHeader, "prism-42")
|
| 29 |
+
header.Set(hyper.PrismModelNameHeader, "GPT-5.2 Codex Max")
|
| 30 |
+
modelID, modelName := extractPrismModel(prismMetadata(header))
|
| 31 |
+
require.Equal(t, "prism-42", modelID)
|
| 32 |
+
require.Equal(t, "GPT-5.2 Codex Max", modelName)
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
t.Run("returns empty when not routed through Prism", func(t *testing.T) {
|
| 36 |
+
t.Parallel()
|
| 37 |
+
modelID, modelName := extractPrismModel(fantasy.ProviderMetadata{})
|
| 38 |
+
require.Empty(t, modelID)
|
| 39 |
+
require.Empty(t, modelName)
|
| 40 |
+
|
| 41 |
+
metadata := &openai.ProviderMetadata{
|
| 42 |
+
ExtraFields: map[string]json.RawMessage{"remaining": json.RawMessage(`{"hypercredits": 10}`)},
|
| 43 |
+
}
|
| 44 |
+
modelID, modelName = extractPrismModel(fantasy.ProviderMetadata{openai.Name: metadata})
|
| 45 |
+
require.Empty(t, modelID)
|
| 46 |
+
require.Empty(t, modelName)
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
t.Run("persisted on the assistant message", func(t *testing.T) {
|
| 50 |
+
t.Parallel()
|
| 51 |
+
header := http.Header{}
|
| 52 |
+
header.Set(hyper.PrismModelIDHeader, "prism-42")
|
| 53 |
+
header.Set(hyper.PrismModelNameHeader, "GPT-5.2 Codex Max")
|
| 54 |
+
|
| 55 |
+
msg := message.Message{}
|
| 56 |
+
msg.PrismModelID, msg.PrismModelName = extractPrismModel(prismMetadata(header))
|
| 57 |
+
require.Equal(t, "prism-42", msg.PrismModelID)
|
| 58 |
+
require.Equal(t, "GPT-5.2 Codex Max", msg.PrismModelName)
|
| 59 |
+
})
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
func TestExtractPrismSavings(t *testing.T) {
|
| 63 |
+
t.Parallel()
|
| 64 |
+
|
| 65 |
+
prismMetadata := func(header http.Header) fantasy.ProviderMetadata {
|
| 66 |
+
metadata := &openai.ProviderMetadata{}
|
| 67 |
+
hyper.HeaderFunc(header, metadata)
|
| 68 |
+
return fantasy.ProviderMetadata{openai.Name: metadata}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
t.Run("returns both savings figures when reported", func(t *testing.T) {
|
| 72 |
+
t.Parallel()
|
| 73 |
+
header := http.Header{}
|
| 74 |
+
header.Set(hyper.PrismHypercreditSavingsHeader, "1.5")
|
| 75 |
+
header.Set(hyper.PrismDollarSavingsHeader, "0.002")
|
| 76 |
+
hypercredits, dollars := extractPrismSavings(prismMetadata(header))
|
| 77 |
+
require.NotNil(t, hypercredits)
|
| 78 |
+
require.Equal(t, 1.5, *hypercredits)
|
| 79 |
+
require.NotNil(t, dollars)
|
| 80 |
+
require.Equal(t, 0.002, *dollars)
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
t.Run("returns nil when not reported", func(t *testing.T) {
|
| 84 |
+
t.Parallel()
|
| 85 |
+
hypercredits, dollars := extractPrismSavings(fantasy.ProviderMetadata{})
|
| 86 |
+
require.Nil(t, hypercredits)
|
| 87 |
+
require.Nil(t, dollars)
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
t.Run("returns nil when malformed", func(t *testing.T) {
|
| 91 |
+
t.Parallel()
|
| 92 |
+
metadata := &openai.ProviderMetadata{
|
| 93 |
+
ExtraFields: map[string]json.RawMessage{
|
| 94 |
+
hyper.PrismHypercreditSavingsField: json.RawMessage(`"not-a-number"`),
|
| 95 |
+
},
|
| 96 |
+
}
|
| 97 |
+
hypercredits, dollars := extractPrismSavings(fantasy.ProviderMetadata{openai.Name: metadata})
|
| 98 |
+
require.Nil(t, hypercredits)
|
| 99 |
+
require.Nil(t, dollars)
|
| 100 |
+
})
|
| 101 |
+
}
|
internal/agent/prompts.go
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
_ "embed"
|
| 6 |
+
|
| 7 |
+
"github.com/charmbracelet/crush/internal/agent/prompt"
|
| 8 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
//go:embed templates/coder.md.tpl
|
| 12 |
+
var coderPromptTmpl []byte
|
| 13 |
+
|
| 14 |
+
//go:embed templates/task.md.tpl
|
| 15 |
+
var taskPromptTmpl []byte
|
| 16 |
+
|
| 17 |
+
//go:embed templates/initialize.md.tpl
|
| 18 |
+
var initializePromptTmpl []byte
|
| 19 |
+
|
| 20 |
+
func coderPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
|
| 21 |
+
systemPrompt, err := prompt.NewPrompt("coder", string(coderPromptTmpl), opts...)
|
| 22 |
+
if err != nil {
|
| 23 |
+
return nil, err
|
| 24 |
+
}
|
| 25 |
+
return systemPrompt, nil
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func taskPrompt(opts ...prompt.Option) (*prompt.Prompt, error) {
|
| 29 |
+
systemPrompt, err := prompt.NewPrompt("task", string(taskPromptTmpl), opts...)
|
| 30 |
+
if err != nil {
|
| 31 |
+
return nil, err
|
| 32 |
+
}
|
| 33 |
+
return systemPrompt, nil
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func InitializePrompt(cfg *config.ConfigStore) (string, error) {
|
| 37 |
+
systemPrompt, err := prompt.NewPrompt("initialize", string(initializePromptTmpl))
|
| 38 |
+
if err != nil {
|
| 39 |
+
return "", err
|
| 40 |
+
}
|
| 41 |
+
return systemPrompt.Build(context.Background(), "", "", cfg)
|
| 42 |
+
}
|
internal/agent/queued_runid_test.go
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"sync/atomic"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 11 |
+
"charm.land/fantasy"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/agent/notify"
|
| 13 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 14 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 15 |
+
"github.com/stretchr/testify/require"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
// gatedStreamModel streams a single text part followed by a clean finish,
|
| 19 |
+
// but blocks the very first Stream call until its gate is released. That
|
| 20 |
+
// lets a test hold a run "active" (past PrepareStep, inside Stream) just
|
| 21 |
+
// long enough to enqueue a follow-up prompt behind the busy session.
|
| 22 |
+
// Subsequent Stream calls (e.g. the recursive run draining the queue)
|
| 23 |
+
// proceed immediately.
|
| 24 |
+
type gatedStreamModel struct {
|
| 25 |
+
text string
|
| 26 |
+
gate chan struct{}
|
| 27 |
+
entered chan struct{}
|
| 28 |
+
calls atomic.Int64
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
func (m *gatedStreamModel) Provider() string { return "fake" }
|
| 32 |
+
func (m *gatedStreamModel) Model() string { return "fake-model" }
|
| 33 |
+
|
| 34 |
+
func (m *gatedStreamModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
| 35 |
+
return &fantasy.Response{
|
| 36 |
+
Content: fantasy.ResponseContent{fantasy.TextContent{Text: m.text}},
|
| 37 |
+
FinishReason: fantasy.FinishReasonStop,
|
| 38 |
+
}, nil
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func (m *gatedStreamModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
| 42 |
+
if m.calls.Add(1) == 1 {
|
| 43 |
+
close(m.entered)
|
| 44 |
+
select {
|
| 45 |
+
case <-m.gate:
|
| 46 |
+
case <-ctx.Done():
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
text := m.text
|
| 50 |
+
return func(yield func(fantasy.StreamPart) bool) {
|
| 51 |
+
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "1"}) {
|
| 52 |
+
return
|
| 53 |
+
}
|
| 54 |
+
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "1", Delta: text}) {
|
| 55 |
+
return
|
| 56 |
+
}
|
| 57 |
+
if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "1"}) {
|
| 58 |
+
return
|
| 59 |
+
}
|
| 60 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop})
|
| 61 |
+
}, nil
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
func (m *gatedStreamModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
| 65 |
+
return nil, errors.New("not implemented")
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
func (m *gatedStreamModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
| 69 |
+
return nil, errors.New("not implemented")
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// TestRun_QueuedRunIDPromptRunsRecursivelyAndPublishesRunComplete is the
|
| 73 |
+
// end-to-end proof of fix 2: a prompt carrying a RunID that is queued
|
| 74 |
+
// behind a busy session must NOT be silently folded into the active turn.
|
| 75 |
+
// It runs as its own turn via the recursive run path and publishes its
|
| 76 |
+
// own terminal RunComplete, so a `crush run` caller blocking on that
|
| 77 |
+
// RunID does not hang. The active turn keeps its own RunComplete too.
|
| 78 |
+
func TestRun_QueuedRunIDPromptRunsRecursivelyAndPublishesRunComplete(t *testing.T) {
|
| 79 |
+
t.Parallel()
|
| 80 |
+
|
| 81 |
+
env := testEnv(t)
|
| 82 |
+
broker := pubsub.NewBroker[notify.RunComplete]()
|
| 83 |
+
t.Cleanup(broker.Shutdown)
|
| 84 |
+
|
| 85 |
+
large := &gatedStreamModel{
|
| 86 |
+
text: "done",
|
| 87 |
+
gate: make(chan struct{}),
|
| 88 |
+
entered: make(chan struct{}),
|
| 89 |
+
}
|
| 90 |
+
small := &finishStreamModel{text: "title"}
|
| 91 |
+
|
| 92 |
+
sa := NewSessionAgent(SessionAgentOptions{
|
| 93 |
+
LargeModel: Model{Model: large, CatwalkCfg: catwalk.Model{ContextWindow: 200000, DefaultMaxTokens: 10000}},
|
| 94 |
+
SmallModel: Model{Model: small, CatwalkCfg: catwalk.Model{ContextWindow: 200000, DefaultMaxTokens: 10000}},
|
| 95 |
+
IsYolo: true,
|
| 96 |
+
Sessions: env.sessions,
|
| 97 |
+
Messages: env.messages,
|
| 98 |
+
RunComplete: broker,
|
| 99 |
+
}).(*sessionAgent)
|
| 100 |
+
|
| 101 |
+
sess, err := env.sessions.Create(t.Context(), "session")
|
| 102 |
+
require.NoError(t, err)
|
| 103 |
+
|
| 104 |
+
subCtx, subCancel := context.WithCancel(t.Context())
|
| 105 |
+
defer subCancel()
|
| 106 |
+
ch := broker.Subscribe(subCtx)
|
| 107 |
+
|
| 108 |
+
// Start the main turn; it blocks inside Stream once active.
|
| 109 |
+
mainDone := make(chan error, 1)
|
| 110 |
+
go func() {
|
| 111 |
+
_, runErr := sa.Run(t.Context(), SessionAgentCall{
|
| 112 |
+
SessionID: sess.ID,
|
| 113 |
+
RunID: "run-main",
|
| 114 |
+
Prompt: "main",
|
| 115 |
+
})
|
| 116 |
+
mainDone <- runErr
|
| 117 |
+
}()
|
| 118 |
+
|
| 119 |
+
// Wait until the main turn is active (inside Stream).
|
| 120 |
+
select {
|
| 121 |
+
case <-large.entered:
|
| 122 |
+
case <-time.After(5 * time.Second):
|
| 123 |
+
t.Fatal("main run never entered Stream")
|
| 124 |
+
}
|
| 125 |
+
require.True(t, sa.IsSessionBusy(sess.ID), "main run must be active before enqueueing the follow-up")
|
| 126 |
+
|
| 127 |
+
// Enqueue a RunID-bearing follow-up behind the busy session.
|
| 128 |
+
res, err := sa.Run(t.Context(), SessionAgentCall{
|
| 129 |
+
SessionID: sess.ID,
|
| 130 |
+
RunID: "run-follow",
|
| 131 |
+
Prompt: "follow",
|
| 132 |
+
})
|
| 133 |
+
require.NoError(t, err)
|
| 134 |
+
require.Nil(t, res, "a busy-session follow-up must enqueue and return (nil, nil)")
|
| 135 |
+
require.Equal(t, 1, sa.QueuedPrompts(sess.ID), "the follow-up must be queued, not folded")
|
| 136 |
+
|
| 137 |
+
// Release the main turn so it completes and hands off to the queue.
|
| 138 |
+
close(large.gate)
|
| 139 |
+
require.NoError(t, <-mainDone)
|
| 140 |
+
|
| 141 |
+
// Both turns must publish their own terminal RunComplete.
|
| 142 |
+
got := map[string]notify.RunComplete{}
|
| 143 |
+
deadline := time.After(5 * time.Second)
|
| 144 |
+
for len(got) < 2 {
|
| 145 |
+
select {
|
| 146 |
+
case ev := <-ch:
|
| 147 |
+
got[ev.Payload.RunID] = ev.Payload
|
| 148 |
+
case <-deadline:
|
| 149 |
+
t.Fatalf("timed out waiting for both RunCompletes; got %v", got)
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
main, ok := got["run-main"]
|
| 154 |
+
require.True(t, ok, "the active turn must publish its own RunComplete")
|
| 155 |
+
require.Empty(t, main.Error)
|
| 156 |
+
require.False(t, main.Cancelled)
|
| 157 |
+
|
| 158 |
+
follow, ok := got["run-follow"]
|
| 159 |
+
require.True(t, ok,
|
| 160 |
+
"the queued RunID prompt must publish its own RunComplete instead of being folded silently")
|
| 161 |
+
require.Empty(t, follow.Error)
|
| 162 |
+
require.False(t, follow.Cancelled)
|
| 163 |
+
require.Equal(t, "done", follow.Text, "the queued prompt ran as its own turn")
|
| 164 |
+
|
| 165 |
+
// Two distinct assistant turns prove the follow-up was not folded.
|
| 166 |
+
msgs, err := env.messages.List(t.Context(), sess.ID)
|
| 167 |
+
require.NoError(t, err)
|
| 168 |
+
var assistants, follows int
|
| 169 |
+
for _, m := range msgs {
|
| 170 |
+
switch m.Role {
|
| 171 |
+
case message.Assistant:
|
| 172 |
+
assistants++
|
| 173 |
+
case message.User:
|
| 174 |
+
if m.Content().String() == "follow" {
|
| 175 |
+
follows++
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
require.Equal(t, 2, assistants, "the active turn and the recursive turn each produce one assistant message")
|
| 180 |
+
require.Equal(t, 1, follows, "the follow-up prompt is its own user turn")
|
| 181 |
+
}
|
internal/agent/request_timeout.go
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"fmt"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"charm.land/fantasy"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
// requestTimeoutError reports that an LLM request exhausted its configured
|
| 13 |
+
// request_timeout budget. For streaming requests this is an idle timeout:
|
| 14 |
+
// it only fires when the provider sends nothing for the whole window, so a
|
| 15 |
+
// slow but actively streaming response is never killed. It wraps the
|
| 16 |
+
// underlying error so callers can still match [context.DeadlineExceeded]
|
| 17 |
+
// through the chain.
|
| 18 |
+
type requestTimeoutError struct {
|
| 19 |
+
timeout time.Duration
|
| 20 |
+
idle bool
|
| 21 |
+
cause error
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func (e *requestTimeoutError) Error() string {
|
| 25 |
+
msg := fmt.Sprintf("LLM request timed out after %s", e.timeout)
|
| 26 |
+
if e.idle {
|
| 27 |
+
msg = fmt.Sprintf("LLM stream received no data for %s", e.timeout)
|
| 28 |
+
}
|
| 29 |
+
if e.cause != nil {
|
| 30 |
+
return fmt.Sprintf("%s: %v", msg, e.cause)
|
| 31 |
+
}
|
| 32 |
+
return msg
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func (e *requestTimeoutError) Unwrap() error { return e.cause }
|
| 36 |
+
|
| 37 |
+
// userMessage explains the timeout in the UI, including how long the request
|
| 38 |
+
// ran before giving up and how to change the limit.
|
| 39 |
+
func (e *requestTimeoutError) userMessage() string {
|
| 40 |
+
hint := "Increase the limit with \"option request-timeout SECONDS\" or set it to 0 to disable the timeout."
|
| 41 |
+
if e.idle {
|
| 42 |
+
return fmt.Sprintf("The model stopped sending data for %s. %s", e.timeout, hint)
|
| 43 |
+
}
|
| 44 |
+
return fmt.Sprintf("The model did not respond within %s. %s", e.timeout, hint)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// requestTimeoutModel wraps a [fantasy.LanguageModel] so requests are
|
| 48 |
+
// bounded by the configured request_timeout. Non-streaming calls get a hard
|
| 49 |
+
// per-request deadline, applied per call so fantasy's retry loop gives every
|
| 50 |
+
// attempt a fresh budget — the same per-request semantics the provider SDKs
|
| 51 |
+
// expose. Streams instead get an idle timeout: the budget resets whenever a
|
| 52 |
+
// part arrives and only fires when the provider goes silent, so a slow but
|
| 53 |
+
// actively streaming response is never aborted.
|
| 54 |
+
type requestTimeoutModel struct {
|
| 55 |
+
fantasy.LanguageModel
|
| 56 |
+
timeout time.Duration
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// newRequestTimeoutModel bounds each request to m with the given timeout. A
|
| 60 |
+
// timeout of zero or less, or a nil model, returns m unchanged.
|
| 61 |
+
func newRequestTimeoutModel(m fantasy.LanguageModel, timeout time.Duration) fantasy.LanguageModel {
|
| 62 |
+
if m == nil || timeout <= 0 {
|
| 63 |
+
return m
|
| 64 |
+
}
|
| 65 |
+
return requestTimeoutModel{LanguageModel: m, timeout: timeout}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// wrapTimedOut replaces err with the requestTimeoutError when this model's
|
| 69 |
+
// own deadline fired. Other errors — user cancellation, outer deadlines,
|
| 70 |
+
// provider failures — pass through unchanged. Cancellation errors caused by
|
| 71 |
+
// our own timer report as [context.DeadlineExceeded] so callers never
|
| 72 |
+
// mistake a timeout for a user cancellation.
|
| 73 |
+
func wrapTimedOut(ctx context.Context, timeoutErr *requestTimeoutError, err error) error {
|
| 74 |
+
if err == nil || context.Cause(ctx) != timeoutErr {
|
| 75 |
+
return err
|
| 76 |
+
}
|
| 77 |
+
if errors.Is(err, context.Canceled) {
|
| 78 |
+
timeoutErr.cause = context.DeadlineExceeded
|
| 79 |
+
} else {
|
| 80 |
+
timeoutErr.cause = err
|
| 81 |
+
}
|
| 82 |
+
return timeoutErr
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Generate implements [fantasy.LanguageModel]. The request gets a hard
|
| 86 |
+
// deadline: there is no incremental progress signal, so the whole call must
|
| 87 |
+
// finish within the budget.
|
| 88 |
+
func (m requestTimeoutModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
| 89 |
+
timeoutErr := &requestTimeoutError{timeout: m.timeout}
|
| 90 |
+
ctx, cancel := context.WithTimeoutCause(ctx, m.timeout, timeoutErr)
|
| 91 |
+
defer cancel()
|
| 92 |
+
resp, err := m.LanguageModel.Generate(ctx, call)
|
| 93 |
+
return resp, wrapTimedOut(ctx, timeoutErr, err)
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Stream implements [fantasy.LanguageModel].
|
| 97 |
+
//
|
| 98 |
+
// The stream is consumed after Stream returns, so the timer must outlive
|
| 99 |
+
// this call: it fires only after timeout seconds without any part arriving,
|
| 100 |
+
// and is released when iteration ends, whether the stream finishes, breaks,
|
| 101 |
+
// or the idle timeout aborts it. Both the initial connection and gaps
|
| 102 |
+
// between parts share the same budget.
|
| 103 |
+
func (m requestTimeoutModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
| 104 |
+
timeoutErr := &requestTimeoutError{timeout: m.timeout, idle: true}
|
| 105 |
+
ctx, cancel := context.WithCancelCause(ctx)
|
| 106 |
+
timer := time.AfterFunc(m.timeout, func() { cancel(timeoutErr) })
|
| 107 |
+
|
| 108 |
+
inner, err := m.LanguageModel.Stream(ctx, call)
|
| 109 |
+
if err != nil {
|
| 110 |
+
timer.Stop()
|
| 111 |
+
cancel(nil)
|
| 112 |
+
return nil, wrapTimedOut(ctx, timeoutErr, err)
|
| 113 |
+
}
|
| 114 |
+
return func(yield func(fantasy.StreamPart) bool) {
|
| 115 |
+
defer timer.Stop()
|
| 116 |
+
defer cancel(nil)
|
| 117 |
+
inner(func(part fantasy.StreamPart) bool {
|
| 118 |
+
timer.Reset(m.timeout)
|
| 119 |
+
part.Error = wrapTimedOut(ctx, timeoutErr, part.Error)
|
| 120 |
+
return yield(part)
|
| 121 |
+
})
|
| 122 |
+
}, nil
|
| 123 |
+
}
|
internal/agent/request_timeout_test.go
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"runtime"
|
| 6 |
+
"testing"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"charm.land/fantasy"
|
| 10 |
+
"charm.land/x/vcr"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 12 |
+
"github.com/stretchr/testify/require"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// fakeLanguageModel is a [fantasy.LanguageModel] stub that records the
|
| 16 |
+
// context its methods were called with and can be configured with a custom
|
| 17 |
+
// stream body.
|
| 18 |
+
type fakeLanguageModel struct {
|
| 19 |
+
generateCtx context.Context
|
| 20 |
+
streamCtx context.Context
|
| 21 |
+
stream func(yield func(fantasy.StreamPart) bool)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func (f *fakeLanguageModel) Generate(ctx context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
| 25 |
+
f.generateCtx = ctx
|
| 26 |
+
return &fantasy.Response{}, nil
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
func (f *fakeLanguageModel) Stream(ctx context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
| 30 |
+
f.streamCtx = ctx
|
| 31 |
+
if f.stream == nil {
|
| 32 |
+
return func(yield func(fantasy.StreamPart) bool) {
|
| 33 |
+
yield(fantasy.StreamPart{})
|
| 34 |
+
}, nil
|
| 35 |
+
}
|
| 36 |
+
return f.stream, nil
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
func (f *fakeLanguageModel) GenerateObject(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
| 40 |
+
return &fantasy.ObjectResponse{}, nil
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
func (f *fakeLanguageModel) StreamObject(context.Context, fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
| 44 |
+
return nil, nil
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
func (f *fakeLanguageModel) Provider() string { return "fake" }
|
| 48 |
+
func (f *fakeLanguageModel) Model() string { return "fake-model" }
|
| 49 |
+
|
| 50 |
+
func TestNewRequestTimeoutModel_Disabled(t *testing.T) {
|
| 51 |
+
t.Parallel()
|
| 52 |
+
|
| 53 |
+
inner := &fakeLanguageModel{}
|
| 54 |
+
require.Same(t, inner, newRequestTimeoutModel(inner, 0))
|
| 55 |
+
require.Same(t, inner, newRequestTimeoutModel(inner, -time.Second))
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
func TestRequestTimeoutModel_GenerateDeadline(t *testing.T) {
|
| 59 |
+
t.Parallel()
|
| 60 |
+
|
| 61 |
+
inner := &fakeLanguageModel{}
|
| 62 |
+
m := newRequestTimeoutModel(inner, 5*time.Minute)
|
| 63 |
+
|
| 64 |
+
_, err := m.Generate(t.Context(), fantasy.Call{})
|
| 65 |
+
require.NoError(t, err)
|
| 66 |
+
|
| 67 |
+
_, ok := inner.generateCtx.Deadline()
|
| 68 |
+
require.True(t, ok, "Generate should run under a deadline")
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
func TestRequestTimeoutModel_StreamDeadlineOutlivesCall(t *testing.T) {
|
| 72 |
+
t.Parallel()
|
| 73 |
+
|
| 74 |
+
inner := &fakeLanguageModel{}
|
| 75 |
+
m := newRequestTimeoutModel(inner, 5*time.Minute)
|
| 76 |
+
|
| 77 |
+
stream, err := m.Stream(t.Context(), fantasy.Call{})
|
| 78 |
+
require.NoError(t, err)
|
| 79 |
+
|
| 80 |
+
require.NoError(t, inner.streamCtx.Err(), "the idle timer must not fire while the stream is being consumed")
|
| 81 |
+
|
| 82 |
+
for range stream {
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
require.ErrorIs(t, inner.streamCtx.Err(), context.Canceled, "the stream context should be released after the stream ends")
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
func TestRequestTimeoutModel_StreamAbortsWhenIdle(t *testing.T) {
|
| 89 |
+
t.Parallel()
|
| 90 |
+
|
| 91 |
+
inner := &fakeLanguageModel{}
|
| 92 |
+
// A stream that outlives the idle window: it waits for the context to
|
| 93 |
+
// be done and reports what it observed.
|
| 94 |
+
streamObserved := make(chan error, 1)
|
| 95 |
+
inner.stream = func(yield func(fantasy.StreamPart) bool) {
|
| 96 |
+
<-inner.streamCtx.Done()
|
| 97 |
+
streamObserved <- inner.streamCtx.Err()
|
| 98 |
+
}
|
| 99 |
+
m := newRequestTimeoutModel(inner, 10*time.Millisecond)
|
| 100 |
+
stream, err := m.Stream(t.Context(), fantasy.Call{})
|
| 101 |
+
require.NoError(t, err)
|
| 102 |
+
|
| 103 |
+
done := make(chan struct{})
|
| 104 |
+
go func() {
|
| 105 |
+
defer close(done)
|
| 106 |
+
for range stream {
|
| 107 |
+
}
|
| 108 |
+
}()
|
| 109 |
+
|
| 110 |
+
select {
|
| 111 |
+
case err := <-streamObserved:
|
| 112 |
+
require.ErrorIs(t, err, context.Canceled)
|
| 113 |
+
case <-time.After(5 * time.Second):
|
| 114 |
+
t.Fatal("stream was not aborted by the idle timeout")
|
| 115 |
+
}
|
| 116 |
+
<-done
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
func TestRequestTimeoutModel_ActiveStreamSurvives(t *testing.T) {
|
| 120 |
+
t.Parallel()
|
| 121 |
+
|
| 122 |
+
inner := &fakeLanguageModel{}
|
| 123 |
+
// A stream that keeps sending data: total runtime exceeds the timeout,
|
| 124 |
+
// but every gap is shorter than the idle window, so it must finish.
|
| 125 |
+
inner.stream = func(yield func(fantasy.StreamPart) bool) {
|
| 126 |
+
for range 6 {
|
| 127 |
+
time.Sleep(10 * time.Millisecond)
|
| 128 |
+
if !yield(fantasy.StreamPart{}) {
|
| 129 |
+
return
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
m := newRequestTimeoutModel(inner, 25*time.Millisecond)
|
| 134 |
+
stream, err := m.Stream(t.Context(), fantasy.Call{})
|
| 135 |
+
require.NoError(t, err)
|
| 136 |
+
|
| 137 |
+
parts := 0
|
| 138 |
+
for part := range stream {
|
| 139 |
+
require.NoError(t, part.Error)
|
| 140 |
+
parts++
|
| 141 |
+
}
|
| 142 |
+
require.Equal(t, 6, parts)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
// blockingModel blocks until the context is done and then returns the
|
| 146 |
+
// context error, the way a hung provider request would.
|
| 147 |
+
type blockingModel struct {
|
| 148 |
+
fakeLanguageModel
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
func (b *blockingModel) Generate(ctx context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
| 152 |
+
<-ctx.Done()
|
| 153 |
+
return nil, ctx.Err()
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
func TestRequestTimeoutModel_GenerateReportsTimeout(t *testing.T) {
|
| 157 |
+
t.Parallel()
|
| 158 |
+
|
| 159 |
+
m := newRequestTimeoutModel(&blockingModel{}, 10*time.Millisecond)
|
| 160 |
+
|
| 161 |
+
_, err := m.Generate(t.Context(), fantasy.Call{})
|
| 162 |
+
require.Error(t, err)
|
| 163 |
+
|
| 164 |
+
var timeoutErr *requestTimeoutError
|
| 165 |
+
require.ErrorAs(t, err, &timeoutErr)
|
| 166 |
+
require.Equal(t, 10*time.Millisecond, timeoutErr.timeout)
|
| 167 |
+
require.ErrorIs(t, err, context.DeadlineExceeded, "the deadline must stay detectable through the chain")
|
| 168 |
+
require.Contains(t, err.Error(), "timed out after 10ms")
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
func TestRequestTimeoutModel_StreamReportsTimeout(t *testing.T) {
|
| 172 |
+
t.Parallel()
|
| 173 |
+
|
| 174 |
+
inner := &fakeLanguageModel{}
|
| 175 |
+
// A provider stream that fails with the context error once the deadline
|
| 176 |
+
// fires, mirroring how SDKs surface mid-stream aborts.
|
| 177 |
+
inner.stream = func(yield func(fantasy.StreamPart) bool) {
|
| 178 |
+
<-inner.streamCtx.Done()
|
| 179 |
+
yield(fantasy.StreamPart{Error: inner.streamCtx.Err()})
|
| 180 |
+
}
|
| 181 |
+
m := newRequestTimeoutModel(inner, 10*time.Millisecond)
|
| 182 |
+
stream, err := m.Stream(t.Context(), fantasy.Call{})
|
| 183 |
+
require.NoError(t, err)
|
| 184 |
+
|
| 185 |
+
var got error
|
| 186 |
+
for part := range stream {
|
| 187 |
+
if part.Error != nil {
|
| 188 |
+
got = part.Error
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
var timeoutErr *requestTimeoutError
|
| 193 |
+
require.ErrorAs(t, got, &timeoutErr)
|
| 194 |
+
require.ErrorIs(t, got, context.DeadlineExceeded)
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
func TestRequestTimeoutModel_ParentCancelPassesThrough(t *testing.T) {
|
| 198 |
+
t.Parallel()
|
| 199 |
+
|
| 200 |
+
m := newRequestTimeoutModel(&blockingModel{}, 5*time.Minute)
|
| 201 |
+
|
| 202 |
+
ctx, cancel := context.WithCancel(t.Context())
|
| 203 |
+
defer cancel()
|
| 204 |
+
go func() {
|
| 205 |
+
time.Sleep(10 * time.Millisecond)
|
| 206 |
+
cancel()
|
| 207 |
+
}()
|
| 208 |
+
|
| 209 |
+
_, err := m.Generate(ctx, fantasy.Call{})
|
| 210 |
+
require.ErrorIs(t, err, context.Canceled)
|
| 211 |
+
|
| 212 |
+
var timeoutErr *requestTimeoutError
|
| 213 |
+
require.NotErrorAs(t, err, &timeoutErr, "user cancellation must not be reported as a timeout")
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
func TestRequestTimeoutErrorMessages(t *testing.T) {
|
| 217 |
+
t.Parallel()
|
| 218 |
+
|
| 219 |
+
err := &requestTimeoutError{timeout: time.Second}
|
| 220 |
+
require.Equal(t, "LLM request timed out after 1s", err.Error())
|
| 221 |
+
require.Contains(t, err.userMessage(), "1s")
|
| 222 |
+
require.Contains(t, err.userMessage(), "request-timeout")
|
| 223 |
+
|
| 224 |
+
err.cause = context.DeadlineExceeded
|
| 225 |
+
require.Equal(t, "LLM request timed out after 1s: context deadline exceeded", err.Error())
|
| 226 |
+
|
| 227 |
+
idle := &requestTimeoutError{timeout: 2 * time.Second, idle: true}
|
| 228 |
+
require.Equal(t, "LLM stream received no data for 2s", idle.Error())
|
| 229 |
+
require.Contains(t, idle.userMessage(), "stopped sending data for 2s")
|
| 230 |
+
require.Contains(t, idle.userMessage(), "request-timeout")
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// timeoutOnlyModel streams a single error part shaped exactly like the one
|
| 234 |
+
// requestTimeoutModel produces when its deadline fires.
|
| 235 |
+
type timeoutOnlyModel struct {
|
| 236 |
+
fakeLanguageModel
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
func (m *timeoutOnlyModel) Stream(context.Context, fantasy.Call) (fantasy.StreamResponse, error) {
|
| 240 |
+
timeoutErr := &requestTimeoutError{timeout: time.Second, idle: true, cause: context.DeadlineExceeded}
|
| 241 |
+
return func(yield func(fantasy.StreamPart) bool) {
|
| 242 |
+
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeError, Error: timeoutErr})
|
| 243 |
+
}, nil
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// TestRequestTimeoutRunFinishMessage pins what the user sees when a request
|
| 247 |
+
// exhausts its timeout: a "Request timed out" finish that names the elapsed
|
| 248 |
+
// budget and how to change it, instead of a bare provider error.
|
| 249 |
+
func TestRequestTimeoutRunFinishMessage(t *testing.T) {
|
| 250 |
+
if runtime.GOOS == "windows" {
|
| 251 |
+
t.Skip("skipping on windows for now")
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
env := testEnv(t)
|
| 255 |
+
model := &timeoutOnlyModel{}
|
| 256 |
+
agent, err := coderAgent(vcr.NewRecorder(t), env, model, model)
|
| 257 |
+
require.NoError(t, err)
|
| 258 |
+
|
| 259 |
+
session, err := env.sessions.Create(t.Context(), "timeout session")
|
| 260 |
+
require.NoError(t, err)
|
| 261 |
+
|
| 262 |
+
_, err = agent.Run(t.Context(), SessionAgentCall{
|
| 263 |
+
Prompt: "Hello",
|
| 264 |
+
SessionID: session.ID,
|
| 265 |
+
MaxOutputTokens: 10000,
|
| 266 |
+
})
|
| 267 |
+
require.Error(t, err)
|
| 268 |
+
|
| 269 |
+
msgs, err := env.messages.List(t.Context(), session.ID)
|
| 270 |
+
require.NoError(t, err)
|
| 271 |
+
|
| 272 |
+
var finish *message.Finish
|
| 273 |
+
for _, msg := range msgs {
|
| 274 |
+
if msg.Role != message.Assistant {
|
| 275 |
+
continue
|
| 276 |
+
}
|
| 277 |
+
if part := msg.FinishPart(); part != nil {
|
| 278 |
+
finish = part
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
require.NotNil(t, finish, "the assistant message should carry a finish part")
|
| 282 |
+
require.Equal(t, message.FinishReasonError, finish.Reason)
|
| 283 |
+
require.Equal(t, "Request timed out", finish.Message)
|
| 284 |
+
require.Contains(t, finish.Details, "stopped sending data for 1s")
|
| 285 |
+
require.Contains(t, finish.Details, "request-timeout")
|
| 286 |
+
}
|
internal/agent/run_complete_test.go
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"testing"
|
| 6 |
+
"time"
|
| 7 |
+
|
| 8 |
+
"github.com/charmbracelet/crush/internal/agent/notify"
|
| 9 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 10 |
+
"github.com/stretchr/testify/require"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
// TestSessionAgentRun_QueueStripsOnComplete verifies that when a Run
|
| 14 |
+
// call is enqueued (because the session is already busy), the
|
| 15 |
+
// OnComplete hook is NOT propagated onto the queued copy. The hook
|
| 16 |
+
// belongs to the caller's retry/coalesce scope (typically
|
| 17 |
+
// coordinator.Run) which has already returned by the time the queue
|
| 18 |
+
// drains; carrying it forward would silently funnel the terminal
|
| 19 |
+
// event into a closure nobody reads, and subscribers (`crush run`)
|
| 20 |
+
// would hang waiting for a RunComplete that never publishes.
|
| 21 |
+
func TestSessionAgentRun_QueueStripsOnComplete(t *testing.T) {
|
| 22 |
+
t.Parallel()
|
| 23 |
+
|
| 24 |
+
env := testEnv(t)
|
| 25 |
+
a := NewSessionAgent(SessionAgentOptions{
|
| 26 |
+
Sessions: env.sessions,
|
| 27 |
+
Messages: env.messages,
|
| 28 |
+
}).(*sessionAgent)
|
| 29 |
+
|
| 30 |
+
const sessionID = "queued-session"
|
| 31 |
+
// Mark the session as busy so Run takes the queue branch
|
| 32 |
+
// without needing a real model.
|
| 33 |
+
a.activeRequests.Set(sessionID, &activeCancel{cancel: func() {}})
|
| 34 |
+
|
| 35 |
+
var called bool
|
| 36 |
+
hook := func(notify.RunComplete) { called = true }
|
| 37 |
+
|
| 38 |
+
res, err := a.Run(t.Context(), SessionAgentCall{
|
| 39 |
+
SessionID: sessionID,
|
| 40 |
+
RunID: "run-xyz",
|
| 41 |
+
Prompt: "queued prompt",
|
| 42 |
+
OnComplete: hook,
|
| 43 |
+
})
|
| 44 |
+
require.NoError(t, err)
|
| 45 |
+
require.Nil(t, res, "queued Run must return (nil, nil)")
|
| 46 |
+
require.False(t, called,
|
| 47 |
+
"OnComplete must not fire on the enqueue path; the caller's scope is still live")
|
| 48 |
+
|
| 49 |
+
queued, ok := a.messageQueue.Get(sessionID)
|
| 50 |
+
require.True(t, ok)
|
| 51 |
+
require.Len(t, queued, 1)
|
| 52 |
+
require.Nil(t, queued[0].OnComplete,
|
| 53 |
+
"queued SessionAgentCall must have OnComplete stripped so the drain falls back to the default broker publish")
|
| 54 |
+
require.Equal(t, "queued prompt", queued[0].Prompt,
|
| 55 |
+
"all other fields must be preserved on the queued copy")
|
| 56 |
+
require.Equal(t, "run-xyz", queued[0].RunID,
|
| 57 |
+
"RunID must be preserved on the queued copy so the drained turn's "+
|
| 58 |
+
"RunComplete still correlates with the originating SendMessage")
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// TestDrainQueueForStep_FiltersUnderDispatchLock verifies that the queue
|
| 62 |
+
// drain evaluates the per-session cancel mark while holding the dispatch
|
| 63 |
+
// mutex (canceledBySeq's documented precondition). Queued calls at or
|
| 64 |
+
// below the cancel high-water mark are dropped, calls queued after the
|
| 65 |
+
// cancel (higher seq) are folded, untracked enqueues (seq == 0) are
|
| 66 |
+
// dropped whenever any mark is present, and the queue is cleared. These
|
| 67 |
+
// calls carry no RunID, so all foldable survivors are returned for
|
| 68 |
+
// folding (the existing follow-up behavior).
|
| 69 |
+
func TestDrainQueueForStep_FiltersUnderDispatchLock(t *testing.T) {
|
| 70 |
+
t.Parallel()
|
| 71 |
+
|
| 72 |
+
env := testEnv(t)
|
| 73 |
+
a := NewSessionAgent(SessionAgentOptions{
|
| 74 |
+
Sessions: env.sessions,
|
| 75 |
+
Messages: env.messages,
|
| 76 |
+
}).(*sessionAgent)
|
| 77 |
+
|
| 78 |
+
const sessionID = "drain-session"
|
| 79 |
+
a.messageQueue.Set(sessionID, []SessionAgentCall{
|
| 80 |
+
{SessionID: sessionID, Prompt: "below", acceptSeq: 1},
|
| 81 |
+
{SessionID: sessionID, Prompt: "at-mark", acceptSeq: 2},
|
| 82 |
+
{SessionID: sessionID, Prompt: "after", acceptSeq: 3},
|
| 83 |
+
{SessionID: sessionID, Prompt: "untracked", acceptSeq: 0},
|
| 84 |
+
})
|
| 85 |
+
// Cancel high-water mark at seq 2: seq <= 2 and seq == 0 are covered.
|
| 86 |
+
a.cancelMark.Set(sessionID, 2)
|
| 87 |
+
|
| 88 |
+
fold, canceledWithRunID := a.drainQueueForStep(sessionID)
|
| 89 |
+
|
| 90 |
+
require.Len(t, fold, 1,
|
| 91 |
+
"only the follow-up queued after the cancel (seq > mark) must be folded")
|
| 92 |
+
require.Equal(t, "after", fold[0].Prompt)
|
| 93 |
+
require.Empty(t, canceledWithRunID,
|
| 94 |
+
"no dropped call carried a RunID, so none need a terminal RunComplete")
|
| 95 |
+
|
| 96 |
+
_, ok := a.messageQueue.Get(sessionID)
|
| 97 |
+
require.False(t, ok, "drain must clear the session message queue when nothing is kept")
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// TestDrainQueueForStep_NoMarkFoldsAllNonRunID verifies that with no
|
| 101 |
+
// cancel mark recorded, every queued call without a RunID is folded.
|
| 102 |
+
func TestDrainQueueForStep_NoMarkFoldsAllNonRunID(t *testing.T) {
|
| 103 |
+
t.Parallel()
|
| 104 |
+
|
| 105 |
+
env := testEnv(t)
|
| 106 |
+
a := NewSessionAgent(SessionAgentOptions{
|
| 107 |
+
Sessions: env.sessions,
|
| 108 |
+
Messages: env.messages,
|
| 109 |
+
}).(*sessionAgent)
|
| 110 |
+
|
| 111 |
+
const sessionID = "drain-nomark"
|
| 112 |
+
a.messageQueue.Set(sessionID, []SessionAgentCall{
|
| 113 |
+
{SessionID: sessionID, Prompt: "a", acceptSeq: 0},
|
| 114 |
+
{SessionID: sessionID, Prompt: "b", acceptSeq: 5},
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
fold, canceledWithRunID := a.drainQueueForStep(sessionID)
|
| 118 |
+
require.Len(t, fold, 2, "no cancel mark means all non-RunID queued calls are folded")
|
| 119 |
+
require.Empty(t, canceledWithRunID)
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// TestDrainQueueForStep_KeepsRunIDPromptsQueued is the core of fix 2: a
|
| 123 |
+
// queued prompt that carries a RunID must NOT be folded into the active
|
| 124 |
+
// turn. Folding it would silently absorb it into another turn and never
|
| 125 |
+
// publish a RunComplete for its RunID, hanging a `crush run` caller that
|
| 126 |
+
// blocks on that event. Such prompts are left in the queue so the
|
| 127 |
+
// recursive run path gives each its own turn and its own RunComplete.
|
| 128 |
+
// Non-RunID prompts are still folded.
|
| 129 |
+
func TestDrainQueueForStep_KeepsRunIDPromptsQueued(t *testing.T) {
|
| 130 |
+
t.Parallel()
|
| 131 |
+
|
| 132 |
+
env := testEnv(t)
|
| 133 |
+
a := NewSessionAgent(SessionAgentOptions{
|
| 134 |
+
Sessions: env.sessions,
|
| 135 |
+
Messages: env.messages,
|
| 136 |
+
}).(*sessionAgent)
|
| 137 |
+
|
| 138 |
+
const sessionID = "drain-runid"
|
| 139 |
+
a.messageQueue.Set(sessionID, []SessionAgentCall{
|
| 140 |
+
{SessionID: sessionID, Prompt: "fold-me", acceptSeq: 1},
|
| 141 |
+
{SessionID: sessionID, RunID: "run-a", Prompt: "keep-me", acceptSeq: 2},
|
| 142 |
+
{SessionID: sessionID, RunID: "run-b", Prompt: "keep-me-too", acceptSeq: 3},
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
fold, canceledWithRunID := a.drainQueueForStep(sessionID)
|
| 146 |
+
|
| 147 |
+
require.Len(t, fold, 1, "only the non-RunID prompt is folded into the active turn")
|
| 148 |
+
require.Equal(t, "fold-me", fold[0].Prompt)
|
| 149 |
+
require.Empty(t, canceledWithRunID)
|
| 150 |
+
|
| 151 |
+
kept, ok := a.messageQueue.Get(sessionID)
|
| 152 |
+
require.True(t, ok, "RunID-bearing prompts must remain queued for the recursive run path")
|
| 153 |
+
require.Len(t, kept, 2)
|
| 154 |
+
require.Equal(t, "run-a", kept[0].RunID)
|
| 155 |
+
require.Equal(t, "run-b", kept[1].RunID)
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// TestDrainQueueForStep_ReportsCanceledRunIDDrops verifies that a queued
|
| 159 |
+
// prompt carrying a RunID that is dropped because a cancel covers it is
|
| 160 |
+
// reported in canceledWithRunID so the caller can publish its terminal
|
| 161 |
+
// cancelled RunComplete. A canceled prompt without a RunID is dropped
|
| 162 |
+
// silently as before.
|
| 163 |
+
func TestDrainQueueForStep_ReportsCanceledRunIDDrops(t *testing.T) {
|
| 164 |
+
t.Parallel()
|
| 165 |
+
|
| 166 |
+
env := testEnv(t)
|
| 167 |
+
a := NewSessionAgent(SessionAgentOptions{
|
| 168 |
+
Sessions: env.sessions,
|
| 169 |
+
Messages: env.messages,
|
| 170 |
+
}).(*sessionAgent)
|
| 171 |
+
|
| 172 |
+
const sessionID = "drain-cancel-runid"
|
| 173 |
+
a.messageQueue.Set(sessionID, []SessionAgentCall{
|
| 174 |
+
{SessionID: sessionID, RunID: "run-canceled", Prompt: "canceled", acceptSeq: 1},
|
| 175 |
+
{SessionID: sessionID, Prompt: "canceled-no-runid", acceptSeq: 1},
|
| 176 |
+
{SessionID: sessionID, RunID: "run-survives", Prompt: "survives", acceptSeq: 5},
|
| 177 |
+
})
|
| 178 |
+
a.cancelMark.Set(sessionID, 2)
|
| 179 |
+
|
| 180 |
+
fold, canceledWithRunID := a.drainQueueForStep(sessionID)
|
| 181 |
+
|
| 182 |
+
require.Empty(t, fold, "no uncanceled non-RunID prompts to fold")
|
| 183 |
+
require.Len(t, canceledWithRunID, 1,
|
| 184 |
+
"only the dropped RunID-bearing prompt needs a terminal RunComplete")
|
| 185 |
+
require.Equal(t, "run-canceled", canceledWithRunID[0].RunID)
|
| 186 |
+
|
| 187 |
+
kept, ok := a.messageQueue.Get(sessionID)
|
| 188 |
+
require.True(t, ok)
|
| 189 |
+
require.Len(t, kept, 1, "the uncanceled RunID prompt stays queued")
|
| 190 |
+
require.Equal(t, "run-survives", kept[0].RunID)
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// TestRunCompletePublisher_MustDeliverOverTakesPublish exercises the
|
| 194 |
+
// pubsub.Publisher interface change end-to-end: a Broker is the only
|
| 195 |
+
// concrete Publisher implementation and must satisfy both Publish and
|
| 196 |
+
// PublishMustDeliver. The coordinator's final RunComplete emit relies
|
| 197 |
+
// on PublishMustDeliver to apply bounded-blocking semantics so a
|
| 198 |
+
// momentarily-full subscriber buffer can't silently drop the
|
| 199 |
+
// authoritative end-of-run event.
|
| 200 |
+
func TestRunCompletePublisher_MustDeliverOverTakesPublish(t *testing.T) {
|
| 201 |
+
t.Parallel()
|
| 202 |
+
|
| 203 |
+
broker := pubsub.NewBroker[notify.RunComplete]()
|
| 204 |
+
t.Cleanup(broker.Shutdown)
|
| 205 |
+
|
| 206 |
+
// Subscribe before publishing so the event is delivered.
|
| 207 |
+
ctx, cancel := context.WithCancel(t.Context())
|
| 208 |
+
defer cancel()
|
| 209 |
+
ch := broker.Subscribe(ctx)
|
| 210 |
+
|
| 211 |
+
rc := notify.RunComplete{SessionID: "S", MessageID: "m", Text: "ok"}
|
| 212 |
+
var pub pubsub.Publisher[notify.RunComplete] = broker
|
| 213 |
+
pub.PublishMustDeliver(t.Context(), pubsub.UpdatedEvent, rc)
|
| 214 |
+
|
| 215 |
+
select {
|
| 216 |
+
case got := <-ch:
|
| 217 |
+
require.Equal(t, rc, got.Payload)
|
| 218 |
+
case <-time.After(time.Second):
|
| 219 |
+
t.Fatal("PublishMustDeliver did not deliver event")
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// requireSingleCancelledRunComplete reads exactly one RunComplete from ch,
|
| 224 |
+
// asserts it is the cancelled terminal event for runID, and verifies no
|
| 225 |
+
// second event arrives. This observes the published pubsub event rather
|
| 226 |
+
// than internal bookkeeping, which is the contract a `crush run` caller
|
| 227 |
+
// blocking on the broker actually relies on.
|
| 228 |
+
func requireSingleCancelledRunComplete(t *testing.T, ch <-chan pubsub.Event[notify.RunComplete], sessionID, runID string) {
|
| 229 |
+
t.Helper()
|
| 230 |
+
select {
|
| 231 |
+
case ev := <-ch:
|
| 232 |
+
require.Equal(t, runID, ev.Payload.RunID,
|
| 233 |
+
"the published RunComplete must carry the dropped queued prompt's RunID")
|
| 234 |
+
require.Equal(t, sessionID, ev.Payload.SessionID)
|
| 235 |
+
require.True(t, ev.Payload.Cancelled,
|
| 236 |
+
"a dropped queued prompt must publish a cancelled RunComplete")
|
| 237 |
+
case <-time.After(5 * time.Second):
|
| 238 |
+
t.Fatal("timed out waiting for the cancelled RunComplete")
|
| 239 |
+
}
|
| 240 |
+
select {
|
| 241 |
+
case extra := <-ch:
|
| 242 |
+
t.Fatalf("expected exactly one RunComplete, got a second: %+v", extra.Payload)
|
| 243 |
+
case <-time.After(100 * time.Millisecond):
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// TestCancel_QueuedRunIDPromptPublishesCancelledRunComplete proves the
|
| 248 |
+
// terminal-event behavior end-to-end: a RunID-bearing prompt sitting in
|
| 249 |
+
// the queue that is canceled while queued (via the public Cancel path,
|
| 250 |
+
// which routes through clearQueueAndNotify -> publishCanceledQueueDrops)
|
| 251 |
+
// must emit exactly one cancelled RunComplete on the broker for its
|
| 252 |
+
// RunID. A queued prompt without a RunID is dropped silently. This is the
|
| 253 |
+
// coverage the earlier drain test lacked: it asserted the returned
|
| 254 |
+
// bookkeeping slice, not the published event a `crush run` caller awaits.
|
| 255 |
+
func TestCancel_QueuedRunIDPromptPublishesCancelledRunComplete(t *testing.T) {
|
| 256 |
+
t.Parallel()
|
| 257 |
+
|
| 258 |
+
env := testEnv(t)
|
| 259 |
+
broker := pubsub.NewBroker[notify.RunComplete]()
|
| 260 |
+
t.Cleanup(broker.Shutdown)
|
| 261 |
+
|
| 262 |
+
a := NewSessionAgent(SessionAgentOptions{
|
| 263 |
+
Sessions: env.sessions,
|
| 264 |
+
Messages: env.messages,
|
| 265 |
+
RunComplete: broker,
|
| 266 |
+
}).(*sessionAgent)
|
| 267 |
+
|
| 268 |
+
subCtx, subCancel := context.WithCancel(t.Context())
|
| 269 |
+
defer subCancel()
|
| 270 |
+
ch := broker.Subscribe(subCtx)
|
| 271 |
+
|
| 272 |
+
const sessionID = "cancel-queued-runid"
|
| 273 |
+
a.messageQueue.Set(sessionID, []SessionAgentCall{
|
| 274 |
+
{SessionID: sessionID, Prompt: "no-runid", acceptSeq: 1},
|
| 275 |
+
{SessionID: sessionID, RunID: "run-queued", Prompt: "queued", acceptSeq: 2},
|
| 276 |
+
})
|
| 277 |
+
|
| 278 |
+
a.Cancel(sessionID)
|
| 279 |
+
|
| 280 |
+
requireSingleCancelledRunComplete(t, ch, sessionID, "run-queued")
|
| 281 |
+
|
| 282 |
+
_, ok := a.messageQueue.Get(sessionID)
|
| 283 |
+
require.False(t, ok, "Cancel must clear the queue")
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// TestDrainQueueForStep_DroppedRunIDPublishesCancelledRunComplete drives
|
| 287 |
+
// the production drain sequence (drainQueueForStep then
|
| 288 |
+
// publishCanceledQueueDrops, mirroring the PrepareStep handoff) and
|
| 289 |
+
// asserts the dropped RunID-bearing prompt actually publishes exactly one
|
| 290 |
+
// cancelled RunComplete on the broker. The companion bookkeeping test
|
| 291 |
+
// covers the returned slice; this one covers the observable terminal
|
| 292 |
+
// event.
|
| 293 |
+
func TestDrainQueueForStep_DroppedRunIDPublishesCancelledRunComplete(t *testing.T) {
|
| 294 |
+
t.Parallel()
|
| 295 |
+
|
| 296 |
+
env := testEnv(t)
|
| 297 |
+
broker := pubsub.NewBroker[notify.RunComplete]()
|
| 298 |
+
t.Cleanup(broker.Shutdown)
|
| 299 |
+
|
| 300 |
+
a := NewSessionAgent(SessionAgentOptions{
|
| 301 |
+
Sessions: env.sessions,
|
| 302 |
+
Messages: env.messages,
|
| 303 |
+
RunComplete: broker,
|
| 304 |
+
}).(*sessionAgent)
|
| 305 |
+
|
| 306 |
+
subCtx, subCancel := context.WithCancel(t.Context())
|
| 307 |
+
defer subCancel()
|
| 308 |
+
ch := broker.Subscribe(subCtx)
|
| 309 |
+
|
| 310 |
+
const sessionID = "drain-drop-runid"
|
| 311 |
+
a.messageQueue.Set(sessionID, []SessionAgentCall{
|
| 312 |
+
{SessionID: sessionID, RunID: "run-dropped", Prompt: "dropped", acceptSeq: 1},
|
| 313 |
+
{SessionID: sessionID, Prompt: "dropped-no-runid", acceptSeq: 1},
|
| 314 |
+
})
|
| 315 |
+
a.cancelMark.Set(sessionID, 2)
|
| 316 |
+
|
| 317 |
+
_, canceledWithRunID := a.drainQueueForStep(sessionID)
|
| 318 |
+
require.Len(t, canceledWithRunID, 1)
|
| 319 |
+
a.publishCanceledQueueDrops(canceledWithRunID)
|
| 320 |
+
|
| 321 |
+
requireSingleCancelledRunComplete(t, ch, sessionID, "run-dropped")
|
| 322 |
+
}
|
internal/agent/run_marker.go
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"sync/atomic"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
// runCompleteMarkerKey is the unexported context key carrying a
|
| 9 |
+
// [runCompleteMarker] from the dispatch boundary (backend.runAgent)
|
| 10 |
+
// down into the coordinator. It lets the dispatcher learn whether the
|
| 11 |
+
// coordinator already published the authoritative terminal
|
| 12 |
+
// notify.RunComplete for the run, so a fallback terminal event is only
|
| 13 |
+
// emitted when one is actually missing (e.g. an error returned before
|
| 14 |
+
// sessionAgent.Run ever executed). It avoids a breaking change to the
|
| 15 |
+
// Coordinator interface.
|
| 16 |
+
type runCompleteMarkerKey struct{}
|
| 17 |
+
|
| 18 |
+
// runCompleteMarker records whether a terminal RunComplete has been
|
| 19 |
+
// published for a run. It is shared by pointer through the context so
|
| 20 |
+
// a publish deep in the call stack is observable by the dispatcher
|
| 21 |
+
// after the call returns.
|
| 22 |
+
type runCompleteMarker struct {
|
| 23 |
+
published atomic.Bool
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// WithRunCompleteMarker returns ctx carrying a fresh marker the
|
| 27 |
+
// coordinator can flag via [MarkRunCompletePublished] once it emits the
|
| 28 |
+
// run's terminal RunComplete. Callers read the result with
|
| 29 |
+
// [RunCompletePublished]. Attaching the marker is optional: code paths
|
| 30 |
+
// without one simply skip the dedup signal.
|
| 31 |
+
func WithRunCompleteMarker(ctx context.Context) context.Context {
|
| 32 |
+
return context.WithValue(ctx, runCompleteMarkerKey{}, &runCompleteMarker{})
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// MarkRunCompletePublished records that the authoritative terminal
|
| 36 |
+
// RunComplete has been published for the run carried by ctx. It is a
|
| 37 |
+
// no-op when no marker is present (e.g. the in-process/local Run path,
|
| 38 |
+
// which is not dispatched through backend.runAgent).
|
| 39 |
+
func MarkRunCompletePublished(ctx context.Context) {
|
| 40 |
+
if m, ok := ctx.Value(runCompleteMarkerKey{}).(*runCompleteMarker); ok {
|
| 41 |
+
m.published.Store(true)
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// RunCompletePublished reports whether [MarkRunCompletePublished] was
|
| 46 |
+
// called on ctx's marker. It returns false when no marker is present.
|
| 47 |
+
func RunCompletePublished(ctx context.Context) bool {
|
| 48 |
+
if m, ok := ctx.Value(runCompleteMarkerKey{}).(*runCompleteMarker); ok {
|
| 49 |
+
return m.published.Load()
|
| 50 |
+
}
|
| 51 |
+
return false
|
| 52 |
+
}
|
internal/agent/runid.go
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import "context"
|
| 4 |
+
|
| 5 |
+
// runIDContextKey is the unexported context key used to carry a
|
| 6 |
+
// caller-supplied RunID from the workspace HTTP boundary
|
| 7 |
+
// (backend.SendMessage) down into coordinator.Run without forcing a
|
| 8 |
+
// breaking change to the Coordinator.Run signature. The value is
|
| 9 |
+
// then copied onto SessionAgentCall.RunID by the coordinator so the
|
| 10 |
+
// agent's terminal RunComplete event can echo it back to the
|
| 11 |
+
// originating caller.
|
| 12 |
+
type runIDContextKey struct{}
|
| 13 |
+
|
| 14 |
+
// WithRunID returns ctx tagged with a per-request RunID. It is the
|
| 15 |
+
// boundary helper for callers that need their SendMessage→Run
|
| 16 |
+
// terminal event to be uniquely correlatable (e.g. `crush run`
|
| 17 |
+
// against a session that may be busy). Empty runIDs are stored
|
| 18 |
+
// as-is; downstream code treats an empty RunID as "caller did not
|
| 19 |
+
// supply one" and falls back to SessionID-only correlation.
|
| 20 |
+
func WithRunID(ctx context.Context, runID string) context.Context {
|
| 21 |
+
return context.WithValue(ctx, runIDContextKey{}, runID)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// RunIDFromContext returns the RunID set by [WithRunID], or "" if
|
| 25 |
+
// none was set or the value is not a string. Exported because the
|
| 26 |
+
// coordinator and tests in other packages need to read it; safe to
|
| 27 |
+
// call on any context.
|
| 28 |
+
func RunIDFromContext(ctx context.Context) string {
|
| 29 |
+
if v, ok := ctx.Value(runIDContextKey{}).(string); ok {
|
| 30 |
+
return v
|
| 31 |
+
}
|
| 32 |
+
return ""
|
| 33 |
+
}
|
internal/agent/usage_fallback.go
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
|
| 6 |
+
"charm.land/fantasy"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func usageIsZero(usage fantasy.Usage) bool {
|
| 10 |
+
return usage.InputTokens == 0 &&
|
| 11 |
+
usage.OutputTokens == 0 &&
|
| 12 |
+
usage.TotalTokens == 0 &&
|
| 13 |
+
usage.ReasoningTokens == 0 &&
|
| 14 |
+
usage.CacheCreationTokens == 0 &&
|
| 15 |
+
usage.CacheReadTokens == 0
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
func fallbackStepUsage(messages []fantasy.Message, step fantasy.StepResult) (fantasy.Usage, bool) {
|
| 19 |
+
if !usageIsZero(step.Usage) {
|
| 20 |
+
return step.Usage, false
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
inputTokens := estimateMessageTokens(messages)
|
| 24 |
+
outputTokens := estimateStepCompletionTokens(step)
|
| 25 |
+
if inputTokens == 0 && outputTokens == 0 {
|
| 26 |
+
return fantasy.Usage{}, false
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
return fantasy.Usage{
|
| 30 |
+
InputTokens: inputTokens,
|
| 31 |
+
OutputTokens: outputTokens,
|
| 32 |
+
TotalTokens: inputTokens + outputTokens,
|
| 33 |
+
}, true
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func cloneFantasyMessages(messages []fantasy.Message) []fantasy.Message {
|
| 37 |
+
cloned := make([]fantasy.Message, len(messages))
|
| 38 |
+
for i, msg := range messages {
|
| 39 |
+
cloned[i] = msg
|
| 40 |
+
cloned[i].Content = append([]fantasy.MessagePart(nil), msg.Content...)
|
| 41 |
+
}
|
| 42 |
+
return cloned
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
func estimateMessageTokens(messages []fantasy.Message) int64 {
|
| 46 |
+
var tokens int64
|
| 47 |
+
for _, msg := range messages {
|
| 48 |
+
tokens += approxTokenCount(string(msg.Role))
|
| 49 |
+
for _, part := range msg.Content {
|
| 50 |
+
tokens += estimateMessagePartTokens(part)
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
return tokens
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func estimateStepCompletionTokens(step fantasy.StepResult) int64 {
|
| 57 |
+
var tokens int64
|
| 58 |
+
for _, content := range step.Content {
|
| 59 |
+
switch c := content.(type) {
|
| 60 |
+
case fantasy.TextContent:
|
| 61 |
+
tokens += approxTokenCount(c.Text)
|
| 62 |
+
case *fantasy.TextContent:
|
| 63 |
+
tokens += approxTokenCount(c.Text)
|
| 64 |
+
case fantasy.ReasoningContent:
|
| 65 |
+
tokens += approxTokenCount(c.Text)
|
| 66 |
+
case *fantasy.ReasoningContent:
|
| 67 |
+
tokens += approxTokenCount(c.Text)
|
| 68 |
+
case fantasy.FileContent:
|
| 69 |
+
tokens += estimateGeneratedFileTokens(c)
|
| 70 |
+
case *fantasy.FileContent:
|
| 71 |
+
tokens += estimateGeneratedFileTokens(*c)
|
| 72 |
+
case fantasy.SourceContent:
|
| 73 |
+
tokens += estimateSourceTokens(c)
|
| 74 |
+
case *fantasy.SourceContent:
|
| 75 |
+
tokens += estimateSourceTokens(*c)
|
| 76 |
+
case fantasy.ToolCallContent:
|
| 77 |
+
tokens += estimateToolCallTokens(c.ToolName, c.Input)
|
| 78 |
+
case *fantasy.ToolCallContent:
|
| 79 |
+
tokens += estimateToolCallTokens(c.ToolName, c.Input)
|
| 80 |
+
case fantasy.ToolResultContent:
|
| 81 |
+
if c.ProviderExecuted {
|
| 82 |
+
tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result)
|
| 83 |
+
}
|
| 84 |
+
case *fantasy.ToolResultContent:
|
| 85 |
+
if c.ProviderExecuted {
|
| 86 |
+
tokens += estimateToolResultContentTokens(c.ToolCallID, c.ToolName, c.ClientMetadata, c.Result)
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
return tokens
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
func estimateMessagePartTokens(part fantasy.MessagePart) int64 {
|
| 94 |
+
switch p := part.(type) {
|
| 95 |
+
case fantasy.TextPart:
|
| 96 |
+
return approxTokenCount(p.Text)
|
| 97 |
+
case *fantasy.TextPart:
|
| 98 |
+
return approxTokenCount(p.Text)
|
| 99 |
+
case fantasy.ReasoningPart:
|
| 100 |
+
return approxTokenCount(p.Text)
|
| 101 |
+
case *fantasy.ReasoningPart:
|
| 102 |
+
return approxTokenCount(p.Text)
|
| 103 |
+
case fantasy.FilePart:
|
| 104 |
+
return estimateFilePartTokens(p)
|
| 105 |
+
case *fantasy.FilePart:
|
| 106 |
+
return estimateFilePartTokens(*p)
|
| 107 |
+
case fantasy.ToolCallPart:
|
| 108 |
+
return estimateToolCallTokens(p.ToolName, p.Input)
|
| 109 |
+
case *fantasy.ToolCallPart:
|
| 110 |
+
return estimateToolCallTokens(p.ToolName, p.Input)
|
| 111 |
+
case fantasy.ToolResultPart:
|
| 112 |
+
return estimateToolResultContentTokens(p.ToolCallID, "", "", p.Output)
|
| 113 |
+
case *fantasy.ToolResultPart:
|
| 114 |
+
return estimateToolResultContentTokens(p.ToolCallID, "", "", p.Output)
|
| 115 |
+
default:
|
| 116 |
+
return 0
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
func estimateToolCallTokens(toolName, input string) int64 {
|
| 121 |
+
return approxTokenCount(toolName) + approxTokenCount(input)
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
func estimateToolResultContentTokens(toolCallID, toolName, metadata string, output fantasy.ToolResultOutputContent) int64 {
|
| 125 |
+
tokens := approxTokenCount(toolCallID) + approxTokenCount(toolName) + approxTokenCount(metadata)
|
| 126 |
+
switch result := output.(type) {
|
| 127 |
+
case fantasy.ToolResultOutputContentText:
|
| 128 |
+
tokens += approxTokenCount(result.Text)
|
| 129 |
+
case *fantasy.ToolResultOutputContentText:
|
| 130 |
+
tokens += approxTokenCount(result.Text)
|
| 131 |
+
case fantasy.ToolResultOutputContentError:
|
| 132 |
+
if result.Error != nil {
|
| 133 |
+
tokens += approxTokenCount(result.Error.Error())
|
| 134 |
+
}
|
| 135 |
+
case *fantasy.ToolResultOutputContentError:
|
| 136 |
+
if result.Error != nil {
|
| 137 |
+
tokens += approxTokenCount(result.Error.Error())
|
| 138 |
+
}
|
| 139 |
+
case fantasy.ToolResultOutputContentMedia:
|
| 140 |
+
tokens += estimateMediaTokens(result.MediaType, result.Text, len(result.Data))
|
| 141 |
+
case *fantasy.ToolResultOutputContentMedia:
|
| 142 |
+
tokens += estimateMediaTokens(result.MediaType, result.Text, len(result.Data))
|
| 143 |
+
}
|
| 144 |
+
return tokens
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
func estimateFilePartTokens(file fantasy.FilePart) int64 {
|
| 148 |
+
return estimateMediaTokens(file.MediaType, file.Filename, len(file.Data))
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
func estimateGeneratedFileTokens(file fantasy.FileContent) int64 {
|
| 152 |
+
return estimateMediaTokens(file.MediaType, "", len(file.Data))
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
func estimateMediaTokens(mediaType, text string, dataBytes int) int64 {
|
| 156 |
+
if dataBytes == 0 {
|
| 157 |
+
return approxTokenCount(mediaType) + approxTokenCount(text)
|
| 158 |
+
}
|
| 159 |
+
return approxTokenCount(fmt.Sprintf("%s %s %d bytes", mediaType, text, dataBytes))
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
func estimateSourceTokens(source fantasy.SourceContent) int64 {
|
| 163 |
+
return approxTokenCount(string(source.SourceType)) +
|
| 164 |
+
approxTokenCount(source.ID) +
|
| 165 |
+
approxTokenCount(source.URL) +
|
| 166 |
+
approxTokenCount(source.Title) +
|
| 167 |
+
approxTokenCount(source.MediaType) +
|
| 168 |
+
approxTokenCount(source.Filename)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
func approxTokenCount(s string) int64 {
|
| 172 |
+
if s == "" {
|
| 173 |
+
return 0
|
| 174 |
+
}
|
| 175 |
+
return int64((len(s) + 3) / 4)
|
| 176 |
+
}
|
internal/agent/usage_fallback_test.go
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package agent
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"errors"
|
| 5 |
+
"testing"
|
| 6 |
+
|
| 7 |
+
"charm.land/catwalk/pkg/catwalk"
|
| 8 |
+
"charm.land/fantasy"
|
| 9 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/session"
|
| 11 |
+
"github.com/stretchr/testify/require"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
func TestUsageIsZero(t *testing.T) {
|
| 15 |
+
t.Parallel()
|
| 16 |
+
|
| 17 |
+
require.True(t, usageIsZero(fantasy.Usage{}))
|
| 18 |
+
require.False(t, usageIsZero(fantasy.Usage{InputTokens: 1}))
|
| 19 |
+
require.False(t, usageIsZero(fantasy.Usage{OutputTokens: 1}))
|
| 20 |
+
require.False(t, usageIsZero(fantasy.Usage{TotalTokens: 1}))
|
| 21 |
+
require.False(t, usageIsZero(fantasy.Usage{ReasoningTokens: 1}))
|
| 22 |
+
require.False(t, usageIsZero(fantasy.Usage{CacheCreationTokens: 1}))
|
| 23 |
+
require.False(t, usageIsZero(fantasy.Usage{CacheReadTokens: 1}))
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
func TestFallbackStepUsageKeepsProviderUsage(t *testing.T) {
|
| 27 |
+
t.Parallel()
|
| 28 |
+
|
| 29 |
+
usage := fantasy.Usage{
|
| 30 |
+
InputTokens: 10,
|
| 31 |
+
OutputTokens: 5,
|
| 32 |
+
TotalTokens: 15,
|
| 33 |
+
}
|
| 34 |
+
step := fantasy.StepResult{
|
| 35 |
+
Response: fantasy.Response{Usage: usage},
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
fallbackUsage, estimated := fallbackStepUsage(nil, step)
|
| 39 |
+
require.False(t, estimated)
|
| 40 |
+
require.Equal(t, usage, fallbackUsage)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
func TestFallbackStepUsageEstimatesPromptAndAssistantText(t *testing.T) {
|
| 44 |
+
t.Parallel()
|
| 45 |
+
|
| 46 |
+
messages := []fantasy.Message{
|
| 47 |
+
fantasy.NewUserMessage("please explain the implementation details"),
|
| 48 |
+
}
|
| 49 |
+
step := fantasy.StepResult{
|
| 50 |
+
Response: fantasy.Response{
|
| 51 |
+
Content: fantasy.ResponseContent{
|
| 52 |
+
fantasy.TextContent{Text: "the implementation stores state safely"},
|
| 53 |
+
},
|
| 54 |
+
},
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
usage, estimated := fallbackStepUsage(messages, step)
|
| 58 |
+
require.True(t, estimated)
|
| 59 |
+
require.Positive(t, usage.InputTokens)
|
| 60 |
+
require.Positive(t, usage.OutputTokens)
|
| 61 |
+
require.Equal(t, usage.InputTokens+usage.OutputTokens, usage.TotalTokens)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
func TestFallbackStepUsageEstimatesReasoning(t *testing.T) {
|
| 65 |
+
t.Parallel()
|
| 66 |
+
|
| 67 |
+
messages := []fantasy.Message{
|
| 68 |
+
{
|
| 69 |
+
Role: fantasy.MessageRoleAssistant,
|
| 70 |
+
Content: []fantasy.MessagePart{
|
| 71 |
+
fantasy.ReasoningPart{Text: "first reason about the request"},
|
| 72 |
+
},
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
step := fantasy.StepResult{
|
| 76 |
+
Response: fantasy.Response{
|
| 77 |
+
Content: fantasy.ResponseContent{
|
| 78 |
+
fantasy.ReasoningContent{Text: "second reason about the answer"},
|
| 79 |
+
},
|
| 80 |
+
},
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
usage, estimated := fallbackStepUsage(messages, step)
|
| 84 |
+
require.True(t, estimated)
|
| 85 |
+
require.Positive(t, usage.InputTokens)
|
| 86 |
+
require.Positive(t, usage.OutputTokens)
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
func TestFallbackStepUsageEstimatesToolCalls(t *testing.T) {
|
| 90 |
+
t.Parallel()
|
| 91 |
+
|
| 92 |
+
step := fantasy.StepResult{
|
| 93 |
+
Response: fantasy.Response{
|
| 94 |
+
Content: fantasy.ResponseContent{
|
| 95 |
+
fantasy.ToolCallContent{
|
| 96 |
+
ToolCallID: "tool-call-1",
|
| 97 |
+
ToolName: "view",
|
| 98 |
+
Input: `{"file_path":"/tmp/example.go"}`,
|
| 99 |
+
},
|
| 100 |
+
},
|
| 101 |
+
},
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
usage, estimated := fallbackStepUsage(nil, step)
|
| 105 |
+
require.True(t, estimated)
|
| 106 |
+
require.Zero(t, usage.InputTokens)
|
| 107 |
+
require.Positive(t, usage.OutputTokens)
|
| 108 |
+
require.Equal(t, usage.OutputTokens, usage.TotalTokens)
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
func TestFallbackStepUsageEstimatesToolResults(t *testing.T) {
|
| 112 |
+
t.Parallel()
|
| 113 |
+
|
| 114 |
+
messages := []fantasy.Message{
|
| 115 |
+
{
|
| 116 |
+
Role: fantasy.MessageRoleTool,
|
| 117 |
+
Content: []fantasy.MessagePart{
|
| 118 |
+
fantasy.ToolResultPart{
|
| 119 |
+
ToolCallID: "tool-call-1",
|
| 120 |
+
Output: fantasy.ToolResultOutputContentText{
|
| 121 |
+
Text: "file contents returned by the tool",
|
| 122 |
+
},
|
| 123 |
+
},
|
| 124 |
+
fantasy.ToolResultPart{
|
| 125 |
+
ToolCallID: "tool-call-2",
|
| 126 |
+
Output: fantasy.ToolResultOutputContentError{
|
| 127 |
+
Error: errors.New("permission denied"),
|
| 128 |
+
},
|
| 129 |
+
},
|
| 130 |
+
fantasy.ToolResultPart{
|
| 131 |
+
ToolCallID: "tool-call-3",
|
| 132 |
+
Output: fantasy.ToolResultOutputContentMedia{
|
| 133 |
+
MediaType: "image/png",
|
| 134 |
+
Text: "screenshot",
|
| 135 |
+
Data: "abc123",
|
| 136 |
+
},
|
| 137 |
+
},
|
| 138 |
+
},
|
| 139 |
+
},
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
usage, estimated := fallbackStepUsage(messages, fantasy.StepResult{})
|
| 143 |
+
require.True(t, estimated)
|
| 144 |
+
require.Positive(t, usage.InputTokens)
|
| 145 |
+
require.Zero(t, usage.OutputTokens)
|
| 146 |
+
require.Equal(t, usage.InputTokens, usage.TotalTokens)
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
func TestFallbackStepUsageSkipsClientToolResultsAsOutput(t *testing.T) {
|
| 150 |
+
t.Parallel()
|
| 151 |
+
|
| 152 |
+
step := fantasy.StepResult{
|
| 153 |
+
Response: fantasy.Response{
|
| 154 |
+
Content: fantasy.ResponseContent{
|
| 155 |
+
fantasy.ToolResultContent{
|
| 156 |
+
ToolCallID: "tool-call-1",
|
| 157 |
+
ToolName: "bash",
|
| 158 |
+
Result: fantasy.ToolResultOutputContentText{
|
| 159 |
+
Text: "large client-executed payload that should not count as model output tokens",
|
| 160 |
+
},
|
| 161 |
+
},
|
| 162 |
+
},
|
| 163 |
+
},
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
usage, estimated := fallbackStepUsage(nil, step)
|
| 167 |
+
require.False(t, estimated)
|
| 168 |
+
require.Zero(t, usage.OutputTokens)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
func TestFallbackStepUsageCountsProviderToolResultsAsOutput(t *testing.T) {
|
| 172 |
+
t.Parallel()
|
| 173 |
+
|
| 174 |
+
step := fantasy.StepResult{
|
| 175 |
+
Response: fantasy.Response{
|
| 176 |
+
Content: fantasy.ResponseContent{
|
| 177 |
+
fantasy.ToolResultContent{
|
| 178 |
+
ToolCallID: "tool-call-1",
|
| 179 |
+
ToolName: "web_search",
|
| 180 |
+
ProviderExecuted: true,
|
| 181 |
+
ClientMetadata: "provider metadata",
|
| 182 |
+
Result: fantasy.ToolResultOutputContentText{Text: "provider-executed result"},
|
| 183 |
+
},
|
| 184 |
+
},
|
| 185 |
+
},
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
usage, estimated := fallbackStepUsage(nil, step)
|
| 189 |
+
require.True(t, estimated)
|
| 190 |
+
require.Positive(t, usage.OutputTokens)
|
| 191 |
+
require.Equal(t, usage.OutputTokens, usage.TotalTokens)
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
func TestFallbackStepUsageReturnsZeroWithoutContent(t *testing.T) {
|
| 195 |
+
t.Parallel()
|
| 196 |
+
|
| 197 |
+
usage, estimated := fallbackStepUsage(nil, fantasy.StepResult{})
|
| 198 |
+
require.False(t, estimated)
|
| 199 |
+
require.True(t, usageIsZero(usage))
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
func TestUpdateSessionUsageSkipsEstimatedCost(t *testing.T) {
|
| 203 |
+
t.Parallel()
|
| 204 |
+
|
| 205 |
+
agent := &sessionAgent{}
|
| 206 |
+
currentSession := &session.Session{ID: "session-id", Cost: 1.25}
|
| 207 |
+
model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
|
| 208 |
+
usage := fantasy.Usage{InputTokens: 1000, OutputTokens: 2000}
|
| 209 |
+
|
| 210 |
+
agent.updateSessionUsage(model, currentSession, usage, nil, true)
|
| 211 |
+
|
| 212 |
+
require.Equal(t, 1.25, currentSession.Cost)
|
| 213 |
+
require.Equal(t, int64(1000), currentSession.PromptTokens)
|
| 214 |
+
require.Equal(t, int64(2000), currentSession.CompletionTokens)
|
| 215 |
+
require.True(t, currentSession.EstimatedUsage)
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
func TestUpdateSessionUsageKeepsCountersForZeroUsage(t *testing.T) {
|
| 219 |
+
t.Parallel()
|
| 220 |
+
|
| 221 |
+
agent := &sessionAgent{}
|
| 222 |
+
currentSession := &session.Session{
|
| 223 |
+
ID: "session-id",
|
| 224 |
+
PromptTokens: 123,
|
| 225 |
+
CompletionTokens: 456,
|
| 226 |
+
Cost: 1.25,
|
| 227 |
+
}
|
| 228 |
+
model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
|
| 229 |
+
|
| 230 |
+
agent.updateSessionUsage(model, currentSession, fantasy.Usage{}, nil, false)
|
| 231 |
+
|
| 232 |
+
require.Equal(t, 1.25, currentSession.Cost)
|
| 233 |
+
require.Equal(t, int64(123), currentSession.PromptTokens)
|
| 234 |
+
require.Equal(t, int64(456), currentSession.CompletionTokens)
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
func TestUpdateSessionUsagePreservesOmittedCountersForPartialUsage(t *testing.T) {
|
| 238 |
+
t.Parallel()
|
| 239 |
+
|
| 240 |
+
agent := &sessionAgent{}
|
| 241 |
+
currentSession := &session.Session{
|
| 242 |
+
ID: "session-id",
|
| 243 |
+
PromptTokens: 123,
|
| 244 |
+
CompletionTokens: 456,
|
| 245 |
+
}
|
| 246 |
+
model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
|
| 247 |
+
usage := fantasy.Usage{InputTokens: 789}
|
| 248 |
+
|
| 249 |
+
agent.updateSessionUsage(model, currentSession, usage, nil, false)
|
| 250 |
+
|
| 251 |
+
require.Equal(t, int64(789), currentSession.PromptTokens)
|
| 252 |
+
require.Equal(t, int64(456), currentSession.CompletionTokens)
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
func TestUpdateSessionUsagePreservesCountersForTotalOnlyUsage(t *testing.T) {
|
| 256 |
+
t.Parallel()
|
| 257 |
+
|
| 258 |
+
agent := &sessionAgent{}
|
| 259 |
+
currentSession := &session.Session{
|
| 260 |
+
ID: "session-id",
|
| 261 |
+
PromptTokens: 123,
|
| 262 |
+
CompletionTokens: 456,
|
| 263 |
+
}
|
| 264 |
+
model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
|
| 265 |
+
usage := fantasy.Usage{TotalTokens: 100}
|
| 266 |
+
|
| 267 |
+
agent.updateSessionUsage(model, currentSession, usage, nil, false)
|
| 268 |
+
|
| 269 |
+
require.Equal(t, int64(123), currentSession.PromptTokens)
|
| 270 |
+
require.Equal(t, int64(456), currentSession.CompletionTokens)
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
func TestUpdateSessionUsagePreservesPromptForOutputOnlyUsage(t *testing.T) {
|
| 274 |
+
t.Parallel()
|
| 275 |
+
|
| 276 |
+
agent := &sessionAgent{}
|
| 277 |
+
currentSession := &session.Session{
|
| 278 |
+
ID: "session-id",
|
| 279 |
+
PromptTokens: 123,
|
| 280 |
+
CompletionTokens: 456,
|
| 281 |
+
}
|
| 282 |
+
model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
|
| 283 |
+
usage := fantasy.Usage{OutputTokens: 50}
|
| 284 |
+
|
| 285 |
+
agent.updateSessionUsage(model, currentSession, usage, nil, false)
|
| 286 |
+
|
| 287 |
+
require.Equal(t, int64(123), currentSession.PromptTokens)
|
| 288 |
+
require.Equal(t, int64(50), currentSession.CompletionTokens)
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
func TestUpdateSessionUsageKeepsCountersForEstimatedZeroUsage(t *testing.T) {
|
| 292 |
+
t.Parallel()
|
| 293 |
+
|
| 294 |
+
agent := &sessionAgent{}
|
| 295 |
+
currentSession := &session.Session{
|
| 296 |
+
ID: "session-id",
|
| 297 |
+
PromptTokens: 123,
|
| 298 |
+
CompletionTokens: 456,
|
| 299 |
+
Cost: 1.25,
|
| 300 |
+
}
|
| 301 |
+
model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
|
| 302 |
+
|
| 303 |
+
agent.updateSessionUsage(model, currentSession, fantasy.Usage{}, nil, true)
|
| 304 |
+
|
| 305 |
+
require.Equal(t, 1.25, currentSession.Cost)
|
| 306 |
+
require.Equal(t, int64(123), currentSession.PromptTokens)
|
| 307 |
+
require.Equal(t, int64(456), currentSession.CompletionTokens)
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
func TestSummaryCompletionTokens(t *testing.T) {
|
| 311 |
+
t.Parallel()
|
| 312 |
+
|
| 313 |
+
summaryMessage := message.Message{
|
| 314 |
+
Parts: []message.ContentPart{
|
| 315 |
+
message.TextContent{Text: "summary text"},
|
| 316 |
+
message.ReasoningContent{Thinking: "reasoning text"},
|
| 317 |
+
},
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
require.Equal(t, int64(42), summaryCompletionTokens(fantasy.Usage{OutputTokens: 42}, summaryMessage))
|
| 321 |
+
require.Equal(t, approxTokenCount("summary text")+approxTokenCount("reasoning text"), summaryCompletionTokens(fantasy.Usage{}, summaryMessage))
|
| 322 |
+
require.Zero(t, summaryCompletionTokens(fantasy.Usage{}, message.Message{}))
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
func TestUpdateSessionUsageAddsProviderCost(t *testing.T) {
|
| 326 |
+
t.Parallel()
|
| 327 |
+
|
| 328 |
+
agent := &sessionAgent{}
|
| 329 |
+
currentSession := &session.Session{ID: "session-id", Cost: 1.25}
|
| 330 |
+
model := Model{CatwalkCfg: catwalk.Model{CostPer1MIn: 10, CostPer1MOut: 20}}
|
| 331 |
+
usage := fantasy.Usage{InputTokens: 1000, OutputTokens: 2000}
|
| 332 |
+
|
| 333 |
+
agent.updateSessionUsage(model, currentSession, usage, nil, false)
|
| 334 |
+
|
| 335 |
+
require.Equal(t, 1.3, currentSession.Cost)
|
| 336 |
+
require.Equal(t, int64(1000), currentSession.PromptTokens)
|
| 337 |
+
require.Equal(t, int64(2000), currentSession.CompletionTokens)
|
| 338 |
+
require.False(t, currentSession.EstimatedUsage)
|
| 339 |
+
}
|
internal/apigen/docs.html.tmpl
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>{{.Info.Title}}</title>
|
| 6 |
+
<style>
|
| 7 |
+
body { font-family: system-ui, sans-serif; max-width: 60em; margin: 2em auto; padding: 0 1em; color: #222; }
|
| 8 |
+
h1 { margin-bottom: 0; }
|
| 9 |
+
h2 { margin-top: 2em; border-bottom: 1px solid #ccc; padding-bottom: .2em; }
|
| 10 |
+
.endpoint { margin: 1.5em 0; }
|
| 11 |
+
.route { font-family: ui-monospace, monospace; font-size: 1.05em; }
|
| 12 |
+
.method { display: inline-block; min-width: 4.5em; font-weight: bold; }
|
| 13 |
+
table { border-collapse: collapse; margin: .5em 0; }
|
| 14 |
+
th, td { text-align: left; padding: .2em 1em .2em 0; vertical-align: top; }
|
| 15 |
+
pre { background: #f5f5f5; padding: .8em; overflow-x: auto; font-size: .85em; }
|
| 16 |
+
.desc { color: #555; }
|
| 17 |
+
h4 { margin: .8em 0 .2em; font-weight: 600; }
|
| 18 |
+
</style>
|
| 19 |
+
</head>
|
| 20 |
+
<body>
|
| 21 |
+
<h1>{{.Info.Title}}</h1>
|
| 22 |
+
{{if .Info.Description}}<p class="desc">{{.Info.Description}}</p>{{end}}
|
| 23 |
+
<p class="desc">Version {{.Info.Version}} · Base path <code>{{.Base}}</code> · <a href="openapi.json">openapi.json</a></p>
|
| 24 |
+
{{range .Tags}}
|
| 25 |
+
<h2>{{.Name}}</h2>
|
| 26 |
+
{{range .Endpoints}}
|
| 27 |
+
<div class="endpoint">
|
| 28 |
+
<div class="route"><span class="method">{{.Method}}</span> {{.Path}}</div>
|
| 29 |
+
<div>{{.Summary}}</div>
|
| 30 |
+
{{if .Description}}<div class="desc">{{.Description}}</div>{{end}}
|
| 31 |
+
<div class="desc">Success: {{.Status}}</div>
|
| 32 |
+
{{if .Params}}
|
| 33 |
+
<h4>Parameters</h4>
|
| 34 |
+
<table>
|
| 35 |
+
<tr><th>Name</th><th>In</th><th>Required</th><th>Description</th></tr>
|
| 36 |
+
{{range .Params}}<tr><td><code>{{.Name}}</code></td><td>{{.In}}</td><td>{{if .Required}}yes{{else}}no{{end}}</td><td>{{.Description}}</td></tr>
|
| 37 |
+
{{end}}</table>
|
| 38 |
+
{{end}}
|
| 39 |
+
{{if .Request}}<h4>Request body</h4><pre>{{.Request}}</pre>{{end}}
|
| 40 |
+
{{if .SSE}}<h4>Response</h4><p>Streams <code>text/event-stream</code>.</p>{{end}}
|
| 41 |
+
{{if .Response}}<h4>Response body</h4><pre>{{.Response}}</pre>{{end}}
|
| 42 |
+
{{if .Errors}}<h4>Errors</h4><p>{{range $i, $c := .Errors}}{{if $i}}, {{end}}{{$c}}{{end}} — JSON error object</p>{{end}}
|
| 43 |
+
</div>
|
| 44 |
+
{{end}}
|
| 45 |
+
{{end}}
|
| 46 |
+
</body>
|
| 47 |
+
</html>
|
internal/apigen/endpoint.go
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package apigen provides a fluent API for documenting HTTP endpoints.
|
| 2 |
+
//
|
| 3 |
+
// An [Endpoint] is the single source of truth for one route: the same
|
| 4 |
+
// value drives mux registration and documentation generation, so the
|
| 5 |
+
// two cannot drift. Endpoints are constructed with the method builders
|
| 6 |
+
// ([Get], [Post], [Put], [Delete]) and finalized with
|
| 7 |
+
// [endpointBuilder.Handle], the only way to produce an Endpoint. A
|
| 8 |
+
// chain without Handle does not compile into a []Endpoint, so
|
| 9 |
+
// incomplete definitions fail at build time.
|
| 10 |
+
package apigen
|
| 11 |
+
|
| 12 |
+
import "net/http"
|
| 13 |
+
|
| 14 |
+
// Param documents a single path or query parameter.
|
| 15 |
+
type Param struct {
|
| 16 |
+
// Name is the parameter name as it appears in the path or query.
|
| 17 |
+
Name string
|
| 18 |
+
// In is "path" or "query".
|
| 19 |
+
In string
|
| 20 |
+
// Type is the JSON schema type, e.g. "string", "integer".
|
| 21 |
+
Type string
|
| 22 |
+
// Description is a short human-readable description.
|
| 23 |
+
Description string
|
| 24 |
+
// Required reports whether the parameter must be provided. Path
|
| 25 |
+
// parameters are always required.
|
| 26 |
+
Required bool
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// Endpoint is the documentation and routing record for a single HTTP
|
| 30 |
+
// endpoint. Construct it with the fluent builders ([Get], [Post], ...);
|
| 31 |
+
// only [endpointBuilder.Handle] produces a complete Endpoint.
|
| 32 |
+
type Endpoint struct {
|
| 33 |
+
method string
|
| 34 |
+
path string
|
| 35 |
+
summary string
|
| 36 |
+
description string
|
| 37 |
+
tags []string
|
| 38 |
+
params []Param
|
| 39 |
+
request any
|
| 40 |
+
response any
|
| 41 |
+
status int
|
| 42 |
+
errors []int
|
| 43 |
+
sse bool
|
| 44 |
+
handler http.HandlerFunc
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// Method returns the HTTP method, e.g. "GET".
|
| 48 |
+
func (e Endpoint) Method() string { return e.method }
|
| 49 |
+
|
| 50 |
+
// Path returns the route pattern, e.g. "/v1/workspaces/{id}".
|
| 51 |
+
func (e Endpoint) Path() string { return e.path }
|
| 52 |
+
|
| 53 |
+
// Summary returns the one-line summary.
|
| 54 |
+
func (e Endpoint) Summary() string { return e.summary }
|
| 55 |
+
|
| 56 |
+
// Description returns the optional longer prose description.
|
| 57 |
+
func (e Endpoint) Description() string { return e.description }
|
| 58 |
+
|
| 59 |
+
// Tags returns the grouping tags.
|
| 60 |
+
func (e Endpoint) Tags() []string { return e.tags }
|
| 61 |
+
|
| 62 |
+
// Params returns the documented path and query parameters.
|
| 63 |
+
func (e Endpoint) Params() []Param { return e.params }
|
| 64 |
+
|
| 65 |
+
// Request returns the request body example value, or nil for none.
|
| 66 |
+
func (e Endpoint) Request() any { return e.request }
|
| 67 |
+
|
| 68 |
+
// Response returns the success response example value, or nil for an
|
| 69 |
+
// empty success response.
|
| 70 |
+
func (e Endpoint) Response() any { return e.response }
|
| 71 |
+
|
| 72 |
+
// Status returns the success status code, defaulting to 200.
|
| 73 |
+
func (e Endpoint) Status() int {
|
| 74 |
+
if e.status == 0 {
|
| 75 |
+
return http.StatusOK
|
| 76 |
+
}
|
| 77 |
+
return e.status
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Errors returns the status codes that return an error object.
|
| 81 |
+
func (e Endpoint) Errors() []int { return e.errors }
|
| 82 |
+
|
| 83 |
+
// SSE reports whether the endpoint streams text/event-stream instead
|
| 84 |
+
// of producing a JSON response.
|
| 85 |
+
func (e Endpoint) SSE() bool { return e.sse }
|
| 86 |
+
|
| 87 |
+
// Handler returns the endpoint's HTTP handler.
|
| 88 |
+
func (e Endpoint) Handler() http.HandlerFunc { return e.handler }
|
| 89 |
+
|
| 90 |
+
// endpointBuilder accumulates endpoint documentation fluently. It is
|
| 91 |
+
// deliberately a distinct type from [Endpoint]: a []Endpoint literal
|
| 92 |
+
// cannot contain a builder, so a chain missing Handle fails to
|
| 93 |
+
// compile.
|
| 94 |
+
type endpointBuilder struct{ e Endpoint }
|
| 95 |
+
|
| 96 |
+
// Get begins a GET endpoint for path, e.g. Get("/v1/workspaces/{id}").
|
| 97 |
+
func Get(path string) endpointBuilder {
|
| 98 |
+
return endpointBuilder{Endpoint{method: http.MethodGet, path: path}}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// Post begins a POST endpoint for path.
|
| 102 |
+
func Post(path string) endpointBuilder {
|
| 103 |
+
return endpointBuilder{Endpoint{method: http.MethodPost, path: path}}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// Put begins a PUT endpoint for path.
|
| 107 |
+
func Put(path string) endpointBuilder {
|
| 108 |
+
return endpointBuilder{Endpoint{method: http.MethodPut, path: path}}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Delete begins a DELETE endpoint for path.
|
| 112 |
+
func Delete(path string) endpointBuilder {
|
| 113 |
+
return endpointBuilder{Endpoint{method: http.MethodDelete, path: path}}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Summary sets the one-line summary.
|
| 117 |
+
func (b endpointBuilder) Summary(s string) endpointBuilder { b.e.summary = s; return b }
|
| 118 |
+
|
| 119 |
+
// Description sets an optional longer prose description.
|
| 120 |
+
func (b endpointBuilder) Description(s string) endpointBuilder { b.e.description = s; return b }
|
| 121 |
+
|
| 122 |
+
// Tags sets the grouping tags used on the docs page.
|
| 123 |
+
func (b endpointBuilder) Tags(t ...string) endpointBuilder { b.e.tags = t; return b }
|
| 124 |
+
|
| 125 |
+
// Accepts documents the request body with an example value, e.g.
|
| 126 |
+
// Accepts(proto.Workspace{}). The value's type is used to derive the
|
| 127 |
+
// JSON schema; the reference is compile-checked.
|
| 128 |
+
func (b endpointBuilder) Accepts(v any) endpointBuilder { b.e.request = v; return b }
|
| 129 |
+
|
| 130 |
+
// Responds documents the success response body with an example value.
|
| 131 |
+
func (b endpointBuilder) Responds(v any) endpointBuilder { b.e.response = v; return b }
|
| 132 |
+
|
| 133 |
+
// Status sets the success status code when it differs from 200, e.g.
|
| 134 |
+
// 202 for an accepted-but-async operation.
|
| 135 |
+
func (b endpointBuilder) Status(code int) endpointBuilder { b.e.status = code; return b }
|
| 136 |
+
|
| 137 |
+
// Fails documents status codes that return an error object.
|
| 138 |
+
func (b endpointBuilder) Fails(codes ...int) endpointBuilder { b.e.errors = codes; return b }
|
| 139 |
+
|
| 140 |
+
// SSE marks the endpoint as streaming text/event-stream.
|
| 141 |
+
func (b endpointBuilder) SSE() endpointBuilder { b.e.sse = true; return b }
|
| 142 |
+
|
| 143 |
+
// PathParam documents a path parameter. Path parameters are always
|
| 144 |
+
// required.
|
| 145 |
+
func (b endpointBuilder) PathParam(name, desc string) endpointBuilder {
|
| 146 |
+
return b.param(Param{Name: name, In: "path", Type: "string", Description: desc, Required: true})
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// QueryParam documents an optional query parameter.
|
| 150 |
+
func (b endpointBuilder) QueryParam(name, desc string) endpointBuilder {
|
| 151 |
+
return b.param(Param{Name: name, In: "query", Type: "string", Description: desc})
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// RequiredQueryParam documents a required query parameter.
|
| 155 |
+
func (b endpointBuilder) RequiredQueryParam(name, desc string) endpointBuilder {
|
| 156 |
+
return b.param(Param{Name: name, In: "query", Type: "string", Description: desc, Required: true})
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
func (b endpointBuilder) param(p Param) endpointBuilder {
|
| 160 |
+
b.e.params = append(b.e.params, p)
|
| 161 |
+
return b
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// Handle finalizes the endpoint with its HTTP handler.
|
| 165 |
+
func (b endpointBuilder) Handle(h http.HandlerFunc) Endpoint {
|
| 166 |
+
b.e.handler = h
|
| 167 |
+
return b.e
|
| 168 |
+
}
|
internal/apigen/endpoint_test.go
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package apigen
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"net/http"
|
| 6 |
+
"strings"
|
| 7 |
+
"testing"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"github.com/stretchr/testify/require"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
type nested struct {
|
| 14 |
+
Value string `json:"value"`
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
type sample struct {
|
| 18 |
+
ID string `json:"id"`
|
| 19 |
+
Count int `json:"count"`
|
| 20 |
+
Ratio float64 `json:"ratio,omitempty"`
|
| 21 |
+
Active bool `json:"active,omitempty"`
|
| 22 |
+
Labels []string `json:"labels"`
|
| 23 |
+
Meta map[string]string `json:"meta,omitempty"`
|
| 24 |
+
Nested nested `json:"nested"`
|
| 25 |
+
Ptr *nested `json:"ptr,omitempty"`
|
| 26 |
+
When time.Time `json:"when"`
|
| 27 |
+
Err error `json:"error,omitempty"`
|
| 28 |
+
Anything any `json:"anything,omitempty"`
|
| 29 |
+
Skipped string `json:"-"`
|
| 30 |
+
unexported string
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
func TestSchemaOfStruct(t *testing.T) {
|
| 34 |
+
t.Parallel()
|
| 35 |
+
s := SchemaOf(sample{})
|
| 36 |
+
|
| 37 |
+
require.Equal(t, "object", s.Type)
|
| 38 |
+
require.ElementsMatch(t, []string{"id", "count", "labels", "nested", "when"}, s.Required)
|
| 39 |
+
|
| 40 |
+
require.Equal(t, "string", s.Properties["id"].Type)
|
| 41 |
+
require.Equal(t, "integer", s.Properties["count"].Type)
|
| 42 |
+
require.Equal(t, "number", s.Properties["ratio"].Type)
|
| 43 |
+
require.Equal(t, "boolean", s.Properties["active"].Type)
|
| 44 |
+
require.Equal(t, "array", s.Properties["labels"].Type)
|
| 45 |
+
require.Equal(t, "string", s.Properties["labels"].Items.Type)
|
| 46 |
+
require.Equal(t, "object", s.Properties["meta"].Type)
|
| 47 |
+
require.Equal(t, "string", s.Properties["meta"].AdditionalProperties.Type)
|
| 48 |
+
require.Equal(t, "object", s.Properties["nested"].Type)
|
| 49 |
+
require.Equal(t, "string", s.Properties["nested"].Properties["value"].Type)
|
| 50 |
+
require.Equal(t, "object", s.Properties["ptr"].Type)
|
| 51 |
+
require.Equal(t, "string", s.Properties["when"].Type)
|
| 52 |
+
require.Equal(t, "date-time", s.Properties["when"].Format)
|
| 53 |
+
require.Equal(t, "string", s.Properties["error"].Type)
|
| 54 |
+
require.Equal(t, &Schema{}, s.Properties["anything"])
|
| 55 |
+
|
| 56 |
+
require.NotContains(t, s.Properties, "Skipped")
|
| 57 |
+
require.NotContains(t, s.Properties, "unexported")
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
type cyc struct {
|
| 61 |
+
Name string `json:"name"`
|
| 62 |
+
Child *cyc `json:"child,omitempty"`
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
func TestSchemaOfCycle(t *testing.T) {
|
| 66 |
+
t.Parallel()
|
| 67 |
+
s := SchemaOf(cyc{})
|
| 68 |
+
require.Equal(t, "object", s.Type)
|
| 69 |
+
require.Equal(t, "apigen.cyc", s.Properties["child"].Description)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
type withTime struct {
|
| 73 |
+
At time.Time `json:"at"`
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
func TestSchemaOfSamePackageExpansion(t *testing.T) {
|
| 77 |
+
t.Parallel()
|
| 78 |
+
// Types from this package expand even when reached through another.
|
| 79 |
+
s := SchemaOf(withTime{})
|
| 80 |
+
require.Equal(t, "string", s.Properties["at"].Type)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
type embeds struct {
|
| 84 |
+
nested
|
| 85 |
+
Extra string `json:"extra"`
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
func TestSchemaOfEmbedded(t *testing.T) {
|
| 89 |
+
t.Parallel()
|
| 90 |
+
s := SchemaOf(embeds{})
|
| 91 |
+
require.Contains(t, s.Properties, "value")
|
| 92 |
+
require.Contains(t, s.Properties, "extra")
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
func TestBuilderCompileContract(t *testing.T) {
|
| 96 |
+
t.Parallel()
|
| 97 |
+
called := false
|
| 98 |
+
h := func(http.ResponseWriter, *http.Request) { called = true }
|
| 99 |
+
|
| 100 |
+
e := Get("/v1/things/{id}").
|
| 101 |
+
Summary("Get thing").
|
| 102 |
+
Description("Longer prose.").
|
| 103 |
+
Tags("things").
|
| 104 |
+
PathParam("id", "Thing ID").
|
| 105 |
+
QueryParam("verbose", "Verbose output").
|
| 106 |
+
RequiredQueryParam("mode", "Mode").
|
| 107 |
+
Responds(sample{}).
|
| 108 |
+
Fails(404, 500).
|
| 109 |
+
Handle(h)
|
| 110 |
+
|
| 111 |
+
require.Equal(t, http.MethodGet, e.Method())
|
| 112 |
+
require.Equal(t, "/v1/things/{id}", e.Path())
|
| 113 |
+
require.Equal(t, "Get thing", e.Summary())
|
| 114 |
+
require.Equal(t, "Longer prose.", e.Description())
|
| 115 |
+
require.Equal(t, []string{"things"}, e.Tags())
|
| 116 |
+
require.Equal(t, []Param{
|
| 117 |
+
{Name: "id", In: "path", Type: "string", Description: "Thing ID", Required: true},
|
| 118 |
+
{Name: "verbose", In: "query", Type: "string", Description: "Verbose output"},
|
| 119 |
+
{Name: "mode", In: "query", Type: "string", Description: "Mode", Required: true},
|
| 120 |
+
}, e.Params())
|
| 121 |
+
require.Equal(t, sample{}, e.Response())
|
| 122 |
+
require.Nil(t, e.Request())
|
| 123 |
+
require.Equal(t, []int{404, 500}, e.Errors())
|
| 124 |
+
require.False(t, e.SSE())
|
| 125 |
+
|
| 126 |
+
e.Handler()(nil, nil)
|
| 127 |
+
require.True(t, called)
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
func TestBuilderVerbsAndSSE(t *testing.T) {
|
| 131 |
+
t.Parallel()
|
| 132 |
+
for method, build := range map[string]func(string) endpointBuilder{
|
| 133 |
+
http.MethodGet: Get,
|
| 134 |
+
http.MethodPost: Post,
|
| 135 |
+
http.MethodPut: Put,
|
| 136 |
+
http.MethodDelete: Delete,
|
| 137 |
+
} {
|
| 138 |
+
e := build("/x").Handle(func(http.ResponseWriter, *http.Request) {})
|
| 139 |
+
require.Equal(t, method, e.Method())
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
e := Get("/events").SSE().Handle(func(http.ResponseWriter, *http.Request) {})
|
| 143 |
+
require.True(t, e.SSE())
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
func TestSpecJSON(t *testing.T) {
|
| 147 |
+
t.Parallel()
|
| 148 |
+
info := Info{Title: "Test API", Version: "1.0", Description: "Desc."}
|
| 149 |
+
endpoints := []Endpoint{
|
| 150 |
+
Post("/v1/things").
|
| 151 |
+
Summary("Create thing").
|
| 152 |
+
Accepts(sample{}).
|
| 153 |
+
Responds(sample{}).
|
| 154 |
+
Fails(400).
|
| 155 |
+
Handle(func(http.ResponseWriter, *http.Request) {}),
|
| 156 |
+
Get("/v1/things/{id}/events").
|
| 157 |
+
Summary("Stream").
|
| 158 |
+
PathParam("id", "Thing ID").
|
| 159 |
+
SSE().
|
| 160 |
+
Handle(func(http.ResponseWriter, *http.Request) {}),
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
data, err := SpecJSON(info, "/v1", endpoints)
|
| 164 |
+
require.NoError(t, err)
|
| 165 |
+
text := string(data)
|
| 166 |
+
|
| 167 |
+
require.Contains(t, text, `"openapi": "3.2.1"`)
|
| 168 |
+
require.Contains(t, text, `"/things"`)
|
| 169 |
+
require.Contains(t, text, `"/things/{id}/events"`)
|
| 170 |
+
require.Contains(t, text, `"text/event-stream"`)
|
| 171 |
+
require.Contains(t, text, `"application/json"`)
|
| 172 |
+
require.NotContains(t, text, "/v1/things\"") // base prefix stripped
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
func TestRenderDocs(t *testing.T) {
|
| 176 |
+
t.Parallel()
|
| 177 |
+
info := Info{Title: "Test API", Version: "1.0", Description: "Desc."}
|
| 178 |
+
endpoints := []Endpoint{
|
| 179 |
+
Get("/v1/things/{id}").
|
| 180 |
+
Summary("Get thing").
|
| 181 |
+
Tags("things").
|
| 182 |
+
PathParam("id", "Thing ID").
|
| 183 |
+
Responds(sample{}).
|
| 184 |
+
Fails(404).
|
| 185 |
+
Handle(func(http.ResponseWriter, *http.Request) {}),
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
var sb strings.Builder
|
| 189 |
+
require.NoError(t, RenderDocs(&sb, info, "/v1", endpoints))
|
| 190 |
+
html := sb.String()
|
| 191 |
+
|
| 192 |
+
require.Contains(t, html, "Test API")
|
| 193 |
+
require.Contains(t, html, "/v1/things/{id}")
|
| 194 |
+
require.Contains(t, html, "Get thing")
|
| 195 |
+
require.Contains(t, html, "Thing ID")
|
| 196 |
+
require.Contains(t, html, "count")
|
| 197 |
+
require.Contains(t, html, "openapi.json")
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
func TestErrorSchemaShape(t *testing.T) {
|
| 201 |
+
t.Parallel()
|
| 202 |
+
// The shared error schema must stay aligned with the error JSON
|
| 203 |
+
// handlers actually write: {"error": "..."}.
|
| 204 |
+
data, err := json.Marshal(errorSchema)
|
| 205 |
+
require.NoError(t, err)
|
| 206 |
+
require.Contains(t, string(data), `"error"`)
|
| 207 |
+
}
|
internal/apigen/openapi.go
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package apigen
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"net/http"
|
| 6 |
+
"sort"
|
| 7 |
+
"strconv"
|
| 8 |
+
"strings"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
// Info carries the top-level API metadata for the OpenAPI document.
|
| 12 |
+
type Info struct {
|
| 13 |
+
Title string
|
| 14 |
+
Version string
|
| 15 |
+
Description string
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// document is an OpenAPI 3.1 document rooted at the API's base path.
|
| 19 |
+
type document struct {
|
| 20 |
+
OpenAPI string `json:"openapi"`
|
| 21 |
+
Info Info `json:"info"`
|
| 22 |
+
Paths map[string]pathItem `json:"paths"`
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
type pathItem map[string]operation
|
| 26 |
+
|
| 27 |
+
type operation struct {
|
| 28 |
+
Summary string `json:"summary"`
|
| 29 |
+
Description string `json:"description,omitempty"`
|
| 30 |
+
Tags []string `json:"tags,omitempty"`
|
| 31 |
+
Parameters []parameter `json:"parameters,omitempty"`
|
| 32 |
+
RequestBody *requestBody `json:"requestBody,omitempty"`
|
| 33 |
+
Responses map[string]specResponse `json:"responses"`
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
type parameter struct {
|
| 37 |
+
Name string `json:"name"`
|
| 38 |
+
In string `json:"in"`
|
| 39 |
+
Description string `json:"description,omitempty"`
|
| 40 |
+
Required bool `json:"required"`
|
| 41 |
+
Schema *Schema `json:"schema"`
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
type requestBody struct {
|
| 45 |
+
Required bool `json:"required"`
|
| 46 |
+
Content map[string]mediaType `json:"content"`
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
type mediaType struct {
|
| 50 |
+
Schema *Schema `json:"schema"`
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
type specResponse struct {
|
| 54 |
+
Description string `json:"description"`
|
| 55 |
+
Content map[string]mediaType `json:"content,omitempty"`
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
var errorSchema = &Schema{
|
| 59 |
+
Type: "object",
|
| 60 |
+
Properties: map[string]*Schema{
|
| 61 |
+
"error": {Type: "string"},
|
| 62 |
+
},
|
| 63 |
+
Required: []string{"error"},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// SpecJSON renders the endpoints as an OpenAPI 3.1 JSON document. Base
|
| 67 |
+
// is the API's base path (e.g. "/v1"); endpoint paths are relative to
|
| 68 |
+
// it. The output is deterministic: map keys are sorted by
|
| 69 |
+
// encoding/json.
|
| 70 |
+
func SpecJSON(info Info, base string, endpoints []Endpoint) ([]byte, error) {
|
| 71 |
+
doc := document{
|
| 72 |
+
OpenAPI: "3.2.1",
|
| 73 |
+
Info: info,
|
| 74 |
+
Paths: map[string]pathItem{},
|
| 75 |
+
}
|
| 76 |
+
for _, e := range endpoints {
|
| 77 |
+
path := strings.TrimPrefix(e.path, base)
|
| 78 |
+
op := operation{
|
| 79 |
+
Summary: e.summary,
|
| 80 |
+
Description: e.description,
|
| 81 |
+
Tags: e.tags,
|
| 82 |
+
Responses: map[string]specResponse{},
|
| 83 |
+
}
|
| 84 |
+
for _, p := range e.params {
|
| 85 |
+
op.Parameters = append(op.Parameters, parameter{
|
| 86 |
+
Name: p.Name,
|
| 87 |
+
In: p.In,
|
| 88 |
+
Description: p.Description,
|
| 89 |
+
Required: p.Required,
|
| 90 |
+
Schema: &Schema{Type: p.Type},
|
| 91 |
+
})
|
| 92 |
+
}
|
| 93 |
+
if e.request != nil {
|
| 94 |
+
op.RequestBody = &requestBody{
|
| 95 |
+
Required: true,
|
| 96 |
+
Content: map[string]mediaType{
|
| 97 |
+
"application/json": {Schema: SchemaOf(e.request)},
|
| 98 |
+
},
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
status := e.Status()
|
| 102 |
+
ok := specResponse{Description: http.StatusText(status)}
|
| 103 |
+
switch {
|
| 104 |
+
case e.sse:
|
| 105 |
+
ok.Content = map[string]mediaType{
|
| 106 |
+
"text/event-stream": {Schema: &Schema{Type: "string"}},
|
| 107 |
+
}
|
| 108 |
+
case e.response != nil:
|
| 109 |
+
ok.Content = map[string]mediaType{
|
| 110 |
+
"application/json": {Schema: SchemaOf(e.response)},
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
op.Responses[strconv.Itoa(status)] = ok
|
| 114 |
+
for _, code := range e.errors {
|
| 115 |
+
op.Responses[strconv.Itoa(code)] = specResponse{
|
| 116 |
+
Description: http.StatusText(code),
|
| 117 |
+
Content: map[string]mediaType{
|
| 118 |
+
"application/json": {Schema: errorSchema},
|
| 119 |
+
},
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
item := doc.Paths[path]
|
| 124 |
+
if item == nil {
|
| 125 |
+
item = pathItem{}
|
| 126 |
+
doc.Paths[path] = item
|
| 127 |
+
}
|
| 128 |
+
item[strings.ToLower(e.method)] = op
|
| 129 |
+
}
|
| 130 |
+
return json.MarshalIndent(doc, "", " ")
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// Tags returns the sorted set of tags across endpoints.
|
| 134 |
+
func Tags(endpoints []Endpoint) []string {
|
| 135 |
+
seen := map[string]bool{}
|
| 136 |
+
var tags []string
|
| 137 |
+
for _, e := range endpoints {
|
| 138 |
+
for _, t := range e.tags {
|
| 139 |
+
if !seen[t] {
|
| 140 |
+
seen[t] = true
|
| 141 |
+
tags = append(tags, t)
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
sort.Strings(tags)
|
| 146 |
+
return tags
|
| 147 |
+
}
|
internal/apigen/render.go
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package apigen
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
_ "embed"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"html/template"
|
| 7 |
+
"io"
|
| 8 |
+
"sort"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
// RenderDocs writes a plain, self-contained HTML documentation page for
|
| 12 |
+
// the endpoints, grouped by tag.
|
| 13 |
+
func RenderDocs(w io.Writer, info Info, base string, endpoints []Endpoint) error {
|
| 14 |
+
return docsTemplate.Execute(w, newDocsModel(info, base, endpoints))
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
type docsModel struct {
|
| 18 |
+
Info Info
|
| 19 |
+
Base string
|
| 20 |
+
Tags []tagGroup
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
type tagGroup struct {
|
| 24 |
+
Name string
|
| 25 |
+
Endpoints []endpointView
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
type endpointView struct {
|
| 29 |
+
Method string
|
| 30 |
+
Path string
|
| 31 |
+
Summary string
|
| 32 |
+
Description string
|
| 33 |
+
Params []Param
|
| 34 |
+
Request string
|
| 35 |
+
Response string
|
| 36 |
+
Status int
|
| 37 |
+
Errors []int
|
| 38 |
+
SSE bool
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func newDocsModel(info Info, base string, endpoints []Endpoint) docsModel {
|
| 42 |
+
byTag := map[string][]endpointView{}
|
| 43 |
+
for _, e := range endpoints {
|
| 44 |
+
view := endpointView{
|
| 45 |
+
Method: e.method,
|
| 46 |
+
Path: e.path,
|
| 47 |
+
Summary: e.summary,
|
| 48 |
+
Description: e.description,
|
| 49 |
+
Params: e.params,
|
| 50 |
+
Status: e.Status(),
|
| 51 |
+
Errors: e.errors,
|
| 52 |
+
SSE: e.sse,
|
| 53 |
+
}
|
| 54 |
+
if e.request != nil {
|
| 55 |
+
view.Request = schemaJSON(SchemaOf(e.request))
|
| 56 |
+
}
|
| 57 |
+
if e.response != nil {
|
| 58 |
+
view.Response = schemaJSON(SchemaOf(e.response))
|
| 59 |
+
}
|
| 60 |
+
tags := e.tags
|
| 61 |
+
if len(tags) == 0 {
|
| 62 |
+
tags = []string{"other"}
|
| 63 |
+
}
|
| 64 |
+
for _, t := range tags {
|
| 65 |
+
byTag[t] = append(byTag[t], view)
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
model := docsModel{Info: info, Base: base}
|
| 70 |
+
for _, name := range Tags(endpoints) {
|
| 71 |
+
group := tagGroup{Name: name, Endpoints: byTag[name]}
|
| 72 |
+
sort.Slice(group.Endpoints, func(i, j int) bool {
|
| 73 |
+
return group.Endpoints[i].Path < group.Endpoints[j].Path
|
| 74 |
+
})
|
| 75 |
+
model.Tags = append(model.Tags, group)
|
| 76 |
+
}
|
| 77 |
+
if other, ok := byTag["other"]; ok {
|
| 78 |
+
model.Tags = append(model.Tags, tagGroup{Name: "other", Endpoints: other})
|
| 79 |
+
}
|
| 80 |
+
return model
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
func schemaJSON(s *Schema) string {
|
| 84 |
+
data, err := json.MarshalIndent(s, "", " ")
|
| 85 |
+
if err != nil {
|
| 86 |
+
return ""
|
| 87 |
+
}
|
| 88 |
+
return string(data)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
//go:embed docs.html.tmpl
|
| 92 |
+
var docsTemplateSource string
|
| 93 |
+
|
| 94 |
+
var docsTemplate = template.Must(template.New("docs").Parse(docsTemplateSource))
|
internal/apigen/schema.go
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package apigen
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"reflect"
|
| 5 |
+
"strings"
|
| 6 |
+
"time"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
// Schema is a JSON-Schema-shaped description of a Go value, derived by
|
| 10 |
+
// reflection. It supports the subset of JSON Schema used by the docs
|
| 11 |
+
// page and the OpenAPI document.
|
| 12 |
+
type Schema struct {
|
| 13 |
+
Type string `json:"type,omitempty"`
|
| 14 |
+
Format string `json:"format,omitempty"`
|
| 15 |
+
Description string `json:"description,omitempty"`
|
| 16 |
+
Properties map[string]*Schema `json:"properties,omitempty"`
|
| 17 |
+
Required []string `json:"required,omitempty"`
|
| 18 |
+
Items *Schema `json:"items,omitempty"`
|
| 19 |
+
AdditionalProperties *Schema `json:"additionalProperties,omitempty"`
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
var (
|
| 23 |
+
timeType = reflect.TypeOf(time.Time{})
|
| 24 |
+
errorType = reflect.TypeOf((*error)(nil)).Elem()
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
// SchemaOf derives a [Schema] from the type of v, honoring encoding/json
|
| 28 |
+
// tags: field names come from json tags, and fields marked omitempty are
|
| 29 |
+
// not required.
|
| 30 |
+
//
|
| 31 |
+
// Structs from the same package as v's type are expanded recursively;
|
| 32 |
+
// structs from other packages (and true type cycles) are rendered as
|
| 33 |
+
// free-form objects described by their type name, which keeps schemas
|
| 34 |
+
// for wide external types honest without inventing shape.
|
| 35 |
+
func SchemaOf(v any) *Schema {
|
| 36 |
+
t := reflect.TypeOf(v)
|
| 37 |
+
if t == nil {
|
| 38 |
+
return &Schema{}
|
| 39 |
+
}
|
| 40 |
+
return schemaOf(t, rootPkg(t), map[reflect.Type]bool{})
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// rootPkg returns the package path of the (possibly pointer) type v's
|
| 44 |
+
// named root, used to bound struct expansion to one package.
|
| 45 |
+
func rootPkg(t reflect.Type) string {
|
| 46 |
+
for t.Kind() == reflect.Pointer {
|
| 47 |
+
t = t.Elem()
|
| 48 |
+
}
|
| 49 |
+
return t.PkgPath()
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
func schemaOf(t reflect.Type, pkg string, expanding map[reflect.Type]bool) *Schema {
|
| 53 |
+
switch t {
|
| 54 |
+
case timeType:
|
| 55 |
+
return &Schema{Type: "string", Format: "date-time"}
|
| 56 |
+
case errorType:
|
| 57 |
+
return &Schema{Type: "string"}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
switch t.Kind() {
|
| 61 |
+
case reflect.Pointer:
|
| 62 |
+
return schemaOf(t.Elem(), pkg, expanding)
|
| 63 |
+
case reflect.Bool:
|
| 64 |
+
return &Schema{Type: "boolean"}
|
| 65 |
+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
| 66 |
+
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
| 67 |
+
return &Schema{Type: "integer"}
|
| 68 |
+
case reflect.Float32, reflect.Float64:
|
| 69 |
+
return &Schema{Type: "number"}
|
| 70 |
+
case reflect.String:
|
| 71 |
+
return &Schema{Type: "string"}
|
| 72 |
+
case reflect.Slice, reflect.Array:
|
| 73 |
+
return &Schema{Type: "array", Items: schemaOf(t.Elem(), pkg, expanding)}
|
| 74 |
+
case reflect.Map:
|
| 75 |
+
if t.Key().Kind() != reflect.String {
|
| 76 |
+
return &Schema{Type: "object"}
|
| 77 |
+
}
|
| 78 |
+
return &Schema{Type: "object", AdditionalProperties: schemaOf(t.Elem(), pkg, expanding)}
|
| 79 |
+
case reflect.Struct:
|
| 80 |
+
return structSchema(t, pkg, expanding)
|
| 81 |
+
default:
|
| 82 |
+
// Interfaces (including any) and anything else: free-form.
|
| 83 |
+
return &Schema{}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
func structSchema(t reflect.Type, pkg string, expanding map[reflect.Type]bool) *Schema {
|
| 88 |
+
if t.PkgPath() != pkg {
|
| 89 |
+
return &Schema{Type: "object", Description: t.String()}
|
| 90 |
+
}
|
| 91 |
+
if expanding[t] {
|
| 92 |
+
return &Schema{Type: "object", Description: t.String()}
|
| 93 |
+
}
|
| 94 |
+
expanding[t] = true
|
| 95 |
+
defer delete(expanding, t)
|
| 96 |
+
|
| 97 |
+
s := &Schema{Type: "object"}
|
| 98 |
+
for i := range t.NumField() {
|
| 99 |
+
f := t.Field(i)
|
| 100 |
+
tag := f.Tag.Get("json")
|
| 101 |
+
if tag == "-" {
|
| 102 |
+
continue
|
| 103 |
+
}
|
| 104 |
+
name, opts, _ := strings.Cut(tag, ",")
|
| 105 |
+
if f.Anonymous && name == "" {
|
| 106 |
+
// Embedded structs contribute their fields, per
|
| 107 |
+
// encoding/json semantics, even when the embedded
|
| 108 |
+
// type itself is unexported.
|
| 109 |
+
emb := schemaOf(f.Type, pkg, expanding)
|
| 110 |
+
for k, v := range emb.Properties {
|
| 111 |
+
if s.Properties == nil {
|
| 112 |
+
s.Properties = map[string]*Schema{}
|
| 113 |
+
}
|
| 114 |
+
s.Properties[k] = v
|
| 115 |
+
}
|
| 116 |
+
s.Required = append(s.Required, emb.Required...)
|
| 117 |
+
continue
|
| 118 |
+
}
|
| 119 |
+
if !f.IsExported() {
|
| 120 |
+
continue
|
| 121 |
+
}
|
| 122 |
+
if name == "" {
|
| 123 |
+
name = f.Name
|
| 124 |
+
}
|
| 125 |
+
if s.Properties == nil {
|
| 126 |
+
s.Properties = map[string]*Schema{}
|
| 127 |
+
}
|
| 128 |
+
s.Properties[name] = schemaOf(f.Type, pkg, expanding)
|
| 129 |
+
if !strings.Contains(opts, "omitempty") {
|
| 130 |
+
s.Required = append(s.Required, name)
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
return s
|
| 134 |
+
}
|
internal/backend/accepted_run_integration_test.go
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package backend
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"testing"
|
| 6 |
+
"time"
|
| 7 |
+
|
| 8 |
+
"charm.land/fantasy"
|
| 9 |
+
"github.com/charmbracelet/crush/internal/agent"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/agent/agenttest"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/db"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 13 |
+
"github.com/charmbracelet/crush/internal/proto"
|
| 14 |
+
"github.com/charmbracelet/crush/internal/session"
|
| 15 |
+
"github.com/stretchr/testify/require"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
// gatedCoordinator wraps a real agent.Coordinator and parks RunAccepted
|
| 19 |
+
// before delegating to it. Every method other than RunAccepted is
|
| 20 |
+
// inherited from the embedded coordinator, so BeginAccepted (called by
|
| 21 |
+
// Backend.SendMessage) and RunAccepted (called by the dispatched run)
|
| 22 |
+
// are the production agent.Coordinator implementations under test, not
|
| 23 |
+
// stubs. The gate only delays entry into the real RunAccepted so a
|
| 24 |
+
// cancel can be made to land in the accepted-but-not-yet-active window
|
| 25 |
+
// deterministically: the accept handle is not consumed by
|
| 26 |
+
// sessionAgent.Run until the real RunAccepted runs after the gate opens.
|
| 27 |
+
type gatedCoordinator struct {
|
| 28 |
+
agent.Coordinator
|
| 29 |
+
entered chan struct{}
|
| 30 |
+
gate chan struct{}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
func (c *gatedCoordinator) RunAccepted(ctx context.Context, accept *agent.AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
|
| 34 |
+
close(c.entered)
|
| 35 |
+
<-c.gate
|
| 36 |
+
return c.Coordinator.RunAccepted(ctx, accept, sessionID, prompt, attachments...)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// newRealCoordinator builds a production agent.Coordinator over a
|
| 40 |
+
// DB-backed session/message store, wrapped in a gate. It is constructed
|
| 41 |
+
// through the real agent.NewCoordinator path (via the test-only
|
| 42 |
+
// agenttest helper) with an offline-resolvable model: the
|
| 43 |
+
// cancel-on-entry path under test persists a canceled turn and returns
|
| 44 |
+
// before any model call, so no network I/O happens.
|
| 45 |
+
func newRealCoordinator(t *testing.T) (*gatedCoordinator, session.Service, message.Service) {
|
| 46 |
+
t.Helper()
|
| 47 |
+
conn, err := db.Connect(t.Context(), t.TempDir())
|
| 48 |
+
require.NoError(t, err)
|
| 49 |
+
t.Cleanup(func() { conn.Close() })
|
| 50 |
+
|
| 51 |
+
q := db.New(conn)
|
| 52 |
+
sessions := session.NewService(q, conn)
|
| 53 |
+
messages := message.NewService(q)
|
| 54 |
+
|
| 55 |
+
coord, err := agenttest.NewCoordinator(t.Context(), t.TempDir(), sessions, messages)
|
| 56 |
+
require.NoError(t, err)
|
| 57 |
+
|
| 58 |
+
return &gatedCoordinator{
|
| 59 |
+
Coordinator: coord,
|
| 60 |
+
entered: make(chan struct{}),
|
| 61 |
+
gate: make(chan struct{}),
|
| 62 |
+
}, sessions, messages
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// TestSendMessage_AcceptedCancelRace_RealMachinery exercises the
|
| 66 |
+
// 202/cancel race end-to-end through Backend.SendMessage against the
|
| 67 |
+
// production agent.Coordinator (BeginAccepted + RunAccepted), not a
|
| 68 |
+
// stub. It asserts that a cancel arriving after the prompt is accepted
|
| 69 |
+
// but before the run becomes active is not lost: the accepted handle
|
| 70 |
+
// reaches sessionAgent.Run and drives cancel-on-entry, which persists a
|
| 71 |
+
// canceled turn instead of streaming.
|
| 72 |
+
//
|
| 73 |
+
// This test would fail if Coordinator.BeginAccepted returned nil (Cancel
|
| 74 |
+
// would find no accepted run and record no mark, and the run would
|
| 75 |
+
// receive a nil Accepted handle and skip cancel-on-entry) or if
|
| 76 |
+
// Coordinator.RunAccepted dropped the handle on its way into
|
| 77 |
+
// sessionAgent.Run (the run would likewise skip cancel-on-entry and try
|
| 78 |
+
// to stream the model). In either case no FinishReasonCanceled turn
|
| 79 |
+
// would be persisted.
|
| 80 |
+
func TestSendMessage_AcceptedCancelRace_RealMachinery(t *testing.T) {
|
| 81 |
+
t.Parallel()
|
| 82 |
+
b, _ := newTestBackend(t)
|
| 83 |
+
|
| 84 |
+
coord, sessions, messages := newRealCoordinator(t)
|
| 85 |
+
sess, err := sessions.Create(t.Context(), "session")
|
| 86 |
+
require.NoError(t, err)
|
| 87 |
+
|
| 88 |
+
ws := insertAgentWorkspace(t, b, coord)
|
| 89 |
+
|
| 90 |
+
require.NoError(t, b.SendMessage(ws.ID, proto.AgentMessage{SessionID: sess.ID, Prompt: "hi"}))
|
| 91 |
+
|
| 92 |
+
// Coordinator.BeginAccepted ran synchronously inside SendMessage
|
| 93 |
+
// before dispatch; the dispatched run has now entered the gate but
|
| 94 |
+
// has not yet called the real RunAccepted, so the accept handle is
|
| 95 |
+
// not yet consumed: the prompt is accepted but not active.
|
| 96 |
+
select {
|
| 97 |
+
case <-coord.entered:
|
| 98 |
+
case <-time.After(2 * time.Second):
|
| 99 |
+
t.Fatal("dispatched run never entered RunAccepted")
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// A cancel arriving now lands in the accepted-but-not-yet-active
|
| 103 |
+
// window and is only recorded because BeginAccepted incremented the
|
| 104 |
+
// accept counter.
|
| 105 |
+
require.NoError(t, b.CancelSession(ws.ID, sess.ID))
|
| 106 |
+
|
| 107 |
+
// Release the gate so the real RunAccepted threads the handle into
|
| 108 |
+
// sessionAgent.Run, which drives cancel-on-entry.
|
| 109 |
+
close(coord.gate)
|
| 110 |
+
|
| 111 |
+
// The dispatched run returns nil (cancel-on-entry), so runWG drains.
|
| 112 |
+
waited := make(chan struct{})
|
| 113 |
+
go func() {
|
| 114 |
+
ws.runWG.Wait()
|
| 115 |
+
close(waited)
|
| 116 |
+
}()
|
| 117 |
+
select {
|
| 118 |
+
case <-waited:
|
| 119 |
+
case <-time.After(5 * time.Second):
|
| 120 |
+
t.Fatal("runWG.Wait did not complete after the canceled run returned")
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// The accepted-but-not-yet-active cancel persisted a canceled turn
|
| 124 |
+
// rather than streaming a real response.
|
| 125 |
+
msgs, err := messages.List(t.Context(), sess.ID)
|
| 126 |
+
require.NoError(t, err)
|
| 127 |
+
require.Len(t, msgs, 2)
|
| 128 |
+
require.Equal(t, message.User, msgs[0].Role)
|
| 129 |
+
require.Equal(t, message.Assistant, msgs[1].Role)
|
| 130 |
+
require.Equal(t, message.FinishReasonCanceled, msgs[1].FinishReason())
|
| 131 |
+
}
|
internal/backend/agent.go
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package backend
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"os"
|
| 7 |
+
|
| 8 |
+
"github.com/charmbracelet/crush/internal/agent"
|
| 9 |
+
"github.com/charmbracelet/crush/internal/agent/notify"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/proto"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 13 |
+
"github.com/charmbracelet/crush/internal/shell"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// SendMessage validates and accepts a prompt for the workspace's agent,
|
| 17 |
+
// then dispatches the run on a goroutine bound to the workspace context
|
| 18 |
+
// and returns immediately. It does not wait for the LLM turn to
|
| 19 |
+
// complete: the run's lifetime is owned by the workspace, not by the
|
| 20 |
+
// caller. Errors from the dispatched run reach observers through the
|
| 21 |
+
// agent event channels (a notify.TypeAgentError notification), not
|
| 22 |
+
// through this return value.
|
| 23 |
+
//
|
| 24 |
+
// SendMessage returns synchronously when the request cannot be accepted:
|
| 25 |
+
// ErrWorkspaceNotFound if the workspace is missing, ErrAgentNotInitialized
|
| 26 |
+
// if its coordinator is nil, the structural validation errors from
|
| 27 |
+
// agent.ValidateCall (ErrEmptyPrompt, ErrSessionMissing) when the prompt
|
| 28 |
+
// or session is missing, and ErrWorkspaceClosing if the workspace is
|
| 29 |
+
// being torn down.
|
| 30 |
+
func (b *Backend) SendMessage(workspaceID string, msg proto.AgentMessage) error {
|
| 31 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 32 |
+
if err != nil {
|
| 33 |
+
return err
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if ws.AgentCoordinator == nil {
|
| 37 |
+
return ErrAgentNotInitialized
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if err := agent.ValidateCall(agent.SessionAgentCall{
|
| 41 |
+
SessionID: msg.SessionID,
|
| 42 |
+
Prompt: msg.Prompt,
|
| 43 |
+
Attachments: proto.AttachmentsToMessage(msg.Attachments),
|
| 44 |
+
}); err != nil {
|
| 45 |
+
return err
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
accept := ws.AgentCoordinator.BeginAccepted(msg.SessionID)
|
| 49 |
+
|
| 50 |
+
ws.runMu.Lock()
|
| 51 |
+
if ws.closing {
|
| 52 |
+
ws.runMu.Unlock()
|
| 53 |
+
accept.Close()
|
| 54 |
+
return ErrWorkspaceClosing
|
| 55 |
+
}
|
| 56 |
+
ws.runWG.Add(1)
|
| 57 |
+
ws.runMu.Unlock()
|
| 58 |
+
|
| 59 |
+
go b.runAgent(ws, msg, accept)
|
| 60 |
+
return nil
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// runAgent executes an accepted agent run for the workspace. It owns the
|
| 64 |
+
// accept reservation (releasing it on return) and the runWG ticket added
|
| 65 |
+
// by SendMessage. The run is bound to the workspace context so its
|
| 66 |
+
// lifetime is independent of any client's HTTP request.
|
| 67 |
+
//
|
| 68 |
+
// On a non-cancel error it surfaces the failure to observers via a
|
| 69 |
+
// notify.TypeAgentError notification (lossy, best-effort). That alone is
|
| 70 |
+
// not a reliable terminal signal: the agent-event fan-in uses lossy
|
| 71 |
+
// subscribers, so a `crush run` caller blocking on its RunID could hang
|
| 72 |
+
// if the event is dropped. To guarantee termination, when msg.RunID is
|
| 73 |
+
// non-empty and the coordinator did not already publish the run's
|
| 74 |
+
// authoritative terminal RunComplete (e.g. the error was returned before
|
| 75 |
+
// sessionAgent.Run executed, such as a readyWg or UpdateModels failure),
|
| 76 |
+
// runAgent emits an errored RunComplete on the must-deliver
|
| 77 |
+
// runCompletions broker so the waiter observes a deterministic terminal
|
| 78 |
+
// event. context.Canceled is expected (sessionAgent.Run already
|
| 79 |
+
// publishes the cancelled terminal marker) and produces no error
|
| 80 |
+
// terminal event.
|
| 81 |
+
//
|
| 82 |
+
// When msg.RunID is non-empty it is attached to the context via
|
| 83 |
+
// agent.WithRunID so the coordinator can stamp the terminal
|
| 84 |
+
// notify.RunComplete event with that correlator. A run-complete marker
|
| 85 |
+
// is also attached so the coordinator can report whether it published
|
| 86 |
+
// the terminal event, letting runAgent avoid a duplicate fallback.
|
| 87 |
+
func (b *Backend) runAgent(ws *Workspace, msg proto.AgentMessage, accept *agent.AcceptedRun) {
|
| 88 |
+
defer ws.runWG.Done()
|
| 89 |
+
defer accept.Close()
|
| 90 |
+
|
| 91 |
+
ctx := ws.ctx
|
| 92 |
+
if msg.RunID != "" {
|
| 93 |
+
ctx = agent.WithRunID(ctx, msg.RunID)
|
| 94 |
+
}
|
| 95 |
+
ctx = agent.WithRunCompleteMarker(ctx)
|
| 96 |
+
|
| 97 |
+
_, err := ws.AgentCoordinator.RunAccepted(ctx, accept, msg.SessionID, msg.Prompt, proto.AttachmentsToMessage(msg.Attachments)...)
|
| 98 |
+
if err == nil || errors.Is(err, context.Canceled) {
|
| 99 |
+
return
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
ws.AgentNotifications().Publish(pubsub.CreatedEvent, notify.Notification{
|
| 103 |
+
SessionID: msg.SessionID,
|
| 104 |
+
RunID: msg.RunID,
|
| 105 |
+
Type: notify.TypeAgentError,
|
| 106 |
+
Message: err.Error(),
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
// Reliable terminal fallback. Only needed when a RunID waiter
|
| 110 |
+
// exists and the coordinator has not already emitted the run's
|
| 111 |
+
// terminal RunComplete; otherwise this would be a duplicate.
|
| 112 |
+
if msg.RunID == "" || agent.RunCompletePublished(ctx) {
|
| 113 |
+
return
|
| 114 |
+
}
|
| 115 |
+
if rc := ws.RunCompletions(); rc != nil {
|
| 116 |
+
rc.PublishMustDeliver(ctx, pubsub.UpdatedEvent, notify.RunComplete{
|
| 117 |
+
SessionID: msg.SessionID,
|
| 118 |
+
RunID: msg.RunID,
|
| 119 |
+
Error: err.Error(),
|
| 120 |
+
})
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// GetAgentInfo returns the agent's model and busy status.
|
| 125 |
+
func (b *Backend) GetAgentInfo(workspaceID string) (proto.AgentInfo, error) {
|
| 126 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 127 |
+
if err != nil {
|
| 128 |
+
return proto.AgentInfo{}, err
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
var agentInfo proto.AgentInfo
|
| 132 |
+
if ws.AgentCoordinator != nil {
|
| 133 |
+
m := ws.AgentCoordinator.Model()
|
| 134 |
+
agentInfo = proto.AgentInfo{
|
| 135 |
+
Model: m.CatwalkCfg,
|
| 136 |
+
ModelCfg: m.ModelCfg,
|
| 137 |
+
IsBusy: ws.AgentCoordinator.IsBusy(),
|
| 138 |
+
IsReady: true,
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
return agentInfo, nil
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// InitAgent initializes the coder agent for the workspace.
|
| 145 |
+
func (b *Backend) InitAgent(ctx context.Context, workspaceID string, interactive bool) error {
|
| 146 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 147 |
+
if err != nil {
|
| 148 |
+
return err
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
if interactive {
|
| 152 |
+
return ws.InitCoderAgent(ctx)
|
| 153 |
+
}
|
| 154 |
+
return ws.InitCoderAgentNonInteractive(ctx)
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// UpdateAgent reloads the agent model configuration.
|
| 158 |
+
func (b *Backend) UpdateAgent(ctx context.Context, workspaceID string) error {
|
| 159 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 160 |
+
if err != nil {
|
| 161 |
+
return err
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
return ws.UpdateAgentModel(ctx)
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// CancelSession cancels an ongoing agent operation for the given
|
| 168 |
+
// session.
|
| 169 |
+
func (b *Backend) CancelSession(workspaceID, sessionID string) error {
|
| 170 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 171 |
+
if err != nil {
|
| 172 |
+
return err
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
if ws.AgentCoordinator != nil {
|
| 176 |
+
ws.AgentCoordinator.Cancel(sessionID)
|
| 177 |
+
}
|
| 178 |
+
return nil
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// SummarizeSession triggers a session summarization.
|
| 182 |
+
func (b *Backend) SummarizeSession(ctx context.Context, workspaceID, sessionID string) error {
|
| 183 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 184 |
+
if err != nil {
|
| 185 |
+
return err
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
if ws.AgentCoordinator == nil {
|
| 189 |
+
return ErrAgentNotInitialized
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
return ws.AgentCoordinator.Summarize(ctx, sessionID)
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// QueuedPrompts returns the number of queued prompts for the session.
|
| 196 |
+
func (b *Backend) QueuedPrompts(workspaceID, sessionID string) (int, error) {
|
| 197 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 198 |
+
if err != nil {
|
| 199 |
+
return 0, err
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
if ws.AgentCoordinator == nil {
|
| 203 |
+
return 0, nil
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
return ws.AgentCoordinator.QueuedPrompts(sessionID), nil
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// ClearQueue clears the prompt queue for the session.
|
| 210 |
+
func (b *Backend) ClearQueue(workspaceID, sessionID string) error {
|
| 211 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 212 |
+
if err != nil {
|
| 213 |
+
return err
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
if ws.AgentCoordinator != nil {
|
| 217 |
+
ws.AgentCoordinator.ClearQueue(sessionID)
|
| 218 |
+
}
|
| 219 |
+
return nil
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// QueuedPromptsList returns the list of queued prompt strings for a
|
| 223 |
+
// session.
|
| 224 |
+
func (b *Backend) QueuedPromptsList(workspaceID, sessionID string) ([]string, error) {
|
| 225 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 226 |
+
if err != nil {
|
| 227 |
+
return nil, err
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
if ws.AgentCoordinator == nil {
|
| 231 |
+
return nil, nil
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
return ws.AgentCoordinator.QueuedPromptsList(sessionID), nil
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// GetDefaultSmallModel returns the default small model for a provider.
|
| 238 |
+
func (b *Backend) GetDefaultSmallModel(workspaceID, providerID string) (config.SelectedModel, error) {
|
| 239 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 240 |
+
if err != nil {
|
| 241 |
+
return config.SelectedModel{}, err
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
return ws.GetDefaultSmallModel(providerID), nil
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// RunShellCommand runs a shell command in the workspace directory and
|
| 248 |
+
// persists the command + output as a user message in the session.
|
| 249 |
+
func (b *Backend) RunShellCommand(ctx context.Context, workspaceID string, req proto.ShellCommandRequest) (proto.ShellCommandResponse, error) {
|
| 250 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 251 |
+
if err != nil {
|
| 252 |
+
return proto.ShellCommandResponse{}, err
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
var persist shell.PersistFunc
|
| 256 |
+
if req.SessionID != "" {
|
| 257 |
+
persist = func(cmd, output string, exitCode int) error {
|
| 258 |
+
return shell.PersistOutput(ctx, ws.Messages, req.SessionID, cmd, output, exitCode)
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
result, err := shell.RunAndPersist(ctx, shell.RunOptions{
|
| 263 |
+
Command: req.Command,
|
| 264 |
+
Cwd: ws.Path,
|
| 265 |
+
Env: append(os.Environ(), ws.Env...),
|
| 266 |
+
TermWidth: req.TermWidth,
|
| 267 |
+
}, persist)
|
| 268 |
+
if err != nil {
|
| 269 |
+
return proto.ShellCommandResponse{}, err
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
return proto.ShellCommandResponse{
|
| 273 |
+
Output: result.Output,
|
| 274 |
+
ExitCode: result.ExitCode,
|
| 275 |
+
}, nil
|
| 276 |
+
}
|
internal/backend/agent_runcomplete_test.go
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package backend
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"testing"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"charm.land/fantasy"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/agent"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/app"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 13 |
+
"github.com/charmbracelet/crush/internal/proto"
|
| 14 |
+
"github.com/google/uuid"
|
| 15 |
+
"github.com/stretchr/testify/require"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
// errorCoordinator is a minimal agent.Coordinator whose RunAccepted
|
| 19 |
+
// returns a configurable error. When markPublished is true it stamps
|
| 20 |
+
// the run-complete marker on the context before returning, simulating a
|
| 21 |
+
// real coordinator that already published the run's authoritative
|
| 22 |
+
// terminal RunComplete (so runAgent must not emit a duplicate fallback).
|
| 23 |
+
type errorCoordinator struct {
|
| 24 |
+
err error
|
| 25 |
+
markPublished bool
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func (c *errorCoordinator) Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
|
| 29 |
+
return nil, c.err
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
func (c *errorCoordinator) RunAccepted(ctx context.Context, accept *agent.AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
|
| 33 |
+
if c.markPublished {
|
| 34 |
+
agent.MarkRunCompletePublished(ctx)
|
| 35 |
+
}
|
| 36 |
+
return nil, c.err
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
func (c *errorCoordinator) BeginAccepted(sessionID string) *agent.AcceptedRun { return nil }
|
| 40 |
+
func (c *errorCoordinator) Cancel(string) {}
|
| 41 |
+
func (c *errorCoordinator) CancelAll() {}
|
| 42 |
+
func (c *errorCoordinator) IsBusy() bool { return false }
|
| 43 |
+
func (c *errorCoordinator) IsSessionBusy(string) bool { return false }
|
| 44 |
+
func (c *errorCoordinator) QueuedPrompts(string) int { return 0 }
|
| 45 |
+
func (c *errorCoordinator) QueuedPromptsList(string) []string { return nil }
|
| 46 |
+
func (c *errorCoordinator) ClearQueue(string) {}
|
| 47 |
+
func (c *errorCoordinator) Summarize(context.Context, string) error { return nil }
|
| 48 |
+
func (c *errorCoordinator) Model() agent.Model { return agent.Model{} }
|
| 49 |
+
func (c *errorCoordinator) UpdateModels(context.Context) error { return nil }
|
| 50 |
+
func (c *errorCoordinator) GenerateTitle(context.Context, string, string) {}
|
| 51 |
+
|
| 52 |
+
// insertRunCompleteWorkspace installs a workspace backed by a real
|
| 53 |
+
// app.App (so the runCompletions broker exists) with the given
|
| 54 |
+
// coordinator and a workspace run context derived from base.
|
| 55 |
+
func insertRunCompleteWorkspace(t *testing.T, b *Backend, base context.Context, coord agent.Coordinator) *Workspace {
|
| 56 |
+
t.Helper()
|
| 57 |
+
a := app.NewForTest(base)
|
| 58 |
+
a.AgentCoordinator = coord
|
| 59 |
+
t.Cleanup(a.ShutdownForTest)
|
| 60 |
+
ws := &Workspace{
|
| 61 |
+
ID: uuid.New().String(),
|
| 62 |
+
Path: t.TempDir(),
|
| 63 |
+
resolvedPath: t.TempDir(),
|
| 64 |
+
clients: make(map[string]*clientState),
|
| 65 |
+
shutdownFn: func() {},
|
| 66 |
+
}
|
| 67 |
+
ws.App = a
|
| 68 |
+
ws.ctx, ws.cancel = context.WithCancel(base)
|
| 69 |
+
b.mu.Lock()
|
| 70 |
+
b.workspaces.Set(ws.ID, ws)
|
| 71 |
+
b.pathIndex[ws.resolvedPath] = ws.ID
|
| 72 |
+
b.mu.Unlock()
|
| 73 |
+
return ws
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// TestRunAgent_PreRunErrorPublishesTerminalRunComplete proves that an
|
| 77 |
+
// error returned from RunAccepted before the coordinator could publish
|
| 78 |
+
// its own terminal event (e.g. a readyWg or UpdateModels failure,
|
| 79 |
+
// modeled here by a stub coordinator) still yields a reliable terminal
|
| 80 |
+
// RunComplete for the run's RunID. Without it, a `crush run` caller
|
| 81 |
+
// blocking on that RunID would hang because the lossy TypeAgentError
|
| 82 |
+
// event is not a guaranteed terminal signal.
|
| 83 |
+
func TestRunAgent_PreRunErrorPublishesTerminalRunComplete(t *testing.T) {
|
| 84 |
+
t.Parallel()
|
| 85 |
+
b, _ := newTestBackend(t)
|
| 86 |
+
runErr := errors.New("update models failed")
|
| 87 |
+
ws := insertRunCompleteWorkspace(t, b, context.Background(), &errorCoordinator{err: runErr})
|
| 88 |
+
|
| 89 |
+
subCtx, cancel := context.WithCancel(context.Background())
|
| 90 |
+
defer cancel()
|
| 91 |
+
ch := ws.RunCompletions().Subscribe(subCtx)
|
| 92 |
+
|
| 93 |
+
err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", RunID: "run-1", Prompt: "hi"})
|
| 94 |
+
require.NoError(t, err)
|
| 95 |
+
|
| 96 |
+
select {
|
| 97 |
+
case ev := <-ch:
|
| 98 |
+
require.Equal(t, "run-1", ev.Payload.RunID,
|
| 99 |
+
"the terminal RunComplete must carry the dispatched RunID")
|
| 100 |
+
require.Equal(t, "S1", ev.Payload.SessionID)
|
| 101 |
+
require.Equal(t, runErr.Error(), ev.Payload.Error,
|
| 102 |
+
"the fallback terminal event must be marked errored")
|
| 103 |
+
require.False(t, ev.Payload.Cancelled)
|
| 104 |
+
case <-time.After(2 * time.Second):
|
| 105 |
+
t.Fatal("no terminal RunComplete published for a pre-run error; a run waiter would hang")
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// TestRunAgent_NoFallbackWhenCoordinatorPublished ensures the fallback
|
| 110 |
+
// is suppressed when the coordinator already emitted the run's
|
| 111 |
+
// authoritative terminal RunComplete, so callers never observe a
|
| 112 |
+
// duplicate terminal event for the same RunID.
|
| 113 |
+
func TestRunAgent_NoFallbackWhenCoordinatorPublished(t *testing.T) {
|
| 114 |
+
t.Parallel()
|
| 115 |
+
b, _ := newTestBackend(t)
|
| 116 |
+
runErr := errors.New("stream failed after publishing terminal event")
|
| 117 |
+
ws := insertRunCompleteWorkspace(t, b, context.Background(),
|
| 118 |
+
&errorCoordinator{err: runErr, markPublished: true})
|
| 119 |
+
|
| 120 |
+
subCtx, cancel := context.WithCancel(context.Background())
|
| 121 |
+
defer cancel()
|
| 122 |
+
ch := ws.RunCompletions().Subscribe(subCtx)
|
| 123 |
+
|
| 124 |
+
err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", RunID: "run-1", Prompt: "hi"})
|
| 125 |
+
require.NoError(t, err)
|
| 126 |
+
|
| 127 |
+
// Wait for the dispatched run goroutine to return so any publish
|
| 128 |
+
// has already happened.
|
| 129 |
+
ws.runWG.Wait()
|
| 130 |
+
|
| 131 |
+
select {
|
| 132 |
+
case ev := <-ch:
|
| 133 |
+
t.Fatalf("runAgent published a duplicate terminal RunComplete: %+v", ev.Payload)
|
| 134 |
+
case <-time.After(200 * time.Millisecond):
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// TestRunAgent_CancellationPublishesNoErrorTerminal verifies that a
|
| 139 |
+
// context.Canceled result from RunAccepted produces no errored terminal
|
| 140 |
+
// RunComplete from runAgent: cancellation is sessionAgent.Run's
|
| 141 |
+
// responsibility (it publishes the cancelled marker) and the dispatcher
|
| 142 |
+
// must not synthesize an error terminal for it.
|
| 143 |
+
func TestRunAgent_CancellationPublishesNoErrorTerminal(t *testing.T) {
|
| 144 |
+
t.Parallel()
|
| 145 |
+
b, _ := newTestBackend(t)
|
| 146 |
+
ws := insertRunCompleteWorkspace(t, b, context.Background(),
|
| 147 |
+
&errorCoordinator{err: context.Canceled})
|
| 148 |
+
|
| 149 |
+
subCtx, cancel := context.WithCancel(context.Background())
|
| 150 |
+
defer cancel()
|
| 151 |
+
ch := ws.RunCompletions().Subscribe(subCtx)
|
| 152 |
+
|
| 153 |
+
err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", RunID: "run-1", Prompt: "hi"})
|
| 154 |
+
require.NoError(t, err)
|
| 155 |
+
|
| 156 |
+
ws.runWG.Wait()
|
| 157 |
+
|
| 158 |
+
select {
|
| 159 |
+
case ev := <-ch:
|
| 160 |
+
t.Fatalf("cancellation must not publish a terminal RunComplete: %+v", ev.Payload)
|
| 161 |
+
case <-time.After(200 * time.Millisecond):
|
| 162 |
+
}
|
| 163 |
+
}
|
internal/backend/agent_test.go
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package backend
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"sync/atomic"
|
| 6 |
+
"testing"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"charm.land/fantasy"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/agent"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/app"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/message"
|
| 13 |
+
"github.com/charmbracelet/crush/internal/proto"
|
| 14 |
+
"github.com/google/uuid"
|
| 15 |
+
"github.com/stretchr/testify/require"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
// blockingCoordinator is a minimal agent.Coordinator whose RunAccepted
|
| 19 |
+
// blocks until release is closed. It records that RunAccepted was
|
| 20 |
+
// entered so tests can observe the dispatched goroutine. Every other
|
| 21 |
+
// method returns a zero value.
|
| 22 |
+
type blockingCoordinator struct {
|
| 23 |
+
entered chan struct{}
|
| 24 |
+
release chan struct{}
|
| 25 |
+
runCount atomic.Int32
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func newBlockingCoordinator() *blockingCoordinator {
|
| 29 |
+
return &blockingCoordinator{
|
| 30 |
+
entered: make(chan struct{}, 1),
|
| 31 |
+
release: make(chan struct{}),
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func (c *blockingCoordinator) Run(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
|
| 36 |
+
return nil, nil
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
func (c *blockingCoordinator) RunAccepted(ctx context.Context, accept *agent.AcceptedRun, sessionID, prompt string, attachments ...message.Attachment) (*fantasy.AgentResult, error) {
|
| 40 |
+
c.runCount.Add(1)
|
| 41 |
+
select {
|
| 42 |
+
case c.entered <- struct{}{}:
|
| 43 |
+
default:
|
| 44 |
+
}
|
| 45 |
+
<-c.release
|
| 46 |
+
return nil, nil
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
func (c *blockingCoordinator) BeginAccepted(sessionID string) *agent.AcceptedRun { return nil }
|
| 50 |
+
func (c *blockingCoordinator) Cancel(string) {}
|
| 51 |
+
func (c *blockingCoordinator) CancelAll() {}
|
| 52 |
+
func (c *blockingCoordinator) IsBusy() bool { return false }
|
| 53 |
+
func (c *blockingCoordinator) IsSessionBusy(string) bool { return false }
|
| 54 |
+
func (c *blockingCoordinator) QueuedPrompts(string) int { return 0 }
|
| 55 |
+
func (c *blockingCoordinator) QueuedPromptsList(string) []string { return nil }
|
| 56 |
+
func (c *blockingCoordinator) ClearQueue(string) {}
|
| 57 |
+
func (c *blockingCoordinator) Summarize(context.Context, string) error { return nil }
|
| 58 |
+
func (c *blockingCoordinator) Model() agent.Model { return agent.Model{} }
|
| 59 |
+
func (c *blockingCoordinator) UpdateModels(context.Context) error { return nil }
|
| 60 |
+
func (c *blockingCoordinator) GenerateTitle(context.Context, string, string) {}
|
| 61 |
+
|
| 62 |
+
// insertAgentWorkspace installs a synthetic workspace with the given
|
| 63 |
+
// coordinator (or none) and a workspace run context, mirroring the
|
| 64 |
+
// fields CreateWorkspace initializes.
|
| 65 |
+
func insertAgentWorkspace(t *testing.T, b *Backend, coord agent.Coordinator) *Workspace {
|
| 66 |
+
t.Helper()
|
| 67 |
+
ws := &Workspace{
|
| 68 |
+
ID: uuid.New().String(),
|
| 69 |
+
Path: t.TempDir(),
|
| 70 |
+
resolvedPath: t.TempDir(),
|
| 71 |
+
clients: make(map[string]*clientState),
|
| 72 |
+
shutdownFn: func() {},
|
| 73 |
+
}
|
| 74 |
+
ws.App = &app.App{AgentCoordinator: coord}
|
| 75 |
+
ws.ctx, ws.cancel = context.WithCancel(b.ctx)
|
| 76 |
+
b.mu.Lock()
|
| 77 |
+
b.workspaces.Set(ws.ID, ws)
|
| 78 |
+
b.pathIndex[ws.resolvedPath] = ws.ID
|
| 79 |
+
b.mu.Unlock()
|
| 80 |
+
return ws
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
func TestSendMessage_WorkspaceNotFound(t *testing.T) {
|
| 84 |
+
t.Parallel()
|
| 85 |
+
b, _ := newTestBackend(t)
|
| 86 |
+
err := b.SendMessage("nope", proto.AgentMessage{SessionID: "S1", Prompt: "hi"})
|
| 87 |
+
require.ErrorIs(t, err, ErrWorkspaceNotFound)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
func TestSendMessage_AgentNotInitialized(t *testing.T) {
|
| 91 |
+
t.Parallel()
|
| 92 |
+
b, _ := newTestBackend(t)
|
| 93 |
+
ws := insertAgentWorkspace(t, b, nil)
|
| 94 |
+
err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", Prompt: "hi"})
|
| 95 |
+
require.ErrorIs(t, err, ErrAgentNotInitialized)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
func TestSendMessage_EmptyPrompt(t *testing.T) {
|
| 99 |
+
t.Parallel()
|
| 100 |
+
b, _ := newTestBackend(t)
|
| 101 |
+
ws := insertAgentWorkspace(t, b, newBlockingCoordinator())
|
| 102 |
+
err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", Prompt: ""})
|
| 103 |
+
require.ErrorIs(t, err, agent.ErrEmptyPrompt)
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
func TestSendMessage_SessionMissing(t *testing.T) {
|
| 107 |
+
t.Parallel()
|
| 108 |
+
b, _ := newTestBackend(t)
|
| 109 |
+
ws := insertAgentWorkspace(t, b, newBlockingCoordinator())
|
| 110 |
+
err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "", Prompt: "hi"})
|
| 111 |
+
require.ErrorIs(t, err, agent.ErrSessionMissing)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
func TestSendMessage_WorkspaceClosing(t *testing.T) {
|
| 115 |
+
t.Parallel()
|
| 116 |
+
b, _ := newTestBackend(t)
|
| 117 |
+
ws := insertAgentWorkspace(t, b, newBlockingCoordinator())
|
| 118 |
+
ws.runMu.Lock()
|
| 119 |
+
ws.closing = true
|
| 120 |
+
ws.runMu.Unlock()
|
| 121 |
+
err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", Prompt: "hi"})
|
| 122 |
+
require.ErrorIs(t, err, ErrWorkspaceClosing)
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// TestSendMessage_SuccessIncrementsRunWG asserts the happy path returns
|
| 126 |
+
// nil synchronously and dispatches a tracked goroutine: while
|
| 127 |
+
// RunAccepted blocks, runWG.Wait must not complete (the ticket is
|
| 128 |
+
// outstanding); after release it drains.
|
| 129 |
+
func TestSendMessage_SuccessIncrementsRunWG(t *testing.T) {
|
| 130 |
+
t.Parallel()
|
| 131 |
+
b, _ := newTestBackend(t)
|
| 132 |
+
coord := newBlockingCoordinator()
|
| 133 |
+
ws := insertAgentWorkspace(t, b, coord)
|
| 134 |
+
|
| 135 |
+
err := b.SendMessage(ws.ID, proto.AgentMessage{SessionID: "S1", Prompt: "hi"})
|
| 136 |
+
require.NoError(t, err)
|
| 137 |
+
|
| 138 |
+
select {
|
| 139 |
+
case <-coord.entered:
|
| 140 |
+
case <-time.After(2 * time.Second):
|
| 141 |
+
t.Fatal("dispatched goroutine never entered RunAccepted")
|
| 142 |
+
}
|
| 143 |
+
require.Equal(t, int32(1), coord.runCount.Load())
|
| 144 |
+
|
| 145 |
+
waited := make(chan struct{})
|
| 146 |
+
go func() {
|
| 147 |
+
ws.runWG.Wait()
|
| 148 |
+
close(waited)
|
| 149 |
+
}()
|
| 150 |
+
|
| 151 |
+
select {
|
| 152 |
+
case <-waited:
|
| 153 |
+
t.Fatal("runWG.Wait completed while the run was still in flight; ticket was not added")
|
| 154 |
+
case <-time.After(100 * time.Millisecond):
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
close(coord.release)
|
| 158 |
+
|
| 159 |
+
select {
|
| 160 |
+
case <-waited:
|
| 161 |
+
case <-time.After(2 * time.Second):
|
| 162 |
+
t.Fatal("runWG.Wait did not complete after the run returned")
|
| 163 |
+
}
|
| 164 |
+
}
|
internal/backend/backend.go
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Package backend provides transport-agnostic operations for managing
|
| 2 |
+
// workspaces, sessions, agents, permissions, and events. It is consumed
|
| 3 |
+
// by protocol-specific layers such as HTTP (server) and ACP.
|
| 4 |
+
package backend
|
| 5 |
+
|
| 6 |
+
import (
|
| 7 |
+
"context"
|
| 8 |
+
"errors"
|
| 9 |
+
"fmt"
|
| 10 |
+
"log/slog"
|
| 11 |
+
"os"
|
| 12 |
+
"path/filepath"
|
| 13 |
+
"runtime"
|
| 14 |
+
"strconv"
|
| 15 |
+
"sync"
|
| 16 |
+
"time"
|
| 17 |
+
|
| 18 |
+
"github.com/charmbracelet/crush/internal/app"
|
| 19 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 20 |
+
"github.com/charmbracelet/crush/internal/csync"
|
| 21 |
+
"github.com/charmbracelet/crush/internal/db"
|
| 22 |
+
"github.com/charmbracelet/crush/internal/proto"
|
| 23 |
+
"github.com/charmbracelet/crush/internal/skills"
|
| 24 |
+
"github.com/charmbracelet/crush/internal/ui/util"
|
| 25 |
+
"github.com/charmbracelet/crush/internal/version"
|
| 26 |
+
"github.com/google/uuid"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
// Common errors returned by backend operations.
|
| 30 |
+
var (
|
| 31 |
+
ErrWorkspaceNotFound = errors.New("workspace not found")
|
| 32 |
+
ErrLSPClientNotFound = errors.New("LSP client not found")
|
| 33 |
+
ErrAgentNotInitialized = errors.New("agent coordinator not initialized")
|
| 34 |
+
ErrPathRequired = errors.New("path is required")
|
| 35 |
+
ErrInvalidPermissionAction = errors.New("invalid permission action")
|
| 36 |
+
ErrUnknownCommand = errors.New("unknown command")
|
| 37 |
+
ErrInvalidClientID = errors.New("invalid client_id")
|
| 38 |
+
ErrClientNotAttached = errors.New("client not attached")
|
| 39 |
+
ErrWorkspaceClosing = errors.New("workspace closing")
|
| 40 |
+
ErrServerShuttingDown = errors.New("server is shutting down")
|
| 41 |
+
ErrServerNotIdle = errors.New("server is hosting live workspaces")
|
| 42 |
+
ErrClientRetired = errors.New("client has been retired")
|
| 43 |
+
ErrChannelOptInMismatch = errors.New("requested channels differ from the existing workspace; channels are an explicit opt-in and are not shared across duplicate creates")
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
// DefaultCreateGrace is the window in which a client must open an SSE
|
| 47 |
+
// stream after creating a workspace before its creation hold is
|
| 48 |
+
// released. Exposed as a package variable so tests can shorten it.
|
| 49 |
+
var DefaultCreateGrace = 30 * time.Second
|
| 50 |
+
|
| 51 |
+
// DefaultIdleShutdownDelay is how long the server stays alive after its
|
| 52 |
+
// last workspace is released before it shuts itself down. The delay
|
| 53 |
+
// exists so a client that closes one session and opens another moments
|
| 54 |
+
// later (the same directory or a different one) reuses the still-running
|
| 55 |
+
// server instead of racing its shutdown: with an immediate shutdown the
|
| 56 |
+
// new client can attach to — or create a workspace on — a server that is
|
| 57 |
+
// already tearing down, and then observe its coder agent as "offline".
|
| 58 |
+
// Any workspace create within the window cancels the pending shutdown.
|
| 59 |
+
// Overridable via CRUSH_SERVER_IDLE_TIMEOUT (seconds; 0 restores the
|
| 60 |
+
// old shut-down-immediately behavior).
|
| 61 |
+
var DefaultIdleShutdownDelay = 60 * time.Second
|
| 62 |
+
|
| 63 |
+
// DefaultDetachGrace is how long a client's claim on a workspace survives
|
| 64 |
+
// after its last SSE stream drops without an explicit release. The stream
|
| 65 |
+
// is the client's refcount claim, so tearing the workspace down the instant
|
| 66 |
+
// it closes turns any momentary drop (a hiccup, a suspended laptop, a proxy
|
| 67 |
+
// timeout) into a permanently lost workspace: the client's reconnect comes
|
| 68 |
+
// back milliseconds later to an ID the server no longer knows. A client
|
| 69 |
+
// that released its claim first (a clean exit) skips the grace. Overridable
|
| 70 |
+
// via CRUSH_SERVER_DETACH_GRACE (seconds; 0 restores immediate teardown).
|
| 71 |
+
var DefaultDetachGrace = 10 * time.Second
|
| 72 |
+
|
| 73 |
+
// ShutdownFunc is called when the backend needs to trigger a server
|
| 74 |
+
// shutdown (e.g. when the last workspace is removed).
|
| 75 |
+
type ShutdownFunc func()
|
| 76 |
+
|
| 77 |
+
// Backend provides transport-agnostic business logic for the Crush
|
| 78 |
+
// server. It manages workspaces and delegates to [app.App] services.
|
| 79 |
+
//
|
| 80 |
+
// Locking order: when both [Backend.mu] and [Workspace.clientsMu] are
|
| 81 |
+
// held at once, [Backend.mu] is acquired first. Detach paths
|
| 82 |
+
// ([detachStream], [releaseHoldLocked], [expireHold]) only hold
|
| 83 |
+
// [Workspace.clientsMu] briefly, drop it, then call [teardown] which
|
| 84 |
+
// takes [Backend.mu] (and then re-takes [Workspace.clientsMu] to
|
| 85 |
+
// re-check that the workspace has not been re-claimed). This avoids
|
| 86 |
+
// the AB/BA hazard with [CreateWorkspace], which holds [Backend.mu]
|
| 87 |
+
// while calling [registerClient] so that a workspace cannot be torn
|
| 88 |
+
// down beneath it.
|
| 89 |
+
type Backend struct {
|
| 90 |
+
workspaces *csync.Map[string, *Workspace]
|
| 91 |
+
// pathIndex maps a resolved absolute workspace path to its
|
| 92 |
+
// workspace ID. Reads and writes are serialised via mu so
|
| 93 |
+
// concurrent CreateWorkspace calls at the same path deduplicate
|
| 94 |
+
// deterministically.
|
| 95 |
+
pathIndex map[string]string
|
| 96 |
+
// pending counts CreateWorkspace calls that have committed to the
|
| 97 |
+
// slow initialization path (config/db/app setup) but have not yet
|
| 98 |
+
// registered their workspace in the map. It is guarded by mu.
|
| 99 |
+
// teardown must observe pending == 0 in addition to an empty
|
| 100 |
+
// workspace map before triggering server shutdown: otherwise a
|
| 101 |
+
// teardown of the last live workspace could race ahead of a
|
| 102 |
+
// concurrent create — which releases mu during its slow init — and
|
| 103 |
+
// shut the whole server down out from under the workspace being
|
| 104 |
+
// born.
|
| 105 |
+
pending int
|
| 106 |
+
// shutdownTimer, when non-nil, is an armed idle-shutdown timer
|
| 107 |
+
// waiting out lingerDelay before it shuts the server down. It is
|
| 108 |
+
// guarded by mu and cancelled the moment a new create arrives.
|
| 109 |
+
shutdownTimer *time.Timer
|
| 110 |
+
// closing latches the decision to exit. Every site that commits to
|
| 111 |
+
// a shutdown sets it while still holding mu, because shutdownFn has
|
| 112 |
+
// to run unlocked; CreateWorkspace refuses once it is set. That
|
| 113 |
+
// makes the shutdown-vs-create decision atomic: a create can never
|
| 114 |
+
// be handed a workspace on a process that is already leaving.
|
| 115 |
+
closing bool
|
| 116 |
+
// retired holds the IDs of clients that announced their exit via
|
| 117 |
+
// RetireClient. Creates from a retired client are refused, which is what
|
| 118 |
+
// lets a client release a workspace whose ID it never learned.
|
| 119 |
+
//
|
| 120 |
+
// Entries are never pruned, and deliberately so: an entry is exactly
|
| 121 |
+
// what refuses a create that was already on the wire when the client
|
| 122 |
+
// said goodbye, and HTTP gives no ordering between two requests, so
|
| 123 |
+
// there is no moment at which the server can prove none is still coming.
|
| 124 |
+
// Dropping an entry to save memory would reintroduce the orphaned
|
| 125 |
+
// workspace this mechanism exists to prevent. The cost is one UUID per
|
| 126 |
+
// client process, and the server only outlives its clients for as long
|
| 127 |
+
// as sessions keep arriving inside the idle-shutdown window.
|
| 128 |
+
retired map[string]struct{}
|
| 129 |
+
mu sync.Mutex
|
| 130 |
+
|
| 131 |
+
cfg *config.ConfigStore
|
| 132 |
+
ctx context.Context
|
| 133 |
+
shutdownFn ShutdownFunc
|
| 134 |
+
createGrace time.Duration
|
| 135 |
+
lingerDelay time.Duration
|
| 136 |
+
detachGrace time.Duration
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
// clientState tracks one client's claim on a workspace.
|
| 140 |
+
//
|
| 141 |
+
// - streams counts the number of live SSE event streams the client
|
| 142 |
+
// currently has open against the workspace.
|
| 143 |
+
// - holdTimer is non-nil in the two timer-held states: the client
|
| 144 |
+
// created the workspace but has not yet attached an SSE stream
|
| 145 |
+
// (fires after createGrace), or the client's last SSE stream dropped
|
| 146 |
+
// without an explicit release (fires after detachGrace, giving the
|
| 147 |
+
// client's reconnect loop a window to re-attach). Either way the
|
| 148 |
+
// timer releases the claim when it expires.
|
| 149 |
+
// - currentSessionID records which session this client is currently
|
| 150 |
+
// viewing. Empty string means the client has no session selected
|
| 151 |
+
// (e.g. the landing screen). Cleared automatically when the
|
| 152 |
+
// clientState entry is removed.
|
| 153 |
+
// - released marks that the client gave up its claim explicitly while
|
| 154 |
+
// streams were still open, which a clean exit does. The final stream
|
| 155 |
+
// detach then tears down immediately instead of waiting out the
|
| 156 |
+
// detach grace for a client that is not coming back.
|
| 157 |
+
//
|
| 158 |
+
// streams and holdTimer are mutually exclusive in practice (the hold
|
| 159 |
+
// timer is stopped the moment an SSE stream attaches), but both being
|
| 160 |
+
// zero/nil means the entry has been released and should be removed.
|
| 161 |
+
type clientState struct {
|
| 162 |
+
streams int
|
| 163 |
+
holdTimer *time.Timer
|
| 164 |
+
currentSessionID string
|
| 165 |
+
released bool
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// Workspace represents a running [app.App] workspace with its
|
| 169 |
+
// associated resources and state.
|
| 170 |
+
type Workspace struct {
|
| 171 |
+
*app.App
|
| 172 |
+
ID string
|
| 173 |
+
Path string
|
| 174 |
+
Cfg *config.ConfigStore
|
| 175 |
+
Env []string
|
| 176 |
+
Skills *skills.Manager
|
| 177 |
+
|
| 178 |
+
// resolvedPath is the path used as the dedup key in
|
| 179 |
+
// Backend.pathIndex. It is filepath.EvalSymlinks(filepath.Abs(Path))
|
| 180 |
+
// with fallback to the cleaned absolute path.
|
| 181 |
+
resolvedPath string
|
| 182 |
+
|
| 183 |
+
// ctx is the workspace-scoped run context. It is derived from
|
| 184 |
+
// the backend context in CreateWorkspace and lives for the
|
| 185 |
+
// lifetime of the workspace; cancel tears it down. Agent runs
|
| 186 |
+
// dispatched on behalf of this workspace are bound to ctx so
|
| 187 |
+
// their lifetime is owned by the workspace, not by any single
|
| 188 |
+
// client's HTTP request.
|
| 189 |
+
ctx context.Context
|
| 190 |
+
cancel context.CancelFunc
|
| 191 |
+
|
| 192 |
+
// runMu guards closing and gates dispatch of new agent runs.
|
| 193 |
+
// closing is set by Shutdown so no new runs are accepted once
|
| 194 |
+
// teardown has begun. runWG tracks dispatched agent goroutines
|
| 195 |
+
// so Shutdown can wait for them to return before app cleanup.
|
| 196 |
+
runMu sync.Mutex
|
| 197 |
+
closing bool
|
| 198 |
+
runWG sync.WaitGroup
|
| 199 |
+
|
| 200 |
+
// clientsMu guards clients. It is held only briefly (no IO).
|
| 201 |
+
clientsMu sync.Mutex
|
| 202 |
+
// clients tracks each client's claim on this workspace. Refcount
|
| 203 |
+
// is a derived value: len(clients).
|
| 204 |
+
clients map[string]*clientState
|
| 205 |
+
|
| 206 |
+
// shutdownFn is the function invoked by [Backend.teardown] to
|
| 207 |
+
// release the workspace's underlying resources. It defaults to the
|
| 208 |
+
// embedded [app.App.Shutdown]; tests may override it to avoid
|
| 209 |
+
// driving a full [app.App] through shutdown.
|
| 210 |
+
shutdownFn func()
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// invokeShutdown calls the workspace shutdown hook if set, falling
|
| 214 |
+
// back to the workspace [Workspace.Shutdown] wrapper when not.
|
| 215 |
+
func (w *Workspace) invokeShutdown() {
|
| 216 |
+
if w.shutdownFn != nil {
|
| 217 |
+
w.shutdownFn()
|
| 218 |
+
return
|
| 219 |
+
}
|
| 220 |
+
if w.App != nil {
|
| 221 |
+
w.Shutdown()
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Shutdown tears the workspace down in an order that is safe for
|
| 226 |
+
// agent runs whose lifetime is bound to the workspace context. It
|
| 227 |
+
// shadows the promoted [app.App.Shutdown] so callers reaching
|
| 228 |
+
// ws.Shutdown() always observe this ordering:
|
| 229 |
+
//
|
| 230 |
+
// 1. Mark the workspace closing so no new agent runs are accepted.
|
| 231 |
+
// 2. Cancel the workspace run context so any dispatched goroutine
|
| 232 |
+
// that has not yet registered its per-session cancel still
|
| 233 |
+
// observes cancellation.
|
| 234 |
+
// 3. Cancel active coordinator work for runs that already
|
| 235 |
+
// registered their per-session cancel function.
|
| 236 |
+
// 4. Wait for dispatched agent goroutines to return.
|
| 237 |
+
// 5. Run the embedded [app.App.Shutdown] cleanup (DB, LSP, etc).
|
| 238 |
+
//
|
| 239 |
+
// CancelAll is idempotent, so the second call inside app.App.Shutdown
|
| 240 |
+
// is harmless; the important guarantee is that cancel -> CancelAll ->
|
| 241 |
+
// runWG.Wait completes before the embedded cleanup touches the DB.
|
| 242 |
+
func (w *Workspace) Shutdown() {
|
| 243 |
+
w.runMu.Lock()
|
| 244 |
+
w.closing = true
|
| 245 |
+
w.runMu.Unlock()
|
| 246 |
+
|
| 247 |
+
if w.cancel != nil {
|
| 248 |
+
w.cancel()
|
| 249 |
+
}
|
| 250 |
+
if w.App != nil && w.AgentCoordinator != nil {
|
| 251 |
+
w.AgentCoordinator.CancelAll()
|
| 252 |
+
}
|
| 253 |
+
w.runWG.Wait()
|
| 254 |
+
if w.App != nil {
|
| 255 |
+
w.App.Shutdown()
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// New creates a new [Backend].
|
| 260 |
+
func New(ctx context.Context, cfg *config.ConfigStore, shutdownFn ShutdownFunc) *Backend {
|
| 261 |
+
return &Backend{
|
| 262 |
+
workspaces: csync.NewMap[string, *Workspace](),
|
| 263 |
+
pathIndex: make(map[string]string),
|
| 264 |
+
retired: make(map[string]struct{}),
|
| 265 |
+
cfg: cfg,
|
| 266 |
+
ctx: ctx,
|
| 267 |
+
shutdownFn: shutdownFn,
|
| 268 |
+
createGrace: DefaultCreateGrace,
|
| 269 |
+
lingerDelay: idleShutdownDelayFromEnv(),
|
| 270 |
+
detachGrace: durationFromEnv("CRUSH_SERVER_DETACH_GRACE", DefaultDetachGrace),
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// idleShutdownDelayFromEnv returns the idle-shutdown delay, honoring a
|
| 275 |
+
// CRUSH_SERVER_IDLE_TIMEOUT override (in seconds; 0 disables lingering).
|
| 276 |
+
func idleShutdownDelayFromEnv() time.Duration {
|
| 277 |
+
return durationFromEnv("CRUSH_SERVER_IDLE_TIMEOUT", DefaultIdleShutdownDelay)
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// durationFromEnv reads a whole number of seconds from the named
|
| 281 |
+
// environment variable, falling back to def when it is unset or
|
| 282 |
+
// unparseable. Zero is a meaningful value for both lifecycle windows it
|
| 283 |
+
// configures, so it is accepted.
|
| 284 |
+
func durationFromEnv(name string, def time.Duration) time.Duration {
|
| 285 |
+
if v := os.Getenv(name); v != "" {
|
| 286 |
+
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
|
| 287 |
+
return time.Duration(secs) * time.Second
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
return def
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
// SetCreateGrace overrides the create-grace window. Intended for tests
|
| 294 |
+
// that need short timeouts.
|
| 295 |
+
func (b *Backend) SetCreateGrace(d time.Duration) {
|
| 296 |
+
b.mu.Lock()
|
| 297 |
+
defer b.mu.Unlock()
|
| 298 |
+
b.createGrace = d
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// SetDetachGrace overrides how long a client's claim survives after its
|
| 302 |
+
// last SSE stream drops. A value <= 0 restores the tear-down-immediately
|
| 303 |
+
// behavior. Intended for tests.
|
| 304 |
+
func (b *Backend) SetDetachGrace(d time.Duration) {
|
| 305 |
+
b.mu.Lock()
|
| 306 |
+
defer b.mu.Unlock()
|
| 307 |
+
b.detachGrace = d
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// SetIdleShutdownDelay overrides how long the server lingers after its
|
| 311 |
+
// last workspace is released before shutting down. A value <= 0 restores
|
| 312 |
+
// the shut-down-immediately behavior. Intended for tests.
|
| 313 |
+
func (b *Backend) SetIdleShutdownDelay(d time.Duration) {
|
| 314 |
+
b.mu.Lock()
|
| 315 |
+
defer b.mu.Unlock()
|
| 316 |
+
b.lingerDelay = d
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
// GetWorkspace retrieves a workspace by ID.
|
| 320 |
+
func (b *Backend) GetWorkspace(id string) (*Workspace, error) {
|
| 321 |
+
ws, ok := b.workspaces.Get(id)
|
| 322 |
+
if !ok {
|
| 323 |
+
return nil, ErrWorkspaceNotFound
|
| 324 |
+
}
|
| 325 |
+
return ws, nil
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// ListWorkspaces returns all running workspaces.
|
| 329 |
+
func (b *Backend) ListWorkspaces() []proto.Workspace {
|
| 330 |
+
workspaces := []proto.Workspace{}
|
| 331 |
+
for _, ws := range b.workspaces.Seq2() {
|
| 332 |
+
workspaces = append(workspaces, workspaceToProto(ws))
|
| 333 |
+
}
|
| 334 |
+
return workspaces
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// CreateWorkspace initializes a new workspace from the given
|
| 338 |
+
// parameters, or returns an existing workspace if one already exists at
|
| 339 |
+
// the same resolved path (first-wins semantics).
|
| 340 |
+
//
|
| 341 |
+
// args.ClientID must be a valid UUID identifying the calling client;
|
| 342 |
+
// the resulting workspace registers a creation hold on behalf of that
|
| 343 |
+
// client which is released either by the first SSE attach (which
|
| 344 |
+
// converts it into a stream claim) or by the grace window expiring.
|
| 345 |
+
func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Workspace, error) {
|
| 346 |
+
if args.Path == "" {
|
| 347 |
+
return nil, proto.Workspace{}, ErrPathRequired
|
| 348 |
+
}
|
| 349 |
+
clientID, err := validateClientID(args.ClientID)
|
| 350 |
+
if err != nil {
|
| 351 |
+
return nil, proto.Workspace{}, err
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
key, err := resolveWorkspaceKey(args.Path)
|
| 355 |
+
if err != nil {
|
| 356 |
+
return nil, proto.Workspace{}, fmt.Errorf("failed to resolve workspace path: %w", err)
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
b.mu.Lock()
|
| 360 |
+
if err := b.admitLocked(clientID); err != nil {
|
| 361 |
+
b.mu.Unlock()
|
| 362 |
+
return nil, proto.Workspace{}, err
|
| 363 |
+
}
|
| 364 |
+
// A client is arriving: cancel any pending idle shutdown so we never
|
| 365 |
+
// hand back a workspace on a server that is about to tear itself down.
|
| 366 |
+
b.cancelShutdownLocked()
|
| 367 |
+
if existingID, ok := b.pathIndex[key]; ok {
|
| 368 |
+
if ws, found := b.workspaces.Get(existingID); found {
|
| 369 |
+
// Hold b.mu while registering: teardown also
|
| 370 |
+
// acquires b.mu before tearing the workspace
|
| 371 |
+
// down, so this guarantees the workspace we
|
| 372 |
+
// return cannot be torn out from under us
|
| 373 |
+
// between lookup and registerClient. Lock order
|
| 374 |
+
// here is b.mu -> ws.clientsMu.
|
| 375 |
+
if !stringSlicesEqual(ws.Cfg.Overrides().EnabledChannels, args.Channels) {
|
| 376 |
+
b.mu.Unlock()
|
| 377 |
+
return nil, proto.Workspace{}, ErrChannelOptInMismatch
|
| 378 |
+
}
|
| 379 |
+
logFirstWinsMismatch(ws, args)
|
| 380 |
+
b.registerClient(ws, clientID)
|
| 381 |
+
b.mu.Unlock()
|
| 382 |
+
return ws, workspaceToProto(ws), nil
|
| 383 |
+
}
|
| 384 |
+
// pathIndex referenced a workspace that has since been
|
| 385 |
+
// removed; clean the stale entry and fall through.
|
| 386 |
+
delete(b.pathIndex, key)
|
| 387 |
+
}
|
| 388 |
+
// Commit to the slow creation path. Mark this create as pending
|
| 389 |
+
// while mu is still held so a teardown that runs during the
|
| 390 |
+
// unlocked init below cannot observe an empty backend and shut the
|
| 391 |
+
// server down. The deferred decrement runs after the workspace has
|
| 392 |
+
// been registered (or the create has failed), keeping the invariant
|
| 393 |
+
// that pending only drops once the workspace is visible in the map.
|
| 394 |
+
b.pending++
|
| 395 |
+
b.mu.Unlock()
|
| 396 |
+
defer func() {
|
| 397 |
+
b.mu.Lock()
|
| 398 |
+
b.pending--
|
| 399 |
+
// If this create ended up registering nothing (it failed, or
|
| 400 |
+
// deduped onto an existing workspace that has since gone) and
|
| 401 |
+
// it was holding the last teardown back, the server may now be
|
| 402 |
+
// idle with no pending work. Arm the idle-shutdown timer here so
|
| 403 |
+
// a failed create racing the last teardown does not leak an
|
| 404 |
+
// empty server that a plain teardown already declined to reap.
|
| 405 |
+
shutdownNow := b.scheduleShutdownIfIdleLocked()
|
| 406 |
+
b.mu.Unlock()
|
| 407 |
+
if shutdownNow {
|
| 408 |
+
slog.Info("No workspaces remain after create settled, shutting down server...")
|
| 409 |
+
b.shutdownFn()
|
| 410 |
+
}
|
| 411 |
+
}()
|
| 412 |
+
|
| 413 |
+
id := uuid.New().String()
|
| 414 |
+
cfg, err := config.Init(args.Path, args.DataDir, args.Debug)
|
| 415 |
+
if err != nil {
|
| 416 |
+
return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
cfg.Overrides().SkipPermissionRequests = args.YOLO
|
| 420 |
+
cfg.Overrides().EnabledChannels = args.Channels
|
| 421 |
+
|
| 422 |
+
if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
|
| 423 |
+
return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
conn, err := db.Connect(b.ctx, cfg.Config().Options.DataDirectory, db.WithDataDirLock(true))
|
| 427 |
+
if err != nil {
|
| 428 |
+
return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
// Discover skills once per workspace, before app.New. The backend
|
| 432 |
+
// hosts multiple workspaces concurrently, so the manager is
|
| 433 |
+
// constructed WITHOUT WithGlobalMirror to prevent last-writer-wins
|
| 434 |
+
// cross-talk between workspaces.
|
| 435 |
+
discoveryCfg := skillsDiscoveryConfig(cfg)
|
| 436 |
+
allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(discoveryCfg)
|
| 437 |
+
skillsMgr := skills.NewManager(
|
| 438 |
+
allSkills, activeSkills, skillStates,
|
| 439 |
+
skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
|
| 440 |
+
skills.WithWorkingDir(discoveryCfg.WorkingDir),
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
appWorkspace, err := app.New(b.ctx, conn, cfg, skillsMgr)
|
| 444 |
+
if err != nil {
|
| 445 |
+
return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
wsCtx, wsCancel := context.WithCancel(b.ctx)
|
| 449 |
+
ws := &Workspace{
|
| 450 |
+
App: appWorkspace,
|
| 451 |
+
ID: id,
|
| 452 |
+
Path: args.Path,
|
| 453 |
+
Cfg: cfg,
|
| 454 |
+
Env: args.Env,
|
| 455 |
+
Skills: skillsMgr,
|
| 456 |
+
resolvedPath: key,
|
| 457 |
+
ctx: wsCtx,
|
| 458 |
+
cancel: wsCancel,
|
| 459 |
+
clients: make(map[string]*clientState),
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
b.mu.Lock()
|
| 463 |
+
// Re-check admission: the client may have retired while the slow
|
| 464 |
+
// init above ran with b.mu released, and registering a claim for a
|
| 465 |
+
// client that has already announced its exit would strand the
|
| 466 |
+
// workspace. (b.closing cannot have flipped: every shutdown decision
|
| 467 |
+
// requires pending == 0, and this create has held pending since
|
| 468 |
+
// before it released b.mu.)
|
| 469 |
+
if err := b.admitLocked(clientID); err != nil {
|
| 470 |
+
b.mu.Unlock()
|
| 471 |
+
ws.invokeShutdown()
|
| 472 |
+
return nil, proto.Workspace{}, err
|
| 473 |
+
}
|
| 474 |
+
// Re-check the index under the lock: a concurrent caller may have
|
| 475 |
+
// won the race between the initial unlock and here.
|
| 476 |
+
if existingID, ok := b.pathIndex[key]; ok {
|
| 477 |
+
if existing, found := b.workspaces.Get(existingID); found {
|
| 478 |
+
// Register under b.mu so teardown cannot run
|
| 479 |
+
// between lookup and registerClient. Lock order
|
| 480 |
+
// is b.mu -> ws.clientsMu.
|
| 481 |
+
if !stringSlicesEqual(existing.Cfg.Overrides().EnabledChannels, args.Channels) {
|
| 482 |
+
b.mu.Unlock()
|
| 483 |
+
ws.invokeShutdown()
|
| 484 |
+
return nil, proto.Workspace{}, ErrChannelOptInMismatch
|
| 485 |
+
}
|
| 486 |
+
logFirstWinsMismatch(existing, args)
|
| 487 |
+
b.registerClient(existing, clientID)
|
| 488 |
+
b.mu.Unlock()
|
| 489 |
+
ws.invokeShutdown()
|
| 490 |
+
return existing, workspaceToProto(existing), nil
|
| 491 |
+
}
|
| 492 |
+
delete(b.pathIndex, key)
|
| 493 |
+
}
|
| 494 |
+
b.workspaces.Set(id, ws)
|
| 495 |
+
b.pathIndex[key] = id
|
| 496 |
+
// Register the originating client's hold while still holding
|
| 497 |
+
// b.mu so the workspace is observable with its claim from the
|
| 498 |
+
// moment it appears in the index.
|
| 499 |
+
b.registerClient(ws, clientID)
|
| 500 |
+
b.mu.Unlock()
|
| 501 |
+
|
| 502 |
+
if args.Version != "" && args.Version != version.Version {
|
| 503 |
+
slog.Warn(
|
| 504 |
+
"Client/server version mismatch",
|
| 505 |
+
"client", args.Version,
|
| 506 |
+
"server", version.Version,
|
| 507 |
+
)
|
| 508 |
+
appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
|
| 509 |
+
"Server version %q differs from client version %q. Consider restarting the server.",
|
| 510 |
+
version.Version, args.Version,
|
| 511 |
+
)))
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
return ws, workspaceToProto(ws), nil
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
// skillsDiscoveryConfig adapts a *config.ConfigStore to the
|
| 518 |
+
// skills.DiscoveryConfig that DiscoverFromConfig consumes.
|
| 519 |
+
func skillsDiscoveryConfig(cfg *config.ConfigStore) skills.DiscoveryConfig {
|
| 520 |
+
opts := cfg.Config().Options
|
| 521 |
+
var paths, disabled []string
|
| 522 |
+
if opts != nil {
|
| 523 |
+
paths = opts.SkillsPaths
|
| 524 |
+
disabled = opts.DisabledSkills
|
| 525 |
+
}
|
| 526 |
+
var resolver func(string) (string, error)
|
| 527 |
+
if r := cfg.Resolver(); r != nil {
|
| 528 |
+
resolver = r.ResolveValue
|
| 529 |
+
}
|
| 530 |
+
return skills.DiscoveryConfig{
|
| 531 |
+
SkillsPaths: paths,
|
| 532 |
+
DisabledSkills: disabled,
|
| 533 |
+
WorkingDir: cfg.WorkingDir(),
|
| 534 |
+
Resolver: resolver,
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
// skillStatesToProto converts internal skill discovery states into the
|
| 539 |
+
// wire format.
|
| 540 |
+
func skillStatesToProto(states []*skills.SkillState) []proto.SkillState {
|
| 541 |
+
if len(states) == 0 {
|
| 542 |
+
return nil
|
| 543 |
+
}
|
| 544 |
+
out := make([]proto.SkillState, len(states))
|
| 545 |
+
for i, s := range states {
|
| 546 |
+
entry := proto.SkillState{
|
| 547 |
+
Name: s.Name,
|
| 548 |
+
Path: s.Path,
|
| 549 |
+
State: proto.SkillDiscoveryState(s.State),
|
| 550 |
+
}
|
| 551 |
+
if s.Err != nil {
|
| 552 |
+
entry.Error = s.Err.Error()
|
| 553 |
+
}
|
| 554 |
+
out[i] = entry
|
| 555 |
+
}
|
| 556 |
+
return out
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
// AttachClient registers a new SSE stream for the given client on the
|
| 560 |
+
// workspace. The stream's deferred cleanup must call DetachClient with
|
| 561 |
+
// the same arguments to release the claim.
|
| 562 |
+
//
|
| 563 |
+
// The lookup and the clients-map mutation are performed under
|
| 564 |
+
// [Backend.mu] so that AttachClient cannot race with [Backend.teardown]:
|
| 565 |
+
// teardown also holds [Backend.mu] while removing the workspace from
|
| 566 |
+
// b.workspaces, so once AttachClient observes the workspace and takes
|
| 567 |
+
// ws.clientsMu (under b.mu), no concurrent teardown can succeed without
|
| 568 |
+
// re-checking the (now non-empty) clients map. Lock order is the
|
| 569 |
+
// canonical b.mu -> ws.clientsMu.
|
| 570 |
+
func (b *Backend) AttachClient(workspaceID, clientID string) error {
|
| 571 |
+
if _, err := validateClientID(clientID); err != nil {
|
| 572 |
+
return err
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
b.mu.Lock()
|
| 576 |
+
defer b.mu.Unlock()
|
| 577 |
+
ws, ok := b.workspaces.Get(workspaceID)
|
| 578 |
+
if !ok {
|
| 579 |
+
return ErrWorkspaceNotFound
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
ws.clientsMu.Lock()
|
| 583 |
+
defer ws.clientsMu.Unlock()
|
| 584 |
+
cs, ok := ws.clients[clientID]
|
| 585 |
+
if !ok {
|
| 586 |
+
// Defensive: SSE attach without a prior CreateWorkspace by
|
| 587 |
+
// this client still installs a stream claim so the stream
|
| 588 |
+
// stays alive for its duration.
|
| 589 |
+
ws.clients[clientID] = &clientState{streams: 1}
|
| 590 |
+
return nil
|
| 591 |
+
}
|
| 592 |
+
if cs.holdTimer != nil {
|
| 593 |
+
cs.holdTimer.Stop()
|
| 594 |
+
cs.holdTimer = nil
|
| 595 |
+
}
|
| 596 |
+
cs.streams++
|
| 597 |
+
return nil
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
// DetachClient releases one SSE stream's hold on the workspace. When the
|
| 601 |
+
// client has no streams left and no pending creation hold, its claim
|
| 602 |
+
// either enters the detach grace — giving a reconnecting client time to
|
| 603 |
+
// re-attach — or, if the grace is disabled or the client already released
|
| 604 |
+
// its claim, is removed, tearing the workspace down once the refcount
|
| 605 |
+
// hits zero.
|
| 606 |
+
func (b *Backend) DetachClient(workspaceID, clientID string) {
|
| 607 |
+
ws, ok := b.workspaces.Get(workspaceID)
|
| 608 |
+
if !ok {
|
| 609 |
+
return
|
| 610 |
+
}
|
| 611 |
+
b.detachStream(ws, clientID)
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
// admitLocked reports whether clientID may still take a claim on this
|
| 615 |
+
// server. It must be called with b.mu held, which is what makes the
|
| 616 |
+
// answer atomic with respect to the shutdown latch and to RetireClient.
|
| 617 |
+
func (b *Backend) admitLocked(clientID string) error {
|
| 618 |
+
if b.closing {
|
| 619 |
+
return ErrServerShuttingDown
|
| 620 |
+
}
|
| 621 |
+
if _, ok := b.retired[clientID]; ok {
|
| 622 |
+
return ErrClientRetired
|
| 623 |
+
}
|
| 624 |
+
return nil
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
// RetireClient records that a client has exited and releases every claim it
|
| 628 |
+
// holds, across every workspace. It is the authoritative "this client is
|
| 629 |
+
// gone" signal, and the reason a client never has to guess whether a create
|
| 630 |
+
// whose response it lost left a workspace behind: either the create landed
|
| 631 |
+
// first and this call releases its claim, or it arrives later and is
|
| 632 |
+
// refused, registering nothing.
|
| 633 |
+
//
|
| 634 |
+
// Idempotent. Lock order is the canonical b.mu -> ws.clientsMu; teardowns
|
| 635 |
+
// for workspaces the client was last on run after b.mu is released and
|
| 636 |
+
// re-check under both locks.
|
| 637 |
+
func (b *Backend) RetireClient(clientID string) error {
|
| 638 |
+
if _, err := validateClientID(clientID); err != nil {
|
| 639 |
+
return err
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
b.mu.Lock()
|
| 643 |
+
if b.retired == nil {
|
| 644 |
+
b.retired = make(map[string]struct{})
|
| 645 |
+
}
|
| 646 |
+
b.retired[clientID] = struct{}{}
|
| 647 |
+
var orphaned []*Workspace
|
| 648 |
+
for _, ws := range b.workspaces.Seq2() {
|
| 649 |
+
ws.clientsMu.Lock()
|
| 650 |
+
if cs, ok := ws.clients[clientID]; ok {
|
| 651 |
+
if cs.holdTimer != nil {
|
| 652 |
+
cs.holdTimer.Stop()
|
| 653 |
+
}
|
| 654 |
+
delete(ws.clients, clientID)
|
| 655 |
+
if len(ws.clients) == 0 {
|
| 656 |
+
orphaned = append(orphaned, ws)
|
| 657 |
+
}
|
| 658 |
+
}
|
| 659 |
+
ws.clientsMu.Unlock()
|
| 660 |
+
}
|
| 661 |
+
b.mu.Unlock()
|
| 662 |
+
|
| 663 |
+
for _, ws := range orphaned {
|
| 664 |
+
b.teardown(ws)
|
| 665 |
+
}
|
| 666 |
+
return nil
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
// releaseHold releases the creation hold for a client, if any. Active
|
| 670 |
+
// stream claims are unaffected. Idempotent: returns nil if the
|
| 671 |
+
// workspace or the client's hold no longer exist.
|
| 672 |
+
func (b *Backend) releaseHold(workspaceID, clientID string) error {
|
| 673 |
+
if _, err := validateClientID(clientID); err != nil {
|
| 674 |
+
return err
|
| 675 |
+
}
|
| 676 |
+
ws, ok := b.workspaces.Get(workspaceID)
|
| 677 |
+
if !ok {
|
| 678 |
+
return nil
|
| 679 |
+
}
|
| 680 |
+
b.releaseHoldLocked(ws, clientID)
|
| 681 |
+
return nil
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
// registerClient installs (idempotently) the given client's claim on
|
| 685 |
+
// the workspace and starts a grace timer if the entry is fresh.
|
| 686 |
+
//
|
| 687 |
+
// A duplicate create from a client whose claim is timer-held (waiting to
|
| 688 |
+
// attach, or waiting out the detach grace) re-arms it for a full create
|
| 689 |
+
// grace and supersedes any earlier explicit release: the client is coming
|
| 690 |
+
// back. The re-arm installs a fresh clientState rather than resetting the
|
| 691 |
+
// old timer, so an already-fired timer racing this call fails expireHold's
|
| 692 |
+
// identity check instead of killing the new claim.
|
| 693 |
+
func (b *Backend) registerClient(ws *Workspace, clientID string) {
|
| 694 |
+
ws.clientsMu.Lock()
|
| 695 |
+
defer ws.clientsMu.Unlock()
|
| 696 |
+
if old, ok := ws.clients[clientID]; ok {
|
| 697 |
+
old.released = false
|
| 698 |
+
if old.holdTimer == nil {
|
| 699 |
+
// Live streams hold the claim; nothing to re-arm.
|
| 700 |
+
return
|
| 701 |
+
}
|
| 702 |
+
old.holdTimer.Stop()
|
| 703 |
+
ws.clients[clientID] = b.newHeldClient(ws, clientID, old.currentSessionID, b.createGrace)
|
| 704 |
+
return
|
| 705 |
+
}
|
| 706 |
+
ws.clients[clientID] = b.newHeldClient(ws, clientID, "", b.createGrace)
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
// newHeldClient builds a clientState whose claim is held only by a timer
|
| 710 |
+
// that releases it after grace. Callers must hold ws.clientsMu.
|
| 711 |
+
func (b *Backend) newHeldClient(ws *Workspace, clientID, sessionID string, grace time.Duration) *clientState {
|
| 712 |
+
cs := &clientState{currentSessionID: sessionID}
|
| 713 |
+
cs.holdTimer = time.AfterFunc(grace, func() {
|
| 714 |
+
b.expireHold(ws, clientID, cs)
|
| 715 |
+
})
|
| 716 |
+
return cs
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
// expireHold is the body of the grace timer. It runs in its own
|
| 720 |
+
// goroutine and races against AttachClient/releaseHold; the timer
|
| 721 |
+
// stays valid only while the entry's holdTimer still points at it.
|
| 722 |
+
func (b *Backend) expireHold(ws *Workspace, clientID string, timer *clientState) {
|
| 723 |
+
ws.clientsMu.Lock()
|
| 724 |
+
cs, ok := ws.clients[clientID]
|
| 725 |
+
if !ok || cs != timer || cs.holdTimer == nil || cs.streams > 0 {
|
| 726 |
+
ws.clientsMu.Unlock()
|
| 727 |
+
return
|
| 728 |
+
}
|
| 729 |
+
cs.holdTimer = nil
|
| 730 |
+
delete(ws.clients, clientID)
|
| 731 |
+
teardown := len(ws.clients) == 0
|
| 732 |
+
ws.clientsMu.Unlock()
|
| 733 |
+
if teardown {
|
| 734 |
+
b.teardown(ws)
|
| 735 |
+
}
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
func (b *Backend) releaseHoldLocked(ws *Workspace, clientID string) {
|
| 739 |
+
ws.clientsMu.Lock()
|
| 740 |
+
cs, ok := ws.clients[clientID]
|
| 741 |
+
if !ok {
|
| 742 |
+
ws.clientsMu.Unlock()
|
| 743 |
+
return
|
| 744 |
+
}
|
| 745 |
+
if cs.holdTimer != nil {
|
| 746 |
+
cs.holdTimer.Stop()
|
| 747 |
+
cs.holdTimer = nil
|
| 748 |
+
}
|
| 749 |
+
teardown := false
|
| 750 |
+
if cs.streams == 0 {
|
| 751 |
+
delete(ws.clients, clientID)
|
| 752 |
+
teardown = len(ws.clients) == 0
|
| 753 |
+
} else {
|
| 754 |
+
// The client gave up its claim while streams are still open, which
|
| 755 |
+
// is what a clean exit looks like. Remember it so the final detach
|
| 756 |
+
// skips the reconnect grace.
|
| 757 |
+
cs.released = true
|
| 758 |
+
}
|
| 759 |
+
ws.clientsMu.Unlock()
|
| 760 |
+
if teardown {
|
| 761 |
+
b.teardown(ws)
|
| 762 |
+
}
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
func (b *Backend) detachStream(ws *Workspace, clientID string) {
|
| 766 |
+
b.mu.Lock()
|
| 767 |
+
grace := b.detachGrace
|
| 768 |
+
b.mu.Unlock()
|
| 769 |
+
|
| 770 |
+
ws.clientsMu.Lock()
|
| 771 |
+
cs, ok := ws.clients[clientID]
|
| 772 |
+
if !ok {
|
| 773 |
+
ws.clientsMu.Unlock()
|
| 774 |
+
return
|
| 775 |
+
}
|
| 776 |
+
if cs.streams > 0 {
|
| 777 |
+
cs.streams--
|
| 778 |
+
}
|
| 779 |
+
teardown := false
|
| 780 |
+
if cs.streams == 0 && cs.holdTimer == nil {
|
| 781 |
+
if grace > 0 && !cs.released {
|
| 782 |
+
// The stream dropped without the client releasing its claim, so
|
| 783 |
+
// treat it as an interruption rather than an exit: hold the
|
| 784 |
+
// workspace under a timer long enough for the client's
|
| 785 |
+
// reconnect to re-attach (AttachClient stops the timer).
|
| 786 |
+
cs.holdTimer = time.AfterFunc(grace, func() {
|
| 787 |
+
b.expireHold(ws, clientID, cs)
|
| 788 |
+
})
|
| 789 |
+
} else {
|
| 790 |
+
delete(ws.clients, clientID)
|
| 791 |
+
teardown = len(ws.clients) == 0
|
| 792 |
+
}
|
| 793 |
+
}
|
| 794 |
+
ws.clientsMu.Unlock()
|
| 795 |
+
if teardown {
|
| 796 |
+
b.teardown(ws)
|
| 797 |
+
}
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
// teardown removes the workspace from the index, shuts down its
|
| 801 |
+
// underlying [app.App], and triggers a server shutdown if it was the
|
| 802 |
+
// last workspace alive.
|
| 803 |
+
//
|
| 804 |
+
// Callers reach teardown after observing len(ws.clients) == 0 while
|
| 805 |
+
// holding ws.clientsMu and then releasing it. Between that release
|
| 806 |
+
// and the b.mu.Lock below, a concurrent CreateWorkspace may have
|
| 807 |
+
// re-registered a client (CreateWorkspace holds b.mu while doing so,
|
| 808 |
+
// so it is mutually exclusive with this critical section). teardown
|
| 809 |
+
// re-checks under both locks (in the canonical b.mu -> ws.clientsMu
|
| 810 |
+
// order) and aborts if the workspace has been re-claimed.
|
| 811 |
+
func (b *Backend) teardown(ws *Workspace) {
|
| 812 |
+
b.mu.Lock()
|
| 813 |
+
ws.clientsMu.Lock()
|
| 814 |
+
if len(ws.clients) > 0 {
|
| 815 |
+
// Race: a CreateWorkspace re-registered a client
|
| 816 |
+
// between the detach path dropping ws.clientsMu and us
|
| 817 |
+
// taking b.mu. Abort: the workspace is still alive.
|
| 818 |
+
ws.clientsMu.Unlock()
|
| 819 |
+
b.mu.Unlock()
|
| 820 |
+
return
|
| 821 |
+
}
|
| 822 |
+
ws.clientsMu.Unlock()
|
| 823 |
+
if existing, ok := b.pathIndex[ws.resolvedPath]; ok && existing == ws.ID {
|
| 824 |
+
delete(b.pathIndex, ws.resolvedPath)
|
| 825 |
+
}
|
| 826 |
+
b.workspaces.Del(ws.ID)
|
| 827 |
+
// Arm (or, with lingering disabled, request) the idle shutdown. It
|
| 828 |
+
// only proceeds once there is genuinely nothing left: no live
|
| 829 |
+
// workspaces AND no create in flight. Deferring via the linger lets a
|
| 830 |
+
// client returning moments later reuse this server instead of racing
|
| 831 |
+
// its shutdown.
|
| 832 |
+
shutdownNow := b.scheduleShutdownIfIdleLocked()
|
| 833 |
+
b.mu.Unlock()
|
| 834 |
+
|
| 835 |
+
ws.invokeShutdown()
|
| 836 |
+
|
| 837 |
+
if shutdownNow {
|
| 838 |
+
slog.Info("Last workspace removed, shutting down server...")
|
| 839 |
+
b.shutdownFn()
|
| 840 |
+
}
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
// scheduleShutdownIfIdleLocked decides what to do about server shutdown
|
| 844 |
+
// after a workspace count or pending-create change. It must be called
|
| 845 |
+
// with b.mu held.
|
| 846 |
+
//
|
| 847 |
+
// It returns true only when the caller should shut the server down
|
| 848 |
+
// synchronously (after releasing b.mu) — that is, when the server is idle
|
| 849 |
+
// and lingering is disabled (lingerDelay <= 0). When lingering is enabled
|
| 850 |
+
// it instead arms a one-shot timer that re-checks idleness after
|
| 851 |
+
// lingerDelay and shuts down then, and returns false. When the server is
|
| 852 |
+
// not idle (a workspace is live or a create is in flight) it does
|
| 853 |
+
// nothing and returns false.
|
| 854 |
+
//
|
| 855 |
+
// Returning true also latches [Backend.closing], since the caller has to
|
| 856 |
+
// release b.mu before it can run shutdownFn.
|
| 857 |
+
func (b *Backend) scheduleShutdownIfIdleLocked() (shutdownNow bool) {
|
| 858 |
+
if b.shutdownFn == nil {
|
| 859 |
+
return false
|
| 860 |
+
}
|
| 861 |
+
if b.workspaces.Len() != 0 || b.pending != 0 {
|
| 862 |
+
return false
|
| 863 |
+
}
|
| 864 |
+
if b.lingerDelay <= 0 {
|
| 865 |
+
b.closing = true
|
| 866 |
+
return true
|
| 867 |
+
}
|
| 868 |
+
if b.shutdownTimer == nil {
|
| 869 |
+
b.shutdownTimer = time.AfterFunc(b.lingerDelay, b.maybeShutdown)
|
| 870 |
+
}
|
| 871 |
+
return false
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
// cancelShutdownLocked stops any armed idle-shutdown timer. It must be
|
| 875 |
+
// called with b.mu held.
|
| 876 |
+
func (b *Backend) cancelShutdownLocked() {
|
| 877 |
+
if b.shutdownTimer != nil {
|
| 878 |
+
b.shutdownTimer.Stop()
|
| 879 |
+
b.shutdownTimer = nil
|
| 880 |
+
}
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
// maybeShutdown is the idle-shutdown timer callback. It shuts the server
|
| 884 |
+
// down only if it is still idle when the linger window elapses; any
|
| 885 |
+
// create that arrived in the meantime cancelled the timer (or bumped
|
| 886 |
+
// pending / the workspace count), so this re-check makes the linger
|
| 887 |
+
// race-free. Deciding to exit latches [Backend.closing] under the same
|
| 888 |
+
// lock, so a create arriving in the gap before shutdownFn runs is
|
| 889 |
+
// refused instead of being initialized on a departing process.
|
| 890 |
+
func (b *Backend) maybeShutdown() {
|
| 891 |
+
b.mu.Lock()
|
| 892 |
+
b.shutdownTimer = nil
|
| 893 |
+
idle := b.workspaces.Len() == 0 && b.pending == 0
|
| 894 |
+
if idle {
|
| 895 |
+
b.closing = true
|
| 896 |
+
}
|
| 897 |
+
fn := b.shutdownFn
|
| 898 |
+
b.mu.Unlock()
|
| 899 |
+
if idle && fn != nil {
|
| 900 |
+
slog.Info("Server idle, shutting down...")
|
| 901 |
+
fn()
|
| 902 |
+
}
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
// DeleteWorkspace is the public entry point used by the HTTP DELETE
|
| 906 |
+
// handler. It releases the named client's creation hold; live streams
|
| 907 |
+
// from the same client remain attached and continue holding the
|
| 908 |
+
// workspace open until their own deferred DetachClient runs.
|
| 909 |
+
func (b *Backend) DeleteWorkspace(id, clientID string) error {
|
| 910 |
+
return b.releaseHold(id, clientID)
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
// SetCurrentSession records which session the given client is
|
| 914 |
+
// currently viewing within the workspace. Passing an empty sessionID
|
| 915 |
+
// clears the client's current-session entry (e.g. the client has
|
| 916 |
+
// returned to the landing screen).
|
| 917 |
+
//
|
| 918 |
+
// The client must be actually attached — i.e. its [clientState] entry
|
| 919 |
+
// must exist and have at least one live stream. A bare creation hold
|
| 920 |
+
// (streams == 0) is rejected with [ErrClientNotAttached]. This
|
| 921 |
+
// guards against zombie writes from a client that has detached and
|
| 922 |
+
// against ghost presence from a hold-only client that never opened an
|
| 923 |
+
// SSE stream.
|
| 924 |
+
func (b *Backend) SetCurrentSession(workspaceID, clientID, sessionID string) error {
|
| 925 |
+
if _, err := validateClientID(clientID); err != nil {
|
| 926 |
+
return err
|
| 927 |
+
}
|
| 928 |
+
ws, ok := b.workspaces.Get(workspaceID)
|
| 929 |
+
if !ok {
|
| 930 |
+
return ErrWorkspaceNotFound
|
| 931 |
+
}
|
| 932 |
+
ws.clientsMu.Lock()
|
| 933 |
+
defer ws.clientsMu.Unlock()
|
| 934 |
+
cs, ok := ws.clients[clientID]
|
| 935 |
+
if !ok || cs.streams == 0 {
|
| 936 |
+
// No entry, or hold-only (no live stream): refuse the
|
| 937 |
+
// write. The presence record this is meant to feed
|
| 938 |
+
// should only reflect clients that can actually observe
|
| 939 |
+
// session events.
|
| 940 |
+
return ErrClientNotAttached
|
| 941 |
+
}
|
| 942 |
+
cs.currentSessionID = sessionID
|
| 943 |
+
return nil
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
// AttachedClients returns the number of clients currently viewing
|
| 947 |
+
// sessionID in the given workspace. Only clients with at least one live
|
| 948 |
+
// SSE stream (streams > 0) AND a matching currentSessionID are counted;
|
| 949 |
+
// pure creation holds do not contribute. Returns [ErrWorkspaceNotFound]
|
| 950 |
+
// if the workspace is unknown.
|
| 951 |
+
func (b *Backend) AttachedClients(workspaceID, sessionID string) (int, error) {
|
| 952 |
+
ws, ok := b.workspaces.Get(workspaceID)
|
| 953 |
+
if !ok {
|
| 954 |
+
return 0, ErrWorkspaceNotFound
|
| 955 |
+
}
|
| 956 |
+
return ws.AttachedClientsForSession(sessionID), nil
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
// AttachedClientsForSession returns the number of clients in this
|
| 960 |
+
// workspace whose currentSessionID equals sessionID and which have at
|
| 961 |
+
// least one live SSE stream. Hold-only clients (streams == 0) do not
|
| 962 |
+
// contribute. Acquires the workspace's [clientsMu] briefly; the
|
| 963 |
+
// returned count is a point-in-time snapshot.
|
| 964 |
+
func (w *Workspace) AttachedClientsForSession(sessionID string) int {
|
| 965 |
+
w.clientsMu.Lock()
|
| 966 |
+
defer w.clientsMu.Unlock()
|
| 967 |
+
n := 0
|
| 968 |
+
for _, cs := range w.clients {
|
| 969 |
+
if cs.streams > 0 && cs.currentSessionID == sessionID {
|
| 970 |
+
n++
|
| 971 |
+
}
|
| 972 |
+
}
|
| 973 |
+
return n
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
// GetWorkspaceProto returns the proto representation of a workspace.
|
| 977 |
+
func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
|
| 978 |
+
ws, err := b.GetWorkspace(id)
|
| 979 |
+
if err != nil {
|
| 980 |
+
return proto.Workspace{}, err
|
| 981 |
+
}
|
| 982 |
+
return workspaceToProto(ws), nil
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
// VersionInfo returns server version information.
|
| 986 |
+
func (b *Backend) VersionInfo() proto.VersionInfo {
|
| 987 |
+
return proto.VersionInfo{
|
| 988 |
+
Version: version.Version,
|
| 989 |
+
Commit: version.Commit,
|
| 990 |
+
BuildID: version.BuildID,
|
| 991 |
+
GoVersion: runtime.Version(),
|
| 992 |
+
Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
|
| 993 |
+
}
|
| 994 |
+
}
|
| 995 |
+
|
| 996 |
+
// Config returns the server-level configuration.
|
| 997 |
+
func (b *Backend) Config() *config.ConfigStore {
|
| 998 |
+
return b.cfg
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
// Shutdown initiates a graceful server shutdown.
|
| 1002 |
+
func (b *Backend) Shutdown() {
|
| 1003 |
+
b.mu.Lock()
|
| 1004 |
+
b.closing = true
|
| 1005 |
+
fn := b.shutdownFn
|
| 1006 |
+
b.mu.Unlock()
|
| 1007 |
+
if fn != nil {
|
| 1008 |
+
fn()
|
| 1009 |
+
}
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
// ShutdownIfIdle shuts the server down only when it is hosting no
|
| 1013 |
+
// workspaces and has no creates in flight, reporting false when it declined
|
| 1014 |
+
// because work is live.
|
| 1015 |
+
//
|
| 1016 |
+
// This is the only shutdown a client may request. A client asks in order to
|
| 1017 |
+
// replace a version-mismatched server, and it cannot check idleness itself
|
| 1018 |
+
// without a second round trip a new session can slip into. Deciding here,
|
| 1019 |
+
// under the lock creates and teardowns take, closes that window: the answer
|
| 1020 |
+
// is atomic, and granting it latches the decision so creates arriving
|
| 1021 |
+
// afterwards are refused.
|
| 1022 |
+
func (b *Backend) ShutdownIfIdle() bool {
|
| 1023 |
+
b.mu.Lock()
|
| 1024 |
+
live, pending := b.workspaces.Len(), b.pending
|
| 1025 |
+
idle := live == 0 && pending == 0
|
| 1026 |
+
if idle {
|
| 1027 |
+
b.closing = true
|
| 1028 |
+
}
|
| 1029 |
+
fn := b.shutdownFn
|
| 1030 |
+
b.mu.Unlock()
|
| 1031 |
+
if !idle {
|
| 1032 |
+
slog.Warn("Refusing shutdown request: server is not idle",
|
| 1033 |
+
"workspaces", live, "pending_creates", pending)
|
| 1034 |
+
return false
|
| 1035 |
+
}
|
| 1036 |
+
if fn != nil {
|
| 1037 |
+
fn()
|
| 1038 |
+
}
|
| 1039 |
+
return true
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
// resolveWorkspaceKey returns a stable canonical form of path suitable
|
| 1043 |
+
// for use as a dedup key. It applies filepath.Abs, then attempts
|
| 1044 |
+
// filepath.EvalSymlinks; because EvalSymlinks errors on non-existent
|
| 1045 |
+
// paths, it falls back to the cleaned absolute path in that case.
|
| 1046 |
+
func resolveWorkspaceKey(path string) (string, error) {
|
| 1047 |
+
abs, err := filepath.Abs(path)
|
| 1048 |
+
if err != nil {
|
| 1049 |
+
return "", err
|
| 1050 |
+
}
|
| 1051 |
+
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
| 1052 |
+
return resolved, nil
|
| 1053 |
+
}
|
| 1054 |
+
return abs, nil
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
// validateClientID returns the trimmed UUID string or an error if the
|
| 1058 |
+
// input is empty or not a valid UUID.
|
| 1059 |
+
func validateClientID(id string) (string, error) {
|
| 1060 |
+
if id == "" {
|
| 1061 |
+
return "", ErrInvalidClientID
|
| 1062 |
+
}
|
| 1063 |
+
if _, err := uuid.Parse(id); err != nil {
|
| 1064 |
+
return "", fmt.Errorf("%w: %v", ErrInvalidClientID, err)
|
| 1065 |
+
}
|
| 1066 |
+
return id, nil
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
func workspaceToProto(ws *Workspace) proto.Workspace {
|
| 1070 |
+
cfg := ws.Cfg.Config()
|
| 1071 |
+
out := proto.Workspace{
|
| 1072 |
+
ID: ws.ID,
|
| 1073 |
+
Path: ws.Path,
|
| 1074 |
+
YOLO: ws.Cfg.Overrides().SkipPermissionRequests,
|
| 1075 |
+
Channels: ws.Cfg.Overrides().EnabledChannels,
|
| 1076 |
+
DataDir: cfg.Options.DataDirectory,
|
| 1077 |
+
Debug: cfg.Options.Debug,
|
| 1078 |
+
Config: cfg,
|
| 1079 |
+
Env: ws.Env,
|
| 1080 |
+
Version: version.Version,
|
| 1081 |
+
}
|
| 1082 |
+
if ws.Skills != nil {
|
| 1083 |
+
out.Skills = skillStatesToProto(ws.Skills.States())
|
| 1084 |
+
}
|
| 1085 |
+
return out
|
| 1086 |
+
}
|
| 1087 |
+
|
| 1088 |
+
// logFirstWinsMismatch emits a debug line whenever a second
|
| 1089 |
+
// CreateWorkspace at the same resolved path arrives with flags that
|
| 1090 |
+
// differ from the originating workspace. The existing workspace wins;
|
| 1091 |
+
// the incoming flags are silently ignored.
|
| 1092 |
+
//
|
| 1093 |
+
// The comparison is done against the incoming args as the caller sent
|
| 1094 |
+
// them — including empty/zero values — rather than after defaulting.
|
| 1095 |
+
// This means that, for example, a second caller who omits DataDir
|
| 1096 |
+
// while the first set one will still log the mismatch.
|
| 1097 |
+
func logFirstWinsMismatch(existing *Workspace, args proto.Workspace) {
|
| 1098 |
+
existingCfg := existing.Cfg.Config()
|
| 1099 |
+
existingYOLO := existing.Cfg.Overrides().SkipPermissionRequests
|
| 1100 |
+
existingChannels := existing.Cfg.Overrides().EnabledChannels
|
| 1101 |
+
if existingYOLO == args.YOLO &&
|
| 1102 |
+
existingCfg.Options.Debug == args.Debug &&
|
| 1103 |
+
existingCfg.Options.DataDirectory == args.DataDir &&
|
| 1104 |
+
stringSlicesEqual(existing.Env, args.Env) &&
|
| 1105 |
+
stringSlicesEqual(existingChannels, args.Channels) {
|
| 1106 |
+
return
|
| 1107 |
+
}
|
| 1108 |
+
slog.Debug(
|
| 1109 |
+
"Workspace flag mismatch on duplicate create; first wins",
|
| 1110 |
+
"workspace_id", existing.ID,
|
| 1111 |
+
"path", existing.Path,
|
| 1112 |
+
"existing_yolo", existingYOLO,
|
| 1113 |
+
"requested_yolo", args.YOLO,
|
| 1114 |
+
"existing_debug", existingCfg.Options.Debug,
|
| 1115 |
+
"requested_debug", args.Debug,
|
| 1116 |
+
"existing_data_dir", existingCfg.Options.DataDirectory,
|
| 1117 |
+
"requested_data_dir", args.DataDir,
|
| 1118 |
+
"existing_env", existing.Env,
|
| 1119 |
+
"requested_env", args.Env,
|
| 1120 |
+
"existing_channels", existingChannels,
|
| 1121 |
+
"requested_channels", args.Channels,
|
| 1122 |
+
)
|
| 1123 |
+
}
|
| 1124 |
+
|
| 1125 |
+
// stringSlicesEqual reports whether a and b contain the same strings
|
| 1126 |
+
// in the same order. nil and empty are treated as equal.
|
| 1127 |
+
func stringSlicesEqual(a, b []string) bool {
|
| 1128 |
+
if len(a) != len(b) {
|
| 1129 |
+
return false
|
| 1130 |
+
}
|
| 1131 |
+
for i := range a {
|
| 1132 |
+
if a[i] != b[i] {
|
| 1133 |
+
return false
|
| 1134 |
+
}
|
| 1135 |
+
}
|
| 1136 |
+
return true
|
| 1137 |
+
}
|
internal/backend/backend_skills_test.go
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package backend_test
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"fmt"
|
| 6 |
+
"os"
|
| 7 |
+
"path/filepath"
|
| 8 |
+
"testing"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
tea "charm.land/bubbletea/v2"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/backend"
|
| 13 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 14 |
+
"github.com/charmbracelet/crush/internal/proto"
|
| 15 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 16 |
+
"github.com/charmbracelet/crush/internal/skills"
|
| 17 |
+
"github.com/google/uuid"
|
| 18 |
+
"github.com/stretchr/testify/require"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
// TestBackend_WorkspaceSkillsIsolation verifies that skill discovery
|
| 22 |
+
// state and SSE events are per-workspace, not process-global. Two
|
| 23 |
+
// workspaces in the same backend process must not see each other's
|
| 24 |
+
// discoveries (either in their initial snapshot or in subsequent
|
| 25 |
+
// PublishStates events).
|
| 26 |
+
func TestBackend_WorkspaceSkillsIsolation(t *testing.T) {
|
| 27 |
+
// Isolate all of config.Init's filesystem reads from the host. The
|
| 28 |
+
// project-local .agents/skills/<name>/SKILL.md per working dir is
|
| 29 |
+
// what we actually want each workspace to see; everything else
|
| 30 |
+
// (global skills, XDG dirs, etc.) must be empty/deterministic.
|
| 31 |
+
hostHome := t.TempDir()
|
| 32 |
+
t.Setenv("HOME", hostHome)
|
| 33 |
+
t.Setenv("XDG_CONFIG_HOME", filepath.Join(hostHome, ".config"))
|
| 34 |
+
t.Setenv("XDG_DATA_HOME", filepath.Join(hostHome, ".local", "share"))
|
| 35 |
+
t.Setenv("XDG_CACHE_HOME", filepath.Join(hostHome, ".cache"))
|
| 36 |
+
t.Setenv("CRUSH_SKILLS_DIR", t.TempDir())
|
| 37 |
+
|
| 38 |
+
// Each workspace gets its own working directory containing a
|
| 39 |
+
// distinct project-local skill so the discovery output differs.
|
| 40 |
+
wdA := t.TempDir()
|
| 41 |
+
wdB := t.TempDir()
|
| 42 |
+
writeSkill(t, wdA, "wsa-only-skill", "Workspace A only skill.")
|
| 43 |
+
writeSkill(t, wdB, "wsb-only-skill", "Workspace B only skill.")
|
| 44 |
+
|
| 45 |
+
srvCfg, err := config.Init(wdA, "", false)
|
| 46 |
+
require.NoError(t, err)
|
| 47 |
+
b := backend.New(t.Context(), srvCfg, nil)
|
| 48 |
+
|
| 49 |
+
cidA := uuid.New().String()
|
| 50 |
+
cidB := uuid.New().String()
|
| 51 |
+
|
| 52 |
+
wsA, _, err := b.CreateWorkspace(proto.Workspace{
|
| 53 |
+
ClientID: cidA,
|
| 54 |
+
Path: wdA,
|
| 55 |
+
DataDir: filepath.Join(wdA, ".crush"),
|
| 56 |
+
})
|
| 57 |
+
require.NoError(t, err)
|
| 58 |
+
t.Cleanup(func() { _ = b.DeleteWorkspace(wsA.ID, cidA) })
|
| 59 |
+
|
| 60 |
+
wsB, _, err := b.CreateWorkspace(proto.Workspace{
|
| 61 |
+
ClientID: cidB,
|
| 62 |
+
Path: wdB,
|
| 63 |
+
DataDir: filepath.Join(wdB, ".crush"),
|
| 64 |
+
})
|
| 65 |
+
require.NoError(t, err)
|
| 66 |
+
t.Cleanup(func() { _ = b.DeleteWorkspace(wsB.ID, cidB) })
|
| 67 |
+
|
| 68 |
+
require.NotNil(t, wsA.Skills, "workspace A must have its own skills.Manager")
|
| 69 |
+
require.NotNil(t, wsB.Skills, "workspace B must have its own skills.Manager")
|
| 70 |
+
require.NotSame(t, wsA.Skills, wsB.Skills, "managers must be distinct instances per workspace")
|
| 71 |
+
|
| 72 |
+
// Initial snapshots see each workspace's own filesystem skill, and
|
| 73 |
+
// neither sees the other's.
|
| 74 |
+
statesA := wsA.Skills.States()
|
| 75 |
+
statesB := wsB.Skills.States()
|
| 76 |
+
require.True(t, containsSkillName(statesA, "wsa-only-skill"),
|
| 77 |
+
"workspace A snapshot missing its own skill")
|
| 78 |
+
require.False(t, containsSkillName(statesA, "wsb-only-skill"),
|
| 79 |
+
"workspace A snapshot leaked workspace B's skill")
|
| 80 |
+
require.True(t, containsSkillName(statesB, "wsb-only-skill"),
|
| 81 |
+
"workspace B snapshot missing its own skill")
|
| 82 |
+
require.False(t, containsSkillName(statesB, "wsa-only-skill"),
|
| 83 |
+
"workspace B snapshot leaked workspace A's skill")
|
| 84 |
+
|
| 85 |
+
// Subscribe to each workspace's SSE event stream.
|
| 86 |
+
ctxA, cancelA := context.WithCancel(t.Context())
|
| 87 |
+
t.Cleanup(cancelA)
|
| 88 |
+
chA, err := b.SubscribeEvents(ctxA, wsA.ID)
|
| 89 |
+
require.NoError(t, err)
|
| 90 |
+
|
| 91 |
+
ctxB, cancelB := context.WithCancel(t.Context())
|
| 92 |
+
t.Cleanup(cancelB)
|
| 93 |
+
chB, err := b.SubscribeEvents(ctxB, wsB.ID)
|
| 94 |
+
require.NoError(t, err)
|
| 95 |
+
|
| 96 |
+
// Trigger a republish on workspace A only. The marker name lets us
|
| 97 |
+
// distinguish this event from any incidental backend activity.
|
| 98 |
+
const marker = "wsa-republish-marker"
|
| 99 |
+
wsA.Skills.PublishStates([]*skills.SkillState{
|
| 100 |
+
{Name: marker, State: skills.StateNormal},
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
// Workspace A must receive its own event.
|
| 104 |
+
require.True(t,
|
| 105 |
+
waitForSkillsEvent(t, chA, marker, 2*time.Second),
|
| 106 |
+
"workspace A never received its own skills event")
|
| 107 |
+
|
| 108 |
+
// Workspace B must NOT receive workspace A's event.
|
| 109 |
+
require.False(t,
|
| 110 |
+
waitForSkillsEvent(t, chB, marker, 250*time.Millisecond),
|
| 111 |
+
"workspace B leaked workspace A's skills event")
|
| 112 |
+
|
| 113 |
+
// And A's published states are now visible on its manager's
|
| 114 |
+
// snapshot (verifies PublishStates updates the cache, not just the
|
| 115 |
+
// broker).
|
| 116 |
+
updatedA := wsA.Skills.States()
|
| 117 |
+
require.True(t, containsSkillName(updatedA, marker),
|
| 118 |
+
"PublishStates must update Manager.States()")
|
| 119 |
+
|
| 120 |
+
// B's manager snapshot is untouched.
|
| 121 |
+
require.False(t, containsSkillName(wsB.Skills.States(), marker),
|
| 122 |
+
"workspace B's Manager.States() leaked workspace A's republish")
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
func writeSkill(t *testing.T, workingDir, name, desc string) {
|
| 126 |
+
t.Helper()
|
| 127 |
+
skillDir := filepath.Join(workingDir, ".agents", "skills", name)
|
| 128 |
+
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
| 129 |
+
content := fmt.Sprintf("---\nname: %s\ndescription: %s\n---\n%s\n", name, desc, desc)
|
| 130 |
+
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644))
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
func containsSkillName(states []*skills.SkillState, name string) bool {
|
| 134 |
+
for _, s := range states {
|
| 135 |
+
if s.Name == name {
|
| 136 |
+
return true
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
return false
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// waitForSkillsEvent drains the given event channel until either a
|
| 143 |
+
// pubsub.Event[skills.Event] containing a state named marker arrives or
|
| 144 |
+
// the timeout elapses. Non-skills events on the channel are silently
|
| 145 |
+
// skipped — the backend fans in many event types and we only care
|
| 146 |
+
// about skills here.
|
| 147 |
+
func waitForSkillsEvent(t *testing.T, ch <-chan pubsub.Event[tea.Msg], marker string, timeout time.Duration) bool {
|
| 148 |
+
t.Helper()
|
| 149 |
+
deadline := time.After(timeout)
|
| 150 |
+
for {
|
| 151 |
+
select {
|
| 152 |
+
case ev, ok := <-ch:
|
| 153 |
+
if !ok {
|
| 154 |
+
return false
|
| 155 |
+
}
|
| 156 |
+
se, ok := ev.Payload.(pubsub.Event[skills.Event])
|
| 157 |
+
if !ok {
|
| 158 |
+
continue
|
| 159 |
+
}
|
| 160 |
+
if containsSkillName(se.Payload.States, marker) {
|
| 161 |
+
return true
|
| 162 |
+
}
|
| 163 |
+
case <-deadline:
|
| 164 |
+
return false
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
}
|
internal/backend/backend_test.go
ADDED
|
@@ -0,0 +1,1784 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package backend
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"context"
|
| 6 |
+
"errors"
|
| 7 |
+
"log/slog"
|
| 8 |
+
"os"
|
| 9 |
+
"path/filepath"
|
| 10 |
+
"runtime"
|
| 11 |
+
"strings"
|
| 12 |
+
"sync"
|
| 13 |
+
"sync/atomic"
|
| 14 |
+
"testing"
|
| 15 |
+
"time"
|
| 16 |
+
|
| 17 |
+
"github.com/charmbracelet/crush/internal/csync"
|
| 18 |
+
"github.com/charmbracelet/crush/internal/proto"
|
| 19 |
+
"github.com/google/uuid"
|
| 20 |
+
"github.com/stretchr/testify/require"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
// newTestBackend returns a Backend whose teardown path skips any
|
| 24 |
+
// real [app.App] shutdown work. Useful for state-machine tests that
|
| 25 |
+
// install synthetic workspaces directly via insertTestWorkspace.
|
| 26 |
+
func newTestBackend(t *testing.T) (*Backend, *atomic.Int32) {
|
| 27 |
+
t.Helper()
|
| 28 |
+
var shutdownCount atomic.Int32
|
| 29 |
+
b := &Backend{
|
| 30 |
+
workspaces: csync.NewMap[string, *Workspace](),
|
| 31 |
+
pathIndex: make(map[string]string),
|
| 32 |
+
ctx: context.Background(),
|
| 33 |
+
createGrace: 50 * time.Millisecond,
|
| 34 |
+
shutdownFn: func() { shutdownCount.Add(1) },
|
| 35 |
+
}
|
| 36 |
+
return b, &shutdownCount
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// insertTestWorkspace installs a synthetic workspace into b at the
|
| 40 |
+
// given resolved path. Its shutdownFn is recorded in the returned
|
| 41 |
+
// counter so tests can assert it ran exactly once.
|
| 42 |
+
func insertTestWorkspace(t *testing.T, b *Backend, key string) (*Workspace, *atomic.Int32) {
|
| 43 |
+
t.Helper()
|
| 44 |
+
var shutdowns atomic.Int32
|
| 45 |
+
ws := &Workspace{
|
| 46 |
+
ID: uuid.New().String(),
|
| 47 |
+
Path: key,
|
| 48 |
+
resolvedPath: key,
|
| 49 |
+
clients: make(map[string]*clientState),
|
| 50 |
+
shutdownFn: func() { shutdowns.Add(1) },
|
| 51 |
+
}
|
| 52 |
+
b.mu.Lock()
|
| 53 |
+
b.workspaces.Set(ws.ID, ws)
|
| 54 |
+
b.pathIndex[key] = ws.ID
|
| 55 |
+
b.mu.Unlock()
|
| 56 |
+
return ws, &shutdowns
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
func newClientID(t *testing.T) string {
|
| 60 |
+
t.Helper()
|
| 61 |
+
return uuid.New().String()
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
func TestResolveWorkspaceKey_AbsoluteAndSymlink(t *testing.T) {
|
| 65 |
+
t.Parallel()
|
| 66 |
+
|
| 67 |
+
tmp := t.TempDir()
|
| 68 |
+
real, err := filepath.EvalSymlinks(tmp)
|
| 69 |
+
require.NoError(t, err)
|
| 70 |
+
|
| 71 |
+
got, err := resolveWorkspaceKey(tmp)
|
| 72 |
+
require.NoError(t, err)
|
| 73 |
+
require.Equal(t, real, got)
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
func TestResolveWorkspaceKey_NonExistentFallback(t *testing.T) {
|
| 77 |
+
t.Parallel()
|
| 78 |
+
|
| 79 |
+
missing := filepath.Join(t.TempDir(), "does", "not", "exist")
|
| 80 |
+
got, err := resolveWorkspaceKey(missing)
|
| 81 |
+
require.NoError(t, err)
|
| 82 |
+
abs, err := filepath.Abs(missing)
|
| 83 |
+
require.NoError(t, err)
|
| 84 |
+
require.Equal(t, abs, got)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
func TestValidateClientID(t *testing.T) {
|
| 88 |
+
t.Parallel()
|
| 89 |
+
|
| 90 |
+
_, err := validateClientID("")
|
| 91 |
+
require.ErrorIs(t, err, ErrInvalidClientID)
|
| 92 |
+
_, err = validateClientID("not-a-uuid")
|
| 93 |
+
require.ErrorIs(t, err, ErrInvalidClientID)
|
| 94 |
+
|
| 95 |
+
id := uuid.New().String()
|
| 96 |
+
got, err := validateClientID(id)
|
| 97 |
+
require.NoError(t, err)
|
| 98 |
+
require.Equal(t, id, got)
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
func TestRegisterClient_Idempotent(t *testing.T) {
|
| 102 |
+
t.Parallel()
|
| 103 |
+
|
| 104 |
+
b, _ := newTestBackend(t)
|
| 105 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/a")
|
| 106 |
+
|
| 107 |
+
cid := newClientID(t)
|
| 108 |
+
b.registerClient(ws, cid)
|
| 109 |
+
b.registerClient(ws, cid)
|
| 110 |
+
|
| 111 |
+
ws.clientsMu.Lock()
|
| 112 |
+
defer ws.clientsMu.Unlock()
|
| 113 |
+
require.Len(t, ws.clients, 1)
|
| 114 |
+
require.NotNil(t, ws.clients[cid].holdTimer)
|
| 115 |
+
require.Equal(t, 0, ws.clients[cid].streams)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
func TestAttachClient_ConsumesHold(t *testing.T) {
|
| 119 |
+
t.Parallel()
|
| 120 |
+
|
| 121 |
+
b, _ := newTestBackend(t)
|
| 122 |
+
ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
|
| 123 |
+
|
| 124 |
+
cid := newClientID(t)
|
| 125 |
+
b.registerClient(ws, cid)
|
| 126 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 127 |
+
|
| 128 |
+
ws.clientsMu.Lock()
|
| 129 |
+
require.Len(t, ws.clients, 1)
|
| 130 |
+
require.Nil(t, ws.clients[cid].holdTimer, "attach must stop the grace timer")
|
| 131 |
+
require.Equal(t, 1, ws.clients[cid].streams)
|
| 132 |
+
ws.clientsMu.Unlock()
|
| 133 |
+
|
| 134 |
+
// Wait past the grace window: a stopped timer must not fire.
|
| 135 |
+
time.Sleep(150 * time.Millisecond)
|
| 136 |
+
require.Equal(t, int32(0), shutdowns.Load(), "workspace must not be torn down while attached")
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
func TestAttachClient_WithoutPriorCreate(t *testing.T) {
|
| 140 |
+
t.Parallel()
|
| 141 |
+
|
| 142 |
+
b, _ := newTestBackend(t)
|
| 143 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/a")
|
| 144 |
+
|
| 145 |
+
cid := newClientID(t)
|
| 146 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 147 |
+
|
| 148 |
+
ws.clientsMu.Lock()
|
| 149 |
+
defer ws.clientsMu.Unlock()
|
| 150 |
+
require.Len(t, ws.clients, 1)
|
| 151 |
+
require.Equal(t, 1, ws.clients[cid].streams)
|
| 152 |
+
require.Nil(t, ws.clients[cid].holdTimer)
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
func TestAttachClient_DuplicateStreams(t *testing.T) {
|
| 156 |
+
t.Parallel()
|
| 157 |
+
|
| 158 |
+
b, _ := newTestBackend(t)
|
| 159 |
+
ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
|
| 160 |
+
|
| 161 |
+
cid := newClientID(t)
|
| 162 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 163 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 164 |
+
|
| 165 |
+
ws.clientsMu.Lock()
|
| 166 |
+
require.Equal(t, 2, ws.clients[cid].streams)
|
| 167 |
+
ws.clientsMu.Unlock()
|
| 168 |
+
|
| 169 |
+
b.DetachClient(ws.ID, cid)
|
| 170 |
+
ws.clientsMu.Lock()
|
| 171 |
+
require.Equal(t, 1, ws.clients[cid].streams)
|
| 172 |
+
ws.clientsMu.Unlock()
|
| 173 |
+
require.Equal(t, int32(0), shutdowns.Load())
|
| 174 |
+
|
| 175 |
+
b.DetachClient(ws.ID, cid)
|
| 176 |
+
require.Equal(t, int32(1), shutdowns.Load(), "second detach tears down the workspace")
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
func TestDetachClient_LastStreamTearsDown(t *testing.T) {
|
| 180 |
+
t.Parallel()
|
| 181 |
+
|
| 182 |
+
b, srvShutdowns := newTestBackend(t)
|
| 183 |
+
ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/a")
|
| 184 |
+
|
| 185 |
+
cid := newClientID(t)
|
| 186 |
+
b.registerClient(ws, cid)
|
| 187 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 188 |
+
b.DetachClient(ws.ID, cid)
|
| 189 |
+
|
| 190 |
+
require.Equal(t, int32(1), wsShutdowns.Load())
|
| 191 |
+
require.Equal(t, int32(1), srvShutdowns.Load(), "last workspace shut down must trigger server shutdown")
|
| 192 |
+
_, err := b.GetWorkspace(ws.ID)
|
| 193 |
+
require.ErrorIs(t, err, ErrWorkspaceNotFound)
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
func TestHoldExpiry_TearsDown(t *testing.T) {
|
| 197 |
+
t.Parallel()
|
| 198 |
+
|
| 199 |
+
b, srvShutdowns := newTestBackend(t)
|
| 200 |
+
ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/a")
|
| 201 |
+
|
| 202 |
+
cid := newClientID(t)
|
| 203 |
+
b.registerClient(ws, cid)
|
| 204 |
+
|
| 205 |
+
require.Eventually(t, func() bool {
|
| 206 |
+
return wsShutdowns.Load() == 1 && srvShutdowns.Load() == 1
|
| 207 |
+
}, 1*time.Second, 5*time.Millisecond)
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
func TestReleaseHold_NoStreams(t *testing.T) {
|
| 211 |
+
t.Parallel()
|
| 212 |
+
|
| 213 |
+
b, _ := newTestBackend(t)
|
| 214 |
+
ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
|
| 215 |
+
|
| 216 |
+
cid := newClientID(t)
|
| 217 |
+
b.registerClient(ws, cid)
|
| 218 |
+
require.NoError(t, b.releaseHold(ws.ID, cid))
|
| 219 |
+
|
| 220 |
+
require.Equal(t, int32(1), shutdowns.Load())
|
| 221 |
+
// Idempotent.
|
| 222 |
+
require.NoError(t, b.releaseHold(ws.ID, cid))
|
| 223 |
+
require.Equal(t, int32(1), shutdowns.Load())
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
func TestReleaseHold_WithActiveStream(t *testing.T) {
|
| 227 |
+
t.Parallel()
|
| 228 |
+
|
| 229 |
+
b, _ := newTestBackend(t)
|
| 230 |
+
ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
|
| 231 |
+
|
| 232 |
+
cid := newClientID(t)
|
| 233 |
+
b.registerClient(ws, cid)
|
| 234 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 235 |
+
require.NoError(t, b.releaseHold(ws.ID, cid))
|
| 236 |
+
|
| 237 |
+
ws.clientsMu.Lock()
|
| 238 |
+
require.Equal(t, 1, ws.clients[cid].streams)
|
| 239 |
+
require.Nil(t, ws.clients[cid].holdTimer)
|
| 240 |
+
ws.clientsMu.Unlock()
|
| 241 |
+
require.Equal(t, int32(0), shutdowns.Load())
|
| 242 |
+
|
| 243 |
+
b.DetachClient(ws.ID, cid)
|
| 244 |
+
require.Equal(t, int32(1), shutdowns.Load())
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
func TestReleaseHoldThenAttach(t *testing.T) {
|
| 248 |
+
t.Parallel()
|
| 249 |
+
|
| 250 |
+
b, _ := newTestBackend(t)
|
| 251 |
+
ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
|
| 252 |
+
|
| 253 |
+
cid := newClientID(t)
|
| 254 |
+
require.NoError(t, b.releaseHold(ws.ID, cid)) // no entry yet — no-op.
|
| 255 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 256 |
+
ws.clientsMu.Lock()
|
| 257 |
+
require.Equal(t, 1, ws.clients[cid].streams)
|
| 258 |
+
ws.clientsMu.Unlock()
|
| 259 |
+
require.NoError(t, b.releaseHold(ws.ID, cid)) // hold-only no-op (no hold timer).
|
| 260 |
+
require.Equal(t, int32(0), shutdowns.Load())
|
| 261 |
+
b.DetachClient(ws.ID, cid)
|
| 262 |
+
require.Equal(t, int32(1), shutdowns.Load())
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
func TestRefcountWithSecondClient(t *testing.T) {
|
| 266 |
+
t.Parallel()
|
| 267 |
+
|
| 268 |
+
b, _ := newTestBackend(t)
|
| 269 |
+
ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
|
| 270 |
+
|
| 271 |
+
cidA := newClientID(t)
|
| 272 |
+
cidB := newClientID(t)
|
| 273 |
+
b.registerClient(ws, cidA)
|
| 274 |
+
require.NoError(t, b.AttachClient(ws.ID, cidA))
|
| 275 |
+
b.registerClient(ws, cidB)
|
| 276 |
+
require.NoError(t, b.AttachClient(ws.ID, cidB))
|
| 277 |
+
|
| 278 |
+
b.DetachClient(ws.ID, cidA)
|
| 279 |
+
ws.clientsMu.Lock()
|
| 280 |
+
require.Contains(t, ws.clients, cidB)
|
| 281 |
+
require.NotContains(t, ws.clients, cidA)
|
| 282 |
+
ws.clientsMu.Unlock()
|
| 283 |
+
require.Equal(t, int32(0), shutdowns.Load(), "workspace survives while second client attached")
|
| 284 |
+
|
| 285 |
+
b.DetachClient(ws.ID, cidB)
|
| 286 |
+
require.Equal(t, int32(1), shutdowns.Load())
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
func TestAttachClient_InvalidID(t *testing.T) {
|
| 290 |
+
t.Parallel()
|
| 291 |
+
|
| 292 |
+
b, _ := newTestBackend(t)
|
| 293 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/a")
|
| 294 |
+
|
| 295 |
+
require.ErrorIs(t, b.AttachClient(ws.ID, ""), ErrInvalidClientID)
|
| 296 |
+
require.ErrorIs(t, b.AttachClient(ws.ID, "not-a-uuid"), ErrInvalidClientID)
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
func TestDeleteWorkspace_RejectsBadClientID(t *testing.T) {
|
| 300 |
+
t.Parallel()
|
| 301 |
+
|
| 302 |
+
b, _ := newTestBackend(t)
|
| 303 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/a")
|
| 304 |
+
|
| 305 |
+
require.ErrorIs(t, b.DeleteWorkspace(ws.ID, ""), ErrInvalidClientID)
|
| 306 |
+
require.ErrorIs(t, b.DeleteWorkspace(ws.ID, "not-a-uuid"), ErrInvalidClientID)
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
// TestHoldExpiry_RaceWithAttach checks that, when the grace timer fires
|
| 310 |
+
// while a concurrent AttachClient call is in flight, the workspace ends
|
| 311 |
+
// up either fully attached or fully torn down — never in a half-state.
|
| 312 |
+
func TestHoldExpiry_RaceWithAttach(t *testing.T) {
|
| 313 |
+
t.Parallel()
|
| 314 |
+
|
| 315 |
+
for i := range 50 {
|
| 316 |
+
b, _ := newTestBackend(t)
|
| 317 |
+
// Tighten the grace window further to force the race.
|
| 318 |
+
b.createGrace = 1 * time.Millisecond
|
| 319 |
+
ws, shutdowns := insertTestWorkspace(t, b, "/tmp/race")
|
| 320 |
+
|
| 321 |
+
cid := newClientID(t)
|
| 322 |
+
b.registerClient(ws, cid)
|
| 323 |
+
// Attach concurrently with the very short grace timer.
|
| 324 |
+
errCh := make(chan error, 1)
|
| 325 |
+
go func() { errCh <- b.AttachClient(ws.ID, cid) }()
|
| 326 |
+
<-errCh
|
| 327 |
+
|
| 328 |
+
// Wait for any pending timer to settle.
|
| 329 |
+
time.Sleep(10 * time.Millisecond)
|
| 330 |
+
|
| 331 |
+
ws.clientsMu.Lock()
|
| 332 |
+
gotShutdown := shutdowns.Load() == 1
|
| 333 |
+
cs, present := ws.clients[cid]
|
| 334 |
+
var (
|
| 335 |
+
gotStreams int
|
| 336 |
+
gotHoldTimer *time.Timer
|
| 337 |
+
)
|
| 338 |
+
if present {
|
| 339 |
+
gotStreams = cs.streams
|
| 340 |
+
gotHoldTimer = cs.holdTimer
|
| 341 |
+
}
|
| 342 |
+
ws.clientsMu.Unlock()
|
| 343 |
+
// Either the workspace was torn down OR the client is
|
| 344 |
+
// attached with streams==1 and the hold timer cleared.
|
| 345 |
+
// The state must be consistent: if shutdown, client is
|
| 346 |
+
// gone; if attached, no teardown and streams==1.
|
| 347 |
+
if gotShutdown {
|
| 348 |
+
require.False(t, present, "iter %d: shutdown but client still present", i)
|
| 349 |
+
} else {
|
| 350 |
+
require.True(t, present, "iter %d: not shutdown but client missing", i)
|
| 351 |
+
require.Equal(t, 1, gotStreams, "iter %d: attach winner must leave streams=1", i)
|
| 352 |
+
require.Nil(t, gotHoldTimer, "iter %d: attach winner must clear holdTimer", i)
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
// TestConcurrentAttachDetach exercises the state machine under
|
| 358 |
+
// parallel attach/detach pressure with the race detector.
|
| 359 |
+
func TestConcurrentAttachDetach(t *testing.T) {
|
| 360 |
+
t.Parallel()
|
| 361 |
+
|
| 362 |
+
b, _ := newTestBackend(t)
|
| 363 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/a")
|
| 364 |
+
|
| 365 |
+
cid := newClientID(t)
|
| 366 |
+
b.registerClient(ws, cid)
|
| 367 |
+
require.NoError(t, b.AttachClient(ws.ID, cid)) // ensure refcount stays > 0.
|
| 368 |
+
|
| 369 |
+
const n = 50
|
| 370 |
+
var wg sync.WaitGroup
|
| 371 |
+
wg.Add(n)
|
| 372 |
+
for range n {
|
| 373 |
+
go func() {
|
| 374 |
+
defer wg.Done()
|
| 375 |
+
cid2 := newClientID(t)
|
| 376 |
+
_ = b.AttachClient(ws.ID, cid2)
|
| 377 |
+
b.DetachClient(ws.ID, cid2)
|
| 378 |
+
}()
|
| 379 |
+
}
|
| 380 |
+
wg.Wait()
|
| 381 |
+
|
| 382 |
+
ws.clientsMu.Lock()
|
| 383 |
+
defer ws.clientsMu.Unlock()
|
| 384 |
+
require.Len(t, ws.clients, 1)
|
| 385 |
+
require.Contains(t, ws.clients, cid)
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
// TestPathDedupe_FullCreate exercises CreateWorkspace end-to-end
|
| 389 |
+
// (config init, real app.App). Two CreateWorkspace calls at the same
|
| 390 |
+
// path return the same workspace ID and share the clients map.
|
| 391 |
+
func TestPathDedupe_FullCreate(t *testing.T) {
|
| 392 |
+
t.Setenv("HOME", t.TempDir())
|
| 393 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 394 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 395 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 396 |
+
|
| 397 |
+
cwd := t.TempDir()
|
| 398 |
+
dataDir := t.TempDir()
|
| 399 |
+
|
| 400 |
+
b := New(context.Background(), nil, func() {})
|
| 401 |
+
b.SetCreateGrace(2 * time.Second)
|
| 402 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 403 |
+
|
| 404 |
+
cidA := uuid.New().String()
|
| 405 |
+
cidB := uuid.New().String()
|
| 406 |
+
|
| 407 |
+
wsA, protoA, err := b.CreateWorkspace(protoWS(cwd, dataDir, cidA))
|
| 408 |
+
require.NoError(t, err)
|
| 409 |
+
require.NotEmpty(t, protoA.ID)
|
| 410 |
+
require.Equal(t, protoA.DataDir, wsA.Cfg.Config().Options.DataDirectory)
|
| 411 |
+
|
| 412 |
+
wsB, protoB, err := b.CreateWorkspace(protoWS(cwd, dataDir, cidB))
|
| 413 |
+
require.NoError(t, err)
|
| 414 |
+
require.Equal(t, wsA.ID, wsB.ID, "second create at same path must return existing workspace")
|
| 415 |
+
require.Equal(t, protoA.ID, protoB.ID)
|
| 416 |
+
|
| 417 |
+
wsA.clientsMu.Lock()
|
| 418 |
+
require.Contains(t, wsA.clients, cidA)
|
| 419 |
+
require.Contains(t, wsA.clients, cidB)
|
| 420 |
+
wsA.clientsMu.Unlock()
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
// TestPathDedupe_DifferentPaths_DifferentWorkspaces confirms that two
|
| 424 |
+
// CreateWorkspace calls at distinct paths produce distinct workspaces.
|
| 425 |
+
func TestPathDedupe_DifferentPaths_DifferentWorkspaces(t *testing.T) {
|
| 426 |
+
t.Setenv("HOME", t.TempDir())
|
| 427 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 428 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 429 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 430 |
+
|
| 431 |
+
cwdA := t.TempDir()
|
| 432 |
+
cwdB := t.TempDir()
|
| 433 |
+
dataA := t.TempDir()
|
| 434 |
+
dataB := t.TempDir()
|
| 435 |
+
|
| 436 |
+
b := New(context.Background(), nil, func() {})
|
| 437 |
+
b.SetCreateGrace(2 * time.Second)
|
| 438 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 439 |
+
|
| 440 |
+
wsA, _, err := b.CreateWorkspace(protoWS(cwdA, dataA, uuid.New().String()))
|
| 441 |
+
require.NoError(t, err)
|
| 442 |
+
wsB, _, err := b.CreateWorkspace(protoWS(cwdB, dataB, uuid.New().String()))
|
| 443 |
+
require.NoError(t, err)
|
| 444 |
+
require.NotEqual(t, wsA.ID, wsB.ID)
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
// TestPathDedupe_FirstWinsKeepsOriginalEnv verifies that the second
|
| 448 |
+
// create at the same path returns the *originating* client's Env in
|
| 449 |
+
// its proto and does not mutate the existing workspace's YOLO/Debug
|
| 450 |
+
// flags.
|
| 451 |
+
func TestPathDedupe_FirstWinsKeepsOriginalEnv(t *testing.T) {
|
| 452 |
+
t.Setenv("HOME", t.TempDir())
|
| 453 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 454 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 455 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 456 |
+
|
| 457 |
+
cwd := t.TempDir()
|
| 458 |
+
dataDir := t.TempDir()
|
| 459 |
+
|
| 460 |
+
b := New(context.Background(), nil, func() {})
|
| 461 |
+
b.SetCreateGrace(2 * time.Second)
|
| 462 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 463 |
+
|
| 464 |
+
originalEnv := []string{"FOO=bar"}
|
| 465 |
+
argsA := protoWS(cwd, dataDir, uuid.New().String())
|
| 466 |
+
argsA.YOLO = true
|
| 467 |
+
argsA.Env = originalEnv
|
| 468 |
+
wsA, protoA, err := b.CreateWorkspace(argsA)
|
| 469 |
+
require.NoError(t, err)
|
| 470 |
+
require.True(t, protoA.YOLO)
|
| 471 |
+
require.Equal(t, originalEnv, protoA.Env)
|
| 472 |
+
|
| 473 |
+
argsB := protoWS(cwd, dataDir, uuid.New().String())
|
| 474 |
+
argsB.YOLO = false
|
| 475 |
+
argsB.Debug = true
|
| 476 |
+
argsB.Env = []string{"BAZ=qux"}
|
| 477 |
+
_, protoB, err := b.CreateWorkspace(argsB)
|
| 478 |
+
require.NoError(t, err)
|
| 479 |
+
require.Equal(t, protoA.ID, protoB.ID)
|
| 480 |
+
require.True(t, protoB.YOLO, "first wins: YOLO must remain true")
|
| 481 |
+
require.Equal(t, originalEnv, protoB.Env, "proto must carry the originating client's Env")
|
| 482 |
+
require.Equal(t, wsA.Cfg.Overrides().SkipPermissionRequests, true)
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
// TestPathDedupe_Symlink confirms two paths that resolve to the same
|
| 486 |
+
// target share a workspace.
|
| 487 |
+
func TestPathDedupe_Symlink(t *testing.T) {
|
| 488 |
+
if runtime.GOOS == "windows" {
|
| 489 |
+
t.Skip("symlink semantics differ on windows")
|
| 490 |
+
}
|
| 491 |
+
t.Setenv("HOME", t.TempDir())
|
| 492 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 493 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 494 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 495 |
+
|
| 496 |
+
real := t.TempDir()
|
| 497 |
+
link := filepath.Join(t.TempDir(), "link")
|
| 498 |
+
require.NoError(t, os.Symlink(real, link))
|
| 499 |
+
dataDir := t.TempDir()
|
| 500 |
+
|
| 501 |
+
b := New(context.Background(), nil, func() {})
|
| 502 |
+
b.SetCreateGrace(2 * time.Second)
|
| 503 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 504 |
+
|
| 505 |
+
wsA, _, err := b.CreateWorkspace(protoWS(real, dataDir, uuid.New().String()))
|
| 506 |
+
require.NoError(t, err)
|
| 507 |
+
wsB, _, err := b.CreateWorkspace(protoWS(link, dataDir, uuid.New().String()))
|
| 508 |
+
require.NoError(t, err)
|
| 509 |
+
require.Equal(t, wsA.ID, wsB.ID)
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
// TestPathDedupe_NonExistentPath ensures CreateWorkspace tolerates a
|
| 513 |
+
// path that does not yet exist (EvalSymlinks falls back to Abs).
|
| 514 |
+
func TestPathDedupe_NonExistentPath(t *testing.T) {
|
| 515 |
+
t.Setenv("HOME", t.TempDir())
|
| 516 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 517 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 518 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 519 |
+
|
| 520 |
+
parent := t.TempDir()
|
| 521 |
+
missing := filepath.Join(parent, "does-not-exist")
|
| 522 |
+
dataDir := t.TempDir()
|
| 523 |
+
|
| 524 |
+
b := New(context.Background(), nil, func() {})
|
| 525 |
+
b.SetCreateGrace(2 * time.Second)
|
| 526 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 527 |
+
|
| 528 |
+
_, p, err := b.CreateWorkspace(protoWS(missing, dataDir, uuid.New().String()))
|
| 529 |
+
require.NoError(t, err)
|
| 530 |
+
require.NotEmpty(t, p.ID)
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
// TestCreateWorkspace_IdempotentSameClient checks that a duplicate
|
| 534 |
+
// create from the same client at the same path does not produce a
|
| 535 |
+
// second claim.
|
| 536 |
+
func TestCreateWorkspace_IdempotentSameClient(t *testing.T) {
|
| 537 |
+
t.Setenv("HOME", t.TempDir())
|
| 538 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 539 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 540 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 541 |
+
|
| 542 |
+
cwd := t.TempDir()
|
| 543 |
+
dataDir := t.TempDir()
|
| 544 |
+
b := New(context.Background(), nil, func() {})
|
| 545 |
+
b.SetCreateGrace(2 * time.Second)
|
| 546 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 547 |
+
|
| 548 |
+
cid := uuid.New().String()
|
| 549 |
+
ws1, _, err := b.CreateWorkspace(protoWS(cwd, dataDir, cid))
|
| 550 |
+
require.NoError(t, err)
|
| 551 |
+
ws2, _, err := b.CreateWorkspace(protoWS(cwd, dataDir, cid))
|
| 552 |
+
require.NoError(t, err)
|
| 553 |
+
require.Equal(t, ws1.ID, ws2.ID)
|
| 554 |
+
|
| 555 |
+
ws1.clientsMu.Lock()
|
| 556 |
+
require.Len(t, ws1.clients, 1, "duplicate create from same client must not double the claim")
|
| 557 |
+
ws1.clientsMu.Unlock()
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
// TestPathDedupe_ParallelCreates ensures two simultaneous CreateWorkspace
|
| 561 |
+
// calls at the same path produce the same workspace and the clients map
|
| 562 |
+
// contains both client IDs.
|
| 563 |
+
func TestPathDedupe_ParallelCreates(t *testing.T) {
|
| 564 |
+
t.Setenv("HOME", t.TempDir())
|
| 565 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 566 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 567 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 568 |
+
|
| 569 |
+
cwd := t.TempDir()
|
| 570 |
+
dataDir := t.TempDir()
|
| 571 |
+
|
| 572 |
+
b := New(context.Background(), nil, func() {})
|
| 573 |
+
b.SetCreateGrace(2 * time.Second)
|
| 574 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 575 |
+
|
| 576 |
+
cidA := uuid.New().String()
|
| 577 |
+
cidB := uuid.New().String()
|
| 578 |
+
|
| 579 |
+
type result struct {
|
| 580 |
+
ws *Workspace
|
| 581 |
+
proto proto.Workspace
|
| 582 |
+
err error
|
| 583 |
+
}
|
| 584 |
+
ch := make(chan result, 2)
|
| 585 |
+
start := make(chan struct{})
|
| 586 |
+
go func() {
|
| 587 |
+
<-start
|
| 588 |
+
ws, p, err := b.CreateWorkspace(protoWS(cwd, dataDir, cidA))
|
| 589 |
+
ch <- result{ws, p, err}
|
| 590 |
+
}()
|
| 591 |
+
go func() {
|
| 592 |
+
<-start
|
| 593 |
+
ws, p, err := b.CreateWorkspace(protoWS(cwd, dataDir, cidB))
|
| 594 |
+
ch <- result{ws, p, err}
|
| 595 |
+
}()
|
| 596 |
+
close(start)
|
| 597 |
+
r1 := <-ch
|
| 598 |
+
r2 := <-ch
|
| 599 |
+
require.NoError(t, r1.err)
|
| 600 |
+
require.NoError(t, r2.err)
|
| 601 |
+
require.Equal(t, r1.ws.ID, r2.ws.ID, "both creates must converge on one workspace ID")
|
| 602 |
+
|
| 603 |
+
ws := r1.ws
|
| 604 |
+
ws.clientsMu.Lock()
|
| 605 |
+
defer ws.clientsMu.Unlock()
|
| 606 |
+
require.Contains(t, ws.clients, cidA)
|
| 607 |
+
require.Contains(t, ws.clients, cidB)
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
// TestCreateWorkspace_RejectsBadClientID covers the 400 path from the
|
| 611 |
+
// backend side.
|
| 612 |
+
func TestCreateWorkspace_RejectsBadClientID(t *testing.T) {
|
| 613 |
+
t.Parallel()
|
| 614 |
+
|
| 615 |
+
b := New(context.Background(), nil, func() {})
|
| 616 |
+
|
| 617 |
+
_, _, err := b.CreateWorkspace(protoWS("/tmp/x", t.TempDir(), ""))
|
| 618 |
+
require.ErrorIs(t, err, ErrInvalidClientID)
|
| 619 |
+
_, _, err = b.CreateWorkspace(protoWS("/tmp/x", t.TempDir(), "not-a-uuid"))
|
| 620 |
+
require.ErrorIs(t, err, ErrInvalidClientID)
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
// drainBackend tears the backend down at the end of a test by deleting
|
| 624 |
+
// every remaining workspace. Necessary so the test process doesn't
|
| 625 |
+
// leak goroutines or DB handles from the embedded [app.App] instances.
|
| 626 |
+
func drainBackend(t *testing.T, b *Backend) {
|
| 627 |
+
t.Helper()
|
| 628 |
+
for _, ws := range b.workspaces.Seq2() {
|
| 629 |
+
ws.clientsMu.Lock()
|
| 630 |
+
ids := make([]string, 0, len(ws.clients))
|
| 631 |
+
for id := range ws.clients {
|
| 632 |
+
ids = append(ids, id)
|
| 633 |
+
}
|
| 634 |
+
ws.clientsMu.Unlock()
|
| 635 |
+
for _, cid := range ids {
|
| 636 |
+
_ = b.releaseHold(ws.ID, cid)
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
func protoWS(path, dataDir, clientID string) proto.Workspace {
|
| 642 |
+
return proto.Workspace{Path: path, DataDir: dataDir, ClientID: clientID}
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
// syncBuffer is a thread-safe buffer that can be safely read and written
|
| 646 |
+
// from multiple goroutines.
|
| 647 |
+
type syncBuffer struct {
|
| 648 |
+
mu sync.Mutex
|
| 649 |
+
buf bytes.Buffer
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
func (sb *syncBuffer) Write(p []byte) (n int, err error) {
|
| 653 |
+
sb.mu.Lock()
|
| 654 |
+
defer sb.mu.Unlock()
|
| 655 |
+
return sb.buf.Write(p)
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
func (sb *syncBuffer) String() string {
|
| 659 |
+
sb.mu.Lock()
|
| 660 |
+
defer sb.mu.Unlock()
|
| 661 |
+
return sb.buf.String()
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
// captureDebugLogs installs a buffer-backed slog handler at Debug
|
| 665 |
+
// level for the duration of the test, returning the buffer. The
|
| 666 |
+
// previous default handler is restored via t.Cleanup.
|
| 667 |
+
func captureDebugLogs(t *testing.T) *syncBuffer {
|
| 668 |
+
t.Helper()
|
| 669 |
+
var sb syncBuffer
|
| 670 |
+
prev := slog.Default()
|
| 671 |
+
handler := slog.NewTextHandler(&sb, &slog.HandlerOptions{Level: slog.LevelDebug})
|
| 672 |
+
slog.SetDefault(slog.New(handler))
|
| 673 |
+
t.Cleanup(func() { slog.SetDefault(prev) })
|
| 674 |
+
return &sb
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
// xdgIsolated points HOME and XDG_* variables at fresh tempdirs so
|
| 678 |
+
// CreateWorkspace's config loading does not interfere with the host
|
| 679 |
+
// machine's real config.
|
| 680 |
+
func xdgIsolated(t *testing.T) {
|
| 681 |
+
t.Helper()
|
| 682 |
+
t.Setenv("HOME", t.TempDir())
|
| 683 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 684 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 685 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
// TestFirstWinsMismatch_LogsOnFlagDifferences verifies that the
|
| 689 |
+
// debug mismatch line is emitted when any of YOLO, Debug, DataDir,
|
| 690 |
+
// or Env differs between the first and second CreateWorkspace at
|
| 691 |
+
// the same path, and that the existing workspace's Debug flag is
|
| 692 |
+
// not overwritten.
|
| 693 |
+
func TestFirstWinsMismatch_LogsOnFlagDifferences(t *testing.T) {
|
| 694 |
+
tests := []struct {
|
| 695 |
+
name string
|
| 696 |
+
mutate func(*proto.Workspace)
|
| 697 |
+
}{
|
| 698 |
+
{
|
| 699 |
+
name: "yolo",
|
| 700 |
+
mutate: func(p *proto.Workspace) { p.YOLO = true },
|
| 701 |
+
},
|
| 702 |
+
{
|
| 703 |
+
name: "debug",
|
| 704 |
+
mutate: func(p *proto.Workspace) { p.Debug = true },
|
| 705 |
+
},
|
| 706 |
+
{
|
| 707 |
+
name: "datadir",
|
| 708 |
+
mutate: func(p *proto.Workspace) { p.DataDir = "" },
|
| 709 |
+
},
|
| 710 |
+
{
|
| 711 |
+
name: "env",
|
| 712 |
+
mutate: func(p *proto.Workspace) { p.Env = []string{"NEW=val"} },
|
| 713 |
+
},
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
for _, tc := range tests {
|
| 717 |
+
t.Run(tc.name, func(t *testing.T) {
|
| 718 |
+
xdgIsolated(t)
|
| 719 |
+
cwd := t.TempDir()
|
| 720 |
+
dataDir := t.TempDir()
|
| 721 |
+
|
| 722 |
+
buf := captureDebugLogs(t)
|
| 723 |
+
b := New(context.Background(), nil, func() {})
|
| 724 |
+
b.SetCreateGrace(2 * time.Second)
|
| 725 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 726 |
+
|
| 727 |
+
argsA := protoWS(cwd, dataDir, uuid.New().String())
|
| 728 |
+
argsA.Env = []string{"FOO=bar"}
|
| 729 |
+
wsA, _, err := b.CreateWorkspace(argsA)
|
| 730 |
+
require.NoError(t, err)
|
| 731 |
+
originalDebug := wsA.Cfg.Config().Options.Debug
|
| 732 |
+
originalYOLO := wsA.Cfg.Overrides().SkipPermissionRequests
|
| 733 |
+
|
| 734 |
+
argsB := protoWS(cwd, dataDir, uuid.New().String())
|
| 735 |
+
argsB.Env = []string{"FOO=bar"} // identical by default
|
| 736 |
+
tc.mutate(&argsB)
|
| 737 |
+
_, _, err = b.CreateWorkspace(argsB)
|
| 738 |
+
require.NoError(t, err)
|
| 739 |
+
|
| 740 |
+
require.Contains(
|
| 741 |
+
t, buf.String(),
|
| 742 |
+
"Workspace flag mismatch on duplicate create",
|
| 743 |
+
"expected debug log for mismatching %s", tc.name,
|
| 744 |
+
)
|
| 745 |
+
// Existing workspace's YOLO and Debug must not change.
|
| 746 |
+
require.Equal(t, originalYOLO, wsA.Cfg.Overrides().SkipPermissionRequests, "YOLO must be immutable on first-wins")
|
| 747 |
+
require.Equal(t, originalDebug, wsA.Cfg.Config().Options.Debug, "Debug must be immutable on first-wins")
|
| 748 |
+
})
|
| 749 |
+
}
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
// TestFirstWinsMismatch_NoLogWhenIdentical confirms identical args
|
| 753 |
+
// do not emit the mismatch log line.
|
| 754 |
+
func TestFirstWinsMismatch_NoLogWhenIdentical(t *testing.T) {
|
| 755 |
+
xdgIsolated(t)
|
| 756 |
+
cwd := t.TempDir()
|
| 757 |
+
dataDir := t.TempDir()
|
| 758 |
+
|
| 759 |
+
buf := captureDebugLogs(t)
|
| 760 |
+
b := New(context.Background(), nil, func() {})
|
| 761 |
+
b.SetCreateGrace(2 * time.Second)
|
| 762 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 763 |
+
|
| 764 |
+
argsA := protoWS(cwd, dataDir, uuid.New().String())
|
| 765 |
+
argsA.Env = []string{"FOO=bar"}
|
| 766 |
+
_, _, err := b.CreateWorkspace(argsA)
|
| 767 |
+
require.NoError(t, err)
|
| 768 |
+
|
| 769 |
+
argsB := protoWS(cwd, dataDir, uuid.New().String())
|
| 770 |
+
argsB.Env = []string{"FOO=bar"}
|
| 771 |
+
_, _, err = b.CreateWorkspace(argsB)
|
| 772 |
+
require.NoError(t, err)
|
| 773 |
+
|
| 774 |
+
require.False(t,
|
| 775 |
+
strings.Contains(buf.String(), "Workspace flag mismatch on duplicate create"),
|
| 776 |
+
"identical args must not log a mismatch: %s", buf.String())
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
// TestChannelOptInBoundary_DuplicateCreate verifies that channels are
|
| 780 |
+
// an explicit opt-in that is never shared across a duplicate create at
|
| 781 |
+
// the same path. A second client whose requested channels differ from
|
| 782 |
+
// the existing workspace (including a client that did not opt in at
|
| 783 |
+
// all) is rejected rather than silently inheriting the existing
|
| 784 |
+
// workspace's channels.
|
| 785 |
+
func TestChannelOptInBoundary_DuplicateCreate(t *testing.T) {
|
| 786 |
+
tests := []struct {
|
| 787 |
+
name string
|
| 788 |
+
firstChannels []string
|
| 789 |
+
secondChannels []string
|
| 790 |
+
wantMismatchErr bool
|
| 791 |
+
}{
|
| 792 |
+
{
|
| 793 |
+
name: "second omits opt-in",
|
| 794 |
+
firstChannels: []string{"server:webhook"},
|
| 795 |
+
secondChannels: nil,
|
| 796 |
+
wantMismatchErr: true,
|
| 797 |
+
},
|
| 798 |
+
{
|
| 799 |
+
name: "second opts into extra channel",
|
| 800 |
+
firstChannels: nil,
|
| 801 |
+
secondChannels: []string{"server:webhook"},
|
| 802 |
+
wantMismatchErr: true,
|
| 803 |
+
},
|
| 804 |
+
{
|
| 805 |
+
name: "different channels",
|
| 806 |
+
firstChannels: []string{"server:webhook"},
|
| 807 |
+
secondChannels: []string{"server:events"},
|
| 808 |
+
wantMismatchErr: true,
|
| 809 |
+
},
|
| 810 |
+
{
|
| 811 |
+
name: "identical channels shared",
|
| 812 |
+
firstChannels: []string{"server:webhook"},
|
| 813 |
+
secondChannels: []string{"server:webhook"},
|
| 814 |
+
wantMismatchErr: false,
|
| 815 |
+
},
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
for _, tc := range tests {
|
| 819 |
+
t.Run(tc.name, func(t *testing.T) {
|
| 820 |
+
xdgIsolated(t)
|
| 821 |
+
cwd := t.TempDir()
|
| 822 |
+
dataDir := t.TempDir()
|
| 823 |
+
|
| 824 |
+
b := New(context.Background(), nil, func() {})
|
| 825 |
+
b.SetCreateGrace(2 * time.Second)
|
| 826 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 827 |
+
|
| 828 |
+
argsA := protoWS(cwd, dataDir, uuid.New().String())
|
| 829 |
+
argsA.Channels = tc.firstChannels
|
| 830 |
+
wsA, _, err := b.CreateWorkspace(argsA)
|
| 831 |
+
require.NoError(t, err)
|
| 832 |
+
|
| 833 |
+
argsB := protoWS(cwd, dataDir, uuid.New().String())
|
| 834 |
+
argsB.Channels = tc.secondChannels
|
| 835 |
+
wsB, protoB, err := b.CreateWorkspace(argsB)
|
| 836 |
+
|
| 837 |
+
if tc.wantMismatchErr {
|
| 838 |
+
require.ErrorIs(t, err, ErrChannelOptInMismatch)
|
| 839 |
+
require.Nil(t, wsB)
|
| 840 |
+
// The existing workspace must not be mutated
|
| 841 |
+
// and must not register the rejected client.
|
| 842 |
+
require.Equal(t, tc.firstChannels, wsA.Cfg.Overrides().EnabledChannels,
|
| 843 |
+
"existing channels must be immutable on rejection")
|
| 844 |
+
wsA.clientsMu.Lock()
|
| 845 |
+
require.Len(t, wsA.clients, 1, "rejected client must not be registered")
|
| 846 |
+
wsA.clientsMu.Unlock()
|
| 847 |
+
return
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
require.NoError(t, err)
|
| 851 |
+
require.Equal(t, wsA.ID, protoB.ID)
|
| 852 |
+
require.Equal(t, tc.firstChannels, protoB.Channels)
|
| 853 |
+
})
|
| 854 |
+
}
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
// TestRaceTwoClientsAttachOneDetaches exercises the PLAN-required
|
| 858 |
+
// race scenario: two clients attach concurrently, then one detaches.
|
| 859 |
+
// The workspace must remain alive with refcount==1 and the clients
|
| 860 |
+
// map must reflect the remaining client only.
|
| 861 |
+
func TestRaceTwoClientsAttachOneDetaches(t *testing.T) {
|
| 862 |
+
t.Parallel()
|
| 863 |
+
|
| 864 |
+
b, _ := newTestBackend(t)
|
| 865 |
+
ws, shutdowns := insertTestWorkspace(t, b, "/tmp/race-two")
|
| 866 |
+
|
| 867 |
+
cidA := newClientID(t)
|
| 868 |
+
cidB := newClientID(t)
|
| 869 |
+
|
| 870 |
+
var wg sync.WaitGroup
|
| 871 |
+
wg.Add(2)
|
| 872 |
+
go func() {
|
| 873 |
+
defer wg.Done()
|
| 874 |
+
require.NoError(t, b.AttachClient(ws.ID, cidA))
|
| 875 |
+
}()
|
| 876 |
+
go func() {
|
| 877 |
+
defer wg.Done()
|
| 878 |
+
require.NoError(t, b.AttachClient(ws.ID, cidB))
|
| 879 |
+
}()
|
| 880 |
+
wg.Wait()
|
| 881 |
+
|
| 882 |
+
ws.clientsMu.Lock()
|
| 883 |
+
require.Len(t, ws.clients, 2, "both clients must be attached")
|
| 884 |
+
ws.clientsMu.Unlock()
|
| 885 |
+
|
| 886 |
+
b.DetachClient(ws.ID, cidA)
|
| 887 |
+
|
| 888 |
+
ws.clientsMu.Lock()
|
| 889 |
+
require.Len(t, ws.clients, 1, "refcount must be 1 after one detach")
|
| 890 |
+
require.Contains(t, ws.clients, cidB, "remaining client must be cidB")
|
| 891 |
+
require.NotContains(t, ws.clients, cidA, "detached client must be removed")
|
| 892 |
+
ws.clientsMu.Unlock()
|
| 893 |
+
require.Equal(t, int32(0), shutdowns.Load(), "workspace must remain alive")
|
| 894 |
+
|
| 895 |
+
// Drain.
|
| 896 |
+
b.DetachClient(ws.ID, cidB)
|
| 897 |
+
require.Equal(t, int32(1), shutdowns.Load())
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
// TestExplicitDeleteThenAttach reproduces the PLAN scenario: start
|
| 901 |
+
// with a real hold, releaseHold consumes it, AttachClient from the
|
| 902 |
+
// same clientID creates a fresh entry with streams==1, and calling
|
| 903 |
+
// releaseHold again is a no-op. A second client keeps the workspace
|
| 904 |
+
// alive so AttachClient can still resolve the workspace ID after the
|
| 905 |
+
// first client's hold is released.
|
| 906 |
+
func TestExplicitDeleteThenAttach(t *testing.T) {
|
| 907 |
+
t.Parallel()
|
| 908 |
+
|
| 909 |
+
// Large grace window so timers cannot fire during the test
|
| 910 |
+
// — we want to exercise the explicit releaseHold path.
|
| 911 |
+
b, _ := newTestBackend(t)
|
| 912 |
+
b.createGrace = time.Hour
|
| 913 |
+
ws, shutdowns := insertTestWorkspace(t, b, "/tmp/delete-then-attach")
|
| 914 |
+
|
| 915 |
+
// Anchor client keeps the workspace registered in
|
| 916 |
+
// b.workspaces across the cid's releaseHold below.
|
| 917 |
+
anchor := newClientID(t)
|
| 918 |
+
require.NoError(t, b.AttachClient(ws.ID, anchor))
|
| 919 |
+
|
| 920 |
+
cid := newClientID(t)
|
| 921 |
+
// Real hold via registerClient (mirrors CreateWorkspace).
|
| 922 |
+
b.registerClient(ws, cid)
|
| 923 |
+
ws.clientsMu.Lock()
|
| 924 |
+
require.Contains(t, ws.clients, cid)
|
| 925 |
+
require.NotNil(t, ws.clients[cid].holdTimer, "hold must be live")
|
| 926 |
+
require.Equal(t, 0, ws.clients[cid].streams)
|
| 927 |
+
ws.clientsMu.Unlock()
|
| 928 |
+
|
| 929 |
+
// releaseHold: consumes the hold and removes the entry
|
| 930 |
+
// (streams == 0). The anchor client keeps the workspace
|
| 931 |
+
// alive.
|
| 932 |
+
require.NoError(t, b.releaseHold(ws.ID, cid))
|
| 933 |
+
require.Equal(t, int32(0), shutdowns.Load(), "anchor must keep workspace alive")
|
| 934 |
+
ws.clientsMu.Lock()
|
| 935 |
+
require.NotContains(t, ws.clients, cid, "entry must be removed by releaseHold")
|
| 936 |
+
ws.clientsMu.Unlock()
|
| 937 |
+
|
| 938 |
+
// AttachClient creates a fresh entry with streams==1 and no
|
| 939 |
+
// hold timer.
|
| 940 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 941 |
+
ws.clientsMu.Lock()
|
| 942 |
+
require.Contains(t, ws.clients, cid, "fresh entry must be created")
|
| 943 |
+
require.Equal(t, 1, ws.clients[cid].streams, "fresh attach must start at streams=1")
|
| 944 |
+
require.Nil(t, ws.clients[cid].holdTimer, "fresh attach must have no hold timer")
|
| 945 |
+
ws.clientsMu.Unlock()
|
| 946 |
+
|
| 947 |
+
// Calling releaseHold again is a no-op (no hold timer to
|
| 948 |
+
// stop, streams > 0 so the entry stays).
|
| 949 |
+
require.NoError(t, b.releaseHold(ws.ID, cid))
|
| 950 |
+
ws.clientsMu.Lock()
|
| 951 |
+
require.Contains(t, ws.clients, cid, "releaseHold must not touch a stream-only entry")
|
| 952 |
+
require.Equal(t, 1, ws.clients[cid].streams)
|
| 953 |
+
require.Nil(t, ws.clients[cid].holdTimer)
|
| 954 |
+
ws.clientsMu.Unlock()
|
| 955 |
+
|
| 956 |
+
// Drain.
|
| 957 |
+
b.DetachClient(ws.ID, cid)
|
| 958 |
+
b.DetachClient(ws.ID, anchor)
|
| 959 |
+
require.Equal(t, int32(1), shutdowns.Load())
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
// TestAttachClient_RacesWithTeardown forces AttachClient to compete
|
| 963 |
+
// with the teardown path triggered by DetachClient. Before the fix,
|
| 964 |
+
// AttachClient could observe a workspace after teardown had already
|
| 965 |
+
// decided to remove it (because AttachClient did not synchronize with
|
| 966 |
+
// Backend.mu), leaving a live stream claim attached to a workspace
|
| 967 |
+
// that was then removed and shut down. With the fix, the outcome must
|
| 968 |
+
// be deterministic: either AttachClient won and the workspace is
|
| 969 |
+
// alive with the client registered, or teardown won and AttachClient
|
| 970 |
+
// returns ErrWorkspaceNotFound — never a half-state where the
|
| 971 |
+
// workspace is gone but ws.clients still contains the new client.
|
| 972 |
+
func TestAttachClient_RacesWithTeardown(t *testing.T) {
|
| 973 |
+
t.Parallel()
|
| 974 |
+
|
| 975 |
+
for i := range 200 {
|
| 976 |
+
b, _ := newTestBackend(t)
|
| 977 |
+
// Keep the grace window long so it can't fire during the
|
| 978 |
+
// test and confuse the bookkeeping.
|
| 979 |
+
b.createGrace = time.Hour
|
| 980 |
+
ws, shutdowns := insertTestWorkspace(t, b, "/tmp/race-teardown")
|
| 981 |
+
|
| 982 |
+
// Seed: cidA holds the workspace open via a stream. The
|
| 983 |
+
// imminent DetachClient(cidA) will be the *only* claim
|
| 984 |
+
// drop, so teardown will run.
|
| 985 |
+
cidA := newClientID(t)
|
| 986 |
+
require.NoError(t, b.AttachClient(ws.ID, cidA))
|
| 987 |
+
|
| 988 |
+
// cidB attempts to attach concurrently with the detach
|
| 989 |
+
// that will tear the workspace down.
|
| 990 |
+
cidB := newClientID(t)
|
| 991 |
+
start := make(chan struct{})
|
| 992 |
+
errCh := make(chan error, 1)
|
| 993 |
+
detachDone := make(chan struct{})
|
| 994 |
+
go func() {
|
| 995 |
+
<-start
|
| 996 |
+
errCh <- b.AttachClient(ws.ID, cidB)
|
| 997 |
+
}()
|
| 998 |
+
go func() {
|
| 999 |
+
<-start
|
| 1000 |
+
b.DetachClient(ws.ID, cidA)
|
| 1001 |
+
close(detachDone)
|
| 1002 |
+
}()
|
| 1003 |
+
close(start)
|
| 1004 |
+
|
| 1005 |
+
// Wait for both goroutines so teardown (including
|
| 1006 |
+
// shutdownFn) has fully run before we read state.
|
| 1007 |
+
attachErr := <-errCh
|
| 1008 |
+
<-detachDone
|
| 1009 |
+
|
| 1010 |
+
_, wsStillRegistered := b.workspaces.Get(ws.ID)
|
| 1011 |
+
ws.clientsMu.Lock()
|
| 1012 |
+
_, hasA := ws.clients[cidA]
|
| 1013 |
+
_, hasB := ws.clients[cidB]
|
| 1014 |
+
clientCount := len(ws.clients)
|
| 1015 |
+
ws.clientsMu.Unlock()
|
| 1016 |
+
shutdownCount := shutdowns.Load()
|
| 1017 |
+
|
| 1018 |
+
switch {
|
| 1019 |
+
case attachErr == nil:
|
| 1020 |
+
// AttachClient won. The workspace must be alive
|
| 1021 |
+
// (registered) with cidB in its clients map. cidA
|
| 1022 |
+
// may or may not still be there depending on who
|
| 1023 |
+
// took clientsMu first, but the workspace must
|
| 1024 |
+
// not have been torn down.
|
| 1025 |
+
require.True(t, wsStillRegistered,
|
| 1026 |
+
"iter %d: attach succeeded but workspace was removed", i)
|
| 1027 |
+
require.True(t, hasB,
|
| 1028 |
+
"iter %d: attach succeeded but cidB missing from clients", i)
|
| 1029 |
+
require.Equal(t, int32(0), shutdownCount,
|
| 1030 |
+
"iter %d: attach succeeded but workspace was shut down", i)
|
| 1031 |
+
case errors.Is(attachErr, ErrWorkspaceNotFound):
|
| 1032 |
+
// Teardown won. The workspace must be removed,
|
| 1033 |
+
// shut down exactly once, and ws.clients must be
|
| 1034 |
+
// empty (no half-state with cidB inserted into a
|
| 1035 |
+
// dead workspace's clients map).
|
| 1036 |
+
require.False(t, wsStillRegistered,
|
| 1037 |
+
"iter %d: ErrWorkspaceNotFound but workspace still registered", i)
|
| 1038 |
+
require.Equal(t, int32(1), shutdownCount,
|
| 1039 |
+
"iter %d: ErrWorkspaceNotFound but shutdown count = %d", i, shutdownCount)
|
| 1040 |
+
require.False(t, hasA,
|
| 1041 |
+
"iter %d: teardown won but cidA still in clients", i)
|
| 1042 |
+
require.False(t, hasB,
|
| 1043 |
+
"iter %d: teardown won but cidB still in clients (would be the leaked attach)", i)
|
| 1044 |
+
require.Zero(t, clientCount,
|
| 1045 |
+
"iter %d: teardown won but clients map is non-empty", i)
|
| 1046 |
+
default:
|
| 1047 |
+
t.Fatalf("iter %d: unexpected AttachClient error: %v", i, attachErr)
|
| 1048 |
+
}
|
| 1049 |
+
}
|
| 1050 |
+
}
|
| 1051 |
+
|
| 1052 |
+
// TestSetCurrentSession_BasicAttachAndSwitch verifies the happy path:
|
| 1053 |
+
// an attached client can set its current session, a second attached
|
| 1054 |
+
// client can target the same session, and one of them can switch to a
|
| 1055 |
+
// different session without disturbing the other's record.
|
| 1056 |
+
func TestSetCurrentSession_BasicAttachAndSwitch(t *testing.T) {
|
| 1057 |
+
t.Parallel()
|
| 1058 |
+
|
| 1059 |
+
b, _ := newTestBackend(t)
|
| 1060 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-basic")
|
| 1061 |
+
|
| 1062 |
+
cidA := newClientID(t)
|
| 1063 |
+
cidB := newClientID(t)
|
| 1064 |
+
require.NoError(t, b.AttachClient(ws.ID, cidA))
|
| 1065 |
+
require.NoError(t, b.AttachClient(ws.ID, cidB))
|
| 1066 |
+
|
| 1067 |
+
require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1"))
|
| 1068 |
+
ws.clientsMu.Lock()
|
| 1069 |
+
require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
|
| 1070 |
+
ws.clientsMu.Unlock()
|
| 1071 |
+
|
| 1072 |
+
require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1"))
|
| 1073 |
+
ws.clientsMu.Lock()
|
| 1074 |
+
require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
|
| 1075 |
+
require.Equal(t, "S1", ws.clients[cidB].currentSessionID)
|
| 1076 |
+
ws.clientsMu.Unlock()
|
| 1077 |
+
|
| 1078 |
+
// B switches to S2; counts redistribute.
|
| 1079 |
+
require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2"))
|
| 1080 |
+
ws.clientsMu.Lock()
|
| 1081 |
+
require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
|
| 1082 |
+
require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
|
| 1083 |
+
ws.clientsMu.Unlock()
|
| 1084 |
+
|
| 1085 |
+
// A clears its selection.
|
| 1086 |
+
require.NoError(t, b.SetCurrentSession(ws.ID, cidA, ""))
|
| 1087 |
+
ws.clientsMu.Lock()
|
| 1088 |
+
require.Empty(t, ws.clients[cidA].currentSessionID)
|
| 1089 |
+
require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
|
| 1090 |
+
ws.clientsMu.Unlock()
|
| 1091 |
+
|
| 1092 |
+
// Drain to release the workspace.
|
| 1093 |
+
b.DetachClient(ws.ID, cidA)
|
| 1094 |
+
b.DetachClient(ws.ID, cidB)
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
// TestSetCurrentSession_DetachClearsEntry verifies the implicit
|
| 1098 |
+
// cleanup: once a client's [clientState] entry is removed (last
|
| 1099 |
+
// stream closed), its currentSessionID is gone with it.
|
| 1100 |
+
func TestSetCurrentSession_DetachClearsEntry(t *testing.T) {
|
| 1101 |
+
t.Parallel()
|
| 1102 |
+
|
| 1103 |
+
b, _ := newTestBackend(t)
|
| 1104 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-detach")
|
| 1105 |
+
|
| 1106 |
+
// Anchor client so the workspace is not torn down when cid
|
| 1107 |
+
// detaches.
|
| 1108 |
+
anchor := newClientID(t)
|
| 1109 |
+
require.NoError(t, b.AttachClient(ws.ID, anchor))
|
| 1110 |
+
|
| 1111 |
+
cid := newClientID(t)
|
| 1112 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 1113 |
+
require.NoError(t, b.SetCurrentSession(ws.ID, cid, "S2"))
|
| 1114 |
+
|
| 1115 |
+
b.DetachClient(ws.ID, cid)
|
| 1116 |
+
|
| 1117 |
+
ws.clientsMu.Lock()
|
| 1118 |
+
_, present := ws.clients[cid]
|
| 1119 |
+
ws.clientsMu.Unlock()
|
| 1120 |
+
require.False(t, present, "detach must remove the clientState entry along with its currentSessionID")
|
| 1121 |
+
|
| 1122 |
+
// A follow-up SetCurrentSession on the gone client must be
|
| 1123 |
+
// rejected with ErrClientNotAttached.
|
| 1124 |
+
require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S3"), ErrClientNotAttached)
|
| 1125 |
+
|
| 1126 |
+
b.DetachClient(ws.ID, anchor)
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
// TestSetCurrentSession_RejectsHoldOnly verifies that a registered
|
| 1130 |
+
// client whose only claim is a creation hold (streams == 0) cannot
|
| 1131 |
+
// influence presence: SetCurrentSession returns ErrClientNotAttached
|
| 1132 |
+
// and the entry's currentSessionID stays empty.
|
| 1133 |
+
func TestSetCurrentSession_RejectsHoldOnly(t *testing.T) {
|
| 1134 |
+
t.Parallel()
|
| 1135 |
+
|
| 1136 |
+
b, _ := newTestBackend(t)
|
| 1137 |
+
// Keep the grace window large so the hold survives the test.
|
| 1138 |
+
b.createGrace = time.Hour
|
| 1139 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-hold")
|
| 1140 |
+
|
| 1141 |
+
cid := newClientID(t)
|
| 1142 |
+
b.registerClient(ws, cid)
|
| 1143 |
+
|
| 1144 |
+
require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S1"), ErrClientNotAttached)
|
| 1145 |
+
|
| 1146 |
+
ws.clientsMu.Lock()
|
| 1147 |
+
require.Empty(t, ws.clients[cid].currentSessionID, "hold-only client must not write a session id")
|
| 1148 |
+
ws.clientsMu.Unlock()
|
| 1149 |
+
|
| 1150 |
+
// Drain.
|
| 1151 |
+
require.NoError(t, b.releaseHold(ws.ID, cid))
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
// TestSetCurrentSession_UnknownClient verifies that a client with no
|
| 1155 |
+
// entry at all is rejected with ErrClientNotAttached.
|
| 1156 |
+
func TestSetCurrentSession_UnknownClient(t *testing.T) {
|
| 1157 |
+
t.Parallel()
|
| 1158 |
+
|
| 1159 |
+
b, _ := newTestBackend(t)
|
| 1160 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-unknown")
|
| 1161 |
+
|
| 1162 |
+
require.ErrorIs(t, b.SetCurrentSession(ws.ID, newClientID(t), "S1"), ErrClientNotAttached)
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
// TestSetCurrentSession_RejectsBadInputs covers the validation
|
| 1166 |
+
// branches: empty/malformed client_id and unknown workspace.
|
| 1167 |
+
func TestSetCurrentSession_RejectsBadInputs(t *testing.T) {
|
| 1168 |
+
t.Parallel()
|
| 1169 |
+
|
| 1170 |
+
b, _ := newTestBackend(t)
|
| 1171 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-bad")
|
| 1172 |
+
|
| 1173 |
+
require.ErrorIs(t, b.SetCurrentSession(ws.ID, "", "S1"), ErrInvalidClientID)
|
| 1174 |
+
require.ErrorIs(t, b.SetCurrentSession(ws.ID, "not-a-uuid", "S1"), ErrInvalidClientID)
|
| 1175 |
+
|
| 1176 |
+
require.ErrorIs(
|
| 1177 |
+
t,
|
| 1178 |
+
b.SetCurrentSession("00000000-0000-0000-0000-000000000000", newClientID(t), "S1"),
|
| 1179 |
+
ErrWorkspaceNotFound,
|
| 1180 |
+
)
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
// TestSetCurrentSession_RaceWithDetach exercises concurrent
|
| 1184 |
+
// SetCurrentSession updates from one client racing against detach
|
| 1185 |
+
// on a second client. The final state must be self-consistent: any
|
| 1186 |
+
// remaining clientState entries reflect a coherent
|
| 1187 |
+
// (streams, currentSessionID) pair.
|
| 1188 |
+
func TestSetCurrentSession_RaceWithDetach(t *testing.T) {
|
| 1189 |
+
t.Parallel()
|
| 1190 |
+
|
| 1191 |
+
b, _ := newTestBackend(t)
|
| 1192 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-race")
|
| 1193 |
+
|
| 1194 |
+
cidA := newClientID(t)
|
| 1195 |
+
cidB := newClientID(t)
|
| 1196 |
+
require.NoError(t, b.AttachClient(ws.ID, cidA))
|
| 1197 |
+
require.NoError(t, b.AttachClient(ws.ID, cidB))
|
| 1198 |
+
|
| 1199 |
+
var wg sync.WaitGroup
|
| 1200 |
+
const updates = 200
|
| 1201 |
+
wg.Add(3)
|
| 1202 |
+
go func() {
|
| 1203 |
+
defer wg.Done()
|
| 1204 |
+
for i := range updates {
|
| 1205 |
+
// Errors are tolerated: once cidA detaches,
|
| 1206 |
+
// further updates against cidA must return
|
| 1207 |
+
// ErrClientNotAttached but never panic.
|
| 1208 |
+
_ = b.SetCurrentSession(ws.ID, cidA, "SA")
|
| 1209 |
+
_ = i
|
| 1210 |
+
}
|
| 1211 |
+
}()
|
| 1212 |
+
go func() {
|
| 1213 |
+
defer wg.Done()
|
| 1214 |
+
for i := range updates {
|
| 1215 |
+
_ = b.SetCurrentSession(ws.ID, cidB, "SB")
|
| 1216 |
+
_ = i
|
| 1217 |
+
}
|
| 1218 |
+
}()
|
| 1219 |
+
go func() {
|
| 1220 |
+
defer wg.Done()
|
| 1221 |
+
// Single concurrent detach of cidA partway through.
|
| 1222 |
+
b.DetachClient(ws.ID, cidA)
|
| 1223 |
+
}()
|
| 1224 |
+
wg.Wait()
|
| 1225 |
+
|
| 1226 |
+
ws.clientsMu.Lock()
|
| 1227 |
+
defer ws.clientsMu.Unlock()
|
| 1228 |
+
require.NotContains(t, ws.clients, cidA, "detached client must be gone")
|
| 1229 |
+
require.Contains(t, ws.clients, cidB, "remaining client must still be present")
|
| 1230 |
+
require.Equal(t, "SB", ws.clients[cidB].currentSessionID, "remaining client must keep its last set session")
|
| 1231 |
+
}
|
| 1232 |
+
|
| 1233 |
+
// TestAttachedClients_BasicLifecycle walks one session's count through
|
| 1234 |
+
// attach -> set -> second client joins -> switch -> detach. It also
|
| 1235 |
+
// confirms hold-only and unselected clients do not contribute.
|
| 1236 |
+
func TestAttachedClients_BasicLifecycle(t *testing.T) {
|
| 1237 |
+
t.Parallel()
|
| 1238 |
+
|
| 1239 |
+
b, _ := newTestBackend(t)
|
| 1240 |
+
// Keep the grace window long so the hold-only client survives.
|
| 1241 |
+
b.createGrace = time.Hour
|
| 1242 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/attached-clients-basic")
|
| 1243 |
+
|
| 1244 |
+
// No clients yet.
|
| 1245 |
+
n, err := b.AttachedClients(ws.ID, "S1")
|
| 1246 |
+
require.NoError(t, err)
|
| 1247 |
+
require.Zero(t, n)
|
| 1248 |
+
|
| 1249 |
+
// Attach A, set to S1. Count for S1 is 1; count for S2 is 0.
|
| 1250 |
+
cidA := newClientID(t)
|
| 1251 |
+
require.NoError(t, b.AttachClient(ws.ID, cidA))
|
| 1252 |
+
require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1"))
|
| 1253 |
+
|
| 1254 |
+
n, err = b.AttachedClients(ws.ID, "S1")
|
| 1255 |
+
require.NoError(t, err)
|
| 1256 |
+
require.Equal(t, 1, n)
|
| 1257 |
+
n, err = b.AttachedClients(ws.ID, "S2")
|
| 1258 |
+
require.NoError(t, err)
|
| 1259 |
+
require.Zero(t, n)
|
| 1260 |
+
|
| 1261 |
+
// Attach B, set to S1. Count for S1 is 2.
|
| 1262 |
+
cidB := newClientID(t)
|
| 1263 |
+
require.NoError(t, b.AttachClient(ws.ID, cidB))
|
| 1264 |
+
require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1"))
|
| 1265 |
+
|
| 1266 |
+
n, _ = b.AttachedClients(ws.ID, "S1")
|
| 1267 |
+
require.Equal(t, 2, n)
|
| 1268 |
+
|
| 1269 |
+
// B switches to S2; counts redistribute.
|
| 1270 |
+
require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2"))
|
| 1271 |
+
n, _ = b.AttachedClients(ws.ID, "S1")
|
| 1272 |
+
require.Equal(t, 1, n)
|
| 1273 |
+
n, _ = b.AttachedClients(ws.ID, "S2")
|
| 1274 |
+
require.Equal(t, 1, n)
|
| 1275 |
+
|
| 1276 |
+
// A hold-only client must NOT be counted, even if we were able to
|
| 1277 |
+
// imagine a currentSessionID on it. registerClient leaves
|
| 1278 |
+
// currentSessionID empty by construction, and SetCurrentSession
|
| 1279 |
+
// rejects hold-only writers — so the contract holds two ways.
|
| 1280 |
+
cidHold := newClientID(t)
|
| 1281 |
+
b.registerClient(ws, cidHold)
|
| 1282 |
+
t.Cleanup(func() { _ = b.releaseHold(ws.ID, cidHold) })
|
| 1283 |
+
n, _ = b.AttachedClients(ws.ID, "S1")
|
| 1284 |
+
require.Equal(t, 1, n, "hold-only client must not contribute")
|
| 1285 |
+
n, _ = b.AttachedClients(ws.ID, "")
|
| 1286 |
+
require.Equal(t, 0, n,
|
| 1287 |
+
"empty sessionID must not match the hold-only entry (streams==0)")
|
| 1288 |
+
|
| 1289 |
+
// A client with streams > 0 but currentSessionID == "" is NOT
|
| 1290 |
+
// counted toward any non-empty session, and is matched only
|
| 1291 |
+
// against the empty session id (which represents the landing
|
| 1292 |
+
// screen).
|
| 1293 |
+
cidC := newClientID(t)
|
| 1294 |
+
require.NoError(t, b.AttachClient(ws.ID, cidC))
|
| 1295 |
+
n, _ = b.AttachedClients(ws.ID, "S1")
|
| 1296 |
+
require.Equal(t, 1, n, "stream-only client with empty currentSessionID must not be counted toward S1")
|
| 1297 |
+
n, _ = b.AttachedClients(ws.ID, "")
|
| 1298 |
+
require.Equal(t, 1, n, "stream-only client with empty currentSessionID matches the empty session id")
|
| 1299 |
+
|
| 1300 |
+
// B detaches: count for S2 drops to 0.
|
| 1301 |
+
b.DetachClient(ws.ID, cidB)
|
| 1302 |
+
n, _ = b.AttachedClients(ws.ID, "S2")
|
| 1303 |
+
require.Zero(t, n)
|
| 1304 |
+
n, _ = b.AttachedClients(ws.ID, "S1")
|
| 1305 |
+
require.Equal(t, 1, n, "A still on S1")
|
| 1306 |
+
|
| 1307 |
+
// Final cleanup.
|
| 1308 |
+
b.DetachClient(ws.ID, cidA)
|
| 1309 |
+
b.DetachClient(ws.ID, cidC)
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
// TestAttachedClients_UnknownWorkspace verifies the error surface.
|
| 1313 |
+
func TestAttachedClients_UnknownWorkspace(t *testing.T) {
|
| 1314 |
+
t.Parallel()
|
| 1315 |
+
|
| 1316 |
+
b, _ := newTestBackend(t)
|
| 1317 |
+
_, err := b.AttachedClients("00000000-0000-0000-0000-000000000000", "S1")
|
| 1318 |
+
require.ErrorIs(t, err, ErrWorkspaceNotFound)
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
+
// TestTeardown_DefersShutdownWhileCreatePending verifies the core of the
|
| 1322 |
+
// "coder agent offline after more than one session" fix: tearing down the
|
| 1323 |
+
// last live workspace must NOT shut the server down while another
|
| 1324 |
+
// CreateWorkspace is mid-flight (it has committed to the slow init path
|
| 1325 |
+
// but not yet registered its workspace). Without the pending guard, the
|
| 1326 |
+
// teardown observed an empty workspace map and killed the whole server
|
| 1327 |
+
// out from under the workspace being born.
|
| 1328 |
+
func TestTeardown_DefersShutdownWhileCreatePending(t *testing.T) {
|
| 1329 |
+
t.Parallel()
|
| 1330 |
+
|
| 1331 |
+
b, serverShutdowns := newTestBackend(t)
|
| 1332 |
+
ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/pending-guard")
|
| 1333 |
+
|
| 1334 |
+
// Simulate a concurrent create that has passed the pending++ point
|
| 1335 |
+
// but has not yet registered its workspace.
|
| 1336 |
+
b.mu.Lock()
|
| 1337 |
+
b.pending = 1
|
| 1338 |
+
b.mu.Unlock()
|
| 1339 |
+
|
| 1340 |
+
b.teardown(ws)
|
| 1341 |
+
|
| 1342 |
+
require.Equal(t, int32(1), wsShutdowns.Load(),
|
| 1343 |
+
"the torn-down workspace's own resources must still be released")
|
| 1344 |
+
require.Equal(t, int32(0), serverShutdowns.Load(),
|
| 1345 |
+
"server must not shut down while a create is in flight")
|
| 1346 |
+
}
|
| 1347 |
+
|
| 1348 |
+
// TestTeardown_ShutsDownWhenIdleAndNoPending is the control for the guard
|
| 1349 |
+
// above: with no create in flight, tearing down the last workspace still
|
| 1350 |
+
// triggers the server shutdown, preserving the "shut down when empty"
|
| 1351 |
+
// behavior.
|
| 1352 |
+
func TestTeardown_ShutsDownWhenIdleAndNoPending(t *testing.T) {
|
| 1353 |
+
t.Parallel()
|
| 1354 |
+
|
| 1355 |
+
b, serverShutdowns := newTestBackend(t)
|
| 1356 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/idle-shutdown")
|
| 1357 |
+
|
| 1358 |
+
b.teardown(ws)
|
| 1359 |
+
|
| 1360 |
+
require.Equal(t, int32(1), serverShutdowns.Load(),
|
| 1361 |
+
"server must shut down once the last workspace is gone and nothing is pending")
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
// TestCreateWorkspace_PendingBalancedOnSuccess drives the real
|
| 1365 |
+
// CreateWorkspace path and asserts the in-flight `pending` counter it
|
| 1366 |
+
// uses to hold off server shutdown is balanced back to zero once the
|
| 1367 |
+
// workspace is registered. This guards the bookkeeping the unit-level
|
| 1368 |
+
// teardown tests stub out: a refactor that drops the increment or
|
| 1369 |
+
// misplaces the deferred decrement would reintroduce the shutdown race.
|
| 1370 |
+
func TestCreateWorkspace_PendingBalancedOnSuccess(t *testing.T) {
|
| 1371 |
+
t.Setenv("HOME", t.TempDir())
|
| 1372 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 1373 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 1374 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 1375 |
+
|
| 1376 |
+
b, shutdownCount := newTestBackend(t)
|
| 1377 |
+
// Keep the create hold alive so its grace timer can't fire and tear
|
| 1378 |
+
// the workspace down mid-assertion.
|
| 1379 |
+
b.SetCreateGrace(time.Hour)
|
| 1380 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 1381 |
+
|
| 1382 |
+
_, _, err := b.CreateWorkspace(protoWS(t.TempDir(), t.TempDir(), uuid.New().String()))
|
| 1383 |
+
require.NoError(t, err)
|
| 1384 |
+
|
| 1385 |
+
b.mu.Lock()
|
| 1386 |
+
pending := b.pending
|
| 1387 |
+
b.mu.Unlock()
|
| 1388 |
+
require.Equal(t, 0, pending, "pending must return to zero after a successful create")
|
| 1389 |
+
require.Equal(t, int32(0), shutdownCount.Load(), "a live workspace must not trigger shutdown")
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
// TestCreateWorkspace_PendingBalancedAndReapsOnFailure asserts that a
|
| 1393 |
+
// create which fails during its slow init still decrements `pending`, and
|
| 1394 |
+
// that the deferred reap shuts an otherwise-idle server down (the safety
|
| 1395 |
+
// net that keeps a failed create racing the last teardown from leaking an
|
| 1396 |
+
// empty server the plain teardown declined to close).
|
| 1397 |
+
func TestCreateWorkspace_PendingBalancedAndReapsOnFailure(t *testing.T) {
|
| 1398 |
+
t.Setenv("HOME", t.TempDir())
|
| 1399 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 1400 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 1401 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 1402 |
+
|
| 1403 |
+
b, shutdownCount := newTestBackend(t)
|
| 1404 |
+
|
| 1405 |
+
// A data dir whose parent is a regular file makes the workspace's
|
| 1406 |
+
// data-directory creation fail deterministically, after the pending
|
| 1407 |
+
// counter has been incremented.
|
| 1408 |
+
tmp := t.TempDir()
|
| 1409 |
+
fileAsParent := filepath.Join(tmp, "notadir")
|
| 1410 |
+
require.NoError(t, os.WriteFile(fileAsParent, []byte("x"), 0o600))
|
| 1411 |
+
badDataDir := filepath.Join(fileAsParent, "data")
|
| 1412 |
+
|
| 1413 |
+
_, _, err := b.CreateWorkspace(protoWS(tmp, badDataDir, uuid.New().String()))
|
| 1414 |
+
require.Error(t, err, "create must fail when the data dir cannot be created")
|
| 1415 |
+
|
| 1416 |
+
b.mu.Lock()
|
| 1417 |
+
pending := b.pending
|
| 1418 |
+
remaining := b.workspaces.Len()
|
| 1419 |
+
b.mu.Unlock()
|
| 1420 |
+
require.Equal(t, 0, pending, "pending must return to zero after a failed create")
|
| 1421 |
+
require.Zero(t, remaining, "no workspace should be registered after a failed create")
|
| 1422 |
+
require.Equal(t, int32(1), shutdownCount.Load(),
|
| 1423 |
+
"a failed create that leaves the server idle must reap it")
|
| 1424 |
+
}
|
| 1425 |
+
|
| 1426 |
+
// TestServer_CreateCancelsPendingIdleShutdown is the regression test for
|
| 1427 |
+
// the coder-agent-offline handoff: when the last client of a session
|
| 1428 |
+
// leaves, the server must linger (not shut down immediately), and a
|
| 1429 |
+
// client returning within the linger window — the same directory or a
|
| 1430 |
+
// different one — must cancel that pending shutdown so it is never handed
|
| 1431 |
+
// a workspace on a server that is about to die.
|
| 1432 |
+
func TestServer_CreateCancelsPendingIdleShutdown(t *testing.T) {
|
| 1433 |
+
t.Setenv("HOME", t.TempDir())
|
| 1434 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 1435 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 1436 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 1437 |
+
t.Setenv("CRUSH_DISABLE_PROVIDER_AUTO_UPDATE", "1")
|
| 1438 |
+
|
| 1439 |
+
b, shutdownCount := newTestBackend(t)
|
| 1440 |
+
b.SetIdleShutdownDelay(10 * time.Second) // long enough not to fire mid-test
|
| 1441 |
+
b.SetCreateGrace(time.Hour)
|
| 1442 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 1443 |
+
|
| 1444 |
+
// Session 1: a workspace that is attached then leaves, arming the
|
| 1445 |
+
// idle-shutdown timer.
|
| 1446 |
+
wsA, _ := insertTestWorkspace(t, b, "/tmp/linger-A")
|
| 1447 |
+
cidA := newClientID(t)
|
| 1448 |
+
require.NoError(t, b.AttachClient(wsA.ID, cidA))
|
| 1449 |
+
b.DetachClient(wsA.ID, cidA)
|
| 1450 |
+
|
| 1451 |
+
b.mu.Lock()
|
| 1452 |
+
armed := b.shutdownTimer != nil
|
| 1453 |
+
b.mu.Unlock()
|
| 1454 |
+
require.True(t, armed, "idle-shutdown timer must be armed after the last client leaves")
|
| 1455 |
+
require.Equal(t, int32(0), shutdownCount.Load(), "server must linger, not shut down immediately")
|
| 1456 |
+
|
| 1457 |
+
// Session 2 arrives within the linger window: creating a workspace
|
| 1458 |
+
// must cancel the pending shutdown.
|
| 1459 |
+
_, _, err := b.CreateWorkspace(protoWS(t.TempDir(), t.TempDir(), uuid.New().String()))
|
| 1460 |
+
require.NoError(t, err)
|
| 1461 |
+
|
| 1462 |
+
b.mu.Lock()
|
| 1463 |
+
stillArmed := b.shutdownTimer != nil
|
| 1464 |
+
b.mu.Unlock()
|
| 1465 |
+
require.False(t, stillArmed, "a create within the linger window must cancel the pending shutdown")
|
| 1466 |
+
require.Equal(t, int32(0), shutdownCount.Load(), "server must not shut down after a client returns")
|
| 1467 |
+
}
|
| 1468 |
+
|
| 1469 |
+
// TestServer_ShutsDownAfterLingerWhenIdle confirms the linger still ends
|
| 1470 |
+
// in a shutdown when no client returns: the server is not leaked.
|
| 1471 |
+
func TestServer_ShutsDownAfterLingerWhenIdle(t *testing.T) {
|
| 1472 |
+
t.Parallel()
|
| 1473 |
+
|
| 1474 |
+
b, shutdownCount := newTestBackend(t)
|
| 1475 |
+
b.SetIdleShutdownDelay(100 * time.Millisecond)
|
| 1476 |
+
|
| 1477 |
+
wsA, _ := insertTestWorkspace(t, b, "/tmp/linger-idle")
|
| 1478 |
+
cidA := newClientID(t)
|
| 1479 |
+
require.NoError(t, b.AttachClient(wsA.ID, cidA))
|
| 1480 |
+
b.DetachClient(wsA.ID, cidA)
|
| 1481 |
+
|
| 1482 |
+
// Nobody returns: after the linger elapses the server shuts down.
|
| 1483 |
+
require.Eventually(t, func() bool { return shutdownCount.Load() == 1 },
|
| 1484 |
+
2*time.Second, 10*time.Millisecond,
|
| 1485 |
+
"idle server must shut down after the linger window")
|
| 1486 |
+
}
|
| 1487 |
+
|
| 1488 |
+
// -- Detach grace --
|
| 1489 |
+
//
|
| 1490 |
+
// The SSE stream is a client's refcount claim, so a stream that drops
|
| 1491 |
+
// without an explicit release must not destroy the workspace before the
|
| 1492 |
+
// client's reconnect loop can come back. These cover both outcomes of
|
| 1493 |
+
// that window plus the fast path a clean exit still gets.
|
| 1494 |
+
|
| 1495 |
+
// TestDetachStream_GraceSurvivesReattach is the core regression: a
|
| 1496 |
+
// momentary stream drop followed by a reconnect must leave the workspace
|
| 1497 |
+
// exactly where it was. Before the grace, the reconnect came back to a
|
| 1498 |
+
// workspace ID the server no longer knew, and every later request 404'd
|
| 1499 |
+
// forever because a sibling session kept the server itself alive.
|
| 1500 |
+
func TestDetachStream_GraceSurvivesReattach(t *testing.T) {
|
| 1501 |
+
t.Parallel()
|
| 1502 |
+
|
| 1503 |
+
b, srvShutdowns := newTestBackend(t)
|
| 1504 |
+
b.SetDetachGrace(time.Hour)
|
| 1505 |
+
ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/blip")
|
| 1506 |
+
|
| 1507 |
+
cid := newClientID(t)
|
| 1508 |
+
b.registerClient(ws, cid)
|
| 1509 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 1510 |
+
|
| 1511 |
+
b.DetachClient(ws.ID, cid)
|
| 1512 |
+
require.Equal(t, int32(0), wsShutdowns.Load(),
|
| 1513 |
+
"a dropped stream must not tear the workspace down within the grace")
|
| 1514 |
+
live, err := b.GetWorkspace(ws.ID)
|
| 1515 |
+
require.NoError(t, err, "the workspace must still be addressable by its original ID")
|
| 1516 |
+
require.Same(t, ws, live)
|
| 1517 |
+
|
| 1518 |
+
// The reconnect lands and converts the grace back into a stream claim.
|
| 1519 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 1520 |
+
ws.clientsMu.Lock()
|
| 1521 |
+
cs := ws.clients[cid]
|
| 1522 |
+
require.Equal(t, 1, cs.streams)
|
| 1523 |
+
require.Nil(t, cs.holdTimer, "re-attaching must cancel the grace timer")
|
| 1524 |
+
ws.clientsMu.Unlock()
|
| 1525 |
+
require.Equal(t, int32(0), srvShutdowns.Load())
|
| 1526 |
+
}
|
| 1527 |
+
|
| 1528 |
+
// TestDetachStream_GraceExpiryTearsDown checks the grace is bounded: a
|
| 1529 |
+
// client that never comes back must not pin the workspace (and with it
|
| 1530 |
+
// the server) indefinitely.
|
| 1531 |
+
func TestDetachStream_GraceExpiryTearsDown(t *testing.T) {
|
| 1532 |
+
t.Parallel()
|
| 1533 |
+
|
| 1534 |
+
b, srvShutdowns := newTestBackend(t)
|
| 1535 |
+
b.SetDetachGrace(50 * time.Millisecond)
|
| 1536 |
+
ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/gone-for-good")
|
| 1537 |
+
|
| 1538 |
+
cid := newClientID(t)
|
| 1539 |
+
b.registerClient(ws, cid)
|
| 1540 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 1541 |
+
b.DetachClient(ws.ID, cid)
|
| 1542 |
+
|
| 1543 |
+
require.Eventually(t, func() bool { return srvShutdowns.Load() == 1 },
|
| 1544 |
+
2*time.Second, 10*time.Millisecond,
|
| 1545 |
+
"an unclaimed grace must expire and tear the workspace down")
|
| 1546 |
+
require.Equal(t, int32(1), wsShutdowns.Load())
|
| 1547 |
+
_, err := b.GetWorkspace(ws.ID)
|
| 1548 |
+
require.ErrorIs(t, err, ErrWorkspaceNotFound)
|
| 1549 |
+
}
|
| 1550 |
+
|
| 1551 |
+
// TestDetachStream_ExplicitReleaseSkipsGrace keeps clean exits fast: a
|
| 1552 |
+
// client that says goodbye before its stream closes is not coming back,
|
| 1553 |
+
// so the grace must not delay the teardown.
|
| 1554 |
+
func TestDetachStream_ExplicitReleaseSkipsGrace(t *testing.T) {
|
| 1555 |
+
t.Parallel()
|
| 1556 |
+
|
| 1557 |
+
b, _ := newTestBackend(t)
|
| 1558 |
+
b.SetDetachGrace(time.Hour)
|
| 1559 |
+
ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/clean-exit")
|
| 1560 |
+
|
| 1561 |
+
cid := newClientID(t)
|
| 1562 |
+
b.registerClient(ws, cid)
|
| 1563 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 1564 |
+
|
| 1565 |
+
// The order a quitting client produces: release, then the stream ends.
|
| 1566 |
+
require.NoError(t, b.releaseHold(ws.ID, cid))
|
| 1567 |
+
require.Equal(t, int32(0), wsShutdowns.Load(), "the live stream still holds it")
|
| 1568 |
+
b.DetachClient(ws.ID, cid)
|
| 1569 |
+
|
| 1570 |
+
require.Equal(t, int32(1), wsShutdowns.Load(),
|
| 1571 |
+
"an explicitly released client must tear down without waiting out the grace")
|
| 1572 |
+
}
|
| 1573 |
+
|
| 1574 |
+
// TestRegisterClient_RearmsGraceAndCancelsRelease covers a client that
|
| 1575 |
+
// recovers by re-creating the workspace rather than re-attaching: the
|
| 1576 |
+
// duplicate create must buy it a fresh attach window and undo an earlier
|
| 1577 |
+
// release, and the superseded timer must not be able to kill the new claim.
|
| 1578 |
+
func TestRegisterClient_RearmsGraceAndCancelsRelease(t *testing.T) {
|
| 1579 |
+
t.Parallel()
|
| 1580 |
+
|
| 1581 |
+
b, _ := newTestBackend(t)
|
| 1582 |
+
b.SetDetachGrace(50 * time.Millisecond)
|
| 1583 |
+
b.createGrace = time.Hour
|
| 1584 |
+
ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/rearm")
|
| 1585 |
+
|
| 1586 |
+
cid := newClientID(t)
|
| 1587 |
+
b.registerClient(ws, cid)
|
| 1588 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 1589 |
+
require.NoError(t, b.releaseHold(ws.ID, cid))
|
| 1590 |
+
b.DetachClient(ws.ID, cid)
|
| 1591 |
+
require.Equal(t, int32(1), wsShutdowns.Load())
|
| 1592 |
+
|
| 1593 |
+
// A second workspace, this time reclaimed by a duplicate create while
|
| 1594 |
+
// its short detach grace is running.
|
| 1595 |
+
ws2, ws2Shutdowns := insertTestWorkspace(t, b, "/tmp/rearm-2")
|
| 1596 |
+
b.registerClient(ws2, cid)
|
| 1597 |
+
require.NoError(t, b.AttachClient(ws2.ID, cid))
|
| 1598 |
+
b.DetachClient(ws2.ID, cid)
|
| 1599 |
+
b.registerClient(ws2, cid)
|
| 1600 |
+
|
| 1601 |
+
ws2.clientsMu.Lock()
|
| 1602 |
+
require.False(t, ws2.clients[cid].released)
|
| 1603 |
+
ws2.clientsMu.Unlock()
|
| 1604 |
+
|
| 1605 |
+
//nolint:forbidigo // The superseded 50ms timer has to be given its chance to fire.
|
| 1606 |
+
time.Sleep(200 * time.Millisecond)
|
| 1607 |
+
require.Equal(t, int32(0), ws2Shutdowns.Load(),
|
| 1608 |
+
"the re-armed claim must outlive the timer it replaced")
|
| 1609 |
+
}
|
| 1610 |
+
|
| 1611 |
+
// -- Client retirement --
|
| 1612 |
+
|
| 1613 |
+
// TestRetireClient_ReleasesEveryClaim checks the primitive a quitting
|
| 1614 |
+
// client relies on: one call drops its claims everywhere, whether they
|
| 1615 |
+
// are held by a stream, a create hold, or a detach grace.
|
| 1616 |
+
func TestRetireClient_ReleasesEveryClaim(t *testing.T) {
|
| 1617 |
+
t.Parallel()
|
| 1618 |
+
|
| 1619 |
+
b, _ := newTestBackend(t)
|
| 1620 |
+
b.SetDetachGrace(time.Hour)
|
| 1621 |
+
b.createGrace = time.Hour
|
| 1622 |
+
|
| 1623 |
+
streamed, streamedShutdowns := insertTestWorkspace(t, b, "/tmp/retire-stream")
|
| 1624 |
+
held, heldShutdowns := insertTestWorkspace(t, b, "/tmp/retire-hold")
|
| 1625 |
+
shared, sharedShutdowns := insertTestWorkspace(t, b, "/tmp/retire-shared")
|
| 1626 |
+
|
| 1627 |
+
cid, other := newClientID(t), newClientID(t)
|
| 1628 |
+
b.registerClient(streamed, cid)
|
| 1629 |
+
require.NoError(t, b.AttachClient(streamed.ID, cid))
|
| 1630 |
+
b.registerClient(held, cid)
|
| 1631 |
+
b.registerClient(shared, cid)
|
| 1632 |
+
b.registerClient(shared, other)
|
| 1633 |
+
|
| 1634 |
+
require.NoError(t, b.RetireClient(cid))
|
| 1635 |
+
|
| 1636 |
+
require.Equal(t, int32(1), streamedShutdowns.Load())
|
| 1637 |
+
require.Equal(t, int32(1), heldShutdowns.Load())
|
| 1638 |
+
require.Equal(t, int32(0), sharedShutdowns.Load(),
|
| 1639 |
+
"a workspace another client is still using must be left alone")
|
| 1640 |
+
shared.clientsMu.Lock()
|
| 1641 |
+
require.NotContains(t, shared.clients, cid)
|
| 1642 |
+
require.Contains(t, shared.clients, other)
|
| 1643 |
+
shared.clientsMu.Unlock()
|
| 1644 |
+
|
| 1645 |
+
// Idempotent: retiring twice is not an error and changes nothing.
|
| 1646 |
+
require.NoError(t, b.RetireClient(cid))
|
| 1647 |
+
require.Equal(t, int32(1), streamedShutdowns.Load())
|
| 1648 |
+
}
|
| 1649 |
+
|
| 1650 |
+
// TestRetireClient_RefusesLaterCreates is what makes teardown exact
|
| 1651 |
+
// without timing guesses. A recovery create whose response the client
|
| 1652 |
+
// never saw can still land on the server afterwards; refusing it is the
|
| 1653 |
+
// only way to guarantee it cannot leave a workspace nobody can name.
|
| 1654 |
+
func TestRetireClient_RefusesLaterCreates(t *testing.T) {
|
| 1655 |
+
t.Setenv("HOME", t.TempDir())
|
| 1656 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 1657 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 1658 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 1659 |
+
|
| 1660 |
+
b := New(context.Background(), nil, func() {})
|
| 1661 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 1662 |
+
|
| 1663 |
+
cid := newClientID(t)
|
| 1664 |
+
require.NoError(t, b.RetireClient(cid))
|
| 1665 |
+
|
| 1666 |
+
_, _, err := b.CreateWorkspace(protoWS(t.TempDir(), t.TempDir(), cid))
|
| 1667 |
+
require.ErrorIs(t, err, ErrClientRetired)
|
| 1668 |
+
require.Zero(t, b.workspaces.Len(), "a refused create must register nothing")
|
| 1669 |
+
}
|
| 1670 |
+
|
| 1671 |
+
// TestRetireClient_DuringPendingCreate drives the actual ordering the
|
| 1672 |
+
// design has to survive: retirement lands while a create is already
|
| 1673 |
+
// inside its slow initialization, holding b.mu released. The create must
|
| 1674 |
+
// notice at its commit point and discard the workspace it built rather
|
| 1675 |
+
// than registering a claim for a client that has already gone.
|
| 1676 |
+
func TestRetireClient_DuringPendingCreate(t *testing.T) {
|
| 1677 |
+
t.Setenv("HOME", t.TempDir())
|
| 1678 |
+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
| 1679 |
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
| 1680 |
+
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
| 1681 |
+
|
| 1682 |
+
b := New(context.Background(), nil, func() {})
|
| 1683 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 1684 |
+
|
| 1685 |
+
cid := newClientID(t)
|
| 1686 |
+
cwd, dataDir := t.TempDir(), t.TempDir()
|
| 1687 |
+
|
| 1688 |
+
createErr := make(chan error, 1)
|
| 1689 |
+
go func() {
|
| 1690 |
+
_, _, err := b.CreateWorkspace(protoWS(cwd, dataDir, cid))
|
| 1691 |
+
createErr <- err
|
| 1692 |
+
}()
|
| 1693 |
+
|
| 1694 |
+
// pending is bumped under b.mu before the create releases the lock for
|
| 1695 |
+
// its slow init (config, db, app), and dropped only after the workspace
|
| 1696 |
+
// is registered. Observing pending == 1 therefore means the create is
|
| 1697 |
+
// genuinely mid-flight with b.mu free — the exact window retirement has
|
| 1698 |
+
// to cover. Nothing here fakes the race; the create really is running.
|
| 1699 |
+
require.Eventually(t, func() bool {
|
| 1700 |
+
b.mu.Lock()
|
| 1701 |
+
defer b.mu.Unlock()
|
| 1702 |
+
return b.pending == 1
|
| 1703 |
+
}, 30*time.Second, time.Millisecond, "create must reach its slow path")
|
| 1704 |
+
|
| 1705 |
+
require.NoError(t, b.RetireClient(cid))
|
| 1706 |
+
|
| 1707 |
+
require.ErrorIs(t, <-createErr, ErrClientRetired,
|
| 1708 |
+
"a create that commits after retirement must be refused")
|
| 1709 |
+
require.Zero(t, b.workspaces.Len(), "no workspace may be left behind")
|
| 1710 |
+
}
|
| 1711 |
+
|
| 1712 |
+
// -- Shutdown as an atomic decision --
|
| 1713 |
+
|
| 1714 |
+
// TestShutdownIfIdle_RefusesWhileWorkspaceLive is the guard that keeps a
|
| 1715 |
+
// second session alive: an upgrading client asks a mismatched server to
|
| 1716 |
+
// stand down, and only the server can answer without racing a session
|
| 1717 |
+
// that arrives between the question and the answer.
|
| 1718 |
+
func TestShutdownIfIdle_RefusesWhileWorkspaceLive(t *testing.T) {
|
| 1719 |
+
t.Parallel()
|
| 1720 |
+
|
| 1721 |
+
b, shutdowns := newTestBackend(t)
|
| 1722 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/in-use")
|
| 1723 |
+
cid := newClientID(t)
|
| 1724 |
+
b.registerClient(ws, cid)
|
| 1725 |
+
|
| 1726 |
+
require.False(t, b.ShutdownIfIdle(), "a server hosting a workspace must refuse")
|
| 1727 |
+
require.Equal(t, int32(0), shutdowns.Load())
|
| 1728 |
+
|
| 1729 |
+
// Refusing must not latch anything: the server keeps serving.
|
| 1730 |
+
b.mu.Lock()
|
| 1731 |
+
require.False(t, b.closing)
|
| 1732 |
+
b.mu.Unlock()
|
| 1733 |
+
}
|
| 1734 |
+
|
| 1735 |
+
// TestShutdownIfIdle_RefusesWhileCreatePending closes the subtler half of
|
| 1736 |
+
// the same window: a workspace still working through its slow startup is
|
| 1737 |
+
// not yet in the workspace map, so a count-based check would call the
|
| 1738 |
+
// server idle and kill a session that is being born.
|
| 1739 |
+
func TestShutdownIfIdle_RefusesWhileCreatePending(t *testing.T) {
|
| 1740 |
+
t.Parallel()
|
| 1741 |
+
|
| 1742 |
+
b, shutdowns := newTestBackend(t)
|
| 1743 |
+
b.mu.Lock()
|
| 1744 |
+
b.pending = 1
|
| 1745 |
+
b.mu.Unlock()
|
| 1746 |
+
|
| 1747 |
+
require.False(t, b.ShutdownIfIdle())
|
| 1748 |
+
require.Equal(t, int32(0), shutdowns.Load())
|
| 1749 |
+
}
|
| 1750 |
+
|
| 1751 |
+
// TestShutdownIfIdle_GrantedAndFinal preserves upgrades: an idle server
|
| 1752 |
+
// does stand down, and the decision is final so a create arriving
|
| 1753 |
+
// afterwards is refused instead of being initialized on a dying process.
|
| 1754 |
+
func TestShutdownIfIdle_GrantedAndFinal(t *testing.T) {
|
| 1755 |
+
t.Parallel()
|
| 1756 |
+
|
| 1757 |
+
b, shutdowns := newTestBackend(t)
|
| 1758 |
+
require.True(t, b.ShutdownIfIdle())
|
| 1759 |
+
require.Equal(t, int32(1), shutdowns.Load())
|
| 1760 |
+
|
| 1761 |
+
_, _, err := b.CreateWorkspace(protoWS(t.TempDir(), t.TempDir(), newClientID(t)))
|
| 1762 |
+
require.ErrorIs(t, err, ErrServerShuttingDown)
|
| 1763 |
+
}
|
| 1764 |
+
|
| 1765 |
+
// TestIdleShutdown_RefusesLaterCreates covers the same finality for the
|
| 1766 |
+
// unprompted path: a server that decided to exit because it went idle
|
| 1767 |
+
// must not accept the create of a client that arrives a moment later.
|
| 1768 |
+
func TestIdleShutdown_RefusesLaterCreates(t *testing.T) {
|
| 1769 |
+
t.Parallel()
|
| 1770 |
+
|
| 1771 |
+
b, shutdowns := newTestBackend(t)
|
| 1772 |
+
b.SetIdleShutdownDelay(10 * time.Millisecond)
|
| 1773 |
+
ws, _ := insertTestWorkspace(t, b, "/tmp/going-idle")
|
| 1774 |
+
cid := newClientID(t)
|
| 1775 |
+
require.NoError(t, b.AttachClient(ws.ID, cid))
|
| 1776 |
+
b.DetachClient(ws.ID, cid)
|
| 1777 |
+
|
| 1778 |
+
require.Eventually(t, func() bool { return shutdowns.Load() == 1 },
|
| 1779 |
+
2*time.Second, 10*time.Millisecond)
|
| 1780 |
+
|
| 1781 |
+
_, _, err := b.CreateWorkspace(protoWS(t.TempDir(), t.TempDir(), cid))
|
| 1782 |
+
require.ErrorIs(t, err, ErrServerShuttingDown,
|
| 1783 |
+
"the client must be told to retry against a replacement server")
|
| 1784 |
+
}
|
internal/backend/config.go
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package backend
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"fmt"
|
| 7 |
+
|
| 8 |
+
"github.com/charmbracelet/crush/internal/agent"
|
| 9 |
+
mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/commands"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 12 |
+
"github.com/charmbracelet/crush/internal/oauth"
|
| 13 |
+
"github.com/charmbracelet/crush/internal/proto"
|
| 14 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 15 |
+
"github.com/charmbracelet/crush/internal/skills"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
// publishConfigChanged publishes a ConfigChanged event on the workspace's
|
| 19 |
+
// event broker so all subscribers (e.g. remote clients) refresh their
|
| 20 |
+
// cached config snapshot. It also re-initializes any MCP servers whose
|
| 21 |
+
// configuration changed as a result of the write.
|
| 22 |
+
func publishConfigChanged(ws *Workspace) {
|
| 23 |
+
if ws == nil || ws.App == nil {
|
| 24 |
+
return
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Re-init MCP servers whose config changed. MCP state is process-global,
|
| 28 |
+
// so this only needs to happen once regardless of which workspace
|
| 29 |
+
// triggered the write. Run async so unrelated config writes (model
|
| 30 |
+
// switches, API keys) don't block on MCP reconciliation. Bound to the
|
| 31 |
+
// workspace ctx so teardown cancels any in-flight init.
|
| 32 |
+
go mcptools.Reinitialize(ws.ctx, ws.Cfg)
|
| 33 |
+
|
| 34 |
+
ws.SendEvent(pubsub.Event[proto.ConfigChanged]{
|
| 35 |
+
Type: pubsub.UpdatedEvent,
|
| 36 |
+
Payload: proto.ConfigChanged{WorkspaceID: ws.ID},
|
| 37 |
+
})
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// MCPResourceContents holds the contents of an MCP resource returned
|
| 41 |
+
// by the backend.
|
| 42 |
+
type MCPResourceContents struct {
|
| 43 |
+
URI string `json:"uri"`
|
| 44 |
+
MIMEType string `json:"mime_type,omitempty"`
|
| 45 |
+
Text string `json:"text,omitempty"`
|
| 46 |
+
Blob []byte `json:"blob,omitempty"`
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// SetConfigField sets a key/value pair in the config file for the
|
| 50 |
+
// given scope.
|
| 51 |
+
func (b *Backend) SetConfigField(workspaceID string, scope config.Scope, key string, value any) error {
|
| 52 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 53 |
+
if err != nil {
|
| 54 |
+
return err
|
| 55 |
+
}
|
| 56 |
+
if err := ws.Cfg.SetConfigField(scope, key, value); err != nil {
|
| 57 |
+
return err
|
| 58 |
+
}
|
| 59 |
+
publishConfigChanged(ws)
|
| 60 |
+
return nil
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// RemoveConfigField removes a key from the config file for the given
|
| 64 |
+
// scope.
|
| 65 |
+
func (b *Backend) RemoveConfigField(workspaceID string, scope config.Scope, key string) error {
|
| 66 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 67 |
+
if err != nil {
|
| 68 |
+
return err
|
| 69 |
+
}
|
| 70 |
+
if err := ws.Cfg.RemoveConfigField(scope, key); err != nil {
|
| 71 |
+
return err
|
| 72 |
+
}
|
| 73 |
+
publishConfigChanged(ws)
|
| 74 |
+
return nil
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// UpdatePreferredModel updates the preferred model for the given type
|
| 78 |
+
// and persists it to the config file at the given scope.
|
| 79 |
+
func (b *Backend) UpdatePreferredModel(workspaceID string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
|
| 80 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 81 |
+
if err != nil {
|
| 82 |
+
return err
|
| 83 |
+
}
|
| 84 |
+
if err := ws.Cfg.UpdatePreferredModel(scope, modelType, model); err != nil {
|
| 85 |
+
return err
|
| 86 |
+
}
|
| 87 |
+
publishConfigChanged(ws)
|
| 88 |
+
return nil
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// SetCompactMode sets the compact mode setting and persists it.
|
| 92 |
+
func (b *Backend) SetCompactMode(workspaceID string, scope config.Scope, enabled bool) error {
|
| 93 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 94 |
+
if err != nil {
|
| 95 |
+
return err
|
| 96 |
+
}
|
| 97 |
+
if err := ws.Cfg.SetCompactMode(scope, enabled); err != nil {
|
| 98 |
+
return err
|
| 99 |
+
}
|
| 100 |
+
publishConfigChanged(ws)
|
| 101 |
+
return nil
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// SetProviderAPIKey sets the API key for a provider and persists it.
|
| 105 |
+
func (b *Backend) SetProviderAPIKey(workspaceID string, scope config.Scope, providerID string, apiKey any) error {
|
| 106 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 107 |
+
if err != nil {
|
| 108 |
+
return err
|
| 109 |
+
}
|
| 110 |
+
if err := ws.Cfg.SetProviderAPIKey(scope, providerID, apiKey); err != nil {
|
| 111 |
+
return err
|
| 112 |
+
}
|
| 113 |
+
publishConfigChanged(ws)
|
| 114 |
+
return nil
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// ImportCopilot attempts to import a GitHub Copilot token from disk.
|
| 118 |
+
func (b *Backend) ImportCopilot(workspaceID string) (*oauth.Token, bool, error) {
|
| 119 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 120 |
+
if err != nil {
|
| 121 |
+
return nil, false, err
|
| 122 |
+
}
|
| 123 |
+
token, ok := ws.Cfg.ImportCopilot()
|
| 124 |
+
if ok {
|
| 125 |
+
publishConfigChanged(ws)
|
| 126 |
+
}
|
| 127 |
+
return token, ok, nil
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// RefreshOAuthToken refreshes the OAuth token for a provider.
|
| 131 |
+
func (b *Backend) RefreshOAuthToken(ctx context.Context, workspaceID string, scope config.Scope, providerID string) error {
|
| 132 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 133 |
+
if err != nil {
|
| 134 |
+
return err
|
| 135 |
+
}
|
| 136 |
+
if err := ws.Cfg.RefreshOAuthToken(ctx, scope, providerID); err != nil {
|
| 137 |
+
return err
|
| 138 |
+
}
|
| 139 |
+
publishConfigChanged(ws)
|
| 140 |
+
return nil
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// ProjectNeedsInitialization checks whether the project in this
|
| 144 |
+
// workspace needs initialization.
|
| 145 |
+
func (b *Backend) ProjectNeedsInitialization(workspaceID string) (bool, error) {
|
| 146 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 147 |
+
if err != nil {
|
| 148 |
+
return false, err
|
| 149 |
+
}
|
| 150 |
+
return config.ProjectNeedsInitialization(ws.Cfg)
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// MarkProjectInitialized marks the project as initialized.
|
| 154 |
+
func (b *Backend) MarkProjectInitialized(workspaceID string) error {
|
| 155 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 156 |
+
if err != nil {
|
| 157 |
+
return err
|
| 158 |
+
}
|
| 159 |
+
if err := config.MarkProjectInitialized(ws.Cfg); err != nil {
|
| 160 |
+
return err
|
| 161 |
+
}
|
| 162 |
+
publishConfigChanged(ws)
|
| 163 |
+
return nil
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
// InitializePrompt builds the initialization prompt for the workspace.
|
| 167 |
+
func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
|
| 168 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 169 |
+
if err != nil {
|
| 170 |
+
return "", err
|
| 171 |
+
}
|
| 172 |
+
return agent.InitializePrompt(ws.Cfg)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// ReadSkill reads a skill's content by ID.
|
| 176 |
+
func (b *Backend) ReadSkill(ctx context.Context, workspaceID, skillID string) ([]byte, proto.SkillReadResult, error) {
|
| 177 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 178 |
+
if err != nil {
|
| 179 |
+
return nil, proto.SkillReadResult{}, err
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
mgr := ws.Skills
|
| 183 |
+
content, result, err := skills.ReadContent(
|
| 184 |
+
mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir(), skillID,
|
| 185 |
+
)
|
| 186 |
+
if err != nil {
|
| 187 |
+
return nil, proto.SkillReadResult{}, err
|
| 188 |
+
}
|
| 189 |
+
return content, proto.SkillReadResult{
|
| 190 |
+
Name: result.Name,
|
| 191 |
+
Description: result.Description,
|
| 192 |
+
Source: string(result.Source),
|
| 193 |
+
Builtin: result.Builtin,
|
| 194 |
+
}, nil
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// ListSkills returns the effective visible skills for a workspace.
|
| 198 |
+
func (b *Backend) ListSkills(workspaceID string) ([]proto.SkillInfo, error) {
|
| 199 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 200 |
+
if err != nil {
|
| 201 |
+
return nil, err
|
| 202 |
+
}
|
| 203 |
+
mgr := ws.Skills
|
| 204 |
+
entries := skills.Catalog(mgr.ActiveSkills(), mgr.ResolvedPaths(), mgr.WorkingDir())
|
| 205 |
+
result := make([]proto.SkillInfo, len(entries))
|
| 206 |
+
for i, entry := range entries {
|
| 207 |
+
result[i] = proto.SkillInfo{
|
| 208 |
+
ID: entry.ID,
|
| 209 |
+
Name: entry.Name,
|
| 210 |
+
Description: entry.Description,
|
| 211 |
+
Label: entry.Label,
|
| 212 |
+
Source: string(entry.Source),
|
| 213 |
+
UserInvocable: entry.UserInvocable,
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
return result, nil
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// EnableDockerMCP validates Docker MCP availability, stages the
|
| 220 |
+
// configuration, starts the MCP client, and persists the config.
|
| 221 |
+
func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
|
| 222 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 223 |
+
if err != nil {
|
| 224 |
+
return err
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
mcpConfig, err := ws.Cfg.PrepareDockerMCPConfig()
|
| 228 |
+
if err != nil {
|
| 229 |
+
return err
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, ws.Cfg); err != nil {
|
| 233 |
+
disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
|
| 234 |
+
ws.Cfg.RemoveDockerMCPInMemory()
|
| 235 |
+
return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
if err := ws.Cfg.PersistDockerMCPConfig(mcpConfig); err != nil {
|
| 239 |
+
disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
|
| 240 |
+
ws.Cfg.RemoveDockerMCPInMemory()
|
| 241 |
+
return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
publishConfigChanged(ws)
|
| 245 |
+
return nil
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// DisableDockerMCP closes the Docker MCP client, removes the
|
| 249 |
+
// configuration, and persists the change.
|
| 250 |
+
func (b *Backend) DisableDockerMCP(workspaceID string) error {
|
| 251 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 252 |
+
if err != nil {
|
| 253 |
+
return err
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
if err := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName); err != nil {
|
| 257 |
+
return fmt.Errorf("failed to disable docker MCP: %w", err)
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
if err := ws.Cfg.DisableDockerMCP(); err != nil {
|
| 261 |
+
return err
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
publishConfigChanged(ws)
|
| 265 |
+
return nil
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// RefreshMCPTools refreshes the tools for a named MCP server.
|
| 269 |
+
func (b *Backend) RefreshMCPTools(ctx context.Context, workspaceID, name string) error {
|
| 270 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 271 |
+
if err != nil {
|
| 272 |
+
return err
|
| 273 |
+
}
|
| 274 |
+
mcptools.RefreshTools(ctx, ws.Cfg, name)
|
| 275 |
+
return nil
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// ReadMCPResource reads a resource from a named MCP server.
|
| 279 |
+
func (b *Backend) ReadMCPResource(ctx context.Context, workspaceID, name, uri string) ([]MCPResourceContents, error) {
|
| 280 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 281 |
+
if err != nil {
|
| 282 |
+
return nil, err
|
| 283 |
+
}
|
| 284 |
+
contents, err := mcptools.ReadResource(ctx, ws.Cfg, name, uri)
|
| 285 |
+
if err != nil {
|
| 286 |
+
return nil, err
|
| 287 |
+
}
|
| 288 |
+
result := make([]MCPResourceContents, len(contents))
|
| 289 |
+
for i, c := range contents {
|
| 290 |
+
result[i] = MCPResourceContents{
|
| 291 |
+
URI: c.URI,
|
| 292 |
+
MIMEType: c.MIMEType,
|
| 293 |
+
Text: c.Text,
|
| 294 |
+
Blob: c.Blob,
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
return result, nil
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
// GetMCPPrompt retrieves a prompt from a named MCP server.
|
| 301 |
+
func (b *Backend) GetMCPPrompt(workspaceID, clientID, promptID string, args map[string]string) (string, error) {
|
| 302 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 303 |
+
if err != nil {
|
| 304 |
+
return "", err
|
| 305 |
+
}
|
| 306 |
+
return commands.GetMCPPrompt(ws.Cfg, clientID, promptID, args)
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
func (b *Backend) ListMCPPrompts(workspaceID string) ([]proto.MCPPrompt, error) {
|
| 310 |
+
if _, err := b.GetWorkspace(workspaceID); err != nil {
|
| 311 |
+
return nil, err
|
| 312 |
+
}
|
| 313 |
+
prompts, err := commands.LoadMCPPrompts()
|
| 314 |
+
if err != nil {
|
| 315 |
+
return nil, err
|
| 316 |
+
}
|
| 317 |
+
result := make([]proto.MCPPrompt, len(prompts))
|
| 318 |
+
for i, prompt := range prompts {
|
| 319 |
+
arguments := make([]proto.MCPPromptArgument, len(prompt.Arguments))
|
| 320 |
+
for j, argument := range prompt.Arguments {
|
| 321 |
+
arguments[j] = proto.MCPPromptArgument{
|
| 322 |
+
ID: argument.ID,
|
| 323 |
+
Title: argument.Title,
|
| 324 |
+
Description: argument.Description,
|
| 325 |
+
Required: argument.Required,
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
result[i] = proto.MCPPrompt{
|
| 329 |
+
ID: prompt.ID,
|
| 330 |
+
Title: prompt.Title,
|
| 331 |
+
Description: prompt.Description,
|
| 332 |
+
PromptID: prompt.PromptID,
|
| 333 |
+
ClientID: prompt.ClientID,
|
| 334 |
+
Arguments: arguments,
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
return result, nil
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
// GetWorkingDir returns the working directory for a workspace.
|
| 341 |
+
func (b *Backend) GetWorkingDir(workspaceID string) (string, error) {
|
| 342 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 343 |
+
if err != nil {
|
| 344 |
+
return "", err
|
| 345 |
+
}
|
| 346 |
+
return ws.Cfg.WorkingDir(), nil
|
| 347 |
+
}
|
internal/backend/config_test.go
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package backend
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"testing"
|
| 6 |
+
"time"
|
| 7 |
+
|
| 8 |
+
tea "charm.land/bubbletea/v2"
|
| 9 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/proto"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 12 |
+
"github.com/google/uuid"
|
| 13 |
+
"github.com/stretchr/testify/require"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// awaitConfigChanged drains events until a ConfigChanged is received
|
| 17 |
+
// for the given workspace ID, or fails the test on timeout. Other
|
| 18 |
+
// event types are ignored.
|
| 19 |
+
func awaitConfigChanged(t *testing.T, evc <-chan pubsub.Event[tea.Msg], workspaceID string) {
|
| 20 |
+
t.Helper()
|
| 21 |
+
deadline := time.After(2 * time.Second)
|
| 22 |
+
for {
|
| 23 |
+
select {
|
| 24 |
+
case ev, ok := <-evc:
|
| 25 |
+
if !ok {
|
| 26 |
+
t.Fatal("event channel closed before ConfigChanged arrived")
|
| 27 |
+
}
|
| 28 |
+
cc, ok := ev.Payload.(pubsub.Event[proto.ConfigChanged])
|
| 29 |
+
if !ok {
|
| 30 |
+
continue
|
| 31 |
+
}
|
| 32 |
+
require.Equal(t, workspaceID, cc.Payload.WorkspaceID)
|
| 33 |
+
return
|
| 34 |
+
case <-deadline:
|
| 35 |
+
t.Fatal("timed out waiting for ConfigChanged event")
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// newPublishingWorkspace creates a real workspace through the backend
|
| 41 |
+
// so its embedded *app.App is wired up and SendEvent works. It returns
|
| 42 |
+
// the backend, the workspace, and a fresh event subscription.
|
| 43 |
+
func newPublishingWorkspace(t *testing.T) (*Backend, *Workspace, <-chan pubsub.Event[tea.Msg]) {
|
| 44 |
+
t.Helper()
|
| 45 |
+
xdgIsolated(t)
|
| 46 |
+
|
| 47 |
+
cwd := t.TempDir()
|
| 48 |
+
dataDir := t.TempDir()
|
| 49 |
+
|
| 50 |
+
b := New(context.Background(), nil, func() {})
|
| 51 |
+
b.SetCreateGrace(2 * time.Second)
|
| 52 |
+
t.Cleanup(func() { drainBackend(t, b) })
|
| 53 |
+
|
| 54 |
+
cid := uuid.New().String()
|
| 55 |
+
ws, _, err := b.CreateWorkspace(protoWS(cwd, dataDir, cid))
|
| 56 |
+
require.NoError(t, err)
|
| 57 |
+
|
| 58 |
+
ctx, cancel := context.WithCancel(context.Background())
|
| 59 |
+
t.Cleanup(cancel)
|
| 60 |
+
return b, ws, ws.Events(ctx)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
func TestSetConfigField_PublishesConfigChanged(t *testing.T) {
|
| 64 |
+
b, ws, evc := newPublishingWorkspace(t)
|
| 65 |
+
|
| 66 |
+
require.NoError(t, b.SetConfigField(ws.ID, config.ScopeGlobal, "options.debug", true))
|
| 67 |
+
awaitConfigChanged(t, evc, ws.ID)
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
func TestRemoveConfigField_PublishesConfigChanged(t *testing.T) {
|
| 71 |
+
b, ws, evc := newPublishingWorkspace(t)
|
| 72 |
+
|
| 73 |
+
// Seed a field we can then remove. Setting also publishes, so
|
| 74 |
+
// drain the resulting event before testing remove.
|
| 75 |
+
require.NoError(t, b.SetConfigField(ws.ID, config.ScopeGlobal, "options.debug", true))
|
| 76 |
+
awaitConfigChanged(t, evc, ws.ID)
|
| 77 |
+
|
| 78 |
+
require.NoError(t, b.RemoveConfigField(ws.ID, config.ScopeGlobal, "options.debug"))
|
| 79 |
+
awaitConfigChanged(t, evc, ws.ID)
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
func TestUpdatePreferredModel_PublishesConfigChanged(t *testing.T) {
|
| 83 |
+
if raceEnabled {
|
| 84 |
+
// UpdatePreferredModel writes config.Models concurrently
|
| 85 |
+
// with the agent coordinator's async sub-agent builder
|
| 86 |
+
// that reads it via buildAgentModels. That race is
|
| 87 |
+
// pre-existing in the codebase and unrelated to this
|
| 88 |
+
// item; ConfigStore mutations are not currently
|
| 89 |
+
// synchronized against background readers in [app.App].
|
| 90 |
+
// The mutator → publish wiring is unit-tested via
|
| 91 |
+
// publishConfigChanged regardless.
|
| 92 |
+
t.Skip("skipped under -race: pre-existing race between ConfigStore writes and agent coordinator startup")
|
| 93 |
+
}
|
| 94 |
+
b, ws, evc := newPublishingWorkspace(t)
|
| 95 |
+
|
| 96 |
+
model := config.SelectedModel{Provider: "openai", Model: "gpt-4"}
|
| 97 |
+
require.NoError(t, b.UpdatePreferredModel(ws.ID, config.ScopeGlobal, config.SelectedModelTypeLarge, model))
|
| 98 |
+
awaitConfigChanged(t, evc, ws.ID)
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
func TestSetCompactMode_PublishesConfigChanged(t *testing.T) {
|
| 102 |
+
b, ws, evc := newPublishingWorkspace(t)
|
| 103 |
+
|
| 104 |
+
require.NoError(t, b.SetCompactMode(ws.ID, config.ScopeGlobal, true))
|
| 105 |
+
awaitConfigChanged(t, evc, ws.ID)
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
func TestSetProviderAPIKey_PublishesConfigChanged(t *testing.T) {
|
| 109 |
+
b, ws, evc := newPublishingWorkspace(t)
|
| 110 |
+
|
| 111 |
+
require.NoError(t, b.SetProviderAPIKey(ws.ID, config.ScopeGlobal, "openai", "test-key"))
|
| 112 |
+
awaitConfigChanged(t, evc, ws.ID)
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
func TestMarkProjectInitialized_PublishesConfigChanged(t *testing.T) {
|
| 116 |
+
b, ws, evc := newPublishingWorkspace(t)
|
| 117 |
+
|
| 118 |
+
require.NoError(t, b.MarkProjectInitialized(ws.ID))
|
| 119 |
+
awaitConfigChanged(t, evc, ws.ID)
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// TestImportCopilot_PublishesConfigChanged exercises the success path
|
| 123 |
+
// by seeding a token file in the location ImportCopilot scans, then
|
| 124 |
+
// asserting the event fires only when ok==true.
|
| 125 |
+
func TestImportCopilot_PublishesConfigChanged(t *testing.T) {
|
| 126 |
+
// ImportCopilot reads from external user-state directories that
|
| 127 |
+
// vary by OS. Rather than recreate that setup, drive the
|
| 128 |
+
// publishing helper directly and assert ImportCopilot's
|
| 129 |
+
// no-event-on-not-found semantics are preserved.
|
| 130 |
+
b, ws, evc := newPublishingWorkspace(t)
|
| 131 |
+
|
| 132 |
+
// Not-found path: no token exists, so no event must fire.
|
| 133 |
+
_, ok, err := b.ImportCopilot(ws.ID)
|
| 134 |
+
require.NoError(t, err)
|
| 135 |
+
require.False(t, ok, "ImportCopilot should return ok=false when no token is present")
|
| 136 |
+
|
| 137 |
+
select {
|
| 138 |
+
case ev := <-evc:
|
| 139 |
+
if _, isCC := ev.Payload.(pubsub.Event[proto.ConfigChanged]); isCC {
|
| 140 |
+
t.Fatal("ImportCopilot must not publish ConfigChanged when nothing was imported")
|
| 141 |
+
}
|
| 142 |
+
case <-time.After(100 * time.Millisecond):
|
| 143 |
+
// Expected: no ConfigChanged.
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// Helper sanity: publishing manually does fire the event.
|
| 147 |
+
publishConfigChanged(ws)
|
| 148 |
+
awaitConfigChanged(t, evc, ws.ID)
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
// TestRefreshOAuthToken_PublishesConfigChangedOnError verifies that
|
| 152 |
+
// the unhappy path does not publish (mutator returned an error). The
|
| 153 |
+
// happy path requires a real OAuth-capable provider configured with a
|
| 154 |
+
// refreshable token, which is beyond an isolated unit test's scope.
|
| 155 |
+
func TestRefreshOAuthToken_NoEventOnError(t *testing.T) {
|
| 156 |
+
b, ws, evc := newPublishingWorkspace(t)
|
| 157 |
+
|
| 158 |
+
// Provider does not exist → store returns an error → no event.
|
| 159 |
+
err := b.RefreshOAuthToken(context.Background(), ws.ID, config.ScopeGlobal, "no-such-provider")
|
| 160 |
+
require.Error(t, err)
|
| 161 |
+
|
| 162 |
+
select {
|
| 163 |
+
case ev := <-evc:
|
| 164 |
+
if _, isCC := ev.Payload.(pubsub.Event[proto.ConfigChanged]); isCC {
|
| 165 |
+
t.Fatal("RefreshOAuthToken must not publish ConfigChanged when it errors")
|
| 166 |
+
}
|
| 167 |
+
case <-time.After(100 * time.Millisecond):
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// TestDisableDockerMCP_PublishesConfigChanged seeds a Docker MCP entry
|
| 172 |
+
// directly so DisableDockerMCP has something to remove without needing
|
| 173 |
+
// a running Docker daemon for PrepareDockerMCPConfig's availability
|
| 174 |
+
// probe.
|
| 175 |
+
func TestDisableDockerMCP_PublishesConfigChanged(t *testing.T) {
|
| 176 |
+
b, ws, evc := newPublishingWorkspace(t)
|
| 177 |
+
|
| 178 |
+
// Persist a Docker MCP entry directly via the store so the
|
| 179 |
+
// downstream DisableDockerMCP path has something to remove.
|
| 180 |
+
require.NoError(t, ws.Cfg.PersistDockerMCPConfig(config.DockerMCPConfig()))
|
| 181 |
+
drainEvents(evc, 100*time.Millisecond)
|
| 182 |
+
|
| 183 |
+
require.NoError(t, b.DisableDockerMCP(ws.ID))
|
| 184 |
+
awaitConfigChanged(t, evc, ws.ID)
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// drainEvents reads from evc until quiet for the given window. Used
|
| 188 |
+
// to flush events emitted by setup steps so the assertion can target
|
| 189 |
+
// the event from the action under test.
|
| 190 |
+
func drainEvents(evc <-chan pubsub.Event[tea.Msg], quiet time.Duration) {
|
| 191 |
+
for {
|
| 192 |
+
select {
|
| 193 |
+
case <-evc:
|
| 194 |
+
case <-time.After(quiet):
|
| 195 |
+
return
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// TestPublishConfigChanged_NilWorkspaceSafe documents that the helper
|
| 201 |
+
// is safe to call on workspaces without an *app.App (e.g. synthetic
|
| 202 |
+
// test workspaces).
|
| 203 |
+
func TestPublishConfigChanged_NilWorkspaceSafe(t *testing.T) {
|
| 204 |
+
t.Parallel()
|
| 205 |
+
require.NotPanics(t, func() { publishConfigChanged(nil) })
|
| 206 |
+
require.NotPanics(t, func() { publishConfigChanged(&Workspace{}) })
|
| 207 |
+
}
|
internal/backend/events.go
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package backend
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
|
| 6 |
+
tea "charm.land/bubbletea/v2"
|
| 7 |
+
|
| 8 |
+
mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
|
| 9 |
+
"github.com/charmbracelet/crush/internal/app"
|
| 10 |
+
"github.com/charmbracelet/crush/internal/config"
|
| 11 |
+
"github.com/charmbracelet/crush/internal/pubsub"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
// SubscribeEvents returns a per-caller event channel for a workspace.
|
| 15 |
+
// Each caller receives all events; multiple callers do not compete.
|
| 16 |
+
func (b *Backend) SubscribeEvents(ctx context.Context, workspaceID string) (<-chan pubsub.Event[tea.Msg], error) {
|
| 17 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 18 |
+
if err != nil {
|
| 19 |
+
return nil, err
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
return ws.Events(ctx), nil
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// GetLSPStates returns the state of all LSP clients.
|
| 26 |
+
func (b *Backend) GetLSPStates(workspaceID string) (map[string]app.LSPClientInfo, error) {
|
| 27 |
+
_, err := b.GetWorkspace(workspaceID)
|
| 28 |
+
if err != nil {
|
| 29 |
+
return nil, err
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return app.GetLSPStates(), nil
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// GetLSPDiagnostics returns diagnostics for a specific LSP client in
|
| 36 |
+
// the workspace.
|
| 37 |
+
func (b *Backend) GetLSPDiagnostics(workspaceID, lspName string) (any, error) {
|
| 38 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 39 |
+
if err != nil {
|
| 40 |
+
return nil, err
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
for name, client := range ws.LSPManager.Clients().Seq2() {
|
| 44 |
+
if name == lspName {
|
| 45 |
+
return client.GetDiagnostics(), nil
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return nil, ErrLSPClientNotFound
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// GetWorkspaceConfig returns the workspace-level configuration.
|
| 53 |
+
func (b *Backend) GetWorkspaceConfig(workspaceID string) (*config.Config, error) {
|
| 54 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 55 |
+
if err != nil {
|
| 56 |
+
return nil, err
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
return ws.Cfg.Config(), nil
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// GetWorkspaceProviders returns the configured providers for a
|
| 63 |
+
// workspace.
|
| 64 |
+
func (b *Backend) GetWorkspaceProviders(workspaceID string) (any, error) {
|
| 65 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 66 |
+
if err != nil {
|
| 67 |
+
return nil, err
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
providers, _ := config.Providers(ws.Cfg.Config())
|
| 71 |
+
return providers, nil
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// LSPStart starts an LSP server for the given path.
|
| 75 |
+
func (b *Backend) LSPStart(ctx context.Context, workspaceID, path string) error {
|
| 76 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 77 |
+
if err != nil {
|
| 78 |
+
return err
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
ws.LSPManager.Start(ctx, path)
|
| 82 |
+
return nil
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// LSPStopAll stops all LSP servers for a workspace.
|
| 86 |
+
func (b *Backend) LSPStopAll(ctx context.Context, workspaceID string) error {
|
| 87 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 88 |
+
if err != nil {
|
| 89 |
+
return err
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
ws.LSPManager.StopAll(ctx)
|
| 93 |
+
return nil
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// MCPGetStates returns the current state of all MCP clients.
|
| 97 |
+
func (b *Backend) MCPGetStates(_ string) map[string]mcptools.ClientInfo {
|
| 98 |
+
return mcptools.GetStates()
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// MCPRefreshPrompts refreshes prompts for a named MCP client.
|
| 102 |
+
func (b *Backend) MCPRefreshPrompts(ctx context.Context, _ string, name string) {
|
| 103 |
+
mcptools.RefreshPrompts(ctx, name)
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// MCPRefreshResources refreshes resources for a named MCP client.
|
| 107 |
+
func (b *Backend) MCPRefreshResources(ctx context.Context, _ string, name string) {
|
| 108 |
+
mcptools.RefreshResources(ctx, name)
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// MCPPendingAuth returns the MCP servers awaiting OAuth authentication,
|
| 112 |
+
// for clients that need to prompt the user. workspaceID selects the
|
| 113 |
+
// workspace whose config provides the server URLs.
|
| 114 |
+
func (b *Backend) MCPPendingAuth(workspaceID string) ([]mcptools.PendingAuthServer, error) {
|
| 115 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 116 |
+
if err != nil {
|
| 117 |
+
return nil, err
|
| 118 |
+
}
|
| 119 |
+
return mcptools.PendingAuthMCPs(ws.Cfg), nil
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// MCPAuthURL returns the current OAuth authorization URL for a named
|
| 123 |
+
// server, if a flow is in progress.
|
| 124 |
+
func (b *Backend) MCPAuthURL(name string) string {
|
| 125 |
+
return mcptools.MCPAuthURL(name)
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// MCPAuthenticate runs the OAuth flow for a named MCP server with the
|
| 129 |
+
// local browser suppressed: the authorization URL is exposed via
|
| 130 |
+
// MCPAuthURL/MCPPendingAuth for the calling client to open on the user's
|
| 131 |
+
// machine. The call blocks until the flow completes, fails, or ctx is
|
| 132 |
+
// cancelled. workspaceID selects the workspace whose config drives the
|
| 133 |
+
// flow.
|
| 134 |
+
func (b *Backend) MCPAuthenticate(ctx context.Context, workspaceID, name string) error {
|
| 135 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 136 |
+
if err != nil {
|
| 137 |
+
return err
|
| 138 |
+
}
|
| 139 |
+
finish, cancel, err := mcptools.BeginAuth(ws.Cfg, name)
|
| 140 |
+
if err != nil {
|
| 141 |
+
return err
|
| 142 |
+
}
|
| 143 |
+
defer cancel()
|
| 144 |
+
return finish(ctx)
|
| 145 |
+
}
|
internal/backend/filetracker.go
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package backend
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"time"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
// FileTrackerRecordRead records a file read for a session.
|
| 9 |
+
func (b *Backend) FileTrackerRecordRead(ctx context.Context, workspaceID, sessionID, path string) error {
|
| 10 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 11 |
+
if err != nil {
|
| 12 |
+
return err
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
ws.FileTracker.RecordRead(ctx, sessionID, path)
|
| 16 |
+
return nil
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// FileTrackerLastReadTime returns the last read time for a file in a session.
|
| 20 |
+
func (b *Backend) FileTrackerLastReadTime(ctx context.Context, workspaceID, sessionID, path string) (time.Time, error) {
|
| 21 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 22 |
+
if err != nil {
|
| 23 |
+
return time.Time{}, err
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
return ws.FileTracker.LastReadTime(ctx, sessionID, path), nil
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// FileTrackerListReadFiles returns the list of read files for a session.
|
| 30 |
+
func (b *Backend) FileTrackerListReadFiles(ctx context.Context, workspaceID, sessionID string) ([]string, error) {
|
| 31 |
+
ws, err := b.GetWorkspace(workspaceID)
|
| 32 |
+
if err != nil {
|
| 33 |
+
return nil, err
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return ws.FileTracker.ListReadFiles(ctx, sessionID)
|
| 37 |
+
}
|