From dcf6be4a473361765af24908825613e50bfc3b7f Mon Sep 17 00:00:00 2001 From: Steve Sanderson Date: Tue, 4 Aug 2026 15:47:40 +0000 Subject: [PATCH 1/4] Add user prompt transformed hook to all SDKs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/README.md | 1 + docs/features/hooks.md | 19 +-- docs/hooks/README.md | 1 + docs/hooks/hooks-overview.md | 2 + docs/hooks/user-prompt-transformed.md | 118 ++++++++++++++++++ dotnet/src/Client.cs | 2 + dotnet/src/Session.cs | 7 ++ dotnet/src/Types.cs | 54 ++++++++ .../E2E/HookLifecycleAndOutputE2ETests.cs | 36 +++++- go/client.go | 2 + go/internal/e2e/hooks_extended_e2e_test.go | 50 +++++++- go/session.go | 10 ++ go/types.go | 59 +++++++-- go/types_test.go | 12 ++ .../com/github/copilot/CopilotSession.java | 12 ++ .../com/github/copilot/rpc/SessionHooks.java | 26 +++- .../rpc/UserPromptTransformedHandler.java | 26 ++++ .../rpc/UserPromptTransformedHookInput.java | 28 +++++ .../rpc/UserPromptTransformedHookOutput.java | 19 +++ .../java/com/github/copilot/HooksTest.java | 32 +++++ .../github/copilot/SessionHandlerTest.java | 21 ++++ nodejs/src/index.ts | 3 + nodejs/src/session.ts | 1 + nodejs/src/types.ts | 32 +++++ nodejs/test/e2e/hooks_extended.e2e.test.ts | 31 +++++ python/copilot/__init__.py | 6 + python/copilot/session.py | 24 ++++ python/e2e/test_hooks_extended_e2e.py | 29 ++++- rust/src/hooks.rs | 90 +++++++++++++ rust/tests/e2e/hooks_extended.rs | 68 +++++++++- ...ed_hook_and_modify_transformed_prompt.yaml | 10 ++ 31 files changed, 806 insertions(+), 25 deletions(-) create mode 100644 docs/hooks/user-prompt-transformed.md create mode 100644 java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java create mode 100644 java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java create mode 100644 java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java create mode 100644 test/snapshots/hooks_extended/should_invoke_userprompttransformed_hook_and_modify_transformed_prompt.yaml diff --git a/docs/README.md b/docs/README.md index ebb287d98a..3be019f144 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,6 +63,7 @@ Detailed API reference for each session hook. * [Pre-Tool Use](./hooks/pre-tool-use.md): approve, deny, or modify tool calls * [Post-Tool Use](./hooks/post-tool-use.md): transform tool results * [User Prompt Submitted](./hooks/user-prompt-submitted.md): modify or filter user messages +* [User Prompt Transformed](./hooks/user-prompt-transformed.md): inspect or replace model-facing prompts * [Session Lifecycle](./hooks/session-lifecycle.md): session start and end * [Error Handling](./hooks/error-handling.md): custom error handling diff --git a/docs/features/hooks.md b/docs/features/hooks.md index feee55546d..6a78339901 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -9,20 +9,22 @@ A hook is a callback you register once when creating a session. The SDK invokes ```mermaid flowchart LR A[Session starts] -->|onSessionStart| B[User sends prompt] - B -->|onUserPromptSubmitted| C[Agent picks a tool] - C -->|onPreToolUse| D[Tool executes] - D -->|onPostToolUse| E{More work?} - E -->|yes| C - E -->|no| F[Session ends] - F -->|onSessionEnd| G((Done)) - C -.->|error| H[onErrorOccurred] - D -.->|error| H + B -->|onUserPromptSubmitted| C[Runtime transforms prompt] + C -->|onUserPromptTransformed| D[Agent picks a tool] + D -->|onPreToolUse| E[Tool executes] + E -->|onPostToolUse| F{More work?} + F -->|yes| D + F -->|no| G[Session ends] + G -->|onSessionEnd| H((Done)) + D -.->|error| I[onErrorOccurred] + E -.->|error| I ``` | Hook | When it fires | What you can do | | ------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------ | | [`onSessionStart`](../hooks/session-lifecycle.md#session-start) | Session begins (new or resumed) | Inject context, load preferences | | [`onUserPromptSubmitted`](../hooks/user-prompt-submitted.md) | User sends a message | Rewrite prompts, add context, filter input | +| [`onUserPromptTransformed`](../hooks/user-prompt-transformed.md) | Runtime builds the model prompt | Inspect or replace model-facing content | | [`onPreToolUse`](../hooks/pre-tool-use.md) | Before a tool executes | Allow / deny / modify the call | | [`onPostToolUse`](../hooks/post-tool-use.md) | After a tool returns (success only) | Transform results, redact secrets, audit | | [`onPostToolUseFailure`](../hooks/post-tool-use.md#failure-variant) | After a tool returns a failure | Inject retry guidance, log failures | @@ -1055,6 +1057,7 @@ For full type definitions, input/output field tables, and additional examples fo * [Pre-Tool Use](../hooks/pre-tool-use.md) * [Post-Tool Use](../hooks/post-tool-use.md) * [User Prompt Submitted](../hooks/user-prompt-submitted.md) +* [User Prompt Transformed](../hooks/user-prompt-transformed.md) * [Session Lifecycle](../hooks/session-lifecycle.md) * [Error Handling](../hooks/error-handling.md) diff --git a/docs/hooks/README.md b/docs/hooks/README.md index 517be96145..a6c7e1aa66 100644 --- a/docs/hooks/README.md +++ b/docs/hooks/README.md @@ -6,5 +6,6 @@ Detailed API reference for each session hook in the GitHub Copilot SDK. * [Pre-tool use](./pre-tool-use.md): approve, deny, or modify tool calls * [Post-tool use](./post-tool-use.md): transform tool results * [User prompt submitted](./user-prompt-submitted.md): modify or filter user messages +* [User prompt transformed](./user-prompt-transformed.md): inspect or replace model-facing prompts * [Session lifecycle](./session-lifecycle.md): session start and end * [Error handling](./error-handling.md): custom error handling diff --git a/docs/hooks/hooks-overview.md b/docs/hooks/hooks-overview.md index 6de4c3e72c..8d5583e996 100644 --- a/docs/hooks/hooks-overview.md +++ b/docs/hooks/hooks-overview.md @@ -16,6 +16,7 @@ Hooks allow you to intercept and customize the behavior of Copilot sessions at k | [`onPostToolUse`](./post-tool-use.md) | After a tool executes (success only) | Result transformation, logging | | [`onPostToolUseFailure`](./post-tool-use.md#failure-variant) | After a tool execution whose result was a failure | Inject retry guidance, log failures | | [`onUserPromptSubmitted`](./user-prompt-submitted.md) | When user sends a message | Prompt modification, filtering | +| [`onUserPromptTransformed`](./user-prompt-transformed.md) | After runtime prompt transformation | Inspect or replace model-facing content | | [`onSessionStart`](./session-lifecycle.md#session-start) | Session begins | Add context, configure session | | [`onSessionEnd`](./session-lifecycle.md#session-end) | Session ends | Cleanup, analytics | | [`onErrorOccurred`](./error-handling.md) | Error happens | Custom error handling | @@ -263,6 +264,7 @@ const session = await client.createSession({ * **[Pre-Tool Use Hook](./pre-tool-use.md)** - Control tool execution permissions * **[Post-Tool Use Hook](./post-tool-use.md)** - Transform tool results * **[User Prompt Submitted Hook](./user-prompt-submitted.md)** - Modify user prompts +* **[User Prompt Transformed Hook](./user-prompt-transformed.md)** - Replace model-facing prompts * **[Session Lifecycle Hooks](./session-lifecycle.md)** - Session start and end * **[Agent Stop Hook](./session-lifecycle.md#agent-stop)** - Validate completion before the agent stops * **[Error Handling Hook](./error-handling.md)** - Custom error handling diff --git a/docs/hooks/user-prompt-transformed.md b/docs/hooks/user-prompt-transformed.md new file mode 100644 index 0000000000..8d1f27595f --- /dev/null +++ b/docs/hooks/user-prompt-transformed.md @@ -0,0 +1,118 @@ +# User prompt transformed hook + +The `userPromptTransformed` hook runs after the runtime adds generated context to a submitted prompt, but before the resulting content is persisted to session history or sent to the model. + +Use it when you need to inspect or replace the exact model-facing prompt. The `prompt` input contains the user prompt after any `userPromptSubmitted` hooks have run, while `transformedPrompt` also contains runtime-generated context such as ``. + +## Input and output + +| Input field | Type | Description | +| --- | --- | --- | +| `sessionId` | string | Runtime session ID | +| `timestamp` | date/time | Time the hook was invoked | +| `cwd` / `workingDirectory` | string | Current working directory | +| `prompt` | string | Prompt after `userPromptSubmitted` hooks | +| `transformedPrompt` | string | Model-facing prompt after runtime transformations | + +Return no value to leave the transformed prompt unchanged. Return `modifiedTransformedPrompt` to replace the content that is stored in session history and sent to the model. + +## Examples + +
+Node.js / TypeScript + +```typescript +const session = await client.createSession({ + hooks: { + onUserPromptTransformed: async (input) => ({ + modifiedTransformedPrompt: redact(input.transformedPrompt), + }), + }, +}); +``` + +
+ +
+Python + +```python +session = await client.create_session( + hooks={ + "on_user_prompt_transformed": lambda input_data, invocation: { + "modifiedTransformedPrompt": redact(input_data["transformedPrompt"]) + } + } +) +``` + +
+ +
+Go + +```go +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Hooks: &copilot.SessionHooks{ + OnUserPromptTransformed: func(input copilot.UserPromptTransformedHookInput, invocation copilot.HookInvocation) (*copilot.UserPromptTransformedHookOutput, error) { + return &copilot.UserPromptTransformedHookOutput{ + ModifiedTransformedPrompt: copilot.String(redact(input.TransformedPrompt)), + }, nil + }, + }, +}) +``` + +
+ +
+.NET + +```csharp +var session = await client.CreateSessionAsync(new SessionConfig +{ + Hooks = new SessionHooks + { + OnUserPromptTransformed = (input, invocation) => + Task.FromResult(new() + { + ModifiedTransformedPrompt = Redact(input.TransformedPrompt), + }), + }, +}); +``` + +
+ +
+Java + +```java +var hooks = new SessionHooks().setOnUserPromptTransformed((input, invocation) -> + CompletableFuture.completedFuture( + new UserPromptTransformedHookOutput(redact(input.transformedPrompt())))); +``` + +
+ +
+Rust + +```rust +#[async_trait] +impl SessionHooks for MyHooks { + async fn on_user_prompt_transformed( + &self, + input: UserPromptTransformedInput, + _ctx: HookContext, + ) -> Option { + Some(UserPromptTransformedOutput { + modified_transformed_prompt: Some(redact(&input.transformed_prompt)), + }) + } +} +``` + +
+ +The replacement is persisted as the user message content, so resumed sessions replay the modified content unchanged. diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index b1199dac8a..4e271598eb 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1097,6 +1097,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Hooks.OnPostToolUse != null || config.Hooks.OnPostToolUseFailure != null || config.Hooks.OnUserPromptSubmitted != null || + config.Hooks.OnUserPromptTransformed != null || config.Hooks.OnSessionStart != null || config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null || @@ -1327,6 +1328,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Hooks.OnPostToolUse != null || config.Hooks.OnPostToolUseFailure != null || config.Hooks.OnUserPromptSubmitted != null || + config.Hooks.OnUserPromptTransformed != null || config.Hooks.OnSessionStart != null || config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null || diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index e3bd56af91..7c34ded166 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -1613,6 +1613,11 @@ internal void RegisterHooks(SessionHooks hooks) JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.UserPromptSubmittedHookInput)!, invocation) : null, + "userPromptTransformed" => hooks.OnUserPromptTransformed != null + ? await hooks.OnUserPromptTransformed( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.UserPromptTransformedHookInput)!, + invocation) + : null, "sessionStart" => hooks.OnSessionStart != null ? await hooks.OnSessionStart( JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.SessionStartHookInput)!, @@ -2033,5 +2038,7 @@ internal void ThrowIfDisposed() [JsonSerializable(typeof(Attachment))] [JsonSerializable(typeof(UserPromptSubmittedHookInput))] [JsonSerializable(typeof(UserPromptSubmittedHookOutput))] + [JsonSerializable(typeof(UserPromptTransformedHookInput))] + [JsonSerializable(typeof(UserPromptTransformedHookOutput))] internal partial class SessionJsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 1f64b6f9d9..a6a1a8c4eb 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1656,6 +1656,55 @@ public sealed class UserPromptSubmittedHookOutput public bool? SuppressOutput { get; set; } } +/// +/// Input for a user-prompt-transformed hook. +/// +public sealed class UserPromptTransformedHookInput +{ + /// + /// The runtime session ID of the session that triggered the hook. + /// + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// + /// Unix timestamp in milliseconds when the prompt was transformed. + /// + [JsonPropertyName("timestamp")] + [JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))] + public DateTimeOffset Timestamp { get; set; } + + /// + /// Current working directory of the session. + /// + [JsonPropertyName("cwd")] + public string WorkingDirectory { get; set; } = string.Empty; + + /// + /// The user prompt after any user-prompt-submitted hooks have run. + /// + [JsonPropertyName("prompt")] + public string Prompt { get; set; } = string.Empty; + + /// + /// The model-facing prompt after runtime transformations. + /// + [JsonPropertyName("transformedPrompt")] + public string TransformedPrompt { get; set; } = string.Empty; +} + +/// +/// Output for a user-prompt-transformed hook. +/// +public sealed class UserPromptTransformedHookOutput +{ + /// + /// Replacement model-facing prompt to persist and send to the model. + /// + [JsonPropertyName("modifiedTransformedPrompt")] + public string? ModifiedTransformedPrompt { get; set; } +} + /// /// Input for a session-start hook. /// @@ -1967,6 +2016,11 @@ public sealed class SessionHooks /// public Func>? OnUserPromptSubmitted { get; set; } + /// + /// Handler called after the runtime transforms a submitted prompt and before it is stored. + /// + public Func>? OnUserPromptTransformed { get; set; } + /// /// Handler called when a session starts. /// diff --git a/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs b/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs index cd94a2ebd6..decdb3190a 100644 --- a/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs +++ b/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs @@ -11,8 +11,9 @@ namespace GitHub.Copilot.Test.E2E; /// /// E2E coverage for every handler exposed on : /// OnPreToolUse, OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, -/// OnSessionStart, OnSessionEnd, OnErrorOccurred, OnAgentStop. Output-shape behavior -/// (modifiedPrompt / additionalContext / errorHandling / modifiedArgs / +/// OnUserPromptTransformed, OnSessionStart, OnSessionEnd, OnErrorOccurred, +/// OnAgentStop. Output-shape behavior (modifiedPrompt / modifiedTransformedPrompt / +/// additionalContext / errorHandling / modifiedArgs / /// modifiedResult / sessionSummary) is asserted alongside hook invocation. If a /// new handler is added to SessionHooks, add a corresponding test here. /// @@ -163,6 +164,37 @@ public async Task Should_Invoke_UserPromptSubmitted_Hook_And_Modify_Prompt() Assert.Contains("HOOKED_PROMPT", response?.Data.Content ?? string.Empty); } + [Fact] + public async Task Should_Invoke_UserPromptTransformed_Hook_And_Modify_Transformed_Prompt() + { + var inputs = new List(); + var session = await CreateSessionAsync(new SessionConfig + { + Hooks = new SessionHooks + { + OnUserPromptTransformed = (input, invocation) => + { + inputs.Add(input); + Assert.False(string.IsNullOrWhiteSpace(invocation.SessionId)); + return Task.FromResult(new UserPromptTransformedHookOutput + { + ModifiedTransformedPrompt = "Reply with exactly: HOOKED_TRANSFORMED_PROMPT", + }); + }, + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Answer the request above." }); + + Assert.NotEmpty(inputs); + Assert.Contains("Answer the request above.", inputs[0].Prompt); + Assert.Contains("Answer the request above.", inputs[0].TransformedPrompt); + Assert.Contains("", inputs[0].TransformedPrompt); + Assert.True(inputs[0].Timestamp > DateTimeOffset.UnixEpoch); + Assert.False(string.IsNullOrEmpty(inputs[0].WorkingDirectory)); + Assert.Contains("HOOKED_TRANSFORMED_PROMPT", response?.Data.Content ?? string.Empty); + } + [Fact] public async Task Should_Invoke_SessionStart_Hook() { diff --git a/go/client.go b/go/client.go index f867b7db28..eb95130300 100644 --- a/go/client.go +++ b/go/client.go @@ -871,6 +871,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses config.Hooks.OnPostToolUse != nil || config.Hooks.OnPostToolUseFailure != nil || config.Hooks.OnUserPromptSubmitted != nil || + config.Hooks.OnUserPromptTransformed != nil || config.Hooks.OnSessionStart != nil || config.Hooks.OnSessionEnd != nil || config.Hooks.OnErrorOccurred != nil || @@ -1159,6 +1160,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, config.Hooks.OnPostToolUse != nil || config.Hooks.OnPostToolUseFailure != nil || config.Hooks.OnUserPromptSubmitted != nil || + config.Hooks.OnUserPromptTransformed != nil || config.Hooks.OnSessionStart != nil || config.Hooks.OnSessionEnd != nil || config.Hooks.OnErrorOccurred != nil || diff --git a/go/internal/e2e/hooks_extended_e2e_test.go b/go/internal/e2e/hooks_extended_e2e_test.go index 1afb05a133..3b9e723968 100644 --- a/go/internal/e2e/hooks_extended_e2e_test.go +++ b/go/internal/e2e/hooks_extended_e2e_test.go @@ -14,8 +14,9 @@ import ( // Mirrors dotnet/test/HookLifecycleAndOutputTests.cs (snapshot category "hooks_extended"). // // Covers each handler exposed on copilot.SessionHooks: OnPreToolUse, -// OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, OnSessionStart, -// OnSessionEnd, OnErrorOccurred, OnAgentStop. Output-shape behavior (modifiedPrompt / +// OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, +// OnUserPromptTransformed, OnSessionStart, OnSessionEnd, OnErrorOccurred, +// OnAgentStop. Output-shape behavior (modifiedPrompt / modifiedTransformedPrompt / // additionalContext / errorHandling / modifiedArgs / modifiedResult / // sessionSummary) is asserted alongside hook invocation. If a new handler is // added to SessionHooks, add a corresponding test here. @@ -72,6 +73,51 @@ func TestHooksExtendedE2E(t *testing.T) { } }) + t.Run("should invoke userPromptTransformed hook and modify transformed prompt", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var inputs []copilot.UserPromptTransformedHookInput + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnUserPromptTransformed: func(input copilot.UserPromptTransformedHookInput, invocation copilot.HookInvocation) (*copilot.UserPromptTransformedHookOutput, error) { + inputs = append(inputs, input) + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + return &copilot.UserPromptTransformedHookOutput{ + ModifiedTransformedPrompt: copilot.String("Reply with exactly: HOOKED_TRANSFORMED_PROMPT"), + }, nil + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Answer the request above."}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + if len(inputs) == 0 { + t.Fatal("Expected at least one userPromptTransformed hook invocation") + } + if !strings.Contains(inputs[0].Prompt, "Answer the request above.") { + t.Errorf("Expected original prompt in hook input, got %q", inputs[0].Prompt) + } + if !strings.Contains(inputs[0].TransformedPrompt, "Answer the request above.") || + !strings.Contains(inputs[0].TransformedPrompt, "") { + t.Errorf("Expected runtime-transformed prompt in hook input, got %q", inputs[0].TransformedPrompt) + } + if !inputs[0].Timestamp.After(time.UnixMilli(0)) || inputs[0].WorkingDirectory == "" { + t.Error("Expected timestamp and working directory in hook input") + } + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok || !strings.Contains(assistantMessage.Content, "HOOKED_TRANSFORMED_PROMPT") { + t.Errorf("Expected transformed prompt response, got %v", response.Data) + } + }) + t.Run("should invoke sessionStart hook", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/session.go b/go/session.go index a30700ce1c..99939de4a8 100644 --- a/go/session.go +++ b/go/session.go @@ -786,6 +786,16 @@ func (s *Session) handleHooksInvoke(hookType string, rawInput json.RawMessage) ( } return hooks.OnUserPromptSubmitted(input, invocation) + case "userPromptTransformed": + if hooks.OnUserPromptTransformed == nil { + return nil, nil + } + var input UserPromptTransformedHookInput + if err := json.Unmarshal(rawInput, &input); err != nil { + return nil, fmt.Errorf("invalid hook input: %w", err) + } + return hooks.OnUserPromptTransformed(input, invocation) + case "sessionStart": if hooks.OnSessionStart == nil { return nil, nil diff --git a/go/types.go b/go/types.go index d9e92ef504..19689c7074 100644 --- a/go/types.go +++ b/go/types.go @@ -679,6 +679,46 @@ type UserPromptSubmittedHookOutput struct { // UserPromptSubmittedHandler handles user-prompt-submitted hook invocations type UserPromptSubmittedHandler func(input UserPromptSubmittedHookInput, invocation HookInvocation) (*UserPromptSubmittedHookOutput, error) +// UserPromptTransformedHookInput is the input for a user-prompt-transformed hook. +type UserPromptTransformedHookInput struct { + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + WorkingDirectory string `json:"cwd"` + Prompt string `json:"prompt"` + TransformedPrompt string `json:"transformedPrompt"` +} + +// MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. +func (h UserPromptTransformedHookInput) MarshalJSON() ([]byte, error) { + type alias UserPromptTransformedHookInput + return json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + alias + }{Timestamp: h.Timestamp.UnixMilli(), alias: alias(h)}) +} + +// UnmarshalJSON implements json.Unmarshaler, parsing Timestamp from Unix milliseconds. +func (h *UserPromptTransformedHookInput) UnmarshalJSON(data []byte) error { + type alias UserPromptTransformedHookInput + aux := &struct { + Timestamp int64 `json:"timestamp"` + *alias + }{alias: (*alias)(h)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + h.Timestamp = time.UnixMilli(aux.Timestamp) + return nil +} + +// UserPromptTransformedHookOutput is the output for a user-prompt-transformed hook. +type UserPromptTransformedHookOutput struct { + ModifiedTransformedPrompt *string `json:"modifiedTransformedPrompt,omitempty"` +} + +// UserPromptTransformedHandler handles user-prompt-transformed hook invocations. +type UserPromptTransformedHandler func(input UserPromptTransformedHookInput, invocation HookInvocation) (*UserPromptTransformedHookOutput, error) + // SessionStartHookInput is the input for a session-start hook type SessionStartHookInput struct { SessionID string `json:"sessionId"` @@ -899,15 +939,16 @@ type HookInvocation struct { // SessionHooks configures hook handlers for a session type SessionHooks struct { - OnPreToolUse PreToolUseHandler - OnPostToolUse PostToolUseHandler - OnPostToolUseFailure PostToolUseFailureHandler - OnUserPromptSubmitted UserPromptSubmittedHandler - OnSessionStart SessionStartHandler - OnSessionEnd SessionEndHandler - OnErrorOccurred ErrorOccurredHandler - OnAgentStop AgentStopHandler - OnPreMCPToolCall PreMCPToolCallHandler + OnPreToolUse PreToolUseHandler + OnPostToolUse PostToolUseHandler + OnPostToolUseFailure PostToolUseFailureHandler + OnUserPromptSubmitted UserPromptSubmittedHandler + OnUserPromptTransformed UserPromptTransformedHandler + OnSessionStart SessionStartHandler + OnSessionEnd SessionEndHandler + OnErrorOccurred ErrorOccurredHandler + OnAgentStop AgentStopHandler + OnPreMCPToolCall PreMCPToolCallHandler } // MCPServerConfig is implemented by MCP server configuration types. diff --git a/go/types_test.go b/go/types_test.go index a76ebaad40..4195464b33 100644 --- a/go/types_test.go +++ b/go/types_test.go @@ -5,6 +5,18 @@ import ( "testing" ) +func TestUserPromptTransformedHookOutput_PreservesEmptyReplacement(t *testing.T) { + data, err := json.Marshal(UserPromptTransformedHookOutput{ + ModifiedTransformedPrompt: String(""), + }) + if err != nil { + t.Fatalf("failed to marshal hook output: %v", err) + } + if string(data) != `{"modifiedTransformedPrompt":""}` { + t.Fatalf("expected empty replacement to be preserved, got %s", data) + } +} + func TestProviderConfig_JSONIncludesHeaders(t *testing.T) { config := ProviderConfig{ BaseURL: "https://example.com/provider", diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index cb16f55fc9..b977b69a12 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -110,6 +110,7 @@ import com.github.copilot.rpc.UserInputRequest; import com.github.copilot.rpc.UserInputResponse; import com.github.copilot.rpc.UserPromptSubmittedHookInput; +import com.github.copilot.rpc.UserPromptTransformedHookInput; /** * Represents a single conversation session with the Copilot CLI. @@ -1869,6 +1870,17 @@ CompletableFuture handleHooksInvoke(String hookType, JsonNode input) { return promptResult.thenApply(output -> (Object) output); } break; + case "userPromptTransformed" : + if (hooks.getOnUserPromptTransformed() != null) { + UserPromptTransformedHookInput transformedInput = MAPPER.treeToValue(input, + UserPromptTransformedHookInput.class); + var transformedResult = hooks.getOnUserPromptTransformed().handle(transformedInput, invocation); + if (transformedResult == null) { + return CompletableFuture.completedFuture(null); + } + return transformedResult.thenApply(output -> (Object) output); + } + break; case "sessionStart" : if (hooks.getOnSessionStart() != null) { SessionStartHookInput startInput = MAPPER.treeToValue(input, SessionStartHookInput.class); diff --git a/java/src/main/java/com/github/copilot/rpc/SessionHooks.java b/java/src/main/java/com/github/copilot/rpc/SessionHooks.java index 9cf68684dd..10de67d5f7 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionHooks.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionHooks.java @@ -42,6 +42,7 @@ public class SessionHooks { private PostToolUseHandler onPostToolUse; private PostToolUseFailureHandler onPostToolUseFailure; private UserPromptSubmittedHandler onUserPromptSubmitted; + private UserPromptTransformedHandler onUserPromptTransformed; private SessionStartHandler onSessionStart; private SessionEndHandler onSessionEnd; private AgentStopHandler onAgentStop; @@ -161,6 +162,27 @@ public SessionHooks setOnUserPromptSubmitted(UserPromptSubmittedHandler onUserPr return this; } + /** + * Gets the user-prompt-transformed handler. + * + * @return the handler, or {@code null} if not set + */ + public UserPromptTransformedHandler getOnUserPromptTransformed() { + return onUserPromptTransformed; + } + + /** + * Sets the handler called after the runtime transforms a submitted prompt. + * + * @param onUserPromptTransformed + * the handler + * @return this instance for method chaining + */ + public SessionHooks setOnUserPromptTransformed(UserPromptTransformedHandler onUserPromptTransformed) { + this.onUserPromptTransformed = onUserPromptTransformed; + return this; + } + /** * Gets the session-start handler. * @@ -237,7 +259,7 @@ public SessionHooks setOnAgentStop(AgentStopHandler onAgentStop) { */ public boolean hasHooks() { return onPreToolUse != null || onPreMcpToolCall != null || onPostToolUse != null || onPostToolUseFailure != null - || onUserPromptSubmitted != null || onSessionStart != null || onSessionEnd != null - || onAgentStop != null; + || onUserPromptSubmitted != null || onUserPromptTransformed != null || onSessionStart != null + || onSessionEnd != null || onAgentStop != null; } } diff --git a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java new file mode 100644 index 0000000000..901b69bbb5 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.concurrent.CompletableFuture; + +/** + * Handler for user-prompt-transformed hooks. + */ +@FunctionalInterface +public interface UserPromptTransformedHandler { + + /** + * Handles a transformed user prompt before it is stored or sent to the model. + * + * @param input + * the hook input + * @param invocation + * metadata about the hook invocation + * @return a future resolving to the hook output, or {@code null} + */ + CompletableFuture handle(UserPromptTransformedHookInput input, + HookInvocation invocation); +} diff --git a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java new file mode 100644 index 0000000000..195a51cd8c --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Input for user-prompt-transformed hooks. + * + * @param sessionId + * the runtime session ID + * @param timestamp + * Unix timestamp in milliseconds + * @param cwd + * the current working directory + * @param prompt + * the prompt after user-prompt-submitted hooks + * @param transformedPrompt + * the model-facing prompt after runtime transformations + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record UserPromptTransformedHookInput(@JsonProperty("sessionId") String sessionId, + @JsonProperty("timestamp") long timestamp, @JsonProperty("cwd") String cwd, + @JsonProperty("prompt") String prompt, @JsonProperty("transformedPrompt") String transformedPrompt) { +} diff --git a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java new file mode 100644 index 0000000000..02418b1039 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Output for user-prompt-transformed hooks. + * + * @param modifiedTransformedPrompt + * replacement model-facing prompt to persist and send to the model + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record UserPromptTransformedHookOutput( + @JsonProperty("modifiedTransformedPrompt") String modifiedTransformedPrompt) { +} diff --git a/java/src/test/java/com/github/copilot/HooksTest.java b/java/src/test/java/com/github/copilot/HooksTest.java index 98cb962fc0..c3833891cc 100644 --- a/java/src/test/java/com/github/copilot/HooksTest.java +++ b/java/src/test/java/com/github/copilot/HooksTest.java @@ -27,6 +27,8 @@ import com.github.copilot.rpc.PreToolUseHookOutput; import com.github.copilot.rpc.SessionConfig; import com.github.copilot.rpc.SessionHooks; +import com.github.copilot.rpc.UserPromptTransformedHookInput; +import com.github.copilot.rpc.UserPromptTransformedHookOutput; /** * Tests for hooks functionality (pre-tool-use and post-tool-use hooks). @@ -267,4 +269,34 @@ void testInvokeAgentStopHookAndApplyBlockResponse() throws Exception { assertTrue(response.getData().content().contains("AGENT_STOP_CONTINUED")); } } + + @Test + void testInvokeUserPromptTransformedHookAndModifyTransformedPrompt() throws Exception { + ctx.configureForTest("hooks_extended", + "should_invoke_userprompttransformed_hook_and_modify_transformed_prompt"); + + var inputs = new ArrayList(); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnUserPromptTransformed((input, invocation) -> { + assertFalse(invocation.getSessionId().isBlank()); + inputs.add(input); + return CompletableFuture.completedFuture( + new UserPromptTransformedHookOutput("Reply with exactly: HOOKED_TRANSFORMED_PROMPT")); + })); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(config).get(); + var response = session.sendAndWait(new MessageOptions().setPrompt("Answer the request above.")).get(60, + TimeUnit.SECONDS); + + assertFalse(inputs.isEmpty()); + assertTrue(inputs.get(0).prompt().contains("Answer the request above.")); + assertTrue(inputs.get(0).transformedPrompt().contains("Answer the request above.")); + assertTrue(inputs.get(0).transformedPrompt().contains("")); + assertTrue(inputs.get(0).timestamp() > 0); + assertFalse(inputs.get(0).cwd().isBlank()); + assertNotNull(response); + assertTrue(response.getData().content().contains("HOOKED_TRANSFORMED_PROMPT")); + } + } } diff --git a/java/src/test/java/com/github/copilot/SessionHandlerTest.java b/java/src/test/java/com/github/copilot/SessionHandlerTest.java index 05994df8d8..345fdccff9 100644 --- a/java/src/test/java/com/github/copilot/SessionHandlerTest.java +++ b/java/src/test/java/com/github/copilot/SessionHandlerTest.java @@ -26,6 +26,7 @@ import com.github.copilot.rpc.UserInputRequest; import com.github.copilot.rpc.UserInputResponse; import com.github.copilot.rpc.UserPromptSubmittedHookOutput; +import com.github.copilot.rpc.UserPromptTransformedHookOutput; /** * Unit tests for CopilotSession internal handler methods. @@ -225,6 +226,26 @@ void testHandleHooksInvokeUserPromptSubmitted() throws Exception { assertEquals("modified prompt", output.modifiedPrompt()); } + @Test + void testHandleHooksInvokeUserPromptTransformed() throws Exception { + var hooks = new SessionHooks().setOnUserPromptTransformed((hookInput, invocation) -> { + assertEquals("handler-test-session", invocation.getSessionId()); + assertEquals("original prompt", hookInput.prompt()); + assertEquals("transformed prompt", hookInput.transformedPrompt()); + return CompletableFuture.completedFuture(new UserPromptTransformedHookOutput("replacement prompt")); + }); + session.registerHooks(hooks); + + JsonNode input = MAPPER.valueToTree(Map.of("sessionId", "runtime-session", "timestamp", 1735689600L, "cwd", + "/tmp", "prompt", "original prompt", "transformedPrompt", "transformed prompt")); + + Object result = session.handleHooksInvoke("userPromptTransformed", input).get(); + + assertInstanceOf(UserPromptTransformedHookOutput.class, result); + var output = (UserPromptTransformedHookOutput) result; + assertEquals("replacement prompt", output.modifiedTransformedPrompt()); + } + // ===== handleHooksInvoke: sessionStart ===== @Test diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 8d79a71a11..f915a8707e 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -63,6 +63,9 @@ export type { AgentStopHandler, AgentStopHookInput, AgentStopHookOutput, + UserPromptTransformedHandler, + UserPromptTransformedHookInput, + UserPromptTransformedHookOutput, CopilotClientMode, CopilotClientOptions, CopilotExpAssignmentResponse, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 5985d1763b..ed575a5154 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -1881,6 +1881,7 @@ export class CopilotSession { postToolUse: this.hooks.onPostToolUse as GenericHandler | undefined, postToolUseFailure: this.hooks.onPostToolUseFailure as GenericHandler | undefined, userPromptSubmitted: this.hooks.onUserPromptSubmitted as GenericHandler | undefined, + userPromptTransformed: this.hooks.onUserPromptTransformed as GenericHandler | undefined, sessionStart: this.hooks.onSessionStart as GenericHandler | undefined, sessionEnd: this.hooks.onSessionEnd as GenericHandler | undefined, errorOccurred: this.hooks.onErrorOccurred as GenericHandler | undefined, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 0c15d2df4d..b3d572496b 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1437,6 +1437,33 @@ export type UserPromptSubmittedHandler = ( invocation: { sessionId: string } ) => Promise | UserPromptSubmittedHookOutput | void; +/** + * Input for the user-prompt-transformed hook. + * + * This hook runs after the runtime has transformed the submitted prompt with + * generated context, but before it is persisted to session history or sent to + * the model. + */ +export interface UserPromptTransformedHookInput extends BaseHookInput { + prompt: string; + transformedPrompt: string; +} + +/** + * Output for the user-prompt-transformed hook. + */ +export interface UserPromptTransformedHookOutput { + modifiedTransformedPrompt?: string; +} + +/** + * Handler for the user-prompt-transformed hook. + */ +export type UserPromptTransformedHandler = ( + input: UserPromptTransformedHookInput, + invocation: { sessionId: string } +) => Promise | UserPromptTransformedHookOutput | void; + /** * Input for session-start hook */ @@ -1593,6 +1620,11 @@ export interface SessionHooks { */ onUserPromptSubmitted?: UserPromptSubmittedHandler; + /** + * Called after the runtime transforms a submitted prompt and before it is stored. + */ + onUserPromptTransformed?: UserPromptTransformedHandler; + /** * Called when a session starts */ diff --git a/nodejs/test/e2e/hooks_extended.e2e.test.ts b/nodejs/test/e2e/hooks_extended.e2e.test.ts index 5b997adb2c..3ac858650e 100644 --- a/nodejs/test/e2e/hooks_extended.e2e.test.ts +++ b/nodejs/test/e2e/hooks_extended.e2e.test.ts @@ -14,6 +14,7 @@ import type { SessionEndHookInput, SessionStartHookInput, UserPromptSubmittedHookInput, + UserPromptTransformedHookInput, } from "../../src/types.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; @@ -169,6 +170,36 @@ describe("Extended session hooks", async () => { await session.disconnect(); }); + it("should invoke userPromptTransformed hook and modify transformed prompt", async () => { + const inputs: UserPromptTransformedHookInput[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onUserPromptTransformed: async (input, invocation) => { + inputs.push(input); + expect(invocation.sessionId).toBeTruthy(); + return { + modifiedTransformedPrompt: "Reply with exactly: HOOKED_TRANSFORMED_PROMPT", + }; + }, + }, + }); + + const response = await session.sendAndWait({ + prompt: "Answer the request above.", + }); + + expect(inputs.length).toBeGreaterThan(0); + expect(inputs[0].prompt).toContain("Answer the request above."); + expect(inputs[0].transformedPrompt).toContain("Answer the request above."); + expect(inputs[0].transformedPrompt).toContain(""); + expect(inputs[0].timestamp).toBeInstanceOf(Date); + expect(inputs[0].workingDirectory).toBeDefined(); + expect(response?.data.content ?? "").toContain("HOOKED_TRANSFORMED_PROMPT"); + + await session.disconnect(); + }); + it("should invoke sessionStart hook", async () => { const inputs: SessionStartHookInput[] = []; const invocationSessionIds: string[] = []; diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 8b0100df24..678fffbf14 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -173,6 +173,9 @@ UserPromptSubmittedHandler, UserPromptSubmittedHookInput, UserPromptSubmittedHookOutput, + UserPromptTransformedHandler, + UserPromptTransformedHookInput, + UserPromptTransformedHookOutput, ) from .session_fs_provider import ( SessionFsFileInfo, @@ -362,6 +365,9 @@ "UserPromptSubmittedHandler", "UserPromptSubmittedHookInput", "UserPromptSubmittedHookOutput", + "UserPromptTransformedHandler", + "UserPromptTransformedHookInput", + "UserPromptTransformedHookOutput", "convert_mcp_call_tool_result", "create_session_fs_adapter", "define_tool", diff --git a/python/copilot/session.py b/python/copilot/session.py index 193c571b3e..92c24bdd84 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -957,6 +957,28 @@ class UserPromptSubmittedHookOutput(TypedDict, total=False): ] +class UserPromptTransformedHookInput(TypedDict): + """Input for the user-prompt-transformed hook.""" + + sessionId: str + timestamp: datetime + workingDirectory: str + prompt: str + transformedPrompt: str + + +class UserPromptTransformedHookOutput(TypedDict, total=False): + """Output for the user-prompt-transformed hook.""" + + modifiedTransformedPrompt: str + + +UserPromptTransformedHandler = Callable[ + [UserPromptTransformedHookInput, dict[str, str]], + UserPromptTransformedHookOutput | None | Awaitable[UserPromptTransformedHookOutput | None], +] + + class SessionStartHookInput(TypedDict): """Input for session-start hook""" @@ -1063,6 +1085,7 @@ class SessionHooks(TypedDict, total=False): on_post_tool_use: PostToolUseHandler on_post_tool_use_failure: PostToolUseFailureHandler on_user_prompt_submitted: UserPromptSubmittedHandler + on_user_prompt_transformed: UserPromptTransformedHandler on_session_start: SessionStartHandler on_session_end: SessionEndHandler on_error_occurred: ErrorOccurredHandler @@ -2794,6 +2817,7 @@ async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: "postToolUse": hooks.get("on_post_tool_use"), "postToolUseFailure": hooks.get("on_post_tool_use_failure"), "userPromptSubmitted": hooks.get("on_user_prompt_submitted"), + "userPromptTransformed": hooks.get("on_user_prompt_transformed"), "sessionStart": hooks.get("on_session_start"), "sessionEnd": hooks.get("on_session_end"), "errorOccurred": hooks.get("on_error_occurred"), diff --git a/python/e2e/test_hooks_extended_e2e.py b/python/e2e/test_hooks_extended_e2e.py index b38534ea22..7af20f32bb 100644 --- a/python/e2e/test_hooks_extended_e2e.py +++ b/python/e2e/test_hooks_extended_e2e.py @@ -3,7 +3,8 @@ E2E coverage for every handler exposed on ``SessionHooks``: ``on_pre_tool_use``, ``on_post_tool_use``, ``on_post_tool_use_failure``, -``on_user_prompt_submitted``, ``on_session_start``, ``on_session_end``, +``on_user_prompt_submitted``, ``on_user_prompt_transformed``, ``on_session_start``, +``on_session_end``, ``on_error_occurred``, ``on_agent_stop``. Output-shape behavior (modifiedPrompt / additionalContext / errorHandling / modifiedArgs / modifiedResult / sessionSummary) is asserted alongside hook invocation. @@ -48,6 +49,32 @@ async def on_user_prompt_submitted(input_data, invocation): finally: await session.disconnect() + async def test_should_invoke_userprompttransformed_hook_and_modify_transformed_prompt( + self, ctx: E2ETestContext + ): + inputs: list[dict] = [] + + async def on_user_prompt_transformed(input_data, invocation): + assert invocation["session_id"] + inputs.append(input_data) + return {"modifiedTransformedPrompt": "Reply with exactly: HOOKED_TRANSFORMED_PROMPT"} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={"on_user_prompt_transformed": on_user_prompt_transformed}, + ) + try: + response = await session.send_and_wait("Answer the request above.") + assert inputs + assert "Answer the request above." in inputs[0]["prompt"] + assert "Answer the request above." in inputs[0]["transformedPrompt"] + assert "" in inputs[0]["transformedPrompt"] + assert inputs[0]["timestamp"].timestamp() > 0 + assert inputs[0]["workingDirectory"] + assert "HOOKED_TRANSFORMED_PROMPT" in (response.data.content or "") + finally: + await session.disconnect() + async def test_should_invoke_sessionstart_hook(self, ctx: E2ETestContext): inputs: list[dict] = [] invocation_session_ids: list[str] = [] diff --git a/rust/src/hooks.rs b/rust/src/hooks.rs index a2b61ed8b9..4986d6cb18 100644 --- a/rust/src/hooks.rs +++ b/rust/src/hooks.rs @@ -199,6 +199,32 @@ pub struct UserPromptSubmittedOutput { pub suppress_output: Option, } +/// Input for the `userPromptTransformed` hook. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserPromptTransformedInput { + /// The runtime session ID of the session that triggered the hook. + pub session_id: String, + /// Unix timestamp in ms. + pub timestamp: f64, + /// Working directory. + #[serde(rename = "cwd")] + pub working_directory: PathBuf, + /// The prompt after any `userPromptSubmitted` hooks have run. + pub prompt: String, + /// The model-facing prompt after runtime transformations. + pub transformed_prompt: String, +} + +/// Output for the `userPromptTransformed` hook. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserPromptTransformedOutput { + /// Replacement model-facing prompt to persist and send to the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_transformed_prompt: Option, +} + /// Input for the `sessionStart` hook. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -381,6 +407,13 @@ pub enum HookEvent { /// Session context. ctx: HookContext, }, + /// Fired after the runtime transforms a submitted prompt. + UserPromptTransformed { + /// Typed input data. + input: UserPromptTransformedInput, + /// Session context. + ctx: HookContext, + }, /// Fired at session creation or resume. SessionStart { /// Typed input data. @@ -430,6 +463,8 @@ pub enum HookOutput { PostToolUseFailure(PostToolUseFailureOutput), /// Response for a user-prompt-submitted hook. UserPromptSubmitted(UserPromptSubmittedOutput), + /// Response for a user-prompt-transformed hook. + UserPromptTransformed(UserPromptTransformedOutput), /// Response for a session-start hook. SessionStart(SessionStartOutput), /// Response for a session-end hook. @@ -449,6 +484,7 @@ impl HookOutput { Self::PostToolUse(_) => "PostToolUse", Self::PostToolUseFailure(_) => "PostToolUseFailure", Self::UserPromptSubmitted(_) => "UserPromptSubmitted", + Self::UserPromptTransformed(_) => "UserPromptTransformed", Self::SessionStart(_) => "SessionStart", Self::SessionEnd(_) => "SessionEnd", Self::ErrorOccurred(_) => "ErrorOccurred", @@ -506,6 +542,11 @@ pub trait SessionHooks: Send + Sync + 'static { .await .map(HookOutput::UserPromptSubmitted) .unwrap_or(HookOutput::None), + HookEvent::UserPromptTransformed { input, ctx } => self + .on_user_prompt_transformed(input, ctx) + .await + .map(HookOutput::UserPromptTransformed) + .unwrap_or(HookOutput::None), HookEvent::SessionStart { input, ctx } => self .on_session_start(input, ctx) .await @@ -583,6 +624,16 @@ pub trait SessionHooks: Send + Sync + 'static { None } + /// Called after the runtime transforms a submitted prompt. Return + /// `Some(output)` to replace the model-facing content before it is stored. + async fn on_user_prompt_transformed( + &self, + _input: UserPromptTransformedInput, + _ctx: HookContext, + ) -> Option { + None + } + /// Called at session creation or resume. Return `Some(output)` to /// inject startup context. async fn on_session_start( @@ -660,6 +711,10 @@ pub(crate) async fn dispatch_hook( let input: UserPromptSubmittedInput = serde_json::from_value(raw_input)?; HookEvent::UserPromptSubmitted { input, ctx } } + "userPromptTransformed" => { + let input: UserPromptTransformedInput = serde_json::from_value(raw_input)?; + HookEvent::UserPromptTransformed { input, ctx } + } "sessionStart" => { let input: SessionStartInput = serde_json::from_value(raw_input)?; HookEvent::SessionStart { input, ctx } @@ -708,6 +763,9 @@ pub(crate) async fn dispatch_hook( ("userPromptSubmitted", HookOutput::UserPromptSubmitted(o)) => { Some(serde_json::to_value(o)?) } + ("userPromptTransformed", HookOutput::UserPromptTransformed(o)) => { + Some(serde_json::to_value(o)?) + } ("sessionStart", HookOutput::SessionStart(o)) => Some(serde_json::to_value(o)?), ("sessionEnd", HookOutput::SessionEnd(o)) => Some(serde_json::to_value(o)?), ("errorOccurred", HookOutput::ErrorOccurred(o)) => Some(serde_json::to_value(o)?), @@ -753,6 +811,14 @@ mod tests { ..Default::default() }) } + HookEvent::UserPromptTransformed { input, .. } => { + HookOutput::UserPromptTransformed(UserPromptTransformedOutput { + modified_transformed_prompt: Some(format!( + "[transformed] {}", + input.transformed_prompt + )), + }) + } _ => HookOutput::None, } } @@ -813,6 +879,30 @@ mod tests { assert_eq!(result["output"]["modifiedPrompt"], "[prefixed] hello world"); } + #[tokio::test] + async fn dispatch_user_prompt_transformed() { + let hooks = TestHooks; + let input = serde_json::json!({ + "sessionId": "sess-1", + "timestamp": 1234567890, + "cwd": "/tmp", + "prompt": "hello world", + "transformedPrompt": "now\nhello world" + }); + let result = dispatch_hook( + &hooks, + &SessionId::new("sess-1"), + "userPromptTransformed", + input, + ) + .await + .unwrap(); + assert_eq!( + result["output"]["modifiedTransformedPrompt"], + "[transformed] now\nhello world" + ); + } + #[tokio::test] async fn dispatch_unregistered_hook_returns_empty() { let hooks = TestHooks; diff --git a/rust/tests/e2e/hooks_extended.rs b/rust/tests/e2e/hooks_extended.rs index b18460642c..dfd77ed7cd 100644 --- a/rust/tests/e2e/hooks_extended.rs +++ b/rust/tests/e2e/hooks_extended.rs @@ -8,6 +8,7 @@ use github_copilot_sdk::hooks::{ PostToolUseFailureInput, PostToolUseFailureOutput, PostToolUseInput, PostToolUseOutput, PreToolUseInput, PreToolUseOutput, SessionEndInput, SessionEndOutput, SessionHooks, SessionStartInput, SessionStartOutput, UserPromptSubmittedInput, UserPromptSubmittedOutput, + UserPromptTransformedInput, UserPromptTransformedOutput, }; use github_copilot_sdk::tool::ToolHandler; use github_copilot_sdk::{Error, SessionConfig, Tool, ToolInvocation, ToolResult}; @@ -189,6 +190,50 @@ async fn should_invoke_userpromptsubmitted_hook_and_modify_prompt() { .await; } +#[tokio::test] +async fn should_invoke_userprompttransformed_hook_and_modify_transformed_prompt() { + super::support::with_shared_e2e_context( + &E2E, + "hooks_extended", + "should_invoke_userprompttransformed_hook_and_modify_transformed_prompt", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(UserPromptTransformedHooks { tx })), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Answer the request above.") + .await + .expect("send") + .expect("assistant message"); + let input = recv_with_timeout(&mut rx, "userPromptTransformed hook").await; + assert!(input.prompt.contains("Answer the request above.")); + assert!( + input + .transformed_prompt + .contains("Answer the request above.") + ); + assert!(input.transformed_prompt.contains("")); + assert!(input.timestamp > 0.0); + assert!(!input.working_directory.as_os_str().is_empty()); + assert!(assistant_message_content(&answer).contains("HOOKED_TRANSFORMED_PROMPT")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + #[tokio::test] async fn should_invoke_sessionstart_hook() { super::support::with_shared_e2e_context( @@ -508,6 +553,27 @@ struct AgentStopHooks { call_count: AtomicUsize, } +struct UserPromptTransformedHooks { + tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl SessionHooks for UserPromptTransformedHooks { + async fn on_user_prompt_transformed( + &self, + input: UserPromptTransformedInput, + ctx: HookContext, + ) -> Option { + assert!(!ctx.session_id.as_str().is_empty()); + let _ = self.tx.send(input); + Some(UserPromptTransformedOutput { + modified_transformed_prompt: Some( + "Reply with exactly: HOOKED_TRANSFORMED_PROMPT".to_string(), + ), + }) + } +} + #[async_trait] impl SessionHooks for AgentStopHooks { async fn on_agent_stop( @@ -739,4 +805,4 @@ impl ToolHandler for EchoValueTool { } } static E2E: super::support::SharedE2eGroup = - super::support::SharedE2eGroup::standard("hooks_extended", 11); + super::support::SharedE2eGroup::standard("hooks_extended", 12); diff --git a/test/snapshots/hooks_extended/should_invoke_userprompttransformed_hook_and_modify_transformed_prompt.yaml b/test/snapshots/hooks_extended/should_invoke_userprompttransformed_hook_and_modify_transformed_prompt.yaml new file mode 100644 index 0000000000..db2b029680 --- /dev/null +++ b/test/snapshots/hooks_extended/should_invoke_userprompttransformed_hook_and_modify_transformed_prompt.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Reply with exactly: HOOKED_TRANSFORMED_PROMPT" + - role: assistant + content: HOOKED_TRANSFORMED_PROMPT From bb19ea36ab8383339da512273947227b0eaf6671 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:30:10 +0000 Subject: [PATCH 2/4] Address user prompt hook review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/hooks/user-prompt-transformed.md | 7 ++++++- .../src/main/java/com/github/copilot/rpc/SessionHooks.java | 2 ++ .../github/copilot/rpc/UserPromptTransformedHandler.java | 2 ++ .../github/copilot/rpc/UserPromptTransformedHookInput.java | 1 + .../copilot/rpc/UserPromptTransformedHookOutput.java | 1 + rust/README.md | 2 +- 6 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/hooks/user-prompt-transformed.md b/docs/hooks/user-prompt-transformed.md index 8d1f27595f..f5959a01e9 100644 --- a/docs/hooks/user-prompt-transformed.md +++ b/docs/hooks/user-prompt-transformed.md @@ -19,8 +19,9 @@ Return no value to leave the transformed prompt unchanged. Return `modifiedTrans ## Examples
-Node.js / TypeScript +TypeScript + ```typescript const session = await client.createSession({ hooks: { @@ -36,6 +37,7 @@ const session = await client.createSession({
Python + ```python session = await client.create_session( hooks={ @@ -51,6 +53,7 @@ session = await client.create_session(
Go + ```go session, err := client.CreateSession(ctx, &copilot.SessionConfig{ Hooks: &copilot.SessionHooks{ @@ -68,6 +71,7 @@ session, err := client.CreateSession(ctx, &copilot.SessionConfig{
.NET + ```csharp var session = await client.CreateSessionAsync(new SessionConfig { @@ -87,6 +91,7 @@ var session = await client.CreateSessionAsync(new SessionConfig
Java + ```java var hooks = new SessionHooks().setOnUserPromptTransformed((input, invocation) -> CompletableFuture.completedFuture( diff --git a/java/src/main/java/com/github/copilot/rpc/SessionHooks.java b/java/src/main/java/com/github/copilot/rpc/SessionHooks.java index 10de67d5f7..e476f888ed 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionHooks.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionHooks.java @@ -166,6 +166,7 @@ public SessionHooks setOnUserPromptSubmitted(UserPromptSubmittedHandler onUserPr * Gets the user-prompt-transformed handler. * * @return the handler, or {@code null} if not set + * @since 1.0.11 */ public UserPromptTransformedHandler getOnUserPromptTransformed() { return onUserPromptTransformed; @@ -177,6 +178,7 @@ public UserPromptTransformedHandler getOnUserPromptTransformed() { * @param onUserPromptTransformed * the handler * @return this instance for method chaining + * @since 1.0.11 */ public SessionHooks setOnUserPromptTransformed(UserPromptTransformedHandler onUserPromptTransformed) { this.onUserPromptTransformed = onUserPromptTransformed; diff --git a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java index 901b69bbb5..ac8496078f 100644 --- a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java +++ b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java @@ -8,6 +8,8 @@ /** * Handler for user-prompt-transformed hooks. + * + * @since 1.0.11 */ @FunctionalInterface public interface UserPromptTransformedHandler { diff --git a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java index 195a51cd8c..ea17596585 100644 --- a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java +++ b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java @@ -20,6 +20,7 @@ * the prompt after user-prompt-submitted hooks * @param transformedPrompt * the model-facing prompt after runtime transformations + * @since 1.0.11 */ @JsonIgnoreProperties(ignoreUnknown = true) public record UserPromptTransformedHookInput(@JsonProperty("sessionId") String sessionId, diff --git a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java index 02418b1039..615f4ea7b9 100644 --- a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java +++ b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java @@ -12,6 +12,7 @@ * * @param modifiedTransformedPrompt * replacement model-facing prompt to persist and send to the model + * @since 1.0.11 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record UserPromptTransformedHookOutput( diff --git a/rust/README.md b/rust/README.md index 0bdaa400c6..3140900447 100644 --- a/rust/README.md +++ b/rust/README.md @@ -320,7 +320,7 @@ let session = client .await?; ``` -**Hook events:** `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmitted`, `SessionStart`, `SessionEnd`, `ErrorOccurred`. Each carries typed input/output structs. `PostToolUse` only fires on success; override `on_post_tool_use_failure` to observe failed tool calls. Return `HookOutput::None` for events you don't handle. +**Hook events:** `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmitted`, `UserPromptTransformed`, `SessionStart`, `SessionEnd`, `ErrorOccurred`. Each carries typed input/output structs. `PostToolUse` only fires on success; override `on_post_tool_use_failure` to observe failed tool calls. Return `HookOutput::None` for events you don't handle. ### System Message Transforms From ea968c108c7bd81d14048ea2126621f5986eedd1 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:36:35 +0000 Subject: [PATCH 3/4] Complete hook registration examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/hooks/user-prompt-transformed.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/hooks/user-prompt-transformed.md b/docs/hooks/user-prompt-transformed.md index f5959a01e9..f7791d78b2 100644 --- a/docs/hooks/user-prompt-transformed.md +++ b/docs/hooks/user-prompt-transformed.md @@ -96,6 +96,8 @@ var session = await client.CreateSessionAsync(new SessionConfig var hooks = new SessionHooks().setOnUserPromptTransformed((input, invocation) -> CompletableFuture.completedFuture( new UserPromptTransformedHookOutput(redact(input.transformedPrompt())))); + +var session = client.createSession(new SessionConfig().setHooks(hooks)).get(); ```
@@ -116,6 +118,10 @@ impl SessionHooks for MyHooks { }) } } + +let session = client + .create_session(SessionConfig::default().with_hooks(Arc::new(MyHooks))) + .await?; ```
From e964fee72bd734a87a07b2f446e3e2789f1269d4 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:42:45 +0000 Subject: [PATCH 4/4] Synchronize transformed hook test inputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/internal/e2e/hooks_extended_e2e_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/go/internal/e2e/hooks_extended_e2e_test.go b/go/internal/e2e/hooks_extended_e2e_test.go index 3b9e723968..5cbba38566 100644 --- a/go/internal/e2e/hooks_extended_e2e_test.go +++ b/go/internal/e2e/hooks_extended_e2e_test.go @@ -76,12 +76,17 @@ func TestHooksExtendedE2E(t *testing.T) { t.Run("should invoke userPromptTransformed hook and modify transformed prompt", func(t *testing.T) { ctx.ConfigureForTest(t) - var inputs []copilot.UserPromptTransformedHookInput + var ( + mu sync.Mutex + inputs []copilot.UserPromptTransformedHookInput + ) session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ OnPermissionRequest: copilot.PermissionHandler.ApproveAll, Hooks: &copilot.SessionHooks{ OnUserPromptTransformed: func(input copilot.UserPromptTransformedHookInput, invocation copilot.HookInvocation) (*copilot.UserPromptTransformedHookOutput, error) { + mu.Lock() inputs = append(inputs, input) + mu.Unlock() if invocation.SessionID == "" { t.Error("Expected non-empty session ID in invocation") } @@ -99,6 +104,9 @@ func TestHooksExtendedE2E(t *testing.T) { if err != nil { t.Fatalf("Failed to send message: %v", err) } + + mu.Lock() + defer mu.Unlock() if len(inputs) == 0 { t.Fatal("Expected at least one userPromptTransformed hook invocation") }