Repository navigation
rust: errorOccurred / sessionEnd hook inputs fail to deserialize: the CLI sends error as an object, the SDK declares String #2794
Description
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.rsis unchanged between them. - CLI: the object shape was observed on
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.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
errorcarrying 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 JSONApply 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
errorfield. - A new private
HookErrorWireenum, about 40 lines including theinto_text,hook_error_textandoptional_hook_error_texthelpers. - 2 unit tests:
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.
Activity