diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..b00cf9e --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,63 @@ +# Vet + test the Go trees: the protocol client/types module (go/) AND the native +# WebSocket server module (go/server/) — each is its own Go module. The live gateway +# E2E (e2e_live_test.go) calls t.Skip without SMOOTH_AGENT_E2E + SMOOAI_GATEWAY_KEY, +# so `go test ./...` needs no secrets. spec/ is a trigger path because the conformance +# test validates against spec/conformance/fixtures.json. +name: Go + +on: + push: + branches: [main] + paths: + - 'go/**' + - 'spec/**' + - '.github/workflows/go.yml' + pull_request: + paths: + - 'go/**' + - 'spec/**' + - '.github/workflows/go.yml' + +concurrency: + group: go-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + vet-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + module: + - go # the protocol client + generated types + - go/server # the native WebSocket server + defaults: + run: + working-directory: ${{ matrix.module }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Go (with build/module cache) + uses: actions/setup-go@v5 + with: + go-version-file: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum + + - name: gofmt (fail on unformatted files) + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: go test (race) + run: go test ./... -race diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..1ed54e0 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,59 @@ +# Lint + test the Python trees: the protocol client/types package (python/) AND the +# native async WebSocket server (python/server/). Both are uv projects. The live +# gateway E2E (tests/test_e2e_live.py) is skipif-marked and skips cleanly without +# SMOOTH_AGENT_E2E + SMOOAI_GATEWAY_KEY, so this job needs no secrets. spec/ is a +# trigger path because the conformance tests validate against spec/conformance/fixtures.json. +name: Python + +on: + push: + branches: [main] + paths: + - 'python/**' + - 'spec/**' + - '.github/workflows/python.yml' + pull_request: + paths: + - 'python/**' + - 'spec/**' + - '.github/workflows/python.yml' + +concurrency: + group: python-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + project: + - python # the protocol client + generated types + - python/server # the native async WebSocket server + defaults: + run: + working-directory: ${{ matrix.project }} + steps: + - uses: actions/checkout@v4 + + - name: Set up uv (with cache) + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Sync (install deps + dev group) + run: uv sync + + - name: Ruff lint + run: uv run ruff check . + + - name: Ruff format check + run: uv run ruff format --check . + + - name: Pytest + run: uv run pytest diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml new file mode 100644 index 0000000..59f3455 --- /dev/null +++ b/.github/workflows/typescript.yml @@ -0,0 +1,54 @@ +# Typecheck + test the TypeScript trees: the published SDK (typescript/ — client, +# React bindings, web-component widget) AND the native TS WebSocket server +# (typescript/server/). Both are pnpm-workspace members; one root install links them. +# The default `vitest run` (the `test` script) excludes the live-gateway E2E, so this +# job needs no secrets. spec/ is a trigger path because the conformance test validates +# against spec/conformance/fixtures.json. +name: TypeScript + +on: + push: + branches: [main] + paths: + - 'typescript/**' + - 'spec/**' + - '.github/workflows/typescript.yml' + pull_request: + paths: + - 'typescript/**' + - 'spec/**' + - '.github/workflows/typescript.yml' + +concurrency: + group: typescript-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + typecheck-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node (with pnpm cache) + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install (frozen lockfile) + run: pnpm install --frozen-lockfile + + # Scope to the two TypeScript packages the lane owns; `console` (the private + # admin app) is a workspace member too but out of scope for this lane. + - name: Typecheck + run: pnpm --filter "@smooai/smooth-operator" --filter "@smooai/smooth-operator-server" typecheck + + - name: Test + run: pnpm --filter "@smooai/smooth-operator" --filter "@smooai/smooth-operator-server" test diff --git a/python/scripts/generate.py b/python/scripts/generate.py index b130415..a86b4f8 100644 --- a/python/scripts/generate.py +++ b/python/scripts/generate.py @@ -95,10 +95,7 @@ def build_merged_schema() -> dict: if has_one_of and local_defs: # Map each local $def name -> its global title, for ref rewriting. - rename = { - name: title_for(name, sub, file_title) - for name, sub in local_defs.items() - } + rename = {name: title_for(name, sub, file_title) for name, sub in local_defs.items()} rewrite = make_rewriter(rename) for name, sub in local_defs.items(): title = rename[name] @@ -111,10 +108,7 @@ def build_merged_schema() -> dict: merged_defs[title] = model else: # Flat top-level object (events, domain). It may carry its own $defs. - rename = { - name: title_for(name, sub, file_title) - for name, sub in local_defs.items() - } + rename = {name: title_for(name, sub, file_title) for name, sub in local_defs.items()} rewrite = make_rewriter(rename) # Promote the file's own $defs into the merged namespace. @@ -183,7 +177,7 @@ def main() -> int: "3.11", "--use-annotated", "--use-field-description", - "--snake-case-field", # snake_case attrs + "--snake-case-field", # snake_case attrs "--use-default-kwarg", "--reuse-model", "--use-standard-collections", diff --git a/python/src/smooth_operator/client.py b/python/src/smooth_operator/client.py index f91918b..d5897ea 100644 --- a/python/src/smooth_operator/client.py +++ b/python/src/smooth_operator/client.py @@ -64,9 +64,7 @@ class TurnTimeoutError(Exception): and ``async for`` over it re-raises it) so a stuck server can't hang the caller.""" def __init__(self, request_id: str, seconds: float) -> None: - super().__init__( - f"Turn {request_id} timed out after {seconds}s without a terminal response" - ) + super().__init__(f"Turn {request_id} timed out after {seconds}s without a terminal response") self.request_id = request_id @@ -143,9 +141,7 @@ def _on_timeout(self) -> None: """Settle the turn with a TurnTimeoutError when no terminal event arrived.""" if self._done.is_set(): return - self._finish( - None, TurnTimeoutError(self.request_id, self._turn_timeout) - ) + self._finish(None, TurnTimeoutError(self.request_id, self._turn_timeout)) def _finish(self, final: EventualResponse | None, err: BaseException | None) -> None: if self._done.is_set(): @@ -183,9 +179,7 @@ async def _iterate(self) -> AsyncIterator[ServerEvent]: get_task = asyncio.ensure_future(self._queue.get()) done_task = asyncio.ensure_future(self._done.wait()) try: - await asyncio.wait( - {get_task, done_task}, return_when=asyncio.FIRST_COMPLETED - ) + await asyncio.wait({get_task, done_task}, return_when=asyncio.FIRST_COMPLETED) finally: if not get_task.done(): get_task.cancel() @@ -219,9 +213,7 @@ def __init__( # ``token`` authenticates against a token-gated (local-flavor) server: it is # folded into the connection URL's ``?token=`` slot on the default transport. # A custom ``transport`` is used as-is (apply the token to its own URL there). - self._transport = ( - transport if transport is not None else WebSocketTransport(url, token=token) - ) + self._transport = transport if transport is not None else WebSocketTransport(url, token=token) self._request_timeout = request_timeout # Overall timeout (seconds) for a streaming send_message turn. 0 disables it. self._turn_timeout = turn_timeout @@ -236,9 +228,7 @@ def __init__( self._unsubscribe: list[Callable[[], None]] = [ self._transport.on_message(self._handle_frame), - self._transport.on_close( - lambda _info: self._fail_all(ConnectionError("Transport closed")) - ), + self._transport.on_close(lambda _info: self._fail_all(ConnectionError("Transport closed"))), ] # ── lifecycle ────────────────────────────────────────────────────────────── @@ -281,9 +271,7 @@ async def create_conversation_session( if auth_context is not None: frame["authContext"] = auth_context event = await self._request(frame) - return CreateConversationSessionResponse.model_validate( - _immediate_data(event) - ) + return CreateConversationSessionResponse.model_validate(_immediate_data(event)) async def get_session(self, *, session_id: str) -> GetSessionResponse: """Fetch a session snapshot by ID.""" @@ -316,9 +304,7 @@ async def ping(self) -> int: return event.data.timestamp return 0 - def send_message( - self, *, session_id: str, message: str, stream: bool = True - ) -> MessageTurn: + def send_message(self, *, session_id: str, message: str, stream: bool = True) -> MessageTurn: """Submit a user message and return a :class:`MessageTurn`. Await it for the terminal ``eventual_response``, or ``async for`` over it for @@ -349,9 +335,7 @@ def send_message( turn.abort(err) return turn - def confirm_tool_action( - self, *, session_id: str, request_id: str, approved: bool - ) -> None: + def confirm_tool_action(self, *, session_id: str, request_id: str, approved: bool) -> None: """Approve/reject a pending tool write, resuming the paused turn for ``request_id``. Resumed events flow back into the original :class:`MessageTurn`.""" self._transport.send( diff --git a/python/src/smooth_operator/validate.py b/python/src/smooth_operator/validate.py index 8bda0b9..f55c0ff 100644 --- a/python/src/smooth_operator/validate.py +++ b/python/src/smooth_operator/validate.py @@ -28,7 +28,7 @@ from referencing.jsonschema import DRAFT202012 # Default spec dir: ../../spec relative to this file (python/src/smooth_operator/ -> spec). -DEFAULT_SPEC_DIR = (Path(__file__).resolve().parent.parent.parent.parent / "spec") +DEFAULT_SPEC_DIR = Path(__file__).resolve().parent.parent.parent.parent / "spec" _SUBDIRS = ["", "actions", "events", "domain"] diff --git a/python/tests/test_client.py b/python/tests/test_client.py index 0bcfa0a..0b834d1 100644 --- a/python/tests/test_client.py +++ b/python/tests/test_client.py @@ -112,12 +112,10 @@ async def iterate() -> None: await asyncio.sleep(0) # let the iterator start transport.emit( - {"type": "stream_token", "requestId": req_id, "token": "Hel", - "data": {"requestId": req_id, "token": "Hel"}} + {"type": "stream_token", "requestId": req_id, "token": "Hel", "data": {"requestId": req_id, "token": "Hel"}} ) transport.emit( - {"type": "stream_token", "requestId": req_id, "token": "lo", - "data": {"requestId": req_id, "token": "lo"}} + {"type": "stream_token", "requestId": req_id, "token": "lo", "data": {"requestId": req_id, "token": "lo"}} ) transport.emit( { @@ -175,8 +173,7 @@ async def test_buffers_tokens_pushed_before_iteration_begins() -> None: # Emit before anyone iterates — must be buffered. transport.emit( - {"type": "stream_token", "requestId": req_id, "token": "A", - "data": {"requestId": req_id, "token": "A"}} + {"type": "stream_token", "requestId": req_id, "token": "A", "data": {"requestId": req_id, "token": "A"}} ) transport.emit( { @@ -247,8 +244,7 @@ async def iterate() -> None: ) # Caller approves; the resumed stream completes the original turn. - client.confirm_tool_action(session_id="22222222-2222-2222-2222-222222222222", - request_id=req_id, approved=True) + client.confirm_tool_action(session_id="22222222-2222-2222-2222-222222222222", request_id=req_id, approved=True) sent = transport.last_sent() assert sent["action"] == "confirm_tool_action" assert sent["approved"] is True @@ -281,8 +277,7 @@ async def test_create_session_resolves_with_immediate_response_data() -> None: await client.connect() coro = asyncio.create_task( - client.create_conversation_session(agent_id="11111111-1111-1111-1111-111111111111", - user_name="Alice") + client.create_conversation_session(agent_id="11111111-1111-1111-1111-111111111111", user_name="Alice") ) await asyncio.sleep(0) req_id = transport.last_sent()["requestId"] @@ -335,23 +330,31 @@ def session_data(sid: str) -> dict: "agentParticipantId": "55555555-5555-5555-5555-555555555555", } - p1 = asyncio.create_task( - client.get_session(session_id="22222222-2222-2222-2222-222222222221") - ) + p1 = asyncio.create_task(client.get_session(session_id="22222222-2222-2222-2222-222222222221")) await asyncio.sleep(0) req1 = transport.last_sent()["requestId"] - p2 = asyncio.create_task( - client.get_session(session_id="22222222-2222-2222-2222-222222222222") - ) + p2 = asyncio.create_task(client.get_session(session_id="22222222-2222-2222-2222-222222222222")) await asyncio.sleep(0) req2 = transport.last_sent()["requestId"] assert req1 != req2 # Resolve out of order. - transport.emit({"type": "immediate_response", "requestId": req2, "status": 200, - "data": session_data("22222222-2222-2222-2222-222222222222")}) - transport.emit({"type": "immediate_response", "requestId": req1, "status": 200, - "data": session_data("22222222-2222-2222-2222-222222222221")}) + transport.emit( + { + "type": "immediate_response", + "requestId": req2, + "status": 200, + "data": session_data("22222222-2222-2222-2222-222222222222"), + } + ) + transport.emit( + { + "type": "immediate_response", + "requestId": req1, + "status": 200, + "data": session_data("22222222-2222-2222-2222-222222222221"), + } + ) s1 = await p1 s2 = await p2 @@ -372,9 +375,7 @@ async def test_forwards_uncorrelated_keepalive_to_on_event_listeners() -> None: async def test_rejects_pending_requests_when_transport_closes() -> None: client, transport = make_client() await client.connect() - coro = asyncio.create_task( - client.get_session(session_id="22222222-2222-2222-2222-222222222222") - ) + coro = asyncio.create_task(client.get_session(session_id="22222222-2222-2222-2222-222222222222")) await asyncio.sleep(0) await transport.close() with pytest.raises(Exception): diff --git a/python/tests/test_conformance.py b/python/tests/test_conformance.py index 6f615bc..8beeba4 100644 --- a/python/tests/test_conformance.py +++ b/python/tests/test_conformance.py @@ -32,9 +32,7 @@ def test_exposes_the_five_documented_fixtures() -> None: @pytest.mark.parametrize("name", list(FIXTURES)) -def test_every_fixture_validates_against_its_declared_schema( - name: str, validator: ProtocolValidator -) -> None: +def test_every_fixture_validates_against_its_declared_schema(name: str, validator: ProtocolValidator) -> None: fixture = FIXTURES[name] result = validator.validate_at(fixture["$schema_ref"], fixture["instance"]) assert result.valid, f"{name} ({fixture['$schema_ref']}): {format_errors(result.errors)}" diff --git a/python/tests/test_e2e_live.py b/python/tests/test_e2e_live.py index 605cb75..69b28dd 100644 --- a/python/tests/test_e2e_live.py +++ b/python/tests/test_e2e_live.py @@ -51,10 +51,7 @@ pytestmark = pytest.mark.skipif( not _E2E_ENABLED, - reason=( - "Live E2E disabled. Set SMOOTH_AGENT_E2E=1 and SMOOAI_GATEWAY_KEY to run " - "(see module docstring)." - ), + reason=("Live E2E disabled. Set SMOOTH_AGENT_E2E=1 and SMOOAI_GATEWAY_KEY to run (see module docstring)."), ) # ── server location / config ──────────────────────────────────────────────────── diff --git a/python/tests/test_robustness.py b/python/tests/test_robustness.py index 7b6126c..980a1bb 100644 --- a/python/tests/test_robustness.py +++ b/python/tests/test_robustness.py @@ -100,8 +100,12 @@ async def test_streaming_turn_times_out_when_no_terminal_event_arrives() -> None # An intermediate event arrives, but the server never sends a terminal one. transport.emit( - {"type": "stream_token", "requestId": req_id, "token": "partial", - "data": {"requestId": req_id, "token": "partial"}} + { + "type": "stream_token", + "requestId": req_id, + "token": "partial", + "data": {"requestId": req_id, "token": "partial"}, + } ) loop = asyncio.get_running_loop()