diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9b3c56e..0176268 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -320,6 +320,9 @@ disabled, response bodies are closed, attempts are bounded by context and HTTP timeouts, and unknown provider values return typed validation errors. Built-in tool inputs are schema-first contracts under `internal/toolcontract`. +The `request_user_input` header limit is 32 Unicode characters across the +provider schema, runtime validation, and OpenAPI. It is intentionally not an +environment setting because every boundary must enforce the same contract. Each provider receives the embedded compact JSON Schema, while Agent execution uses ogen-generated DTOs, strict decoders, and validators from the same file. Explicit mappers keep generated contract types out of the Agent domain. MCP diff --git a/api/openapi.yaml b/api/openapi.yaml index 8c3a0b7..be85fb3 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1226,7 +1226,8 @@ components: header: type: string minLength: 1 - maxLength: 12 + maxLength: 32 + description: Short navigation label with at most 32 Unicode characters. question: type: string minLength: 1 diff --git a/docs/adr/0005-atomic-question-answers.md b/docs/adr/0005-atomic-question-answers.md index 4f9de27..33bb1c1 100644 --- a/docs/adr/0005-atomic-question-answers.md +++ b/docs/adr/0005-atomic-question-answers.md @@ -20,6 +20,11 @@ calls from that model response are canceled. Runtime validation prevents a later call from replacing an unresolved batch. After resolution, later model turns may create any number of additional batches. +The header is a navigation label limited to 32 Unicode characters. The limit is +part of the model schema, runtime validation, and public API contract. It is not +runtime configurable because those three boundaries must agree. Clients handle +narrow layouts by truncating the visible label while exposing its full text. + `AnswerQuestions` takes the pending call ID and one non-empty answer for every question ID. The RPC locks `PendingUserInput`, validates the exact set, deletes the batch, and publishes one typed value to `AnsweredUserInputs` in one atomic diff --git a/internal/agent/flow.go b/internal/agent/flow.go index 4e320ae..0d15899 100644 --- a/internal/agent/flow.go +++ b/internal/agent/flow.go @@ -1438,7 +1438,7 @@ func (flow *Flow) contextMessages(ctx dex.Context, config AgentConfig, state Age if state.InteractionMode != InteractionModePlanning { result = append(result, AgentMessage{ Role: MessageRoleSystem, - Content: "When you need user input, call request_user_input instead of asking only in assistant text. Ask 1-3 related questions in one batch. If no reply is required, finish without a follow-up question.", + Content: "When you need user input, call request_user_input instead of asking only in assistant text. Ask 1-3 related questions in one batch. Give each question a short navigation header of at most 32 Unicode characters. If no reply is required, finish without a follow-up question.", }) } summary, err := flow.getSummary(ctx) @@ -2426,7 +2426,7 @@ func (step compactContextStep) Execute(ctx dex.Context, input Sequence) (*dex.St }) if err != nil { if eventErr := step.flow.writeActivity(ctx, AgentEvent{ - Kind: EventKindCompactionFailed, Message: "Context compaction failed.", + Kind: EventKindCompactionFailed, Message: "Context compaction failed.", MessageSequence: &input, }); eventErr != nil { return nil, errors.Join(err, eventErr) } @@ -2450,7 +2450,7 @@ func (step compactContextStep) Execute(ctx dex.Context, input Sequence) (*dex.St return nil, err } activity := AgentEvent{ - Kind: EventKindCompacted, + Kind: EventKindCompacted, MessageSequence: &input, Message: condenseActivityMessage(fmt.Sprintf( "Compacted conversation through message %d.", input, )), @@ -2828,7 +2828,7 @@ func (flow *Flow) callCompactionModel( }) if err != nil { if eventErr := flow.writeActivity(ctx, AgentEvent{ - Kind: EventKindCompactionFailed, Message: "Context compaction failed.", + Kind: EventKindCompactionFailed, Message: "Context compaction failed.", MessageSequence: &throughSequence, }); eventErr != nil { return modelCallResult{}, errors.Join(err, eventErr) } @@ -2966,7 +2966,7 @@ func (flow *Flow) applyCompactionModelResult( return "", err } activity := AgentEvent{ - Kind: EventKindCompacted, + Kind: EventKindCompacted, MessageSequence: &result.ThroughSequence, Message: condenseActivityMessage(fmt.Sprintf( "Compacted conversation through message %d.", result.ThroughSequence, )), diff --git a/internal/agent/flow_integration_test.go b/internal/agent/flow_integration_test.go index 6b93e4c..ee25b83 100644 --- a/internal/agent/flow_integration_test.go +++ b/internal/agent/flow_integration_test.go @@ -892,6 +892,13 @@ func TestAgentModelHooksIntegration(t *testing.T) { if compactionBefore.Load() == 0 || modelClient.summarizeCalls.Load() == 0 { t.Fatalf("compaction before/model calls = %d/%d", compactionBefore.Load(), modelClient.summarizeCalls.Load()) } + compacted := readActivityUntil(t, environment.agent, flowID, func(event AgentEvent) bool { + return event.Kind == EventKindCompacted + }) + if compacted.Activity.MessageSequence == nil || + !strings.Contains(compacted.Activity.Message, fmt.Sprintf("message %d", *compacted.Activity.MessageSequence)) { + t.Fatalf("compaction Activity = %#v", compacted.Activity) + } }) t.Run("business rejection skips model and after hook", func(t *testing.T) { diff --git a/internal/agent/tools.go b/internal/agent/tools.go index e655bdc..acd7b54 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -30,9 +30,10 @@ import ( ) const ( - maximumUserInputQuestions = 3 - maximumUserInputOptions = 3 - maximumUserMessageLength = 1_000_000 + maximumUserInputQuestions = 3 + maximumUserInputOptions = 3 + maximumUserInputHeaderCharacters = 32 + maximumUserMessageLength = 1_000_000 ) const ( @@ -226,8 +227,12 @@ func validateUserInputQuestions(values []UserInputQuestion) ([]UserInputQuestion if value.ID == "" || value.Header == "" || value.Question == "" { return nil, fmt.Errorf("question %d requires id, header, and question", index+1) } - if utf8.RuneCountInString(value.Header) > 12 { - return nil, fmt.Errorf("question %d header must contain at most 12 characters", index+1) + if utf8.RuneCountInString(value.Header) > maximumUserInputHeaderCharacters { + return nil, fmt.Errorf( + "question %d header must contain at most %d Unicode characters", + index+1, + maximumUserInputHeaderCharacters, + ) } if _, found := seenIDs[value.ID]; found { return nil, fmt.Errorf("question IDs must be unique: %q", value.ID) diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index ffa5140..b8a5509 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -16,7 +16,10 @@ package agent -import "testing" +import ( + "strings" + "testing" +) func TestSequenceKeyUsesUnpaddedDecimal(t *testing.T) { tests := map[Sequence]string{ @@ -86,7 +89,7 @@ func TestUserInputQuestionsEnforceBatchShape(t *testing.T) { } invalid := []UserInputQuestion{{ ID: "region", - Header: "Header longer than twelve", + Header: strings.Repeat("界", maximumUserInputHeaderCharacters+1), Question: "Where?", Options: []UserInputOption{{Label: "West", Description: "Use west."}}, }} diff --git a/internal/api/generated/oas_schemas_gen.go b/internal/api/generated/oas_schemas_gen.go index e0db7bf..8a4f8eb 100644 --- a/internal/api/generated/oas_schemas_gen.go +++ b/internal/api/generated/oas_schemas_gen.go @@ -4384,7 +4384,8 @@ func (s *UserInputOption) SetDescription(val string) { // Ref: #/components/schemas/UserInputQuestion type UserInputQuestion struct { - ID string `json:"id"` + ID string `json:"id"` + // Short navigation label with at most 32 Unicode characters. Header string `json:"header"` Question string `json:"question"` Options []UserInputOption `json:"options"` diff --git a/internal/api/generated/oas_validators_gen.go b/internal/api/generated/oas_validators_gen.go index 7cf6811..4a80185 100644 --- a/internal/api/generated/oas_validators_gen.go +++ b/internal/api/generated/oas_validators_gen.go @@ -3766,7 +3766,7 @@ func (s *UserInputQuestion) Validate() error { if err := (validate.String{ MinLength: 1, MinLengthSet: true, - MaxLength: 12, + MaxLength: 32, MaxLengthSet: true, Email: false, Hostname: false, diff --git a/internal/model/mock.go b/internal/model/mock.go index 9b8a73f..1f5e625 100644 --- a/internal/model/mock.go +++ b/internal/model/mock.go @@ -105,6 +105,7 @@ func (*MockClient) complete(ctx context.Context, request agent.ModelRequest) (ag } userRequest := lastUserContent(request.Messages) + lastMessage := lastConversationMessage(request.Messages) if strings.ToLower(userRequest) == "/plan-clear" && hasTool(available, agent.ToolNameWriteTodos) { arguments, err := writeTodosObject([]agent.PlanTask{}) if err != nil { @@ -145,6 +146,29 @@ func (*MockClient) complete(ctx context.Context, request agent.ModelRequest) (ag request.WriteActivity, ) } + if lastMessage != nil && + lastMessage.Role == agent.MessageRoleUser && + strings.EqualFold(userRequest, "/invalid-question") && + hasTool(available, agent.ToolNameRequestUserInput) { + arguments, err := userInputQuestionsObject([]agent.UserInputQuestion{{ + ID: "invalid-header", + Header: strings.Repeat("界", 33), + Question: "Should this invalid question be rejected?", + Options: []agent.UserInputOption{ + {Label: "Yes", Description: "Reject the invalid request."}, + {Label: "No", Description: "Accept the invalid request."}, + }, + }}) + if err != nil { + return agent.ModelReply{}, err + } + return calls.toolReply( + "I will send an invalid question for the recovery test.", + agent.ToolNameRequestUserInput, + arguments, + request.WriteActivity, + ) + } if strings.HasPrefix(strings.ToLower(userRequest), "/ask-many ") && hasTool(available, agent.ToolNameRequestUserInput) && hasTool(available, agent.ToolNameDurableWait) { prompt := strings.TrimSpace(userRequest[len("/ask-many "):]) @@ -231,7 +255,6 @@ func (*MockClient) complete(ctx context.Context, request agent.ModelRequest) (ag ) } - lastMessage := lastConversationMessage(request.Messages) content := "How can I help?" if lastMessage != nil { switch { diff --git a/internal/toolcontract/contract_test.go b/internal/toolcontract/contract_test.go index 2c8f6ec..9ab3b16 100644 --- a/internal/toolcontract/contract_test.go +++ b/internal/toolcontract/contract_test.go @@ -87,14 +87,14 @@ func TestRequestUserInputContract(t *testing.T) { wantErr bool }{ {name: "minimums", encoded: `{"questions":[` + validQuestion + `]}`}, - {name: "maximum header length", encoded: `{"questions":[{"id":"region","header":"123456789012","question":"Where?","options":[{"label":"West","description":"Use west."},{"label":"East","description":"Use east."}]}]}`}, + {name: "maximum header length", encoded: `{"questions":[{"id":"region","header":"` + strings.Repeat("界", 32) + `","question":"Where?","options":[{"label":"West","description":"Use west."},{"label":"East","description":"Use east."}]}]}`}, {name: "missing questions", encoded: `{}`, wantErr: true}, {name: "null questions", encoded: `{"questions":null}`, wantErr: true}, {name: "no questions", encoded: `{"questions":[]}`, wantErr: true}, {name: "too many questions", encoded: `{"questions":[` + validQuestion + `,` + validQuestion + `,` + validQuestion + `,` + validQuestion + `]}`, wantErr: true}, {name: "empty ID", encoded: `{"questions":[{"id":"","header":"Region","question":"Where?","options":[{"label":"West","description":"Use west."},{"label":"East","description":"Use east."}]}]}`, wantErr: true}, {name: "empty header", encoded: `{"questions":[{"id":"region","header":"","question":"Where?","options":[{"label":"West","description":"Use west."},{"label":"East","description":"Use east."}]}]}`, wantErr: true}, - {name: "header too long", encoded: `{"questions":[{"id":"region","header":"1234567890123","question":"Where?","options":[{"label":"West","description":"Use west."},{"label":"East","description":"Use east."}]}]}`, wantErr: true}, + {name: "header too long", encoded: `{"questions":[{"id":"region","header":"` + strings.Repeat("界", 33) + `","question":"Where?","options":[{"label":"West","description":"Use west."},{"label":"East","description":"Use east."}]}]}`, wantErr: true}, {name: "empty question", encoded: `{"questions":[{"id":"region","header":"Region","question":"","options":[{"label":"West","description":"Use west."},{"label":"East","description":"Use east."}]}]}`, wantErr: true}, {name: "one option", encoded: `{"questions":[{"id":"region","header":"Region","question":"Where?","options":[{"label":"West","description":"Use west."}]}]}`, wantErr: true}, {name: "four options", encoded: `{"questions":[{"id":"region","header":"Region","question":"Where?","options":[{"label":"1","description":"1"},{"label":"2","description":"2"},{"label":"3","description":"3"},{"label":"4","description":"4"}]}]}`, wantErr: true}, diff --git a/internal/toolcontract/generated/request_user_input_gen.go b/internal/toolcontract/generated/request_user_input_gen.go index dd49cdf..bb8ff6f 100644 --- a/internal/toolcontract/generated/request_user_input_gen.go +++ b/internal/toolcontract/generated/request_user_input_gen.go @@ -29,7 +29,7 @@ func (s *RequestUserInputInput) SetQuestions(val []RequestUserInputInputQuestion type RequestUserInputInputQuestionsItem struct { // Stable identifier unique within this batch. ID string `json:"id"` - // Short label for navigation. + // Short navigation label with at most 32 Unicode characters. Header string `json:"header"` // One concise question for the user. Question string `json:"question"` @@ -558,7 +558,7 @@ func (s *RequestUserInputInputQuestionsItem) Validate() error { if err := (validate.String{ MinLength: 1, MinLengthSet: true, - MaxLength: 12, + MaxLength: 32, MaxLengthSet: true, Email: false, Hostname: false, diff --git a/internal/toolcontract/schema/request_user_input.json b/internal/toolcontract/schema/request_user_input.json index a81c8e1..89f60e5 100644 --- a/internal/toolcontract/schema/request_user_input.json +++ b/internal/toolcontract/schema/request_user_input.json @@ -16,8 +16,8 @@ "header": { "type": "string", "minLength": 1, - "maxLength": 12, - "description": "Short label for navigation." + "maxLength": 32, + "description": "Short navigation label with at most 32 Unicode characters." }, "question": { "type": "string", diff --git a/web/packages/superagent-ui/README.md b/web/packages/superagent-ui/README.md index 3a7f3f2..a6c1b24 100644 --- a/web/packages/superagent-ui/README.md +++ b/web/packages/superagent-ui/README.md @@ -71,8 +71,15 @@ may collapse, but repeated calls separated by another model stay in place. `ConversationTurn.questions` safely projects durable `request_user_input` calls. `TimelineMessage.answeredInputCallId` associates the later user answer; -malformed and legacy data degrade to a neutral historical card without showing -raw tool JSON. Use `renderQuestion` to customize that card. +failed tool results remain visible with a failed status, while malformed and +legacy rejected calls without a result are shown as failed without exposing raw +tool JSON. +Use `renderQuestion` to customize that card. + +`ConversationView` owns message alignment and timestamp disclosure even when a +consumer supplies `renderMessage`. User messages are compact, right-aligned +bubbles; assistant messages remain left-aligned. Hovering or focusing either +message reveals its local timestamp below the content. Completed model and tool duration is `createdAt - startedAt`. Missing or negative legacy timing is omitted. Running durations update once per second but @@ -113,6 +120,8 @@ GitHub Flavored Markdown without enabling raw HTML. `PendingQuestionBatch` handles one-to-three-question navigation, predefined and free-form answers, local draft review, and one atomic ordered `onSubmit` call. +Navigation labels use an ellipsis in narrow layouts and expose the complete +header through a native tooltip. The application keeps ownership of the durable call ID and transport request. Key the component by that stable batch identity when replacing one pending batch with another so React resets its local drafts. diff --git a/web/packages/superagent-ui/src/ConversationView.test.tsx b/web/packages/superagent-ui/src/ConversationView.test.tsx index c4b347d..593a576 100644 --- a/web/packages/superagent-ui/src/ConversationView.test.tsx +++ b/web/packages/superagent-ui/src/ConversationView.test.tsx @@ -54,6 +54,115 @@ describe("ConversationView", () => { expect(container).toHaveTextContent("0 failed"); }); + it("distinguishes message roles and exposes their timestamps", () => { + render( + , + ); + + const userMessage = screen.getByLabelText("User message"); + const assistantMessage = screen.getByLabelText("Assistant message"); + expect(userMessage).toHaveClass("sa-conversation-message--user"); + expect(assistantMessage).toHaveClass("sa-conversation-message--assistant"); + expect(userMessage.querySelector(".sa-conversation-user")).not.toBeNull(); + expect( + assistantMessage.querySelector(".sa-conversation-assistant"), + ).not.toBeNull(); + expect(userMessage.querySelector("time")).toHaveAttribute( + "dateTime", + "2026-09-24T17:39:47Z", + ); + expect(assistantMessage.querySelector("time")).toHaveAttribute( + "dateTime", + "2026-09-24T17:40:12Z", + ); + }); + + it("renders legacy historical activity in its nearest turn", () => { + const { container } = render( + , + ); + + const turns = container.querySelectorAll(".sa-conversation-turn"); + expect(turns).toHaveLength(2); + expect(turns[0]).not.toHaveTextContent("Compacted conversation"); + expect(turns[1]).toHaveTextContent("Historical activity · 1 event"); + expect(turns[1]).toHaveTextContent("Compacted conversation"); + expect(container).not.toHaveTextContent("recovered events"); + }); + it("updates a running tool duration without announcing every tick", () => { vi.useFakeTimers(); vi.setSystemTime(base + 2_000); diff --git a/web/packages/superagent-ui/src/ConversationView.tsx b/web/packages/superagent-ui/src/ConversationView.tsx index 22db309..08fac3d 100644 --- a/web/packages/superagent-ui/src/ConversationView.tsx +++ b/web/packages/superagent-ui/src/ConversationView.tsx @@ -104,6 +104,40 @@ function DefaultMessage({ entry }: { entry: TimelineSequencedMessage }) { ); } +function messageTime(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? value + : new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(date); +} + +function MessageFrame({ + messageRole, + createdAt, + children, +}: { + messageRole: "user" | "assistant"; + createdAt: string; + children: ReactNode; +}) { + return ( +
+
{children}
+ +
+ ); +} + function DefaultTool({ item }: { item: ConversationToolWorkItem }) { return (
@@ -288,6 +322,36 @@ function WorkLog({ ); } +function HistoricalActivity({ + entries, +}: { + entries: readonly (TimelineActivityEntry | TimelineLiveTextEntry)[]; +}) { + if (entries.length === 0) return null; + return ( +
+ + Historical activity · {entries.length} event + {entries.length === 1 ? "" : "s"} + +
    + {entries.map((entry, index) => ( +
  1. + {typeof entry.value === "string" + ? entry.value + : entry.value.message} +
  2. + ))} +
+
+ ); +} + export function ConversationView({ messages, consumedUserMessages = [], @@ -356,44 +420,42 @@ export function ConversationView({ return (
- {presentation.earlierActivity.length > 0 ? ( -
- - Earlier activity · {presentation.earlierActivity.length} recovered - events - -
    - {presentation.earlierActivity.map((entry, index) => ( -
  1. - {typeof entry.value === "string" - ? entry.value - : entry.value.message} -
  2. - ))} -
-
- ) : null} + {presentation.turns.map((turn) => (
+ {turn.messages.map((entry) => { + if ( + entry.message.role !== "user" && + entry.message.role !== "assistant" + ) + return null; + const rendered = renderMessage?.(entry) ?? ( + + ); + return ( + + {rendered} + + ); + })} {turn.consumedUserMessages.map((entry) => ( -
- {entry.value.content} -
- ))} - {turn.messages.map((entry) => ( -
- {renderMessage?.(entry) ?? } -
+ +
{entry.value.content}
+
))} + {turn.questions.map((question) => (
diff --git a/web/packages/superagent-ui/src/PendingQuestionBatch.test.tsx b/web/packages/superagent-ui/src/PendingQuestionBatch.test.tsx index a661d4d..feafed5 100644 --- a/web/packages/superagent-ui/src/PendingQuestionBatch.test.tsx +++ b/web/packages/superagent-ui/src/PendingQuestionBatch.test.tsx @@ -101,6 +101,24 @@ describe("PendingQuestionBatch", () => { expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); expect(onSubmit).not.toHaveBeenCalled(); }); + + it("exposes the full navigation header when its label is truncated", () => { + const header = "Deployment region and compliance boundary"; + render( + , + ); + + const tab = screen.getByRole("button", { name: header }); + expect(tab).toHaveAttribute("title", header); + expect(tab.querySelector(".sa-question-tab-label")).toHaveTextContent( + header, + ); + }); }); function question( diff --git a/web/packages/superagent-ui/src/PendingQuestionBatch.tsx b/web/packages/superagent-ui/src/PendingQuestionBatch.tsx index fbf20cf..e7c2b14 100644 --- a/web/packages/superagent-ui/src/PendingQuestionBatch.tsx +++ b/web/packages/superagent-ui/src/PendingQuestionBatch.tsx @@ -119,12 +119,13 @@ export function PendingQuestionBatch({ : "sa-question-button sa-question-button--secondary secondary" } aria-current={index === currentIndex ? "step" : undefined} + title={candidate.header} key={candidate.id} onClick={() => { setCurrentIndex(index); }} > - {candidate.header} + {candidate.header} {questionAnswer(drafts[candidate.id]) !== "" && ( { ); }); + it("places historical activity in its associated or nearest turn", () => { + const messages = [ + message(1, "user", "first"), + message(2, "assistant", "first answer"), + message(10, "user", "second"), + message(11, "assistant", "second answer"), + ]; + const activities: TimelineActivityEntry[] = [ + { + resumeToken: "associated", + source: "activity", + createdAt: iso(3_000), + value: { + kind: "compacted", + message: "Compacted through message 2.", + messageSequence: 2, + }, + }, + { + resumeToken: "legacy", + source: "activity", + createdAt: iso(9_000), + value: { + kind: "compacted", + message: "Legacy compaction event.", + messageSequence: null, + }, + }, + ]; + + const view = buildConversationPresentation({ messages, activities }); + + expect(view.earlierActivity).toHaveLength(0); + expect(required(view.turns[0]).historicalActivities).toEqual([ + activities[0], + ]); + expect(required(view.turns[1]).historicalActivities).toEqual([ + activities[1], + ]); + }); + it("keeps current and resolved waits separate from tool execution", () => { const messages = [ message(1, "user", "build"), @@ -350,6 +391,77 @@ describe("conversation presentation", () => { expect(required(presentation.turns[0]).tools).toHaveLength(0); }); + it("marks a rejected input request as failed history", () => { + const messages = [ + message(1, "user", "start"), + message(2, "assistant", "", { + toolCalls: [ + { + id: "question-failed", + name: "request_user_input", + argumentsJson: JSON.stringify({ + questions: [ + { + id: "scope", + header: "A header rejected by the runtime", + question: "Full fix?", + options: [ + { label: "Yes", description: "Implement everything" }, + { label: "No", description: "Stop here" }, + ], + }, + ], + }), + }, + ], + }), + message(3, "tool", '{"status":"failed","error":"invalid_user_input"}', { + toolCallId: "question-failed", + toolName: "request_user_input", + }), + ]; + + const question = required( + required(buildConversationPresentation({ messages }).turns[0]) + .questions[0], + ); + expect(question.status).toBe("failed"); + expect(question.questions[0]?.header).toBe( + "A header rejected by the runtime", + ); + }); + + it("marks legacy rejected input without a tool result as failed", () => { + const messages = [ + message(1, "user", "start"), + message(2, "assistant", "", { + toolCalls: [ + { + id: "question-rejected-before-execution", + name: "request_user_input", + argumentsJson: JSON.stringify({ + questions: [ + { + id: "scope", + header: "Scope", + question: "Full fix?", + options: [], + }, + ], + }), + }, + ], + }), + message(3, "assistant", "Please clarify the scope."), + ]; + + const question = required( + required(buildConversationPresentation({ messages }).turns[0]) + .questions[0], + ); + expect(question.status).toBe("failed"); + }); + it("formats durations and unions parallel work safely", () => { expect(formatDuration(500)).toBe("<1s"); expect(formatDuration(12_000)).toBe("12s"); diff --git a/web/packages/superagent-ui/src/conversationPresentation.ts b/web/packages/superagent-ui/src/conversationPresentation.ts index de311ae..fe2c8a9 100644 --- a/web/packages/superagent-ui/src/conversationPresentation.ts +++ b/web/packages/superagent-ui/src/conversationPresentation.ts @@ -72,7 +72,7 @@ export interface ConversationQuestionItem { key: string; callId: string; questions: ConversationQuestion[]; - status: "pending" | "answered" | "cancelled" | "historical"; + status: "pending" | "answered" | "cancelled" | "failed"; answer: TimelineSequencedMessage | null; isMalformed: boolean; } @@ -86,6 +86,7 @@ export interface ConversationTurn { operations: ConversationWorkItem[]; questions: ConversationQuestionItem[]; activities: TimelineActivityEntry[]; + historicalActivities: TimelineActivityEntry[]; durationMs: number | null; workDurationMs: number | null; waitDurationMs: number | null; @@ -109,6 +110,11 @@ export interface ConversationPresentationInput { const MODEL_TERMINAL = new Set(["model_completed", "model_failed"]); const MODEL_LIFECYCLE = new Set(["model_started", ...MODEL_TERMINAL]); +const HISTORICAL_ACTIVITY = new Set([ + "compacted", + "compaction_failed", + "snapshot_required", +]); function timestamp(value: string | null | undefined): number | null { const parsed = Date.parse(value ?? ""); @@ -196,6 +202,7 @@ function groupDurableTurns(messages: readonly TimelineSequencedMessage[]) { operations: [], questions: [], activities: [], + historicalActivities: [], durationMs: null, workDurationMs: null, waitDurationMs: null, @@ -253,13 +260,18 @@ export function buildConversationPresentation( values.push(activity); activityBySequence.set(activity.value.messageSequence, values); } - const turn = activity.value.callId + let turn = activity.value.callId ? callToTurn.get(activity.value.callId) : activity.value.messageSequence === null ? undefined : sequenceToTurn.get(activity.value.messageSequence); - if (turn) turn.activities.push(activity); - else if (!MODEL_LIFECYCLE.has(activity.value.kind)) + if (turn === undefined && HISTORICAL_ACTIVITY.has(activity.value.kind)) + turn = nearestTurnByTime(turns, activity.createdAt); + if (turn) { + turn.activities.push(activity); + if (HISTORICAL_ACTIVITY.has(activity.value.kind)) + turn.historicalActivities.push(activity); + } else if (!MODEL_LIFECYCLE.has(activity.value.kind)) earlierActivity.push(activity); } @@ -278,6 +290,7 @@ export function buildConversationPresentation( operations: [], questions: [], activities: [], + historicalActivities: [], durationMs: null, workDurationMs: null, waitDurationMs: null, @@ -376,6 +389,7 @@ export function buildConversationPresentation( buildQuestionItem( call, messages, + resultByCall.get(call.id) ?? null, activityByCall, pendingWaitByCall, ), @@ -618,6 +632,35 @@ export function buildConversationPresentation( return { turns, earlierActivity }; } +function nearestTurnByTime( + turns: readonly ConversationTurn[], + createdAt: string, +): ConversationTurn | undefined { + const activityTime = timestamp(createdAt); + if (activityTime === null) return undefined; + let nearest: ConversationTurn | undefined; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const turn of turns) { + const times = turn.messages + .map((entry) => timestamp(entry.message.createdAt)) + .filter((value): value is number => value !== null); + if (times.length === 0) continue; + const start = Math.min(...times); + const end = Math.max(...times); + const distance = + activityTime < start + ? start - activityTime + : activityTime > end + ? activityTime - end + : 0; + if (distance < nearestDistance) { + nearest = turn; + nearestDistance = distance; + } + } + return nearest; +} + function maximumAttempt( activities: readonly TimelineActivityEntry[], ): number | null { @@ -667,6 +710,7 @@ function toolStartedAt( function buildQuestionItem( call: TimelineToolCall, messages: readonly TimelineSequencedMessage[], + result: TimelineSequencedMessage | null, activities: ReadonlyMap, pending: ReadonlyMap, ): ConversationQuestionItem { @@ -677,22 +721,44 @@ function buildQuestionItem( const cancelled = (activities.get(call.id) ?? []).some( (entry) => entry.value.kind === "user_input_cancelled", ); + const failed = + isFailedToolResult(result) || + (activities.get(call.id) ?? []).some( + (entry) => entry.value.kind === "tool_failed", + ); return { key: `question-${call.id}`, callId: call.id, questions: parsed.questions, status: answer ? "answered" - : cancelled - ? "cancelled" - : pending.has(call.id) - ? "pending" - : "historical", + : failed + ? "failed" + : cancelled + ? "cancelled" + : pending.has(call.id) + ? "pending" + : "failed", answer, isMalformed: parsed.isMalformed, }; } +function isFailedToolResult(result: TimelineSequencedMessage | null): boolean { + if (result === null) return false; + try { + const value: unknown = JSON.parse(result.message.content); + return ( + value !== null && + typeof value === "object" && + "status" in value && + value.status === "failed" + ); + } catch { + return false; + } +} + function parseQuestions(argumentsJson: string): { questions: ConversationQuestion[]; isMalformed: boolean; diff --git a/web/packages/superagent-ui/styles.css b/web/packages/superagent-ui/styles.css index 7879d60..2b07dc0 100644 --- a/web/packages/superagent-ui/styles.css +++ b/web/packages/superagent-ui/styles.css @@ -346,9 +346,20 @@ } .sa-question-tabs .sa-question-button { + display: flex; + min-width: 0; + max-width: min(12rem, 100%); + align-items: center; padding: 6px 9px; } +.sa-question-tab-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .sa-question-button--active, .sa-question-option--selected { color: var(--sa-button-text-color, white); @@ -836,13 +847,73 @@ gap: 16px; } -.sa-conversation-user { +.sa-conversation-message { + display: flex; + flex-direction: column; + min-width: 0; + width: 100%; + outline: none; +} + +.sa-conversation-message__content { + display: flex; + min-width: 0; + flex-direction: column; +} + +.sa-conversation-message.sa-conversation-message--user { + align-items: flex-end; align-self: flex-end; - max-width: 92%; + width: fit-content; + max-width: min(76%, 42rem); +} + +.sa-conversation-message--user .sa-conversation-message__content { + align-items: flex-end; + width: 100%; +} + +.sa-conversation-message--user .sa-conversation-message__content > * { + width: fit-content; + max-width: 100%; + overflow-wrap: anywhere; + border: 1px solid var(--sa-user-message-border, #d9dfd2); + border-radius: 14px 14px 4px 14px; + padding: 10px 13px; + background: var(--sa-user-message-background, #edf2e8); +} + +.sa-conversation-message--assistant { + align-items: flex-start; +} + +.sa-conversation-message--assistant .sa-conversation-message__content { + width: 100%; +} + +.sa-message-timestamp { + height: 1.25em; + margin-top: 4px; + overflow: hidden; + color: var(--sa-faint-color, #8b93a7); + font-size: 11px; + line-height: 1.25; + opacity: 0; + transition: opacity 120ms ease; +} + +.sa-conversation-message:hover .sa-message-timestamp, +.sa-conversation-message:focus-within .sa-message-timestamp { + opacity: 1; +} + +.sa-conversation-message--user .sa-message-timestamp { + align-self: flex-end; +} + +.sa-conversation-user { white-space: pre-wrap; - border-radius: 10px 10px 3px 10px; - padding: 9px 12px; - background: var(--sa-raised-background, #eef2e8); + overflow-wrap: anywhere; } .sa-work-log, @@ -856,6 +927,12 @@ background: var(--sa-panel-background, #fff); } +.sa-work-log { + color: var(--sa-muted-color, #667066); + font-size: var(--sa-work-log-font-size, 12px); + line-height: 1.45; +} + .sa-work-log--failed { border-color: var(--sa-danger-color, #9b2c25); } @@ -875,6 +952,18 @@ padding: 10px 12px; } +.sa-work-log > summary > strong { + color: var(--sa-text-color, #1d2530); + font-size: inherit; + font-weight: 650; +} + +.sa-work-log + > summary + > span:not(.sa-work-log__durations):not(.sa-running-indicator) { + font-size: 11px; +} + .sa-work-log__durations { margin-left: auto; display: flex; @@ -905,6 +994,9 @@ border: 1px solid var(--sa-border, #d8dfd2); border-radius: 0.75rem; background: var(--sa-surface, #fff); + color: var(--sa-muted-color, #667066); + font-size: var(--sa-question-font-size, 12px); + line-height: 1.5; } .sa-question-card__heading { @@ -912,6 +1004,12 @@ align-items: center; justify-content: space-between; gap: 0.75rem; + color: var(--sa-faint-color, #8b93a7); + font-size: 11px; +} + +.sa-question-card__heading > strong { + font-weight: 650; } .sa-question-card section + section { diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 991e1f8..41fc68e 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -631,7 +631,7 @@ describe("App", () => { const history = await screen.findByLabelText("Conversation history"); await within(history).findByText("Running local-tools.search."); expect( - within(history).getByText(/Earlier activity · 3 recovered events/), + within(history).getByText(/Historical activity · 3 events/), ).toBeInTheDocument(); expect( within(history).getByText("Calling mock/reliable."), diff --git a/web/src/ConversationView.tsx b/web/src/ConversationView.tsx index 7fb8406..e041e8a 100644 --- a/web/src/ConversationView.tsx +++ b/web/src/ConversationView.tsx @@ -316,24 +316,20 @@ export function ConversationView({ ) { return null; } - return ( -
-
- {messageRoleLabel(message.role)} - -
- {message.content !== "" && - (message.role === MessageRole.ASSISTANT ? ( - - ) : ( -

{message.content}

- ))} -
+ +
+ ) : ( +
+ {message.content} +
); }} /> @@ -867,10 +863,6 @@ function statusLabel(value: string): string { .join(" "); } -function messageRoleLabel(role: string): string { - return role === "tool" ? "Tool result" : statusLabel(role); -} - function taskIcon(status: TaskStatus): string { switch (status) { case TaskStatus.COMPLETED: @@ -881,13 +873,3 @@ function taskIcon(status: TaskStatus): string { return "○"; } } - -function formatTime(value: string): string { - const date = new Date(value); - return Number.isNaN(date.getTime()) - ? value - : new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - }).format(date); -} diff --git a/web/src/api/generated/types.gen.ts b/web/src/api/generated/types.gen.ts index bbace5a..d10f298 100644 --- a/web/src/api/generated/types.gen.ts +++ b/web/src/api/generated/types.gen.ts @@ -331,6 +331,9 @@ export type PendingUserInput = { export type UserInputQuestion = { id: string; + /** + * Short navigation label with at most 32 Unicode characters. + */ header: string; question: string; options: Array; diff --git a/web/src/app.css b/web/src/app.css index 81c8c36..5608f18 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -437,23 +437,16 @@ button.secondary { color: #344054; } .message-bubble { - width: min(86%, 720px); + width: 100%; min-width: 0; max-width: 100%; - padding: 13px 15px; - border: 1px solid #e3e7ed; - border-radius: 15px; - background: #f8f9fb; overflow-wrap: anywhere; } .message-bubble.user { - justify-self: end; - border-color: #d7dcff; - background: #f1f2ff; + justify-self: stretch; } .message-bubble.assistant { - justify-self: start; - background: white; + justify-self: stretch; } .message-bubble.tool { width: 100%; @@ -763,9 +756,6 @@ pre { .plan-content { padding: 0 14px 14px; } - .message-bubble { - width: 94%; - } .messages-card { min-height: 320px; padding: 14px; diff --git a/web/tests/full-stack.spec.ts b/web/tests/full-stack.spec.ts index fddcccd..a18902b 100644 --- a/web/tests/full-stack.spec.ts +++ b/web/tests/full-stack.spec.ts @@ -108,17 +108,21 @@ test("renders chronological transient activity and durable queue interactions", "/reason Checked the constraints | Durable answer", ), ).toHaveCount(0); - const recoveredActivity = history.locator("details.sa-earlier-activity"); - await expect(recoveredActivity.locator("summary")).toContainText( - "1 recovered event", + const historicalActivity = history.locator("details.sa-earlier-activity"); + await expect(historicalActivity.locator("summary")).toContainText( + "Historical activity · 1 event", ); - await expect(recoveredActivity).toContainText( + await expect(historicalActivity).toContainText( "Consumed 1 queued user message.", ); expect(snapshots.length).toBeGreaterThanOrEqual(2); const workLog = history.locator("details.sa-work-log").last(); await expect(workLog).toHaveCount(1); + await expect(workLog.locator(":scope > summary")).toHaveCSS( + "font-size", + "12px", + ); await expect(workLog).not.toContainText("Checked the constraints"); await expect(composer).toBeFocused(); await workLog.locator(":scope > summary").click(); @@ -143,6 +147,42 @@ test("renders chronological transient activity and durable queue interactions", hasText: "/reason Checked the constraints | Durable answer", }), ).toHaveCount(1); + const userMessage = history.locator(".message-bubble.user").filter({ + hasText: "/reason Checked the constraints | Durable answer", + }); + const assistantMessage = history + .locator(".message-bubble.assistant") + .filter({ hasText: "Durable answer" }); + const userBox = await userMessage.boundingBox(); + const assistantBox = await assistantMessage.boundingBox(); + expect(userBox).not.toBeNull(); + expect(assistantBox).not.toBeNull(); + expect(userBox?.x).toBeGreaterThan(assistantBox?.x ?? 0); + expect(userBox?.width).toBeLessThan(assistantBox?.width ?? 0); + await expect(userMessage.locator(".sa-conversation-user")).toHaveCSS( + "background-color", + "rgb(237, 242, 232)", + ); + await expect(userMessage.locator(".sa-conversation-user")).toHaveCSS( + "border-top-style", + "solid", + ); + await expect(userMessage.locator(".sa-message-timestamp")).toHaveCSS( + "opacity", + "0", + ); + await userMessage.hover(); + await expect(userMessage.locator(".sa-message-timestamp")).toHaveCSS( + "opacity", + "1", + ); + expect(await userMessage.boundingBox()).toEqual(userBox); + await assistantMessage.hover(); + await expect(assistantMessage.locator(".sa-message-timestamp")).toHaveCSS( + "opacity", + "1", + ); + expect(await assistantMessage.boundingBox()).toEqual(assistantBox); await expect(history.locator(".live-message")).toHaveCount(0); await expect(history.locator(".activity-entry")).toHaveCount(0); await expect(workLog.getByText("Model replied", { exact: true })).toHaveCount( @@ -317,7 +357,7 @@ test("removes a consumed queued message before the next Snapshot completes", asy ); const consumedMessage = page .getByRole("region", { name: "Conversation history" }) - .locator(".sa-conversation-user, .message-bubble.user") + .locator(".message-bubble.user") .filter({ hasText: "consume this queued message" }); await expect(consumedMessage).toHaveCount(1); expect(snapshotStatuses).toHaveLength(completedSnapshots); @@ -358,6 +398,14 @@ test("submits a question answer before steering and queued messages", async ({ const questions = page.getByRole("region", { name: "Agent questions" }); await expect(questions).toBeVisible(); + const regionTab = questions.locator( + '.sa-question-tabs button[title="Region"]', + ); + await expect(regionTab).toHaveAttribute("title", "Region"); + await expect(regionTab.locator(".sa-question-tab-label")).toHaveCSS( + "text-overflow", + "ellipsis", + ); const steeringRequest = queue .locator(".queue-message") .filter({ hasText: "steered after question" }); @@ -1216,6 +1264,33 @@ test("recovers visibly after invalid write_todos arguments", async ({ expect(snapshot.description.plan).toBeFalsy(); }); +test("marks rejected request_user_input history as failed", async ({ + page, +}) => { + test.setTimeout(90_000); + await startAgent(page); + const composer = page.getByRole("textbox", { name: "Message" }); + + await composer.fill("/invalid-question"); + await page.getByRole("button", { name: "Send" }).click(); + + const failedQuestion = page.locator( + '.sa-question-card[data-status="failed"]', + ); + await expect(failedQuestion).toBeVisible({ timeout: 20_000 }); + await expect(failedQuestion).toContainText("Assistant requested input"); + await expect(failedQuestion.locator(".sa-question-card__heading")).toHaveCSS( + "font-size", + "11px", + ); + await expect(failedQuestion).toHaveCSS("font-size", "12px"); + await expect(failedQuestion).toContainText("failed"); + await expect( + page.getByRole("region", { name: "Agent questions" }), + ).toHaveCount(0); + await expectAgentWaitingForMessage(page); +}); + test("disables busy Plan actions and continues a stalled active Plan", async ({ page, }) => { @@ -1649,9 +1724,7 @@ async function expectAgentWaitingForMessage(page: Page): Promise { } async function conversationUserText(history: Locator): Promise { - return history - .locator(".message-bubble.user, .sa-conversation-user") - .allTextContents(); + return history.locator(".message-bubble.user").allTextContents(); } async function expectMessageQueueExpanded(queue: Locator): Promise {