Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/adr/0005-atomic-question-answers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions internal/agent/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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,
)),
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
)),
Expand Down
7 changes: 7 additions & 0 deletions internal/agent/flow_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 10 additions & 5 deletions internal/agent/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions internal/agent/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@

package agent

import "testing"
import (
"strings"
"testing"
)

func TestSequenceKeyUsesUnpaddedDecimal(t *testing.T) {
tests := map[Sequence]string{
Expand Down Expand Up @@ -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."}},
}}
Expand Down
3 changes: 2 additions & 1 deletion internal/api/generated/oas_schemas_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/api/generated/oas_validators_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 24 additions & 1 deletion internal/model/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 "):])
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/toolcontract/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
4 changes: 2 additions & 2 deletions internal/toolcontract/generated/request_user_input_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions internal/toolcontract/schema/request_user_input.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 11 additions & 2 deletions web/packages/superagent-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
109 changes: 109 additions & 0 deletions web/packages/superagent-ui/src/ConversationView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,115 @@ describe("ConversationView", () => {
expect(container).toHaveTextContent("0 failed");
});

it("distinguishes message roles and exposes their timestamps", () => {
render(
<ConversationView
messages={[
{
sequence: 1,
message: {
role: "user",
content: "Build an approval app",
toolCalls: [],
toolCallId: null,
toolName: null,
createdAt: "2026-09-24T17:39:47Z",
},
},
{
sequence: 2,
message: {
role: "assistant",
content: "I will inspect the repository.",
toolCalls: [],
toolCallId: null,
toolName: null,
createdAt: "2026-09-24T17:40:12Z",
},
},
]}
/>,
);

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(
<ConversationView
messages={[
{
sequence: 1,
message: {
role: "user",
content: "first",
toolCalls: [],
toolCallId: null,
toolName: null,
createdAt: new Date(base + 1_000).toISOString(),
},
},
{
sequence: 2,
message: {
role: "assistant",
content: "first answer",
toolCalls: [],
toolCallId: null,
toolName: null,
createdAt: new Date(base + 2_000).toISOString(),
},
},
{
sequence: 10,
message: {
role: "user",
content: "second",
toolCalls: [],
toolCallId: null,
toolName: null,
createdAt: new Date(base + 10_000).toISOString(),
},
},
]}
activities={[
{
resumeToken: "legacy-compaction",
source: "activity",
createdAt: new Date(base + 9_000).toISOString(),
value: {
kind: "compacted",
message: "Compacted conversation through message 2.",
messageSequence: null,
},
},
]}
/>,
);

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);
Expand Down
Loading