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
26 changes: 14 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,18 @@ Agent state. Streams reduce latency but never become recovery state.

## Package ownership

| Package | Owns | Must not own |
| ---------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
| `agent` | Public constructors, stable application types, model/tool extension interfaces | Private Dex descriptors, provider protocols, process lifecycle |
| `model` | Public built-in provider adapters, router, and process-memory credentials | Dex resources, provider protocol implementation, process lifecycle |
| `internal/agent` | Domain IDs/enums, Flow graph, private Dex descriptors, command client | Provider protocols, HTTP transport models, global configuration |
| `internal/api` | ogen implementation, validation mapping, problem responses | Handwritten routes, generated-model duplicates, durable state |
| `internal/app` | Dependency construction, goroutine ownership, startup and shutdown | Domain decisions or provider-specific payloads |
| `internal/config` | Environment parsing and validated immutable sections | Runtime singletons or secret logging |
| `internal/model` | Provider routing, protocol adapters, in-memory credential lookup | Dex resources or HTTP API responses |
| `internal/mcp` | Trusted server config, discovery, policy, single-attempt sessions, brokers | Agent state transitions, retry loops, or exported Dex resources |
| `web` | React portal and generated Fetch client | Handwritten API response types or durable-state reconstruction |
| `web/packages/superagent-ui` | Transport-free React conversation components, timeline merge/sort, ToolCall cards, and local interaction behavior | Dex/API clients, routing, durable state, or product workflows |
| Package | Owns | Must not own |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `agent` | Public constructors, stable application types, model/tool extension interfaces | Private Dex descriptors, provider protocols, process lifecycle |
| `model` | Public built-in provider adapters, router, and process-memory credentials | Dex resources, provider protocol implementation, process lifecycle |
| `internal/agent` | Domain IDs/enums, Flow graph, private Dex descriptors, command client | Provider protocols, HTTP transport models, global configuration |
| `internal/api` | ogen implementation, validation mapping, problem responses | Handwritten routes, generated-model duplicates, durable state |
| `internal/app` | Dependency construction, goroutine ownership, startup and shutdown | Domain decisions or provider-specific payloads |
| `internal/config` | Environment parsing and validated immutable sections | Runtime singletons or secret logging |
| `internal/model` | Provider routing, protocol adapters, in-memory credential lookup | Dex resources or HTTP API responses |
| `internal/mcp` | Trusted server config, discovery, policy, single-attempt sessions, brokers | Agent state transitions, retry loops, or exported Dex resources |
| `web` | React portal and generated Fetch client | Handwritten API response types or durable-state reconstruction |
| `web/packages/superagent-ui` | Transport-free React conversation components, timeline merge/sort, ToolCall cards, and local interaction behavior | Dex/API clients, routing, durable state, or product workflows |

Interfaces live at their consuming boundary. Concrete single-use components do
not receive speculative interfaces, and there is no general-purpose helpers
Expand Down Expand Up @@ -226,6 +226,8 @@ Stream loss is corrected by Snapshot.

Manual tool recovery moves programmatic focus to its labelled warning region.
The heading remains the accessible label without becoming a focused control.
Affected calls use bounded argument disclosures and explicit decision cards so
large payloads cannot dominate the pending-action surface.

Consumed user bubbles are anchored after the latest explicit message sequence
seen before the consumption event. The event's own message sequence associates
Expand Down
4 changes: 4 additions & 0 deletions web/packages/superagent-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ message reveals its local timestamp below the content.
appears. The existing warning card communicates that focus without drawing a
browser-default input outline around its heading.

Each affected call uses a compact card with formatted, height-bounded arguments
and two explicit recovery choices. The disclosure keeps a visible inset focus
indicator without resembling a text input.

