Skip to content

rust: errorOccurred / sessionEnd hook inputs fail to deserialize: the CLI sends error as an object, the SDK declares String #2794

Description

@Lukaze

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions