-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(tegg): [3/3] add @AgentController decorator with plugin integration #5827
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
a4f7658
feat(tegg): [3/3] add @AgentController decorator with plugin integration
jerryliang64 5b2a400
fix(tegg): prevent event listener leak in NodeSSEWriter
jerryliang64 f90b7e8
refactor(tegg): align AgentController code style with HTTP/MCP conven…
jerryliang64 992dfbc
fix(tegg): unify input types, fix streamRun abort handling, avoid msg…
jerryliang64 c4819e4
fix(tegg): fix CI lint error and update exports snapshot
jerryliang64 2731f3a
fix(tegg): update tegg-types exports snapshot with agent controller s…
jerryliang64 c6b4c97
refactor(tegg): tighten MessageObject interface and eliminate direct …
jerryliang64 a2252b5
test(tegg): add coverage for AgentControllerProto and AgentInfoUtil
jerryliang64 f177016
refactor(tegg): rename NodeSSEWriter to HttpSSEWriter
jerryliang64 a18924b
refactor(tegg): extract shared message types into AgentMessage.ts
jerryliang64 73a35bd
style(tegg): fix import order in AgentRuntime.ts
jerryliang64 fe8904b
refactor(tegg): make createStore required in AgentHandler interface
jerryliang64 2af6c6f
fix(tegg): add missing createStore to AgentFooController test fixture
jerryliang64 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import type { ServerResponse } from 'node:http'; | ||
|
|
||
| import type { SSEWriter } from './SSEWriter.ts'; | ||
|
jerryliang64 marked this conversation as resolved.
|
||
|
|
||
| export class HttpSSEWriter implements SSEWriter { | ||
| private res: ServerResponse; | ||
| private _closed = false; | ||
| private closeCallbacks: Array<() => void> = []; | ||
| private headersSent = false; | ||
| private readonly onResClose: () => void; | ||
|
|
||
| constructor(res: ServerResponse) { | ||
| this.res = res; | ||
| this.onResClose = () => { | ||
| this._closed = true; | ||
| for (const cb of this.closeCallbacks) cb(); | ||
| this.closeCallbacks.length = 0; | ||
| }; | ||
| res.on('close', this.onResClose); | ||
| } | ||
|
|
||
| /** Lazily write headers on first event — avoids sending corrupt headers if constructor throws. */ | ||
| private ensureHeaders(): void { | ||
| if (this.headersSent) return; | ||
| this.headersSent = true; | ||
| this.res.writeHead(200, { | ||
| 'content-type': 'text/event-stream', | ||
| 'cache-control': 'no-cache', | ||
| connection: 'keep-alive', | ||
| }); | ||
| } | ||
|
|
||
| writeEvent(event: string, data: unknown): void { | ||
| if (this._closed) return; | ||
| this.ensureHeaders(); | ||
| this.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); | ||
|
jerryliang64 marked this conversation as resolved.
jerryliang64 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| get closed(): boolean { | ||
| return this._closed; | ||
| } | ||
|
|
||
| end(): void { | ||
| if (!this._closed) { | ||
| this._closed = true; | ||
| this.res.off('close', this.onResClose); | ||
| this.closeCallbacks.length = 0; | ||
| this.res.end(); | ||
|
jerryliang64 marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| onClose(callback: () => void): void { | ||
| this.closeCallbacks.push(callback); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| import assert from 'node:assert'; | ||
| import { EventEmitter } from 'node:events'; | ||
|
|
||
| import { describe, it, beforeEach } from 'vitest'; | ||
|
|
||
| import { HttpSSEWriter } from '../src/HttpSSEWriter.ts'; | ||
|
|
||
| /** | ||
| * Minimal mock of Node.js ServerResponse for testing HttpSSEWriter. | ||
| * Captures writeHead/write/end calls and emits 'close' on demand. | ||
| */ | ||
| class MockServerResponse extends EventEmitter { | ||
| writtenHead: { statusCode: number; headers: Record<string, string> } | null = null; | ||
| chunks: string[] = []; | ||
| ended = false; | ||
|
|
||
| writeHead(statusCode: number, headers: Record<string, string>): void { | ||
| this.writtenHead = { statusCode, headers }; | ||
| } | ||
|
|
||
| write(chunk: string): boolean { | ||
| this.chunks.push(chunk); | ||
| return true; | ||
| } | ||
|
|
||
| end(): void { | ||
| this.ended = true; | ||
| } | ||
| } | ||
|
|
||
| describe('test/HttpSSEWriter.test.ts', () => { | ||
| let res: MockServerResponse; | ||
|
|
||
| beforeEach(() => { | ||
| res = new MockServerResponse(); | ||
| }); | ||
|
|
||
| it('should delay headers until first writeEvent', () => { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const writer = new HttpSSEWriter(res as any); | ||
|
|
||
| // Headers not sent yet after construction | ||
| assert.equal(res.writtenHead, null); | ||
| assert.equal(res.chunks.length, 0); | ||
|
|
||
| writer.writeEvent('test', { foo: 'bar' }); | ||
|
|
||
| // Now headers should be sent | ||
| assert.ok(res.writtenHead); | ||
| assert.equal(res.writtenHead.statusCode, 200); | ||
| }); | ||
|
|
||
| it('should use lowercase header keys', () => { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const writer = new HttpSSEWriter(res as any); | ||
| writer.writeEvent('ping', {}); | ||
|
|
||
| assert.ok(res.writtenHead); | ||
| assert.equal(res.writtenHead.headers['content-type'], 'text/event-stream'); | ||
| assert.equal(res.writtenHead.headers['cache-control'], 'no-cache'); | ||
| assert.equal(res.writtenHead.headers['connection'], 'keep-alive'); | ||
| }); | ||
|
|
||
| it('should format SSE events correctly', () => { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const writer = new HttpSSEWriter(res as any); | ||
| writer.writeEvent('message', { text: 'hello' }); | ||
|
|
||
| assert.equal(res.chunks.length, 1); | ||
| assert.equal(res.chunks[0], 'event: message\ndata: {"text":"hello"}\n\n'); | ||
| }); | ||
|
|
||
| it('should not write after connection closes', () => { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const writer = new HttpSSEWriter(res as any); | ||
|
|
||
| // Simulate client disconnect | ||
| res.emit('close'); | ||
|
|
||
| assert.equal(writer.closed, true); | ||
| writer.writeEvent('late', { data: 'ignored' }); | ||
|
|
||
| // No headers sent, no chunks written | ||
| assert.equal(res.writtenHead, null); | ||
| assert.equal(res.chunks.length, 0); | ||
| }); | ||
|
|
||
| it('should trigger onClose callbacks when connection closes', () => { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const writer = new HttpSSEWriter(res as any); | ||
| const calls: number[] = []; | ||
|
|
||
| writer.onClose(() => calls.push(1)); | ||
| writer.onClose(() => calls.push(2)); | ||
|
|
||
| res.emit('close'); | ||
|
|
||
| assert.deepStrictEqual(calls, [1, 2]); | ||
| }); | ||
|
|
||
| it('should handle end() idempotently', () => { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const writer = new HttpSSEWriter(res as any); | ||
|
|
||
| assert.equal(writer.closed, false); | ||
|
|
||
| writer.end(); | ||
| assert.equal(writer.closed, true); | ||
| assert.equal(res.ended, true); | ||
|
|
||
| // Reset flag to verify second end() doesn't call res.end() again | ||
| res.ended = false; | ||
| writer.end(); | ||
| assert.equal(res.ended, false); // Not called again | ||
| }); | ||
|
|
||
| it('should write multiple events sequentially', () => { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const writer = new HttpSSEWriter(res as any); | ||
|
|
||
| writer.writeEvent('event1', { n: 1 }); | ||
| writer.writeEvent('event2', { n: 2 }); | ||
| writer.writeEvent('event3', { n: 3 }); | ||
|
|
||
| assert.equal(res.chunks.length, 3); | ||
| assert.equal(res.chunks[0], 'event: event1\ndata: {"n":1}\n\n'); | ||
| assert.equal(res.chunks[1], 'event: event2\ndata: {"n":2}\n\n'); | ||
| assert.equal(res.chunks[2], 'event: event3\ndata: {"n":3}\n\n'); | ||
|
|
||
| // Headers sent only once | ||
| assert.ok(res.writtenHead); | ||
| }); | ||
|
|
||
| it('should start with closed=false', () => { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const writer = new HttpSSEWriter(res as any); | ||
| assert.equal(writer.closed, false); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.