Completed model and tool duration is `createdAt - startedAt`. Missing or
negative legacy timing is omitted. Running durations update once per second but
are excluded from live-region announcements. Stream timestamps remain ordering
Expand Down
30 changes: 29 additions & 1 deletion web/packages/superagent-ui/src/ToolRecoveryPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("ToolRecoveryPanel", () => {
screen.getByRole("region", { name: "Execution outcome is unknown" }),
).toHaveFocus();
const continueOptions = screen.getAllByRole("radio", {
name: "Continue with unknown result",
name: /Continue with unknown result/,
});
const secondContinueOption = continueOptions.at(1);
if (secondContinueOption === undefined) {
Expand Down Expand Up @@ -93,4 +93,32 @@ describe("ToolRecoveryPanel", () => {
).toBeDisabled();
expect(onResolve).not.toHaveBeenCalled();
});

it("formats valid arguments and preserves malformed payloads", () => {
render(
<ToolRecoveryPanel
onResolve={vi.fn()}
recovery={{
recoveryId: "recovery-4",
calls: [
{
callId: "call-c",
toolName: "files__read",
argumentsJson: '{"path":"a.txt"}',
errorType: "timeout",
},
{
callId: "call-d",
toolName: "files__search",
argumentsJson: "not-json",
errorType: "connection_error",
},
],
}}
/>,
);

expect(screen.getByText(/\{\s+"path": "a\.txt"\s+\}/)).toBeInTheDocument();
expect(screen.getByText("not-json")).toBeInTheDocument();
});
});
122 changes: 82 additions & 40 deletions web/packages/superagent-ui/src/ToolRecoveryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,46 +86,80 @@ export function ToolRecoveryPanel({
keeps the same call ID, but the MCP server may not deduplicate it.
</p>
<div className="sa-tool-recovery-calls">
{recovery.calls.map((call) => (
<fieldset className="sa-tool-recovery-call" key={call.callId}>
<legend>{call.toolName}</legend>
<small>Error type: {call.errorType}</small>
<details onToggle={revealOpenedDetails}>
<summary>Arguments</summary>
<pre>{call.argumentsJson}</pre>
</details>
<label>
<input
type="radio"
name={`recovery-${call.callId}`}
checked={selections[call.callId] === "retry"}
disabled={isDisabled}
onChange={() => {
setSelections((current) => ({
...current,
[call.callId]: "retry",
}));
}}
/>{" "}
Retry
</label>
<label>
<input
type="radio"
name={`recovery-${call.callId}`}
checked={selections[call.callId] === "continue_with_unknown"}
disabled={isDisabled}
onChange={() => {
setSelections((current) => ({
...current,
[call.callId]: "continue_with_unknown",
}));
}}
/>{" "}
Continue with unknown result
</label>
</fieldset>
))}
{recovery.calls.map((call, index) => {
const callHeadingID = `${headingID}-call-${String(index)}`;
const retryInputID = `${callHeadingID}-retry`;
const continueInputID = `${callHeadingID}-continue`;
const isRetrySelected = selections[call.callId] === "retry";
return (
<article className="sa-tool-recovery-call" key={call.callId}>
<div className="sa-tool-recovery-call-header">
<h3 id={callHeadingID}>{call.toolName}</h3>
<span className="sa-tool-recovery-error">
Error · {call.errorType}
</span>
</div>
<details
className="sa-tool-recovery-arguments"
onToggle={revealOpenedDetails}
>
<summary>
<span>Arguments</span>
<span aria-hidden="true" className="sa-tool-recovery-chevron">
›
</span>
</summary>
<pre>{formatArguments(call.argumentsJson)}</pre>
</details>
<div
aria-labelledby={callHeadingID}
className="sa-tool-recovery-choices"
role="radiogroup"
>
<label
className={`sa-tool-recovery-choice${isRetrySelected ? " is-selected" : ""}`}
htmlFor={retryInputID}
>
<input
id={retryInputID}
type="radio"
name={`recovery-${call.callId}`}
checked={isRetrySelected}
disabled={isDisabled}
onChange={() => {
setSelections((current) => ({
...current,
[call.callId]: "retry",
}));
}}
/>
<strong>Retry</strong>
<small>Run the same call again</small>
</label>
<label
className={`sa-tool-recovery-choice${isRetrySelected ? "" : " is-selected"}`}
htmlFor={continueInputID}
>
<input
id={continueInputID}
type="radio"
name={`recovery-${call.callId}`}
checked={!isRetrySelected}
disabled={isDisabled}
onChange={() => {
setSelections((current) => ({
...current,
[call.callId]: "continue_with_unknown",
}));
}}
/>
<strong>Continue with unknown result</strong>
<small>Assume the call may have completed</small>
</label>
</div>
</article>
);
})}
</div>
<div className="sa-tool-recovery-actions">
<button type="button" disabled={isDisabled} onClick={submitResume}>
Expand Down Expand Up @@ -164,3 +198,11 @@ function initialSelections(
for (const call of calls) selections[call.callId] = "retry";
return selections;
}

function formatArguments(argumentsJson: string): string {
try {
return JSON.stringify(JSON.parse(argumentsJson) as unknown, null, 2);
} catch {
return argumentsJson;
}
}
Loading