SaylorTwift HF Staff commited on
Commit
3d7a2d5
·
verified ·
1 Parent(s): 6932c8c

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. internal/agent/.env.sample +5 -0
  2. internal/agent/accepted_run_test.go +231 -0
  3. internal/agent/agent.go +2331 -0
  4. internal/agent/agent_test.go +1353 -0
  5. internal/agent/agent_tool.go +68 -0
  6. internal/agent/agentic_fetch_tool.go +206 -0
  7. internal/agent/aws_sso_refresh.go +158 -0
  8. internal/agent/aws_sso_refresh_test.go +44 -0
  9. internal/agent/common_test.go +206 -0
  10. internal/agent/convert_test.go +94 -0
  11. internal/agent/coordinator.go +1757 -0
  12. internal/agent/coordinator_mcp_gate_test.go +115 -0
  13. internal/agent/coordinator_readiness_test.go +97 -0
  14. internal/agent/coordinator_test.go +792 -0
  15. internal/agent/dispatch_cancel_test.go +447 -0
  16. internal/agent/dispatch_race_test.go +155 -0
  17. internal/agent/errors.go +10 -0
  18. internal/agent/event.go +51 -0
  19. internal/agent/hooked_tool.go +142 -0
  20. internal/agent/hooked_tool_test.go +147 -0
  21. internal/agent/loop_detection.go +92 -0
  22. internal/agent/loop_detection_test.go +205 -0
  23. internal/agent/opencode_routing_test.go +110 -0
  24. internal/agent/prism_model_test.go +101 -0
  25. internal/agent/prompts.go +42 -0
  26. internal/agent/queued_runid_test.go +181 -0
  27. internal/agent/request_timeout.go +123 -0
  28. internal/agent/request_timeout_test.go +286 -0
  29. internal/agent/run_complete_test.go +322 -0
  30. internal/agent/run_marker.go +52 -0
  31. internal/agent/runid.go +33 -0
  32. internal/agent/usage_fallback.go +176 -0
  33. internal/agent/usage_fallback_test.go +339 -0
  34. internal/apigen/docs.html.tmpl +47 -0
  35. internal/apigen/endpoint.go +168 -0
  36. internal/apigen/endpoint_test.go +207 -0
  37. internal/apigen/openapi.go +147 -0
  38. internal/apigen/render.go +94 -0
  39. internal/apigen/schema.go +134 -0
  40. internal/backend/accepted_run_integration_test.go +131 -0
  41. internal/backend/agent.go +276 -0
  42. internal/backend/agent_runcomplete_test.go +163 -0
  43. internal/backend/agent_test.go +164 -0
  44. internal/backend/backend.go +1137 -0
  45. internal/backend/backend_skills_test.go +167 -0
  46. internal/backend/backend_test.go +1784 -0
  47. internal/backend/config.go +347 -0
  48. internal/backend/config_test.go +207 -0
  49. internal/backend/events.go +145 -0
  50. 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, &currentSession, 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}} &middot; Base path <code>{{.Base}}</code> &middot; <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}} &mdash; 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
+ }