feat(ai): stream AI suggestions over HTTP - #664
Conversation
2265cba to
6bf913f
Compare
2ee7bd0 to
497be8b
Compare
497be8b to
9ff73a0
Compare
9ff73a0 to
8f8ee4a
Compare
8f8ee4a to
bd3667c
Compare
| }); | ||
| } | ||
|
|
||
| const res: any = new Writable({ |
There was a problem hiding this comment.
probably could be typed, since you assign it right away
There was a problem hiding this comment.
Added FakeResponse for this.
| const response = result.toUIMessageStreamResponse(); | ||
|
|
||
| res.status(response.status); | ||
| response.headers.forEach((value, key) => res.setHeader(key, value)); | ||
|
|
||
| if (!response.body) { | ||
| res.end(); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>).pipe(res); | ||
| } catch (error) { | ||
| next(error); | ||
| } | ||
| }); |
There was a problem hiding this comment.
we can just use
result.pipeUIMessageStreamToResponse(res);with extra options or just
result.pipeTextStreamToResponse(res);since we don't need any metadata toolcalls etc
There was a problem hiding this comment.
Good suggestion. And since I don't see any possibilities that ask-ai will need text/event-stream format, I'm using pipeTextStreamToResponse here.
@FeironoX5 If you're still working on stream receiving in hawk.garage, could you agree/disagree with this?
There was a problem hiding this comment.
Correction to my earlier reply: text/event-stream may turn out to be needed after all. On a rejected answer the guard in #668 can only append the fallback as more text, so the client shows a truncated prefix glued to it. Signalling a failure on its own channel needs the UI message stream. Leaving pipeTextStreamToResponse for now and revisiting once the feature is testable on stage.
bd3667c to
97fa6c2
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## fix/ai-prompt-injection #664 +/- ##
==========================================================
Coverage ? 48.91%
==========================================================
Files ? 62
Lines ? 2819
Branches ? 638
==========================================================
Hits ? 1379
Misses ? 1362
Partials ? 78 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
createFakeResponse assigned every Express-shaped method it needed right after construction, so it could be typed as that shape from the start instead of any - reviewer feedback on #664. Also adds writeHead, which the AI SDK's response-piping helpers call directly, bypassing Express's status()/setHeader() convenience methods.
routes.ts landed in integrations/vercel-ai/ in the original commit, even though it only calls askAiService and never touches the transport - the same domain-code-in-an-adapter-directory problem services/ai.ts itself had before it moved into askAi/. Wire its imports to the new location and expose it through the askAi barrel, alongside AskAiService. Also switches result.toUIMessageStreamResponse() + manual Response-to-Express bridging for result.pipeTextStreamToResponse(res) - reviewer feedback on #664. The model call is tool-less by design (see VercelAIApi's docstring), so there's no tool-call/reasoning metadata to carry, and plain text drops the SSE envelope this otherwise never needed. Drops the now-unused ReadableStream/Response ESLint globals that only existed for the old SSE-based test fixture.
There was a problem hiding this comment.
Pull request overview
Adds an HTTP streaming endpoint for AI suggestions and wires it into the API, alongside extending the Vercel AI integration with a streaming call and updating tests/utilities to support streaming responses.
Changes:
- Introduces
GET /integration/ai/streamExpress route and app wiring for AI suggestion streaming. - Extends the Vercel AI integration with a
stream()method and adds service-levelstreamSuggestion(). - Adds/updates Jest tests and introduces a reusable Express request/response test helper that can capture streamed bodies/headers.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/services/askAiRoutes.test.ts | New tests covering auth/validation/error cases and streaming response behavior for /integration/ai/stream. |
| test/services/askAi.test.ts | Adds service-level coverage for streamSuggestion() behavior. |
| test/integrations/vercel-ai.test.ts | Adds integration-level coverage for vercelAIApi.stream() forwarding to streamText. |
| test/integrations/github-routes.test.ts | Refactors tests to reuse the new makeExpressRequest helper. |
| test/helpers/expressRequest.ts | New helper to drive Express apps without a socket and capture streamed responses/headers. |
| src/services/types.ts | Exports Event type for reuse by services. |
| src/services/askAi/service.ts | Adds streamSuggestion() and refactors event lookup into getEventOrThrow(). |
| src/services/askAi/routes.ts | New Express router for AI streaming endpoint and authorization checks. |
| src/services/askAi/index.ts | Exports appendAiAssistantRoutes for app integration. |
| src/integrations/vercel-ai/index.ts | Adds stream() wrapper around streamText and centralizes provider gateway options. |
| src/index.ts | Registers AI assistant routes on the main Express app. |
| src/directives/requireUserInWorkspace.ts | Exports checkUserInWorkspaceByProjectId for use from Express routes. |
| package.json | Bumps package version. |
Suppressed comments (2)
src/services/askAi/routes.ts:87
- The inner
catchconverts anystreamSuggestionerror into a 404 and returnserror.messageto the caller. That will misreport transport/DB failures as "not found" and can leak internal error details (e.g. events factory errors that include ids). Only map the known not-found case to 404; rethrow unexpected errors so the outer handler cannext(error)and return a 5xx.
try {
result = await askAiService.streamSuggestion(eventsFactory, eventId, originalEventId);
} catch (error) {
res.status(404).json({ error: error instanceof Error ? error.message : 'Event not found' });
src/services/askAi/routes.ts:91
- This route calls
result.pipeTextStreamToResponse(res), but the PR description says the AI SDK'stoUIMessageStreamResponse()(a Fetch APIResponse) is adapted onto the Express response. As written, there is no adaptation and the call isn’t type-checked (becauseresultis implicitlyany), so a wrong method name or incompatible stream type would only fail at runtime. Consider explicitly usingtoUIMessageStreamResponse()and piping its status/headers/body into Express.
result.pipeTextStreamToResponse(res);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Copilot review on #664: projectId comes from req.query, which Express parses as string[] for a repeated key (?projectId=a&projectId=b). The route cast it straight to string and forwarded it to checkUserInWorkspaceByProjectId/getEventsFactory, both expecting a single id - eventId and originalEventId already had the typeof guard this was missing. authorizeProjectAccess now validates and returns the narrowed id instead of the caller re-casting it. makeExpressRequest's query param takes string | string[] now, to let tests simulate a repeated key.
Copilot review on #664: getEventOrThrow only handled a falsy return from getEventRepetition, but it can also throw - EventsFactory throws "Cant find event repetition for repetitionId: ..." on an unmatched id, echoing the raw id back, and an invalid id format throws a raw BSON error. Both reached the HTTP route's catch block unfiltered. Catches and normalizes to the same generic message as the missing-event case.
815fba8 to
c805ff1
Compare
The suggestion reaches the client only once the model has finished writing it, so nothing appears until the whole generation is done. Serve it from GET /integration/ai/stream, adapting the Fetch Response that toUIMessageStreamResponse returns onto the Express response. The route reuses the workspace membership check that guards the GraphQL field, which is why checkUserInWorkspaceByProjectId becomes exported.
pipeTextStreamToResponse writes the model's text as bare bytes under text/plain. The body then carries no framing, so everything in it is content by definition and the server has no way to tell the client that what follows is a failure rather than more of the answer. Serve the stream with pipeUIMessageStreamToResponse instead. The response becomes text/event-stream carrying typed parts, which leaves room for an error part alongside the text deltas.
07d849e to
f77e029
Compare
The suggestion reaches the client only when the model has finished writing it, so the user sees nothing until the whole generation is done.
The stream is served from a dedicated
GET /integration/ai/streamExpress route, which pipes the AI SDK's text stream onto the Express response withpipeTextStreamToResponse, astext/plain. The route sits behind the same workspace membership check as the GraphQL field, which is whycheckUserInWorkspaceByProjectIdbecomes exported.The Express request helper written for the GitHub route tests moves to
test/helpers/expressRequest.tsso both suites share one copy.