Files
dozzle/internal/cloud/tools_resolve_readpath_test.go
Amir Raminfar 8811dc82bd
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
Push container / Push branches and PRs (push) Has been cancelled
Test / Typecheck (push) Has been cancelled
Test / JavaScript Tests (push) Has been cancelled
Test / Go Tests (push) Has been cancelled
Test / Go Staticcheck (push) Has been cancelled
Test / Integration Tests (push) Has been cancelled
fix(cloud): resolve read-only container tools in one shot (no extra LLM round-trip) (#4767)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 07:59:19 -07:00

240 lines
11 KiB
Go

package cloud
import (
"context"
"testing"
"time"
"github.com/amir20/dozzle/internal/container"
container_support "github.com/amir20/dozzle/internal/support/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// The read path collapses the find-then-act round-trip: when a name matches
// several same-service replicas/corpses, a read-only tool resolves to the
// single most-relevant container (running first, then newest active) in one
// shot instead of erroring and forcing the LLM to call find_containers and
// re-issue. The write path stays strict — these tests lock both halves in.
// --- resolver unit tests: read mode (resolveContainerRefRead) ---
func TestResolveRead_AllStopped_PicksMostRecentlyActiveCorpse(t *testing.T) {
// The crash-loop case: every "svc.1.*" task has exited. There is no live
// container, but a read-only inspect/logs call still has an obvious target —
// the corpse that died most recently, which is what the investigation is
// about. Resolve to it rather than erroring.
t0 := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
deps := resolverDeps([]container.Container{
{ID: "old", Name: "svc.1.aaa", Host: "local", State: "exited", StartedAt: t0, FinishedAt: t0.Add(1 * time.Minute)},
{ID: "newest", Name: "svc.1.bbb", Host: "local", State: "exited", StartedAt: t0.Add(5 * time.Minute), FinishedAt: t0.Add(6 * time.Minute)},
{ID: "mid", Name: "svc.1.ccc", Host: "local", State: "exited", StartedAt: t0.Add(2 * time.Minute), FinishedAt: t0.Add(3 * time.Minute)},
})
host, id, note, err := resolveContainerRefRead("svc.1", "", deps)
assert.NoError(t, err)
assert.Equal(t, "local", host)
assert.Equal(t, "newest", id)
// The pick is transparent: the note names the chosen task and that siblings
// exist, so the model can re-call with an explicit id if it wants another.
assert.NotEmpty(t, note)
assert.Contains(t, note, "svc.1.bbb")
}
func TestResolveRead_MultipleRunning_PicksNewestRunningWithNote(t *testing.T) {
// Several live replicas of one service. For a read (logs/inspect) the
// replicas are interchangeable, so resolve to the newest running one and say
// so — never force a round-trip.
t0 := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
deps := resolverDeps([]container.Container{
{ID: "r-old", Name: "svc.1.aaa", Host: "local", State: "running", StartedAt: t0},
{ID: "r-new", Name: "svc.1.bbb", Host: "local", State: "running", StartedAt: t0.Add(10 * time.Minute)},
{ID: "dead", Name: "svc.1.ccc", Host: "local", State: "exited", StartedAt: t0.Add(20 * time.Minute)},
})
host, id, note, err := resolveContainerRefRead("svc.1", "", deps)
assert.NoError(t, err)
assert.Equal(t, "local", host)
// Running beats a newer-but-dead container; among running, newest StartedAt.
assert.Equal(t, "r-new", id)
assert.NotEmpty(t, note)
assert.Contains(t, note, "svc.1.bbb")
assert.Contains(t, note, "running")
}
func TestResolveRead_SingleRunning_NoNote(t *testing.T) {
// Exactly one running among corpses — same outcome as the write path, and
// since the choice is unambiguous there is nothing to disclose: no note.
deps := resolverDeps([]container.Container{
{ID: "dead", Name: "svc.1.aaa", Host: "local", State: "exited"},
{ID: "live", Name: "svc.1.bbb", Host: "local", State: "running"},
})
host, id, note, err := resolveContainerRefRead("svc.1", "", deps)
assert.NoError(t, err)
assert.Equal(t, "local", host)
assert.Equal(t, "live", id)
assert.Empty(t, note)
}
func TestResolveRead_UniqueName_NoNote(t *testing.T) {
// A plain unique match resolves exactly as before with no note — the read
// relaxation only adds behavior to the previously-erroring ambiguous case.
deps := resolverDeps([]container.Container{
{ID: "abc123def456", Name: "nginx", Host: "local", State: "running"},
{ID: "fff999", Name: "redis", Host: "local", State: "running"},
})
host, id, note, err := resolveContainerRefRead("nginx", "", deps)
assert.NoError(t, err)
assert.Equal(t, "local", host)
assert.Equal(t, "abc123def456", id)
assert.Empty(t, note)
}
func TestResolveRead_AmbiguousAcrossHosts_PicksNewestRunning(t *testing.T) {
// Same name on two hosts. The write path refuses (host_id needed). The read
// path is allowed to pick the newest running one across hosts — reads are
// safe and the note discloses the cross-host siblings.
t0 := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
deps := resolverDeps([]container.Container{
{ID: "a", Name: "nginx", Host: "host-a", State: "running", StartedAt: t0},
{ID: "b", Name: "nginx", Host: "host-b", State: "running", StartedAt: t0.Add(1 * time.Minute)},
})
host, id, note, err := resolveContainerRefRead("nginx", "", deps)
assert.NoError(t, err)
assert.Equal(t, "host-b", host)
assert.Equal(t, "b", id)
assert.NotEmpty(t, note)
}
func TestResolveRead_NotFound_StillErrors(t *testing.T) {
// No match at all is still an error — the relaxation never invents a target.
deps := resolverDeps([]container.Container{
{ID: "id1", Name: "nginx", Host: "local"},
})
_, _, _, err := resolveContainerRefRead("does-not-exist", "", deps)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no container matching")
}
func TestResolveRead_HostScoped_StillRespectsHost(t *testing.T) {
// An explicit host still scopes the search even on the read path.
deps := resolverDeps([]container.Container{
{ID: "id1", Name: "nginx", Host: "host-a", State: "running"},
{ID: "id2", Name: "nginx", Host: "host-b", State: "running"},
})
host, id, _, err := resolveContainerRefRead("nginx", "host-b", deps)
assert.NoError(t, err)
assert.Equal(t, "host-b", host)
assert.Equal(t, "id2", id)
}
// --- write path stays strict: the relaxation must NOT leak into writes ---
func TestResolveWrite_AllStopped_StillRefuses(t *testing.T) {
// The read path now resolves this; the write path must still refuse, because
// picking a destructive target among corpses is exactly the ambiguity write
// tools must never guess at.
deps := resolverDeps([]container.Container{
{ID: "s1", Name: "svc.1.aaa", Host: "local", State: "exited"},
{ID: "s2", Name: "svc.1.bbb", Host: "local", State: "exited"},
})
_, _, err := resolveContainerRef("svc.1", "", deps)
assert.Error(t, err)
assert.Contains(t, err.Error(), "matches multiple containers")
}
func TestResolveWrite_MultipleRunning_StillRefuses(t *testing.T) {
deps := resolverDeps([]container.Container{
{ID: "r1", Name: "svc.1.aaa", Host: "local", State: "running"},
{ID: "r2", Name: "svc.1.bbb", Host: "local", State: "running"},
})
_, _, err := resolveContainerRef("svc.1", "", deps)
assert.Error(t, err)
assert.Contains(t, err.Error(), "matches multiple containers")
}
// --- end-to-end through ExecuteTool: read tools resolve in one shot ---
// closedLogsClientService is a MockClientService whose LogsBetweenDates returns
// an already-closed channel, so executeFetchContainerLogs's range loop drains
// immediately instead of blocking on the bare mock's nil channel. Embeds
// MockClientService so it satisfies the full ClientService interface unchanged.
type closedLogsClientService struct {
MockClientService
}
func (c *closedLogsClientService) LogsBetweenDates(_ context.Context, _ container.Container, _ time.Time, _ time.Time, _ container.StdType) (<-chan *container.LogEvent, error) {
ch := make(chan *container.LogEvent)
close(ch)
return ch, nil
}
func TestExecuteTool_FetchLogs_AllStopped_ResolvesNewestCorpseWithNote(t *testing.T) {
// fetch_container_logs on a crash-looped "svc.1" (every task exited) must
// succeed against the most-recently-active corpse and surface the pick in
// the result, not fail and force a find_containers round-trip.
t0 := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
mockHost := &MockHostService{}
withResolver(mockHost,
container.Container{ID: "old", Name: "svc.1.aaa", Host: "local", State: "exited", StartedAt: t0},
container.Container{ID: "newest", Name: "svc.1.bbb", Host: "local", State: "exited", StartedAt: t0.Add(5 * time.Minute)},
)
newestC := container.Container{ID: "newest", Name: "svc.1.bbb", Host: "local", State: "exited", StartedAt: t0.Add(5 * time.Minute)}
cs := container_support.NewContainerService(&closedLogsClientService{}, newestC)
mockHost.On("FindContainer", "local", "newest", container.ContainerLabels(nil)).Return(cs, nil)
resp := ExecuteTool(context.Background(), "fetch_container_logs", `{"container_id":"svc.1"}`, ToolDeps{HostService: mockHost})
assert.True(t, resp.Success)
// The returned container_name discloses the resolution so the model gets the
// sibling context for free in the same turn.
name := resp.GetFetchLogs().ContainerName
assert.Contains(t, name, "svc.1.bbb")
assert.Contains(t, name, "svc.1") // mentions the ambiguous ref it resolved
mockHost.AssertCalled(t, "FindContainer", "local", "newest", container.ContainerLabels(nil))
}
func TestExecuteTool_InspectContainer_AllStopped_ResolvesNewestCorpse(t *testing.T) {
// inspect_container on the same crash-loop resolves to the newest corpse in
// one shot. Inspect returns the concrete id/name/state (itself
// disambiguating), so we don't mangle its structured fields with a note.
t0 := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
mockHost := &MockHostService{}
withResolver(mockHost,
container.Container{ID: "old", Name: "svc.1.aaa", Host: "local", State: "exited", StartedAt: t0},
container.Container{ID: "newest", Name: "svc.1.bbb", Host: "local", State: "exited", StartedAt: t0.Add(5 * time.Minute)},
)
newestC := container.Container{ID: "newest", Name: "svc.1.bbb", Host: "local", State: "exited", StartedAt: t0.Add(5 * time.Minute)}
cs := container_support.NewContainerService(&MockClientService{}, newestC)
mockHost.On("FindContainer", "local", "newest", container.ContainerLabels(nil)).Return(cs, nil)
resp := ExecuteTool(context.Background(), "inspect_container", `{"container_id":"svc.1"}`, ToolDeps{HostService: mockHost})
assert.True(t, resp.Success)
assert.Equal(t, "newest", resp.GetInspectContainer().Id)
assert.Equal(t, "svc.1.bbb", resp.GetInspectContainer().Name)
}
func TestExecuteTool_RestartContainer_AllStopped_StillRefuses(t *testing.T) {
// The destructive counterpart of the fetch_logs test above: the SAME
// ambiguous all-stopped ref that reads now resolve must still refuse for a
// write tool, and must never reach FindContainer / ContainerAction.
mockClient := &MockClientService{}
mockHost := &MockHostService{}
withResolver(mockHost,
container.Container{ID: "s1", Name: "svc.1.aaa", Host: "local", State: "exited"},
container.Container{ID: "s2", Name: "svc.1.bbb", Host: "local", State: "exited"},
)
resp := ExecuteTool(context.Background(), "restart_container", `{"container_id":"svc.1"}`, ToolDeps{HostService: mockHost, EnableActions: true})
assert.False(t, resp.Success)
assert.Contains(t, resp.Error, "matches multiple containers")
mockHost.AssertNotCalled(t, "FindContainer", mock.Anything, mock.Anything, mock.Anything)
mockClient.AssertNotCalled(t, "ContainerAction", mock.Anything, mock.Anything, mock.Anything)
}