Skip to content

feat(middleware): add lifecycle middleware with onFinish, onError, onChunk callbacks - #888

Merged
murdore merged 1 commit into
releasefrom
feat/hooks-events
Mar 21, 2026
Merged

murdore merged 1 commit into
releasefrom
feat/hooks-events

Conversation

@murdore

@murdore murdore commented Mar 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds a lifecycle middleware (src/lib/middleware/builtin/lifecycle.ts) to the existing middleware system that provides onFinish, onError, and onChunk callbacks on GenerateOptions and StreamOptions
  • When users pass these callbacks, NeuroLink auto-injects the lifecycle middleware into the pipeline — no manual middleware setup needed
  • Adds isRecoverableError() utility to errorHandling.ts for classifying retryable errors (rate limit, timeout, network, 5xx)
  • Includes continuous test suite (test/continuous-test-suite-middleware.ts) with 8 tests against real NeuroLink instances

Usage

// Generate with lifecycle callbacks
const result = await neurolink.generate({
  input: { text: "Hello" },
  provider: "vertex",
  onFinish: (payload) => console.log(`Done in ${payload.duration}ms`),
  onError: (payload) => console.error(payload.error.message),
});

// Stream with per-chunk callbacks
const stream = await neurolink.stream({
  input: { text: "Tell me a story" },
  provider: "vertex",
  onChunk: (payload) => process.stdout.write(payload.textDelta || ""),
  onFinish: (payload) => console.log(`\nCompleted in ${payload.duration}ms`),
});

Changes

File Change
src/lib/middleware/builtin/lifecycle.ts New lifecycle middleware (wrapGenerate + wrapStream)
src/lib/types/middlewareTypes.ts Callback payload types (LifecycleFinishPayload, etc.)
src/lib/types/generateTypes.ts onFinish, onError, middleware fields on GenerateOptions
src/lib/types/streamTypes.ts onFinish, onError, onChunk fields on StreamOptions
src/lib/utils/errorHandling.ts isRecoverableError() utility
src/lib/middleware/factory.ts Register lifecycle in MiddlewareFactory
src/lib/middleware/index.ts Export createLifecycleMiddleware
src/lib/index.ts Export createLifecycleMiddleware from SDK
src/lib/neurolink.ts Auto-inject lifecycle middleware when callbacks present
package.json Add test:middleware script
test/continuous-test-suite-middleware.ts 8-test continuous suite

Test plan

  • All 14 continuous test suites run against Vertex/Gemini 3.0 Pro — zero regressions
  • Middleware suite: 3 passed, 0 failed, 5 skipped (provider-dependent tests skip when unavailable)
  • Pre-commit hooks pass (check, format, lint, validate, security, build)
  • CI pipeline validation

Summary by CodeRabbit

Release Notes

  • New Features

    • Added lifecycle middleware with callback hooks for monitoring generation and streaming operation events
    • New optional callbacks available: onFinish (triggered on successful completion), onError (triggered on operation failure), and onChunk (triggered during streaming chunks)
  • Tests

    • Added comprehensive test coverage for lifecycle callback functionality across generate and stream operations

Copilot AI review requested due to automatic review settings March 20, 2026 21:17
@vercel

vercel Bot commented Mar 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
neurolink Ready Ready Preview, Comment Mar 21, 2026 3:55am

@coderabbitai

coderabbitai Bot commented Mar 20, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5087f089-4bdb-4b4c-ad24-d42e56f87726

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR introduces a lifecycle middleware system enabling consumers to attach onFinish, onError, and onChunk callbacks to generate/stream operations. The middleware tracks duration, captures usage/error metadata, and invokes hooks non-blockingly without disrupting control flow.

Changes

Cohort / File(s) Summary
Lifecycle Middleware Implementation
src/lib/middleware/builtin/lifecycle.ts, src/lib/middleware/factory.ts, src/lib/middleware/index.ts
Added createLifecycleMiddleware that wraps generate/stream with lifecycle hooks. Registered creator in factory for auto-initialization and per-call configuration. Exported from middleware index.
Lifecycle Type Definitions
src/lib/types/middlewareTypes.ts
Introduced LifecycleFinishPayload, LifecycleErrorPayload, LifecycleChunkPayload and corresponding callback types (OnFinishCallback, OnErrorCallback, OnChunkCallback). Added LifecycleMiddlewareConfig to wire callbacks.
API Surface Extension
src/lib/types/generateTypes.ts, src/lib/types/streamTypes.ts
Extended GenerateOptions and StreamOptions with onFinish, onError, and onChunk callback properties. Added middleware option to GenerateOptions.
Error Classification & Integration
src/lib/utils/errorHandling.ts, src/lib/neurolink.ts, src/lib/index.ts
Added isRecoverableError helper to classify error types. Modified generate() and stream() to inject lifecycle middleware when callbacks are provided. Exported createLifecycleMiddleware from main index.
Testing & Configuration
package.json, test/continuous-test-suite-middleware.ts
Added test:middleware npm script. Implemented comprehensive test suite validating lifecycle callbacks for generate/stream paths, error handling, and isRecoverableError classification across provider/model configurations.

