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
1674 lines
60 KiB
Go
1674 lines
60 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
agentsessionstore "github.com/tutti-os/tutti/packages/agent/daemon/activity"
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
"github.com/tutti-os/tutti/packages/agent/store-sqlite/canonical"
|
|
)
|
|
|
|
type retryingActivityClient struct {
|
|
failuresBeforeSuccess int
|
|
calls int
|
|
inputs []agentsessionstore.ReportActivityInput
|
|
stateInputs []canonical.ReportSessionStateInput
|
|
messageInputs []canonical.ReportSessionMessagesInput
|
|
reply *agentsessionstore.ReportActivityReply
|
|
}
|
|
|
|
func (c *retryingActivityClient) ReportActivity(
|
|
_ context.Context,
|
|
input agentsessionstore.ReportActivityInput,
|
|
) (agentsessionstore.ReportActivityReply, error) {
|
|
c.calls++
|
|
c.inputs = append(c.inputs, input)
|
|
if c.calls <= c.failuresBeforeSuccess {
|
|
return agentsessionstore.ReportActivityReply{}, errors.New("temporary report failure")
|
|
}
|
|
if c.reply != nil {
|
|
return *c.reply, nil
|
|
}
|
|
return acceptedReportActivityReply(input), nil
|
|
}
|
|
|
|
func (c *retryingActivityClient) ReportSessionState(
|
|
_ context.Context,
|
|
input canonical.ReportSessionStateInput,
|
|
) (canonical.ReportSessionStateReply, error) {
|
|
c.calls++
|
|
c.stateInputs = append(c.stateInputs, input)
|
|
if c.calls <= c.failuresBeforeSuccess {
|
|
return canonical.ReportSessionStateReply{}, errors.New("temporary report failure")
|
|
}
|
|
if c.reply != nil && c.reply.AcceptedStatePatchCount == 0 {
|
|
return canonical.ReportSessionStateReply{Accepted: false}, nil
|
|
}
|
|
return canonical.ReportSessionStateReply{Accepted: true, LastEventAtUnixMS: input.State.OccurredAtUnixMS}, nil
|
|
}
|
|
|
|
func (c *retryingActivityClient) ReportSessionMessages(
|
|
_ context.Context,
|
|
input canonical.ReportSessionMessagesInput,
|
|
) (canonical.ReportSessionMessagesReply, error) {
|
|
c.calls++
|
|
c.messageInputs = append(c.messageInputs, input)
|
|
if c.calls <= c.failuresBeforeSuccess {
|
|
return canonical.ReportSessionMessagesReply{}, errors.New("temporary report failure")
|
|
}
|
|
if c.reply != nil {
|
|
return canonical.ReportSessionMessagesReply{AcceptedCount: c.reply.AcceptedMessageUpdateCount}, nil
|
|
}
|
|
return canonical.ReportSessionMessagesReply{AcceptedCount: len(input.Updates)}, nil
|
|
}
|
|
|
|
func acceptedReportActivityReply(input agentsessionstore.ReportActivityInput) agentsessionstore.ReportActivityReply {
|
|
return agentsessionstore.ReportActivityReply{
|
|
AcceptedTimelineItemCount: len(input.TimelineItems),
|
|
AcceptedStatePatchCount: len(input.StatePatches),
|
|
AcceptedMessageUpdateCount: len(input.MessageUpdates),
|
|
}
|
|
}
|
|
|
|
func TestReporterRetriesActivityReportAndPreservesRuntimeOrigin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := &retryingActivityClient{failuresBeforeSuccess: 2}
|
|
var logBuffer bytes.Buffer
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
Logger: slog.New(slog.NewTextHandler(&logBuffer, nil)),
|
|
MaxAttempts: 3,
|
|
Backoff: []time.Duration{0, 0},
|
|
}
|
|
|
|
err := reporter.Report(context.Background(), reportActivityInput(reportTestSession(), []activityshared.Event{
|
|
newSessionActivityEvent(reportTestSession(), EventSessionStarted, SessionStatusReady, nil),
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Report: %v", err)
|
|
}
|
|
if client.calls != 3 {
|
|
t.Fatalf("calls = %d, want 3", client.calls)
|
|
}
|
|
if got := client.stateInputs[len(client.stateInputs)-1].Source.SessionOrigin; got != WorkspaceAgentSessionOriginRuntime {
|
|
t.Fatalf("session origin = %q, want %q", got, WorkspaceAgentSessionOriginRuntime)
|
|
}
|
|
logs := logBuffer.String()
|
|
if !strings.Contains(logs, "agent_session.activity_report.retry") {
|
|
t.Fatalf("logs did not contain retry event: %s", logs)
|
|
}
|
|
if !strings.Contains(logs, "agent_session.activity_report.succeeded_after_retry") {
|
|
t.Fatalf("logs did not contain retry success event: %s", logs)
|
|
}
|
|
if !strings.Contains(logs, "agent session activity report succeeded") {
|
|
t.Fatalf("logs did not contain success event: %s", logs)
|
|
}
|
|
}
|
|
|
|
func TestReporterSuppressesRoutineSuccessLogsAtInfoLevel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := &retryingActivityClient{}
|
|
var logBuffer bytes.Buffer
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
Logger: slog.New(slog.NewTextHandler(&logBuffer, nil)),
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
err := reporter.Report(context.Background(), reportActivityInput(reportTestSession(), []activityshared.Event{
|
|
newSessionActivityEvent(reportTestSession(), EventSessionStarted, SessionStatusReady, nil),
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Report: %v", err)
|
|
}
|
|
if client.calls != 1 {
|
|
t.Fatalf("calls = %d, want 1", client.calls)
|
|
}
|
|
logs := logBuffer.String()
|
|
if strings.Contains(logs, "agent_session.activity_report.prepared") ||
|
|
strings.Contains(logs, "agent_session.activity_report.succeeded") {
|
|
t.Fatalf("routine success logs should be debug-only: %s", logs)
|
|
}
|
|
}
|
|
|
|
func TestReporterReturnsErrorAfterRetriesExhausted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := &retryingActivityClient{failuresBeforeSuccess: 10}
|
|
var logBuffer bytes.Buffer
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
Logger: slog.New(slog.NewTextHandler(&logBuffer, nil)),
|
|
MaxAttempts: 3,
|
|
Backoff: []time.Duration{0, 0},
|
|
}
|
|
|
|
err := reporter.Report(context.Background(), reportActivityInput(reportTestSession(), []activityshared.Event{
|
|
newSessionActivityEvent(reportTestSession(), EventSessionStarted, SessionStatusReady, nil),
|
|
}))
|
|
if err == nil {
|
|
t.Fatal("Report returned nil error after retries were exhausted")
|
|
}
|
|
if client.calls != 3 {
|
|
t.Fatalf("calls = %d, want 3", client.calls)
|
|
}
|
|
if logs := logBuffer.String(); !strings.Contains(logs, "agent_session.activity_report.failed") {
|
|
t.Fatalf("logs did not contain final failure event: %s", logs)
|
|
}
|
|
}
|
|
|
|
func TestTimelineItemPreservesStructuredUserContentMetadata(t *testing.T) {
|
|
structuredContent := []map[string]any{
|
|
{"type": "text", "text": "look"},
|
|
{"type": "image", "mimeType": "image/png", "attachmentId": "attachment-1"},
|
|
}
|
|
item, _, ok := timelineItemFromSessionEvent(
|
|
"workspace-1",
|
|
canonical.EventSource{Provider: "codex"},
|
|
activityshared.Event{
|
|
EventID: "event-1",
|
|
Type: activityshared.EventMessageCreated,
|
|
Provider: activityshared.ProviderCodex,
|
|
Payload: activityshared.EventPayload{
|
|
Role: activityshared.MessageRoleUser,
|
|
Content: "look",
|
|
Metadata: map[string]any{
|
|
"content": structuredContent,
|
|
},
|
|
},
|
|
},
|
|
"session-1",
|
|
123,
|
|
)
|
|
if !ok {
|
|
t.Fatal("timelineItemFromSessionEvent returned !ok")
|
|
}
|
|
if got := item.Payload["content"]; got == nil {
|
|
t.Fatalf("payload content missing: %#v", item.Payload)
|
|
} else if _, ok := got.([]map[string]any); !ok {
|
|
t.Fatalf("payload content = %#v, want structured content blocks", got)
|
|
}
|
|
if got := item.Payload["text"]; got != "look" {
|
|
t.Fatalf("payload text = %#v, want display text", got)
|
|
}
|
|
}
|
|
|
|
func TestReporterSendsMessageOnlyReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := &retryingActivityClient{}
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
err := reporter.Report(context.Background(), agentsessionstore.ReportActivityInput{
|
|
WorkspaceID: "room-1",
|
|
Source: canonical.EventSource{
|
|
Provider: "codex",
|
|
AgentID: "agent-session-1",
|
|
},
|
|
MessageUpdates: []agentsessionstore.WorkspaceAgentMessageUpdate{{
|
|
AgentSessionID: "agent-session-1",
|
|
MessageID: "message-1",
|
|
TurnID: "turn-1",
|
|
Seq: 1,
|
|
Role: "assistant",
|
|
Kind: "text",
|
|
Payload: map[string]any{"text": "hello"},
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Report: %v", err)
|
|
}
|
|
if client.calls != 1 {
|
|
t.Fatalf("calls = %d, want 1", client.calls)
|
|
}
|
|
if got := len(client.messageInputs[0].Updates); got != 1 {
|
|
t.Fatalf("message updates = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestReporterReturnsErrorWhenMessageUpdatesPartiallyAccepted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := &retryingActivityClient{
|
|
reply: &agentsessionstore.ReportActivityReply{
|
|
AcceptedTimelineItemCount: 1,
|
|
AcceptedStatePatchCount: 1,
|
|
AcceptedMessageUpdateCount: 0,
|
|
},
|
|
}
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
err := reporter.Report(context.Background(), mixedMessageUpdateReportInput())
|
|
if err == nil {
|
|
t.Fatal("Report returned nil error for partially accepted message updates")
|
|
}
|
|
if !strings.Contains(err.Error(), "message updates") {
|
|
t.Fatalf("error = %q, want mention message updates", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestReporterSucceedsWhenMixedReportCountsAccepted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := mixedMessageUpdateReportInput()
|
|
client := &retryingActivityClient{
|
|
reply: ptrReportActivityReply(acceptedReportActivityReply(input)),
|
|
}
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
if err := reporter.Report(context.Background(), input); err != nil {
|
|
t.Fatalf("Report: %v", err)
|
|
}
|
|
if client.calls != 2 {
|
|
t.Fatalf("calls = %d, want state and messages reports", client.calls)
|
|
}
|
|
}
|
|
|
|
func TestReporterProjectsMessagesAndCallsToSessionMessageUpdatesOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
client := &retryingActivityClient{}
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{
|
|
newTurnActivityEventWithID(session, "message-user-1", EventMessage, "turn-1", messageStreamStateCompleted, RoleUser, "inspect repo", nil),
|
|
newTurnActivityEventWithID(session, "call-1-item", EventCallStarted, "turn-1", messageStreamStateStreaming, "", "Read", map[string]any{
|
|
"callId": "call-1",
|
|
"callType": "tool",
|
|
"name": "Read",
|
|
"toolName": "Read",
|
|
"input": map[string]any{"path": "README.md"},
|
|
}),
|
|
newTurnActivityEventWithID(session, "call-1-item", EventCallCompleted, "turn-1", messageStreamStateCompleted, "", "Read", map[string]any{
|
|
"callId": "call-1",
|
|
"callType": "tool",
|
|
"name": "Read",
|
|
"toolName": "Read",
|
|
"output": map[string]any{"text": "hello"},
|
|
}),
|
|
})
|
|
err := reporter.Report(context.Background(), report)
|
|
if err != nil {
|
|
t.Fatalf("Report: %v", err)
|
|
}
|
|
if client.calls != 1 {
|
|
t.Fatalf("calls = %d, want one session message report", client.calls)
|
|
}
|
|
if len(report.TimelineItems) != 0 {
|
|
t.Fatalf("timeline items = %#v, want no legacy timeline output", report.TimelineItems)
|
|
}
|
|
if len(report.MessageUpdates) != 3 {
|
|
t.Fatalf("message updates = %#v, want user message and call lifecycle updates", report.MessageUpdates)
|
|
}
|
|
if got := countEntityPatches(report.StatePatches); got != 0 {
|
|
t.Fatalf("entity patches = %#v, count = %d, want no legacy timeline-derived entity patches", report.StatePatches, got)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputProjectsRuntimeMessagesToMessageUpdates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
userEvent := newTurnActivityEventWithID(session, "user-event-1", EventMessage, "turn-1", messageStreamStateCompleted, RoleUser, "inspect repo", map[string]any{
|
|
"clientSubmitId": "submit-1",
|
|
"messageId": "client-submit:user:submit-1",
|
|
})
|
|
userEvent.OccurredAtUnixMS = 101
|
|
assistantEvent := newTurnActivityEventWithID(session, "assistant-event-1", EventMessage, "turn-1", messageStreamStateCompleted, RoleAssistant, "found README", map[string]any{
|
|
"streamState": messageStreamStateCompleted,
|
|
"adapterExtra": "not-forwarded",
|
|
})
|
|
assistantEvent.OccurredAtUnixMS = 102
|
|
thinkingEvent := newTurnActivityEventWithID(session, "thinking-event-1", EventMessage, "turn-1", messageStreamStateCompleted, RoleAssistantThinking, "checking files", map[string]any{
|
|
"messageId": "thinking-message-1",
|
|
"streamState": messageStreamStateCompleted,
|
|
})
|
|
thinkingEvent.OccurredAtUnixMS = 103
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{userEvent, assistantEvent, thinkingEvent})
|
|
|
|
if len(report.MessageUpdates) != 3 {
|
|
t.Fatalf("message updates = %#v, want user, assistant, and reasoning updates", report.MessageUpdates)
|
|
}
|
|
user := report.MessageUpdates[0]
|
|
if user.AgentSessionID != session.AgentSessionID ||
|
|
user.MessageID != "client-submit:user:submit-1" ||
|
|
user.Seq != uint64(userEvent.OccurredAtUnixMS) ||
|
|
user.TurnID != "turn-1" ||
|
|
user.Role != "user" ||
|
|
user.Kind != "text" ||
|
|
user.OccurredAtUnixMS != userEvent.OccurredAtUnixMS ||
|
|
user.Payload["content"] != "inspect repo" ||
|
|
user.Payload["source"] != "runtime" ||
|
|
user.Payload["clientSubmitId"] != "submit-1" {
|
|
t.Fatalf("user message update = %#v", user)
|
|
}
|
|
if user.Semantics == nil || user.Semantics.UserVisibleAssistantResponse {
|
|
t.Fatalf("user message semantics = %#v, want explicit userVisibleAssistantResponse=false", user.Semantics)
|
|
}
|
|
assistant := report.MessageUpdates[1]
|
|
if assistant.MessageID != "assistant-event-1" ||
|
|
assistant.Seq != uint64(assistantEvent.OccurredAtUnixMS) ||
|
|
assistant.Role != "assistant" ||
|
|
assistant.Kind != "text" ||
|
|
assistant.Payload["content"] != "found README" ||
|
|
assistant.Payload["source"] != "runtime" {
|
|
t.Fatalf("assistant message update = %#v", assistant)
|
|
}
|
|
if assistant.Semantics == nil || !assistant.Semantics.UserVisibleAssistantResponse {
|
|
t.Fatalf("assistant message semantics = %#v, want userVisibleAssistantResponse=true", assistant.Semantics)
|
|
}
|
|
if _, ok := assistant.Payload["adapterExtra"]; ok {
|
|
t.Fatalf("assistant message update payload = %#v, want compact UI payload", assistant.Payload)
|
|
}
|
|
thinking := report.MessageUpdates[2]
|
|
if thinking.MessageID != "thinking-message-1" ||
|
|
thinking.Seq != uint64(thinkingEvent.OccurredAtUnixMS) ||
|
|
thinking.Role != "assistant" ||
|
|
thinking.Kind != "reasoning" ||
|
|
thinking.Payload["content"] != "checking files" ||
|
|
thinking.Payload["source"] != "runtime" {
|
|
t.Fatalf("thinking message update = %#v", thinking)
|
|
}
|
|
if thinking.Semantics == nil || thinking.Semantics.UserVisibleAssistantResponse {
|
|
t.Fatalf("thinking message semantics = %#v, want explicit userVisibleAssistantResponse=false", thinking.Semantics)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputProjectsTuttiAgentRuntimeMessages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
session.Provider = ProviderTuttiAgent
|
|
userEvent := newTurnActivityEventWithID(session, "user-event-1", EventMessage, "turn-1", messageStreamStateCompleted, RoleUser, "inspect repo", nil)
|
|
assistantEvent := newTurnActivityEventWithID(session, "assistant-event-1", EventMessage, "turn-1", messageStreamStateCompleted, RoleAssistant, "found README", nil)
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{userEvent, assistantEvent})
|
|
if len(report.MessageUpdates) != 2 {
|
|
t.Fatalf("message updates = %#v, want user and assistant updates", report.MessageUpdates)
|
|
}
|
|
if got := report.MessageUpdates[0].AgentSessionID; got != session.AgentSessionID {
|
|
t.Fatalf("message agent session id = %q, want %q", got, session.AgentSessionID)
|
|
}
|
|
if got := report.MessageUpdates[1].Payload["text"]; got != "found README" {
|
|
t.Fatalf("assistant text payload = %#v, want final text", got)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputProjectsDisplayPromptAndRichContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
userEvent := newTurnActivityEventWithID(session, "user-event-1", EventMessage, "turn-1", messageStreamStateCompleted, RoleUser, "Run Automation", map[string]any{
|
|
"messageId": "user-message-1",
|
|
"displayPrompt": "Run Automation",
|
|
"content": []any{map[string]any{
|
|
"type": "text",
|
|
"text": "real automation prompt",
|
|
}, map[string]any{
|
|
"type": "image",
|
|
"mimeType": "image/png",
|
|
"attachmentId": "attachment-1",
|
|
"data": "base64-image-bytes",
|
|
}},
|
|
})
|
|
userEvent.OccurredAtUnixMS = 101
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{userEvent})
|
|
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want one user update", report.MessageUpdates)
|
|
}
|
|
user := report.MessageUpdates[0]
|
|
if user.Payload["text"] != "Run Automation" ||
|
|
user.Payload["displayPrompt"] != "Run Automation" {
|
|
t.Fatalf("user message payload = %#v, want text and displayPrompt", user.Payload)
|
|
}
|
|
content := payloadArray(user.Payload["content"])
|
|
if len(content) != 2 {
|
|
t.Fatalf("user message content = %#v, want rich content blocks", user.Payload["content"])
|
|
}
|
|
imageBlock := payloadObject(content[1])
|
|
if _, exists := imageBlock["data"]; exists {
|
|
t.Fatalf("user message image block retained data bytes: %#v", imageBlock)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputForwardsMessageKindToPayload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
planEvent := newTurnActivityEventWithID(session, "plan-event-1", EventMessage, "turn-1", messageStreamStateCompleted, RoleAssistant, "# Plan\n1. inspect", map[string]any{
|
|
"messageId": "plan-message-1",
|
|
"streamState": messageStreamStateCompleted,
|
|
"messageKind": "plan",
|
|
})
|
|
planEvent.OccurredAtUnixMS = 110
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{planEvent})
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want one plan update", report.MessageUpdates)
|
|
}
|
|
plan := report.MessageUpdates[0]
|
|
if plan.Payload["messageKind"] != "plan" {
|
|
t.Fatalf("plan message payload = %#v, want messageKind=plan forwarded to the GUI", plan.Payload)
|
|
}
|
|
if plan.Semantics == nil || !plan.Semantics.UserVisibleAssistantResponse {
|
|
t.Fatalf("plan message semantics = %#v, want userVisibleAssistantResponse=true", plan.Semantics)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputDoesNotProjectLegacySubAgentMarkers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
marker := newTurnActivityEventWithID(session, "subagent-lifecycle:child-1:turn-1", EventMessage, "turn-1", "canceled", RoleAssistant, "", map[string]any{
|
|
"messageId": "subagent-lifecycle:child-1:turn-1",
|
|
"contentMode": messageContentModeSnapshot,
|
|
"streamState": "canceled",
|
|
"messageKind": "subAgentLifecycle",
|
|
"subAgentLifecycleStatus": "canceled",
|
|
"subAgentName": "Repo smell analyst",
|
|
"detail": "user requested",
|
|
"ownerThreadId": "child-1",
|
|
})
|
|
marker.OccurredAtUnixMS = 120
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{marker})
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want one marker update", report.MessageUpdates)
|
|
}
|
|
payload := report.MessageUpdates[0].Payload
|
|
if payload["subAgentLifecycleStatus"] != nil || payload["subAgentName"] != nil || payload["ownerThreadId"] != nil {
|
|
t.Fatalf("marker payload = %#v, want legacy child projection fields discarded", payload)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputForwardsSystemNoticeMetadataToPayload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
noticeEvent := newTurnActivityEventWithID(session, "notice-event-1", EventMessage, "turn-1", messageStreamStateCompleted, RoleAssistant, "Codex warning", map[string]any{
|
|
"messageId": "notice-message-1",
|
|
"kind": "agent_system_notice",
|
|
"noticeKind": "warning",
|
|
"severity": "warning",
|
|
"title": "Codex warning",
|
|
"detail": "Skill descriptions were shortened to fit the 2% skills context budget.",
|
|
"additionalDetails": "Disable unused skills or plugins to leave more room for the rest.",
|
|
"code": "CODEX_VERSION_TOO_OLD",
|
|
"retryable": false,
|
|
"streamState": messageStreamStateCompleted,
|
|
})
|
|
noticeEvent.OccurredAtUnixMS = 111
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{noticeEvent})
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want one notice update", report.MessageUpdates)
|
|
}
|
|
notice := report.MessageUpdates[0]
|
|
if notice.Payload["kind"] != "agent_system_notice" ||
|
|
notice.Payload["noticeKind"] != "warning" ||
|
|
notice.Payload["severity"] != "warning" ||
|
|
notice.Payload["title"] != "Codex warning" ||
|
|
notice.Payload["detail"] != "Skill descriptions were shortened to fit the 2% skills context budget." ||
|
|
notice.Payload["additionalDetails"] != "Disable unused skills or plugins to leave more room for the rest." ||
|
|
notice.Payload["code"] != "CODEX_VERSION_TOO_OLD" ||
|
|
notice.Payload["retryable"] != false {
|
|
t.Fatalf("notice message payload = %#v, want system notice metadata forwarded to the GUI", notice.Payload)
|
|
}
|
|
if notice.Semantics == nil || notice.Semantics.UserVisibleAssistantResponse {
|
|
t.Fatalf("notice message semantics = %#v, want explicit userVisibleAssistantResponse=false", notice.Semantics)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputProjectsRuntimeCallsToStableMessageUpdates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
started := newTurnActivityEventWithID(session, "call-event-start", EventCallStarted, "turn-1", messageStreamStateStreaming, "", "Read", map[string]any{
|
|
"callId": "call-1",
|
|
"callType": "tool",
|
|
"name": "Read",
|
|
"toolName": "Read",
|
|
"input": map[string]any{"path": "README.md"},
|
|
})
|
|
started.OccurredAtUnixMS = 201
|
|
completed := newTurnActivityEventWithID(session, "call-event-complete", EventCallCompleted, "turn-1", messageStreamStateCompleted, "", "Read", map[string]any{
|
|
"callId": "call-1",
|
|
"callType": "tool",
|
|
"name": "Read",
|
|
"toolName": "Read",
|
|
"output": map[string]any{"text": "hello"},
|
|
})
|
|
completed.OccurredAtUnixMS = 202
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{started, completed})
|
|
|
|
if len(report.MessageUpdates) != 2 {
|
|
t.Fatalf("message updates = %#v, want start and completion updates", report.MessageUpdates)
|
|
}
|
|
startUpdate := report.MessageUpdates[0]
|
|
if startUpdate.MessageID != "toolcall:call-1" ||
|
|
startUpdate.Seq != uint64(started.OccurredAtUnixMS) ||
|
|
startUpdate.TurnID != "turn-1" ||
|
|
startUpdate.Role != "assistant" ||
|
|
startUpdate.Kind != "tool_call" ||
|
|
startUpdate.Status != "running" ||
|
|
startUpdate.CallID != "call-1" ||
|
|
startUpdate.Title != "Read" ||
|
|
startUpdate.StartedAtUnixMS != started.OccurredAtUnixMS ||
|
|
startUpdate.CompletedAtUnixMS != 0 ||
|
|
startUpdate.Payload["source"] != "runtime" ||
|
|
startUpdate.Payload["name"] != "Read" ||
|
|
startUpdate.Payload["input"].(map[string]any)["path"] != "README.md" {
|
|
t.Fatalf("started call update = %#v", startUpdate)
|
|
}
|
|
completeUpdate := report.MessageUpdates[1]
|
|
if completeUpdate.MessageID != "toolcall:call-1" ||
|
|
completeUpdate.Seq != uint64(completed.OccurredAtUnixMS) ||
|
|
completeUpdate.Role != "assistant" ||
|
|
completeUpdate.Kind != "tool_call" ||
|
|
completeUpdate.Status != "completed" ||
|
|
completeUpdate.CallID != "call-1" ||
|
|
completeUpdate.Title != "Read" ||
|
|
completeUpdate.StartedAtUnixMS != 0 ||
|
|
completeUpdate.CompletedAtUnixMS != completed.OccurredAtUnixMS ||
|
|
completeUpdate.Payload["source"] != "runtime" ||
|
|
completeUpdate.Payload["name"] != "Read" ||
|
|
completeUpdate.Payload["output"].(map[string]any)["text"] != "hello" {
|
|
t.Fatalf("completed call update = %#v", completeUpdate)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputPersistsCanonicalTurnFileChanges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
session.Provider = ProviderCursor
|
|
fileChanges := map[string]any{
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "/workspace/obsolete.txt",
|
|
"change": "deleted",
|
|
"oldString": "obsolete\n",
|
|
"newString": "",
|
|
},
|
|
},
|
|
}
|
|
completed := newTurnActivityEventWithID(session, "call-event-delete", EventCallCompleted, "turn-delete", messageStreamStateCompleted, "", "Write", map[string]any{
|
|
"callId": "delete-1",
|
|
"callType": "tool",
|
|
"name": "Write",
|
|
"toolName": "Write",
|
|
"fileChanges": fileChanges,
|
|
"output": map[string]any{
|
|
"filePath": "/workspace/obsolete.txt",
|
|
"oldString": "obsolete\n",
|
|
},
|
|
})
|
|
completed.OccurredAtUnixMS = 301
|
|
turnUpdated := newTurnActivityEvent(session, EventTurnUpdated, "turn-delete", SessionStatusWorking, "", "", map[string]any{
|
|
"fileChanges": fileChanges,
|
|
})
|
|
turnUpdated.OccurredAtUnixMS = 302
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{completed, turnUpdated})
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want one completed tool update", report.MessageUpdates)
|
|
}
|
|
payload := report.MessageUpdates[0].Payload
|
|
files := payloadArray(payloadMap(payload, "fileChanges")["files"])
|
|
if len(files) != 1 || files[0]["change"] != "deleted" {
|
|
t.Fatalf("tool payload = %#v, want canonical deleted file changes", payload)
|
|
}
|
|
if len(report.StatePatches) != 1 || report.StatePatches[0].Turn == nil {
|
|
t.Fatalf("state patches = %#v, want turn file-change patch", report.StatePatches)
|
|
}
|
|
turnFiles := payloadArray(report.StatePatches[0].Turn.FileChanges["files"])
|
|
if len(turnFiles) != 1 || turnFiles[0]["change"] != "deleted" {
|
|
t.Fatalf("turn patch = %#v, want canonical deleted file changes", report.StatePatches[0].Turn)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputProjectsOnlyExplicitInteractionTransitions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
pending := &pendingInteractiveRequest{
|
|
requestID: "request-1",
|
|
turnID: "turn-1",
|
|
callID: "call-1",
|
|
callType: "interactive",
|
|
kind: "ask-user",
|
|
toolName: "AskUserQuestion",
|
|
input: map[string]any{
|
|
"questions": []any{map[string]any{"id": "scope", "question": "Scope?"}},
|
|
},
|
|
}
|
|
callStarted := newTurnActivityEventWithID(session, "call-1", EventCallStarted, "turn-1", "waiting_input", "", "AskUserQuestion", map[string]any{
|
|
"callId": "call-1", "callType": "interactive", "toolName": "AskUserQuestion",
|
|
"input": pending.input,
|
|
})
|
|
|
|
withoutTransition := reportActivityInput(session, []activityshared.Event{callStarted})
|
|
for _, patch := range withoutTransition.StatePatches {
|
|
if patch.InteractionTransition != nil {
|
|
t.Fatalf("call.started derived interaction transition = %#v", patch.InteractionTransition)
|
|
}
|
|
}
|
|
|
|
requested := normalizedInteractionRequestedEvent(session, "turn-1", pending)
|
|
report := reportActivityInput(session, []activityshared.Event{callStarted, requested})
|
|
if len(report.StatePatches) != 1 || report.StatePatches[0].InteractionTransition == nil {
|
|
t.Fatalf("state patches = %#v, want one explicit interaction transition", report.StatePatches)
|
|
}
|
|
transition := report.StatePatches[0].InteractionTransition
|
|
if transition.RequestID != "request-1" || transition.TurnID != "turn-1" ||
|
|
transition.Kind != "question" || transition.Status != "pending" ||
|
|
transition.ToolName != "AskUserQuestion" {
|
|
t.Fatalf("interaction transition = %#v", transition)
|
|
}
|
|
turn := report.StatePatches[0].Turn
|
|
if turn == nil || turn.TurnID != "turn-1" || turn.Phase != "waiting" || turn.Origin != "provider_initiated" || turn.ActiveTurnID == nil || *turn.ActiveTurnID != "turn-1" {
|
|
t.Fatalf("provider-initiated turn = %#v, want explicit atomic turn creation", turn)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputPreservesToolInputFromTerminalMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
completed := newTurnActivityEventWithID(session, "call-event-complete", EventCallCompleted, "turn-1", messageStreamStateCompleted, "", "Search web", map[string]any{
|
|
"callId": "call-1",
|
|
"callType": "tool",
|
|
"name": "Search web",
|
|
"toolName": "WebSearch",
|
|
"input": map[string]any{
|
|
"query": "opencode architecture",
|
|
},
|
|
"status": "completed",
|
|
})
|
|
completed.OccurredAtUnixMS = 202
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{completed})
|
|
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want one", report.MessageUpdates)
|
|
}
|
|
if semantics := report.MessageUpdates[0].Semantics; semantics == nil || semantics.UserVisibleAssistantResponse {
|
|
t.Fatalf("tool message semantics = %#v, want explicit userVisibleAssistantResponse=false", semantics)
|
|
}
|
|
update := report.MessageUpdates[0]
|
|
input, _ := update.Payload["input"].(map[string]any)
|
|
if update.Title != "WebSearch" || update.Payload["toolName"] != "WebSearch" || input["query"] != "opencode architecture" {
|
|
t.Fatalf("terminal tool message update = %#v, want metadata input and tool name", update)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputDoesNotUseCallIDAsMessageUpdateTitle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
callID := "call_SMAI3q45S9s5TwOqO8R7ZMdU"
|
|
started := newTurnActivityEventWithID(session, "call-event-start", EventCallStarted, "turn-1", messageStreamStateStreaming, "", callID, map[string]any{
|
|
"callId": callID,
|
|
"callType": "tool",
|
|
"name": callID,
|
|
"toolName": "Bash",
|
|
"input": map[string]any{"command": "pwd"},
|
|
})
|
|
started.OccurredAtUnixMS = 203
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{started})
|
|
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want one call update", report.MessageUpdates)
|
|
}
|
|
update := report.MessageUpdates[0]
|
|
if update.Title != "Bash" {
|
|
t.Fatalf("message update title = %q, want canonical tool title", update.Title)
|
|
}
|
|
if got := update.Payload["name"]; got != "Bash" {
|
|
t.Fatalf("message update payload = %#v, want canonical name", update.Payload)
|
|
}
|
|
if got := update.Payload["toolName"]; got != "Bash" {
|
|
t.Fatalf("message update payload = %#v, want canonical toolName", update.Payload)
|
|
}
|
|
}
|
|
|
|
func TestReportActivityInputCanonicalizesToolNamesAcrossAgents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
tests := []struct {
|
|
name string
|
|
payloadName string
|
|
metadata map[string]any
|
|
wantTool string
|
|
wantName string
|
|
}{
|
|
{
|
|
name: "codex exec command becomes bash",
|
|
payloadName: "exec_command",
|
|
metadata: map[string]any{"toolName": "exec_command"},
|
|
wantTool: "Bash",
|
|
wantName: "Bash",
|
|
},
|
|
{
|
|
name: "hermes shell command becomes bash",
|
|
payloadName: "run_shell_command",
|
|
metadata: map[string]any{"toolName": "run_shell_command"},
|
|
wantTool: "Bash",
|
|
wantName: "Bash",
|
|
},
|
|
{
|
|
name: "subagent task becomes agent",
|
|
payloadName: "run_subagent",
|
|
metadata: map[string]any{"toolName": "Task"},
|
|
wantTool: "Agent",
|
|
wantName: "Agent",
|
|
},
|
|
{
|
|
name: "delegate task becomes agent",
|
|
payloadName: "delegate_task",
|
|
metadata: map[string]any{"toolName": "delegate_task"},
|
|
wantTool: "Agent",
|
|
wantName: "Agent",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
event := newTurnActivityEventWithID(session, "call-event-"+tt.name, EventCallStarted, "turn-1", messageStreamStateStreaming, "", tt.payloadName, map[string]any{
|
|
"callId": "call-" + strings.ReplaceAll(tt.name, " ", "-"),
|
|
"callType": "tool",
|
|
"name": tt.payloadName,
|
|
"input": map[string]any{"command": "pwd"},
|
|
})
|
|
for key, value := range tt.metadata {
|
|
event.Payload.Metadata[key] = value
|
|
}
|
|
event.OccurredAtUnixMS = 204
|
|
|
|
report := reportActivityInput(session, []activityshared.Event{event})
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want one call update", report.MessageUpdates)
|
|
}
|
|
update := report.MessageUpdates[0]
|
|
if got := update.Payload["toolName"]; got != tt.wantTool {
|
|
t.Fatalf("message update payload = %#v, want toolName %q", update.Payload, tt.wantTool)
|
|
}
|
|
if got := update.Title; got != tt.wantName {
|
|
t.Fatalf("message update title = %q, want %q", got, tt.wantName)
|
|
}
|
|
if got := update.Payload["name"]; got != tt.wantName {
|
|
t.Fatalf("message update payload = %#v, want name %q", update.Payload, tt.wantName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReporterSendsRuntimeMessageOnlyReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
message := newTurnActivityEventWithID(session, "assistant-message-only", EventMessage, "turn-1", messageStreamStateStreaming, RoleAssistant, "streaming", map[string]any{
|
|
"streamState": messageStreamStateStreaming,
|
|
})
|
|
message.OccurredAtUnixMS = 301
|
|
client := &retryingActivityClient{}
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
err := reporter.Report(context.Background(), reportActivityInput(session, []activityshared.Event{message}))
|
|
if err != nil {
|
|
t.Fatalf("Report: %v", err)
|
|
}
|
|
if client.calls != 1 {
|
|
t.Fatalf("calls = %d, want one message report", client.calls)
|
|
}
|
|
if len(client.messageInputs[0].Updates) != 1 {
|
|
t.Fatalf("reported message updates = %#v, want one", client.messageInputs[0].Updates)
|
|
}
|
|
if len(client.stateInputs) != 0 {
|
|
t.Fatalf("reported state inputs = %#v, want message-only report", client.stateInputs)
|
|
}
|
|
}
|
|
|
|
func TestReporterProjectsCanonicalFieldsFromStartedAndCompletedCallsToMessageUpdates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
updates := reportActivityInput(session, []activityshared.Event{
|
|
newTurnActivityEventWithID(session, "call-2-item", EventCallStarted, "turn-2", messageStreamStateStreaming, "", "Run command", map[string]any{
|
|
"callId": "call-2",
|
|
"callType": "tool",
|
|
"name": "Run command",
|
|
"toolName": "Bash",
|
|
"input": map[string]any{
|
|
"command": "ls -la",
|
|
"cwd": "/workspace/app",
|
|
},
|
|
}),
|
|
newTurnActivityEventWithID(session, "call-2-item", EventCallCompleted, "turn-2", messageStreamStateCompleted, "", "Run command", map[string]any{
|
|
"callId": "call-2",
|
|
"callType": "tool",
|
|
"name": "Run command",
|
|
"toolName": "Bash",
|
|
"output": map[string]any{
|
|
"stdout": "README.md\n",
|
|
},
|
|
"error": map[string]any{
|
|
"stderr": "warning: truncated\n",
|
|
},
|
|
}),
|
|
}).MessageUpdates
|
|
|
|
if len(updates) != 2 {
|
|
t.Fatalf("message updates = %#v, want start and completion updates", updates)
|
|
}
|
|
if updates[0].MessageID != updates[1].MessageID {
|
|
t.Fatalf("message updates = %#v, want stable message id across lifecycle", updates)
|
|
}
|
|
if got := updates[0].Payload["toolName"]; got != "Bash" {
|
|
t.Fatalf("payload = %#v, want canonical toolName", updates[0].Payload)
|
|
}
|
|
if got := updates[0].Payload["input"].(map[string]any)["command"]; got != "ls -la" {
|
|
t.Fatalf("payload = %#v, want started input preserved", updates[0].Payload)
|
|
}
|
|
if got := updates[1].Payload["output"].(map[string]any)["stdout"]; got != "README.md\n" {
|
|
t.Fatalf("payload = %#v, want completed output preserved", updates[1].Payload)
|
|
}
|
|
if got := updates[1].Payload["output"].(map[string]any)["text"]; got != nil {
|
|
t.Fatalf("payload = %#v, want terminal output text tombstone", updates[1].Payload)
|
|
}
|
|
if got := updates[1].Payload["error"].(map[string]any)["stderr"]; got != "warning: truncated\n" {
|
|
t.Fatalf("payload = %#v, want completed error preserved", updates[1].Payload)
|
|
}
|
|
if got := updates[1].Payload["error"].(map[string]any)["text"]; got != nil {
|
|
t.Fatalf("payload = %#v, want terminal error text tombstone", updates[1].Payload)
|
|
}
|
|
if got := updates[1].Status; got != "completed" {
|
|
t.Fatalf("completed update = %#v, want completed status preserved", updates[1])
|
|
}
|
|
}
|
|
|
|
func TestReporterProjectsStandardACPToolLifecycleToStableMessageUpdates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
session.Provider = hermesExtensionTestProvider
|
|
updates := reportActivityInput(session, []activityshared.Event{
|
|
newTurnActivityEventWithID(session, "stable-tool-item", EventCallStarted, "turn-std", messageStreamStateStreaming, "", "Bash", map[string]any{
|
|
"callId": "tool-std-1",
|
|
"callType": "tool",
|
|
"name": "Bash",
|
|
"input": map[string]any{
|
|
"command": "pwd",
|
|
},
|
|
}),
|
|
newTurnActivityEventWithID(session, "stable-tool-item", EventCallCompleted, "turn-std", messageStreamStateCompleted, "", "Bash", map[string]any{
|
|
"callId": "tool-std-1",
|
|
"callType": "tool",
|
|
"name": "Bash",
|
|
"output": map[string]any{
|
|
"stdout": "/workspace/app\n",
|
|
},
|
|
}),
|
|
}).MessageUpdates
|
|
|
|
if len(updates) != 2 {
|
|
t.Fatalf("message updates = %#v, want start and completion updates", updates)
|
|
}
|
|
if updates[0].MessageID != updates[1].MessageID || updates[1].CallID != "tool-std-1" {
|
|
t.Fatalf("message updates = %#v, want stable call message for tool-std-1", updates)
|
|
}
|
|
if got := updates[0].Payload["input"].(map[string]any)["command"]; got != "pwd" {
|
|
t.Fatalf("payload = %#v, want preserved input", updates[0].Payload)
|
|
}
|
|
if got := updates[1].Payload["output"].(map[string]any)["stdout"]; got != "/workspace/app\n" {
|
|
t.Fatalf("payload = %#v, want preserved output", updates[1].Payload)
|
|
}
|
|
if got := updates[1].Payload["output"].(map[string]any)["text"]; got != nil {
|
|
t.Fatalf("payload = %#v, want terminal output text tombstone", updates[1].Payload)
|
|
}
|
|
}
|
|
|
|
func TestReporterCompactsTerminalCommandAliasBeforeTruncationAndMerge(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
stdout string
|
|
}{
|
|
{
|
|
name: "leading whitespace shifts independent truncation windows",
|
|
stdout: " " + strings.Repeat(
|
|
"x",
|
|
canonical.ToolOutputTextMaxBytes+64,
|
|
) + "\n",
|
|
},
|
|
{
|
|
name: "trailing whitespace pushes raw stream over field limit",
|
|
stdout: strings.Repeat(
|
|
"y",
|
|
canonical.ToolOutputTextMaxBytes,
|
|
) + strings.Repeat("\n", 64),
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
session := reportTestSession()
|
|
report := reportActivityInput(session, []activityshared.Event{
|
|
newTurnActivityEventWithID(
|
|
session,
|
|
"terminal-alias",
|
|
EventCallCompleted,
|
|
"turn-terminal-alias",
|
|
messageStreamStateCompleted,
|
|
"",
|
|
"Run command",
|
|
map[string]any{
|
|
"callId": "call-terminal-alias",
|
|
"name": "Run command",
|
|
"toolName": "Bash",
|
|
"input": map[string]any{
|
|
"command": "print output",
|
|
},
|
|
"output": map[string]any{
|
|
"stdout": test.stdout,
|
|
},
|
|
},
|
|
),
|
|
})
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want one", report.MessageUpdates)
|
|
}
|
|
update := report.MessageUpdates[0]
|
|
output := update.Payload["output"].(map[string]any)
|
|
if output["text"] != nil {
|
|
t.Fatalf("reporter output retained alias: %#v", output)
|
|
}
|
|
stdout := output["stdout"].(string)
|
|
if len(stdout) > canonical.ToolOutputTextMaxBytes ||
|
|
!strings.HasSuffix(stdout, canonical.ToolOutputTruncationMarker) {
|
|
t.Fatalf("reporter stdout was not bounded: %d bytes", len(stdout))
|
|
}
|
|
|
|
existing := canonical.MessageSnapshot{
|
|
AgentSessionID: "session-1",
|
|
MessageID: update.MessageID,
|
|
Version: 1,
|
|
TurnID: update.TurnID,
|
|
Role: update.Role,
|
|
Kind: update.Kind,
|
|
Status: "running",
|
|
Payload: map[string]any{
|
|
"toolName": "Bash",
|
|
"input": map[string]any{"command": "print output"},
|
|
"output": map[string]any{
|
|
"text": canonical.TruncateToolOutputText(
|
|
strings.TrimSpace(test.stdout),
|
|
),
|
|
},
|
|
},
|
|
OccurredAtUnixMS: 10,
|
|
StartedAtUnixMS: 10,
|
|
CreatedAtUnixMS: 10,
|
|
UpdatedAtUnixMS: 10,
|
|
}
|
|
projected, ok := canonical.ProjectMessageUpdate(
|
|
existing,
|
|
true,
|
|
canonical.MessageUpdate{
|
|
MessageID: update.MessageID,
|
|
TurnID: update.TurnID,
|
|
Role: update.Role,
|
|
Kind: update.Kind,
|
|
Status: update.Status,
|
|
Payload: update.Payload,
|
|
OccurredAtUnixMS: update.OccurredAtUnixMS,
|
|
StartedAtUnixMS: update.StartedAtUnixMS,
|
|
CompletedAtUnixMS: update.CompletedAtUnixMS,
|
|
},
|
|
2,
|
|
20,
|
|
)
|
|
if !ok {
|
|
t.Fatal("ProjectMessageUpdate() rejected terminal report")
|
|
}
|
|
projectedOutput := projected.Payload["output"].(map[string]any)
|
|
if _, exists := projectedOutput["text"]; exists {
|
|
t.Fatalf("durable output retained running text alias: %#v", projectedOutput)
|
|
}
|
|
encoded, err := json.Marshal(projected.Payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(encoded) > canonical.ToolCallPayloadMaxBytes {
|
|
t.Fatalf("durable payload has %d bytes", len(encoded))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReporterCanonicalizesToolOutputTextFromContentBlocks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
report := reportActivityInput(session, []activityshared.Event{
|
|
newTurnActivityEventWithID(session, "web-search-complete", EventCallCompleted, "turn-web", messageStreamStateCompleted, "", "WebSearch", map[string]any{
|
|
"callId": "call-web",
|
|
"callType": "tool",
|
|
"name": "WebSearch",
|
|
"toolName": "WebSearch",
|
|
"output": map[string]any{
|
|
"content": []any{
|
|
map[string]any{
|
|
"type": "tool_result",
|
|
"text": "Web search results for query: \"Tokyo weather\"",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
})
|
|
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want 1", report.MessageUpdates)
|
|
}
|
|
output, _ := report.MessageUpdates[0].Payload["output"].(map[string]any)
|
|
if got := output["text"]; got != "Web search results for query: \"Tokyo weather\"" {
|
|
t.Fatalf("output = %#v, want canonical output text from content blocks", output)
|
|
}
|
|
}
|
|
|
|
func TestReporterTruncatesFormalToolOutputFieldsBeforeLiveAndDurableProjection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
large := strings.Repeat("x", canonical.ToolOutputTextMaxBytes+64)
|
|
session := reportTestSession()
|
|
event := newTurnActivityEventWithID(
|
|
session,
|
|
"large-tool-output",
|
|
EventCallFailed,
|
|
"turn-large",
|
|
messageStreamStateFailed,
|
|
"",
|
|
"Run command",
|
|
map[string]any{
|
|
"callId": "call-large",
|
|
"input": map[string]any{"text": large},
|
|
"output": map[string]any{
|
|
"text": large,
|
|
"stdout": large,
|
|
"message": large,
|
|
"steps": []any{
|
|
map[string]any{
|
|
"toolResult": map[string]any{"stdout": large},
|
|
},
|
|
},
|
|
},
|
|
"error": map[string]any{
|
|
"stderr": large,
|
|
},
|
|
"metadata": map[string]any{
|
|
"steps": []any{
|
|
map[string]any{
|
|
"toolError": map[string]any{"stderr": large},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
stream := ProjectActivityEventsToStreamEvents(session, []activityshared.Event{event})
|
|
report := reportActivityInput(session, []activityshared.Event{event})
|
|
if len(stream) != 1 || len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("stream=%#v report=%#v, want one update in each projection", stream, report)
|
|
}
|
|
liveUpdate := stream[0].Data.(agentsessionstore.WorkspaceAgentMessageUpdate)
|
|
for _, update := range []agentsessionstore.WorkspaceAgentMessageUpdate{
|
|
liveUpdate,
|
|
report.MessageUpdates[0],
|
|
} {
|
|
if update.Payload["input"].(map[string]any)["text"] != large {
|
|
t.Fatal("tool input was truncated")
|
|
}
|
|
output := update.Payload["output"].(map[string]any)
|
|
for _, key := range []string{"text", "stdout"} {
|
|
value := output[key].(string)
|
|
if len(value) > canonical.ToolOutputTextMaxBytes ||
|
|
!strings.HasSuffix(value, canonical.ToolOutputTruncationMarker) {
|
|
t.Fatalf("output.%s was not bounded: %d bytes", key, len(value))
|
|
}
|
|
}
|
|
if output["message"] != large {
|
|
t.Fatal("non-target structured output field was truncated")
|
|
}
|
|
step := output["steps"].([]any)[0].(map[string]any)
|
|
stdout := step["toolResult"].(map[string]any)["stdout"].(string)
|
|
if len(stdout) > canonical.ToolOutputTextMaxBytes ||
|
|
!strings.HasSuffix(stdout, canonical.ToolOutputTruncationMarker) {
|
|
t.Fatalf("nested stdout was not bounded: %d bytes", len(stdout))
|
|
}
|
|
stderr := update.Payload["error"].(map[string]any)["stderr"].(string)
|
|
if len(stderr) > canonical.ToolOutputTextMaxBytes ||
|
|
!strings.HasSuffix(stderr, canonical.ToolOutputTruncationMarker) {
|
|
t.Fatalf("error.stderr was not bounded: %d bytes", len(stderr))
|
|
}
|
|
metadataStep := update.Payload["metadata"].(map[string]any)["steps"].([]any)[0].(map[string]any)
|
|
metadataStderr := metadataStep["toolError"].(map[string]any)["stderr"].(string)
|
|
if len(metadataStderr) > canonical.ToolOutputTextMaxBytes ||
|
|
!strings.HasSuffix(metadataStderr, canonical.ToolOutputTruncationMarker) {
|
|
t.Fatalf("metadata step stderr was not bounded: %d bytes", len(metadataStderr))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReporterProjectsFailedCallOutputAlongsideError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
report := reportActivityInput(session, []activityshared.Event{
|
|
newTurnActivityEventWithID(session, "failed-call-item", EventCallFailed, "turn-failed", messageStreamStateFailed, "", "Run command", map[string]any{
|
|
"callId": "call-failed",
|
|
"callType": "tool",
|
|
"name": "Run command",
|
|
"toolName": "Bash",
|
|
"output": map[string]any{
|
|
"aggregated_output": "fatal: not a git repository\n",
|
|
"stderr": "fatal: not a git repository\n",
|
|
},
|
|
"error": map[string]any{
|
|
"message": "fatal: not a git repository",
|
|
"aggregated_output": "fatal: not a git repository\n",
|
|
},
|
|
}),
|
|
})
|
|
|
|
if len(report.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want 1", report.MessageUpdates)
|
|
}
|
|
item := report.MessageUpdates[0]
|
|
if item.Kind != "tool_call" || item.Status != "failed" {
|
|
t.Fatalf("message update = %#v, want failed call snapshot", item)
|
|
}
|
|
output, _ := item.Payload["output"].(map[string]any)
|
|
if got := output["aggregated_output"]; got != "fatal: not a git repository\n" {
|
|
t.Fatalf("timeline payload = %#v, want mirrored output", item.Payload)
|
|
}
|
|
errorPayload, _ := item.Payload["error"].(map[string]any)
|
|
if got := errorPayload["message"]; got != "fatal: not a git repository" {
|
|
t.Fatalf("timeline payload = %#v, want error payload preserved", item.Payload)
|
|
}
|
|
if got := countEntityPatches(report.StatePatches); got != 0 {
|
|
t.Fatalf("entity patches = %#v, count = %d, want no legacy entity patches", report.StatePatches, got)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeReportActivityInputForLog(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
report := reportActivityInput(reportTestSession(), []activityshared.Event{
|
|
newTurnActivityEventWithID(reportTestSession(), "call-3-item", EventCallStarted, "turn-3", messageStreamStateStreaming, "", "Run command", map[string]any{
|
|
"callId": "call-3",
|
|
"callType": "tool",
|
|
"name": "Run command",
|
|
"input": map[string]any{
|
|
"command": "pwd",
|
|
},
|
|
}),
|
|
newTurnActivityEventWithID(reportTestSession(), "call-3-item", EventCallCompleted, "turn-3", messageStreamStateCompleted, "", "Run command", map[string]any{
|
|
"callId": "call-3",
|
|
"callType": "tool",
|
|
"name": "Run command",
|
|
"output": map[string]any{
|
|
"stdout": "/workspace/app\n",
|
|
},
|
|
}),
|
|
})
|
|
|
|
messageUpdates, statePatches := SummarizeReportActivityInputForLog(report)
|
|
if len(messageUpdates) != 2 {
|
|
t.Fatalf("message update summary = %#v, want started and completed updates", messageUpdates)
|
|
}
|
|
if !strings.Contains(messageUpdates[0], "tool_call") ||
|
|
!strings.Contains(messageUpdates[0], "body=input") ||
|
|
!strings.Contains(messageUpdates[1], "body=output") {
|
|
t.Fatalf("message update summary = %#v, want started input and completed output", messageUpdates)
|
|
}
|
|
if len(statePatches) != 0 {
|
|
t.Fatalf("state patch summary = %#v, want no legacy entity patches", statePatches)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeReportActivityInputForLogIncludesProviderAndRuntimeTurnLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
activeTurnID := "root-turn-1"
|
|
report := agentsessionstore.ReportActivityInput{
|
|
StatePatches: []agentsessionstore.WorkspaceAgentStatePatch{{
|
|
AgentSessionID: "session-1",
|
|
TurnLifecycle: &canonical.WorkspaceAgentTurnLifecycle{
|
|
ActiveTurnID: &activeTurnID,
|
|
Phase: "waiting",
|
|
},
|
|
RootProviderTurn: &canonical.WorkspaceAgentRootProviderTurnTransition{
|
|
RootTurnID: "root-turn-1",
|
|
ProviderTurnID: "provider-turn-2",
|
|
Phase: agentsessionstore.RootProviderTurnPhaseCompleted,
|
|
Outcome: "completed",
|
|
},
|
|
}},
|
|
}
|
|
|
|
_, statePatches := SummarizeReportActivityInputForLog(report)
|
|
if len(statePatches) != 1 ||
|
|
!strings.Contains(statePatches[0], "turnLifecycle=root-turn-1/waiting") ||
|
|
!strings.Contains(statePatches[0], "rootProviderTurn=root-turn-1/provider-turn-2/completed/completed") {
|
|
t.Fatalf("state patch summary = %#v, want runtime and root provider lifecycle", statePatches)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeReportActivityInputForLogIncludesFailedCallDiagnostics(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
report := reportActivityInput(reportTestSession(), []activityshared.Event{
|
|
newTurnActivityEventWithID(reportTestSession(), "call-failed-item", EventCallFailed, "turn-failed", messageStreamStateFailed, "", "Generate document", map[string]any{
|
|
"callId": "call-failed",
|
|
"callType": "tool",
|
|
"name": "Generate document",
|
|
"toolName": "document-generator",
|
|
"output": map[string]any{
|
|
"stderr": "render failed after loading template\nmore verbose output omitted",
|
|
},
|
|
"error": map[string]any{
|
|
"message": "render failed after loading template\ncaused by missing placeholder",
|
|
},
|
|
}),
|
|
})
|
|
|
|
messageUpdates, statePatches := SummarizeReportActivityInputForLog(report)
|
|
if len(messageUpdates) != 1 {
|
|
t.Fatalf("message update summary = %#v, want failed call update", messageUpdates)
|
|
}
|
|
summary := messageUpdates[0]
|
|
if !strings.Contains(summary, "tool_call") ||
|
|
!strings.Contains(summary, "failed") ||
|
|
!strings.Contains(summary, "body=output+error") ||
|
|
!strings.Contains(summary, "name=document-generator") ||
|
|
!strings.Contains(summary, `error="render failed after loading template caused by missing placeholder"`) {
|
|
t.Fatalf("message update summary = %#v, want failed call diagnostics", messageUpdates)
|
|
}
|
|
if len(statePatches) != 0 {
|
|
t.Fatalf("state patch summary = %#v, want no legacy entity patches", statePatches)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeReportActivityInputForLogDoesNotExpandNonCallErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
report := agentsessionstore.ReportActivityInput{
|
|
MessageUpdates: []agentsessionstore.WorkspaceAgentMessageUpdate{
|
|
{
|
|
MessageID: "message-failed",
|
|
Kind: "text",
|
|
Status: "failed",
|
|
Payload: map[string]any{
|
|
"error": map[string]any{
|
|
"message": "provider returned internal failure details",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
messageUpdates, statePatches := SummarizeReportActivityInputForLog(report)
|
|
if len(messageUpdates) != 1 {
|
|
t.Fatalf("message update summary = %#v, want failed text update", messageUpdates)
|
|
}
|
|
if strings.Contains(messageUpdates[0], "provider returned internal failure details") ||
|
|
strings.Contains(messageUpdates[0], "error=") {
|
|
t.Fatalf("message update summary = %#v, want no expanded non-call error", messageUpdates)
|
|
}
|
|
if len(statePatches) != 0 {
|
|
t.Fatalf("state patch summary = %#v, want no legacy entity patches", statePatches)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeLogValueCountsCollapsesRepeatedValues(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := summarizeLogValueCounts([]string{
|
|
"message.appended",
|
|
"message.appended",
|
|
"message.appended",
|
|
"turn.completed",
|
|
"message.appended",
|
|
})
|
|
want := []string{"message.appended=4", "turn.completed=1"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("summary = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestReporterProjectsSessionAndTurnLifecycleToStatePatches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
client := &retryingActivityClient{}
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
input := reportActivityInput(session, []activityshared.Event{
|
|
newSessionActivityEvent(session, EventSessionStarted, SessionStatusReady, nil),
|
|
newTurnActivityEventWithID(session, "turn-start-1", EventTurnStarted, "turn-1", SessionStatusWorking, "", "", nil),
|
|
newTurnActivityEventWithID(session, "turn-done-1", EventTurnCompleted, "turn-1", SessionStatusReady, "", "", map[string]any{
|
|
"stopReason": "end_turn",
|
|
}),
|
|
})
|
|
err := reporter.Report(context.Background(), input)
|
|
if err != nil {
|
|
t.Fatalf("Report: %v", err)
|
|
}
|
|
if client.calls != 3 {
|
|
t.Fatalf("calls = %d, want 3 state reports", client.calls)
|
|
}
|
|
if len(client.stateInputs) != 3 {
|
|
t.Fatalf("state inputs = %#v, want 3", client.stateInputs)
|
|
}
|
|
if len(input.TimelineItems) != 0 {
|
|
t.Fatalf("timeline items = %#v, want none for lifecycle-only report", input.TimelineItems)
|
|
}
|
|
if len(input.StatePatches) != 3 {
|
|
t.Fatalf("state patches = %#v, want 3", input.StatePatches)
|
|
}
|
|
if input.StatePatches[0].LifecycleStatus != string(activityshared.SessionLifecycleStatusActive) {
|
|
t.Fatalf("session patch = %#v", input.StatePatches[0])
|
|
}
|
|
if input.StatePatches[1].Turn == nil ||
|
|
input.StatePatches[1].Turn.TurnID != "turn-1" ||
|
|
input.StatePatches[1].Turn.StartedAtUnixMS == 0 ||
|
|
input.StatePatches[1].CurrentPhase != string(activityshared.TurnPhaseWorking) {
|
|
t.Fatalf("turn started patch = %#v", input.StatePatches[1])
|
|
}
|
|
if input.StatePatches[2].Turn == nil ||
|
|
input.StatePatches[2].Turn.CompletedAtUnixMS == 0 ||
|
|
input.StatePatches[2].Turn.ActiveTurnID != nil ||
|
|
input.StatePatches[2].Turn.Phase != "settled" ||
|
|
input.StatePatches[2].Turn.Outcome != "completed" ||
|
|
input.StatePatches[2].Turn.SubmitAvailability == nil ||
|
|
input.StatePatches[2].Turn.SubmitAvailability.State != "available" ||
|
|
input.StatePatches[2].TurnLifecycle == nil ||
|
|
input.StatePatches[2].TurnLifecycle.ActiveTurnID != nil ||
|
|
input.StatePatches[2].TurnLifecycle.Phase != "settled" ||
|
|
input.StatePatches[2].TurnLifecycle.Outcome == nil ||
|
|
*input.StatePatches[2].TurnLifecycle.Outcome != "completed" ||
|
|
input.StatePatches[2].SubmitAvailability == nil ||
|
|
input.StatePatches[2].SubmitAvailability.State != "available" ||
|
|
input.StatePatches[2].CurrentPhase != string(activityshared.TurnPhaseIdle) {
|
|
t.Fatalf("turn completed patch = %#v", input.StatePatches[2])
|
|
}
|
|
if input.StatePatches[2].LastError != "" {
|
|
t.Fatalf("turn completed last error = %q, want empty", input.StatePatches[2].LastError)
|
|
}
|
|
if client.stateInputs[2].State.LastError != "" {
|
|
t.Fatalf("reported turn completed last error = %q, want empty", client.stateInputs[2].State.LastError)
|
|
}
|
|
}
|
|
|
|
func TestReporterProjectsSessionCompletedWithServerLifecycleStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
client := &retryingActivityClient{}
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
input := reportActivityInput(session, []activityshared.Event{
|
|
newSessionActivityEvent(session, EventSessionCompleted, SessionStatusCompleted, nil),
|
|
})
|
|
err := reporter.Report(context.Background(), input)
|
|
if err != nil {
|
|
t.Fatalf("Report: %v", err)
|
|
}
|
|
if client.calls != 1 {
|
|
t.Fatalf("calls = %d, want 1", client.calls)
|
|
}
|
|
if len(input.StatePatches) != 1 {
|
|
t.Fatalf("state patches = %#v, want 1", input.StatePatches)
|
|
}
|
|
if input.StatePatches[0].LifecycleStatus != "completed" ||
|
|
input.StatePatches[0].CurrentPhase != string(activityshared.TurnPhaseIdle) {
|
|
t.Fatalf("state patch = %#v, want server-compatible completed lifecycle", input.StatePatches[0])
|
|
}
|
|
}
|
|
|
|
func TestReporterProjectsReadySessionUpdateAsIdlePhase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
client := &retryingActivityClient{}
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
input := reportActivityInput(session, []activityshared.Event{
|
|
newSessionActivityEvent(session, EventSessionUpdated, SessionStatusReady, nil),
|
|
})
|
|
err := reporter.Report(context.Background(), input)
|
|
if err != nil {
|
|
t.Fatalf("Report: %v", err)
|
|
}
|
|
if client.calls != 1 {
|
|
t.Fatalf("calls = %d, want 1", client.calls)
|
|
}
|
|
if len(input.StatePatches) != 1 {
|
|
t.Fatalf("state patches = %#v, want 1", input.StatePatches)
|
|
}
|
|
if input.StatePatches[0].CurrentPhase != string(activityshared.TurnPhaseIdle) {
|
|
t.Fatalf("state patch = %#v, want ready status projected as idle phase", input.StatePatches[0])
|
|
}
|
|
}
|
|
|
|
func TestReporterProjectsTurnFailureErrorToStatePatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
client := &retryingActivityClient{}
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
input := reportActivityInput(session, []activityshared.Event{
|
|
newTurnActivityEventWithID(session, "turn-failed-1", EventTurnFailed, "turn-1", SessionStatusFailed, "", "", map[string]any{
|
|
"error": "API Error: 403 Key limit exceeded (total limit)",
|
|
}),
|
|
})
|
|
err := reporter.Report(context.Background(), input)
|
|
if err != nil {
|
|
t.Fatalf("Report: %v", err)
|
|
}
|
|
if client.calls != 2 {
|
|
t.Fatalf("calls = %d, want state and visible failure message reports", client.calls)
|
|
}
|
|
if len(client.stateInputs) != 1 {
|
|
t.Fatalf("state inputs = %#v, want 1", client.stateInputs)
|
|
}
|
|
if len(client.messageInputs) != 1 {
|
|
t.Fatalf("message inputs = %#v, want visible failure message", client.messageInputs)
|
|
}
|
|
if len(input.StatePatches) != 1 {
|
|
t.Fatalf("state patches = %#v, want 1", input.StatePatches)
|
|
}
|
|
if input.StatePatches[0].CurrentPhase != string(activityshared.TurnPhaseIdle) ||
|
|
input.StatePatches[0].Turn == nil ||
|
|
input.StatePatches[0].Turn.ActiveTurnID != nil ||
|
|
input.StatePatches[0].Turn.Phase != "settled" ||
|
|
input.StatePatches[0].Turn.Outcome != "failed" ||
|
|
input.StatePatches[0].Turn.SubmitAvailability == nil ||
|
|
input.StatePatches[0].Turn.SubmitAvailability.State != "available" ||
|
|
input.StatePatches[0].TurnLifecycle == nil ||
|
|
input.StatePatches[0].TurnLifecycle.ActiveTurnID != nil ||
|
|
input.StatePatches[0].TurnLifecycle.Phase != "settled" ||
|
|
input.StatePatches[0].TurnLifecycle.Outcome == nil ||
|
|
*input.StatePatches[0].TurnLifecycle.Outcome != "failed" ||
|
|
input.StatePatches[0].SubmitAvailability == nil ||
|
|
input.StatePatches[0].SubmitAvailability.State != "available" {
|
|
t.Fatalf("turn failed patch = %#v", input.StatePatches[0])
|
|
}
|
|
if input.StatePatches[0].LastError != "Codex request failed because a quota or rate limit was reached." {
|
|
t.Fatalf("last error = %q, want projected failure reason", input.StatePatches[0].LastError)
|
|
}
|
|
if client.stateInputs[0].State.LastError != "Codex request failed because a quota or rate limit was reached." {
|
|
t.Fatalf("reported last error = %q, want projected failure reason", client.stateInputs[0].State.LastError)
|
|
}
|
|
if len(input.MessageUpdates) != 1 {
|
|
t.Fatalf("message updates = %#v, want visible failure message", input.MessageUpdates)
|
|
}
|
|
item := input.MessageUpdates[0]
|
|
if item.Kind != "text" ||
|
|
item.Status != messageStreamStateFailed ||
|
|
item.Payload["kind"] != visibleErrorKind ||
|
|
item.Payload["code"] != "quota_or_rate_limit" {
|
|
t.Fatalf("visible failure message update = %#v", item)
|
|
}
|
|
if item.Payload["detail"] != "API Error: 403 Key limit exceeded (total limit)" {
|
|
t.Fatalf("visible failure detail = %#v", item.Payload["detail"])
|
|
}
|
|
if item.Semantics == nil || !item.Semantics.UserVisibleAssistantResponse {
|
|
t.Fatalf("visible failure semantics = %#v, want explicit user-visible assistant response", item.Semantics)
|
|
}
|
|
}
|
|
|
|
func TestReporterDoesNotSendEmptyOrUnreportableBatches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := reportTestSession()
|
|
client := &retryingActivityClient{}
|
|
reporter := Reporter{
|
|
ClientProvider: func() ActivityClient { return client },
|
|
MaxAttempts: 1,
|
|
}
|
|
|
|
if err := reporter.Report(context.Background(), reportActivityInput(session, []activityshared.Event{
|
|
{Type: activityshared.EventPresenceHeartbeat},
|
|
})); err != nil {
|
|
t.Fatalf("Report unreportable: %v", err)
|
|
}
|
|
if client.calls != 0 {
|
|
t.Fatalf("calls after unreportable batch = %d, want 0", client.calls)
|
|
}
|
|
|
|
if err := reporter.Report(context.Background(), reportActivityInput(Session{}, []activityshared.Event{
|
|
{Type: activityshared.EventMessageAppended, EventID: "message-without-session"},
|
|
})); err != nil {
|
|
t.Fatalf("Report empty projection: %v", err)
|
|
}
|
|
if client.calls != 0 {
|
|
t.Fatalf("calls after empty projection = %d, want 0", client.calls)
|
|
}
|
|
}
|
|
|
|
func reportTestSession() Session {
|
|
return Session{
|
|
RoomID: "room-1",
|
|
AgentSessionID: "agent-session-1",
|
|
Provider: ProviderCodex,
|
|
ProviderSessionID: "provider-session-1",
|
|
CWD: "/workspace",
|
|
Status: SessionStatusReady,
|
|
Title: "Codex",
|
|
CreatedAtUnixMS: 1,
|
|
UpdatedAtUnixMS: 1,
|
|
}
|
|
}
|
|
|
|
func countEntityPatches(patches []agentsessionstore.WorkspaceAgentStatePatch) int {
|
|
count := 0
|
|
for _, patch := range patches {
|
|
count += len(patch.Entities)
|
|
}
|
|
return count
|
|
}
|
|
|
|
func mixedMessageUpdateReportInput() agentsessionstore.ReportActivityInput {
|
|
return agentsessionstore.ReportActivityInput{
|
|
WorkspaceID: "room-1",
|
|
Source: canonical.EventSource{
|
|
Provider: "codex",
|
|
AgentID: "agent-session-1",
|
|
},
|
|
TimelineItems: []agentsessionstore.WorkspaceAgentTimelineItem{{
|
|
AgentSessionID: "agent-session-1",
|
|
EventID: "event-1",
|
|
ItemType: "message.assistant",
|
|
}},
|
|
StatePatches: []agentsessionstore.WorkspaceAgentStatePatch{{
|
|
AgentSessionID: "agent-session-1",
|
|
CurrentPhase: "working",
|
|
}},
|
|
MessageUpdates: []agentsessionstore.WorkspaceAgentMessageUpdate{{
|
|
AgentSessionID: "agent-session-1",
|
|
MessageID: "message-1",
|
|
TurnID: "turn-1",
|
|
Seq: 1,
|
|
Role: "assistant",
|
|
Kind: "text",
|
|
Payload: map[string]any{"text": "hello"},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func ptrReportActivityReply(reply agentsessionstore.ReportActivityReply) *agentsessionstore.ReportActivityReply {
|
|
return &reply
|
|
}
|
|
|
|
func TestExplicitTurnLifecycleProjectionUsesDescriptorCatalog(t *testing.T) {
|
|
for _, provider := range []string{" CODEX ", ProviderClaudeCode, ProviderOpenCode, ProviderTuttiAgent} {
|
|
if !providerUsesExplicitTurnLifecyclePatch(provider) {
|
|
t.Fatalf("provider %q did not enable explicit turn lifecycle projection", provider)
|
|
}
|
|
}
|
|
if providerUsesExplicitTurnLifecyclePatch("unmigrated-provider") {
|
|
t.Fatal("unknown unmigrated provider unexpectedly enabled explicit turn lifecycle projection")
|
|
}
|
|
}
|