Skip to content

Commit 930e674

Browse files
committed
refactor(chat): drop "Latest" sentinel; show concrete artifact versions
The "Latest" label in the attach-artifact dialog implied live-sync semantics that don't match reality — when the agent processes the message, the selected version is snapshot into its namespace and frozen. Replace it with the actual resolved-latest version number so the snapshot is visible up front. Backend: - The bulk artifacts endpoint now returns the resolved-latest `version` per record. Free to compute: load_artifact_content_or_metadata already calls list_versions internally to resolve "latest"; we just stop discarding the result. Frontend: - AttachArtifactDialog's per-row picker drops the "Latest" sentinel. Default value = the artifact's backend-reported version. The full version list still lazy-loads on first open; until then the picker shows just the latest as its only option. - The emitted URI always carries `?version=N` (concrete, never implicit). The agent translator's "latest"-fallback path remains for legacy URIs but is no longer exercised by the dialog. Tests + the Storybook play test updated to match the new contract.
1 parent 44e0560 commit 930e674

7 files changed

Lines changed: 87 additions & 54 deletions

File tree

client/webui/frontend/src/lib/api/artifacts/hooks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function transformArtifacts(artifacts: BulkArtifactsResponse["artifacts"]): Arti
2020
mime_type: artifact.mimeType ?? "application/octet-stream",
2121
last_modified: artifact.lastModified ?? new Date().toISOString(),
2222
uri: artifact.uri ?? "",
23+
version: artifact.version ?? undefined,
2324
sessionId: artifact.sessionId,
2425
sessionName: artifact.sessionName,
2526
projectId: artifact.projectId ?? undefined,

