Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions .github/workflows/typescript.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 3 additions & 9 deletions python/scripts/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
32 changes: 8 additions & 24 deletions python/src/smooth_operator/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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 ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion python/src/smooth_operator/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
Loading
Loading