Files
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

2374 lines
88 KiB
Go

package agentsessionstore
import (
"context"
"reflect"
"testing"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
)
func TestStoreTracksRoomsAndClonesState(t *testing.T) {
svc := New(nil)
svc.TrackRoom(" room-1 ")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Presences: []WorkspaceAgentPresence{{ID: 1, Provider: "codex"}},
Sessions: []ProviderActivitySessionProjection{{AgentSessionID: "agent-1", EffectiveStatus: "working"}},
})
if _, ok := svc.GetAgentState("missing-room"); ok {
t.Fatal("GetAgentState ok = true for untracked room")
}
state, ok := svc.GetAgentState(" room-1 ")
if !ok {
t.Fatal("GetAgentState ok = false")
}
state.Presences[0].Provider = "mutated"
state.Sessions[0].AgentSessionID = "mutated"
state, ok = svc.GetAgentState("room-1")
if !ok ||
len(state.Presences) != 1 ||
state.Presences[0].Provider != "codex" ||
len(state.Sessions) != 1 ||
state.Sessions[0].AgentSessionID != "agent-1" {
t.Fatalf("state after external mutation = %#v, ok=%v", state, ok)
}
input := []ProviderActivitySessionProjection{{AgentSessionID: "agent-2"}}
inputPresences := []WorkspaceAgentPresence{{ID: 2, Provider: "claude"}}
svc.updateState("room-1", WorkspaceAgentSnapshot{Presences: inputPresences, Sessions: input})
inputPresences[0].Provider = "mutated"
input[0].AgentSessionID = "mutated"
state, ok = svc.GetAgentState("room-1")
if !ok ||
len(state.Presences) != 1 ||
state.Presences[0].Provider != "claude" ||
len(state.Sessions) != 1 ||
state.Sessions[0].AgentSessionID != "agent-2" {
t.Fatalf("state after input mutation = %#v, ok=%v", state, ok)
}
}
func TestStoreRefCountRemovesRoomOnlyAfterLastUntrack(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.TrackRoom(" room-1 ")
svc.TrackRoom(" ")
svc.updateState("room-1", WorkspaceAgentSnapshot{Sessions: []ProviderActivitySessionProjection{{AgentSessionID: "agent-1"}}})
if got := svc.listRoomIDs(); !reflect.DeepEqual(got, []string{"room-1"}) {
t.Fatalf("room ids = %#v", got)
}
svc.UntrackRoom("room-1")
if _, ok := svc.GetAgentState("room-1"); !ok {
t.Fatal("room removed after first untrack")
}
svc.UntrackRoom(" room-1 ")
if _, ok := svc.GetAgentState("room-1"); ok {
t.Fatal("room still tracked after final untrack")
}
}
func TestStoreClearsFailedSyncStateAfterLaterSuccess(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
}}})
svc.MarkActivitySyncPending("room-1", "agent-1", 1, 0, 0)
svc.MarkActivitySyncPending("room-1", "agent-1", 1, 0, 0)
svc.MarkActivitySyncFailed("room-1", "agent-1", 1, 0, 0, context.Canceled)
svc.MarkActivitySyncSucceeded("room-1", "agent-1", 1, 0, 0)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 || state.Sessions[0].SyncState == nil {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
syncState := state.Sessions[0].SyncState
if syncState.Status != WorkspaceAgentSyncStatusSynced {
t.Fatalf("sync status = %q, want synced", syncState.Status)
}
if syncState.PendingTimelineItemCount != 0 {
t.Fatalf("pending timeline count = %d, want 0", syncState.PendingTimelineItemCount)
}
if syncState.PendingStatePatchCount != 0 {
t.Fatalf("pending state patch count = %d, want 0", syncState.PendingStatePatchCount)
}
if syncState.FailedReportCount != 0 {
t.Fatalf("failed report count = %d, want 0", syncState.FailedReportCount)
}
if syncState.LastError != "" {
t.Fatalf("last error = %q, want empty", syncState.LastError)
}
}
func TestStoreMarksPendingAfterFailureWhenNextReportStarts(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
}}})
svc.MarkActivitySyncPending("room-1", "agent-1", 1, 0, 0)
svc.MarkActivitySyncFailed("room-1", "agent-1", 1, 0, 0, context.Canceled)
svc.MarkActivitySyncPending("room-1", "agent-1", 1, 0, 0)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 || state.Sessions[0].SyncState == nil {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
syncState := state.Sessions[0].SyncState
if syncState.Status != WorkspaceAgentSyncStatusPending {
t.Fatalf("sync status = %q, want pending", syncState.Status)
}
if syncState.FailedReportCount != 1 {
t.Fatalf("failed report count = %d, want 1", syncState.FailedReportCount)
}
if syncState.LastError != context.Canceled.Error() {
t.Fatalf("last error = %q, want %q", syncState.LastError, context.Canceled.Error())
}
}
func TestStoreTracksPendingMessageUpdateCount(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
}}})
svc.MarkActivitySyncPending("room-1", "agent-1", 0, 0, 2)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 || state.Sessions[0].SyncState == nil {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if got := state.Sessions[0].SyncState.PendingMessageUpdateCount; got != 2 {
t.Fatalf("pending message update count = %d, want 2", got)
}
svc.MarkActivitySyncFailed("room-1", "agent-1", 0, 0, 2, context.Canceled)
state, _ = svc.GetAgentState("room-1")
if got := state.Sessions[0].SyncState.PendingMessageUpdateCount; got != 2 {
t.Fatalf("pending message update count after failure = %d, want failed report to remain pending", got)
}
svc.updateState("room-1", WorkspaceAgentSnapshot{Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
}, {
AgentSessionID: "agent-2",
}}})
svc.MarkActivitySyncPending("room-1", "agent-2", 0, 0, 2)
svc.MarkActivitySyncSucceeded("room-1", "agent-2", 0, 0, 2)
state, _ = svc.GetAgentState("room-1")
if got := state.Sessions[1].SyncState.PendingMessageUpdateCount; got != 0 {
t.Fatalf("pending message update count after full success = %d, want 0", got)
}
if got := state.Sessions[1].SyncState.Status; got != WorkspaceAgentSyncStatusSynced {
t.Fatalf("sync status = %q, want synced", got)
}
}
func TestStoreRemoteMessageEchoDoesNotNotifyBusinessUpdate(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
var notifyCount int
svc.SetUpdateListener(func(string, WorkspaceAgentSnapshot) {
notifyCount++
})
svc.ApplySessionMessages("room-1", EventSource{AgentID: "agent-1"}, "agent-1", []WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
Role: "assistant",
Kind: "text",
Status: "completed",
Payload: map[string]any{"text": "hello"},
OccurredAtUnixMS: 1000,
}})
if notifyCount != 1 {
t.Fatalf("notify count after local message = %d, want 1", notifyCount)
}
svc.appendSessionMessages("room-1", "agent-1", []WorkspaceAgentSessionMessage{{
ID: 42,
AgentSessionID: "agent-1",
MessageID: "message-1",
Role: "assistant",
Kind: "text",
Status: "completed",
Payload: map[string]any{"text": "hello"},
OccurredAtUnixMS: 1000,
CreatedAtUnixMS: 1100,
UpdatedAtUnixMS: 1100,
Version: 7,
}}, 7)
if notifyCount != 1 {
t.Fatalf("notify count after remote echo = %d, want unchanged", notifyCount)
}
reply, ok := svc.ListSessionMessages("room-1", "agent-1", 0, 10)
if !ok || reply.LatestVersion != 1 || len(reply.Messages) != 1 || reply.Messages[0].Version != 1 {
t.Fatalf("messages reply = %#v, ok=%v, want local cursor preserved", reply, ok)
}
svc.appendSessionMessages("room-1", "agent-1", []WorkspaceAgentSessionMessage{{
ID: 43,
AgentSessionID: "agent-1",
MessageID: "message-2",
Role: "user",
Kind: "text",
Payload: map[string]any{"text": "new"},
OccurredAtUnixMS: 1200,
Version: 8,
}}, 8)
if notifyCount != 2 {
t.Fatalf("notify count after new remote message = %d, want 2", notifyCount)
}
}
func TestStoreAssignsGapFreeMessageVersionsForSameMillisecondRows(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
sameMS := int64(1710000000001)
svc.ApplySessionMessages("room-1", EventSource{AgentID: "agent-1"}, "agent-1", []WorkspaceAgentSessionMessageUpdate{{
MessageID: "client-submit:user:submit-1",
TurnID: "turn-1",
Role: "user",
Kind: "text",
Payload: map[string]any{"clientSubmitId": "submit-1", "text": "hello"},
OccurredAtUnixMS: sameMS,
}, {
MessageID: "assistant-1",
TurnID: "turn-1",
Role: "assistant",
Kind: "text",
Payload: map[string]any{"text": "hi"},
OccurredAtUnixMS: sameMS,
}})
reply, ok := svc.ListSessionMessages("room-1", "agent-1", 0, 10)
if !ok || reply.LatestVersion != 2 || len(reply.Messages) != 2 {
t.Fatalf("messages reply = %#v, ok=%v, want two gap-free rows", reply, ok)
}
if reply.Messages[0].Version != 1 || reply.Messages[1].Version != 2 {
t.Fatalf("versions = %d/%d, want 1/2", reply.Messages[0].Version, reply.Messages[1].Version)
}
incremental, ok := svc.ListSessionMessages("room-1", "agent-1", 1, 10)
if !ok || incremental.LatestVersion != 2 || len(incremental.Messages) != 1 || incremental.Messages[0].MessageID != "assistant-1" {
t.Fatalf("incremental reply = %#v, ok=%v, want only version 2", incremental, ok)
}
full, ok := svc.ListSessionMessages("room-1", "agent-1", 0, 10)
if !ok || len(full.Messages) != 2 {
t.Fatalf("full resync reply = %#v, ok=%v, want complete snapshot", full, ok)
}
}
func TestStoreSortsSessionMessagesByOccurredAtNotVersion(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplySessionMessages("room-1", EventSource{AgentID: "agent-1"}, "agent-1", []WorkspaceAgentSessionMessageUpdate{{
MessageID: "later-ingested-first",
TurnID: "turn-1",
Role: "assistant",
Kind: "text",
Payload: map[string]any{"text": "later"},
OccurredAtUnixMS: 3000,
}, {
MessageID: "earlier-ingested-second",
TurnID: "turn-1",
Role: "user",
Kind: "text",
Payload: map[string]any{"text": "earlier"},
OccurredAtUnixMS: 1000,
}})
reply, ok := svc.ListSessionMessages("room-1", "agent-1", 0, 10)
if !ok || len(reply.Messages) != 2 {
t.Fatalf("messages reply = %#v, ok=%v, want two rows", reply, ok)
}
if reply.Messages[0].MessageID != "earlier-ingested-second" || reply.Messages[0].Version != 2 {
t.Fatalf("first message = %#v, want earlier row despite version 2", reply.Messages[0])
}
if reply.Messages[1].MessageID != "later-ingested-first" || reply.Messages[1].Version != 1 {
t.Fatalf("second message = %#v, want later row despite version 1", reply.Messages[1])
}
}
func TestStoreZeroOccurredAtLegacyRowSortsByFallbackTimestamp(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
// Hydration (RestoreSnapshot -> mergeSnapshotMessagesLocked) stores rows
// as-is: legacy rows can carry StartedAt/CompletedAt but no OccurredAt.
svc.RestoreSnapshot("room-1", WorkspaceAgentSnapshot{
SessionMessagesByID: map[string][]WorkspaceAgentSessionMessage{
"agent-1": {{
MessageID: "legacy-started-only",
Role: "assistant",
Kind: "text",
Payload: map[string]any{"text": "old"},
StartedAtUnixMS: 1000,
}, {
MessageID: "timestamped-later",
Role: "assistant",
Kind: "text",
Payload: map[string]any{"text": "new"},
OccurredAtUnixMS: 2000,
}},
},
})
reply, ok := svc.ListSessionMessages("room-1", "agent-1", 0, 10)
if !ok || len(reply.Messages) != 2 {
t.Fatalf("messages reply = %#v, ok=%v, want two rows", reply, ok)
}
if reply.Messages[0].MessageID != "legacy-started-only" || reply.Messages[1].MessageID != "timestamped-later" {
t.Fatalf(
"order = [%s %s], want the zero-OccurredAt legacy row placed by its startedAt fallback, not forced last",
reply.Messages[0].MessageID,
reply.Messages[1].MessageID,
)
}
}
func TestStoreListSessionMessagesPagesByVersionDespiteOccurredAtOrder(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
// Ingest (version) order is the reverse of display (occurredAt) order.
svc.ApplySessionMessages("room-1", EventSource{AgentID: "agent-1"}, "agent-1", []WorkspaceAgentSessionMessageUpdate{{
MessageID: "third-by-time",
TurnID: "turn-1",
Role: "assistant",
Kind: "text",
Payload: map[string]any{"text": "third"},
OccurredAtUnixMS: 3000,
}, {
MessageID: "second-by-time",
TurnID: "turn-1",
Role: "assistant",
Kind: "text",
Payload: map[string]any{"text": "second"},
OccurredAtUnixMS: 2000,
}, {
MessageID: "first-by-time",
TurnID: "turn-1",
Role: "user",
Kind: "text",
Payload: map[string]any{"text": "first"},
OccurredAtUnixMS: 1000,
}})
// Page with limit=1 and advance the cursor the way poller.go
// maxMessageVersion does: max(reply.LatestVersion, delivered versions).
// Every message must be delivered exactly once; a page must never contain
// version N while omitting an undelivered version < N.
delivered := []string{}
cursor := uint64(0)
for page := 0; page < 6; page++ {
reply, ok := svc.ListSessionMessages("room-1", "agent-1", cursor, 1)
if !ok {
t.Fatalf("ListSessionMessages ok = false on page %d", page)
}
if len(reply.Messages) == 0 {
if reply.HasMore {
t.Fatalf("empty page %d reported hasMore", page)
}
break
}
next := reply.LatestVersion
for _, message := range reply.Messages {
delivered = append(delivered, message.MessageID)
if message.Version > next {
next = message.Version
}
}
if next <= cursor {
t.Fatalf("cursor did not advance on page %d: %d -> %d", page, cursor, next)
}
cursor = next
}
want := []string{"third-by-time", "second-by-time", "first-by-time"}
if !reflect.DeepEqual(delivered, want) {
t.Fatalf("delivered = %#v, want every message in version order %#v", delivered, want)
}
}
func TestStoreSnapshotCarriesTurnStateAndMessagesFromActivityEvents(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
source := EventSource{
Provider: "codex",
ProviderSessionID: "provider-1",
AgentID: "agent-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}
svc.ApplyEvents("room-1", source, []activityshared.Event{{
EventID: "session-started-1",
Type: activityshared.EventSessionStarted,
Provider: activityshared.ProviderCodex,
ProviderSessionID: "provider-1",
AgentSessionID: "agent-1",
OccurredAtUnixMS: 1000,
Payload: activityshared.EventPayload{
LifecycleStatus: string(activityshared.SessionLifecycleStatusActive),
EffectiveStatus: string(activityshared.SessionStatusIdle),
},
}, {
EventID: "turn-started-1",
Type: activityshared.EventTurnStarted,
Provider: activityshared.ProviderCodex,
ProviderSessionID: "provider-1",
AgentSessionID: "agent-1",
OccurredAtUnixMS: 1001,
Payload: activityshared.EventPayload{
TurnID: "turn-1",
TurnPhase: string(activityshared.TurnPhaseWorking),
},
}, {
EventID: "message-1",
Type: activityshared.EventMessageAppended,
Provider: activityshared.ProviderCodex,
ProviderSessionID: "provider-1",
AgentSessionID: "agent-1",
OccurredAtUnixMS: 1002,
Payload: activityshared.EventPayload{
TurnID: "turn-1",
Role: activityshared.MessageRoleAssistant,
Content: "working",
Semantics: activityshared.MessageSemantics{UserVisibleAssistantResponse: true},
Metadata: map[string]any{
"messageId": "assistant:turn-1:1",
},
},
}})
snapshot, ok := svc.GetAgentSnapshot("room-1")
if !ok || len(snapshot.Sessions) != 1 {
t.Fatalf("snapshot = %#v, ok=%v, want one session", snapshot, ok)
}
session := snapshot.Sessions[0]
if session.TurnPhase != string(activityshared.TurnPhaseWorking) || session.EffectiveStatus != string(activityshared.SessionStatusWorking) {
t.Fatalf("session turn state = phase %q effective %q, want working", session.TurnPhase, session.EffectiveStatus)
}
messages := snapshot.SessionMessagesByID["agent-1"]
if len(messages) != 1 || messages[0].MessageID != "assistant:turn-1:1" || messages[0].Version != 1 {
t.Fatalf("snapshot messages = %#v, want one versioned message", snapshot.SessionMessagesByID)
}
if messages[0].Semantics == nil || !messages[0].Semantics.UserVisibleAssistantResponse {
t.Fatalf("snapshot message semantics = %#v, want explicit visible assistant response", messages[0].Semantics)
}
}
func TestSessionMessageUpdateFromActivityEventCarriesExplicitSemantics(t *testing.T) {
t.Parallel()
ctx := activityshared.EventContext{
EventID: "event-1",
AgentSessionID: "agent-1",
TurnID: "turn-1",
}
message := activityshared.NewMessageAppended(
ctx,
activityshared.MessageRoleAssistant,
"done",
true,
)
messageUpdate, ok := sessionMessageUpdateFromActivityEvent("agent-1", message, 1_000)
if !ok || messageUpdate.Semantics == nil || !messageUpdate.Semantics.UserVisibleAssistantResponse {
t.Fatalf("message update = %#v, want explicit visible assistant response", messageUpdate)
}
call := activityshared.NewCallStarted(ctx, "call-1", "tool", "Read", map[string]any{"path": "README.md"})
callUpdate, ok := sessionMessageUpdateFromActivityEvent("agent-1", call, 1_001)
if !ok || callUpdate.Semantics == nil || callUpdate.Semantics.UserVisibleAssistantResponse {
t.Fatalf("call update = %#v, want explicit non-visible assistant response", callUpdate)
}
}
func TestStoreRemoteSessionEchoDoesNotNotifyBusinessUpdate(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
var notifyCount int
svc.SetUpdateListener(func(string, WorkspaceAgentSnapshot) {
notifyCount++
})
svc.ApplySessionState("room-1", EventSource{
Provider: "codex",
AgentID: "agent-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}, "agent-1", WorkspaceAgentSessionStateUpdate{
Provider: "codex",
Title: "Build feature",
LifecycleStatus: string(activityshared.SessionLifecycleStatusActive),
CurrentPhase: string(activityshared.TurnPhaseWorking),
OccurredAtUnixMS: 1000,
StartedAtUnixMS: 900,
})
if notifyCount != 1 {
t.Fatalf("notify count after local state = %d, want 1", notifyCount)
}
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
remoteEcho := state.Sessions[0]
remoteEcho.ID = 99
svc.updateStateForOrigin("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{remoteEcho},
}, WorkspaceAgentSessionOriginRuntime)
if notifyCount != 1 {
t.Fatalf("notify count after remote echo = %d, want unchanged", notifyCount)
}
remoteEcho.Title = "Updated title"
remoteEcho.UpdatedAtUnixMS = 1200
svc.updateStateForOrigin("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{remoteEcho},
}, WorkspaceAgentSessionOriginRuntime)
if notifyCount != 2 {
t.Fatalf("notify count after changed remote state = %d, want 2", notifyCount)
}
}
func TestStoreLoadsStoredSyncState(t *testing.T) {
store := NewFileAgentSyncStateStore(t.TempDir())
first := New(nil, WithSyncStateStore(store))
first.TrackRoom("room-1")
first.updateState("room-1", WorkspaceAgentSnapshot{Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
}}})
first.MarkActivitySyncPending("room-1", "agent-1", 1, 0, 0)
first.MarkActivitySyncFailed("room-1", "agent-1", 1, 0, 0, context.Canceled)
second := New(nil, WithSyncStateStore(store))
second.TrackRoom("room-1")
second.updateState("room-1", WorkspaceAgentSnapshot{Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
}}})
state, ok := second.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 || state.Sessions[0].SyncState == nil {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if got := state.Sessions[0].SyncState.Status; got != WorkspaceAgentSyncStatusFailed {
t.Fatalf("loaded sync status = %q, want failed", got)
}
if got := state.Sessions[0].SyncState.FailedReportCount; got != 1 {
t.Fatalf("loaded failed report count = %d, want 1", got)
}
}
func TestStoreLoadsPendingSyncStateAsFailed(t *testing.T) {
store := NewFileAgentSyncStateStore(t.TempDir())
first := New(nil, WithSyncStateStore(store))
first.TrackRoom("room-1")
first.updateState("room-1", WorkspaceAgentSnapshot{Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
}}})
first.MarkActivitySyncPending("room-1", "agent-1", 1, 0, 0)
second := New(nil, WithSyncStateStore(store))
second.TrackRoom("room-1")
second.updateState("room-1", WorkspaceAgentSnapshot{Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
}}})
state, ok := second.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 || state.Sessions[0].SyncState == nil {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
syncState := state.Sessions[0].SyncState
if syncState.Status != WorkspaceAgentSyncStatusFailed {
t.Fatalf("loaded sync status = %q, want failed", syncState.Status)
}
if syncState.FailedReportCount != 1 {
t.Fatalf("failed report count = %d, want 1", syncState.FailedReportCount)
}
}
func TestStoreHideAgentSessionRemovesStateAndMessages(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{
{AgentSessionID: "agent-1", EffectiveStatus: "working"},
{AgentSessionID: "agent-2", EffectiveStatus: "ready"},
},
})
svc.ApplySessionMessages("room-1", EventSource{Provider: "codex"}, "agent-1", []WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
Role: "assistant",
Kind: "text",
Status: "completed",
}})
svc.HideAgentSession("room-1", "agent-1")
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 || state.Sessions[0].AgentSessionID != "agent-2" {
t.Fatalf("state after hide = %#v, ok=%v", state, ok)
}
messages, ok := svc.GetAgentMessages("room-1", "agent-1")
if !ok || len(messages.Messages) != 0 {
t.Fatalf("messages after hide = %#v, ok=%v", messages, ok)
}
svc.ApplyEvents("room-1", EventSource{Provider: "codex"}, []activityshared.Event{
activityshared.NewSessionUpdated(activityshared.EventContext{
EventID: "updated",
Provider: activityshared.ProviderCodex,
ProviderSessionID: "provider-1",
AgentSessionID: "agent-1",
OccurredAtUnixMS: 2,
}, activityshared.SessionStatusWorking),
})
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{
{AgentSessionID: "agent-1", EffectiveStatus: "working"},
{AgentSessionID: "agent-2", EffectiveStatus: "ready"},
},
})
state, ok = svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 || state.Sessions[0].AgentSessionID != "agent-2" {
t.Fatalf("state after hidden re-ingest = %#v, ok=%v", state, ok)
}
}
func TestStoreRecordsSessionOwnerOnceFromEventSource(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplyEvents("room-1", EventSource{Provider: "codex", UserID: "user-1"}, []activityshared.Event{
activityshared.NewSessionStarted(activityshared.EventContext{
EventID: "started",
Provider: activityshared.ProviderCodex,
AgentSessionID: "agent-1",
OccurredAtUnixMS: 1,
}),
})
svc.ApplyEvents("room-1", EventSource{Provider: "codex", UserID: "user-2"}, []activityshared.Event{
activityshared.NewSessionUpdated(activityshared.EventContext{
EventID: "updated",
Provider: activityshared.ProviderCodex,
AgentSessionID: "agent-1",
OccurredAtUnixMS: 2,
}, activityshared.SessionStatusWorking),
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if got := state.Sessions[0].UserID; got != "user-1" {
t.Fatalf("session owner = %q, want user-1", got)
}
}
func TestStoreReadyStatePatchClearsWorkingSession(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.ApplyActivity("room-1", EventSource{}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-1",
CurrentPhase: "ready",
OccurredAtUnixMS: 1000,
}}, nil)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.EffectiveStatus != string(activityshared.SessionStatusIdle) ||
session.TurnPhase != string(activityshared.TurnPhaseIdle) {
t.Fatalf("session = %#v, want ready patch to clear working status", session)
}
}
func TestStoreNormalizesWaitingStatePatchAliases(t *testing.T) {
tests := []struct {
name string
phase string
}{
{name: "awaiting approval", phase: "awaiting_approval"},
{name: "waiting approval", phase: "waiting_approval"},
{name: "waiting input", phase: "waiting_input"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplyActivity("room-1", EventSource{}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
CurrentPhase: tt.phase,
OccurredAtUnixMS: 1000,
}}, nil)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.EffectiveStatus != "waiting" || session.TurnPhase != tt.phase {
t.Fatalf("session = %#v, want waiting effective status and phase %q", session, tt.phase)
}
})
}
}
func TestStoreWaitingStatePatchAdvancesSessionUpdatedAt(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.ApplyActivity("room-1", EventSource{}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-1",
CurrentPhase: "waiting_approval",
OccurredAtUnixMS: 1000,
}}, nil)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.EffectiveStatus != "waiting" ||
session.TurnPhase != "waiting_approval" ||
session.UpdatedAtUnixMS != 1000 {
t.Fatalf("session = %#v, want waiting status with updated_at 1000", session)
}
}
func TestStoreInfersWorkingStateFromRunningEntityWhenPhaseMissing(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplyActivity("room-1", EventSource{}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
OccurredAtUnixMS: 1000,
Entities: []WorkspaceAgentEntityPatch{{
CallID: "call-1",
Status: "running",
}},
}}, nil)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.EffectiveStatus != string(activityshared.SessionStatusWorking) ||
session.TurnPhase != string(activityshared.TurnPhaseWorking) {
t.Fatalf("session = %#v, want running entity patch to infer working session state", session)
}
}
func TestStoreDoesNotClearWorkingStateOnCompletedEntityPatchWithoutPhase(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.ApplyActivity("room-1", EventSource{}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
OccurredAtUnixMS: 1000,
Entities: []WorkspaceAgentEntityPatch{{
CallID: "call-1",
Status: "completed",
}},
}}, nil)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.EffectiveStatus != string(activityshared.SessionStatusWorking) ||
session.TurnPhase != string(activityshared.TurnPhaseWorking) {
t.Fatalf("session = %#v, want completed entity patch without phase to preserve working state", session)
}
}
func TestStoreTreatsCanceledPatchAsTerminalCanceled(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.ApplyActivity("room-1", EventSource{}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-1",
LifecycleStatus: "canceled",
OccurredAtUnixMS: 1000,
}}, nil)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.LifecycleStatus != "canceled" ||
session.Status != string(activityshared.SessionStatusCanceled) ||
session.EffectiveStatus != string(activityshared.SessionStatusCanceled) ||
session.EndedAtUnixMS != 1000 ||
session.UpdatedAtUnixMS != 900 {
t.Fatalf("session = %#v, want canceled patch to become terminal canceled", session)
}
}
func TestStoreDoesNotNormalizeSessionStatusFromTurnPhase(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
TurnPhase: "idle",
EffectiveStatus: "active",
}},
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if state.Sessions[0].EffectiveStatus != "active" {
t.Fatalf("effective status = %q, want active", state.Sessions[0].EffectiveStatus)
}
}
func TestStoreMapsWaitingTurnPhaseToSessionStatus(t *testing.T) {
t.Parallel()
service := New(nil)
service.TrackRoom("room-1")
service.ApplyActivity("room-1", EventSource{
Provider: "codex",
ProviderSessionID: "provider-session",
AgentID: "agent-session",
}, nil, []WorkspaceAgentStatePatch{
{
AgentSessionID: "agent-session",
Provider: "codex",
ProviderSessionID: "provider-session",
CurrentPhase: string(activityshared.TurnPhaseWaitingApproval),
Turn: &WorkspaceAgentTurnPatch{
TurnID: "turn-1",
Phase: string(activityshared.TurnPhaseWaitingApproval),
},
OccurredAtUnixMS: 1710000000000,
},
}, nil)
state, ok := service.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok = %v, want one session", state, ok)
}
session := state.Sessions[0]
if session.Status != string(activityshared.SessionStatusWaiting) ||
session.EffectiveStatus != string(activityshared.SessionStatusWaiting) ||
session.TurnPhase != string(activityshared.TurnPhaseWaitingApproval) {
t.Fatalf("session = %#v, want waiting status with waiting approval turn phase", session)
}
}
func TestGoalTurnProvenanceSurvivesEventAndCompatibilityRoundTrip(t *testing.T) {
t.Parallel()
event := activityshared.NewTurnStarted(activityshared.EventContext{
EventID: "turn-started", Provider: activityshared.ProviderClaudeCode,
ProviderSessionID: "provider-session", AgentSessionID: "agent-session",
OccurredAtUnixMS: 100,
}, "turn-1")
event.Payload.Metadata = map[string]any{
"turnOrigin": "goal_continuation", "sourceGoalOperationId": "goal-op-1",
"sourceGoalRevision": int64(7), "sourceGoalRepairEpoch": int64(3),
}
patch, ok := statePatchFromActivityEvent(EventSource{Provider: "claude-code"}, event, "agent-session", 100)
if !ok || patch.Turn == nil {
t.Fatalf("event patch=%#v ok=%v", patch, ok)
}
state := sessionStateUpdateFromPatch(patch)
roundTrip := statePatchFromSessionState("agent-session", state)
if roundTrip.Turn == nil || roundTrip.Turn.Origin != "goal_continuation" ||
roundTrip.Turn.SourceGoalOperationID != "goal-op-1" || roundTrip.Turn.SourceGoalRevision != 7 ||
roundTrip.Turn.SourceGoalRepairEpoch != 3 {
t.Fatalf("round-trip provenance=%#v", roundTrip.Turn)
}
}
func TestCapabilityAndRuntimeContextPatchSurviveCompatibilityRoundTrip(t *testing.T) {
t.Parallel()
patch := WorkspaceAgentStatePatch{
AgentSessionID: "session",
Capabilities: canonical.NewCapabilitySnapshot([]string{canonical.CapabilityGoalPause}),
RuntimeContextPatch: &canonical.RuntimeContextPatch{
Set: map[string]any{"planMode": true},
Unset: []string{"stale"},
},
}
state := sessionStateUpdateFromPatch(patch)
roundTrip := statePatchFromSessionState("session", state)
if roundTrip.Capabilities == nil || len(roundTrip.Capabilities.Values) != 1 || roundTrip.Capabilities.Values[0] != canonical.CapabilityGoalPause {
t.Fatalf("capabilities = %#v", roundTrip.Capabilities)
}
if roundTrip.RuntimeContextPatch == nil || roundTrip.RuntimeContextPatch.Set["planMode"] != true || len(roundTrip.RuntimeContextPatch.Unset) != 1 || roundTrip.RuntimeContextPatch.Unset[0] != "stale" {
t.Fatalf("runtime context patch = %#v", roundTrip.RuntimeContextPatch)
}
}
func TestStoreAppliesRuntimeStatusEventsImmediately(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplyEvents("room-1", EventSource{
Provider: "claude-code",
ProviderSessionID: "provider-session",
CWD: "/workspace/room-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}, []activityshared.Event{
activityshared.NewSessionStarted(activityshared.EventContext{
EventID: "session-started",
Provider: activityshared.ProviderClaudeCode,
ProviderSessionID: "provider-session",
AgentSessionID: "agent-session",
CWD: "/workspace/room-1",
OccurredAtUnixMS: 1710000000000,
}),
activityshared.NewTurnStarted(activityshared.EventContext{
EventID: "turn-started",
Provider: activityshared.ProviderClaudeCode,
ProviderSessionID: "provider-session",
AgentSessionID: "agent-session",
CWD: "/workspace/room-1",
OccurredAtUnixMS: 1710000000100,
}, "turn-1"),
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if state.Sessions[0].EffectiveStatus != "working" || state.Sessions[0].TurnPhase != "working" {
t.Fatalf("running session = %#v, want working immediately", state.Sessions[0])
}
if state.Sessions[0].SessionOrigin != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("session origin = %q", state.Sessions[0].SessionOrigin)
}
svc.ApplyEvents("room-1", EventSource{}, []activityshared.Event{
activityshared.NewTurnCompleted(activityshared.EventContext{
EventID: "turn-completed",
Provider: activityshared.ProviderClaudeCode,
ProviderSessionID: "provider-session",
AgentSessionID: "agent-session",
CWD: "/workspace/room-1",
OccurredAtUnixMS: 1710000000200,
}, "turn-1", activityshared.TurnOutcomeCompleted),
activityshared.NewSessionUpdated(activityshared.EventContext{
EventID: "session-idle",
Provider: activityshared.ProviderClaudeCode,
ProviderSessionID: "provider-session",
AgentSessionID: "agent-session",
CWD: "/workspace/room-1",
OccurredAtUnixMS: 1710000000200,
}, activityshared.SessionStatusIdle),
})
state, ok = svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if state.Sessions[0].EffectiveStatus != "idle" || state.Sessions[0].TurnPhase != "idle" {
t.Fatalf("completed session = %#v, want idle immediately", state.Sessions[0])
}
if state.Sessions[0].UpdatedAtUnixMS != 1710000000200 {
t.Fatalf("updated_at = %d, want runtime timestamp", state.Sessions[0].UpdatedAtUnixMS)
}
}
func TestStatePatchFromActivityEventIgnoresCompletedTurnLastError(t *testing.T) {
event := activityshared.NewTurnCompleted(activityshared.EventContext{
EventID: "turn-completed",
Provider: activityshared.ProviderClaudeCode,
ProviderSessionID: "provider-session",
AgentSessionID: "agent-session",
CWD: "/workspace/room-1",
OccurredAtUnixMS: 1710000000200,
}, "turn-1", activityshared.TurnOutcomeCompleted)
event.Payload.Metadata = map[string]any{
"lastError": "end_turn",
"stopReason": "end_turn",
}
patch, ok := statePatchFromActivityEvent(EventSource{}, event, "agent-session", event.OccurredAtUnixMS)
if !ok {
t.Fatal("statePatchFromActivityEvent ok = false, want true")
}
if patch.LastError != "" {
t.Fatalf("last error = %q, want empty for completed turn", patch.LastError)
}
}
func TestStoreDoesNotAdvanceUpdatedAtForPassiveSessionUpdateEvent(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-session",
Provider: "claude-code",
ProviderSessionID: "provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
LifecycleStatus: string(activityshared.SessionLifecycleStatusActive),
TurnPhase: string(activityshared.TurnPhaseIdle),
EffectiveStatus: string(activityshared.SessionStatusIdle),
UpdatedAtUnixMS: 1000,
Title: "before",
}},
})
svc.ApplyEvents("room-1", EventSource{
Provider: "claude-code",
ProviderSessionID: "provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}, []activityshared.Event{
activityshared.NewSessionUpdated(activityshared.EventContext{
EventID: "session-idle",
Provider: activityshared.ProviderClaudeCode,
ProviderSessionID: "provider-session",
AgentSessionID: "agent-session",
OccurredAtUnixMS: 1100,
}, activityshared.SessionStatusIdle),
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.UpdatedAtUnixMS != 1000 {
t.Fatalf("updated_at = %d, want existing timestamp 1000", session.UpdatedAtUnixMS)
}
if session.EffectiveStatus != string(activityshared.SessionStatusIdle) ||
session.TurnPhase != string(activityshared.TurnPhaseIdle) {
t.Fatalf("session = %#v, want idle status preserved", session)
}
}
func TestStoreAppliesTerminalStatePatchesWithEndTime(t *testing.T) {
tests := []struct {
name string
activity ReportActivityInput
wantLifecycleStatus string
wantEffectiveStatus string
wantTurnPhase string
wantEndedAtUnixMS int64
}{
{
name: "completed event patch keeps completed status despite idle phase",
activity: ReportActivityInput{
WorkspaceID: "room-1",
Source: EventSource{
Provider: "codex",
ProviderSessionID: "provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
},
StatePatches: []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-session",
Provider: "codex",
ProviderSessionID: "provider-session",
LifecycleStatus: string(activityshared.SessionStatusCompleted),
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1710000000100,
}},
},
wantLifecycleStatus: string(activityshared.SessionStatusCompleted),
wantEffectiveStatus: string(activityshared.SessionStatusCompleted),
wantTurnPhase: string(activityshared.TurnPhaseIdle),
wantEndedAtUnixMS: 1710000000100,
},
{
name: "failed patch keeps failed status despite idle phase",
activity: ReportActivityInput{
WorkspaceID: "room-1",
Source: EventSource{
Provider: "codex",
ProviderSessionID: "provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
},
StatePatches: []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-session",
Provider: "codex",
ProviderSessionID: "provider-session",
LifecycleStatus: string(activityshared.SessionLifecycleStatusFailed),
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1710000000200,
}},
},
wantLifecycleStatus: string(activityshared.SessionLifecycleStatusFailed),
wantEffectiveStatus: string(activityshared.SessionStatusFailed),
wantTurnPhase: string(activityshared.TurnPhaseIdle),
wantEndedAtUnixMS: 1710000000200,
},
{
name: "failed turn patch records turn end time without ending session lifecycle",
activity: ReportActivityInput{
WorkspaceID: "room-1",
Source: EventSource{
Provider: "claude-code",
ProviderSessionID: "provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
},
StatePatches: []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-session",
Provider: "claude-code",
ProviderSessionID: "provider-session",
CurrentPhase: string(activityshared.TurnPhaseFailed),
OccurredAtUnixMS: 1710000000250,
Turn: &WorkspaceAgentTurnPatch{
TurnID: "turn-1",
Phase: string(activityshared.TurnPhaseFailed),
},
}},
},
wantLifecycleStatus: string(activityshared.SessionLifecycleStatusActive),
wantEffectiveStatus: string(activityshared.SessionStatusFailed),
wantTurnPhase: string(activityshared.TurnPhaseFailed),
wantEndedAtUnixMS: 1710000000250,
},
{
name: "completed patch prefers explicit completed time",
activity: ReportActivityInput{
WorkspaceID: "room-1",
Source: EventSource{
Provider: "codex",
ProviderSessionID: "provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
},
StatePatches: []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-session",
Provider: "codex",
ProviderSessionID: "provider-session",
LifecycleStatus: string(activityshared.SessionLifecycleStatusEnded),
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1710000000300,
Turn: &WorkspaceAgentTurnPatch{
TurnID: "turn-1",
CompletedAtUnixMS: 1710000000350,
},
}},
},
wantLifecycleStatus: string(activityshared.SessionLifecycleStatusEnded),
wantEffectiveStatus: string(activityshared.SessionStatusCompleted),
wantTurnPhase: string(activityshared.TurnPhaseIdle),
wantEndedAtUnixMS: 1710000000350,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplyActivity("room-1", tt.activity.Source, tt.activity.TimelineItems, tt.activity.StatePatches, tt.activity.MessageUpdates)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.LifecycleStatus != tt.wantLifecycleStatus ||
session.EffectiveStatus != tt.wantEffectiveStatus ||
session.TurnPhase != tt.wantTurnPhase ||
session.EndedAtUnixMS != tt.wantEndedAtUnixMS {
t.Fatalf("session = %#v, want lifecycle=%q effective=%q phase=%q ended_at=%d",
session,
tt.wantLifecycleStatus,
tt.wantEffectiveStatus,
tt.wantTurnPhase,
tt.wantEndedAtUnixMS,
)
}
})
}
}
func TestStoreKeepsNewerLocalIdleAgainstStaleWorkingSync(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
Provider: "nexight",
ProviderSessionID: "nexight-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.markSessionIdle("room-1", "agent-1", 1000)
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
Provider: "nexight",
ProviderSessionID: "nexight-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if state.Sessions[0].EffectiveStatus != "idle" || state.Sessions[0].TurnPhase != "idle" {
t.Fatalf("session regressed to stale working state: %#v", state.Sessions[0])
}
if state.Sessions[0].UpdatedAtUnixMS != 1000 {
t.Fatalf("updated_at = %d, want local idle timestamp 1000", state.Sessions[0].UpdatedAtUnixMS)
}
}
func TestStoreStoresMessageUpdatesForLocalReads(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplyActivity("room-1", EventSource{AgentID: "agent-session-1"}, nil, nil, []WorkspaceAgentMessageUpdate{{
AgentSessionID: "agent-session-1",
MessageID: "message-2",
Seq: 2,
Role: "assistant",
Kind: "text",
Status: "streaming",
Semantics: &WorkspaceAgentMessageSemantics{UserVisibleAssistantResponse: true},
Payload: map[string]any{"text": "working"},
OccurredAtUnixMS: 1710000000002,
}, {
AgentSessionID: "agent-session-1",
MessageID: "message-1",
Seq: 1,
Role: "user",
Kind: "text",
Payload: map[string]any{"text": "hello"},
OccurredAtUnixMS: 1710000000001,
}})
svc.ApplyActivity("room-1", EventSource{AgentID: "agent-session-1"}, nil, nil, []WorkspaceAgentMessageUpdate{{
AgentSessionID: "agent-session-1",
MessageID: "message-2",
Seq: 99,
Role: "assistant",
Kind: "text",
Status: "completed",
Payload: map[string]any{"text": "done"},
CompletedAtUnixMS: 1710000000003,
}})
messages, ok := svc.GetAgentMessages("room-1", "agent-session-1")
if !ok {
t.Fatal("GetAgentMessages() ok = false, want true")
}
if len(messages.Messages) != 2 {
t.Fatalf("messages = %#v, want 2", messages.Messages)
}
if messages.Messages[0].MessageID != "message-1" || messages.Messages[0].Seq != 2 {
t.Fatalf("first message = %#v, want message-1 displayed first with seq 2", messages.Messages[0])
}
if messages.Messages[1].MessageID != "message-2" || messages.Messages[1].Seq != 1 || messages.Messages[1].Status != "completed" {
t.Fatalf("second message = %#v, want completed message-2 preserving first seq 1", messages.Messages[1])
}
if messages.Messages[1].Payload["text"] != "done" || messages.Messages[1].CompletedAtUnixMS != 1710000000003 {
t.Fatalf("merged message = %#v", messages.Messages[1])
}
if messages.Messages[1].Semantics == nil || !messages.Messages[1].Semantics.UserVisibleAssistantResponse {
t.Fatalf("merged message semantics = %#v, want original explicit visibility preserved", messages.Messages[1].Semantics)
}
messages.Messages[1].Payload["text"] = "mutated"
messages, _ = svc.GetAgentMessages("room-1", "agent-session-1")
if messages.Messages[1].Payload["text"] != "done" {
t.Fatalf("cached message payload was mutated through caller: %#v", messages.Messages[1].Payload)
}
}
func TestStoreAppliesSessionStateForLocalReads(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplySessionState("room-1", EventSource{
Provider: "codex",
ProviderSessionID: "provider-session-1",
AgentID: "agent-session-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
UserID: "user-1",
}, "agent-session-1", WorkspaceAgentSessionStateUpdate{
Provider: "codex",
ProviderSessionID: "provider-session-1",
Model: "gpt-5",
CWD: "/workspace",
Title: "Working",
LifecycleStatus: "active",
CurrentPhase: "coding",
OccurredAtUnixMS: 1710000000001,
StartedAtUnixMS: 1710000000000,
EndedAtUnixMS: 1710000000002,
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v, want one session", state, ok)
}
session := state.Sessions[0]
if session.AgentSessionID != "agent-session-1" || session.UserID != "user-1" ||
session.Provider != "codex" || session.ProviderSessionID != "provider-session-1" ||
session.SessionOrigin != WorkspaceAgentSessionOriginRuntime || session.Title != "Working" ||
session.TurnPhase != "coding" || session.EffectiveStatus != "idle" {
t.Fatalf("session = %#v", session)
}
if session.StartedAtUnixMS != 1710000000000 || session.EndedAtUnixMS != 1710000000002 {
t.Fatalf("session times = %d/%d, want 1710000000000/1710000000002", session.StartedAtUnixMS, session.EndedAtUnixMS)
}
}
func TestStoreAppliesSessionMessagesForLocalReads(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplySessionMessages("room-1", EventSource{
AgentID: "agent-session-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}, "agent-session-1", []WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
TurnID: "turn-1",
Role: "assistant",
Kind: "text",
Status: "streaming",
Payload: map[string]any{"text": "hel"},
OccurredAtUnixMS: 1710000000001,
}})
svc.ApplySessionMessages("room-1", EventSource{
AgentID: "agent-session-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}, "agent-session-1", []WorkspaceAgentSessionMessageUpdate{{
MessageID: "message-1",
Status: "completed",
Payload: map[string]any{"text": "hello"},
CompletedAtUnixMS: 1710000000002,
}})
messages, ok := svc.GetAgentMessages("room-1", "agent-session-1")
if !ok || len(messages.Messages) != 1 {
t.Fatalf("messages = %#v, ok=%v, want one message", messages, ok)
}
message := messages.Messages[0]
if message.AgentSessionID != "agent-session-1" || message.MessageID != "message-1" ||
message.TurnID != "turn-1" || message.Role != "assistant" || message.Kind != "text" ||
message.Status != "completed" || message.CompletedAtUnixMS != 1710000000002 {
t.Fatalf("message = %#v", message)
}
if message.Seq != 1 || message.CallID != "" || message.Title != "" {
t.Fatalf("new session message leaked old top-level fields: %#v", message)
}
if message.Payload["text"] != "hello" {
t.Fatalf("payload = %#v", message.Payload)
}
}
func TestStoreBuildsSnapshotMessagesFromCanonicalSessionMessages(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplyActivity("room-1", EventSource{AgentID: "agent-session-1"}, nil, nil, []WorkspaceAgentMessageUpdate{{
AgentSessionID: "agent-session-1",
MessageID: "toolcall:call-1",
Seq: 2,
TurnID: "turn-1",
Role: "assistant",
Kind: "tool_call",
Status: "running",
CallID: "call-1",
Title: "Bash",
Payload: map[string]any{"toolName": "Bash", "input": map[string]any{"command": "pwd"}},
OccurredAtUnixMS: 1710000000002,
StartedAtUnixMS: 1710000000002,
CompletedAtUnixMS: 0,
}})
snapshot, ok := svc.GetAgentSnapshot("room-1")
if !ok {
t.Fatal("GetAgentSnapshot() ok = false, want true")
}
sessionMessages := snapshot.SessionMessagesByID["agent-session-1"]
if len(sessionMessages) != 1 {
t.Fatalf("SessionMessagesByID = %#v, want one message", snapshot.SessionMessagesByID)
}
message := sessionMessages[0]
if message.AgentSessionID != "agent-session-1" || message.MessageID != "toolcall:call-1" ||
message.Version != 1 || message.Status != "running" {
t.Fatalf("snapshot message = %#v", message)
}
if message.Payload["toolName"] != "Bash" || message.Payload["callId"] != "call-1" || message.Payload["title"] != "Bash" {
t.Fatalf("snapshot payload = %#v, want preserved legacy metadata in canonical payload", message.Payload)
}
}
func TestStoreReconcilesProviderOnlyMessageUpdates(t *testing.T) {
runtime := ProviderActivitySessionProjection{AgentSessionID: "runtime-1", Provider: "codex", ProviderSessionID: "provider-1", SessionOrigin: WorkspaceAgentSessionOriginRuntime}
tests := []struct {
name, provider, providerSessionID, messageID, messageSessionID, sourceOrigin, kind, status, wantBucket string
payload map[string]any
sessions []ProviderActivitySessionProjection
lateMetadata bool
forbidBuckets []string
}{
{name: "unique alias uses canonical runtime session", provider: "codex", providerSessionID: "provider-1", messageID: "approval-1", messageSessionID: "provider-1", sourceOrigin: WorkspaceAgentSessionOriginRuntime, kind: "tool_call", status: "waiting", payload: map[string]any{"toolName": "Bash"}, sessions: []ProviderActivitySessionProjection{runtime}, wantBucket: "runtime-1", forbidBuckets: []string{"provider-1"}},
{name: "late metadata migrates provider bucket", provider: "codex", providerSessionID: "provider-1", messageID: "approval-1", kind: "tool_call", status: "waiting", payload: map[string]any{"toolName": "Bash"}, sessions: []ProviderActivitySessionProjection{runtime}, lateMetadata: true, wantBucket: "runtime-1", forbidBuckets: []string{"provider-1"}},
{name: "same origin resolves unique alias", provider: "codex", providerSessionID: "provider-1", messageID: "runtime-message-1", messageSessionID: "provider-1", sourceOrigin: WorkspaceAgentSessionOriginRuntime, kind: "text", status: "completed", payload: map[string]any{"text": "runtime"}, sessions: []ProviderActivitySessionProjection{runtime}, wantBucket: "runtime-1", forbidBuckets: []string{"provider-1"}},
{name: "ambiguous candidates stay in provider bucket", provider: "codex", providerSessionID: "provider-1", messageID: "ambiguous-message-1", messageSessionID: "provider-1", kind: "text", status: "completed", payload: map[string]any{"text": "ambiguous"}, sessions: []ProviderActivitySessionProjection{runtime, {AgentSessionID: "runtime-2", Provider: "codex", ProviderSessionID: "provider-1", SessionOrigin: WorkspaceAgentSessionOriginRuntime}}, wantBucket: "provider-1", forbidBuckets: []string{"runtime-1", "runtime-2"}},
{name: "provider mismatch stays in provider bucket", provider: "claude", providerSessionID: "shared-provider-session", messageID: "claude-message-1", messageSessionID: "shared-provider-session", sourceOrigin: WorkspaceAgentSessionOriginRuntime, kind: "text", status: "completed", payload: map[string]any{"text": "claude"}, sessions: []ProviderActivitySessionProjection{{AgentSessionID: "codex-session-1", Provider: "codex", ProviderSessionID: "shared-provider-session", SessionOrigin: WorkspaceAgentSessionOriginRuntime}}, wantBucket: "shared-provider-session", forbidBuckets: []string{"codex-session-1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
if !tt.lateMetadata {
svc.updateState("room-1", WorkspaceAgentSnapshot{Sessions: tt.sessions})
}
svc.ApplyActivity("room-1", EventSource{Provider: tt.provider, ProviderSessionID: tt.providerSessionID, SessionOrigin: tt.sourceOrigin}, nil, nil, []WorkspaceAgentMessageUpdate{{
AgentSessionID: tt.messageSessionID, MessageID: tt.messageID, Role: "assistant", Kind: tt.kind, Status: tt.status, Payload: tt.payload, OccurredAtUnixMS: 1000,
}})
if tt.lateMetadata {
svc.updateState("room-1", WorkspaceAgentSnapshot{Sessions: tt.sessions})
}
snapshot, ok := svc.GetAgentSnapshot("room-1")
if !ok {
t.Fatal("GetAgentSnapshot() ok = false")
}
for _, bucket := range tt.forbidBuckets {
if messages, exists := snapshot.SessionMessagesByID[bucket]; exists {
t.Fatalf("bucket %q = %#v, want key absent", bucket, messages)
}
}
messages := snapshot.SessionMessagesByID[tt.wantBucket]
if len(messages) != 1 || messages[0].AgentSessionID != tt.wantBucket || messages[0].MessageID != tt.messageID ||
messages[0].Kind != tt.kind || messages[0].Status != tt.status || !reflect.DeepEqual(messages[0].Payload, tt.payload) {
t.Fatalf("messages = %#v, want %s in %s", snapshot.SessionMessagesByID, tt.messageID, tt.wantBucket)
}
})
}
}
func TestStoreIgnoresSnapshotTimelineDetailsWhenUpdatingState(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-session-1",
LifecycleStatus: "active",
EffectiveStatus: "working",
}},
SessionTimelineByID: map[string][]WorkspaceAgentTimelineItem{
"agent-session-1": {{
ID: 7,
AgentSessionID: "agent-session-1",
EventID: "timeline-1",
ItemType: "message.assistant",
}},
},
})
snapshot, ok := svc.GetAgentSnapshot("room-1")
if !ok {
t.Fatal("GetAgentSnapshot() ok = false, want true")
}
if len(snapshot.SessionTimelineByID) != 0 {
t.Fatalf("snapshot timeline = %#v, want snapshot timeline details ignored", snapshot.SessionTimelineByID)
}
if len(snapshot.Sessions) != 1 || snapshot.Sessions[0].AgentSessionID != "agent-session-1" {
t.Fatalf("snapshot sessions = %#v, want state preserved", snapshot.Sessions)
}
}
func TestStoreMergesToolMessageUpdatePayloadsForLocalReads(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplyActivity("room-1", EventSource{AgentID: "agent-session-1"}, nil, nil, []WorkspaceAgentMessageUpdate{{
AgentSessionID: "agent-session-1",
MessageID: "toolcall:call-1",
Seq: 2,
Role: "assistant",
Kind: "tool_call",
Status: "running",
CallID: "call-1",
Title: "Bash",
Payload: map[string]any{"name": "Bash", "toolName": "Bash", "input": map[string]any{"command": "pwd"}},
StartedAtUnixMS: 1710000000002,
OccurredAtUnixMS: 1710000000002,
}})
svc.ApplyActivity("room-1", EventSource{AgentID: "agent-session-1"}, nil, nil, []WorkspaceAgentMessageUpdate{{
AgentSessionID: "agent-session-1",
MessageID: "toolcall:call-1",
Seq: 3,
Role: "assistant",
Kind: "tool_call",
Status: "completed",
CallID: "call-1",
Title: "Bash",
Payload: map[string]any{"status": "completed", "output": map[string]any{"stdout": "/workspace\n"}},
CompletedAtUnixMS: 1710000000003,
OccurredAtUnixMS: 1710000000003,
}})
messages, ok := svc.GetAgentMessages("room-1", "agent-session-1")
if !ok || len(messages.Messages) != 1 {
t.Fatalf("messages = %#v, ok=%v, want one message", messages.Messages, ok)
}
message := messages.Messages[0]
if message.Seq != 1 || message.Status != "completed" || message.StartedAtUnixMS != 1710000000002 || message.CompletedAtUnixMS != 1710000000003 {
t.Fatalf("merged tool message metadata = %#v", message)
}
input, _ := message.Payload["input"].(map[string]any)
output, _ := message.Payload["output"].(map[string]any)
if input["command"] != "pwd" || output["stdout"] != "/workspace\n" || message.Payload["toolName"] != "Bash" {
t.Fatalf("merged tool payload = %#v, want input, output, and toolName", message.Payload)
}
}
func TestStoreKeepsLocalIdleAgainstPassiveSessionUpdateSync(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
Provider: "nexight",
ProviderSessionID: "nexight-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.markSessionIdle("room-1", "agent-1", 1000)
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
Provider: "nexight",
ProviderSessionID: "nexight-1",
LifecycleStatus: "active",
TurnPhase: "updated",
EffectiveStatus: "active",
UpdatedAtUnixMS: 1100,
}},
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if state.Sessions[0].EffectiveStatus != "idle" || state.Sessions[0].TurnPhase != "idle" {
t.Fatalf("session regressed to passive active update: %#v", state.Sessions[0])
}
if state.Sessions[0].UpdatedAtUnixMS != 1000 {
t.Fatalf("updated_at = %d, want local idle timestamp 1000", state.Sessions[0].UpdatedAtUnixMS)
}
}
func TestStoreAllowsNewerWorkingSyncAfterLocalIdle(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
Provider: "nexight",
ProviderSessionID: "nexight-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.markSessionIdle("room-1", "agent-1", 1000)
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
Provider: "nexight",
ProviderSessionID: "nexight-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 1100,
}},
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if state.Sessions[0].EffectiveStatus != "working" || state.Sessions[0].TurnPhase != "working" {
t.Fatalf("newer working sync was not applied: %#v", state.Sessions[0])
}
}
func TestStoreKeepsLocalTerminalAgainstStaleActiveSync(t *testing.T) {
tests := []struct {
name string
patch WorkspaceAgentStatePatch
wantLifecycleStatus string
wantEffectiveStatus string
wantEndedAtUnixMS int64
}{
{
name: "completed",
patch: WorkspaceAgentStatePatch{
AgentSessionID: "agent-1",
LifecycleStatus: string(activityshared.SessionLifecycleStatusEnded),
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1000,
},
wantLifecycleStatus: string(activityshared.SessionLifecycleStatusEnded),
wantEffectiveStatus: string(activityshared.SessionStatusCompleted),
wantEndedAtUnixMS: 1000,
},
{
name: "failed",
patch: WorkspaceAgentStatePatch{
AgentSessionID: "agent-1",
LifecycleStatus: string(activityshared.SessionLifecycleStatusFailed),
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1000,
},
wantLifecycleStatus: string(activityshared.SessionLifecycleStatusFailed),
wantEffectiveStatus: string(activityshared.SessionStatusFailed),
wantEndedAtUnixMS: 1000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.ApplyActivity("room-1", EventSource{}, nil, []WorkspaceAgentStatePatch{tt.patch}, nil)
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.LifecycleStatus != tt.wantLifecycleStatus ||
session.EffectiveStatus != tt.wantEffectiveStatus ||
session.TurnPhase != string(activityshared.TurnPhaseIdle) ||
session.EndedAtUnixMS != tt.wantEndedAtUnixMS ||
session.UpdatedAtUnixMS != 900 {
t.Fatalf("session = %#v, want terminal state with ended_at %d", session, tt.wantEndedAtUnixMS)
}
})
}
}
func TestStoreKeepsLocalFailedTurnAgainstStaleWorkingSync(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
Provider: "claude-code",
ProviderSessionID: "provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
LifecycleStatus: string(activityshared.SessionLifecycleStatusActive),
TurnPhase: string(activityshared.TurnPhaseWorking),
EffectiveStatus: string(activityshared.SessionStatusWorking),
StartedAtUnixMS: 1000,
UpdatedAtUnixMS: 1000,
}},
})
svc.ApplyActivity("room-1", EventSource{
Provider: "claude-code",
ProviderSessionID: "provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-1",
Provider: "claude-code",
ProviderSessionID: "provider-session",
CurrentPhase: string(activityshared.TurnPhaseFailed),
OccurredAtUnixMS: 1100,
Turn: &WorkspaceAgentTurnPatch{
TurnID: "turn-1",
Phase: string(activityshared.TurnPhaseFailed),
},
}}, nil)
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
Provider: "claude-code",
ProviderSessionID: "provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
LifecycleStatus: string(activityshared.SessionLifecycleStatusActive),
TurnPhase: string(activityshared.TurnPhaseWorking),
EffectiveStatus: string(activityshared.SessionStatusWorking),
StartedAtUnixMS: 1000,
UpdatedAtUnixMS: 1200,
}},
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.LifecycleStatus != string(activityshared.SessionLifecycleStatusActive) ||
session.EffectiveStatus != string(activityshared.SessionStatusFailed) ||
session.TurnPhase != string(activityshared.TurnPhaseFailed) ||
session.EndedAtUnixMS != 1100 ||
session.UpdatedAtUnixMS != 1100 {
t.Fatalf("session = %#v, want local failed turn to win over stale upstream working", session)
}
}
func TestStoreAllowsNewerSyncAfterLocalTerminal(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.ApplyActivity("room-1", EventSource{}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-1",
LifecycleStatus: string(activityshared.SessionLifecycleStatusEnded),
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1000,
}}, nil)
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 1100,
}},
})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if state.Sessions[0].EffectiveStatus != "working" || state.Sessions[0].TurnPhase != "working" || state.Sessions[0].EndedAtUnixMS != 0 {
t.Fatalf("newer sync was not applied: %#v", state.Sessions[0])
}
}
func TestStoreAppliesProviderOnlyStatePatchToExistingSession(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
Provider: "codex",
ProviderSessionID: "provider-1",
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.ApplyActivity("room-1", EventSource{Provider: "codex", ProviderSessionID: "provider-1"}, nil, []WorkspaceAgentStatePatch{{
Provider: "codex",
ProviderSessionID: "provider-1",
LifecycleStatus: string(activityshared.SessionLifecycleStatusEnded),
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1000,
}}, nil)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.AgentSessionID != "agent-1" {
t.Fatalf("agent session id = %q, want agent-1", session.AgentSessionID)
}
if session.ProviderSessionID != "provider-1" {
t.Fatalf("provider session id = %q, want provider-1", session.ProviderSessionID)
}
if session.LifecycleStatus != string(activityshared.SessionLifecycleStatusEnded) ||
session.EffectiveStatus != string(activityshared.SessionStatusCompleted) ||
session.TurnPhase != string(activityshared.TurnPhaseIdle) ||
session.EndedAtUnixMS != 1000 ||
session.UpdatedAtUnixMS != 900 {
t.Fatalf("session = %#v, want completed session without recency bump from provider-only patch", session)
}
}
func TestStoreKeepsProviderOnlyStatePatchForDifferentProvider(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "codex-session-1",
Provider: "codex",
ProviderSessionID: "shared-provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.ApplyActivity("room-1", EventSource{
Provider: "claude",
ProviderSessionID: "shared-provider-session",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "shared-provider-session",
Provider: "claude",
ProviderSessionID: "shared-provider-session",
LifecycleStatus: string(activityshared.SessionLifecycleStatusActive),
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1000,
}}, nil)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 2 {
t.Fatalf("state = %#v, ok=%v, want distinct provider sessions", state, ok)
}
var codexSession *ProviderActivitySessionProjection
var claudeSession *ProviderActivitySessionProjection
for i := range state.Sessions {
session := &state.Sessions[i]
switch session.AgentSessionID {
case "codex-session-1":
codexSession = session
case "shared-provider-session":
claudeSession = session
}
}
if codexSession == nil || codexSession.Provider != "codex" || codexSession.EffectiveStatus != "working" {
t.Fatalf("codex session = %#v, want untouched codex session", codexSession)
}
if claudeSession == nil || claudeSession.Provider != "claude" || claudeSession.ProviderSessionID != "shared-provider-session" {
t.Fatalf("claude session = %#v, want separate provider session", claudeSession)
}
}
func TestStoreDoesNotAdvanceUpdatedAtForPassiveIdleStatePatch(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "agent-1",
Provider: "codex",
ProviderSessionID: "provider-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
LifecycleStatus: string(activityshared.SessionLifecycleStatusActive),
TurnPhase: string(activityshared.TurnPhaseIdle),
EffectiveStatus: string(activityshared.SessionStatusIdle),
UpdatedAtUnixMS: 1000,
Title: "before",
}},
})
svc.ApplyActivity("room-1", EventSource{
Provider: "codex",
ProviderSessionID: "provider-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "agent-1",
Provider: "codex",
ProviderSessionID: "provider-1",
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1100,
Title: "after",
}})
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
session := state.Sessions[0]
if session.Title != "after" {
t.Fatalf("title = %q, want after", session.Title)
}
if session.UpdatedAtUnixMS != 1000 {
t.Fatalf("updated_at = %d, want existing timestamp 1000", session.UpdatedAtUnixMS)
}
if session.EffectiveStatus != string(activityshared.SessionStatusIdle) ||
session.TurnPhase != string(activityshared.TurnPhaseIdle) {
t.Fatalf("session = %#v, want idle status preserved", session)
}
}
func TestStoreAppliesProviderSessionAliasToRuntimeSession(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{{
AgentSessionID: "runtime-1",
Provider: "codex",
ProviderSessionID: "provider-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
}},
})
svc.ApplyActivity("room-1", EventSource{
Provider: "codex",
ProviderSessionID: "provider-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}, nil, []WorkspaceAgentStatePatch{{
AgentSessionID: "provider-1",
Provider: "codex",
ProviderSessionID: "provider-1",
LifecycleStatus: string(activityshared.SessionLifecycleStatusActive),
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1000,
}}, nil)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 1 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
runtimeSession := state.Sessions[0]
if runtimeSession.AgentSessionID != "runtime-1" ||
runtimeSession.SessionOrigin != WorkspaceAgentSessionOriginRuntime ||
runtimeSession.ProviderSessionID != "provider-1" ||
runtimeSession.EffectiveStatus != string(activityshared.SessionStatusIdle) {
t.Fatalf("runtime session = %#v, want provider alias patch applied to runtime session", runtimeSession)
}
}
func TestStoreKeepsProviderOnlyStatePatchAmbiguousForDuplicateRuntimeSessions(t *testing.T) {
svc := New(nil)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{
{
AgentSessionID: "runtime-1",
Provider: "codex",
ProviderSessionID: "provider-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 900,
},
{
AgentSessionID: "runtime-2",
Provider: "codex",
ProviderSessionID: "provider-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
LifecycleStatus: "active",
TurnPhase: "working",
EffectiveStatus: "working",
UpdatedAtUnixMS: 850,
},
},
})
svc.ApplyActivity("room-1", EventSource{
Provider: "codex",
ProviderSessionID: "provider-1",
SessionOrigin: WorkspaceAgentSessionOriginRuntime,
}, nil, []WorkspaceAgentStatePatch{{
Provider: "codex",
ProviderSessionID: "provider-1",
LifecycleStatus: string(activityshared.SessionLifecycleStatusEnded),
CurrentPhase: string(activityshared.TurnPhaseIdle),
OccurredAtUnixMS: 1000,
}}, nil)
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 2 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
var runtimeSession *ProviderActivitySessionProjection
var peerSession *ProviderActivitySessionProjection
for i := range state.Sessions {
session := &state.Sessions[i]
switch session.AgentSessionID {
case "runtime-1":
runtimeSession = session
case "runtime-2":
peerSession = session
}
}
if runtimeSession == nil || peerSession == nil {
t.Fatalf("sessions = %#v, want runtime sessions", state.Sessions)
}
if runtimeSession.EffectiveStatus != "working" || runtimeSession.SessionOrigin != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("runtime session = %#v, want untouched runtime session", *runtimeSession)
}
if peerSession.EffectiveStatus != "working" || peerSession.SessionOrigin != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("peer session = %#v, want untouched runtime session", *peerSession)
}
}
func TestStoreInterruptWorkspaceAgents(t *testing.T) {
t.Run("all-working filters non-working sessions", func(t *testing.T) {
client := &fakeInterruptReporter{}
svc := New(client)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{
interruptSession("agent-working", "codex-session", "working", "working"),
interruptSession("agent-ready", "claude-session", "working", "ready"),
},
})
if err := svc.InterruptWorkspaceAgents(context.Background(), " room-1 ", "workspace-leave"); err != nil {
t.Fatalf("InterruptWorkspaceAgents() error = %v", err)
}
if len(client.reportInputs) != 1 {
t.Fatalf("reported inputs = %#v, want one state report per patch", client.reportInputs)
}
report := client.reportInputs[0]
if report.WorkspaceID != "room-1" || len(report.TimelineItems) != 0 || len(report.StatePatches) != 1 {
t.Fatalf("report = %#v, want one state-only report for room-1", report)
}
patch := report.StatePatches[0]
if patch.AgentSessionID != "agent-working" || patch.Provider != "codex" || patch.ProviderSessionID != "codex-session" ||
patch.LifecycleStatus != string(activityshared.SessionStatusCompleted) ||
patch.CurrentPhase != string(activityshared.TurnPhaseIdle) || patch.OccurredAtUnixMS == 0 {
t.Fatalf("reported patch = %#v", patch)
}
state, ok := svc.GetAgentState("room-1")
if !ok || len(state.Sessions) != 2 {
t.Fatalf("state = %#v, ok=%v", state, ok)
}
if state.Sessions[0].LifecycleStatus != "ended" || state.Sessions[0].EffectiveStatus != "completed" || state.Sessions[0].TurnPhase != "idle" {
t.Fatalf("interrupted session state = %#v", state.Sessions[0])
}
if state.Sessions[1].EffectiveStatus != "ready" {
t.Fatalf("ready session was changed: %#v", state.Sessions[1])
}
})
t.Run("multiple runtime sessions fan out separately", func(t *testing.T) {
client := &fakeInterruptReporter{}
svc := New(client)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{
Sessions: []ProviderActivitySessionProjection{
interruptRuntimeSession("runtime-1", "shared-provider-session"),
interruptRuntimeSession("runtime-2", "shared-provider-session"),
},
})
if err := svc.InterruptWorkspaceAgents(context.Background(), "room-1", "user_interrupt"); err != nil {
t.Fatalf("InterruptWorkspaceAgents() error = %v", err)
}
if len(client.reportInputs) != 2 {
t.Fatalf("reported inputs = %#v, want one report per runtime session", client.reportInputs)
}
for index, input := range client.reportInputs {
wantID := []string{"runtime-1", "runtime-2"}[index]
if input.Source.SessionOrigin != WorkspaceAgentSessionOriginRuntime || len(input.StatePatches) != 1 || input.StatePatches[0].AgentSessionID != wantID {
t.Fatalf("report[%d] = %#v, want runtime report for %s", index, input, wantID)
}
if input.Source.Provider != "codex" || input.Source.ProviderSessionID != "shared-provider-session" {
t.Fatalf("report source = %#v, want provider identity preserved", input.Source)
}
}
})
}
func TestStoreInterruptWorkspaceAgentSessions(t *testing.T) {
tests := []struct {
name string
sessions []ProviderActivitySessionProjection
targets []string
wantReportIDs []string
wantProviderIDs []string
syntheticReportID string
wantUntouchedID string
wantUntouchedState string
}{
{
name: "explicit target leaves peer untouched",
sessions: []ProviderActivitySessionProjection{interruptSession("agent-local", "codex-local", "working", "working"), interruptSession("agent-other-user", "codex-other-user", "working", "working")},
targets: []string{"agent-local"}, wantReportIDs: []string{"agent-local"}, wantProviderIDs: []string{"codex-local"},
wantUntouchedID: "agent-other-user", wantUntouchedState: "working",
},
{
name: "explicit idle target closes locally",
sessions: []ProviderActivitySessionProjection{interruptSession("agent-local", "codex-local", "idle", "idle"), interruptSession("agent-other-user", "codex-other-user", "idle", "idle")},
targets: []string{"agent-local"}, wantReportIDs: []string{"agent-local"}, wantProviderIDs: []string{"codex-local"},
wantUntouchedID: "agent-other-user", wantUntouchedState: "idle",
},
{
name: "provider session id aliases runtime target",
sessions: []ProviderActivitySessionProjection{interruptSession("runtime-session", "provider-session", "idle", "idle")},
targets: []string{"provider-session"}, wantReportIDs: []string{"runtime-session"}, wantProviderIDs: []string{"provider-session"},
},
{
name: "single-session fallback folds unmatched targets",
sessions: []ProviderActivitySessionProjection{interruptSession("folded-session", "folded-provider-session", "idle", "idle")},
targets: []string{"runtime-session", "provider-session"}, wantReportIDs: []string{"folded-session"}, wantProviderIDs: []string{"folded-provider-session"},
},
{
name: "runtime-origin reports retain stable order",
sessions: []ProviderActivitySessionProjection{interruptRuntimeSession("peer-session", "provider-session"), interruptRuntimeSession("runtime-session", "provider-session")},
targets: []string{"peer-session", "runtime-session"}, wantReportIDs: []string{"peer-session", "runtime-session"}, wantProviderIDs: []string{"provider-session", "provider-session"},
},
{
name: "provider context adds missing runtime fallback",
sessions: []ProviderActivitySessionProjection{interruptRuntimeSession("peer-session", "provider-session")},
targets: []string{"runtime-session", "provider-session"}, wantReportIDs: []string{"peer-session", "runtime-session"}, wantProviderIDs: []string{"provider-session", "provider-session"}, syntheticReportID: "runtime-session",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := &fakeInterruptReporter{}
svc := New(client)
svc.TrackRoom("room-1")
svc.updateState("room-1", WorkspaceAgentSnapshot{Sessions: tt.sessions})
if err := svc.InterruptWorkspaceAgentSessions(context.Background(), "room-1", "workspace-leave", tt.targets); err != nil {
t.Fatalf("InterruptWorkspaceAgentSessions() error = %v", err)
}
if len(client.reportInputs) != len(tt.wantReportIDs) {
t.Fatalf("reported inputs = %#v, want ids %v", client.reportInputs, tt.wantReportIDs)
}
for i, wantID := range tt.wantReportIDs {
report := client.reportInputs[i]
if len(report.StatePatches) != 1 || report.StatePatches[0].AgentSessionID != wantID {
t.Fatalf("report[%d] = %#v, want %s", i, report, wantID)
}
patch := report.StatePatches[0]
if patch.Provider != "codex" || patch.ProviderSessionID != tt.wantProviderIDs[i] ||
report.Source.Provider != "codex" || report.Source.ProviderSessionID != tt.wantProviderIDs[i] {
t.Fatalf("report identity = %#v, want codex/%s", report, tt.wantProviderIDs[i])
}
if patch.LifecycleStatus != "completed" || patch.CurrentPhase != "idle" {
t.Fatalf("patch = %#v, want completed idle", patch)
}
if tt.sessions[0].SessionOrigin == WorkspaceAgentSessionOriginRuntime && report.Source.SessionOrigin != WorkspaceAgentSessionOriginRuntime {
t.Fatalf("report source = %#v, want runtime origin", report.Source)
}
}
state, ok := svc.GetAgentState("room-1")
if !ok {
t.Fatal("GetAgentState() ok = false")
}
if tt.wantUntouchedID != "" {
peer := findInterruptSession(state.Sessions, tt.wantUntouchedID)
if peer == nil || peer.EffectiveStatus != tt.wantUntouchedState || peer.LifecycleStatus != "active" {
t.Fatalf("untouched session = %#v, want %s", peer, tt.wantUntouchedState)
}
}
for _, wantID := range tt.wantReportIDs {
session := findInterruptSession(state.Sessions, wantID)
if session == nil {
if wantID != tt.syntheticReportID {
t.Fatalf("target session %q missing from state", wantID)
}
continue
}
if session.Status != "completed" || session.LifecycleStatus != "ended" || session.EffectiveStatus != "completed" || session.TurnPhase != "idle" {
t.Fatalf("target session = %#v, want completed", session)
}
}
})
}
}
func interruptSession(agentSessionID, providerSessionID, turnPhase, effectiveStatus string) ProviderActivitySessionProjection {
return ProviderActivitySessionProjection{
AgentSessionID: agentSessionID, Provider: "codex", ProviderSessionID: providerSessionID,
CWD: "/workspace/room-1", LifecycleStatus: "active", TurnPhase: turnPhase, EffectiveStatus: effectiveStatus,
}
}
func interruptRuntimeSession(agentSessionID, providerSessionID string) ProviderActivitySessionProjection {
session := interruptSession(agentSessionID, providerSessionID, "working", "working")
session.SessionOrigin = WorkspaceAgentSessionOriginRuntime
return session
}
func findInterruptSession(sessions []ProviderActivitySessionProjection, id string) *ProviderActivitySessionProjection {
for i := range sessions {
if sessions[i].AgentSessionID == id {
return &sessions[i]
}
}
return nil
}
type fakeInterruptReporter struct {
reportInputs []ReportActivityInput
}
func (*fakeInterruptReporter) ListAgents(context.Context, string) (*WorkspaceAgentSnapshot, error) {
return &WorkspaceAgentSnapshot{}, nil
}
func (*fakeInterruptReporter) ListSessionMessages(context.Context, ListSessionMessagesInput) (*ListSessionMessagesReply, error) {
return &ListSessionMessagesReply{}, nil
}
func (f *fakeInterruptReporter) ReportSessionState(_ context.Context, input ReportSessionStateInput) (ReportSessionStateReply, error) {
patch := WorkspaceAgentStatePatch{
AgentSessionID: input.AgentSessionID,
Provider: input.State.Provider,
ProviderSessionID: input.State.ProviderSessionID,
Model: input.State.Model,
CWD: input.State.CWD,
Title: input.State.Title,
LifecycleStatus: input.State.LifecycleStatus,
CurrentPhase: input.State.CurrentPhase,
OccurredAtUnixMS: input.State.OccurredAtUnixMS,
}
if input.State.Turn != nil {
patch.Turn = &WorkspaceAgentTurnPatch{
TurnID: input.State.Turn.TurnID,
Phase: input.State.Turn.Phase,
Outcome: input.State.Turn.Outcome,
FileChanges: clonePayloadMap(input.State.Turn.FileChanges),
StartedAtUnixMS: input.State.Turn.StartedAtUnixMS,
CompletedAtUnixMS: input.State.Turn.CompletedAtUnixMS,
}
}
f.reportInputs = append(f.reportInputs, ReportActivityInput{
WorkspaceID: input.WorkspaceID,
Source: input.Source,
StatePatches: []WorkspaceAgentStatePatch{patch},
})
return ReportSessionStateReply{
Accepted: true,
LastEventAtUnixMS: input.State.OccurredAtUnixMS,
}, nil
}
func (*fakeInterruptReporter) ReportSessionMessages(_ context.Context, input ReportSessionMessagesInput) (ReportSessionMessagesReply, error) {
return ReportSessionMessagesReply{AcceptedCount: len(input.Updates)}, nil
}
// Every live turn-lifecycle phase must map to blocked(active_turn), not nil.
// Returning nil for a live phase drops SubmitAvailability from the pushed state
// patch, so the GUI keeps a stale "available" and lets the user submit into an
// active turn (which the daemon then rejects with "already has an active turn").
func TestSubmitAvailabilityForTurnLifecyclePhaseCoversLivePhases(t *testing.T) {
blockedActive := &WorkspaceAgentSubmitAvailability{State: "blocked", Reason: "active_turn"}
blockedWaiting := &WorkspaceAgentSubmitAvailability{State: "blocked", Reason: "waiting"}
available := &WorkspaceAgentSubmitAvailability{State: "available"}
cases := []struct {
phase string
want *WorkspaceAgentSubmitAvailability
}{
{"settled", available},
{"submitted", blockedActive},
{"running", blockedActive},
{"working", blockedActive}, // regression: used to fall through to nil
{"streaming", blockedActive}, // regression: used to fall through to nil
{string(activityshared.TurnPhaseWaitingApproval), blockedWaiting},
{string(activityshared.TurnPhaseWaitingInput), blockedWaiting},
{"idle", nil},
{"", nil},
}
for _, tc := range cases {
got := submitAvailabilityForTurnLifecyclePhase(tc.phase)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("phase %q: got %#v, want %#v", tc.phase, got, tc.want)
}
}
}
func TestExplicitTurnLifecycleProjectionUsesMigratedProviderPolicy(t *testing.T) {
for _, provider := range []string{" CODEX ", "opencode"} {
if !providerUsesExplicitTurnLifecycleProjection(provider) {
t.Fatalf("migrated provider %q did not enable explicit turn lifecycle projection", provider)
}
}
if providerUsesExplicitTurnLifecycleProjection("unmigrated-provider") {
t.Fatal("unmigrated provider unexpectedly enabled explicit turn lifecycle projection")
}
}