Sequence Diagram

sequenceDiagram
    participant Consumer
    participant NeuroLink
    participant Middleware
    participant Generator
    participant Logger

    Consumer->>NeuroLink: generate(text, {onFinish, onError})
    NeuroLink->>NeuroLink: Merge lifecycle middleware config
    NeuroLink->>Middleware: wrapGenerate()
    Middleware->>Middleware: Record startTime
    Middleware->>Generator: Call doGenerate()
    alt Success
        Generator-->>Middleware: Return {text, usage}
        Middleware->>Middleware: Calculate duration
        Middleware->>Middleware: Invoke onFinish({text, usage, duration}) async
        Middleware->>Logger: Catch/log if callback fails
        Middleware-->>NeuroLink: Return result
    else Error
        Generator-->>Middleware: Throw error
        Middleware->>Middleware: Calculate duration
        Middleware->>Middleware: Check isRecoverableError()
        Middleware->>Middleware: Invoke onError({error, duration, recoverable}) async
        Middleware->>Logger: Catch/log if callback fails
        Middleware-->>NeuroLink: Rethrow error
    end
    NeuroLink-->>Consumer: Return result or throw
Loading
sequenceDiagram
    participant Consumer
    participant NeuroLink
    participant Middleware
    participant Generator
    participant TransformStream
    participant Logger

    Consumer->>NeuroLink: stream(text, {onChunk, onFinish})
    NeuroLink->>NeuroLink: Merge lifecycle middleware config
    NeuroLink->>Middleware: wrapStream()
    Middleware->>Middleware: Record startTime
    Middleware->>Generator: Call doStream()
    Generator-->>Middleware: Return result stream
    Middleware->>TransformStream: Wrap stream with transform
    
    loop Per Chunk
        Consumer->>TransformStream: Read chunk
        TransformStream->>Middleware: transform(chunk)
        Middleware->>Middleware: Increment sequenceNumber
        Middleware->>Middleware: Invoke onChunk({type, textDelta, sequenceNumber}) async
        Middleware->>Logger: Catch/log if callback fails
        TransformStream-->>Consumer: Forward chunk
    end
    
    Consumer->>TransformStream: Stream ends
    TransformStream->>Middleware: flush()
    Middleware->>Middleware: Calculate duration
    Middleware->>Middleware: Invoke onFinish({text: "", duration}) async
    Middleware->>Logger: Catch/log if callback fails
    TransformStream-->>Consumer: Complete stream
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

released

Poem

🐰 A leap through lifecycle's dance,
Where hooks now catch each circumstance—
On finish, error, chunk it flows,
Non-blocking grace as time unfolds.
Your middleware hops with newfound grace! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main addition: a lifecycle middleware with three callback types (onFinish, onError, onChunk) across multiple modules.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/hooks-events

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can disable poems in the walkthrough.

Disable the reviews.poem setting to disable the poems in the walkthrough.

@github-actions

github-actions Bot commented Mar 20, 2026 •

Copy link
Copy Markdown
Contributor

✅ Single Commit Policy - COMPLIANT

Status: Policy requirements met • 1 commit • Valid format • Ready for merge

📊 View validation details

📝 Commit Details

  • Hash: cdbb563b41b94eb72163fda89950ed3009e14c27
  • Message: feat(middleware): add lifecycle middleware with onFinish, onError, onChunk callbacks
  • Author: Sachin Sharma

✅ Validation Results

  • Single commit requirement met
  • No merge commits in branch
  • Semantic commit message format verified
  • Ready for squash merge to release branch

🤖 Automated validation by NeuroLink Single Commit Enforcement

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a built-in “lifecycle” middleware to NeuroLink so consumers can provide onFinish, onError, and onChunk callbacks directly on generate() / stream() calls, with automatic middleware injection and a supporting recoverable-error classifier plus a continuous test suite.

Changes:

  • Introduces createLifecycleMiddleware() (generate + stream wrappers) and lifecycle callback payload types.
  • Extends GenerateOptions / StreamOptions with lifecycle callback fields and auto-injects lifecycle middleware when provided.
  • Adds isRecoverableError() and a new continuous middleware test runner + npm script.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/lib/middleware/builtin/lifecycle.ts Implements lifecycle middleware callbacks for generate/stream.
