Skip to content

Commit 94123b8

Browse files
committed
test(cli): add e2e test for the agent session lifecycle
Adds an opt-in end-to-end test that drives the real `lk agent session` start/say/end flow against a minimal one-file echo agent (testdata/echo-agent), asserting the model echoes a token back and that the detached daemon exits afterward. The fixture is a uv project so the daemon's `uv run python` auto-syncs deps; its __main__ dispatches console mode to the TCP console directly since cli.run_app() doesn't expose --connect-addr on released agents. Includes a GitHub Actions workflow that runs the test on Linux and Windows, triggered by workflow_dispatch and pushes to any repo branch. Gated behind LIVEKIT_API_KEY so it skips without credentials.
1 parent 7514534 commit 94123b8

5 files changed

Lines changed: 260 additions & 0 deletions

File tree

.github/workflows/session-e2e.yaml

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Session E2E
2+
3+
# Drives the real `lk agent session` start/say/end lifecycle against the minimal
4+
# one-file echo agent in cmd/lk/testdata/echo-agent, on Linux and Windows. This
5+
# exercises the detached daemon, the readiness handshake, the console IPC
6+
# transport, and the model round-trip end to end -- runtime behavior that
7+
# `go test` alone never covers.
8+
#
9+
# Runs on manual dispatch and on pushes to any repo branch (forks can't trigger
10+
# `push`, so secrets are only exposed to trusted collaborators). It needs live
11+
# LiveKit credentials -- set these repo secrets first: LIVEKIT_API_KEY,
12+
# LIVEKIT_API_SECRET, LIVEKIT_URL. The echo agent drives its LLM through LiveKit
13+
# Inference, so no other provider keys are needed.
14+
#
15+
# The echo agent depends on plain PyPI livekit-agents (synced by `uv sync` from
16+
# its pyproject.toml). Note: on current releases/main, `cli.run_app()` routes
17+
# `console` through the legacy click CLI, which has no --connect-addr (that
18+
# lives behind `python -m livekit.agents`). The fixture's __main__ dispatches
19+
# console mode to the TCP console directly to bridge the daemon's
20+
# `python <entry> console --connect-addr` launch.
21+
#
22+
# Node is intentionally not in the matrix yet: this branch's session daemon only
23+
# supports Python agents (`detectProject` rejects non-Python), and Node console
24+
# support depends on the brian/agent-session-node-support CLI line (#868/#878)
25+
# plus agents-js #1804. Add a node arm once those land.
26+
27+
on:
28+
workflow_dispatch:
29+
push:
30+
branches: ['**']
31+
32+
concurrency:
33+
group: session-e2e-${{ github.ref }}
34+
cancel-in-progress: true
35+
36+
jobs:
37+
e2e:
38+
strategy:
39+
fail-fast: false
40+
matrix:
41+
os: [ubuntu-latest, windows-latest]
42+
43+
runs-on: ${{ matrix.os }}
44+
name: python on ${{ matrix.os }}
45+
46+
permissions:
47+
contents: read
48+
49+
steps:
50+
- name: Checkout livekit-cli
51+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
52+
53+
- name: Set up Go
54+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
55+
with:
56+
go-version-file: go.mod
57+
cache: true
58+
59+
- name: Set up Python
60+
uses: actions/setup-python@v5
61+
with:
62+
python-version: "3.12"
63+
64+
- name: Set up uv
65+
uses: astral-sh/setup-uv@v5
66+
with:
67+
enable-cache: true
68+
69+
- name: Sync echo agent deps
70+
working-directory: cmd/lk/testdata/echo-agent
71+
run: uv sync
72+
73+
- name: Run session e2e
74+
env:
75+
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
76+
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
77+
LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }}
78+
run: go test ./cmd/lk -run TestSessionE2E -count=1 -v -timeout 600s

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,13 @@ dist/
2222

2323
.DS_Store
2424
/lk
25+
26+
# local secrets copied for e2e testing
27+
.env
28+
29+
# python venvs created for e2e agent fixtures
30+
.venv/
31+
cmd/lk/testdata/**/.venv
32+
33+
# uv lockfiles for test agent fixtures (resolved fresh in CI)
34+
cmd/lk/testdata/**/uv.lock

cmd/lk/session_e2e_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2025 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"context"
19+
"net"
20+
"os"
21+
"os/exec"
22+
"path/filepath"
23+
"runtime"
24+
"strings"
25+
"testing"
26+
"time"
27+
28+
"github.com/stretchr/testify/require"
29+
)
30+
31+
// TestSessionE2E drives the real `lk agent session` lifecycle end to end:
32+
// build the binary, `start` the detached daemon, `say` to make the model echo
33+
// a token (asserting the CLI→daemon→agent→LLM round-trip), `end`, then confirm
34+
// the daemon exited (nothing answers on the port).
35+
//
36+
// Opt-in: needs a prepared agent venv + live creds, so it skips unless
37+
// LIVEKIT_API_KEY is set. Defaults to testdata/echo-agent; override with LK_SESSION_E2E_AGENT.
38+
func TestSessionE2E(t *testing.T) {
39+
if os.Getenv("LIVEKIT_API_KEY") == "" {
40+
t.Skip("set LIVEKIT_API_KEY (and prepare the agent venv) to run the session e2e test")
41+
}
42+
entrypoint := os.Getenv("LK_SESSION_E2E_AGENT")
43+
if entrypoint == "" {
44+
entrypoint = filepath.Join("testdata", "echo-agent", "agent.py")
45+
}
46+
entrypoint, err := filepath.Abs(entrypoint)
47+
require.NoError(t, err)
48+
require.FileExists(t, entrypoint, "agent entrypoint not found (set LK_SESSION_E2E_AGENT to override)")
49+
50+
// Dedicated port so the test can't collide with a real session on 8775.
51+
port := "18775"
52+
if p := os.Getenv("LK_SESSION_E2E_PORT"); p != "" {
53+
port = p
54+
}
55+
56+
bin := buildLK(t)
57+
58+
run := func(timeout time.Duration, args ...string) (string, error) {
59+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
60+
defer cancel()
61+
cmd := exec.CommandContext(ctx, bin, args...)
62+
cmd.Env = os.Environ()
63+
out, err := cmd.CombinedOutput()
64+
return string(out), err
65+
}
66+
67+
// Best-effort teardown so a mid-run failure doesn't leave the daemon alive.
68+
t.Cleanup(func() {
69+
_, _ = run(15*time.Second, "agent", "session", "end", "--port", port)
70+
})
71+
72+
// start: launches the detached daemon and returns once the agent is ready.
73+
startOut, err := run(90*time.Second, "agent", "session", "start", "--port", port, entrypoint)
74+
require.NoError(t, err, "session start failed:\n%s", startOut)
75+
require.Contains(t, startOut, "Session started.", "start did not report readiness:\n%s", startOut)
76+
77+
// say: the token appears once in the echoed prompt and again in the reply, so
78+
// >=2 occurrences proves the agent answered, not just the local echo.
79+
token := "PINEAPPLE7351"
80+
sayOut, err := run(90*time.Second, "agent", "session", "say", "--port", port,
81+
"Repeat this token back to me exactly and nothing else: "+token)
82+
require.NoError(t, err, "session say failed:\n%s", sayOut)
83+
require.GreaterOrEqualf(t, strings.Count(sayOut, token), 2,
84+
"agent did not echo the token back; say output:\n%s", sayOut)
85+
86+
endOut, err := run(30*time.Second, "agent", "session", "end", "--port", port)
87+
require.NoError(t, err, "session end failed:\n%s", endOut)
88+
require.Contains(t, endOut, "Session ended.", "end did not confirm shutdown:\n%s", endOut)
89+
90+
// The detached daemon should now be gone: nothing should answer on the port.
91+
require.Eventually(t, func() bool {
92+
conn, derr := net.DialTimeout("tcp", "127.0.0.1:"+port, 200*time.Millisecond)
93+
if derr != nil {
94+
return true // refused → daemon exited
95+
}
96+
conn.Close()
97+
return false
98+
}, 10*time.Second, 200*time.Millisecond, "session daemon still listening on port %s after end", port)
99+
}
100+
101+
// buildLK compiles the lk binary into a temp dir and returns its path.
102+
func buildLK(t *testing.T) string {
103+
t.Helper()
104+
bin := filepath.Join(t.TempDir(), "lk")
105+
if runtime.GOOS == "windows" {
106+
bin += ".exe"
107+
}
108+
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
109+
defer cancel()
110+
build := exec.CommandContext(ctx, "go", "build", "-o", bin, ".")
111+
out, err := build.CombinedOutput()
112+
require.NoErrorf(t, err, "failed to build lk binary:\n%s", out)
113+
return bin
114+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Minimal one-file echo agent for the `lk agent session` e2e test.
2+
3+
Driven in text mode, so an LLM is the only component needed. Echoes the user's
4+
text verbatim, which the test asserts on.
5+
"""
6+
7+
from dotenv import load_dotenv
8+
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli, inference
9+
10+
load_dotenv()
11+
12+
server = AgentServer()
13+
14+
15+
@server.rtc_session()
16+
async def entrypoint(ctx: JobContext):
17+
session = AgentSession(llm=inference.LLM(model="openai/gpt-4o-mini"))
18+
await session.start(
19+
agent=Agent(
20+
instructions=(
21+
"You are an echo bot. Reply with exactly the text the user "
22+
"sends, verbatim, and nothing else."
23+
),
24+
),
25+
room=ctx.room,
26+
)
27+
# No TTS, so disable audio output or the turn crashes in tts_node.
28+
session.output.set_audio_enabled(False)
29+
await ctx.connect()
30+
31+
32+
if __name__ == "__main__":
33+
import sys
34+
35+
argv = sys.argv[1:]
36+
if argv and argv[0] == "console":
37+
# The daemon launches `python agent.py console --connect-addr <addr>`, but
38+
# cli.run_app() sends `console` to the legacy click CLI (no --connect-addr),
39+
# so dispatch to the TCP console directly.
40+
from livekit.agents.cli.cli import _run_tcp_console
41+
42+
_run_tcp_console(
43+
server=server,
44+
connect_addr=argv[argv.index("--connect-addr") + 1],
45+
record="--record" in argv,
46+
)
47+
else:
48+
cli.run_app(server)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# uv project marker: [tool.uv] lets the daemon's `uv run python` auto-sync
2+
# these deps -- no separate install step.
3+
[project]
4+
name = "lk-session-e2e-echo-agent"
5+
version = "0"
6+
requires-python = ">=3.12"
7+
dependencies = ["livekit-agents", "python-dotenv"]
8+
9+
[tool.uv]
10+
package = false

0 commit comments

Comments
 (0)