1515package main
1616
1717import (
18+ "bytes"
1819 "context"
1920 "net"
2021 "os"
@@ -28,10 +29,13 @@ import (
2829 "github.com/stretchr/testify/require"
2930)
3031
32+ const sessionE2ETimeout = 5 * time .Second
33+
3134// TestSessionE2E drives the real `lk agent session` lifecycle end to end:
3235// 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).
36+ // a token (asserting the CLI→daemon→agent→LLM round-trip), `end`, confirm a
37+ // second `say` cannot still reach the agent, then confirm the daemon exited
38+ // (nothing answers on the port).
3539//
3640// Opt-in: needs a prepared agent venv + live creds, so it skips unless
3741// LIVEKIT_API_KEY is set. Defaults to testdata/echo-agent; override with LK_SESSION_E2E_AGENT.
@@ -55,47 +59,87 @@ func TestSessionE2E(t *testing.T) {
5559
5660 bin := buildLK (t )
5761
58- run := func (timeout time.Duration , args ... string ) (string , error ) {
62+ type runResult struct {
63+ stdout string
64+ stderr string
65+ exitCode int
66+ }
67+
68+ runCapture := func (timeout time.Duration , args ... string ) (runResult , error ) {
5969 ctx , cancel := context .WithTimeout (context .Background (), timeout )
6070 defer cancel ()
6171 cmd := exec .CommandContext (ctx , bin , args ... )
6272 cmd .Env = os .Environ ()
63- out , err := cmd .CombinedOutput ()
64- return string (out ), err
73+ var stdout , stderr bytes.Buffer
74+ cmd .Stdout = & stdout
75+ cmd .Stderr = & stderr
76+ err := cmd .Run ()
77+ require .NotNil (t , cmd .ProcessState , "command did not start: %v" , err )
78+
79+ return runResult {
80+ stdout : stdout .String (),
81+ stderr : stderr .String (),
82+ exitCode : cmd .ProcessState .ExitCode (),
83+ }, err
84+ }
85+
86+ run := func (timeout time.Duration , args ... string ) (string , error ) {
87+ res , err := runCapture (timeout , args ... )
88+ return res .stdout + res .stderr , err
89+ }
90+
91+ portIsFree := 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
6598 }
6699
67100 // Best-effort teardown so a mid-run failure doesn't leave the daemon alive.
68101 t .Cleanup (func () {
69- _ , _ = run (15 * time . Second , "agent" , "session" , "end" , "--port" , port )
102+ _ , _ = run (sessionE2ETimeout , "agent" , "session" , "end" , "--port" , port )
70103 })
71104
72105 // 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 )
106+ startOut , err := run (15 * time .Second , "agent" , "session" , "start" , "--port" , port , entrypoint )
74107 require .NoError (t , err , "session start failed:\n %s" , startOut )
75108 require .Contains (t , startOut , "Session started." , "start did not report readiness:\n %s" , startOut )
76109
77110 // say: the token appears once in the echoed prompt and again in the reply, so
78111 // >=2 occurrences proves the agent answered, not just the local echo.
79112 token := "PINEAPPLE7351"
80- sayOut , err := run (90 * time . Second , "agent" , "session" , "say" , "--port" , port ,
113+ sayOut , err := run (sessionE2ETimeout , "agent" , "session" , "say" , "--port" , port ,
81114 "Repeat this token back to me exactly and nothing else: " + token )
82115 require .NoError (t , err , "session say failed:\n %s" , sayOut )
83116 require .GreaterOrEqualf (t , strings .Count (sayOut , token ), 2 ,
84117 "agent did not echo the token back; say output:\n %s" , sayOut )
85118
86- endOut , err := run (30 * time . Second , "agent" , "session" , "end" , "--port" , port )
119+ endOut , err := run (sessionE2ETimeout , "agent" , "session" , "end" , "--port" , port )
87120 require .NoError (t , err , "session end failed:\n %s" , endOut )
88121 require .Contains (t , endOut , "Session ended." , "end did not confirm shutdown:\n %s" , endOut )
89122
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 )
123+ require .Eventually (t , portIsFree , sessionE2ETimeout , 200 * time .Millisecond ,
124+ "session daemon still listening on port %s after end" , port )
125+
126+ // After a successful match and shutdown, another say must not reach a live
127+ // agent or reproduce the token.
128+ afterEndSay , err := runCapture (sessionE2ETimeout , "agent" , "session" , "say" , "--port" , port ,
129+ "Repeat this token back to me exactly and nothing else: " + token )
130+ afterEndSayOut := afterEndSay .stdout + afterEndSay .stderr
131+ require .Error (t , err , "session say unexpectedly succeeded after end:\n %s" , afterEndSayOut )
132+ require .Equal (t , 1 , afterEndSay .exitCode ,
133+ "session say after end exited with wrong code; stdout:\n %s\n stderr:\n %s" ,
134+ afterEndSay .stdout , afterEndSay .stderr )
135+ require .Truef (t , strings .HasPrefix (afterEndSayOut , "no session running" ),
136+ "session say after end output did not start with no session running; stdout:\n %s\n stderr:\n %s" ,
137+ afterEndSay .stdout , afterEndSay .stderr )
138+ require .NotContains (t , afterEndSayOut , token ,
139+ "session say after end unexpectedly contained the matched token; stdout:\n %s\n stderr:\n %s" ,
140+ afterEndSay .stdout , afterEndSay .stderr )
141+
142+ require .True (t , portIsFree (), "session daemon started listening again on port %s after failed say" , port )
99143}
100144
101145// buildLK returns the path to the lk binary under test. If LK_SESSION_E2E_BIN
0 commit comments