From e42ced6d71b8097b82545cfc0bf3c3488b9b8dfa Mon Sep 17 00:00:00 2001 From: dvlpjrs Date: Thu, 23 Jul 2026 03:01:34 +0000 Subject: [PATCH 1/3] [TASK] Add CI workflow: lint, typecheck, tests --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b965687 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.12" + enable-cache: true + - run: uv sync --frozen + - run: uv run --frozen ruff format --check . + - run: uv run --frozen ruff check . + - run: uv lock --check + - name: Check install.sh syntax + run: | + sh -n scripts/install.sh + bash -n scripts/install.sh + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.12" + enable-cache: true + - run: uv sync --frozen + - run: uv run --frozen pyright + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + - run: uv sync --frozen + - run: uv run --frozen pytest -m "not live" -n auto From 0135a2e25502f3d0332f3719c2cedcd0b8a81ae9 Mon Sep 17 00:00:00 2001 From: dvlpjrs Date: Thu, 23 Jul 2026 03:07:00 +0000 Subject: [PATCH 2/3] [TASK] Fix live-test marker and pre-existing lint/type errors --- src/gumloop/cli/commands/_sync_output.py | 7 +- src/gumloop/cli/commands/chat.py | 14 +-- src/gumloop/cli/commands/sync.py | 13 +-- src/gumloop/resources/agents.py | 4 +- src/gumloop/spec/_extensions.py | 6 +- src/gumloop/sync/installed.py | 3 +- src/gumloop/sync/target_files.py | 5 +- tests/cli/test_agents.py | 8 +- tests/cli/test_chat.py | 141 ++++++++++++++--------- tests/cli/test_sync_enrollment.py | 4 +- tests/integration/conftest.py | 3 +- tests/integration/test_live.py | 12 +- tests/sdk/test_agents.py | 12 +- tests/sdk/test_chat.py | 75 ++++++------ tests/sdk/test_client.py | 3 +- tests/sdk/test_sync.py | 16 ++- 16 files changed, 174 insertions(+), 152 deletions(-) diff --git a/src/gumloop/cli/commands/_sync_output.py b/src/gumloop/cli/commands/_sync_output.py index 4247c2a..80a7457 100644 --- a/src/gumloop/cli/commands/_sync_output.py +++ b/src/gumloop/cli/commands/_sync_output.py @@ -120,9 +120,7 @@ def print_enrollment( table.add_column("Coding agents") console.print() - console.print( - f"Enrolled in [cyan]{escape_markup(plan.organization.organization_name)}[/cyan]" - ) + console.print(f"Enrolled in [cyan]{escape_markup(plan.organization.organization_name)}[/cyan]") if not targets: table.add_row( Text("—"), @@ -278,8 +276,7 @@ def _print_changes(self, execution: SyncExecution, *, verbose: bool) -> None: visible = [ change for change in changes - if isinstance(change, dict) - and self._change_is_visible(change, verbose=verbose) + if isinstance(change, dict) and self._change_is_visible(change, verbose=verbose) ] if not visible: return diff --git a/src/gumloop/cli/commands/chat.py b/src/gumloop/cli/commands/chat.py index c22553d..e91532f 100644 --- a/src/gumloop/cli/commands/chat.py +++ b/src/gumloop/cli/commands/chat.py @@ -2,10 +2,10 @@ import json import sys +from collections.abc import Iterator from pathlib import Path from typing import Annotated from typing import Any -from typing import Iterator import typer @@ -112,7 +112,9 @@ def create_completion( ] = False, json_output: Annotated[ bool, - typer.Option("--json", help="Print the response as JSON. Streaming + --json emits ndjson (one chunk per line)."), + typer.Option( + "--json", help="Print the response as JSON. Streaming + --json emits ndjson (one chunk per line)." + ), ] = False, ) -> None: """Create a chat completion.""" @@ -134,9 +136,7 @@ def create_completion( if not user_message: raise GumloopError("Pass a PROMPT or --message-stdin - with text to send.") - messages: list[dict[str, Any]] = [ - {"role": "system", "content": s} for s in (system or []) - ] + messages: list[dict[str, Any]] = [{"role": "system", "content": s} for s in (system or [])] messages.append({"role": "user", "content": user_message}) # Stream resolution: explicit flags always win; --json without --stream @@ -174,9 +174,7 @@ def create_completion( console.print() # trailing newline to separate the prompt that follows return - result = cli.call_with_refresh( - lambda client: client.chat.completions.create(**kwargs) - ) + result = cli.call_with_refresh(lambda client: client.chat.completions.create(**kwargs)) except GumloopError as error: exit_with_error(error, json_output=json_output) diff --git a/src/gumloop/cli/commands/sync.py b/src/gumloop/cli/commands/sync.py index 62eaed4..6cdcf2c 100644 --- a/src/gumloop/cli/commands/sync.py +++ b/src/gumloop/cli/commands/sync.py @@ -152,9 +152,7 @@ def _prepare_persistent_sync( scheduler = scheduler_for_current_platform(home=home) if non_interactive: background: dict[str, object] = { - "enabled": scheduler.is_current( - Path(config.scheduler_gumloop_path) - ), + "enabled": scheduler.is_current(Path(config.scheduler_gumloop_path)), "interval_seconds": SYNC_INTERVAL_SECONDS, "scheduler": "launch_agent", } @@ -172,10 +170,7 @@ def _prepare_persistent_sync( executable_path = resolve_gumloop_executable() scheduler.validate(executable_path) - was_current = ( - config.scheduler_gumloop_path == str(executable_path) - and scheduler.is_current(executable_path) - ) + was_current = config.scheduler_gumloop_path == str(executable_path) and scheduler.is_current(executable_path) scheduler.install(executable_path) background = { "enabled": True, @@ -183,9 +178,7 @@ def _prepare_persistent_sync( "scheduler": "launch_agent", } if config.scheduler_gumloop_path != str(executable_path): - config = config.model_copy( - update={"scheduler_gumloop_path": str(executable_path)} - ) + config = config.model_copy(update={"scheduler_gumloop_path": str(executable_path)}) write_config(config, home) if not json_output and not was_current: output.print_background_status("repaired") diff --git a/src/gumloop/resources/agents.py b/src/gumloop/resources/agents.py index a19001d..7f241e5 100644 --- a/src/gumloop/resources/agents.py +++ b/src/gumloop/resources/agents.py @@ -81,9 +81,7 @@ def detach_skills(self, agent_id: str, skill_ids: str | Sequence[str]) -> AgentS ) def list_skills(self, agent_id: str, **kwargs: Any) -> SkillListResponse: - return SkillListResponse.model_validate( - self._client.get("skills", params={"agent_id": agent_id, **kwargs}) - ) + return SkillListResponse.model_validate(self._client.get("skills", params={"agent_id": agent_id, **kwargs})) def attach_mcp_server(self, agent_id: str, server_id: str, **config: Any) -> AgentMcpServerResponse: """Attach an MCP server, or update its config if already attached (idempotent upsert).""" diff --git a/src/gumloop/spec/_extensions.py b/src/gumloop/spec/_extensions.py index a1c8b6b..363844d 100644 --- a/src/gumloop/spec/_extensions.py +++ b/src/gumloop/spec/_extensions.py @@ -10,14 +10,14 @@ from __future__ import annotations from typing import Any -from typing import Optional from openrouter.components import ChatAssistantImages from openrouter.components import ChatStreamChoice as _ChatStreamChoice from openrouter.components import ChatStreamChunk as _ChatStreamChunk from openrouter.components import ChatStreamDelta as _ChatStreamDelta from openrouter.components import ChatUsage as _ChatUsage -from openrouter.types import UNSET, OptionalNullable +from openrouter.types import UNSET +from openrouter.types import OptionalNullable class ChatUsage(_ChatUsage): @@ -53,5 +53,5 @@ class ChatStreamChunk(_ChatStreamChunk): # Re-annotate so validation constructs our extended ChatUsage and # ChatStreamChoice. Pydantic resolves by annotation, not isinstance — # without this, parent annotations would drop the added fields. - usage: Optional[ChatUsage] = None # type: ignore[assignment] + usage: ChatUsage | None = None # type: ignore[assignment] choices: list[ChatStreamChoice] # type: ignore[assignment] diff --git a/src/gumloop/sync/installed.py b/src/gumloop/sync/installed.py index e5a7aef..8fa11b7 100644 --- a/src/gumloop/sync/installed.py +++ b/src/gumloop/sync/installed.py @@ -54,8 +54,7 @@ def scan_target( marker_read=marker_read, content_hash=( directory_content_hash(resolved) - if resolved.is_dir() - and (marker_read.status == "valid" or entry.name in hash_unmanaged_names) + if resolved.is_dir() and (marker_read.status == "valid" or entry.name in hash_unmanaged_names) else None ), shared_symlink=True, diff --git a/src/gumloop/sync/target_files.py b/src/gumloop/sync/target_files.py index e70541b..1450d25 100644 --- a/src/gumloop/sync/target_files.py +++ b/src/gumloop/sync/target_files.py @@ -284,9 +284,8 @@ def _operation_entries( ) -> tuple[Path, ...]: entries = tuple(sorted(root.iterdir(), key=lambda path: path.name)) for entry in entries: - valid_name = ( - (install_names and is_safe_install_name(entry.name)) - or (prepared_names and entry.name.startswith("skill-")) + valid_name = (install_names and is_safe_install_name(entry.name)) or ( + prepared_names and entry.name.startswith("skill-") ) if not valid_name or entry.is_symlink() or not entry.is_dir(): raise SyncError("target_failed", f"The Gumloop workspace contains an invalid operation entry: {entry}") diff --git a/tests/cli/test_agents.py b/tests/cli/test_agents.py index 893b8a4..ba77488 100644 --- a/tests/cli/test_agents.py +++ b/tests/cli/test_agents.py @@ -152,15 +152,11 @@ def test_agents_detach_skills_command(cli_runner: CliRunner) -> None: @respx.mock def test_agents_attach_mcp_server_command(cli_runner: CliRunner) -> None: route = respx.put(f"{API_BASE}/agents/agent_abc/mcp-servers/gmail").mock( - return_value=httpx.Response( - 200, json={"agent_id": "agent_abc", "created": True, "auth_status": "connected"} - ) + return_value=httpx.Response(200, json={"agent_id": "agent_abc", "created": True, "auth_status": "connected"}) ) save_credentials(Credentials(api_key="key")) - result = cli_runner.invoke( - app, ["agents", "attach-mcp-server", "agent_abc", "gmail", "--approval-mode", "off"] - ) + result = cli_runner.invoke(app, ["agents", "attach-mcp-server", "agent_abc", "gmail", "--approval-mode", "off"]) assert result.exit_code == 0, result.output assert json.loads(route.calls[0].request.content) == {"approval_mode": "off"} diff --git a/tests/cli/test_chat.py b/tests/cli/test_chat.py index d323098..d0322c8 100644 --- a/tests/cli/test_chat.py +++ b/tests/cli/test_chat.py @@ -19,9 +19,7 @@ def _sse(payloads: list[dict | str]) -> str: - return "".join( - f"data: {p if isinstance(p, str) else json.dumps(p)}\n\n" for p in payloads - ) + return "".join(f"data: {p if isinstance(p, str) else json.dumps(p)}\n\n" for p in payloads) _UNARY_RESPONSE: dict[str, Any] = { @@ -42,9 +40,7 @@ def _sse(payloads: list[dict | str]) -> str: @respx.mock def test_create_no_stream_outputs_message_content(cli_runner: CliRunner) -> None: - route = respx.post(f"{STREAM_BASE}/chat/completions").mock( - return_value=httpx.Response(200, json=_UNARY_RESPONSE) - ) + route = respx.post(f"{STREAM_BASE}/chat/completions").mock(return_value=httpx.Response(200, json=_UNARY_RESPONSE)) save_credentials(Credentials(api_key="key")) result = cli_runner.invoke( @@ -62,9 +58,7 @@ def test_create_no_stream_outputs_message_content(cli_runner: CliRunner) -> None @respx.mock def test_create_json_implies_no_stream_and_outputs_json(cli_runner: CliRunner) -> None: - route = respx.post(f"{STREAM_BASE}/chat/completions").mock( - return_value=httpx.Response(200, json=_UNARY_RESPONSE) - ) + route = respx.post(f"{STREAM_BASE}/chat/completions").mock(return_value=httpx.Response(200, json=_UNARY_RESPONSE)) save_credentials(Credentials(api_key="key")) result = cli_runner.invoke( @@ -81,9 +75,7 @@ def test_create_json_implies_no_stream_and_outputs_json(cli_runner: CliRunner) - @respx.mock def test_create_stdin_message_routes_to_request_body(cli_runner: CliRunner) -> None: - route = respx.post(f"{STREAM_BASE}/chat/completions").mock( - return_value=httpx.Response(200, json=_UNARY_RESPONSE) - ) + route = respx.post(f"{STREAM_BASE}/chat/completions").mock(return_value=httpx.Response(200, json=_UNARY_RESPONSE)) save_credentials(Credentials(api_key="key")) result = cli_runner.invoke( @@ -101,18 +93,22 @@ def test_create_stdin_message_routes_to_request_body(cli_runner: CliRunner) -> N @respx.mock def test_create_system_messages_repeatable_and_ordered(cli_runner: CliRunner) -> None: - route = respx.post(f"{STREAM_BASE}/chat/completions").mock( - return_value=httpx.Response(200, json=_UNARY_RESPONSE) - ) + route = respx.post(f"{STREAM_BASE}/chat/completions").mock(return_value=httpx.Response(200, json=_UNARY_RESPONSE)) save_credentials(Credentials(api_key="key")) result = cli_runner.invoke( app, [ - "chat", "completions", "create", "go", - "-m", "claude-sonnet-4-5", - "--system", "you are A", - "--system", "you are B", + "chat", + "completions", + "create", + "go", + "-m", + "claude-sonnet-4-5", + "--system", + "you are A", + "--system", + "you are B", "--json", ], ) @@ -133,9 +129,14 @@ def test_create_rejects_both_prompt_and_message_stdin(cli_runner: CliRunner) -> result = cli_runner.invoke( app, [ - "chat", "completions", "create", "x", - "-m", "claude-sonnet-4-5", - "--message-stdin", "-", + "chat", + "completions", + "create", + "x", + "-m", + "claude-sonnet-4-5", + "--message-stdin", + "-", "--json", ], input="y\n", @@ -186,15 +187,24 @@ def test_create_streaming_outputs_concatenated_deltas( chunks = [ { - "id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m", + "id": "c1", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "Hi"}, "finish_reason": None}], }, { - "id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m", + "id": "c1", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", "choices": [{"index": 0, "delta": {"content": " there"}, "finish_reason": None}], }, { - "id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m", + "id": "c1", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], }, "[DONE]", @@ -233,7 +243,10 @@ def test_create_stream_flag_forces_streaming_when_piped( monkeypatch.setattr("gumloop.cli.commands.chat._stdout_is_tty", lambda: False) chunks: list[dict | str] = [ { - "id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m", + "id": "c1", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}], }, "[DONE]", @@ -273,16 +286,22 @@ def test_create_stream_and_no_stream_conflict(cli_runner: CliRunner) -> None: @respx.mock def test_create_modality_lands_in_request_body_as_modalities(cli_runner: CliRunner) -> None: - route = respx.post(f"{STREAM_BASE}/chat/completions").mock( - return_value=httpx.Response(200, json=_UNARY_RESPONSE) - ) + route = respx.post(f"{STREAM_BASE}/chat/completions").mock(return_value=httpx.Response(200, json=_UNARY_RESPONSE)) save_credentials(Credentials(api_key="key")) result = cli_runner.invoke( app, [ - "chat", "completions", "create", "x", "-m", "m", - "--modality", "image", "--modality", "text", + "chat", + "completions", + "create", + "x", + "-m", + "m", + "--modality", + "image", + "--modality", + "text", "--json", ], ) @@ -300,17 +319,22 @@ def test_create_schema_file_lands_in_response_format( schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} schema_path = tmp_path / "person.json" schema_path.write_text(json.dumps(schema)) - route = respx.post(f"{STREAM_BASE}/chat/completions").mock( - return_value=httpx.Response(200, json=_UNARY_RESPONSE) - ) + route = respx.post(f"{STREAM_BASE}/chat/completions").mock(return_value=httpx.Response(200, json=_UNARY_RESPONSE)) save_credentials(Credentials(api_key="key")) result = cli_runner.invoke( app, [ - "chat", "completions", "create", "x", "-m", "m", - "--schema-file", str(schema_path), - "--schema-name", "Person", + "chat", + "completions", + "create", + "x", + "-m", + "m", + "--schema-file", + str(schema_path), + "--schema-name", + "Person", "--json", ], ) @@ -332,15 +356,24 @@ def test_create_stream_json_emits_ndjson( monkeypatch.setattr("gumloop.cli.commands.chat._stdout_is_tty", lambda: False) chunks: list[dict | str] = [ { - "id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m", + "id": "c1", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "Hi"}, "finish_reason": None}], }, { - "id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m", + "id": "c1", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", "choices": [{"index": 0, "delta": {"content": " there"}, "finish_reason": None}], }, { - "id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m", + "id": "c1", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], }, "[DONE]", @@ -367,8 +400,7 @@ def test_create_stream_json_emits_ndjson( assert all("choices" in obj and "id" in obj for obj in parsed) # Concatenated content reconstructs to the streamed text. text = "".join( - (choice.get("delta", {}) or {}).get("content", "") or "" - for obj in parsed for choice in obj["choices"] + (choice.get("delta", {}) or {}).get("content", "") or "" for obj in parsed for choice in obj["choices"] ) assert text == "Hi there" @@ -382,9 +414,7 @@ def test_create_stream_json_emits_ndjson( def test_max_tokens_alias_sends_same_wire_field_as_max_completion_tokens( cli_runner: CliRunner, ) -> None: - route = respx.post(f"{STREAM_BASE}/chat/completions").mock( - return_value=httpx.Response(200, json=_UNARY_RESPONSE) - ) + route = respx.post(f"{STREAM_BASE}/chat/completions").mock(return_value=httpx.Response(200, json=_UNARY_RESPONSE)) save_credentials(Credentials(api_key="key")) a = cli_runner.invoke( @@ -414,10 +444,17 @@ def test_max_tokens_and_max_completion_tokens_conflict(cli_runner: CliRunner) -> result = cli_runner.invoke( app, [ - "chat", "completions", "create", "x", "-m", "m", + "chat", + "completions", + "create", + "x", + "-m", + "m", "--no-stream", - "--max-tokens", "256", - "--max-completion-tokens", "256", + "--max-tokens", + "256", + "--max-completion-tokens", + "256", ], ) @@ -458,9 +495,7 @@ def test_stream_flag_overrides_tty_detection( ) -> None: # TTY=True would normally stream; --no-stream must override and produce unary. monkeypatch.setattr("gumloop.cli.commands.chat._stdout_is_tty", lambda: True) - route = respx.post(f"{STREAM_BASE}/chat/completions").mock( - return_value=httpx.Response(200, json=_UNARY_RESPONSE) - ) + route = respx.post(f"{STREAM_BASE}/chat/completions").mock(return_value=httpx.Response(200, json=_UNARY_RESPONSE)) save_credentials(Credentials(api_key="key")) result = cli_runner.invoke( @@ -478,9 +513,7 @@ def test_modality_flag_wire_name_matches_sdk_kwarg(cli_runner: CliRunner) -> Non # The kwarg name on the SDK is ``modalities`` (plural). The CLI flag is # ``--modality`` (singular, repeatable) for ergonomics, but the wire MUST # carry the plural so the SDK accepts it. - route = respx.post(f"{STREAM_BASE}/chat/completions").mock( - return_value=httpx.Response(200, json=_UNARY_RESPONSE) - ) + route = respx.post(f"{STREAM_BASE}/chat/completions").mock(return_value=httpx.Response(200, json=_UNARY_RESPONSE)) save_credentials(Credentials(api_key="key")) result = cli_runner.invoke( diff --git a/tests/cli/test_sync_enrollment.py b/tests/cli/test_sync_enrollment.py index 15133ab..7273204 100644 --- a/tests/cli/test_sync_enrollment.py +++ b/tests/cli/test_sync_enrollment.py @@ -514,9 +514,7 @@ def test_departure_stops_scheduler_and_requires_reenrollment( envelope = parse_json_envelope(result) sync_dir = _sync_root(sync_cli_environment.home) - state = json.loads( - (sync_dir / "state.json").read_text(encoding="utf-8") - ) + state = json.loads((sync_dir / "state.json").read_text(encoding="utf-8")) assert result.exit_code == 0 assert envelope["result"]["departure_cleanup"] is True assert envelope["result"]["background"]["enabled"] is False diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index fb37bba..e182c5d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -19,8 +19,7 @@ def _required(name: str) -> str: value = os.environ.get(name) if not value: pytest.fail( - f"required env var {name} is not set — populate gumloop-py/.env " - f"or export it before running live tests", + f"required env var {name} is not set — populate gumloop-py/.env or export it before running live tests", pytrace=False, ) return value diff --git a/tests/integration/test_live.py b/tests/integration/test_live.py index fb579a9..0549df7 100644 --- a/tests/integration/test_live.py +++ b/tests/integration/test_live.py @@ -10,6 +10,8 @@ from gumloop import Gumloop from gumloop import GumloopClient +pytestmark = pytest.mark.live + def test_run_flow_completes_and_returns_outputs(live_client: GumloopClient, test_flow_id: str) -> None: outputs = live_client.run_flow(test_flow_id, inputs={}, timeout=60.0) @@ -140,13 +142,17 @@ def test_attach_again_updates_config_in_place(self, dev_client: Gumloop, make_ag assert response.mcp_server.get("approval_mode") == "always" def test_identity_keys_cannot_be_spoofed_and_secrets_never_round_trip( - self, dev_client: Gumloop, make_agent, + self, + dev_client: Gumloop, + make_agent, ) -> None: agent = make_agent() response = dev_client.agents.attach_mcp_server( - agent.id, "gmail", - mcp_server_url="https://evil.example.com", secret_id="spoofed", + agent.id, + "gmail", + mcp_server_url="https://evil.example.com", + secret_id="spoofed", ) assert response.mcp_server.get("server_id") == "gmail" diff --git a/tests/sdk/test_agents.py b/tests/sdk/test_agents.py index e823592..71d7233 100644 --- a/tests/sdk/test_agents.py +++ b/tests/sdk/test_agents.py @@ -96,9 +96,7 @@ def test_agents_retrieve_and_update_routes(client: Gumloop) -> None: @respx.mock def test_agents_attach_skills_sends_only_attach_body(client: Gumloop) -> None: route = respx.patch(f"{API_BASE}/agents/agent_123/skills").mock( - return_value=httpx.Response( - 200, json={"agent_id": "agent_123", "skill_ids": ["s1", "s2"], "attached": ["s2"]} - ) + return_value=httpx.Response(200, json={"agent_id": "agent_123", "skill_ids": ["s1", "s2"], "attached": ["s2"]}) ) result = client.agents.attach_skills("agent_123", ["s1", "s2"]) @@ -180,9 +178,7 @@ def test_agents_detach_mcp_server(client: Gumloop) -> None: @respx.mock def test_agents_list_mcp_servers(client: Gumloop) -> None: respx.get(f"{API_BASE}/agents/agent_123/mcp-servers").mock( - return_value=httpx.Response( - 200, json={"agent_id": "agent_123", "mcp_servers": [{"server_id": "gmail"}]} - ) + return_value=httpx.Response(200, json={"agent_id": "agent_123", "mcp_servers": [{"server_id": "gmail"}]}) ) result = client.agents.list_mcp_servers("agent_123") @@ -321,7 +317,9 @@ async def run() -> None: assert (await client.agents.get_evaluation_config("agent_123")).config.agent_id == "agent_123" assert (await client.agents.update_evaluation_config("agent_123", enabled=True)).config.enabled is True assert (await client.agents.list_evaluations("agent_123")).evaluations == [] - assert (await client.agents.get_evaluation("agent_123", "eval_1")).evaluation.evaluation_id == "eval_1" + evaluation = (await client.agents.get_evaluation("agent_123", "eval_1")).evaluation + assert evaluation is not None + assert evaluation.evaluation_id == "eval_1" asyncio.run(run()) diff --git a/tests/sdk/test_chat.py b/tests/sdk/test_chat.py index 2be1313..bb661ac 100644 --- a/tests/sdk/test_chat.py +++ b/tests/sdk/test_chat.py @@ -81,7 +81,7 @@ def test_chat_accepts_chatrequest_instance(client: Gumloop) -> None: req = ChatRequest( model="moonshotai/kimi-k2.6", - messages=[{"role": "user", "content": "hi"}], + messages=[{"role": "user", "content": "hi"}], # type: ignore[arg-type] temperature=0.2, ) client.chat.completions.create(req) @@ -128,9 +128,7 @@ def test_chat_kwargs_override_request_body(client: Gumloop) -> None: def _sse(payloads: list[dict | str]) -> str: - return "".join( - f"data: {p if isinstance(p, str) else json.dumps(p)}\n\n" for p in payloads - ) + return "".join(f"data: {p if isinstance(p, str) else json.dumps(p)}\n\n" for p in payloads) @respx.mock @@ -141,9 +139,7 @@ def test_chat_stream_yields_typed_chunks(client: Gumloop) -> None: "object": "chat.completion.chunk", "created": 1, "model": "m", - "choices": [ - {"index": 0, "delta": {"role": "assistant", "content": "Hi"}, "finish_reason": None} - ], + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "Hi"}, "finish_reason": None}], }, { "id": "c1", @@ -203,20 +199,23 @@ def test_chat_stream_skips_done_sentinel(client: Gumloop) -> None: respx.post(f"{STREAM_BASE}/chat/completions").mock( return_value=httpx.Response( 200, - text=_sse([{ - "id": "c1", - "object": "chat.completion.chunk", - "created": 1, - "model": "m", - "choices": [{"index": 0, "delta": {"content": "x"}, "finish_reason": None}], - }, "[DONE]"]), + text=_sse( + [ + { + "id": "c1", + "object": "chat.completion.chunk", + "created": 1, + "model": "m", + "choices": [{"index": 0, "delta": {"content": "x"}, "finish_reason": None}], + }, + "[DONE]", + ] + ), headers={"content-type": "text/event-stream"}, ) ) - received = list( - client.chat.completions.create(model="m", messages=[{"role": "user", "content": "x"}], stream=True) - ) + received = list(client.chat.completions.create(model="m", messages=[{"role": "user", "content": "x"}], stream=True)) assert len(received) == 1 @@ -242,9 +241,7 @@ def test_chat_stream_error_chunk_surfaces_via_chunk_error(client: Gumloop) -> No ) ) - received = list( - client.chat.completions.create(model="m", messages=[{"role": "user", "content": "x"}], stream=True) - ) + received = list(client.chat.completions.create(model="m", messages=[{"role": "user", "content": "x"}], stream=True)) assert len(received) == 1 chunk = received[0] @@ -379,27 +376,31 @@ def test_chat_stream_carries_image_delta_chunks(client: Gumloop) -> None: "object": "chat.completion.chunk", "created": 1, "model": "gpt-image-1.5", - "choices": [{ - "index": 0, - "delta": { - "role": "assistant", - "images": [{"image_url": {"url": "data:image/png;base64,YWJj"}}], - }, - "finish_reason": None, - }], + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "images": [{"image_url": {"url": "data:image/png;base64,YWJj"}}], + }, + "finish_reason": None, + } + ], }, { "id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "gpt-image-1.5", - "choices": [{ - "index": 0, - "delta": { - "images": [{"image_url": {"url": "data:image/png;base64,ZGVm"}}], - }, - "finish_reason": "stop", - }], + "choices": [ + { + "index": 0, + "delta": { + "images": [{"image_url": {"url": "data:image/png;base64,ZGVm"}}], + }, + "finish_reason": "stop", + } + ], }, "[DONE]", ] @@ -448,9 +449,7 @@ def test_async_chat_create(async_client: AsyncGumloop) -> None: ) async def run() -> None: - result = await async_client.chat.completions.create( - model="m", messages=[{"role": "user", "content": "x"}] - ) + result = await async_client.chat.completions.create(model="m", messages=[{"role": "user", "content": "x"}]) assert result.choices[0].message.content == "hi" asyncio.run(run()) diff --git a/tests/sdk/test_client.py b/tests/sdk/test_client.py index 43f8147..20555ee 100644 --- a/tests/sdk/test_client.py +++ b/tests/sdk/test_client.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from typing import Any import httpx import pytest @@ -99,7 +100,7 @@ def test_env_sourced_token_falls_back_to_snapshot_when_env_cleared(monkeypatch: ) @respx.mock def test_explicit_credential_ignores_env_rotation( - monkeypatch: pytest.MonkeyPatch, kwargs: dict[str, str], expected_header: str + monkeypatch: pytest.MonkeyPatch, kwargs: dict[str, Any], expected_header: str ) -> None: """Explicitly passed credentials are immutable — a env var appearing or rotating later must never hijack the client's identity.""" diff --git a/tests/sdk/test_sync.py b/tests/sdk/test_sync.py index ffa5802..1524959 100644 --- a/tests/sdk/test_sync.py +++ b/tests/sdk/test_sync.py @@ -76,7 +76,9 @@ class TestPlan: def test_plan_sends_cli_version_header_auth_and_body_for_api_key(self, api_key_sync: Sync) -> None: """A personal API key plan request carries auth, CLI version, and the plan body.""" route = respx.post(PLAN_URL).mock( - return_value=httpx.Response(200, json=load_json("responses/normal-plan.json"), headers=_sync_response_headers()) + return_value=httpx.Response( + 200, json=load_json("responses/normal-plan.json"), headers=_sync_response_headers() + ) ) result = api_key_sync.plan(organization_id="org_fixture") @@ -92,7 +94,9 @@ def test_plan_sends_cli_version_header_auth_and_body_for_api_key(self, api_key_s def test_plan_omits_organization_id_when_not_provided(self, api_key_sync: Sync) -> None: """A plan request omits organization_id when the caller leaves it unset.""" route = respx.post(PLAN_URL).mock( - return_value=httpx.Response(200, json=load_json("responses/normal-plan.json"), headers=_sync_response_headers()) + return_value=httpx.Response( + 200, json=load_json("responses/normal-plan.json"), headers=_sync_response_headers() + ) ) api_key_sync.plan() @@ -104,7 +108,9 @@ def test_plan_omits_organization_id_when_not_provided(self, api_key_sync: Sync) def test_plan_oauth_uses_bearer_without_x_auth_key(self, oauth_sync: Sync) -> None: """An OAuth plan request sends only the bearer token.""" route = respx.post(PLAN_URL).mock( - return_value=httpx.Response(200, json=load_json("responses/normal-plan.json"), headers=_sync_response_headers()) + return_value=httpx.Response( + 200, json=load_json("responses/normal-plan.json"), headers=_sync_response_headers() + ) ) oauth_sync.plan() @@ -128,7 +134,9 @@ def test_plan_parses_valid_response_and_accepts_additive_fields(self, api_key_sy @respx.mock def test_plan_malformed_json_raises_invalid_desired_state(self, api_key_sync: Sync) -> None: """Malformed plan JSON becomes a stable invalid_desired_state sync error.""" - respx.post(PLAN_URL).mock(return_value=httpx.Response(200, content=b"not-json", headers=_sync_response_headers())) + respx.post(PLAN_URL).mock( + return_value=httpx.Response(200, content=b"not-json", headers=_sync_response_headers()) + ) with pytest.raises(SyncError) as exc_info: api_key_sync.plan() From 7794a79d4063250710776f4e490d77bba3ec7785 Mon Sep 17 00:00:00 2001 From: dvlpjrs Date: Thu, 23 Jul 2026 03:12:48 +0000 Subject: [PATCH 3/3] [TASK] Merge main and fix lint/typecheck in guMCP transport --- src/gumloop/_gumcp_transport.py | 19 +++++-------------- tests/sdk/test_gumcp_transport.py | 13 ++++--------- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/gumloop/_gumcp_transport.py b/src/gumloop/_gumcp_transport.py index b9dd583..5efc8b5 100644 --- a/src/gumloop/_gumcp_transport.py +++ b/src/gumloop/_gumcp_transport.py @@ -37,7 +37,7 @@ def gumcp_env_ready() -> bool: def _import_async_client() -> Any: try: - from gumcp_client import AsyncClient + from gumcp_client import AsyncClient # type: ignore[import-not-found] except ImportError as exc: raise GumloopError( "GUMCP_ACCESS_TOKEN is set but gumcp_client is not installed. " @@ -164,9 +164,7 @@ def _map_exception(exc: BaseException, *, ref: str, server_id: str, tool_name: s tool_name=tool_name, status="error", code="mcp_server_connection_failed", - message=( - "MCP server connection failed. Check the MCP server URL, authentication, and credentials." - ), + message=("MCP server connection failed. Check the MCP server URL, authentication, and credentials."), error_type="api_error", details={"server_id": server_id}, ) @@ -181,8 +179,7 @@ def _map_exception(exc: BaseException, *, ref: str, server_id: str, tool_name: s status="error", code="mcp_server_http_error", message=( - f"MCP server returned HTTP {http_status}. " - "Check the MCP server URL, authentication, and credentials." + f"MCP server returned HTTP {http_status}. Check the MCP server URL, authentication, and credentials." ), error_type=_api_error_type(http_status), details={"server_id": server_id, "status_code": http_status}, @@ -197,9 +194,7 @@ def _map_exception(exc: BaseException, *, ref: str, server_id: str, tool_name: s tool_name=tool_name, status="error", code="mcp_server_connection_failed", - message=( - "MCP server connection failed. Check the MCP server URL, authentication, and credentials." - ), + message=("MCP server connection failed. Check the MCP server URL, authentication, and credentials."), error_type="api_error", details={"server_id": server_id}, ) @@ -309,11 +304,7 @@ async def call_one( return _map_exception(exc, ref=result_ref, server_id=server_id, tool_name=tool_name) except Exception as exc: result = _map_exception(exc, ref=result_ref, server_id=server_id, tool_name=tool_name) - if ( - not _retried - and result.status == "unauthenticated" - and self._current_fingerprint() != self._fingerprint - ): + if not _retried and result.status == "unauthenticated" and self._current_fingerprint() != self._fingerprint: # Env token rotated mid-flight: rebuild once and retry. await self._close_client() return await self.call_one( diff --git a/tests/sdk/test_gumcp_transport.py b/tests/sdk/test_gumcp_transport.py index 286bf4e..d3d88bb 100644 --- a/tests/sdk/test_gumcp_transport.py +++ b/tests/sdk/test_gumcp_transport.py @@ -139,9 +139,7 @@ def test_execute_many_rejects_more_than_five(gumcp_env: None) -> None: with patch("gumloop._gumcp_transport._import_async_client", return_value=MagicMock): client = Gumloop(access_token="http-token") with pytest.raises(ValueError, match="cannot exceed 5"): - client.mcp.execute_many( - [{"server_id": "gmail", "tool_name": f"t{i}", "arguments": {}} for i in range(6)] - ) + client.mcp.execute_many([{"server_id": "gmail", "tool_name": f"t{i}", "arguments": {}} for i in range(6)]) def test_error_mapping_auth_and_not_allowed(gumcp_env: None) -> None: @@ -216,9 +214,7 @@ def test_http_path_still_used_when_gumcp_env_absent_for_execute_many( def test_cancel_scope_error_maps_and_keeps_session(gumcp_env: None) -> None: """Cancel-scope failures map to results; the session is kept.""" mock_client = MagicMock() - mock_client.call_tool = AsyncMock( - side_effect=[asyncio.CancelledError("cancel scope corrupted"), ["ok"]] - ) + mock_client.call_tool = AsyncMock(side_effect=[asyncio.CancelledError("cancel scope corrupted"), ["ok"]]) mock_client.close = AsyncMock() construct_count = {"n": 0} @@ -361,9 +357,7 @@ async def close(self) -> None: with patch("gumloop._gumcp_transport._import_async_client", return_value=LoopRecordingClient): client = Gumloop(access_token="http-token") with ThreadPoolExecutor(max_workers=4) as ex: - futures = [ - ex.submit(client.mcp.execute, "gmail", f"tool_{i}", {}) for i in range(8) - ] + futures = [ex.submit(client.mcp.execute, "gmail", f"tool_{i}", {}) for i in range(8)] results = [f.result().results[0] for f in futures] client.close() @@ -394,6 +388,7 @@ def test_auth_failure_after_env_rotation_rebuilds_and_retries(gumcp_env: None) - def _factory(**kwargs: Any) -> Any: mock_client = MagicMock() if not clients: + async def _fail(_tool: str, _args: dict[str, Any]) -> list[str]: os.environ["GUMCP_ACCESS_TOKEN"] = "gumcp-token-rotated" raise RuntimeError("credentials_not_found: Authentication required")