client/webui/frontend/src/lib/api/artifacts/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface BulkArtifactsResponse {
1010
mimeType: string | null;
1111
lastModified: string | null;
1212
uri: string | null;
13+
/** Resolved-latest version reported by the backend at list time. */
14+
version: number | null;
1315
sessionId: string;
1416
sessionName: string | null;
1517
projectId: string | null;

client/webui/frontend/src/lib/components/chat/file/AttachArtifactDialog.tsx

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,37 @@ import { formatBytes } from "@/lib/utils/format";
1010
import { getFileTypeColor } from "./FileIcon";
1111
import { getExtensionLabel } from "./attachmentUtils";
1212

13-
const LATEST_VERSION_VALUE = "latest";
14-
1513
/**
16-
* Inline per-row version picker. Lazy-loads versions on first open so a
17-
* dialog with 30+ artifacts doesn't fan out N list_versions calls upfront.
18-
* Mirrors the look of the side-panel `ArtifactDetails` selector.
14+
* Inline per-row version picker. The default is the resolved-latest version
15+
* provided by the bulk-list endpoint (a concrete number, not a "Latest"
16+
* sentinel — that label would have falsely implied live-sync semantics; in
17+
* practice the selected version is snapshot into the agent's namespace at
18+
* attach time and frozen). The full version list is lazy-loaded on first
19+
* open so a dialog with 30+ artifacts doesn't fan out N list_versions calls
20+
* upfront. Mirrors the look of the side-panel `ArtifactDetails` selector.
1921
*/
2022
const ArtifactVersionPicker: React.FC<{
2123
artifact: ArtifactWithSession;
22-
value: string;
23-
onValueChange: (value: string) => void;
24+
value: number;
25+
onValueChange: (value: number) => void;
2426
}> = ({ artifact, value, onValueChange }) => {
2527
const [hasOpened, setHasOpened] = useState(false);
26-
const { data: versions, isLoading } = useArtifactVersions({
28+
const { data: lazyVersions, isLoading } = useArtifactVersions({
2729
sessionId: artifact.sessionId,
2830
projectId: artifact.projectId,
2931
filename: artifact.filename,
3032
enabled: hasOpened,
3133
});
3234

35+
// Until the user opens the picker we only know the latest version (from
36+
// the bulk-list response). Render that single option; the lazy fetch
37+
// expands the list once they look.
38+
const versions = lazyVersions ?? (typeof artifact.version === "number" ? [artifact.version] : []);
39+
3340
return (
3441
<Select
35-
value={value}
36-
onValueChange={onValueChange}
42+
value={value.toString()}
43+
onValueChange={v => onValueChange(parseInt(v, 10))}
3744
onOpenChange={open => {
3845
if (open) setHasOpened(true);
3946
}}
@@ -46,20 +53,19 @@ const ArtifactVersionPicker: React.FC<{
4653
e.stopPropagation();
4754
}}
4855
>
49-
<SelectValue placeholder="Latest" />
56+
<SelectValue />
5057
</SelectTrigger>
5158
<SelectContent
5259
onClick={e => {
5360
e.stopPropagation();
5461
}}
5562
>
56-
<SelectItem value={LATEST_VERSION_VALUE}>Latest</SelectItem>
5763
{isLoading && (
5864
<div className="flex items-center justify-center py-1">
5965
<Spinner size="small" variant="muted" />
6066
</div>
6167
)}
62-
{versions?.map(v => (
68+
{versions.map(v => (
6369
<SelectItem key={v} value={v.toString()}>
6470
Version {v}
6571
</SelectItem>
@@ -81,18 +87,16 @@ const resolveArtifactUri = (artifact: ArtifactWithSession): string | null => {
8187
};
8288

8389
/**
84-
* Apply a per-row version override to a canonical artifact URI. "Latest" maps
85-
* to omitting `?version=` so the agent-side translator resolves the latest at
86-
* fetch time. A specific version is encoded as `?version=N`, replacing any
87-
* pre-existing query.
90+
* Apply a concrete version to a canonical artifact URI as `?version=N`,
91+
* replacing any pre-existing query. The version is always explicit (the
92+
* dialog resolved "latest" to a concrete number when the row was rendered)
93+
* so the snapshot semantics are visible end-to-end.
8894
*/
89-
const applyVersionToUri = (uri: string, version: string): string => {
95+
const applyVersionToUri = (uri: string, version: number): string => {
9096
try {
9197
const url = new URL(uri);
9298
url.search = "";
93-
if (version !== LATEST_VERSION_VALUE) {
94-
url.searchParams.set("version", version);
95-
}
99+
url.searchParams.set("version", version.toString());
96100
return url.toString();
97101
} catch {
98102
return uri;
@@ -116,9 +120,10 @@ export const AttachArtifactDialog: React.FC<AttachArtifactDialogProps> = ({ isOp
116120
const { data: artifacts = [], isLoading, hasMore, loadMore, isLoadingMore } = useAllArtifacts(debouncedSearch || undefined);
117121

118122
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
119-
// Per-row version override; absent entry = "latest". Keyed the same way
120-
// as `selectedKeys` so they can be looked up together when attaching.
121-
const [versionByKey, setVersionByKey] = useState<Map<string, string>>(new Map());
123+
// Per-row version override; absent entry = the artifact's own
124+
// backend-resolved latest version. Keyed the same way as `selectedKeys` so
125+
// they can be looked up together when attaching.
126+
const [versionByKey, setVersionByKey] = useState<Map<string, number>>(new Map());
122127

123128
// Reset state when dialog closes so it opens fresh next time.
124129
useEffect(() => {
@@ -169,10 +174,12 @@ export const AttachArtifactDialog: React.FC<AttachArtifactDialogProps> = ({ isOp
169174
.filter(({ artifact }) => selectedKeys.has(keyFor(artifact)))
170175
.map(({ artifact, resolvedUri }) => {
171176
const baseUri = resolvedUri ?? artifact.uri;
172-
const versionChoice = versionByKey.get(keyFor(artifact)) ?? LATEST_VERSION_VALUE;
173-
// Encode the per-row version override on the URI so the
174-
// chat submit pipeline doesn't need to re-resolve it.
175-
const finalUri = baseUri ? applyVersionToUri(baseUri, versionChoice) : baseUri;
177+
// Use the per-row override if set, else the artifact's
178+
// backend-resolved latest version. Either way, the URI
179+
// always carries an explicit `?version=N` — no implicit
180+
// "latest" semantics survive into the submit pipeline.
181+
const versionChoice = versionByKey.get(keyFor(artifact)) ?? artifact.version;
182+
const finalUri = baseUri && typeof versionChoice === "number" ? applyVersionToUri(baseUri, versionChoice) : baseUri;
176183
return { ...artifact, uri: finalUri };
177184
}),
178185
[visibleArtifacts, selectedKeys, versionByKey]
@@ -243,17 +250,19 @@ export const AttachArtifactDialog: React.FC<AttachArtifactDialogProps> = ({ isOp
243250
<div className="min-w-0 flex-1 truncate text-sm font-medium text-(--primary-text-wMain)" title={artifact.filename}>
244251
{artifact.filename}
245252
</div>
246-
<ArtifactVersionPicker
247-
artifact={artifact}
248-
value={versionByKey.get(k) ?? LATEST_VERSION_VALUE}
249-
onValueChange={v => {
250-
setVersionByKey(prev => {
251-
const next = new Map(prev);
252-
next.set(k, v);
253-
return next;
254-
});
255-
}}
256-
/>
253+
{typeof artifact.version === "number" && (
254+
<ArtifactVersionPicker
255+
artifact={artifact}
256+
value={versionByKey.get(k) ?? artifact.version}
257+
onValueChange={v => {
258+
setVersionByKey(prev => {
259+
const next = new Map(prev);
260+
next.set(k, v);
261+
return next;
262+
});
263+
}}
264+
/>
265+
)}
257266
</div>
258267
<div className="flex items-center gap-2 text-xs text-(--secondary-text-wMain)">
259268
<span className="truncate">{artifact.mime_type}</span>

client/webui/frontend/src/stories/chat/AttachArtifactDialog.stories.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ export const VersionPickerEncodesSelectedVersion: Story = {
110110
mimeType: "application/pdf",
111111
lastModified: "2026-01-01T00:00:00Z",
112112
uri: "artifact://my-app/user-1/sess-1/report.pdf",
113+
// Backend reports the resolved-latest version
114+
// so the picker can default to it without an
115+
// extra round-trip.
116+
version: 2,
113117
sessionId: "sess-1",
114118
sessionName: "Session One",
115119
projectId: null,
@@ -123,7 +127,7 @@ export const VersionPickerEncodesSelectedVersion: Story = {
123127
nextPage: null,
124128
});
125129
}),
126-
// The per-row picker lazy-fetches versions on first open.
130+
// The per-row picker lazy-fetches the full version list on first open.
127131
http.get("*/api/v1/artifacts/:sessionId/report.pdf/versions", () => {
128132
return HttpResponse.json([0, 1, 2]);
129133
}),

client/webui/frontend/src/stories/chat/AttachArtifactDialog.test.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ function toRawArtifact(a: ArtifactWithSession) {
6969
mimeType: a.mime_type,
7070
lastModified: a.last_modified,
7171
uri: a.uri,
72+
version: a.version ?? null,
7273
sessionId: a.sessionId,
7374
sessionName: a.sessionName,
7475
projectId: a.projectId ?? null,
@@ -128,29 +129,29 @@ describe("AttachArtifactDialog", () => {
128129
expect(screen.getByText(/no artifacts available/i)).toBeInTheDocument();
129130
});
130131

131-
test("emits the canonical artifact:// URI without ?version when the user keeps the 'Latest' default", async () => {
132-
// The /artifacts/all endpoint returns a canonical 4-segment URI
133-
// without a `?version=N` query (the agent-side translator resolves
134-
// "latest" at fetch time). The dialog forwards it untouched when the
135-
// per-row version picker is left on its default.
132+
test("encodes the backend-reported latest version on the emitted URI by default", async () => {
133+
// The /artifacts/all endpoint returns the resolved-latest version
134+
// alongside each record. The dialog uses that as the picker's
135+
// default and bakes it into the URI as ?version=N — no implicit
136+
// "latest" semantics survive into the submit pipeline.
136137
const canonical = "artifact://my-app/user-1/sess-legacy/legacy.txt";
137138
const onAttach = vi.fn();
138-
renderDialog([makeArtifact({ filename: "legacy.txt", sessionId: "sess-legacy", uri: canonical })], { onAttach });
139+
renderDialog([makeArtifact({ filename: "legacy.txt", sessionId: "sess-legacy", uri: canonical, version: 2 })], { onAttach });
139140

140141
await userEvent.click(screen.getByText("legacy.txt"));
141142
await userEvent.click(screen.getByRole("button", { name: /attach 1/i }));
142143

143144
expect(onAttach).toHaveBeenCalledTimes(1);
144145
const [emitted] = onAttach.mock.calls[0];
145146
expect(emitted).toHaveLength(1);
146-
expect(emitted[0].uri).toBe(canonical);
147+
expect(emitted[0].uri).toBe(`${canonical}?version=2`);
147148
});
148149

149150
test("hides records that arrive without a canonical URI (treats them as unattachable)", () => {
150151
// Backend should always return `uri` on the bulk endpoint. If a record
151152
// arrives without one, the agent-side translator would reject any
152153
// fallback we synthesized — surface "unattachable" by hiding it.
153-
renderDialog([makeArtifact({ filename: "broken.txt", sessionId: "sess-x", uri: "" })]);
154+
renderDialog([makeArtifact({ filename: "broken.txt", sessionId: "sess-x", uri: "", version: 0 })]);
154155

155156
expect(screen.queryByText("broken.txt")).not.toBeInTheDocument();
156157
expect(screen.getByText(/no artifacts available/i)).toBeInTheDocument();

src/solace_agent_mesh/agent/utils/artifact_helpers.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1311,7 +1311,17 @@ async def _load_single_metadata(filename: str) -> Optional[ArtifactInfo]:
13111311
log_identifier_prefix=f"{log_prefix} [{filename}]",
13121312
)
13131313

1314-
info = _metadata_to_artifact_info(filename, data.get("metadata", {}))
1314+
# `version` is the metadata file's resolved-latest version,
1315+
# captured for free here (load_artifact_content_or_metadata
1316+
# already had to call list_versions to resolve "latest"). The
1317+
# WebUI's attach-artifact dialog uses this to pre-fill its
1318+
# version picker default with a concrete number rather than a
1319+
# "Latest" sentinel.
1320+
info = _metadata_to_artifact_info(
1321+
filename,
1322+
data.get("metadata", {}),
1323+
version=data.get("version"),
1324+
)
13151325
# Populate the canonical 4-segment artifact:// URI so consumers
13161326
# (the chat-input attach dialog) can pass it to handleSubmit
13171327
# without falling back to a synthesized form the agent-side

src/solace_agent_mesh/gateway/http_sse/routers/artifacts.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -548,26 +548,31 @@ async def upload_artifact_with_session(
548548

549549
class ArtifactWithContext(BaseModel):
550550
"""Artifact info with session/project context for bulk listing."""
551-
551+
552552
# Core artifact fields from ArtifactInfo
553553
filename: str
554554
size: int
555555
mime_type: Optional[str] = Field(None, alias="mimeType")
556556
last_modified: Optional[str] = Field(None, alias="lastModified") # ISO date string
557557
uri: Optional[str] = None
558-
558+
# Resolved-latest version captured during the metadata load (free —
559+
# load_artifact_content_or_metadata had to call list_versions anyway).
560+
# The WebUI uses this so the attach-artifact dialog can default its
561+
# version picker to a concrete number instead of a "Latest" sentinel.
562+
version: Optional[int] = None
563+
559564
# Context fields
560565
session_id: str = Field(..., alias="sessionId")
561566
session_name: Optional[str] = Field(None, alias="sessionName")
562567
project_id: Optional[str] = Field(None, alias="projectId")
563568
project_name: Optional[str] = Field(None, alias="projectName")
564-
569+
565570
# Source field for origin badges (upload, generated, project)
566571
source: Optional[str] = None
567-
572+
568573
# Tags for categorization (e.g., ["__working"] to mark as internal)
569574
tags: Optional[list[str]] = None
570-
575+
571576
model_config = {"populate_by_name": True}
572577

573578

@@ -835,6 +840,7 @@ async def _fetch_session_artifacts(
835840
mime_type=artifact.mime_type,
836841
last_modified=artifact.last_modified,
837842
uri=artifact.uri,
843+
version=artifact.version,
838844
session_id=session_id,
839845
session_name=session_name,
840846
project_id=project_id,

0 commit comments

Comments
 (0)