Summary
On the wire, the CLI sends the error field of the errorOccurred hook input as an error object, and the error of sessionEnd as an object too. The input of an errorOccurred hooks.invoke request looks like this:
{
"sessionId": "…",
"timestamp": 0,
"cwd": "…",
"error": {
"name": "Error",
"message": "model timeout",
"stack": "Error: model timeout\n at …"
},
"errorContext": "model_call",
"recoverable": true
}
The sessionEnd input carries an object such as "error": {"message": "…"}.
The Rust SDK declares ErrorOccurredInput.error: String and SessionEndInput.error: Option<String>. As a result, hooks::dispatch_hook fails serde_json::from_value with invalid type: map, expected a string. The session's hooks.invoke handler logs hook dispatch failed at warn level and answers {"output": {}}. SessionHooks::on_error_occurred / on_session_end are never called for these events.
The TypeScript types have the same mismatch: nodejs/src/types.ts, ErrorOccurredHookInput.error: string and SessionEndHookInput.error?: string. It does not throw at runtime there, but the type is wrong.
Affected versions
- SDK rust v1.0.14 and v1.0.15.
hooks.rs is unchanged between them.
- CLI: the object shape was observed on
hooks.invoke frames from CLI 1.0.84-5 and 1.0.85. It was not re-captured on later versions, but we have seen nothing to suggest it changed through 1.0.89.
Code location
rust/src/hooks.rs at rust/v1.0.15:
pub struct SessionEndInput { /* … */
/// Error message, if the session ended due to an error.
#[serde(default)]
pub error: Option<String>,
}
pub struct ErrorOccurredInput { /* … */
/// The error message.
pub error: String,
In hooks::dispatch_hook, the "sessionEnd" / "errorOccurred" arms do let input: SessionEndInput = serde_json::from_value(raw_input)?;.
In rust/src/session.rs, the "hooks.invoke" arm runs warn!(…, "hook dispatch failed") on Err(e) and returns json!({ "output": {} }).
Minimal reproduction
let input = serde_json::json!({
"sessionId": "s", "timestamp": 0, "cwd": "/tmp",
"error": {"name": "Error", "message": "model timeout", "stack": "Error: model timeout"},
"errorContext": "model_call", "recoverable": true
});
let r: Result<copilot::hooks::ErrorOccurredInput, _> = serde_json::from_value(input);
assert!(r.is_err()); // "invalid type: map, expected a string"
End to end: register a SessionHooks that implements on_error_occurred. Then start a session whose model provider refuses the connection, for example a BYOK provider pointing at a closed port. The hook is never called, and the log shows hook dispatch failed.
Expected vs actual
- Expected: the hook receives the input, with
error carrying the error's message.
- Actual: deserialization fails, the hook is skipped, and the CLI receives an empty output.
Proposed fix
Keep the public field types, so the change is non-breaking. Decode both wire shapes through a private untagged helper:
#[derive(Deserialize)]
#[serde(untagged)]
enum HookErrorWire { Text(String), Object(serde_json::Map<String, Value>) }
// Text(s) -> s; Object with string "message" -> message; other object -> compact JSON
Apply it with #[serde(deserialize_with = "hook_error_text")] on ErrorOccurredInput.error, and with #[serde(default, deserialize_with = "optional_hook_error_text")] on SessionEndInput.error. A null or absent value maps to None.
A richer alternative, which is breaking, is a typed HookError { name, message, stack }. Correct the TS types to string | { name?: string; message: string; stack?: string }, or to the object type.
Patch diff summary
rust/src/hooks.rs:
- 2 attribute changes, one on each
error field.
- A new private
HookErrorWire enum, about 40 lines including the into_text, hook_error_text and optional_hook_error_text helpers.
- 2 unit tests:
errorOccurred with the object dispatches and delivers message.
sessionEnd decodes from an object, a string, null and absent, and from an object without message, which falls back to JSON text.
Summary
On the wire, the CLI sends the
errorfield of theerrorOccurredhook input as an error object, and theerrorofsessionEndas an object too. Theinputof anerrorOccurredhooks.invokerequest looks like this:{ "sessionId": "…", "timestamp": 0, "cwd": "…", "error": { "name": "Error", "message": "model timeout", "stack": "Error: model timeout\n at …" }, "errorContext": "model_call", "recoverable": true }The
sessionEndinput carries an object such as"error": {"message": "…"}.The Rust SDK declares
ErrorOccurredInput.error: StringandSessionEndInput.error: Option<String>. As a result,hooks::dispatch_hookfailsserde_json::from_valuewithinvalid type: map, expected a string. The session'shooks.invokehandler logshook dispatch failedat warn level and answers{"output": {}}.SessionHooks::on_error_occurred/on_session_endare never called for these events.The TypeScript types have the same mismatch:
nodejs/src/types.ts,ErrorOccurredHookInput.error: stringandSessionEndHookInput.error?: string. It does not throw at runtime there, but the type is wrong.Affected versions
hooks.rsis unchanged between them.hooks.invokeframes from CLI 1.0.84-5 and 1.0.85. It was not re-captured on later versions, but we have seen nothing to suggest it changed through 1.0.89.Code location
rust/src/hooks.rsatrust/v1.0.15:In
hooks::dispatch_hook, the"sessionEnd"/"errorOccurred"arms dolet input: SessionEndInput = serde_json::from_value(raw_input)?;.In
rust/src/session.rs, the"hooks.invoke"arm runswarn!(…, "hook dispatch failed")onErr(e)and returnsjson!({ "output": {} }).Minimal reproduction
End to end: register a
SessionHooksthat implementson_error_occurred. Then start a session whose model provider refuses the connection, for example a BYOK provider pointing at a closed port. The hook is never called, and the log showshook dispatch failed.Expected vs actual
errorcarrying the error's message.Proposed fix
Keep the public field types, so the change is non-breaking. Decode both wire shapes through a private untagged helper:
Apply it with
#[serde(deserialize_with = "hook_error_text")]onErrorOccurredInput.error, and with#[serde(default, deserialize_with = "optional_hook_error_text")]onSessionEndInput.error. A null or absent value maps toNone.A richer alternative, which is breaking, is a typed
HookError { name, message, stack }. Correct the TS types tostring | { name?: string; message: string; stack?: string }, or to the object type.Patch diff summary
rust/src/hooks.rs:errorfield.HookErrorWireenum, about 40 lines including theinto_text,hook_error_textandoptional_hook_error_texthelpers.errorOccurredwith the object dispatches and deliversmessage.sessionEnddecodes from an object, a string, null and absent, and from an object withoutmessage, which falls back to JSON text.