diff --git a/internal/assistant/client.go b/internal/assistant/client.go index 310dbea8..1d9f8eef 100644 --- a/internal/assistant/client.go +++ b/internal/assistant/client.go @@ -88,8 +88,8 @@ type CompletionRequest struct { OnEvent func(StreamEvent) `json:"-"` OnProviderObserve func(context.Context, *CompletionRequest, int) `json:"-"` OnProviderRequest ProviderRequestHook `json:"-"` - OnToolCall func(context.Context, ToolCallEvent) `json:"-"` - OnToolResult func(context.Context, *ToolEvent) `json:"-"` + OnToolCall func(context.Context, *ToolCallEvent) error `json:"-"` + OnToolResult func(context.Context, *ToolEvent) error `json:"-"` ToolRegistry *tool.Registry `json:"-"` SessionID string `json:"session_id"` SystemPrompt string `json:"system_prompt"` diff --git a/internal/assistant/export_test.go b/internal/assistant/export_test.go new file mode 100644 index 00000000..a191de4b --- /dev/null +++ b/internal/assistant/export_test.go @@ -0,0 +1,15 @@ +package assistant + +import ( + "context" +) + +// DispatchToolCallLifecycleForTest exposes tool call lifecycle dispatch for external package tests. +func (runtime *Runtime) DispatchToolCallLifecycleForTest(ctx context.Context, call *ToolCallEvent) error { + return runtime.dispatchToolCallLifecycle(ctx, call) +} + +// DispatchToolResultLifecycleForTest exposes tool result lifecycle dispatch for external package tests. +func (runtime *Runtime) DispatchToolResultLifecycleForTest(ctx context.Context, event *ToolEvent) error { + return runtime.dispatchToolResultLifecycle(ctx, event) +} diff --git a/internal/assistant/lifecycle.go b/internal/assistant/lifecycle.go index 4561e8be..b1729f0f 100644 --- a/internal/assistant/lifecycle.go +++ b/internal/assistant/lifecycle.go @@ -67,6 +67,8 @@ func (runtime *Runtime) dispatchLifecycle( return extension.LifecycleDispatchResult{ Payload: cloneAnyMap(payload), ProviderRequest: extension.ProviderRequestMutation{Headers: map[string]string{}}, + ToolCall: extension.ToolCallMutation{Arguments: nil}, + ToolResult: extension.ToolResultMutation{Result: nil, DetailsJSON: nil, Error: nil}, Name: string(name), Errors: []string{}, Duration: 0, @@ -227,6 +229,71 @@ func turnEndLifecyclePayload( return payload } +func (runtime *Runtime) dispatchToolCallLifecycle(ctx context.Context, call *ToolCallEvent) error { + if call == nil { + return nil + } + payload := toolCallPayload(*call) + runtime.emit(ctx, string(extension.LifecycleToolCall), payload) + if runtime.extensions == nil { + return nil + } + + result, err := runtime.extensions.DispatchLifecycle(ctx, extension.LifecycleEvent{ + Name: extension.LifecycleToolCall, + Payload: payload, + }) + if err != nil { + return err + } + applyToolCallMutation(call, result.ToolCall) + + return nil +} + +func (runtime *Runtime) dispatchToolResultLifecycle(ctx context.Context, event *ToolEvent) error { + if event == nil { + return nil + } + payload := toolEventPayload(event) + runtime.emit(ctx, string(extension.LifecycleToolResult), payload) + if runtime.extensions != nil { + result, err := runtime.extensions.DispatchLifecycle(ctx, extension.LifecycleEvent{ + Name: extension.LifecycleToolResult, + Payload: payload, + }) + if err != nil { + return err + } + applyToolResultMutation(event, result.ToolResult) + } + if event.Error != "" { + runtime.dispatchObservationalLifecycle(ctx, extension.LifecycleToolError, toolEventPayload(event)) + } + + return nil +} + +func applyToolCallMutation(call *ToolCallEvent, mutation extension.ToolCallMutation) { + if len(mutation.Arguments) == 0 { + return + } + call.Arguments = mutation.Arguments + call.ArgumentsJSON = encodeToolArguments(call.Arguments) +} + +func applyToolResultMutation(event *ToolEvent, mutation extension.ToolResultMutation) { + if mutation.Result != nil { + event.Result = *mutation.Result + } + if mutation.DetailsJSON != nil { + event.DetailsJSON = *mutation.DetailsJSON + } + if mutation.Error != nil { + event.Error = *mutation.Error + } +} + func contextBuildLifecyclePayload( sessionID string, cwd string, diff --git a/internal/assistant/runtime.go b/internal/assistant/runtime.go index 319e21f5..65ec9c90 100644 --- a/internal/assistant/runtime.go +++ b/internal/assistant/runtime.go @@ -827,8 +827,8 @@ func (runtime *Runtime) modelCompletionRequest( OnEvent: onEvent, OnProviderObserve: runtime.emitProviderRequest, OnProviderRequest: runtime.dispatchProviderRequestHook, - OnToolCall: runtime.emitToolCall, - OnToolResult: runtime.emitToolResult, + OnToolCall: runtime.dispatchToolCallLifecycle, + OnToolResult: runtime.dispatchToolResultLifecycle, ToolRegistry: registry, SessionID: sessionID, SystemPrompt: systemPrompt, diff --git a/internal/assistant/runtime_events.go b/internal/assistant/runtime_events.go index 5a2dce5c..346afff8 100644 --- a/internal/assistant/runtime_events.go +++ b/internal/assistant/runtime_events.go @@ -64,21 +64,6 @@ func (runtime *Runtime) emitProviderError(ctx context.Context, request *Completi }) } -func (runtime *Runtime) emitToolCall(ctx context.Context, call ToolCallEvent) { - runtime.dispatchObservationalLifecycle(ctx, extension.LifecycleToolCall, toolCallPayload(call)) -} - -func (runtime *Runtime) emitToolResult(ctx context.Context, event *ToolEvent) { - if event == nil { - return - } - payload := toolEventPayload(event) - runtime.dispatchObservationalLifecycle(ctx, extension.LifecycleToolResult, payload) - if event.Error != "" { - runtime.dispatchObservationalLifecycle(ctx, extension.LifecycleToolError, payload) - } -} - func toolCallPayload(call ToolCallEvent) map[string]any { return map[string]any{ "call_id": call.ID, diff --git a/internal/assistant/runtime_events_test.go b/internal/assistant/runtime_events_test.go index 050e54ed..83f6791f 100644 --- a/internal/assistant/runtime_events_test.go +++ b/internal/assistant/runtime_events_test.go @@ -18,6 +18,7 @@ import ( const ( testCompletionText = "done" testToolName = "read" + testToolPathKey = "path" testToolArgsJSON = `{"path":"README.md"}` ) @@ -101,21 +102,27 @@ func (toolCallbackClient) Complete( request *assistant.CompletionRequest, ) (*assistant.CompletionResult, error) { if request.OnToolCall != nil { - request.OnToolCall(ctx, assistant.ToolCallEvent{ + toolCall := assistant.ToolCallEvent{ Arguments: map[string]any{"path": "README.md"}, ID: "call-1", Name: testToolName, ArgumentsJSON: testToolArgsJSON, - }) + } + if err := request.OnToolCall(ctx, &toolCall); err != nil { + return nil, err + } } if request.OnToolResult != nil { - request.OnToolResult(ctx, &assistant.ToolEvent{ + toolResult := assistant.ToolEvent{ Name: testToolName, ArgumentsJSON: testToolArgsJSON, DetailsJSON: "", Result: "contents", Error: "", - }) + } + if err := request.OnToolResult(ctx, &toolResult); err != nil { + return nil, err + } } return &assistant.CompletionResult{ diff --git a/internal/assistant/runtime_lifecycle_test.go b/internal/assistant/runtime_lifecycle_test.go index 2e5e7f49..055370a7 100644 --- a/internal/assistant/runtime_lifecycle_test.go +++ b/internal/assistant/runtime_lifecycle_test.go @@ -194,8 +194,8 @@ func TestRuntime_PromptEmitsSideEffectMessageAppendEvents(t *testing.T) { Thinking: []string{"reasoning"}, ToolEvents: []assistant.ToolEvent{ { - Name: "read", - ArgumentsJSON: `{"path":"README.md"}`, + Name: testToolName, + ArgumentsJSON: testToolArgsJSON, DetailsJSON: "", Result: "contents", Error: "", diff --git a/internal/assistant/runtime_test.go b/internal/assistant/runtime_test.go index 5f1bfbd3..c87680b3 100644 --- a/internal/assistant/runtime_test.go +++ b/internal/assistant/runtime_test.go @@ -550,8 +550,8 @@ func (partialFailureCompletionClient) Complete( }) request.OnEvent(assistant.StreamEvent{ ToolEvent: &assistant.ToolEvent{ - Name: "read", - ArgumentsJSON: `{"path":"README.md"}`, + Name: testToolName, + ArgumentsJSON: testToolArgsJSON, DetailsJSON: "", Result: "file content", Error: "", diff --git a/internal/assistant/tool_lifecycle_test.go b/internal/assistant/tool_lifecycle_test.go new file mode 100644 index 00000000..d4328b03 --- /dev/null +++ b/internal/assistant/tool_lifecycle_test.go @@ -0,0 +1,154 @@ +package assistant_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/omarluq/librecode/internal/assistant" +) + +const testToolLifecycleError = "boom" + +func TestRuntime_ToolCallLifecycleAppliesArgumentMutation(t *testing.T) { + t.Parallel() + + tests := []struct { + expectedArguments map[string]any + initialArguments map[string]any + name string + lua string + expectedArgumentsJSON string + }{ + { + name: "rewrites path and adds limit", + initialArguments: map[string]any{testToolPathKey: "README.md"}, + lua: ` +local lc = require("librecode") +lc.on("tool_call", function(event) + return { + tool_call = { + arguments = { + path = "changed.txt", + limit = 3, + }, + }, + } +end) +`, + expectedArguments: map[string]any{ + testToolPathKey: "changed.txt", + "limit": float64(3), + }, + expectedArgumentsJSON: `{"limit":3,"path":"changed.txt"}`, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + runtime, _, manager := newTestRuntimeWithManager(t, testCompletionClient{}) + loadRuntimeExtension(t, manager, testCase.lua) + call := assistant.ToolCallEvent{ + Arguments: testCase.initialArguments, + ID: "call-1", + Name: testToolName, + ArgumentsJSON: testToolArgsJSON, + } + + err := runtime.DispatchToolCallLifecycleForTest(context.Background(), &call) + + require.NoError(t, err) + assert.Equal(t, testCase.expectedArguments, call.Arguments) + assert.JSONEq(t, testCase.expectedArgumentsJSON, call.ArgumentsJSON) + }) + } +} + +func TestRuntime_ToolResultLifecycleAppliesResultMutation(t *testing.T) { + t.Parallel() + + tests := []struct { + initialEvent *assistant.ToolEvent + name string + lua string + expectedResult string + expectedDetailsJSON string + expectedError string + }{ + { + name: "redacts result and clears error", + initialEvent: &assistant.ToolEvent{ + Name: testToolName, + ArgumentsJSON: testToolArgsJSON, + DetailsJSON: "{}", + Result: "secret", + Error: testToolLifecycleError, + }, + lua: ` +local lc = require("librecode") +lc.on("tool_result", function(event) + return { + tool_result = { + result = "redacted", + details_json = "{\"redacted\":true}", + error = "", + }, + } +end) +`, + expectedResult: "redacted", + expectedDetailsJSON: `{"redacted":true}`, + expectedError: "", + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + runtime, _, manager := newTestRuntimeWithManager(t, testCompletionClient{}) + loadRuntimeExtension(t, manager, testCase.lua) + + err := runtime.DispatchToolResultLifecycleForTest(context.Background(), testCase.initialEvent) + + require.NoError(t, err) + assert.Equal(t, testCase.expectedResult, testCase.initialEvent.Result) + assert.JSONEq(t, testCase.expectedDetailsJSON, testCase.initialEvent.DetailsJSON) + assert.Equal(t, testCase.expectedError, testCase.initialEvent.Error) + }) + } +} + +func TestRuntime_ToolResultLifecycleDispatchesToolErrorHandlers(t *testing.T) { + t.Parallel() + + runtime, _, manager := newTestRuntimeWithManager(t, testCompletionClient{}) + loadRuntimeExtension(t, manager, ` +local lc = require("librecode") +local seen = "" +lc.on("tool_error", function(event) + seen = event.payload.name .. ":" .. event.payload.error +end) +lc.register_command("seen_tool_error", "seen_tool_error", function() + return seen +end) +`) + event := &assistant.ToolEvent{ + Name: testToolName, + ArgumentsJSON: testToolArgsJSON, + DetailsJSON: "", + Result: testToolLifecycleError, + Error: testToolLifecycleError, + } + + err := runtime.DispatchToolResultLifecycleForTest(context.Background(), event) + + require.NoError(t, err) + output, err := manager.ExecuteCommand(context.Background(), "seen_tool_error", "") + require.NoError(t, err) + assert.Equal(t, "read:boom", output) +} diff --git a/internal/assistant/tool_loop.go b/internal/assistant/tool_loop.go index 6a91c001..bb977ef3 100644 --- a/internal/assistant/tool_loop.go +++ b/internal/assistant/tool_loop.go @@ -35,8 +35,8 @@ func executeToolCalls( cwd string, calls []toolCall, onEvent func(StreamEvent), - onToolCall func(context.Context, ToolCallEvent), - onToolResult func(context.Context, *ToolEvent), + onToolCall func(context.Context, *ToolCallEvent) error, + onToolResult func(context.Context, *ToolEvent) error, ) ([]any, []ToolEvent) { if registry == nil { registry = tool.NewRegistry(cwd) @@ -44,58 +44,133 @@ func executeToolCalls( outputs := make([]any, 0, len(calls)) events := make([]ToolEvent, 0, len(calls)) for _, call := range calls { - emitStreamEvent(onEvent, StreamEvent{ - ToolEvent: nil, - Usage: nil, - Kind: StreamEventToolStart, - Text: call.Name, - }) - if onToolCall != nil { - onToolCall(ctx, ToolCallEvent{ - Arguments: call.Arguments, - ID: call.ID, - Name: call.Name, - ArgumentsJSON: call.ArgumentsJSON, - }) - } - result, err := registry.Execute(ctx, call.Name, call.Arguments) - resultText := result.Text() - detailsJSON := encodeToolDetails(result.Details) - event := ToolEvent{ - Name: call.Name, - ArgumentsJSON: call.ArgumentsJSON, - DetailsJSON: detailsJSON, - Result: resultText, - Error: "", - } - if err != nil { - event.Error = err.Error() - resultText = err.Error() - } - if strings.TrimSpace(resultText) == "" { - resultText = "(tool returned no text output)" - } - event.Result = resultText + event := executeOneToolCall(ctx, registry, call, onEvent, onToolCall, onToolResult) events = append(events, event) - if onToolResult != nil { - onToolResult(ctx, &event) - } - emitStreamEvent(onEvent, StreamEvent{ - ToolEvent: &event, - Usage: nil, - Kind: StreamEventToolResult, - Text: "", - }) - outputs = append(outputs, map[string]any{ - jsonTypeKey: functionCallOutputType, - jsonCallIDKey: call.ID, - jsonOutputKey: toolOutputText(resultText, detailsJSON), - }) + outputs = append(outputs, toolOutputForCall(call.ID, event.Result, event.DetailsJSON)) } return outputs, events } +func executeOneToolCall( + ctx context.Context, + registry *tool.Registry, + call toolCall, + onEvent func(StreamEvent), + onToolCall func(context.Context, *ToolCallEvent) error, + onToolResult func(context.Context, *ToolEvent) error, +) ToolEvent { + emitToolStart(onEvent, call.Name) + callEvent := newToolCallEvent(call) + if err := dispatchToolCallHook(ctx, onToolCall, &callEvent); err != nil { + event := toolLifecycleErrorEvent(callEvent, err) + emitToolResult(onEvent, &event) + + return event + } + event := runToolCall(ctx, registry, callEvent) + if err := dispatchToolResultHook(ctx, onToolResult, &event); err != nil { + event.Error = err.Error() + event.Result = err.Error() + } + emitToolResult(onEvent, &event) + + return event +} + +func emitToolStart(onEvent func(StreamEvent), name string) { + emitStreamEvent(onEvent, StreamEvent{ + ToolEvent: nil, + Usage: nil, + Kind: StreamEventToolStart, + Text: name, + }) +} + +func emitToolResult(onEvent func(StreamEvent), event *ToolEvent) { + emitStreamEvent(onEvent, StreamEvent{ + ToolEvent: event, + Usage: nil, + Kind: StreamEventToolResult, + Text: "", + }) +} + +func newToolCallEvent(call toolCall) ToolCallEvent { + return ToolCallEvent{ + Arguments: call.Arguments, + ID: call.ID, + Name: call.Name, + ArgumentsJSON: call.ArgumentsJSON, + } +} + +func dispatchToolCallHook( + ctx context.Context, + onToolCall func(context.Context, *ToolCallEvent) error, + call *ToolCallEvent, +) error { + if onToolCall == nil { + return nil + } + + return onToolCall(ctx, call) +} + +func runToolCall(ctx context.Context, registry *tool.Registry, call ToolCallEvent) ToolEvent { + result, err := registry.Execute(ctx, call.Name, call.Arguments) + resultText := result.Text() + detailsJSON := encodeToolDetails(result.Details) + if err != nil { + resultText = err.Error() + } + if strings.TrimSpace(resultText) == "" { + resultText = "(tool returned no text output)" + } + event := ToolEvent{ + Name: call.Name, + ArgumentsJSON: call.ArgumentsJSON, + DetailsJSON: detailsJSON, + Result: resultText, + Error: "", + } + if err != nil { + event.Error = err.Error() + } + + return event +} + +func dispatchToolResultHook( + ctx context.Context, + onToolResult func(context.Context, *ToolEvent) error, + event *ToolEvent, +) error { + if onToolResult == nil { + return nil + } + + return onToolResult(ctx, event) +} + +func toolLifecycleErrorEvent(call ToolCallEvent, err error) ToolEvent { + return ToolEvent{ + Name: call.Name, + ArgumentsJSON: call.ArgumentsJSON, + DetailsJSON: "", + Result: err.Error(), + Error: err.Error(), + } +} + +func toolOutputForCall(callID, resultText, detailsJSON string) map[string]any { + return map[string]any{ + jsonTypeKey: functionCallOutputType, + jsonCallIDKey: callID, + jsonOutputKey: toolOutputText(resultText, detailsJSON), + } +} + func finishTextResult(result *CompletionResult, text, emptyCode string) (bool, error) { trimmed := strings.TrimSpace(text) if trimmed == "" { diff --git a/internal/assistant/tool_loop_test.go b/internal/assistant/tool_loop_test.go index 29032ebe..c9e8bab6 100644 --- a/internal/assistant/tool_loop_test.go +++ b/internal/assistant/tool_loop_test.go @@ -68,12 +68,15 @@ func TestExecuteToolCallsInvokesCallbacksAndStreamsEvents(t *testing.T) { func(event StreamEvent) { streamEvents = append(streamEvents, event) }, - func(_ context.Context, event ToolCallEvent) { - toolCalls = append(toolCalls, event) + func(_ context.Context, event *ToolCallEvent) error { + require.NotNil(t, event) + toolCalls = append(toolCalls, *event) + return nil }, - func(_ context.Context, event *ToolEvent) { + func(_ context.Context, event *ToolEvent) error { require.NotNil(t, event) toolResults = append(toolResults, *event) + return nil }, ) diff --git a/internal/extension/lifecycle.go b/internal/extension/lifecycle.go index e3fb78e0..c467c58e 100644 --- a/internal/extension/lifecycle.go +++ b/internal/extension/lifecycle.go @@ -67,6 +67,8 @@ type LifecycleEvent struct { type LifecycleDispatchResult struct { Payload map[string]any `json:"payload"` ProviderRequest ProviderRequestMutation `json:"provider_request"` + ToolCall ToolCallMutation `json:"tool_call"` + ToolResult ToolResultMutation `json:"tool_result"` Name string `json:"name"` Errors []string `json:"errors"` Duration time.Duration `json:"duration"` @@ -80,6 +82,18 @@ type ProviderRequestMutation struct { Headers map[string]string `json:"headers"` } +// ToolCallMutation contains tool call argument changes returned by lifecycle handlers. +type ToolCallMutation struct { + Arguments map[string]any `json:"arguments"` +} + +// ToolResultMutation contains tool result changes returned by lifecycle handlers. +type ToolResultMutation struct { + Result *string `json:"result,omitempty"` + DetailsJSON *string `json:"details_json,omitempty"` + Error *string `json:"error,omitempty"` +} + // LifecycleDispatcher emits runtime-neutral lifecycle events. type LifecycleDispatcher interface { DispatchLifecycle(ctx context.Context, event LifecycleEvent) (LifecycleDispatchResult, error) @@ -94,6 +108,8 @@ func (manager *Manager) DispatchLifecycle(ctx context.Context, event LifecycleEv result := LifecycleDispatchResult{ Payload: cloneMap(event.Payload), ProviderRequest: ProviderRequestMutation{Headers: map[string]string{}}, + ToolCall: ToolCallMutation{Arguments: nil}, + ToolResult: ToolResultMutation{Result: nil, DetailsJSON: nil, Error: nil}, Name: string(event.Name), Errors: []string{}, Duration: 0, @@ -163,6 +179,14 @@ func applyLifecycleLuaResult(result *LifecycleDispatchResult, value lua.LValue) if providerRequest, ok := providerRequestMutationFromLua(providerRequestValue); ok { result.ProviderRequest = mergeProviderRequestMutation(result.ProviderRequest, providerRequest) } + toolCallValue := table.RawGetString("tool_call") + if toolCall, ok := toolCallMutationFromLua(toolCallValue); ok { + result.ToolCall = mergeToolCallMutation(result.ToolCall, toolCall) + } + toolResultValue := table.RawGetString("tool_result") + if toolResult, ok := toolResultMutationFromLua(toolResultValue); ok { + result.ToolResult = mergeToolResultMutation(result.ToolResult, toolResult) + } } func mergeProviderRequestMutation(base, override ProviderRequestMutation) ProviderRequestMutation { @@ -177,6 +201,68 @@ func mergeProviderRequestMutation(base, override ProviderRequestMutation) Provid return merged } +func mergeToolCallMutation(base, override ToolCallMutation) ToolCallMutation { + if len(override.Arguments) == 0 { + return base + } + arguments := cloneMap(base.Arguments) + for key, value := range override.Arguments { + arguments[key] = value + } + + return ToolCallMutation{Arguments: arguments} +} + +func mergeToolResultMutation(base, override ToolResultMutation) ToolResultMutation { + merged := base + if override.Result != nil { + merged.Result = override.Result + } + if override.DetailsJSON != nil { + merged.DetailsJSON = override.DetailsJSON + } + if override.Error != nil { + merged.Error = override.Error + } + + return merged +} + +func toolCallMutationFromLua(value lua.LValue) (ToolCallMutation, bool) { + payload, ok := luaValueToGo(value).(map[string]any) + if !ok { + return ToolCallMutation{Arguments: nil}, false + } + arguments, ok := payload["arguments"].(map[string]any) + if !ok || len(arguments) == 0 { + return ToolCallMutation{Arguments: nil}, false + } + + return ToolCallMutation{Arguments: arguments}, true +} + +func toolResultMutationFromLua(value lua.LValue) (ToolResultMutation, bool) { + payload, ok := luaValueToGo(value).(map[string]any) + if !ok { + return ToolResultMutation{Result: nil, DetailsJSON: nil, Error: nil}, false + } + mutation := ToolResultMutation{Result: nil, DetailsJSON: nil, Error: nil} + if result, ok := payload["result"].(string); ok { + mutation.Result = &result + } + if detailsJSON, ok := payload["details_json"].(string); ok { + mutation.DetailsJSON = &detailsJSON + } + if errorText, ok := payload["error"].(string); ok { + mutation.Error = &errorText + } + if mutation.Result == nil && mutation.DetailsJSON == nil && mutation.Error == nil { + return ToolResultMutation{Result: nil, DetailsJSON: nil, Error: nil}, false + } + + return mutation, true +} + func providerRequestMutationFromLua(value lua.LValue) (ProviderRequestMutation, bool) { payload, ok := luaValueToGo(value).(map[string]any) if !ok { diff --git a/internal/extension/lifecycle_test.go b/internal/extension/lifecycle_test.go index 037d2b63..b81d8acc 100644 --- a/internal/extension/lifecycle_test.go +++ b/internal/extension/lifecycle_test.go @@ -10,6 +10,8 @@ import ( "github.com/omarluq/librecode/internal/extension" ) +const testLifecyclePathKey = "path" + func TestManager_DispatchLifecycleRunsHandlersInPriorityOrder(t *testing.T) { t.Parallel() @@ -131,6 +133,69 @@ end) }, result.ProviderRequest.Headers) } +func TestManager_DispatchLifecycleCollectsToolMutations(t *testing.T) { + t.Parallel() + + manager := loadTestExtension(t, ` +local lc = require("librecode") +lc.on("tool_call", { priority = 10 }, function(event) + return { + tool_call = { + arguments = { + path = "changed.txt", + }, + }, + } +end) +lc.on("tool_call", { priority = 1 }, function(event) + return { + tool_call = { + arguments = { + limit = 5, + }, + }, + } +end) +lc.on("tool_result", function(event) + return { + tool_result = { + result = "filtered", + details_json = "{\"filtered\":true}", + error = "", + }, + } +end) +`) + callResult, err := manager.DispatchLifecycle(context.Background(), extension.LifecycleEvent{ + Name: extension.LifecycleToolCall, + Payload: map[string]any{ + "name": "read", + "arguments": map[string]any{testLifecyclePathKey: "README.md"}, + }, + }) + require.NoError(t, err) + assert.Equal( + t, + map[string]any{"limit": float64(5), testLifecyclePathKey: "changed.txt"}, + callResult.ToolCall.Arguments, + ) + + resultResult, err := manager.DispatchLifecycle(context.Background(), extension.LifecycleEvent{ + Name: extension.LifecycleToolResult, + Payload: map[string]any{ + "name": "read", + "result": "contents", + }, + }) + require.NoError(t, err) + require.NotNil(t, resultResult.ToolResult.Result) + require.NotNil(t, resultResult.ToolResult.DetailsJSON) + require.NotNil(t, resultResult.ToolResult.Error) + assert.Equal(t, "filtered", *resultResult.ToolResult.Result) + assert.JSONEq(t, `{"filtered":true}`, *resultResult.ToolResult.DetailsJSON) + assert.Empty(t, *resultResult.ToolResult.Error) +} + func TestManager_DispatchLifecycleRequiresName(t *testing.T) { t.Parallel()