diff --git a/.github/workflows/session-e2e.yaml b/.github/workflows/session-e2e.yaml index d6222628d..bacf037e7 100644 --- a/.github/workflows/session-e2e.yaml +++ b/.github/workflows/session-e2e.yaml @@ -1,10 +1,11 @@ name: Session E2E # Drives the real `lk agent daemon` start/say/stop lifecycle against the minimal -# one-file echo agents in cmd/lk/testdata, on Linux, macOS, and Windows. This -# exercises the detached daemon, the readiness handshake, the console IPC -# transport, and the model round-trip end to end -- runtime behavior that -# `go test` alone never covers. +# one-file echo agents in cmd/lk/testdata, on Linux and macOS (the Windows arm +# lives in windows.yaml, which cross-compiles with zig). This exercises the +# detached daemon, the readiness handshake, the console IPC transport, and the +# model round-trip end to end -- runtime behavior that `go test` alone never +# covers. # # Both agent runtimes are exercised via the `lang` matrix dimension: Python # (testdata/echo-agent, the test's default) and Node (testdata/echo-agent-node, @@ -22,16 +23,6 @@ name: Session E2E # fixture via `uv sync` from its pyproject.toml; the Node fixture via # `npm install` from its package.json. The Node `.ts` entrypoint runs through # Node's --experimental-strip-types loader, which needs Node >= 22.6. -# -# Windows is split into two jobs: pkg/apm/webrtc's C++ uses MSVC SEH -# (__try/__except) that the runner's mingw GCC can't compile, so we build with -# zig (clang) exactly as .goreleaser.yaml does. zig-as-CC must run on Linux: on -# native Windows the cgo link of ~560 webrtc/portaudio objects overflows the -# ~32KB command-line limit. So `cross-build-windows` cross-compiles lk.exe AND -# the e2e test binary on Linux and uploads them; `e2e-windows` downloads them -# and runs the test natively (LK_SESSION_E2E_BIN points the test at the -# prebuilt lk, so nothing is rebuilt on the Windows runner). The binaries are -# language-agnostic, so that build runs once and feeds every `lang` arm. on: workflow_dispatch: @@ -114,135 +105,3 @@ jobs: # Resolved relative to cmd/lk, the test binary's working directory. LK_SESSION_E2E_AGENT: ${{ matrix.lang == 'node' && 'testdata/echo-agent-node/agent.ts' || '' }} run: go test ./cmd/lk -run TestSessionE2E -count=1 -v -timeout 600s - - # Cross-compile the Windows artifacts on Linux with zig, mirroring - # .goreleaser.yaml's lk-windows-amd64 build. Produces lk.exe (the binary under - # test) and the compiled e2e test binary, both shipped to e2e-windows. - cross-build-windows: - runs-on: ubuntu-latest - name: Cross-build Windows artifacts (zig) - - permissions: - contents: read - - steps: - - name: Checkout livekit-cli - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - submodules: true - - - name: Set up Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - with: - version: 0.14.1 - - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 - with: - go-version-file: go.mod - cache: true - - # Generate the MinGW import libs lld needs for Go's /DEFAULTLIB references - # (dbghelp, bcrypt, ...), exactly as goreleaser's before-hook does. - - name: Generate MinGW import libs - run: scripts/setup-cross.sh windows/amd64 - - - name: Cross-compile lk.exe and the e2e test binary - env: - GOOS: windows - GOARCH: amd64 - CGO_ENABLED: "1" - # zig cc/c++ as the cgo toolchain, matching .goreleaser.yaml's Windows - # build. -fms-extensions (SEH) isn't on cgo's allowlist; -fno-sanitize - # is a zig UBSan-under-lld workaround -- both copied from goreleaser. - CC: zig cc -target x86_64-windows-gnu - CXX: zig c++ -target x86_64-windows-gnu - CGO_CXXFLAGS_ALLOW: -fms-extensions - CGO_CXXFLAGS: -fno-sanitize=all - run: | - ldflags="-extldflags=-L$PWD/.cross/windows_amd64/mingw_lib" - mkdir -p dist - go build -ldflags="$ldflags" -o dist/lk.exe ./cmd/lk - go test -c -ldflags="$ldflags" -o dist/session-e2e.test.exe ./cmd/lk - - - name: Upload Windows artifacts - uses: actions/upload-artifact@v4 - with: - name: windows-e2e - path: dist/*.exe - retention-days: 1 - if-no-files-found: error - - # Run the prebuilt Windows artifacts natively. No Go/zig/cgo here: the test - # binary just drives lk.exe (via LK_SESSION_E2E_BIN) against the echo agent. - e2e-windows: - needs: cross-build-windows - runs-on: windows-latest - name: Agent Session with ${{ matrix.lang }} Agent on windows-latest - - strategy: - fail-fast: false - matrix: - lang: [python, node] - - permissions: - contents: read - - steps: - - name: Checkout livekit-cli - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - # No submodules: the prebuilt binaries already contain the cgo code; - # only the echo-agent fixtures (plain repo files) are needed at runtime. - - - name: Download Windows artifacts - uses: actions/download-artifact@v4 - with: - name: windows-e2e - path: dist - - - name: Set up Python - if: matrix.lang == 'python' - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Set up uv - if: matrix.lang == 'python' - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - - - name: Sync echo agent deps - if: matrix.lang == 'python' - working-directory: cmd/lk/testdata/echo-agent - run: uv sync - - - name: Set up Node - if: matrix.lang == 'node' - uses: actions/setup-node@v4 - with: - # --experimental-strip-types (used to run the .ts entrypoint) needs >= 22.6. - node-version: "22" - - - name: Install Node echo agent deps - if: matrix.lang == 'node' - working-directory: cmd/lk/testdata/echo-agent-node - run: npm install - - - name: Run session e2e - # Run from cmd/lk so the test's relative testdata/ entrypoint paths - # resolve; point it at the cross-built lk.exe so nothing rebuilds. - shell: bash - working-directory: cmd/lk - env: - # Relative to cmd/lk; Go's filepath.Abs resolves it. Forward slashes - # keep bash happy (GITHUB_WORKSPACE would carry Windows backslashes). - LK_SESSION_E2E_BIN: ../../dist/lk.exe - LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }} - LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }} - LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }} - # Default (unset) targets the Python fixture; point at the Node one for that arm. - LK_SESSION_E2E_AGENT: ${{ matrix.lang == 'node' && 'testdata/echo-agent-node/agent.ts' || '' }} - run: | - ../../dist/session-e2e.test.exe \ - -test.run TestSessionE2E -test.count=1 -test.v -test.timeout 600s diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml new file mode 100644 index 000000000..6b9350e71 --- /dev/null +++ b/.github/workflows/windows.yaml @@ -0,0 +1,218 @@ +name: Windows + +# All Windows CI for the repo. test.yaml covers Linux/macOS Go tests and the +# `e2e` job in session-e2e.yaml covers Linux/macOS session e2e; Windows is here. +# +# pkg/apm/webrtc's C++ uses MSVC SEH (__try/__except) that the runner's mingw GCC +# can't compile, and on native Windows the cgo link of ~560 webrtc/portaudio +# objects overflows the ~32KB command-line limit. So -- exactly like +# .goreleaser.yaml -- everything is cross-compiled on Linux with zig (clang) in a +# single `cross-build` job, then run natively on windows-latest. Both consumers +# (the unit-test run and the session e2e run) depend on that one build, so the +# expensive cgo compile happens once. + +on: + push: + branches: ['**'] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: windows-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Cross-compile lk.exe and a test binary per package on Linux with zig. + # manifest.tsv maps each test binary to its package directory so the unit-test + # job can run it from there (tests read cwd-relative fixtures, e.g. + # cmd/lk/testdata and pkg/agentfs/examples). + cross-build: + runs-on: ubuntu-latest + name: Cross-build Windows artifacts (zig) + + permissions: + contents: read + + steps: + - name: Checkout livekit-cli + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + # pkg/portaudio/pa_src is a submodule with the vendored PortAudio C + # source linked into cmd/lk via cgo. + submodules: true + + - name: Set up Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + with: + version: 0.14.1 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + cache: true + + # Generate the MinGW import libs lld needs for Go's /DEFAULTLIB references + # (dbghelp, bcrypt, ...), exactly as goreleaser's before-hook does. + - name: Generate MinGW import libs + run: scripts/setup-cross.sh windows/amd64 + + - name: Cross-compile lk.exe and per-package test binaries + env: + GOOS: windows + GOARCH: amd64 + CGO_ENABLED: "1" + # zig cc/c++ as the cgo toolchain, matching .goreleaser.yaml's Windows + # build. -fms-extensions (SEH) isn't on cgo's allowlist; -fno-sanitize + # is a zig UBSan-under-lld workaround -- both copied from goreleaser. + CC: zig cc -target x86_64-windows-gnu + CXX: zig c++ -target x86_64-windows-gnu + CGO_CXXFLAGS_ALLOW: -fms-extensions + CGO_CXXFLAGS: -fno-sanitize=all + run: | + set -euo pipefail + ldflags="-extldflags=-L$PWD/.cross/windows_amd64/mingw_lib" + module=$(go list -m) + mkdir -p dist + go build -ldflags="$ldflags" -o dist/lk.exe ./cmd/lk + : > dist/manifest.tsv + for pkg in $(go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...); do + rel=${pkg#"$module"/} + [ "$rel" = "$module" ] && rel="." + safe=$(echo "$rel" | tr '/' '_') + go test -c -ldflags="$ldflags" -o "dist/${safe}.test.exe" "$pkg" + printf '%s\t%s\n' "${safe}.test.exe" "$rel" >> dist/manifest.tsv + done + echo "Built lk.exe and:"; cat dist/manifest.tsv + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-artifacts + path: dist + retention-days: 1 + if-no-files-found: error + + # Run every package's test binary natively, from its package directory so + # cwd-relative fixtures resolve. No secrets: TestSessionE2E (in the cmd/lk + # binary) skips without LIVEKIT_API_KEY and is exercised by windows-session-e2e + # below. node ships on the runner; uv is installed for the Python SDK tests. + windows-tests: + needs: cross-build + runs-on: windows-latest + name: Go test on windows-latest + + permissions: + contents: read + + steps: + - name: Checkout livekit-cli + # Test fixtures (cmd/lk/testdata, pkg/agentfs/examples) are read at + # runtime; submodules are C source already compiled into the binaries. + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows-artifacts + path: dist + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Run test binaries + shell: bash + run: | + set -uo pipefail + root=$(pwd) + fail=0 + while IFS=$'\t' read -r exe rel; do + echo "::group::$rel" + ( cd "$rel" && "$root/dist/$exe" -test.v -test.count=1 ) || fail=1 + echo "::endgroup::" + done < "$root/dist/manifest.tsv" + exit $fail + + # Drive the real session lifecycle on Windows against the prebuilt lk.exe. + # Needs live LiveKit credentials, so skip on pull_request (forks can't read + # secrets); the cmd/lk test binary already exists from cross-build. + windows-session-e2e: + needs: cross-build + if: github.event_name != 'pull_request' + runs-on: windows-latest + name: lk agent with ${{ matrix.lang }} agent on windows-latest + + # Same `lang` matrix as session-e2e.yaml's e2e job: Python and Node echo + # agents. The cross-built binaries are language-agnostic, so one build + # feeds both arms. + strategy: + fail-fast: false + matrix: + lang: [python, node] + + permissions: + contents: read + + steps: + - name: Checkout livekit-cli + # Needed for the echo-agent fixtures; submodules are compiled into lk.exe. + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows-artifacts + path: dist + + - name: Set up Python + if: matrix.lang == 'python' + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up uv + if: matrix.lang == 'python' + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Sync echo agent deps + if: matrix.lang == 'python' + working-directory: cmd/lk/testdata/echo-agent + run: uv sync + + - name: Set up Node + if: matrix.lang == 'node' + uses: actions/setup-node@v4 + with: + # --experimental-strip-types (used to run the .ts entrypoint) needs >= 22.6. + node-version: "22" + + - name: Install Node echo agent deps + if: matrix.lang == 'node' + working-directory: cmd/lk/testdata/echo-agent-node + run: npm install + + - name: Run session e2e + # Run from cmd/lk so the test's relative testdata/ entrypoint paths + # resolve; point it at the cross-built lk.exe so nothing rebuilds. + shell: bash + working-directory: cmd/lk + env: + LK_SESSION_E2E_BIN: ../../dist/lk.exe + LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }} + LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }} + LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }} + # Default (unset) targets the Python fixture; point at the Node one for that arm. + LK_SESSION_E2E_AGENT: ${{ matrix.lang == 'node' && 'testdata/echo-agent-node/agent.ts' || '' }} + run: | + ../../dist/cmd_lk.test.exe \ + -test.run TestSessionE2E -test.count=1 -test.v -test.timeout 600s diff --git a/cmd/lk/console_tui.go b/cmd/lk/console_tui.go index 6186b8659..b837b9958 100644 --- a/cmd/lk/console_tui.go +++ b/cmd/lk/console_tui.go @@ -18,6 +18,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "strings" "time" @@ -51,22 +52,30 @@ var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", " // startSpinner shows a braille spinner on stderr with the given message. // Returns a stop function that clears the spinner line. func startSpinner(msg string) func() { + return startSpinnerTo(os.Stderr, msg) +} + +func startSpinnerTo(w io.Writer, msg string) func() { done := make(chan struct{}) + cleared := make(chan struct{}) go func() { - i := 0 - for { + defer close(cleared) + for i := 0; ; i++ { + fmt.Fprintf(w, "\r %s %s", spinnerFrames[i%len(spinnerFrames)], msg) select { case <-done: - fmt.Fprintf(os.Stderr, "\r\033[K") + fmt.Fprintf(w, "\r\033[K") return - default: - fmt.Fprintf(os.Stderr, "\r %s %s", spinnerFrames[i%len(spinnerFrames)], msg) - i++ - time.Sleep(80 * time.Millisecond) + case <-time.After(80 * time.Millisecond): } } }() - return func() { close(done) } + // Stopping blocks until the line is cleared, so the caller's next print + // (e.g. an error) never lands on the leftover spinner text. + return func() { + close(done) + <-cleared + } } type consoleTickMsg struct{} diff --git a/cmd/lk/console_tui_test.go b/cmd/lk/console_tui_test.go new file mode 100644 index 000000000..c6dd764cf --- /dev/null +++ b/cmd/lk/console_tui_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStopSpinner_ClearsLineBeforeReturning(t *testing.T) { + // Models the caller printing an error right after stopping the spinner: + // by the time stop returns, the spinner line must already be cleared, or + // the error lands on the same line as the leftover "⠋ Starting agent". + var buf bytes.Buffer + stop := startSpinnerTo(&buf, "Starting agent") + stop() + out := buf.String() + require.True(t, strings.HasSuffix(out, "\r\033[K"), + "spinner line not cleared when stop returned; output: %q", out) +} diff --git a/cmd/lk/python_sdk_version_test.go b/cmd/lk/python_sdk_version_test.go new file mode 100644 index 000000000..8c2f09042 --- /dev/null +++ b/cmd/lk/python_sdk_version_test.go @@ -0,0 +1,122 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-cli/v2/pkg/agentfs" +) + +// setupUvAgentProject builds a real uv project whose only dependency is a local +// stub package named "livekit-agents" pinned to stubVersion. This resolves a +// known installed version through real uv — no network, no real SDK — and +// works on every platform uv supports. depSpec is the dependency string written +// to the project's pyproject.toml (e.g. "livekit-agents" or +// "livekit-agents>=1.0"); when sync is true the stub is installed into the env. +func setupUvAgentProject(t *testing.T, stubVersion, depSpec string, sync bool) string { + t.Helper() + if _, err := exec.LookPath("uv"); err != nil { + t.Skip("uv not on PATH") + } + dir := t.TempDir() + + stubMod := filepath.Join(dir, "stub", "src", "livekit_agents") + require.NoError(t, os.MkdirAll(stubMod, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(stubMod, "__init__.py"), nil, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "stub", "pyproject.toml"), []byte( + "[project]\n"+ + "name = \"livekit-agents\"\n"+ + "version = \""+stubVersion+"\"\n"+ + "requires-python = \">=3.8\"\n"+ + "[build-system]\n"+ + "requires = [\"uv_build>=0.5,<10\"]\n"+ + "build-backend = \"uv_build\"\n"), 0o644)) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte( + "[project]\n"+ + "name = \"test-agent\"\n"+ + "version = \"0.0.0\"\n"+ + "requires-python = \">=3.8\"\n"+ + "dependencies = [\""+depSpec+"\"]\n"+ + "[tool.uv.sources]\n"+ + "livekit-agents = { path = \"stub\" }\n"), 0o644)) + + if sync { + cmd := exec.Command("uv", "sync") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("uv sync failed: %v\n%s", err, out) + } + } + return dir +} + +func TestResolvePythonAgentVersion_ReadsInstalledVersion(t *testing.T) { + dir := setupUvAgentProject(t, "1.6.7", "livekit-agents", true) + version, notInstalled := resolvePythonAgentVersion(dir, agentfs.ProjectTypePythonUV) + require.Equal(t, "1.6.7", version) + require.False(t, notInstalled) +} + +func TestCheckPythonSDKVersion_TooOld(t *testing.T) { + dir := setupUvAgentProject(t, "1.0.0", "livekit-agents", true) + err := checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV}) + require.Error(t, err) + require.Contains(t, err.Error(), "too old") +} + +func TestCheckPythonSDKVersion_InstalledBeatsLooseConstraint(t *testing.T) { + // The pyproject floor >=1.0 would fail static parsing, but the installed + // 1.6.7 is what gets used — proving the resolved version wins. + dir := setupUvAgentProject(t, "1.6.7", "livekit-agents>=1.0", true) + require.NoError(t, checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV})) +} + +func TestCheckPythonSDKVersion_UnsyncedUVProjectSuggestsSync(t *testing.T) { + // Models a fresh clone where dependencies were never installed. The CLI is + // a proxy for the local environment and never syncs it implicitly, so the + // agent would die at launch with ModuleNotFoundError; the pre-flight must + // fail fast and tell the user how to fix it instead. + dir := setupUvAgentProject(t, "1.6.7", "livekit-agents>=1.6.0", false) + err := checkPythonSDKVersion(AgentStartConfig{Dir: dir, ProjectType: agentfs.ProjectTypePythonUV}) + require.ErrorContains(t, err, "uv sync") +} + +func TestFindPythonBinary_UVRunDoesNotSyncEnvironment(t *testing.T) { + // Models a user who synced their env, then edited a dependency's version + // without re-syncing. Running through the CLI-resolved interpreter must + // execute against the environment as it exists on disk — a plain `uv run` + // would re-lock and install 9.9.9 as a side effect of launching. + dir := setupUvAgentProject(t, "1.6.7", "livekit-agents", true) + stubPyproject := filepath.Join(dir, "stub", "pyproject.toml") + orig, err := os.ReadFile(stubPyproject) + require.NoError(t, err) + require.NoError(t, os.WriteFile(stubPyproject, []byte(strings.Replace(string(orig), "1.6.7", "9.9.9", 1)), 0o644)) + + bin, prefixArgs, err := findPythonBinary(dir, agentfs.ProjectTypePythonUV) + require.NoError(t, err) + cmd := exec.Command(bin, append(prefixArgs, "-c", `import importlib.metadata as m; print(m.version("livekit-agents"))`)...) + cmd.Dir = dir + out, err := cmd.Output() + require.NoError(t, err) + require.Equal(t, "1.6.7", strings.TrimSpace(string(out))) +} diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index 979949fa6..d8859d283 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -58,7 +58,9 @@ func findPythonBinary(dir string, projectType agentfs.ProjectType) (string, []st if projectType == agentfs.ProjectTypePythonUV { uvPath, err := exec.LookPath("uv") if err == nil { - return uvPath, []string{"run", "python"}, nil + // --no-sync: the CLI proxies the environment as it exists on disk + // and must never install or upgrade packages as a side effect. + return uvPath, []string{"run", "--no-sync", "python"}, nil } } @@ -180,6 +182,68 @@ func checkNodeSDKVersion(cfg AgentStartConfig) error { return nil } +// pythonResolveVersionScript prints the installed livekit-agents version, or a +// sentinel when the interpreter runs fine but the package isn't installed — +// distinguishing "dependencies not synced" from probe failures (no +// interpreter, timeout), which exit non-zero. +const pythonResolveVersionScript = `import importlib.metadata as m +try: + print(m.version("livekit-agents")) +except m.PackageNotFoundError: + print("` + pythonAgentNotInstalled + `")` + +const pythonAgentNotInstalled = "__NOT_INSTALLED__" + +// resolvePythonAgentVersion returns the installed livekit-agents version read +// via the project's interpreter, so any installer (uv, pip, poetry) reports the +// version that will actually run. notInstalled reports that the interpreter ran +// but the package is missing from its environment; version is "" when it can't +// be determined at all (no interpreter, probe failure, etc.). +func resolvePythonAgentVersion(dir string, projectType agentfs.ProjectType) (version string, notInstalled bool) { + pythonBin, prefixArgs, err := findPythonBinary(dir, projectType) + if err != nil { + return "", false + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + args := append(append([]string{}, prefixArgs...), "-c", pythonResolveVersionScript) + cmd := exec.CommandContext(ctx, pythonBin, args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", false + } + v := strings.TrimSpace(string(out)) + if v == pythonAgentNotInstalled { + return "", true + } + return v, false +} + +// checkPythonSDKVersion gates a Python agent on thinCLIMinVersion. It prefers +// the installed version (resolved via the interpreter, accurate regardless of +// the package manager and not fooled by a loose version constraint); when +// dependencies aren't installed it falls back to static project-file parsing. +func checkPythonSDKVersion(cfg AgentStartConfig) error { + version, notInstalled := resolvePythonAgentVersion(cfg.Dir, cfg.ProjectType) + if notInstalled && cfg.ProjectType == agentfs.ProjectTypePythonUV { + // The launch runs `uv run --no-sync`, so a missing package won't be + // installed on the way up — fail fast with the fix instead. + return fmt.Errorf("livekit-agents is not installed in the project environment; run `uv sync` and try again") + } + if version != "" { + // An unparseable version (e.g. a local "0.0.0.dev" tag) shouldn't block a run. + if ok, err := agentfs.IsVersionSatisfied(version, thinCLIMinVersion); err == nil && !ok { + return fmt.Errorf("livekit-agents version %s is too old, please upgrade to %s or newer", version, thinCLIMinVersion) + } + return nil + } + return agentfs.CheckSDKVersion(cfg.Dir, cfg.ProjectType, map[string]string{ + "python-min-sdk-version": thinCLIMinVersion, + "node-min-sdk-version": thinCLIMinVersion, + }) +} + // defaultEntrypoints returns candidate entrypoint paths (relative to the // project root or working directory) probed for a project type, in priority // order. Forward slashes are valid on all platforms. @@ -277,7 +341,7 @@ const thinCLIMinVersion = "1.6.0" // buildAgentCommand resolves the interpreter and argv for an agent subprocess, // branching on project type. Python uses the thin CLI: // ` -m livekit.agents SUBCOMMAND ENTRYPOINT FLAGS` -// (uv prefixes `run python`). Node runs the entrypoint directly: +// (uv prefixes `run --no-sync python`). Node runs the entrypoint directly: // `node [--experimental-strip-types] ENTRYPOINT SUBCOMMAND FLAGS`, // where the type-stripping flag lets a `.ts` entrypoint run without a build. func buildAgentCommand(cfg AgentStartConfig) (string, []string, error) { @@ -328,10 +392,7 @@ func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { if err := checkNodeSDKVersion(cfg); err != nil { return nil, err } - } else if err := agentfs.CheckSDKVersion(cfg.Dir, cfg.ProjectType, map[string]string{ - "python-min-sdk-version": thinCLIMinVersion, - "node-min-sdk-version": thinCLIMinVersion, - }); err != nil { + } else if err := checkPythonSDKVersion(cfg); err != nil { return nil, err } diff --git a/cmd/lk/testdata/echo-agent/pyproject.toml b/cmd/lk/testdata/echo-agent/pyproject.toml index a0f07ffa2..c94e0de0e 100644 --- a/cmd/lk/testdata/echo-agent/pyproject.toml +++ b/cmd/lk/testdata/echo-agent/pyproject.toml @@ -1,5 +1,5 @@ -# uv project marker: [tool.uv] lets the daemon's `uv run python` auto-sync -# these deps -- no separate install step. +# uv project marker. The daemon launches via `uv run --no-sync`, so deps must +# be installed up front (CI runs `uv sync`; see session-e2e.yaml). [project] name = "lk-session-e2e-echo-agent" version = "0" diff --git a/pkg/agentfs/sdk_version_check.go b/pkg/agentfs/sdk_version_check.go index ce8e59e2f..a699cc2b6 100644 --- a/pkg/agentfs/sdk_version_check.go +++ b/pkg/agentfs/sdk_version_check.go @@ -582,8 +582,10 @@ func checkUvLock(filePath, minVersion string) VersionCheckResult { return VersionCheckResult{Error: err} } - // Look for livekit-agents in the lock file - pattern := regexp.MustCompile(`(?m)^\s*livekit-agents\s*=\s*"([^"]+)"`) + // Look for the [[package]] block with livekit-agents. uv.lock shares + // poetry.lock's TOML layout (name then version on separate lines), so the + // resolved version lives in the block, not on a `livekit-agents = "..."` line. + pattern := regexp.MustCompile(`(?s)\[\[package\]\]\s*\nname\s*=\s*"livekit-agents"\s*\nversion\s*=\s*"([^"]+)"`) matches := pattern.FindStringSubmatch(string(content)) if matches != nil { version := matches[1] diff --git a/pkg/agentfs/sdk_version_check_test.go b/pkg/agentfs/sdk_version_check_test.go index 2049d2c9e..d758614a7 100644 --- a/pkg/agentfs/sdk_version_check_test.go +++ b/pkg/agentfs/sdk_version_check_test.go @@ -138,7 +138,10 @@ version = "1.5.0"`, name: "Python uv.lock with valid version", projectType: ProjectTypePythonUV, setupFiles: map[string]string{ - "uv.lock": `livekit-agents = "1.5.0"`, + "uv.lock": `[[package]] +name = "livekit-agents" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" }`, }, expectError: false, }, @@ -200,6 +203,41 @@ version = "1.5.0"`, } } +// TestCheckSDKVersion_UvLockPreferredOverPyprojectFloor reproduces the case where +// pyproject.toml declares a loose lower bound (e.g. >=1.0) but uv.lock pins a +// resolved version that satisfies the minimum. The resolved lock version must win, +// otherwise the loose floor is misread as the installed version and wrongly rejected. +func TestCheckSDKVersion_UvLockPreferredOverPyprojectFloor(t *testing.T) { + tempDir, err := os.MkdirTemp("", "sdk-version-uvlock-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + settingsMap := map[string]string{ + "python-min-sdk-version": "1.6.0", + "node-min-sdk-version": "1.6.0", + } + + files := map[string]string{ + "pyproject.toml": `[project] +dependencies = ["livekit-agents>=1.0"]`, + "uv.lock": `[[package]] +name = "livekit-agents" +version = "1.6.7" +source = { registry = "https://pypi.org/simple" }`, + } + for filename, content := range files { + if err := os.WriteFile(filepath.Join(tempDir, filename), []byte(content), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } + } + + if err := CheckSDKVersion(tempDir, ProjectTypePythonUV, settingsMap); err != nil { + t.Errorf("Expected no error (uv.lock resolves 1.6.7 >= 1.6.0), got: %v", err) + } +} + func TestIsVersionSatisfied(t *testing.T) { tests := []struct { version string