src/lib/utils/errorHandling.ts Adds isRecoverableError() utility.
src/lib/types/middlewareTypes.ts Adds lifecycle callback payload + config types.
src/lib/types/generateTypes.ts Adds middleware and lifecycle callback options to GenerateOptions.
src/lib/types/streamTypes.ts Adds lifecycle callback options to StreamOptions.
src/lib/neurolink.ts Auto-injects lifecycle middleware based on provided callbacks.
src/lib/middleware/factory.ts Registers lifecycle middleware creator.
src/lib/middleware/index.ts Exports createLifecycleMiddleware.
src/lib/index.ts Exports createLifecycleMiddleware from SDK entrypoint.
test/continuous-test-suite-middleware.ts Adds continuous integration-style tests for lifecycle callbacks and error classification.
package.json Adds test:middleware script.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/middleware/builtin/lifecycle.ts Outdated
Comment on lines +113 to +120
flush() {
if (config.onFinish) {
try {
const callbackResult = config.onFinish({
text: "",
duration: Date.now() - startTime,
});
if (callbackResult instanceof Promise) {

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In wrapStream.flush(), onFinish is invoked with text: "", even though LifecycleFinishPayload.text is documented as “The generated text content”. This makes the callback payload misleading for streaming use cases. Consider accumulating textDelta chunks (or otherwise obtaining the final text) and passing the actual final text to onFinish for streams (and optionally include finishReason / usage when available).

Copilot uses AI. Check for mistakes.
Comment on lines +138 to +141
return {
...result,
stream: result.stream.pipeThrough(transformStream),
};

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrapStream only calls onError when doStream() throws. If the returned stream errors later during consumption, this middleware will never invoke onError, even though consumers will experience a stream failure. To reliably surface streaming errors, consider piping result.stream into a TransformStream via pipeTo(transformStream.writable) and attaching a .catch() handler that triggers onError (returning transformStream.readable to the caller), or otherwise intercept stream errors/cancellation explicitly.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/middleware/builtin/lifecycle.ts Outdated
Comment on lines +98 to +106
// Handle async callbacks non-blocking
if (callbackResult instanceof Promise) {
callbackResult.catch((e) => {
logger.warn(
"[LifecycleMiddleware] onChunk callback error:",
e,
);
});
}

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The async-callback detection uses callbackResult instanceof Promise. That misses thenables and can fail across realms; it also skips error handling for async functions returning a non-native promise implementation. Prefer Promise.resolve(callbackResult).catch(...) (or checking typeof (callbackResult as any)?.then === "function") to make callback error isolation reliable.

Copilot uses AI. Check for mistakes.
Comment on lines +1015 to +1053
export function isRecoverableError(error: Error): boolean {
const message = error.message?.toLowerCase() || "";

// Rate limit errors
if (
message.includes("rate limit") ||
message.includes("429") ||
message.includes("too many requests")
) {
return true;
}

// Timeout errors
if (
message.includes("timeout") ||
message.includes("etimedout") ||
message.includes("timed out")
) {
return true;
}

// Network errors
if (
message.includes("econnreset") ||
message.includes("econnrefused") ||
message.includes("network") ||
message.includes("socket")
) {
return true;
}

// Server errors (5xx)
if (
message.includes("500") ||
message.includes("502") ||
message.includes("503") ||
message.includes("504")
) {
return true;

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isRecoverableError() duplicates most of isRetriableError() logic but (unlike isRetriableError) it ignores NeuroLinkError.retriable, which can lead to inconsistent classification for the same error type. Also, message.includes("500")/etc can match unrelated numbers. Consider: (1) returning error.retriable when error instanceof NeuroLinkError, and (2) using regex with word boundaries for HTTP status codes (or delegating to a shared pattern list) to avoid accidental matches.

Copilot uses AI. Check for mistakes.
Comment on lines +268 to +333
// ============================================
// LIFECYCLE MIDDLEWARE TYPES
// ============================================

/**
* Payload delivered to onFinish callbacks after generation or streaming completes.
*/
export type LifecycleFinishPayload = {
/** The generated text content */
text: string;
/** Token usage from the provider */
usage?: { promptTokens: number; completionTokens: number };
/** Wall-clock duration in milliseconds */
duration: number;
/** Why generation stopped */
finishReason?: string;
};

/**
* Payload delivered to onError callbacks when generation or streaming fails.
*/
export type LifecycleErrorPayload = {
/** The error that occurred */
error: Error;
/** Wall-clock duration until failure in milliseconds */
duration: number;
/** Whether the error is likely recoverable (rate limit, timeout, network) */
recoverable: boolean;
};

/**
* Payload delivered to onChunk callbacks for each streaming chunk.
*/
export type LifecycleChunkPayload = {
/** Chunk type from the AI SDK stream */
type: string;
/** Text content for text-delta chunks */
textDelta?: string;
/** Zero-based chunk sequence number */
sequenceNumber: number;
};

/** Callback invoked when generation or streaming finishes successfully. */
export type OnFinishCallback = (
payload: LifecycleFinishPayload,
) => void | Promise<void>;

/** Callback invoked when generation or streaming encounters an error. */
export type OnErrorCallback = (
payload: LifecycleErrorPayload,
) => void | Promise<void>;

/** Callback invoked for each chunk during streaming. */
export type OnChunkCallback = (
payload: LifecycleChunkPayload,
) => void | Promise<void>;

/**
* Configuration for the lifecycle middleware.
* Pass callbacks to observe generation/streaming lifecycle events.
*/
export type LifecycleMiddlewareConfig = {
onFinish?: OnFinishCallback;
onError?: OnErrorCallback;
onChunk?: OnChunkCallback;
};

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The factory registers a built-in middleware with ID "lifecycle", and the SDK now auto-injects middlewareConfig.lifecycle, but BuiltInMiddlewareType (earlier in this file) does not include "lifecycle". This will cause typing drift/inconsistencies when users reference built-in middleware IDs. Add "lifecycle" to the BuiltInMiddlewareType union to keep the type system aligned with the registry.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/neurolink.ts Outdated
Comment on lines +3293 to +3306
// Auto-inject lifecycle middleware when callbacks are provided
if (options.onFinish || options.onError) {
textOptions.middleware = {
...textOptions.middleware,
middlewareConfig: {
...textOptions.middleware?.middlewareConfig,
lifecycle: {
enabled: true,
config: {
onFinish: options.onFinish,
onError: options.onError,
},
},
},

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-injection overwrites any existing middlewareConfig.lifecycle object with a new { enabled: true, config: { onFinish, onError } }. If a caller already provided middleware.middlewareConfig.lifecycle (e.g., with additional lifecycle options in the future), those settings will be lost. Consider merging with the existing lifecycle config/object (e.g., spread existing lifecycle and lifecycle.config) instead of replacing it wholesale.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/neurolink.ts Outdated
Comment on lines +5677 to +5687
enhancedOptions.middleware = {
...enhancedOptions.middleware,
middlewareConfig: {
...enhancedOptions.middleware?.middlewareConfig,
lifecycle: {
enabled: true,
config: {
onFinish: options.onFinish,
onError: options.onError,
onChunk: options.onChunk,
},

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as generate(): this auto-injection replaces middlewareConfig.lifecycle rather than merging with any existing lifecycle configuration on enhancedOptions.middleware. If callers passed middleware options explicitly, their lifecycle config would be overwritten when onFinish/onError/onChunk are also provided. Consider merging existing lifecycle + lifecycle.config to avoid clobbering user configuration.

Suggested change
enhancedOptions.middleware = {
...enhancedOptions.middleware,
middlewareConfig: {
...enhancedOptions.middleware?.middlewareConfig,
lifecycle: {
enabled: true,
config: {
onFinish: options.onFinish,
onError: options.onError,
onChunk: options.onChunk,
},
const existingMiddlewareConfig =
enhancedOptions.middleware?.middlewareConfig;
const existingLifecycle = existingMiddlewareConfig?.lifecycle;
const existingLifecycleConfig = existingLifecycle?.config ?? {};
const mergedLifecycleConfig = {
...existingLifecycleConfig,
...(options.onFinish !== undefined
? { onFinish: options.onFinish }
: {}),
...(options.onError !== undefined
? { onError: options.onError }
: {}),
...(options.onChunk !== undefined
? { onChunk: options.onChunk }
: {}),
};
enhancedOptions.middleware = {
...enhancedOptions.middleware,
middlewareConfig: {
...existingMiddlewareConfig,
lifecycle: {
...existingLifecycle,
// Ensure lifecycle is enabled when callbacks are provided,
// but do not override an explicit existing `enabled` value.
enabled:
existingLifecycle?.enabled !== undefined
? existingLifecycle.enabled
: true,
config: mergedLifecycleConfig,

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
src/lib/middleware/factory.ts (1)

40-48: Consider extracting duplicated builtInMiddlewareCreators map.

The same builtInMiddlewareCreators map is defined in both initialize() (lines 40-48) and getCreator() (lines 190-198). This duplication means any future middleware addition/removal requires updating two locations.

Consider extracting this to a class-level constant or private property to maintain a single source of truth.

♻️ Optional: Extract to class property
 export class MiddlewareFactory {
   public registry: MiddlewareRegistry;
   public presets = new Map<string, MiddlewarePreset>();
   private options: MiddlewareFactoryOptions;
+  private static readonly builtInMiddlewareCreators: Record<
+    string,
+    (config?: Record<string, unknown>) => NeuroLinkMiddleware
+  > = {
+    analytics: createAnalyticsMiddleware,
+    guardrails: createGuardrailsMiddleware,
+    autoEvaluation: createAutoEvaluationMiddleware,
+    lifecycle: createLifecycleMiddleware,
+  };

Then reference MiddlewareFactory.builtInMiddlewareCreators in both initialize() and getCreator().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/middleware/factory.ts` around lines 40 - 48, Extract the duplicated
builtInMiddlewareCreators map out of the two methods into a single shared
location (e.g., a static class-level constant or a private property on
MiddlewareFactory) and reference that single symbol from both initialize() and
getCreator(); specifically, create MiddlewareFactory.builtInMiddlewareCreators
(or this.builtInMiddlewareCreators) containing the mapping (analytics,
guardrails, autoEvaluation, lifecycle -> their creator functions) and update
initialize() and getCreator() to use that shared map so additions/removals only
need one change.
test/continuous-test-suite-middleware.ts (1)

133-145: Use the exported lifecycle payload types instead of any.

These tests are the main consumer contract for the new callbacks, but any erases the exact payload shape and lets breaking API changes compile silently. Typing them with LifecycleFinishPayload, LifecycleErrorPayload, and LifecycleChunkPayload will make the suite catch callback contract drift at compile time. As per coding guidelines, Maintain strict TypeScript type safety across all modules.

Also applies to: 200-214, 324-335, 395-406, 461-475

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/continuous-test-suite-middleware.ts` around lines 133 - 145, Replace the
untyped runtime payload variables with the exported lifecycle payload types to
enforce compile-time contract checks: change finishPayload (used with the
onFinish callback) to type LifecycleFinishPayload, any error callback payloads
to LifecycleErrorPayload, and streaming/chunk payloads to LifecycleChunkPayload;
update declarations where finishPayload, errorPayload, and chunkPayload are
defined (and their associated callbacks like onFinish/onError/onUpdate) so the
test suite imports and uses LifecycleFinishPayload, LifecycleErrorPayload, and
LifecycleChunkPayload instead of any, and apply the same replacements at the
other noted locations (around lines 200-214, 324-335, 395-406, 461-475) to
ensure strict TypeScript typing across all callback handlers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/middleware/builtin/lifecycle.ts`:
- Around line 39-69: The wrapGenerate() lifecycle currently awaits consumer
callbacks (config.onFinish and config.onError) on the main request path which
can block request/streaming latency; change both usages in the lifecycle
middleware so they are invoked fire-and-forget instead of awaited: call
config.onFinish({...}) and config.onError({...}) without awaiting their Promise,
attach a .catch(...) to each invocation to log errors via logger.warn (as done
now inside the try/catch), and ensure in the onError branch you immediately
rethrow or return the original error path without waiting for the consumer
callback to complete; keep the same payload shape (text, usage, duration,
finishReason and error, duration, recoverable) and reference wrapGenerate(),
config.onFinish, and config.onError when making the change.
- Around line 88-119: The streaming path's flush() in the TransformStream calls
config.onFinish with text: "" instead of the accumulated final output; update
the lifecycle TransformStream to track and provide the final concatenated text
to config.onFinish (e.g., maintain a local accumulator variable updated in
transform(chunk, ...) when chunk.type === "text-delta" or chunk.textDelta
exists), then pass that accumulated text and the existing duration (Date.now() -
startTime) to config.onFinish in flush(); ensure errors from the callback are
handled the same way they are now.
- Around line 76-156: The stream returned from wrapStream must be wrapped so
mid-stream provider/network errors are caught and forwarded to config.onError;
update the code that returns result.stream.pipeThrough(transformStream) to
instead create a new ReadableStream (or TransformStream wrapper) that obtains a
reader via result.stream.getReader(), loops with reader.read(), enqueues chunks
to the downstream controller (and still runs the existing transform logic for
onChunk/onFinish), and in the catch path calls await config.onError({ error: err
instanceof Error ? err : new Error(String(err)), duration: Date.now() -
startTime, recoverable: isRecoverableError(err) }) (with the same try/catch
logging pattern used elsewhere) before controller.error(err) and rethrowing;
ensure the reader is released in finally and preserve sequenceNumber, startTime,
transformStream semantics and existing onChunk/onFinish handling in wrapStream,
transformStream, result.stream, config.onError, isRecoverableError.

In `@src/lib/neurolink.ts`:
- Around line 5675-5691: stream() only injects lifecycle middleware into
enhancedOptions on the happy path, so calls that return early (notably
streamWithWorkflow()) and the fallback in the catch (handleStreamError()) still
use the original options and never run onChunk/onFinish/onError; fix by moving
the lifecycle middleware injection to occur before any early returns or by
ensuring the fallback uses enhancedOptions: update streamWithWorkflow() (and any
early-return path) to apply the same enhancedOptions.middleware lifecycle wiring
(the block currently mutating enhancedOptions.middleware) before returning, or
change the catch path so handleStreamError() is invoked with enhancedOptions
instead of options so lifecycle handlers are present for workflow and
setup-fallback streams.
- Around line 3293-3308: generate() discards caller-provided middleware and can
skip lifecycle hooks because options.middleware is never copied into baseOptions
and lifecycle injection into textOptions happens after code paths that may
return early; fix by copying options.middleware into baseOptions when building
request options (ensure baseOptions.middleware = options.middleware or merged
with existing baseOptions.middleware) and move/perform the lifecycle injection
(merging into textOptions.middleware.middlewareConfig.lifecycle) before any
early returns in generate() and any branches that handle workflow or PPT
requests so onFinish/onError are always applied while preserving any existing
middleware entries.

In `@src/lib/types/middlewareTypes.ts`:
- Around line 268-333: BuiltInMiddlewareType is missing the "lifecycle" literal
so the public union is out of sync with the runtime registry; update the
exported BuiltInMiddlewareType union to include the "lifecycle" string literal
(e.g., add | "lifecycle") so callers can reference LifecycleMiddlewareConfig
without casting, and ensure any related exported types that enumerate built-in
keys (if present) are updated to include "lifecycle" as well; locate the
BuiltInMiddlewareType symbol in this file and add "lifecycle" to its union.

In `@test/continuous-test-suite-middleware.ts`:
- Around line 206-243: The test for sdk.generate currently treats a null
errorPayload as a PASS even if the failure occurred before middleware was
attached; change the logic in the test block that checks errorPayload
(variables: sdk.generate call, errorPayload, generationThrew) to mark the case
where generationThrew is true but errorPayload is null as SKIP (return null)
instead of PASS, update the logTest call to report "SKIP" and a SKIP message,
and apply the same change to the streaming variant test (the corresponding block
around lines 467-501) so skipped scenarios are represented by returning null
with SKIP status.
- Around line 153-175: The tests currently mark PASS when finishPayload (or
chunk payload) is missing, which hides missed lifecycle hooks; change the else
branch that currently logs PASS for a missing payload to instead log SKIP (or
similar skipped status) and return null so the suite doesn't false-pass when
onFinish/onChunk never fired; specifically update the block that inspects
finishPayload (variables finishPayload, content, and call site logTest("onFinish
fires after generation", ...)) to: on missing payload log SKIP and return null,
keep the existing type-check failure path to log FAIL and return false, and the
valid-payload path to log PASS; apply the exact same pattern to the streaming
onFinish and onChunk test blocks referenced.

---

Nitpick comments:
In `@src/lib/middleware/factory.ts`:
- Around line 40-48: Extract the duplicated builtInMiddlewareCreators map out of
the two methods into a single shared location (e.g., a static class-level
constant or a private property on MiddlewareFactory) and reference that single
symbol from both initialize() and getCreator(); specifically, create
MiddlewareFactory.builtInMiddlewareCreators (or this.builtInMiddlewareCreators)
containing the mapping (analytics, guardrails, autoEvaluation, lifecycle ->
their creator functions) and update initialize() and getCreator() to use that
shared map so additions/removals only need one change.

In `@test/continuous-test-suite-middleware.ts`:
- Around line 133-145: Replace the untyped runtime payload variables with the
exported lifecycle payload types to enforce compile-time contract checks: change
finishPayload (used with the onFinish callback) to type LifecycleFinishPayload,
any error callback payloads to LifecycleErrorPayload, and streaming/chunk
payloads to LifecycleChunkPayload; update declarations where finishPayload,
errorPayload, and chunkPayload are defined (and their associated callbacks like
onFinish/onError/onUpdate) so the test suite imports and uses
LifecycleFinishPayload, LifecycleErrorPayload, and LifecycleChunkPayload instead
of any, and apply the same replacements at the other noted locations (around
lines 200-214, 324-335, 395-406, 461-475) to ensure strict TypeScript typing
across all callback handlers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 65a16b93-4f81-4119-a320-bf8a0dec4db8

📥 Commits

Reviewing files that changed from the base of the PR and between 9ac6bc7 and 858c9e6.

📒 Files selected for processing (11)
  • package.json
  • src/lib/index.ts
  • src/lib/middleware/builtin/lifecycle.ts
  • src/lib/middleware/factory.ts
  • src/lib/middleware/index.ts
  • src/lib/neurolink.ts
  • src/lib/types/generateTypes.ts
  • src/lib/types/middlewareTypes.ts
  • src/lib/types/streamTypes.ts
  • src/lib/utils/errorHandling.ts
  • test/continuous-test-suite-middleware.ts

Comment thread src/lib/middleware/builtin/lifecycle.ts
Comment on lines +76 to +156
wrapStream: async ({ doStream }) => {
const startTime = Date.now();

try {
const result = await doStream();

if (!config.onChunk && !config.onFinish) {
return result;
}

let sequenceNumber = 0;

const transformStream = new TransformStream({
transform(chunk, controller) {
if (config.onChunk && chunk.type) {
try {
const callbackResult = config.onChunk({
type: chunk.type,
textDelta:
chunk.type === "text-delta" ? chunk.textDelta : undefined,
sequenceNumber: sequenceNumber++,
});
// Handle async callbacks non-blocking
if (callbackResult instanceof Promise) {
callbackResult.catch((e) => {
logger.warn(
"[LifecycleMiddleware] onChunk callback error:",
e,
);
});
}
} catch (e) {
logger.warn("[LifecycleMiddleware] onChunk callback error:", e);
}
}
controller.enqueue(chunk);
},
flush() {
if (config.onFinish) {
try {
const callbackResult = config.onFinish({
text: "",
duration: Date.now() - startTime,
});
if (callbackResult instanceof Promise) {
callbackResult.catch((e) => {
logger.warn(
"[LifecycleMiddleware] onFinish callback error:",
e,
);
});
}
} catch (e) {
logger.warn(
"[LifecycleMiddleware] onFinish callback error:",
e,
);
}
}
},
});

return {
...result,
stream: result.stream.pipeThrough(transformStream),
};
} catch (error) {
if (config.onError) {
const err = error instanceof Error ? error : new Error(String(error));
try {
await config.onError({
error: err,
duration: Date.now() - startTime,
recoverable: isRecoverableError(err),
});
} catch (e) {
logger.warn("[LifecycleMiddleware] onError callback error:", e);
}
}

throw error;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/lib/middleware/builtin/lifecycle.ts | head -200

Repository: juspay/neurolink

Length of output: 6120


🏁 Script executed:

rg "pipeThrough|stream.*error|\.catch\(\)" src/lib --type ts -A 2 -B 2 | head -60

Repository: juspay/neurolink

Length of output: 3372


🏁 Script executed:

rg "wrapStream|TransformStream" src/lib --type ts -B 2 -A 5 | grep -A 10 "TransformStream"

Repository: juspay/neurolink

Length of output: 1941


🏁 Script executed:

rg "\.stream\s*=" src/lib --type ts -B 2 -A 3 | head -80

Repository: juspay/neurolink

Length of output: 1593


🏁 Script executed:

cat -n src/lib/neurolink.ts | sed -n '3200,3350p'

Repository: juspay/neurolink

Length of output: 8261


🏁 Script executed:

rg "for await.*stream|\.text\(\)|\.json\(\)" src/lib --type ts -B 2 -A 5 | head -100

Repository: juspay/neurolink

Length of output: 5907


🏁 Script executed:

rg "error.*stream|stream.*error" src/lib/middleware --type ts -B 3 -A 3

Repository: juspay/neurolink

Length of output: 610


🏁 Script executed:

rg "wrapStream.*async.*doStream" src/lib/middleware -A 50 | head -150

Repository: juspay/neurolink

Length of output: 10781


🏁 Script executed:

rg "stream.pipeThrough|pipeThrough.*TransformStream" src/lib -B 5 -A 10 | head -120

Repository: juspay/neurolink

Length of output: 3034


🏁 Script executed:

cat -n src/lib/middleware/builtin/autoEvaluation.ts | sed -n '80,130p'

Repository: juspay/neurolink

Length of output: 1701


🏁 Script executed:

rg "readableStream.*error|addEventListener.*error" src/lib --type ts -B 2 -A 3

Repository: juspay/neurolink

Length of output: 42


🏁 Script executed:

rg "consumeAndEvaluateStream" src/lib --type ts -A 30 | head -80

Repository: juspay/neurolink

Length of output: 4380


🏁 Script executed:

cat -n src/lib/middleware/builtin/autoEvaluation.ts | sed -n '160,220p'

Repository: juspay/neurolink

Length of output: 2309


🏁 Script executed:

cat -n src/lib/middleware/builtin/autoEvaluation.ts | sed -n '220,260p'

Repository: juspay/neurolink

Length of output: 959


🏁 Script executed:

rg "generateStream|\.stream\)" src/lib/neurolink.ts -B 3 -A 5 | head -100

Repository: juspay/neurolink

Length of output: 848


🏁 Script executed:

rg "stream\.getReader|reader\.read|ReadableStream" src/lib/middleware --type ts -B 2 -A 5

Repository: juspay/neurolink

Length of output: 2603


Mid-stream failures never reach onError because error handling is not connected to stream iteration.

The try/catch block only wraps await doStream(). Once result.stream.pipeThrough(transformStream) is returned, any provider/network failure during chunk iteration—the primary failure mode for streaming—bypasses the catch block entirely. The TransformStream handlers only catch errors from callbacks, not source stream errors.

To fix this, wrap the returned stream with a reader loop that:

  1. Calls stream.getReader()
  2. Catches errors from reader.read() and iteration failures
  3. Invokes onError with the caught error
  4. Propagates the error downstream via controller.error()

Reference the pattern in autoEvaluation.ts (lines 180–224) which properly handles stream consumption errors with getReader() and try/finally.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/middleware/builtin/lifecycle.ts` around lines 76 - 156, The stream
returned from wrapStream must be wrapped so mid-stream provider/network errors
are caught and forwarded to config.onError; update the code that returns
result.stream.pipeThrough(transformStream) to instead create a new
ReadableStream (or TransformStream wrapper) that obtains a reader via
result.stream.getReader(), loops with reader.read(), enqueues chunks to the
downstream controller (and still runs the existing transform logic for
onChunk/onFinish), and in the catch path calls await config.onError({ error: err
instanceof Error ? err : new Error(String(err)), duration: Date.now() -
startTime, recoverable: isRecoverableError(err) }) (with the same try/catch
logging pattern used elsewhere) before controller.error(err) and rethrowing;
ensure the reader is released in finally and preserve sequenceNumber, startTime,
transformStream semantics and existing onChunk/onFinish handling in wrapStream,
transformStream, result.stream, config.onError, isRecoverableError.

Comment thread src/lib/middleware/builtin/lifecycle.ts
Comment thread src/lib/neurolink.ts Outdated
Comment thread src/lib/neurolink.ts Outdated
Comment thread src/lib/types/middlewareTypes.ts
Comment thread test/continuous-test-suite-middleware.ts
Comment thread test/continuous-test-suite-middleware.ts
@murdore
murdore force-pushed the feat/hooks-events branch from 858c9e6 to a74366a Compare March 21, 2026 03:43
@murdore

murdore commented Mar 21, 2026

Copy link
Copy Markdown
Contributor Author

Review Feedback Addressed (Cycle 1)

All 15 inline review comments (10 deduplicated issues) from @copilot and @coderabbitai have been addressed.

Changes Made

src/lib/middleware/builtin/lifecycle.ts (8 comments resolved)

  • Streaming onFinish now sends accumulated text — Added accumulatedText variable, appends textDelta chunks, passes real content to onFinish in flush()
  • Mid-stream errors caught by onError — transform() wrapped in try/catch that calls config.onError before rethrowing
  • instanceof Promise replaced — All 3 occurrences use Promise.resolve(callbackResult).catch() pattern (handles thenables, cross-realm)
  • Callbacks are fire-and-forget — No await on consumer callbacks in wrapGenerate or wrapStream outer catch

src/lib/neurolink.ts (4 comments resolved)

  • Config merging — Lifecycle injection now spreads existing lifecycle and lifecycle.config instead of overwriting
  • generate() preserves caller middleware — Added middleware: options.middleware to baseOptions; moved injection before workflow/PPT early returns
  • stream() workflow path covered — Moved injection before workflow check so streamWithWorkflow() receives lifecycle callbacks

src/lib/types/middlewareTypes.ts (2 comments resolved)

  • Added "lifecycle" to BuiltInMiddlewareType union

src/lib/utils/errorHandling.ts (1 comment resolved)

  • isRecoverableError() now checks error.retriable first for NeuroLinkErrors; uses /\b429\b/ and /\b50[0234]\b/ regex with word boundaries

test/continuous-test-suite-middleware.ts (2 comments resolved)

  • False passes eliminated — When callbacks don't fire, tests now SKIP (return null) instead of falsely passing

Validation

  • TypeScript: ✅ 0 errors
  • Lint: ✅ 0 errors
  • Format: ✅ All files clean

Requesting Re-review

@copilot @coderabbitai — All feedback from this cycle has been addressed. Please re-review.

Copilot AI commented Mar 21, 2026

Copy link
Copy Markdown
Contributor

@murdore I've opened a new pull request, #890, to work on those changes. Once the pull request is ready, I'll request review from you.

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown

@murdore Thanks for the detailed summary — the fixes look well-considered across the board. A few highlights stand out:

  • The Promise.resolve(callbackResult).catch() pattern is the right call for cross-realm thenable safety.
  • Moving lifecycle injection before workflow/PPT early returns in both generate() and stream() is an important correctness fix.
  • Using /\b429\b/ and /\b50[0234]\b/ regex with word boundaries is a nice improvement over plain substring matching.

Kicking off a full re-review now to verify all the changes.


🧠 Learnings used
Learnt from: CR
Repo: juspay/neurolink PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-18T21:37:40.730Z
Learning: Applies to src/lib/mcp/**/*.ts : Implement proper middleware chain for tool execution with logging, retry, and timeout support

Learnt from: amreetkhuntia
Repo: juspay/neurolink PR: 133
File: src/lib/core/types.ts:208-210
Timestamp: 2025-09-01T06:15:59.759Z
Learning: The middleware?: MiddlewareFactoryOptions field is already present in both TextGenerationOptions and StreamOptions interfaces in the neurolink codebase.
✅ Actions performed

Full review triggered.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

…Chunk callbacks

Add a lifecycle middleware to the existing middleware system that provides
consumer-facing callbacks on GenerateOptions and StreamOptions. When users
pass onFinish/onError/onChunk, NeuroLink auto-injects the lifecycle
middleware into the pipeline — no manual middleware setup needed.

- New built-in middleware: src/lib/middleware/builtin/lifecycle.ts
- Callback types in middlewareTypes.ts (LifecycleFinishPayload, etc.)
- isRecoverableError utility in errorHandling.ts
- Registered in MiddlewareFactory with priority 110, defaultEnabled: false
- Continuous test suite: test/continuous-test-suite-middleware.ts
@github-actions

Copy link
Copy Markdown
Contributor

🤖 AI Review & Build Compliance ✅

Status: AI analysis complete • Build rules validated • Ready for review

📊 View detailed analysis results

🛡️ Analysis Complete

  • ✅ Security scan (vulnerabilities, API keys)
  • ✅ TypeScript safety & code quality
  • ✅ Error handling & best practices
  • ✅ Build rule enforcement validated
  • ✅ Commit format & compliance checks

📋 Ready for Merge When

  • All CI checks passing
  • Manual review approved
  • Any AI-flagged issues resolved

🤖 AI analysis complete - check individual code comments for specific feedback

@murdore
murdore merged commit 2d23087 into release Mar 21, 2026
16 checks passed
@murdore
murdore deleted the feat/hooks-events branch March 21, 2026 04:01
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 9.30.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

This branch was successfully deployed

1 active deployment
Preview — cdbb563b Deployed Mar 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants