Skip to content

Commit 1ebe5db

Browse files
authored
test(cli): e2e test for the agent session lifecycle (#882)
* 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. * fix(cli): use a readiness file instead of an inherited pipe fd The session daemon spawn passed the readiness pipe to the detached child via cmd.ExtraFiles (fd 3), but os/exec's ExtraFiles is unsupported on Windows, so daemon.Start() failed with "fork/exec ...: not supported by windows" and the session never started there. Replace the inherited fd with a temp readiness file: the daemon writes its status atomically (write + rename) and `start` polls it until it sees a status, the daemon process exits, or a timeout slightly past the daemon's own agent-connect deadline. Works identically on Linux and Windows.
1 parent 7514534 commit 1ebe5db

7 files changed

Lines changed: 327 additions & 35 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.go

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
package main
1616

1717
import (
18-
"bufio"
1918
"context"
2019
"encoding/binary"
2120
"encoding/json"
@@ -26,6 +25,7 @@ import (
2625
"os/exec"
2726
"strconv"
2827
"strings"
28+
"time"
2929

3030
"github.com/urfave/cli/v3"
3131
)
@@ -38,11 +38,11 @@ const (
3838
sessionHost = "127.0.0.1"
3939
defaultSessionPort = 8775
4040

41-
envSessionPort = "LK_SESSION_PORT" // fixed port
42-
envSessionDir = "LK_SESSION_DIR" // resolved project dir
43-
envSessionEntry = "LK_SESSION_ENTRY" // resolved entrypoint (project-relative)
44-
envSessionPType = "LK_SESSION_PTYPE" // agentfs.ProjectType string
45-
envSessionReadyFD = "LK_SESSION_READY_FD"
41+
envSessionPort = "LK_SESSION_PORT" // fixed port
42+
envSessionDir = "LK_SESSION_DIR" // resolved project dir
43+
envSessionEntry = "LK_SESSION_ENTRY" // resolved entrypoint (project-relative)
44+
envSessionPType = "LK_SESSION_PTYPE" // agentfs.ProjectType string
45+
envSessionReadyFile = "LK_SESSION_READY_FILE" // path the daemon writes its status to
4646

4747
// sessionDaemonSubcommand is the hidden entrypoint `start` re-execs into.
4848
sessionDaemonSubcommand = "daemon"
@@ -92,7 +92,7 @@ var agentSessionCommand = &cli.Command{
9292
Name: sessionDaemonSubcommand,
9393
Hidden: true,
9494
Action: func(ctx context.Context, cmd *cli.Command) error {
95-
if os.Getenv(envSessionReadyFD) == "" {
95+
if os.Getenv(envSessionReadyFile) == "" {
9696
return fmt.Errorf("`session daemon` is an internal entrypoint; run `lk agent session start <entrypoint>` instead")
9797
}
9898
runSessionDaemon()
@@ -118,19 +118,20 @@ func runSessionStart(ctx context.Context, cmd *cli.Command) error {
118118
return fmt.Errorf("could not resolve own binary: %w", err)
119119
}
120120

121-
// Pipe the daemon uses to report readiness (or a startup error) before we
122-
// return. This avoids racing a TCP probe against the agent's own connect.
123-
readyR, readyW, err := os.Pipe()
121+
// Readiness file the daemon writes once it is up (or failed) before we
122+
// return, so we don't race a TCP probe against the agent's own connect.
123+
readyFile, err := os.CreateTemp("", "lk-session-ready-*.txt")
124124
if err != nil {
125125
return err
126126
}
127-
defer readyR.Close()
127+
readyPath := readyFile.Name()
128+
readyFile.Close()
129+
defer os.Remove(readyPath)
128130

129131
// The daemon is detached, so its own stdout/stderr (panics etc.) go to a
130132
// temp log rather than the user's terminal.
131133
logFile, err := os.CreateTemp("", "lk-session-daemon-*.log")
132134
if err != nil {
133-
readyW.Close()
134135
return err
135136
}
136137

@@ -140,24 +141,19 @@ func runSessionStart(ctx context.Context, cmd *cli.Command) error {
140141
envSessionDir+"="+projectDir,
141142
envSessionEntry+"="+entrypoint,
142143
envSessionPType+"="+string(projectType),
143-
envSessionReadyFD+"=3", // ExtraFiles[0] is fd 3 in the child
144+
envSessionReadyFile+"="+readyPath,
144145
)
145-
daemon.ExtraFiles = []*os.File{readyW}
146146
daemon.Stdout = logFile
147147
daemon.Stderr = logFile
148148
setDetachedProcAttr(daemon)
149149

150150
if err := daemon.Start(); err != nil {
151-
readyW.Close()
152151
logFile.Close()
153152
return fmt.Errorf("failed to start session daemon: %w", err)
154153
}
155-
// Close our copy of the write end so the read below sees EOF if the daemon dies.
156-
readyW.Close()
157154
logFile.Close()
158155

159-
status, _ := bufio.NewReader(readyR).ReadString('\n')
160-
status = strings.TrimSpace(status)
156+
status := awaitDaemonReady(daemon, readyPath)
161157
switch {
162158
case status == "ready":
163159
fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir)
@@ -170,6 +166,43 @@ func runSessionStart(ctx context.Context, cmd *cli.Command) error {
170166
}
171167
}
172168

169+
// awaitDaemonReady waits for the detached daemon to report via the readiness
170+
// file, returning its status line ("ready" or "error: ...") or "" if the
171+
// daemon exits or times out without reporting.
172+
func awaitDaemonReady(daemon *exec.Cmd, readyPath string) string {
173+
exited := make(chan struct{})
174+
go func() { _ = daemon.Wait(); close(exited) }()
175+
176+
// Slightly longer than the daemon's own 60s agent-connect timeout so its
177+
// "error: timed out ..." status reaches us before we give up.
178+
timeout := time.After(65 * time.Second)
179+
for {
180+
if status, ok := readReadyStatus(readyPath); ok {
181+
return status
182+
}
183+
select {
184+
case <-exited:
185+
if status, ok := readReadyStatus(readyPath); ok {
186+
return status
187+
}
188+
return ""
189+
case <-timeout:
190+
return ""
191+
case <-time.After(50 * time.Millisecond):
192+
}
193+
}
194+
}
195+
196+
// readReadyStatus returns the daemon's status line once the readiness file has
197+
// content (written atomically via rename), or ok=false while it is still empty.
198+
func readReadyStatus(path string) (string, bool) {
199+
data, err := os.ReadFile(path)
200+
if err != nil || len(data) == 0 {
201+
return "", false
202+
}
203+
return strings.TrimSpace(string(data)), true
204+
}
205+
173206
func runSessionSay(ctx context.Context, cmd *cli.Command) error {
174207
text := strings.TrimSpace(strings.Join(cmd.Args().Slice(), " "))
175208
if text == "" {

cmd/lk/session_daemon.go

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -100,26 +100,25 @@ func runSessionDaemon() {
100100
agentProc.Kill()
101101
}
102102

103-
// readyWriter returns the inherited pipe `lk agent session start` reads to learn the
104-
// daemon became ready (or failed). Nil if not launched via start.
105-
func readyWriter() *os.File {
106-
fdStr := os.Getenv(envSessionReadyFD)
107-
if fdStr == "" {
108-
return nil
109-
}
110-
fd, err := strconv.Atoi(fdStr)
111-
if err != nil {
112-
return nil
113-
}
114-
return os.NewFile(uintptr(fd), "ready")
103+
// readyWriter returns the path of the readiness file `lk agent session start`
104+
// polls to learn the daemon became ready (or failed). Empty if not launched
105+
// via start.
106+
func readyWriter() string {
107+
return os.Getenv(envSessionReadyFile)
115108
}
116109

117-
func signalReady(f *os.File, msg string) {
118-
if f == nil {
110+
// signalReady atomically writes the daemon's status to the readiness file the
111+
// parent `start` is polling. The write-then-rename keeps the parent from
112+
// reading a partial line.
113+
func signalReady(path, msg string) {
114+
if path == "" {
115+
return
116+
}
117+
tmp := path + ".tmp"
118+
if err := os.WriteFile(tmp, []byte(msg+"\n"), 0o600); err != nil {
119119
return
120120
}
121-
fmt.Fprintln(f, msg)
122-
f.Close()
121+
_ = os.Rename(tmp, path)
123122
}
124123

125124
type sessionDaemon struct {

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+
}

0 commit comments

Comments
 (0)