@@ -2,9 +2,11 @@ package claude
22
33import (
44 "context"
5+ "errors"
56 "os"
67 "os/exec"
78 "path/filepath"
9+ "strings"
810 "sync"
911 "testing"
1012)
@@ -66,8 +68,9 @@ func TestRunPromptCtx_AppliesLifecycleHooks(t *testing.T) {
6668 execCommand = originalExecCommand
6769 }()
6870
69- jsonOutput := `[{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"pwd"}}]},"session_id":"hook-session"},{"type":"result","subtype":"success","total_cost_usd":0.25,"duration_ms":12,"duration_api_ms":8,"is_error":false,"num_turns":1,"result":"done","session_id":"hook-session"}]`
70- execCommand = mockExecCommandContext (t , []string {"-p" , "Hook test" , "--output-format" , "json" }, jsonOutput , 0 )
71+ jsonOutput := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"pwd"}}]},"session_id":"hook-session"}` + "\n " +
72+ `{"type":"result","subtype":"success","total_cost_usd":0.25,"duration_ms":12,"duration_api_ms":8,"is_error":false,"num_turns":1,"result":"done","session_id":"hook-session"}` + "\n "
73+ execCommand = mockExecCommandContext (t , []string {"-p" , "Hook test" , "--output-format" , "stream-json" , "--verbose" }, jsonOutput , 0 )
7174
7275 pm := NewPluginManager ()
7376 plugin := & lifecyclePlugin {}
@@ -103,8 +106,8 @@ func TestRunPromptCtx_AppliesLifecycleHooks(t *testing.T) {
103106 if plugin .initCount != 1 {
104107 t .Fatalf ("Initialize count = %d, want 1" , plugin .initCount )
105108 }
106- if plugin .shutdownCount != 1 {
107- t .Fatalf ("Shutdown count = %d, want 1 " , plugin .shutdownCount )
109+ if plugin .shutdownCount != 0 {
110+ t .Fatalf ("Shutdown count = %d, want 0 " , plugin .shutdownCount )
108111 }
109112 if plugin .messageCount != 2 {
110113 t .Fatalf ("Message count = %d, want 2" , plugin .messageCount )
@@ -129,8 +132,8 @@ func TestRunPromptCtx_DoesNotShutdownPreinitializedPluginManager(t *testing.T) {
129132 execCommand = originalExecCommand
130133 }()
131134
132- jsonOutput := `{"type":"result","subtype":"success","total_cost_usd":0.01,"duration_ms":5,"duration_api_ms":5,"is_error":false,"num_turns":1,"result":"ok","session_id":"sticky-session"}`
133- execCommand = mockExecCommandContext (t , []string {"-p" , "Sticky hooks" , "--output-format" , "json" }, jsonOutput , 0 )
135+ jsonOutput := `{"type":"result","subtype":"success","total_cost_usd":0.01,"duration_ms":5,"duration_api_ms":5,"is_error":false,"num_turns":1,"result":"ok","session_id":"sticky-session"}` + " \n "
136+ execCommand = mockExecCommandContext (t , []string {"-p" , "Sticky hooks" , "--output-format" , "stream- json" , "--verbose " }, jsonOutput , 0 )
134137
135138 pm := NewPluginManager ()
136139 plugin := & lifecyclePlugin {}
@@ -163,6 +166,123 @@ func TestRunPromptCtx_DoesNotShutdownPreinitializedPluginManager(t *testing.T) {
163166 }
164167}
165168
169+ func TestRunPromptCtx_ReusesLazyInitializedPluginManager (t * testing.T ) {
170+ originalExecCommand := execCommand
171+ defer func () {
172+ execCommand = originalExecCommand
173+ }()
174+
175+ jsonOutput := `{"type":"result","subtype":"success","total_cost_usd":0.01,"duration_ms":5,"duration_api_ms":5,"is_error":false,"num_turns":1,"result":"ok","session_id":"sticky-session"}` + "\n "
176+ execCommand = mockExecCommandContext (t , []string {"-p" , "Sticky hooks" , "--output-format" , "stream-json" , "--verbose" }, jsonOutput , 0 )
177+
178+ pm := NewPluginManager ()
179+ plugin := & lifecyclePlugin {}
180+ if err := pm .Register (plugin , nil ); err != nil {
181+ t .Fatalf ("Register() error = %v" , err )
182+ }
183+
184+ client := NewClient ("claude" )
185+ for i := 0 ; i < 2 ; i ++ {
186+ if _ , err := client .RunPromptCtx (context .Background (), "Sticky hooks" , & RunOptions {
187+ Format : JSONOutput ,
188+ PluginManager : pm ,
189+ }); err != nil {
190+ t .Fatalf ("RunPromptCtx() error = %v" , err )
191+ }
192+ }
193+
194+ plugin .mu .Lock ()
195+ defer plugin .mu .Unlock ()
196+
197+ if plugin .initCount != 1 {
198+ t .Fatalf ("Initialize count = %d, want 1" , plugin .initCount )
199+ }
200+ if plugin .shutdownCount != 0 {
201+ t .Fatalf ("Shutdown count = %d, want 0" , plugin .shutdownCount )
202+ }
203+ }
204+
205+ func TestRunFromStdinCtx_AppliesLifecycleHooks (t * testing.T ) {
206+ originalExecCommand := execCommand
207+ defer func () {
208+ execCommand = originalExecCommand
209+ }()
210+
211+ jsonOutput := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"README.md"}}]},"session_id":"stdin-hook-session"}` + "\n " +
212+ `{"type":"result","subtype":"success","total_cost_usd":0.05,"duration_ms":7,"duration_api_ms":5,"is_error":false,"num_turns":1,"result":"stdin-done","session_id":"stdin-hook-session"}` + "\n "
213+ execCommand = mockExecCommandContext (t , []string {"-p" , "Hook stdin" , "--output-format" , "stream-json" , "--verbose" }, jsonOutput , 0 )
214+
215+ pm := NewPluginManager ()
216+ plugin := & lifecyclePlugin {}
217+ if err := pm .Register (plugin , nil ); err != nil {
218+ t .Fatalf ("Register() error = %v" , err )
219+ }
220+
221+ client := NewClient ("claude" )
222+ result , err := client .RunFromStdinCtx (context .Background (), strings .NewReader ("stdin body" ), "Hook stdin" , & RunOptions {
223+ Format : JSONOutput ,
224+ PluginManager : pm ,
225+ })
226+ if err != nil {
227+ t .Fatalf ("RunFromStdinCtx() error = %v" , err )
228+ }
229+ if result .Result != "stdin-done" {
230+ t .Fatalf ("Result = %q, want %q" , result .Result , "stdin-done" )
231+ }
232+
233+ plugin .mu .Lock ()
234+ defer plugin .mu .Unlock ()
235+
236+ if plugin .initCount != 1 {
237+ t .Fatalf ("Initialize count = %d, want 1" , plugin .initCount )
238+ }
239+ if plugin .shutdownCount != 0 {
240+ t .Fatalf ("Shutdown count = %d, want 0" , plugin .shutdownCount )
241+ }
242+ if plugin .messageCount != 2 {
243+ t .Fatalf ("Message count = %d, want 2" , plugin .messageCount )
244+ }
245+ if plugin .completeCount != 1 {
246+ t .Fatalf ("Complete count = %d, want 1" , plugin .completeCount )
247+ }
248+ if len (plugin .toolCalls ) != 1 || plugin .toolCalls [0 ] != "Read" {
249+ t .Fatalf ("Tool calls = %v, want [Read]" , plugin .toolCalls )
250+ }
251+ if plugin .lastToolInput .FilePath != "README.md" {
252+ t .Fatalf ("Tool file path = %q, want %q" , plugin .lastToolInput .FilePath , "README.md" )
253+ }
254+ }
255+
256+ func TestRunPromptCtx_BudgetExceededReturnsResultAndError (t * testing.T ) {
257+ originalExecCommand := execCommand
258+ defer func () {
259+ execCommand = originalExecCommand
260+ }()
261+
262+ jsonOutput := `{"type":"result","subtype":"success","total_cost_usd":0.25,"duration_ms":12,"duration_api_ms":8,"is_error":false,"num_turns":1,"result":"done","session_id":"budget-session"}`
263+ execCommand = mockExecCommandContext (t , []string {"-p" , "Budget test" , "--output-format" , "json" }, jsonOutput , 0 )
264+
265+ tracker := NewBudgetTracker (& BudgetConfig {MaxBudgetUSD : 0.10 })
266+ client := NewClient ("claude" )
267+
268+ result , err := client .RunPromptCtx (context .Background (), "Budget test" , & RunOptions {
269+ Format : JSONOutput ,
270+ BudgetTracker : tracker ,
271+ })
272+ if ! errors .Is (err , ErrBudgetExceeded ) {
273+ t .Fatalf ("RunPromptCtx() error = %v, want ErrBudgetExceeded" , err )
274+ }
275+ if result == nil {
276+ t .Fatal ("RunPromptCtx() result = nil, want result" )
277+ }
278+ if result .Result != "done" {
279+ t .Fatalf ("Result = %q, want %q" , result .Result , "done" )
280+ }
281+ if tracker .TotalSpent () != 0.25 {
282+ t .Fatalf ("TotalSpent = %f, want %f" , tracker .TotalSpent (), 0.25 )
283+ }
284+ }
285+
166286func TestStreamPrompt_AppliesLifecycleHooks (t * testing.T ) {
167287 originalExecCommand := execCommand
168288 defer func () {
@@ -210,8 +330,8 @@ func TestStreamPrompt_AppliesLifecycleHooks(t *testing.T) {
210330 if plugin .initCount != 1 {
211331 t .Fatalf ("Initialize count = %d, want 1" , plugin .initCount )
212332 }
213- if plugin .shutdownCount != 1 {
214- t .Fatalf ("Shutdown count = %d, want 1 " , plugin .shutdownCount )
333+ if plugin .shutdownCount != 0 {
334+ t .Fatalf ("Shutdown count = %d, want 0 " , plugin .shutdownCount )
215335 }
216336 if plugin .messageCount != 2 {
217337 t .Fatalf ("Message count = %d, want 2" , plugin .messageCount )
@@ -227,6 +347,49 @@ func TestStreamPrompt_AppliesLifecycleHooks(t *testing.T) {
227347 }
228348}
229349
350+ func TestStreamPrompt_BudgetExceededReturnsResultBeforeError (t * testing.T ) {
351+ originalExecCommand := execCommand
352+ defer func () {
353+ execCommand = originalExecCommand
354+ }()
355+
356+ mockBinary := buildStreamingMockBinary (t )
357+ execCommand = func (ctx context.Context , name string , arg ... string ) * exec.Cmd {
358+ return exec .Command (mockBinary )
359+ }
360+
361+ tracker := NewBudgetTracker (& BudgetConfig {MaxBudgetUSD : 0.10 })
362+ client := NewClient ("claude" )
363+ messageCh , errCh := client .StreamPrompt (context .Background (), "Stream hooks" , & RunOptions {
364+ BudgetTracker : tracker ,
365+ })
366+
367+ var gotMessages []Message
368+ for msg := range messageCh {
369+ gotMessages = append (gotMessages , msg )
370+ }
371+
372+ var gotErr error
373+ for err := range errCh {
374+ if err != nil {
375+ gotErr = err
376+ }
377+ }
378+
379+ if ! errors .Is (gotErr , ErrBudgetExceeded ) {
380+ t .Fatalf ("StreamPrompt() error = %v, want ErrBudgetExceeded" , gotErr )
381+ }
382+ if len (gotMessages ) != 2 {
383+ t .Fatalf ("Expected 2 streamed messages, got %d" , len (gotMessages ))
384+ }
385+ if gotMessages [len (gotMessages )- 1 ].Type != "result" {
386+ t .Fatalf ("Last streamed message type = %q, want %q" , gotMessages [len (gotMessages )- 1 ].Type , "result" )
387+ }
388+ if tracker .TotalSpent () != 0.4 {
389+ t .Fatalf ("TotalSpent = %f, want %f" , tracker .TotalSpent (), 0.4 )
390+ }
391+ }
392+
230393func buildStreamingMockBinary (t * testing.T ) string {
231394 t .Helper ()
232395
0 commit comments