Files
tutti/packages/agent/daemon/runtime/process_transport_cassette_test.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

2721 lines
78 KiB
Go

package agentruntime
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
sessionreplay "github.com/tutti-os/tutti/packages/agent/session-replay"
)
func codexReplayDescriptorForCassetteTest(
t *testing.T,
) sessionreplay.ProviderReplayDescriptor {
t.Helper()
descriptor, ok := sessionreplay.FindProviderReplayByProvider(ProviderCodex)
if !ok {
t.Fatal("Codex replay descriptor is missing")
}
return descriptor
}
type cassetteTestTransport struct {
connection *cassetteTestConnection
}
type cassetteTestQueueTransport struct {
mu sync.Mutex
connections []*cassetteTestConnection
}
func (t *cassetteTestQueueTransport) Start(context.Context, ProcessSpec) (ProcessConnection, error) {
t.mu.Lock()
defer t.mu.Unlock()
if len(t.connections) == 0 {
return nil, errors.New("no test connection")
}
connection := t.connections[0]
t.connections = t.connections[1:]
return connection, nil
}
func (t cassetteTestTransport) Start(context.Context, ProcessSpec) (ProcessConnection, error) {
return t.connection, nil
}
type cassetteFinalizingTestTransport struct {
cassetteTestTransport
finalized bool
}
func (t *cassetteFinalizingTestTransport) Finalize() error {
t.finalized = true
return nil
}
type cassetteReplayControlTestTransport struct {
cassetteTestTransport
paused bool
fastForward bool
}
func (t *cassetteReplayControlTestTransport) PauseReplayPlayback() error {
t.paused = true
return nil
}
func (t *cassetteReplayControlTestTransport) ResumeReplayPlayback() error {
t.paused = false
return nil
}
func (t *cassetteReplayControlTestTransport) SetReplayPlaybackFastForward(enabled bool) error {
t.fastForward = enabled
return nil
}
type cassetteTestConnection struct {
mu sync.Mutex
sent [][]byte
received []ProcessFrame
closed bool
}
func (c *cassetteTestConnection) Send(data []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
c.sent = append(c.sent, append([]byte(nil), data...))
return nil
}
func (c *cassetteTestConnection) Recv() (ProcessFrame, error) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.received) == 0 {
return ProcessFrame{}, io.EOF
}
frame := c.received[0]
c.received = c.received[1:]
return frame, nil
}
func (c *cassetteTestConnection) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
return nil
}
func TestRecordingAndReplayProcessTransportPreserveChunks(t *testing.T) {
exitCode := 0
baseConnection := &cassetteTestConnection{
received: []ProcessFrame{
{Stdout: []byte("{\"jsonrpc\":\"2.0\",")},
{Stdout: []byte("\"id\":1,\"result\":{}}\n")},
{Stderr: []byte("diagnostic\n")},
{ExitCode: &exitCode},
},
}
directory := t.TempDir()
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: baseConnection},
directory,
)
if err != nil {
t.Fatal(err)
}
spec := ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-recorded",
CWD: "/workspace/recorded",
}
connection, err := recording.Start(context.Background(), spec)
if err != nil {
t.Fatal(err)
}
outbound := []byte("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n")
if err := connection.Send(outbound); err != nil {
t.Fatal(err)
}
var recordedFrames []ProcessFrame
for range baseConnection.received {
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
recordedFrames = append(recordedFrames, frame)
}
if err := connection.Close(); err != nil {
t.Fatal(err)
}
if err := recording.Finalize(); err != nil {
t.Fatal(err)
}
manifestRaw, err := os.ReadFile(filepath.Join(directory, processCassetteManifestName))
if err != nil {
t.Fatal(err)
}
var manifest ProcessCassetteManifest
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
t.Fatal(err)
}
if manifest.FrameCount != 5 ||
manifest.PayloadBytes == 0 ||
manifest.StoredBytes == 0 ||
manifest.MaxFrameBytes == 0 {
t.Fatalf("manifest size accounting = %#v", manifest)
}
if manifest.FramesByKind["outbound"].FrameCount != 1 ||
manifest.FramesByKind["stdout"].FrameCount != 2 ||
manifest.FramesByKind["stderr"].FrameCount != 1 ||
manifest.FramesByKind["exit"].FrameCount != 1 {
t.Fatalf("manifest kind accounting = %#v", manifest.FramesByKind)
}
if manifest.Limits.MaxFrameBytes != processCassetteMaxPayloadBytes ||
manifest.Limits.MaxStoredBytes != processCassetteMaxStoredBytes {
t.Fatalf("manifest limits = %#v", manifest.Limits)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
replayConnection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-recorded",
CWD: "/workspace/replayed",
})
if err != nil {
t.Fatal(err)
}
if err := replayConnection.Send(outbound); err != nil {
t.Fatal(err)
}
for index, want := range recordedFrames {
got, err := replayConnection.Recv()
if err != nil {
t.Fatalf("receive frame %d: %v", index, err)
}
assertProcessFrameEqual(t, got, want)
}
if _, err := replayConnection.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("Recv() error = %v, want EOF", err)
}
if err := replayConnection.Close(); err != nil {
t.Fatal(err)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestProcessCassetteWriterRejectsOversizedFrameBeforeWriting(t *testing.T) {
writer, err := newProcessCassetteWriter(t.TempDir())
if err != nil {
t.Fatal(err)
}
writer.maxPayloadBytes = 3
writer.maxStoredBytes = 1024
err = writer.append(processCassetteChunk{
ConnectionID: "connection-1",
ChunkSeq: 1,
Kind: "stdout",
Data: "dG9vbGFyZ2U=",
})
if !errors.Is(err, ErrProcessCassetteSizeLimit) {
t.Fatalf("append() error = %v, want size limit", err)
}
info, statErr := writer.chunks.Stat()
if statErr != nil {
t.Fatal(statErr)
}
if info.Size() != 0 || writer.manifest.StoredBytes != 0 || writer.manifest.FrameCount != 0 {
t.Fatalf(
"oversized frame was partially recorded: size=%d manifest=%#v",
info.Size(),
writer.manifest,
)
}
if err := writer.abort(); err != nil {
t.Fatal(err)
}
}
func TestProcessCassetteWriterRejectsTotalStoredSizeBeforeWriting(t *testing.T) {
writer, err := newProcessCassetteWriter(t.TempDir())
if err != nil {
t.Fatal(err)
}
writer.maxPayloadBytes = 1024
writer.maxStoredBytes = 1
err = writer.append(processCassetteChunk{
ConnectionID: "connection-1",
ChunkSeq: 1,
Kind: "exit",
ExitCode: intPointer(0),
})
if !errors.Is(err, ErrProcessCassetteSizeLimit) {
t.Fatalf("append() error = %v, want size limit", err)
}
info, statErr := writer.chunks.Stat()
if statErr != nil {
t.Fatal(statErr)
}
if info.Size() != 0 || writer.manifest.StoredBytes != 0 {
t.Fatalf("oversized total was partially recorded: size=%d", info.Size())
}
if err := writer.abort(); err != nil {
t.Fatal(err)
}
}
func TestRecordingProcessTransportProjectsAccountEmailOnlyInPersistedCopy(t *testing.T) {
const privateEmail = "developer@example.com"
accountResponse := []byte(
`{"id":2,"result":{"account":{"type":"chatgpt","email":"` +
privateEmail +
`","planType":"pro"},"requiresOpenaiAuth":true}}` + "\n",
)
split := len(accountResponse) / 2
unrelated := []byte(
`{"method":"item/agentMessage/delta","params":{"text":"keep ` +
privateEmail +
` unchanged"}}` + "\n",
)
baseConnection := &cassetteTestConnection{
received: []ProcessFrame{
{Stdout: append([]byte(nil), accountResponse[:split]...)},
{Stdout: append([]byte(nil), accountResponse[split:]...)},
{Stdout: unrelated},
},
}
directory := t.TempDir()
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: baseConnection},
directory,
)
if err != nil {
t.Fatal(err)
}
connection, err := recording.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
accountRequest := []byte(
`{"id":2,"method":"account/read","params":{"refreshToken":false}}` + "\n",
)
if err := connection.Send(accountRequest); err != nil {
t.Fatal(err)
}
unrelatedOutbound := []byte(
`{"id":3,"method":"turn/start","params":{"prompt":"keep ` +
privateEmail +
` and /Users/developer/private unchanged"}}` + "\n",
)
if err := connection.Send(unrelatedOutbound); err != nil {
t.Fatal(err)
}
var liveStdout []byte
for range 3 {
frame, recvErr := connection.Recv()
if recvErr != nil {
t.Fatal(recvErr)
}
liveStdout = append(liveStdout, frame.Stdout...)
}
if !bytes.Contains(liveStdout, []byte(privateEmail)) {
t.Fatalf("live Adapter copy was projected: %s", liveStdout)
}
if err := connection.Close(); err != nil {
t.Fatal(err)
}
if err := recording.Finalize(); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(directory, processCassetteChunksName))
if err != nil {
t.Fatal(err)
}
var persistedStdout []byte
var persistedOutbound []byte
for _, line := range bytes.Split(bytes.TrimSpace(raw), []byte{'\n'}) {
var chunk processCassetteChunk
if err := json.Unmarshal(line, &chunk); err != nil {
t.Fatal(err)
}
data, err := base64.StdEncoding.DecodeString(chunk.Data)
if err != nil {
t.Fatal(err)
}
switch chunk.Kind {
case "stdout":
persistedStdout = append(persistedStdout, data...)
case "outbound":
persistedOutbound = append(persistedOutbound, data...)
}
}
if bytes.Contains(
persistedStdout,
[]byte(`"email":"`+privateEmail+`"`),
) {
t.Fatalf("unexpected persisted private account email: %s", persistedStdout)
}
if !bytes.Contains(persistedStdout, []byte(portableProcessCassetteAccountEmail)) {
t.Fatalf("persisted account response was not projected: %s", persistedStdout)
}
if !bytes.Contains(persistedStdout, []byte("keep "+privateEmail+" unchanged")) {
t.Fatalf("unrelated Provider text was rewritten: %s", persistedStdout)
}
if !bytes.Contains(
persistedOutbound,
[]byte("keep "+privateEmail+" and /Users/developer/private unchanged"),
) {
t.Fatalf("outbound prompt text was rewritten: %s", persistedOutbound)
}
}
func TestRecordingProcessTransportRejectsCredentialBearingProtocolMethod(t *testing.T) {
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
t.TempDir(),
)
if err != nil {
t.Fatal(err)
}
connection, err := recording.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
err = connection.Send([]byte(
`{"id":1,"method":"account/login/start","params":{"apiKey":"secret"}}` + "\n",
))
if err == nil || !strings.Contains(err.Error(), "credential-bearing method") {
t.Fatalf("Send() error = %v, want credential-bearing method rejection", err)
}
}
func TestRecordingAndReplayProcessTransportMapStructuredHomePath(t *testing.T) {
directory := t.TempDir()
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
directory,
)
if err != nil {
t.Fatal(err)
}
connection, err := recording.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
CWD: "/Users/developer/workspace",
Env: []string{"HOME=/Users/developer"},
})
if err != nil {
t.Fatal(err)
}
err = connection.Send([]byte(
`{"id":1,"method":"turn/start","params":{"path":"/Users/developer/private"}}` + "\n",
))
if err != nil {
t.Fatal(err)
}
if err := connection.Close(); err != nil {
t.Fatal(err)
}
if err := recording.Finalize(); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(directory, processCassetteChunksName))
if err != nil {
t.Fatal(err)
}
var persisted []byte
for _, line := range bytes.Split(bytes.TrimSpace(raw), []byte{'\n'}) {
var chunk processCassetteChunk
if err := json.Unmarshal(line, &chunk); err != nil {
t.Fatal(err)
}
data, err := base64.StdEncoding.DecodeString(chunk.Data)
if err != nil {
t.Fatal(err)
}
persisted = append(persisted, data...)
}
if bytes.Contains(persisted, []byte("/Users/developer")) ||
!bytes.Contains(persisted, []byte(portableProcessCassetteHomeToken)) {
t.Fatalf("persisted cassette did not project HOME path: %s", persisted)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
replayed, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
CWD: "/Users/replay/workspace",
Env: []string{"HOME=/Users/replay"},
})
if err != nil {
t.Fatal(err)
}
if err := replayed.Send([]byte(
`{"id":1,"method":"turn/start","params":{"path":"/Users/replay/private"}}` + "\n",
)); err != nil {
t.Fatalf("Send() error = %v, want replay HOME mapping", err)
}
}
func TestRecordingAndReplayProcessTransportMapGeneratedImagePathToCodexHome(
t *testing.T,
) {
directory := t.TempDir()
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
directory,
)
if err != nil {
t.Fatal(err)
}
connection, err := recording.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
CWD: "/Users/developer/workspace",
Env: []string{
"HOME=/Users/developer",
"CODEX_HOME=/recording/codex-home",
},
})
if err != nil {
t.Fatal(err)
}
generatedPath := "/recording/codex-home/generated_images/call-1/image.png"
if err := connection.Send([]byte(
`{"id":1,"method":"turn/start","params":{"savedPath":"` +
generatedPath + `"}}` + "\n",
)); err != nil {
t.Fatal(err)
}
if err := connection.Close(); err != nil {
t.Fatal(err)
}
if err := recording.Finalize(); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(directory, processCassetteChunksName))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte(generatedPath)) {
t.Fatalf("persisted cassette retained generated image path: %s", raw)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
replayed, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
CWD: "/Users/replay/workspace",
Env: []string{
"HOME=/Users/replay",
"CODEX_HOME=/replay/codex-home",
},
})
if err != nil {
t.Fatal(err)
}
if err := replayed.Send([]byte(
`{"id":1,"method":"turn/start","params":{"savedPath":` +
`"/replay/codex-home/generated_images/call-1/image.png"}}` + "\n",
)); err != nil {
t.Fatalf("Send() error = %v, want replay CODEX_HOME mapping", err)
}
}
func TestRecordingProcessTransportRejectsUnknownAbsolutePath(t *testing.T) {
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
t.TempDir(),
)
if err != nil {
t.Fatal(err)
}
connection, err := recording.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
CWD: "/Users/developer/workspace",
Env: []string{"HOME=/Users/developer"},
})
if err != nil {
t.Fatal(err)
}
err = connection.Send([]byte(
`{"id":1,"method":"turn/start","params":{"path":"/Volumes/private"}}` + "\n",
))
if err == nil || !strings.Contains(err.Error(), "non-portable path at $.params.path") {
t.Fatalf("Send() error = %v, want non-portable path rejection", err)
}
if strings.Contains(err.Error(), "/Volumes/private") {
t.Fatalf("Send() error exposed the rejected path: %v", err)
}
}
func intPointer(value int) *int {
return &value
}
func TestReplayProcessTransportFailsClosedOnOutboundMismatch(t *testing.T) {
directory := recordCassetteForTest(t, []byte("recorded\n"))
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
connection, err := replay.Start(context.Background(), ProcessSpec{Provider: ProviderCodex})
if err != nil {
t.Fatal(err)
}
err = connection.Send([]byte("different\n"))
if err == nil || !strings.Contains(err.Error(), "outbound mismatch") {
t.Fatalf("Send() error = %v, want outbound mismatch", err)
}
if err := replay.VerifyComplete(); err == nil || !strings.Contains(err.Error(), "outbound mismatch") {
t.Fatalf("VerifyComplete() error = %v, want original outbound mismatch", err)
}
}
func TestReplayProcessTransportMapsRecordedCWDInStrictJSONMatch(t *testing.T) {
directory := t.TempDir()
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
directory,
)
if err != nil {
t.Fatal(err)
}
connection, err := recording.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
CWD: "/workspace/recorded-session",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte(
"{\"id\":1,\"method\":\"thread/start\",\"params\":{\"cwd\":\"/workspace/recorded-session/subdirectory\",\"unknown\":\"strict\"}}\n",
)); err != nil {
t.Fatal(err)
}
if err := connection.Close(); err != nil {
t.Fatal(err)
}
if err := recording.Finalize(); err != nil {
t.Fatal(err)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
replayConnection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
CWD: "/workspace/replay-session",
})
if err != nil {
t.Fatal(err)
}
if err := replayConnection.Send([]byte(
"{\"params\":{\"unknown\":\"strict\",\"cwd\":\"/workspace/replay-session/subdirectory\"},\"method\":\"thread/start\",\"id\":1}\n",
)); err != nil {
t.Fatalf("Send() error = %v, want mapped semantic match", err)
}
}
func TestReplayProcessTransportMatchesJSONRPCRequestSemanticsAndMapsResponseID(t *testing.T) {
expected := []byte(
`{"id":18,"method":"turn/start","params":{"approvalPolicy":"on-request","input":[{"text":"请回答:1+2=?","type":"text"}],"model":"gpt-5.3-codex-spark","threadId":"thread-1"}}` + "\n",
)
actual := []byte(
`{"id":9,"method":"turn/start","params":{"threadId":"thread-1","input":[{"text":"请回答:1+2=?","type":"text"}],"approvalPolicy":"on-request","model":"gpt-5.3-codex-spark"}}` + "\n",
)
descriptor := codexReplayDescriptorForCassetteTest(t)
responseIDs, _, matches := processCassetteJSONMatch(
descriptor,
expected,
actual,
"",
"",
"",
nil,
)
if !matches {
t.Fatal("semantically identical JSON-RPC request did not match")
}
if got := responseIDs["18"]; got != json.Number("9") {
t.Fatalf("mapped response id = %#v, want 9", got)
}
changedPrompt := bytes.Replace(actual, []byte("1+2"), []byte("3+1"), 1)
if _, _, matches := processCassetteJSONMatch(
descriptor,
expected,
changedPrompt,
"",
"",
"",
nil,
); matches {
t.Fatal("JSON-RPC request with changed prompt matched")
}
}
func TestReplayProcessTransportIgnoresVolatileInitializeClientInfo(t *testing.T) {
expected := []byte(
`{"id":1,"method":"initialize","params":{"clientInfo":{"name":"codex-cli","title":"Codex","version":"0.146.0"},"capabilities":{"experimentalApi":true}}}` + "\n",
)
actual := []byte(
`{"id":7,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"codex-cli","title":"TSH","version":"0.146.1"}}}` + "\n",
)
descriptor := codexReplayDescriptorForCassetteTest(t)
responseIDs, _, matches := processCassetteJSONMatch(
descriptor,
expected,
actual,
"",
"",
"",
nil,
)
if !matches {
t.Fatal("initialize clientInfo version/title drift did not match")
}
if got := responseIDs["1"]; got != json.Number("7") {
t.Fatalf("mapped response id = %#v, want 7", got)
}
changedName := bytes.Replace(actual, []byte(`"name":"codex-cli"`), []byte(`"name":"other-cli"`), 1)
if _, _, matches := processCassetteJSONMatch(
descriptor,
expected,
changedName,
"",
"",
"",
nil,
); matches {
t.Fatal("initialize clientInfo.name mismatch matched")
}
}
func TestClaudeSidecarRecordingAndReplayProjectsEnvironmentAndGeneratedIdentities(t *testing.T) {
directory := t.TempDir()
base := &cassetteTestConnection{received: []ProcessFrame{
{Stdout: []byte(`{"version":10,"id":"request-recorded","type":"ok","payload":{"providerSessionId":"provider-recorded"}}` + "\n")},
{Stdout: []byte(`{"version":10,"id":"exec-recorded","type":"ok"}` + "\n")},
}}
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: base},
directory,
)
if err != nil {
t.Fatal(err)
}
recordedSpec := ProcessSpec{
Provider: ProviderClaudeCode,
AgentSessionID: "session-recorded",
CWD: "/Users/recorded/project",
Env: []string{
"ANTHROPIC_API_KEY=secret-recorded",
"CLAUDE_CONFIG_DIR=/Users/recorded/.claude",
"IS_SANDBOX=1",
},
}
connection, err := recording.Start(context.Background(), recordedSpec)
if err != nil {
t.Fatal(err)
}
recordedStart := []byte(`{"version":10,"id":"request-recorded","type":"start","payload":{"agentSessionId":"session-recorded","providerSessionId":"provider-recorded","cwd":"/Users/recorded/project","env":{"ANTHROPIC_API_KEY":"secret-recorded","CLAUDE_CONFIG_DIR":"/Users/recorded/.claude","IS_SANDBOX":"1"}}}` + "\n")
if err := connection.Send(recordedStart); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
recordedExec := []byte(`{"version":10,"id":"exec-recorded","type":"exec","payload":{"agentSessionId":"session-recorded","turnId":"turn-recorded","promptCorrelationId":"submit-recorded","prompt":"REPLAY_EXACT"}}` + "\n")
if err := connection.Send(recordedExec); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
if err := connection.Close(); err != nil {
t.Fatal(err)
}
if err := recording.Finalize(); err != nil {
t.Fatal(err)
}
frames, err := os.ReadFile(filepath.Join(directory, processCassetteChunksName))
if err != nil {
t.Fatal(err)
}
var projectedPayload bytes.Buffer
for _, line := range bytes.Split(bytes.TrimSpace(frames), []byte("\n")) {
var chunk processCassetteChunk
if err := json.Unmarshal(line, &chunk); err != nil {
t.Fatal(err)
}
if chunk.Data == "" {
continue
}
decoded, err := base64.StdEncoding.DecodeString(chunk.Data)
if err != nil {
t.Fatal(err)
}
projectedPayload.Write(decoded)
}
for _, forbidden := range []string{
"secret-recorded",
"/Users/recorded/.claude",
"ANTHROPIC_API_KEY",
"CLAUDE_CONFIG_DIR",
"IS_SANDBOX",
} {
if bytes.Contains(projectedPayload.Bytes(), []byte(forbidden)) {
t.Fatalf("projected Claude tape retained %q", forbidden)
}
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
replayed, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderClaudeCode,
AgentSessionID: "session-replayed",
CWD: "/isolated/project",
})
if err != nil {
t.Fatal(err)
}
replayStart := []byte(`{"payload":{"env":{"ANTHROPIC_API_KEY":"different-secret","CLAUDE_CONFIG_DIR":"/isolated/.claude","IS_SANDBOX":"1"},"cwd":"/isolated/project","providerSessionId":"provider-replayed","agentSessionId":"session-replayed"},"type":"start","id":"request-replayed","version":10}` + "\n")
if err := replayed.Send(replayStart); err != nil {
t.Fatal(err)
}
frame, err := replayed.Recv()
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(frame.Stdout, []byte(`"id":"request-replayed"`)) ||
!bytes.Contains(frame.Stdout, []byte(`"providerSessionId":"provider-recorded"`)) {
t.Fatalf("mapped Claude start response = %s", frame.Stdout)
}
replayExec := []byte(`{"version":10,"id":"exec-replayed","type":"exec","payload":{"agentSessionId":"session-replayed","turnId":"turn-replayed","promptCorrelationId":"submit-replayed","prompt":"REPLAY_EXACT"}}` + "\n")
if err := replayed.Send(replayExec); err != nil {
t.Fatal(err)
}
frame, err = replayed.Recv()
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(frame.Stdout, []byte(`"id":"exec-replayed"`)) {
t.Fatalf("mapped Claude exec response = %s", frame.Stdout)
}
if err := replayed.Close(); err != nil {
t.Fatal(err)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
mismatchReplay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
mismatch, err := mismatchReplay.Start(context.Background(), ProcessSpec{
Provider: ProviderClaudeCode,
AgentSessionID: "session-replayed",
CWD: "/isolated/project",
})
if err != nil {
t.Fatal(err)
}
if err := mismatch.Send(replayStart); err != nil {
t.Fatal(err)
}
if _, err := mismatch.Recv(); err != nil {
t.Fatal(err)
}
changedPrompt := bytes.Replace(replayExec, []byte("REPLAY_EXACT"), []byte("WRONG"), 1)
if err := mismatch.Send(changedPrompt); err == nil ||
!strings.Contains(err.Error(), "outbound mismatch") {
t.Fatalf("changed Claude prompt error = %v, want outbound mismatch", err)
}
}
func TestRecordingAndReplayProcessTransportProjectsPlanDecisionClientUserMessageID(t *testing.T) {
directory := t.TempDir()
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
directory,
)
if err != nil {
t.Fatal(err)
}
connection, err := recording.Start(
context.Background(),
ProcessSpec{Provider: ProviderCodex},
)
if err != nil {
t.Fatal(err)
}
recorded := []byte(
`{"id":9,"method":"turn/start","params":{"input":[{"text":"Implement the plan.","type":"text"}],"clientUserMessageId":"plan-decision:recorded-operation"}}` + "\n",
)
if err := connection.Send(recorded); err != nil {
t.Fatal(err)
}
if err := connection.Close(); err != nil {
t.Fatal(err)
}
if err := recording.Finalize(); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(directory, processCassetteChunksName))
if err != nil {
t.Fatal(err)
}
var frame processCassetteChunk
if err := json.Unmarshal(bytes.TrimSpace(raw), &frame); err != nil {
t.Fatal(err)
}
projected, err := base64.StdEncoding.DecodeString(frame.Data)
if err != nil {
t.Fatal(err)
}
var projectedMessage map[string]any
if err := json.Unmarshal(bytes.TrimSpace(projected), &projectedMessage); err != nil {
t.Fatal(err)
}
params, _ := projectedMessage["params"].(map[string]any)
descriptor := codexReplayDescriptorForCassetteTest(t)
wantPortableID := descriptor.Tape.GeneratedRequestFields[0].PortableValue
if got := payloadString(params, "clientUserMessageId"); got != wantPortableID {
t.Fatalf("persisted clientUserMessageId = %q, want portable marker", got)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
replayed, err := replay.Start(
context.Background(),
ProcessSpec{Provider: ProviderCodex},
)
if err != nil {
t.Fatal(err)
}
actual := []byte(
`{"id":21,"method":"turn/start","params":{"input":[{"text":"Implement the plan.","type":"text"}],"clientUserMessageId":"plan-decision:replay-operation"}}` + "\n",
)
if err := replayed.Send(actual); err != nil {
t.Fatalf("Send() error = %v, want projected runtime-operation match", err)
}
}
func TestReplayProcessTransportRemapsOrdinaryClientUserMessageIDConsistently(t *testing.T) {
expected := []byte(
`{"id":9,"method":"turn/start","params":{"clientUserMessageId":"user-submit-1"}}` + "\n",
)
actual := []byte(
`{"id":10,"method":"turn/start","params":{"clientUserMessageId":"user-submit-2"}}` + "\n",
)
descriptor := codexReplayDescriptorForCassetteTest(t)
_, learned, matches := processCassetteJSONMatch(
descriptor,
expected,
actual,
"",
"",
"",
nil,
)
if !matches {
t.Fatal("ordinary clientUserMessageId runtime identity did not match")
}
if got := learned["user-submit-1"]; got != "user-submit-2" {
t.Fatalf("learned clientUserMessageId = %q, want user-submit-2", got)
}
followupExpected := []byte(
`{"id":11,"method":"turn/start","params":{"clientUserMessageId":"user-submit-1"}}` + "\n",
)
followupActual := []byte(
`{"id":12,"method":"turn/start","params":{"clientUserMessageId":"user-submit-2"}}` + "\n",
)
if _, _, matches := processCassetteJSONMatch(
descriptor,
followupExpected,
followupActual,
"",
"",
"",
learned,
); !matches {
t.Fatal("consistent clientUserMessageId mapping did not match")
}
conflictingActual := bytes.Replace(
followupActual,
[]byte("user-submit-2"),
[]byte("user-submit-3"),
1,
)
if _, _, matches := processCassetteJSONMatch(
descriptor,
followupExpected,
conflictingActual,
"",
"",
"",
learned,
); matches {
t.Fatal("conflicting clientUserMessageId mapping matched")
}
mappedInbound := mapProcessCassetteFrameJSON(
[]byte(`{"method":"turn/completed","params":{"clientUserMessageId":"user-submit-1"}}`+"\n"),
"",
"",
"",
descriptor,
learned,
)
var inboundMessage map[string]any
if err := json.Unmarshal(bytes.TrimSpace(mappedInbound), &inboundMessage); err != nil {
t.Fatal(err)
}
inboundParams, _ := inboundMessage["params"].(map[string]any)
if got := payloadString(inboundParams, "clientUserMessageId"); got != "user-submit-2" {
t.Fatalf("mapped inbound clientUserMessageId = %q, want user-submit-2", got)
}
}
func TestReplayProcessTransportMapsProtocolCWDWhenProcessCWDIsEmpty(t *testing.T) {
directory := t.TempDir()
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
directory,
)
if err != nil {
t.Fatal(err)
}
connection, err := recording.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, ProtocolCWD: "/recorded/project",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte(
`{"id":1,"method":"session/load","params":{"cwd":"/recorded/project"}}` + "\n",
)); err != nil {
t.Fatal(err)
}
if err := connection.Close(); err != nil {
t.Fatal(err)
}
if err := recording.Finalize(); err != nil {
t.Fatal(err)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
replayed, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, ProtocolCWD: "/",
})
if err != nil {
t.Fatal(err)
}
if err := replayed.Send([]byte(
`{"id":1,"method":"session/load","params":{"cwd":"/"}}` + "\n",
)); err != nil {
t.Fatal(err)
}
}
func TestRecordingProcessTransportRejectsProviderWithoutReplayAdapter(t *testing.T) {
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
t.TempDir(),
)
if err != nil {
t.Fatal(err)
}
_, err = recording.Start(context.Background(), ProcessSpec{
Provider: ProviderCursor,
})
if err == nil || !strings.Contains(err.Error(), "no replay adapter") {
t.Fatalf("Start() error = %v, want missing replay adapter", err)
}
}
func TestReplayProcessConnectionRecvContextCancelsWhileWaitingForOutbound(t *testing.T) {
directory := recordCassetteForTest(t, []byte("recorded\n"))
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
connection, err := replay.Start(
context.Background(),
ProcessSpec{Provider: ProviderCodex},
)
if err != nil {
t.Fatal(err)
}
contextual, ok := connection.(ContextProcessConnection)
if !ok {
t.Fatal("replay connection does not implement ContextProcessConnection")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := contextual.RecvContext(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("RecvContext() error = %v, want context canceled", err)
}
if err := connection.Send([]byte("recorded\n")); err != nil {
t.Fatalf("Send() after canceled receive = %v", err)
}
}
func TestReplayProcessTransportWaitsForRecordedInboundBeforeNextOutbound(t *testing.T) {
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
{Kind: "outbound", Data: base64.StdEncoding.EncodeToString([]byte("first"))},
{Kind: "stdout", Data: base64.StdEncoding.EncodeToString([]byte("notification"))},
{Kind: "outbound", Data: base64.StdEncoding.EncodeToString([]byte("second"))},
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("first")); err != nil {
t.Fatal(err)
}
sendResult := make(chan error, 1)
go func() {
sendResult <- connection.Send([]byte("second"))
}()
select {
case err := <-sendResult:
t.Fatalf("second Send() completed before inbound receive: %v", err)
case <-time.After(20 * time.Millisecond):
}
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if string(frame.Stdout) != "notification" {
t.Fatalf("stdout = %q, want notification", frame.Stdout)
}
select {
case err := <-sendResult:
if err != nil {
t.Fatalf("second Send() error = %v", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("second Send() did not continue after inbound receive")
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestReplayProcessTransportSkipsAbsentObservationalRPC(t *testing.T) {
request := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "outbound",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
stdout := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
request(`{"id":1,"method":"initialize"}`),
stdout(`{"id":1,"result":{}}`),
request(`{"id":2,"method":"thread/read","params":{"includeTurns":true}}`),
stdout(`{"id":2,"result":{"thread":{}}}`),
stdout(`{"method":"turn/started","params":{"turn":{"id":"turn-1"}}}`),
request(`{"id":30,"method":"turn/start"}`),
stdout(`{"id":30,"result":{"turn":{"id":"turn-1"}}}`),
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("{\"id\":1,\"method\":\"initialize\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(frame.Stdout), "turn/started") ||
strings.Contains(string(frame.Stdout), `"id":2`) {
t.Fatalf("stdout = %q, want notification without skipped response", frame.Stdout)
}
if err := connection.Send([]byte("{\"id\":3,\"method\":\"turn/start\"}\n")); err != nil {
t.Fatal(err)
}
response, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(response.Stdout), `"id":3`) ||
strings.Contains(string(response.Stdout), `"id":30`) {
t.Fatalf("stdout = %q, want replay request id", response.Stdout)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestReplayProcessTransportSkipsOneAbsentObservationalProbeRunWithoutPerProbeDelay(
t *testing.T,
) {
request := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "outbound",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
stdout := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
chunks := []processCassetteChunk{
request(`{"id":1,"method":"initialize"}`),
stdout(`{"id":1,"result":{}}`),
}
for id := 2; id <= 5; id++ {
chunks = append(
chunks,
request(fmt.Sprintf(`{"id":%d,"method":"thread/read"}`, id)),
stdout(fmt.Sprintf(`{"id":%d,"result":{"thread":{}}}`, id)),
)
}
chunks = append(
chunks,
stdout(`{"method":"turn/started","params":{"turn":{"id":"turn-1"}}}`),
)
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: chunks,
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("{\"id\":1,\"method\":\"initialize\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
startedAt := time.Now()
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if elapsed := time.Since(startedAt); elapsed >= 150*time.Millisecond {
t.Fatalf("absent probe run took %s, want one grace interval", elapsed)
}
if !strings.Contains(string(frame.Stdout), "turn/started") {
t.Fatalf("stdout = %q, want turn/started", frame.Stdout)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestReplayProcessTransportLetsAnInboundResponseOvertakeAnObservationalRPC(t *testing.T) {
request := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "outbound",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
stdout := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
request(`{"id":1,"method":"initialize"}`),
stdout(`{"id":1,"result":{}}`),
request(`{"id":2,"method":"thread/read","params":{"includeTurns":true}}`),
stdout(`{"id":2,"result":{"thread":{}}}`),
request(`{"id":0,"result":{"decision":"accept"}}`),
stdout(`{"method":"serverRequest/resolved","params":{"requestId":0}}`),
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("{\"id\":1,\"method\":\"initialize\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
sent := make(chan error, 1)
go func() {
sent <- connection.Send([]byte(
"{\"id\":0,\"result\":{\"decision\":\"accept\"}}\n",
))
}()
resolved, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(resolved.Stdout), "serverRequest/resolved") ||
strings.Contains(string(resolved.Stdout), `"id":2`) {
t.Fatalf("stdout = %q, want resolved notification without probe response", resolved.Stdout)
}
if err := <-sent; err != nil {
t.Fatal(err)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestReplayProcessTransportDoesNotRequireUnlaunchedProbeConnection(t *testing.T) {
jsonChunk := func(kind string, value string) processCassetteChunk {
return processCassetteChunk{
Kind: kind,
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
exitCode := 0
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{
{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
jsonChunk("outbound", `{"id":1,"method":"initialize"}`),
jsonChunk("stdout", `{"id":1,"result":{}}`),
},
},
{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
jsonChunk("outbound", `{"id":1,"method":"initialize"}`),
jsonChunk("stdout", `{"id":1,"result":{}}`),
jsonChunk("outbound", `{"method":"initialized"}`),
jsonChunk("outbound", `{"id":2,"method":"thread/read"}`),
jsonChunk("stdout", `{"id":2,"result":{"thread":{}}}`),
{Kind: "exit", ExitCode: &exitCode},
},
},
})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("{\"id\":1,\"method\":\"initialize\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestReplayProcessTransportAbsorbsExtraOptionalProbeDuringInbound(t *testing.T) {
request := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "outbound",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
stdout := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
request(`{"id":1,"method":"initialize"}`),
stdout(`{"id":1,"result":{}}`),
stdout(`{"method":"turn/started","params":{"turn":{"id":"turn-1"}}}`),
request(`{"id":30,"method":"turn/start"}`),
stdout(`{"id":30,"result":{"turn":{"id":"turn-1"}}}`),
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("{\"id\":1,\"method\":\"initialize\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
// Extra nickname-style probes arrive while the tape is delivering inbound.
if err := connection.Send([]byte(
"{\"id\":9,\"method\":\"thread/read\",\"params\":{\"threadId\":\"child-1\"}}\n",
)); err != nil {
t.Fatal(err)
}
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(frame.Stdout), `"id":9`) ||
!strings.Contains(string(frame.Stdout), `"result"`) {
t.Fatalf("synthetic probe stdout = %q, want id=9 result", frame.Stdout)
}
frame, err = connection.Recv()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(frame.Stdout), "turn/started") {
t.Fatalf("stdout = %q, want turn/started", frame.Stdout)
}
if err := connection.Send([]byte("{\"id\":3,\"method\":\"turn/start\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
if state := replay.ReplayPlaybackState(); !state.Drained {
t.Fatalf("playback state = %#v, want drained", state)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestReplayProcessTransportSynthesizesOptionalGoalGetDuringInbound(t *testing.T) {
request := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "outbound",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
stdout := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
request(`{"id":1,"method":"initialize"}`),
stdout(`{"id":1,"result":{}}`),
stdout(`{"method":"turn/started","params":{"turn":{"id":"turn-1"}}}`),
request(`{"id":30,"method":"turn/start"}`),
stdout(`{"id":30,"result":{"turn":{"id":"turn-1"}}}`),
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("{\"id\":1,\"method\":\"initialize\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
// Activation metadata refresh may probe goal/get before the provider cursor
// reaches later taped traffic. Absorbing must still unblock NoHandler waits.
if err := connection.Send([]byte(
`{"id":42,"method":"thread/goal/get","params":{"threadId":"thread-1"}}` + "\n",
)); err != nil {
t.Fatal(err)
}
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(frame.Stdout), `"id":42`) ||
!strings.Contains(string(frame.Stdout), `"result"`) {
t.Fatalf("synthetic goal/get stdout = %q, want id=42 result", frame.Stdout)
}
frame, err = connection.Recv()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(frame.Stdout), "turn/started") {
t.Fatalf("stdout = %q, want turn/started", frame.Stdout)
}
if err := connection.Send([]byte("{\"id\":3,\"method\":\"turn/start\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
if state := replay.ReplayPlaybackState(); !state.Drained {
t.Fatalf("playback state = %#v, want drained", state)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestReplayProcessTransportSynthesizesOptionalProbeWhileInboundPaused(t *testing.T) {
request := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "outbound",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
stdout := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
request(`{"id":1,"method":"initialize"}`),
stdout(`{"id":1,"result":{}}`),
stdout(`{"method":"turn/started","params":{"turn":{"id":"turn-1"}}}`),
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("{\"id\":1,\"method\":\"initialize\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
if err := replay.PauseReplayPlayback(); err != nil {
t.Fatal(err)
}
type recvResult struct {
frame ProcessFrame
err error
}
result := make(chan recvResult, 1)
go func() {
frame, recvErr := connection.Recv()
result <- recvResult{frame: frame, err: recvErr}
}()
time.Sleep(20 * time.Millisecond)
if err := connection.Send([]byte(
`{"id":42,"method":"thread/goal/get","params":{"threadId":"thread-1"}}` + "\n",
)); err != nil {
t.Fatal(err)
}
select {
case got := <-result:
if got.err != nil {
t.Fatal(got.err)
}
if !strings.Contains(string(got.frame.Stdout), `"id":42`) ||
!strings.Contains(string(got.frame.Stdout), `"result"`) {
t.Fatalf(
"synthetic goal/get while paused stdout = %q, want id=42 result",
got.frame.Stdout,
)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("Recv() did not drain synthetic optional probe while inbound paused")
}
if err := replay.ResumeReplayPlayback(); err != nil {
t.Fatal(err)
}
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(frame.Stdout), "turn/started") {
t.Fatalf("stdout = %q, want turn/started after resume", frame.Stdout)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestReplayProcessTransportCompleteUnitYieldsForSyntheticWhileHeld(t *testing.T) {
request := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "outbound",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
stdout := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
request(`{"id":1,"method":"initialize"}`),
stdout(`{"id":1,"result":{}}`),
stdout(`{"method":"turn/started","params":{"turn":{"id":"turn-1"}}}`),
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
replayConn, ok := connection.(*replayProcessConnection)
if !ok {
t.Fatal("connection is not replayProcessConnection")
}
if err := connection.Send([]byte("{\"id\":1,\"method\":\"initialize\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(frame.Stdout), "turn/started") {
t.Fatalf("stdout = %q, want turn/started", frame.Stdout)
}
if err := replay.SetReplayProviderCursor([]sessionreplay.ProviderUnitPosition{{
ConnectionID: frame.ConnectionID,
ChunkSeq: frame.ChunkSeq,
UnitIndex: 1,
}}); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
done <- replayConn.CompleteProviderInputUnit(context.Background(), ProviderInputUnit{
Position: sessionreplay.ProviderUnitPosition{
ConnectionID: frame.ConnectionID,
ChunkSeq: frame.ChunkSeq,
UnitIndex: 1,
},
Kind: sessionreplay.ProviderInputUnitProtocolMessage,
})
}()
time.Sleep(20 * time.Millisecond)
if err := connection.Send([]byte(
`{"id":42,"method":"thread/goal/get","params":{"threadId":"thread-1"}}` + "\n",
)); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if !errors.Is(err, errReplaySyntheticPending) {
t.Fatalf("CompleteProviderInputUnit error = %v, want synthetic pending", err)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("CompleteProviderInputUnit did not yield for synthetic optional probe")
}
synthetic, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(synthetic.Stdout), `"id":42`) {
t.Fatalf("synthetic stdout = %q, want id=42", synthetic.Stdout)
}
replay.ClearReplayProviderCursor()
}
func TestReplayProcessTransportAbsorbsExtraOptionalProbeAfterCassetteEnd(t *testing.T) {
request := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "outbound",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
stdout := func(value string) processCassetteChunk {
return processCassetteChunk{
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte(value + "\n")),
}
}
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
request(`{"id":1,"method":"initialize"}`),
stdout(`{"id":1,"result":{}}`),
request(`{"id":2,"method":"thread/read","params":{"threadId":"child-1"}}`),
stdout(`{"id":2,"result":{"thread":{"nickname":"helper"}}}`),
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("{\"id\":1,\"method\":\"initialize\"}\n")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte(
"{\"id\":2,\"method\":\"thread/read\",\"params\":{\"threadId\":\"child-1\"}}\n",
)); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
for _, id := range []int{12, 13, 14} {
if err := connection.Send([]byte(fmt.Sprintf(
`{"id":%d,"method":"thread/read","params":{"threadId":"child-1"}}`+"\n",
id,
))); err != nil {
t.Fatalf("extra thread/read id=%d error = %v", id, err)
}
}
if state := replay.ReplayPlaybackState(); !state.Drained {
t.Fatalf("playback state = %#v, want drained after absorbed probes", state)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestSessionRecordingProcessTransportCompletesWithoutClosingProvider(t *testing.T) {
baseConnection := &cassetteTestConnection{
received: []ProcessFrame{
{Stdout: []byte("recorded\n")},
{Stdout: []byte("not-recorded\n")},
},
}
transport, err := NewSessionRecordingProcessTransport(
cassetteTestTransport{connection: baseConnection},
)
if err != nil {
t.Fatal(err)
}
directory := t.TempDir()
if err := transport.Arm("session-1", "recording-1", directory); err != nil {
t.Fatal(err)
}
connection, err := transport.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("initialize\n")); err != nil {
t.Fatal(err)
}
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if string(frame.Stdout) != "recorded\n" {
t.Fatalf("first stdout = %q", frame.Stdout)
}
if err := transport.Complete("session-1"); err != nil {
t.Fatal(err)
}
if baseConnection.closed {
t.Fatal("completing recording closed the provider connection")
}
if err := connection.Send([]byte("after-complete\n")); err != nil {
t.Fatal(err)
}
frame, err = connection.Recv()
if err != nil {
t.Fatal(err)
}
if string(frame.Stdout) != "not-recorded\n" {
t.Fatalf("second stdout = %q", frame.Stdout)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
replayConnection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
checkpoint, ok := replayConnection.(ProcessCassetteCheckpointConnection)
if !ok ||
checkpoint.ProcessCassetteCaptureOrigin() != ProcessCassetteCaptureOriginProcessStart {
t.Fatalf(
"capture origin = %q, want %q",
processCassetteCaptureOrigin(replayConnection),
ProcessCassetteCaptureOriginProcessStart,
)
}
if err := replayConnection.Send([]byte("initialize\n")); err != nil {
t.Fatal(err)
}
replayed, err := replayConnection.Recv()
if err != nil {
t.Fatal(err)
}
if string(replayed.Stdout) != "recorded\n" {
t.Fatalf("replayed stdout = %q", replayed.Stdout)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := replayConnection.(ContextProcessConnection).RecvContext(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("RecvContext() after capture end = %v, want context canceled", err)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestSessionRecordingProcessTransportKeepsInputUnitRecordingGeneration(
t *testing.T,
) {
baseConnection := &cassetteTestConnection{
received: []ProcessFrame{{Stdout: []byte("{\"method\":\"turn/started\"}\n")}},
}
transport, err := NewSessionRecordingProcessTransport(
cassetteTestTransport{connection: baseConnection},
)
if err != nil {
t.Fatal(err)
}
var received ProviderInputUnit
transport.SetProviderInputUnitSink(func(unit ProviderInputUnit) error {
received = unit
return nil
})
if err := transport.Arm(
"session-1",
"recording-1",
t.TempDir(),
); err != nil {
t.Fatal(err)
}
connection, err := transport.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if frame.RecordingID != "recording-1" {
t.Fatalf("frame recording ID = %q", frame.RecordingID)
}
if err := transport.Cancel("session-1"); err != nil {
t.Fatal(err)
}
if err := transport.Arm(
"session-1",
"recording-2",
t.TempDir(),
); err != nil {
t.Fatal(err)
}
unit := providerInputUnit(
frame,
1,
sessionreplay.ProviderInputUnitProtocolMessage,
)
if err := connection.(ProviderInputUnitCompletion).
CompleteProviderInputUnit(context.Background(), unit); err != nil {
t.Fatal(err)
}
if received.RecordingID != "recording-1" {
t.Fatalf("delayed unit recording ID = %q", received.RecordingID)
}
if err := transport.Cancel("session-1"); err != nil {
t.Fatal(err)
}
}
func TestSessionGraphProcessTapeMatchesParallelAndNestedSessionsByIdentity(t *testing.T) {
base := &cassetteTestQueueTransport{
connections: []*cassetteTestConnection{{}, {}, {}, {}},
}
transport, err := NewSessionRecordingProcessTransport(base)
if err != nil {
t.Fatal(err)
}
directory := t.TempDir()
if err := transport.Arm("root-session", "recording-1", directory); err != nil {
t.Fatal(err)
}
root, err := transport.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "root-session",
RootAgentSessionID: "root-session",
})
if err != nil {
t.Fatal(err)
}
childA, err := transport.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "child-a",
RootAgentSessionID: "root-session",
})
if err != nil {
t.Fatal(err)
}
childB, err := transport.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "child-b",
RootAgentSessionID: "root-session",
})
if err != nil {
t.Fatal(err)
}
grandchild, err := transport.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "grandchild-of-a",
RootAgentSessionID: "root-session",
})
if err != nil {
t.Fatal(err)
}
if err := root.Send([]byte("root-outbound")); err != nil {
t.Fatal(err)
}
if err := childA.Send([]byte("child-a-outbound")); err != nil {
t.Fatal(err)
}
if err := childB.Send([]byte("child-b-outbound")); err != nil {
t.Fatal(err)
}
if err := grandchild.Send([]byte("grandchild-outbound")); err != nil {
t.Fatal(err)
}
if err := transport.Complete("root-session"); err != nil {
t.Fatal(err)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
// Start in the opposite global order. Session identity prevents cassettes
// from crossing when child launches race.
replayedGrandchild, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "grandchild-of-a",
RootAgentSessionID: "root-session",
})
if err != nil {
t.Fatal(err)
}
replayedChildB, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "child-b",
RootAgentSessionID: "root-session",
})
if err != nil {
t.Fatal(err)
}
replayedRoot, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "root-session",
RootAgentSessionID: "root-session",
})
if err != nil {
t.Fatal(err)
}
replayedChildA, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "child-a",
RootAgentSessionID: "root-session",
})
if err != nil {
t.Fatal(err)
}
if err := replayedGrandchild.Send([]byte("grandchild-outbound")); err != nil {
t.Fatal(err)
}
if err := replayedChildB.Send([]byte("child-b-outbound")); err != nil {
t.Fatal(err)
}
if err := replayedRoot.Send([]byte("root-outbound")); err != nil {
t.Fatal(err)
}
if err := replayedChildA.Send([]byte("child-a-outbound")); err != nil {
t.Fatal(err)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestSessionRecordingAttachesToExistingProviderConnection(t *testing.T) {
baseConnection := &cassetteTestConnection{}
transport, err := NewSessionRecordingProcessTransport(
cassetteTestTransport{connection: baseConnection},
)
if err != nil {
t.Fatal(err)
}
connection, err := transport.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "existing-session",
RootAgentSessionID: "existing-session",
})
if err != nil {
t.Fatal(err)
}
directory := t.TempDir()
if err := transport.Arm("existing-session", "recording-1", directory); err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("continued-turn")); err != nil {
t.Fatal(err)
}
if err := transport.Complete("existing-session"); err != nil {
t.Fatal(err)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
replayed, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "existing-session",
RootAgentSessionID: "existing-session",
})
if err != nil {
t.Fatal(err)
}
checkpoint, ok := replayed.(ProcessCassetteCheckpointConnection)
if !ok ||
checkpoint.ProcessCassetteCaptureOrigin() != ProcessCassetteCaptureOriginAttachedLiveConnection {
t.Fatalf(
"capture origin = %q, want %q",
processCassetteCaptureOrigin(replayed),
ProcessCassetteCaptureOriginAttachedLiveConnection,
)
}
if err := replayed.Send([]byte("continued-turn")); err != nil {
t.Fatal(err)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestSessionRecordingProcessTransportRejectsConcurrentArm(t *testing.T) {
transport, err := NewSessionRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
)
if err != nil {
t.Fatal(err)
}
if err := transport.Arm("session-1", "recording-1", t.TempDir()); err != nil {
t.Fatal(err)
}
if err := transport.Arm("session-2", "recording-2", t.TempDir()); !errors.Is(err, ErrSessionRecordingBusy) {
t.Fatalf("Arm() error = %v, want busy", err)
}
if err := transport.Cancel("session-1"); err != nil {
t.Fatal(err)
}
}
func TestSessionRecordingProcessTransportFinalizesWrappedTransport(t *testing.T) {
base := &cassetteFinalizingTestTransport{
cassetteTestTransport: cassetteTestTransport{
connection: &cassetteTestConnection{},
},
}
transport, err := NewSessionRecordingProcessTransport(base)
if err != nil {
t.Fatal(err)
}
if err := transport.Finalize(); err != nil {
t.Fatal(err)
}
if !base.finalized {
t.Fatal("wrapped transport was not finalized")
}
}
func TestSessionRecordingProcessTransportDelegatesReplayPlaybackControls(t *testing.T) {
base := &cassetteReplayControlTestTransport{
cassetteTestTransport: cassetteTestTransport{
connection: &cassetteTestConnection{},
},
}
transport, err := NewSessionRecordingProcessTransport(base)
if err != nil {
t.Fatal(err)
}
if err := transport.PauseReplayPlayback(); err != nil {
t.Fatal(err)
}
if !base.paused {
t.Fatal("PauseReplayPlayback() was not delegated")
}
if err := transport.SetReplayPlaybackFastForward(true); err != nil {
t.Fatal(err)
}
if !base.fastForward {
t.Fatal("SetReplayPlaybackFastForward() was not delegated")
}
if err := transport.ResumeReplayPlayback(); err != nil {
t.Fatal(err)
}
if base.paused {
t.Fatal("ResumeReplayPlayback() was not delegated")
}
}
func TestNilSessionRecordingProcessTransportIgnoresInputUnitSink(t *testing.T) {
var transport *SessionRecordingProcessTransport
transport.SetProviderInputUnitSink(func(ProviderInputUnit) error {
t.Fatal("nil recording transport invoked input unit sink")
return nil
})
}
func TestReplayProcessTransportRejectsIncompleteCassette(t *testing.T) {
directory := recordCassetteForTestWithoutFinalize(t)
_, err := NewReplayProcessTransport(directory)
if err == nil || !strings.Contains(err.Error(), "want complete") {
t.Fatalf("NewReplayProcessTransport() error = %v, want incomplete rejection", err)
}
}
func TestReplayProcessTransportRejectsMissingProjectionVersion(t *testing.T) {
directory := recordCassetteForTest(t, []byte("recorded\n"))
manifestPath := filepath.Join(directory, processCassetteManifestName)
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
var manifest ProcessCassetteManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
manifest.ProjectionVersion = 0
raw, err = json.MarshalIndent(manifest, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(manifestPath, append(raw, '\n'), 0o600); err != nil {
t.Fatal(err)
}
_, err = NewReplayProcessTransport(directory)
if err == nil || !strings.Contains(err.Error(), "projection version 0") {
t.Fatalf("NewReplayProcessTransport() error = %v, want projection version rejection", err)
}
}
func TestReplayProcessTransportRejectsMissingCaptureOrigin(t *testing.T) {
directory := t.TempDir()
writer, err := newProcessCassetteWriter(directory)
if err != nil {
t.Fatal(err)
}
connectionID, err := writer.start(
ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
ProcessCassetteCaptureOriginProcessStart,
)
if err != nil {
t.Fatal(err)
}
if err := writer.append(processCassetteChunk{
ConnectionID: connectionID,
ChunkSeq: 1,
Kind: "outbound",
Data: base64.StdEncoding.EncodeToString([]byte("initialize")),
}); err != nil {
t.Fatal(err)
}
if err := writer.finishConnection(); err != nil {
t.Fatal(err)
}
if err := writer.finalize(); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(directory, processCassetteManifestName)
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
var manifest ProcessCassetteManifest
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
manifest.Connections[0].CaptureOrigin = ""
raw, err = json.MarshalIndent(manifest, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(manifestPath, append(raw, '\n'), 0o600); err != nil {
t.Fatal(err)
}
_, err = NewReplayProcessTransport(directory)
if err == nil || !strings.Contains(err.Error(), "capture origin") {
t.Fatalf("NewReplayProcessTransport() error = %v, want capture-origin rejection", err)
}
}
func TestReplayProcessTransportUsesElapsedTimeAtConfiguredSpeed(t *testing.T) {
directory := t.TempDir()
writer, err := newProcessCassetteWriter(directory)
if err != nil {
t.Fatal(err)
}
connectionID, err := writer.start(ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
}, ProcessCassetteCaptureOriginProcessStart)
if err != nil {
t.Fatal(err)
}
if err := writer.append(processCassetteChunk{
ConnectionID: connectionID,
ChunkSeq: 1,
ElapsedMS: 1_000,
Kind: "outbound",
Data: base64.StdEncoding.EncodeToString([]byte("start")),
}); err != nil {
t.Fatal(err)
}
if err := writer.append(processCassetteChunk{
ConnectionID: connectionID,
ChunkSeq: 2,
ElapsedMS: 1_120,
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte("ready")),
}); err != nil {
t.Fatal(err)
}
if err := writer.finishConnection(); err != nil {
t.Fatal(err)
}
if err := writer.finalize(); err != nil {
t.Fatal(err)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
if state := replay.ReplayPlaybackState(); state.Speed != 1 {
t.Fatalf("default playback speed = %v, want 1", state.Speed)
}
if err := replay.SetReplayPlaybackSpeed(4); err != nil {
t.Fatal(err)
}
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex,
AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("start")); err != nil {
t.Fatal(err)
}
started := time.Now()
frame, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
elapsed := time.Since(started)
if string(frame.Stdout) != "ready" {
t.Fatalf("stdout = %q, want ready", frame.Stdout)
}
if elapsed < 20*time.Millisecond {
t.Fatalf("elapsed = %v, want recorded timing at 4x", elapsed)
}
if elapsed > 250*time.Millisecond {
t.Fatalf("elapsed = %v, want approximately 30ms at 4x", elapsed)
}
if state := replay.ReplayPlaybackState(); !state.Drained {
t.Fatalf("playback state = %#v, want drained", state)
}
if err := replay.SetReplayPlaybackSpeed(3); !errors.Is(err, ErrReplayPlaybackSpeed) {
t.Fatalf("unsupported speed error = %v", err)
}
}
func TestReplayProcessTransportPauseBlocksNextInboundUntilResume(t *testing.T) {
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
{ElapsedMS: 0, Kind: "outbound", Data: base64.StdEncoding.EncodeToString([]byte("start"))},
{ElapsedMS: 120, Kind: "stdout", Data: base64.StdEncoding.EncodeToString([]byte("ready"))},
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("start")); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
frame, recvErr := connection.Recv()
if recvErr == nil && string(frame.Stdout) != "ready" {
recvErr = fmt.Errorf("stdout = %q, want ready", frame.Stdout)
}
result <- recvErr
}()
time.Sleep(20 * time.Millisecond)
if err := replay.PauseReplayPlayback(); err != nil {
t.Fatal(err)
}
if state := replay.ReplayPlaybackState(); !state.Paused || state.FastForward {
t.Fatalf("paused playback state = %#v", state)
}
select {
case err := <-result:
t.Fatalf("Recv() returned while paused: %v", err)
case <-time.After(150 * time.Millisecond):
}
if err := replay.ResumeReplayPlayback(); err != nil {
t.Fatal(err)
}
resumedAt := time.Now()
select {
case err := <-result:
if err != nil {
t.Fatal(err)
}
if elapsed := time.Since(resumedAt); elapsed < 60*time.Millisecond {
t.Fatalf("Recv() returned %v after resume, want paused time excluded", elapsed)
}
case <-time.After(300 * time.Millisecond):
t.Fatal("Recv() did not return after resume")
}
}
func TestReplayProcessTransportPauseIsSharedAcrossConnections(t *testing.T) {
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{
{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{{
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte("one")),
}},
},
{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-2"},
chunks: []processCassetteChunk{{
Kind: "stdout",
Data: base64.StdEncoding.EncodeToString([]byte("two")),
}},
},
})
if err := replay.PauseReplayPlayback(); err != nil {
t.Fatal(err)
}
results := make(chan string, 2)
for _, sessionID := range []string{"session-1", "session-2"} {
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: sessionID,
})
if err != nil {
t.Fatal(err)
}
go func() {
frame, recvErr := connection.Recv()
if recvErr != nil {
results <- "error: " + recvErr.Error()
return
}
results <- string(frame.Stdout)
}()
}
select {
case result := <-results:
t.Fatalf("connection returned while shared playback was paused: %q", result)
case <-time.After(50 * time.Millisecond):
}
if err := replay.ResumeReplayPlayback(); err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for range 2 {
select {
case result := <-results:
got[result] = true
case <-time.After(200 * time.Millisecond):
t.Fatal("connection did not resume")
}
}
if !got["one"] || !got["two"] {
t.Fatalf("resumed frames = %#v, want both connections", got)
}
}
func TestReplayProcessTransportPausedReceiveCanCancelAndClose(t *testing.T) {
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
{Kind: "outbound", Data: base64.StdEncoding.EncodeToString([]byte("start"))},
{Kind: "stdout", Data: base64.StdEncoding.EncodeToString([]byte("ready"))},
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := replay.PauseReplayPlayback(); err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("start")); err != nil {
t.Fatalf("outbound validation was blocked while paused: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
canceled := make(chan error, 1)
go func() {
_, recvErr := connection.(ContextProcessConnection).RecvContext(ctx)
canceled <- recvErr
}()
cancel()
select {
case err := <-canceled:
if !errors.Is(err, context.Canceled) {
t.Fatalf("RecvContext() error = %v, want context canceled", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("paused RecvContext() did not cancel")
}
closed := make(chan error, 1)
received := make(chan error, 1)
go func() {
_, recvErr := connection.Recv()
received <- recvErr
}()
go func() {
closed <- connection.Close()
}()
select {
case err := <-closed:
if err == nil || !strings.Contains(err.Error(), "consumed 1 of 2 chunks") {
t.Fatalf("Close() error = %v, want incomplete cassette error", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("Close() deadlocked while paused")
}
select {
case err := <-received:
if !errors.Is(err, io.EOF) {
t.Fatalf("Recv() after Close() error = %v, want EOF", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("paused Recv() did not unblock after Close()")
}
}
func TestReplayProcessTransportFastForwardConsumesFramesAndValidatesOutbound(t *testing.T) {
replay := replayProcessTransportWithChunksForTest(t, []replayConnectionChunksForTest{{
spec: ProcessSpec{Provider: ProviderCodex, AgentSessionID: "session-1"},
chunks: []processCassetteChunk{
{ElapsedMS: 10_000, Kind: "outbound", Data: base64.StdEncoding.EncodeToString([]byte("first"))},
{ElapsedMS: 20_000, Kind: "stdout", Data: base64.StdEncoding.EncodeToString([]byte("one"))},
{ElapsedMS: 30_000, Kind: "outbound", Data: base64.StdEncoding.EncodeToString([]byte("second"))},
{ElapsedMS: 40_000, Kind: "stdout", Data: base64.StdEncoding.EncodeToString([]byte("two"))},
},
}})
connection, err := replay.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := replay.PauseReplayPlayback(); err != nil {
t.Fatal(err)
}
if err := replay.SetReplayPlaybackFastForward(true); err != nil {
t.Fatal(err)
}
if state := replay.ReplayPlaybackState(); !state.Paused || !state.FastForward {
t.Fatalf("fast-forward playback state = %#v", state)
}
started := time.Now()
if err := connection.Send([]byte("first")); err != nil {
t.Fatal(err)
}
first, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if string(first.Stdout) != "one" {
t.Fatalf("first stdout = %q, want one", first.Stdout)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := connection.(ContextProcessConnection).RecvContext(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("RecvContext() before required outbound error = %v, want context canceled", err)
}
if err := connection.Send([]byte("second")); err != nil {
t.Fatal(err)
}
second, err := connection.Recv()
if err != nil {
t.Fatal(err)
}
if string(second.Stdout) != "two" {
t.Fatalf("second stdout = %q, want two", second.Stdout)
}
if elapsed := time.Since(started); elapsed > 200*time.Millisecond {
t.Fatalf("fast-forward took %v, want recorded waits skipped", elapsed)
}
if err := replay.SetReplayPlaybackFastForward(false); err != nil {
t.Fatal(err)
}
if state := replay.ReplayPlaybackState(); !state.Paused || state.FastForward || !state.Drained {
t.Fatalf("finished playback state = %#v", state)
}
if err := replay.VerifyComplete(); err != nil {
t.Fatal(err)
}
}
func TestReplayProcessTransportRejectsChunkSequenceGap(t *testing.T) {
directory := recordCassetteForTest(t, []byte("recorded\n"))
chunksPath := filepath.Join(directory, processCassetteChunksName)
raw, err := os.ReadFile(chunksPath)
if err != nil {
t.Fatal(err)
}
raw = []byte(strings.Replace(string(raw), `"chunkSeq":1`, `"chunkSeq":2`, 1))
if err := os.WriteFile(chunksPath, raw, 0o600); err != nil {
t.Fatal(err)
}
_, err = NewReplayProcessTransport(directory)
if err == nil || !strings.Contains(err.Error(), "integrity mismatch") {
t.Fatalf("NewReplayProcessTransport() error = %v, want integrity rejection", err)
}
}
func TestReplayProcessTransportRejectsMissingInboundFrame(t *testing.T) {
directory := t.TempDir()
base := &cassetteTestConnection{
received: []ProcessFrame{{Stdout: []byte("provider response")}},
}
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: base},
directory,
)
if err != nil {
t.Fatal(err)
}
connection, err := recording.Start(context.Background(), ProcessSpec{
Provider: ProviderCodex, AgentSessionID: "session-1",
})
if err != nil {
t.Fatal(err)
}
if err := connection.Send([]byte("request")); err != nil {
t.Fatal(err)
}
if _, err := connection.Recv(); err != nil {
t.Fatal(err)
}
if err := connection.Close(); err != nil {
t.Fatal(err)
}
if err := recording.Finalize(); err != nil {
t.Fatal(err)
}
framesPath := filepath.Join(directory, processCassetteChunksName)
raw, err := os.ReadFile(framesPath)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(lines) != 2 {
t.Fatalf("frames = %d, want outbound and inbound", len(lines))
}
if err := os.WriteFile(framesPath, []byte(lines[0]+"\n"), 0o600); err != nil {
t.Fatal(err)
}
_, err = NewReplayProcessTransport(directory)
if err == nil || !strings.Contains(err.Error(), "integrity mismatch") {
t.Fatalf("NewReplayProcessTransport() error = %v, want missing inbound rejection", err)
}
}
type replayConnectionChunksForTest struct {
spec ProcessSpec
chunks []processCassetteChunk
}
func replayProcessTransportWithChunksForTest(
t *testing.T,
connections []replayConnectionChunksForTest,
) *ReplayProcessTransport {
t.Helper()
directory := t.TempDir()
writer, err := newProcessCassetteWriter(directory)
if err != nil {
t.Fatal(err)
}
for _, connection := range connections {
connectionID, err := writer.start(
connection.spec,
ProcessCassetteCaptureOriginProcessStart,
)
if err != nil {
t.Fatal(err)
}
for index, chunk := range connection.chunks {
chunk.ConnectionID = connectionID
chunk.ChunkSeq = uint64(index + 1)
if err := writer.append(chunk); err != nil {
t.Fatal(err)
}
}
if err := writer.finishConnection(); err != nil {
t.Fatal(err)
}
}
if err := writer.finalize(); err != nil {
t.Fatal(err)
}
replay, err := NewReplayProcessTransport(directory)
if err != nil {
t.Fatal(err)
}
return replay
}
func recordCassetteForTest(t *testing.T, outbound []byte) string {
t.Helper()
directory := t.TempDir()
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
directory,
)
if err != nil {
t.Fatal(err)
}
connection, err := recording.Start(
context.Background(),
ProcessSpec{Provider: ProviderCodex},
)
if err != nil {
t.Fatal(err)
}
if err := connection.Send(outbound); err != nil {
t.Fatal(err)
}
if err := connection.Close(); err != nil {
t.Fatal(err)
}
if err := recording.Finalize(); err != nil {
t.Fatal(err)
}
return directory
}
func recordCassetteForTestWithoutFinalize(t *testing.T) string {
t.Helper()
directory := t.TempDir()
recording, err := NewRecordingProcessTransport(
cassetteTestTransport{connection: &cassetteTestConnection{}},
directory,
)
if err != nil {
t.Fatal(err)
}
if err := recording.writer.chunks.Close(); err != nil {
t.Fatal(err)
}
return directory
}
func assertProcessFrameEqual(t *testing.T, got, want ProcessFrame) {
t.Helper()
if string(got.Stdout) != string(want.Stdout) ||
string(got.Stderr) != string(want.Stderr) ||
got.Message != want.Message {
t.Fatalf("frame = %#v, want %#v", got, want)
}
switch {
case got.ExitCode == nil && want.ExitCode == nil:
case got.ExitCode == nil || want.ExitCode == nil:
t.Fatalf("frame exit = %v, want %v", got.ExitCode, want.ExitCode)
case *got.ExitCode != *want.ExitCode:
t.Fatalf("frame exit = %d, want %d", *got.ExitCode, *want.ExitCode)
}
}