Skip to content
34 changes: 27 additions & 7 deletions services/studio/src/nmp/studio/studio_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,17 +235,23 @@ class StudioLinkDestination:
"/workspaces/{workspace}/intake/spans",
aliases=("spans", "span_list", "intake_span_list"),
),
"intake_session": StudioLinkDestination(
"Session {session_id}",
"/workspaces/{workspace}/intake/sessions/{session_id}",
aliases=("session", "session_detail", "intake_session_detail"),
required_args=("session_id",),
),
"intake_trace": StudioLinkDestination(
"Trace {name}",
"/workspaces/{workspace}/intake/traces/{name}",
"Trace {trace_id}",
"/workspaces/{workspace}/intake/sessions/{session_id}?traceId={trace_id}",
aliases=("trace", "trace_detail"),
requires_name=True,
required_args=("session_id", "trace_id"),
),
"intake_span": StudioLinkDestination(
"Span {span_id}",
"/workspaces/{workspace}/intake/traces/{trace_id}?spanId={span_id}",
"/workspaces/{workspace}/intake/sessions/{session_id}?traceId={trace_id}&spanId={span_id}",
aliases=("span", "span_detail"),
required_args=("trace_id", "span_id"),
required_args=("session_id", "trace_id", "span_id"),
),
"data_designer": StudioLinkDestination(
"Data Designer",
Expand Down Expand Up @@ -340,6 +346,7 @@ class StudioLinkDestination:
),
"experiment_name": ("experimentName", "experiment_id", "experimentId"),
"file_path": ("file", "filePath", "file_path_encoded", "filePathEncoded", "path"),
"session_id": ("sessionId",),
"trace_id": ("traceId",),
"span_id": ("spanId", "name"),
}
Expand Down Expand Up @@ -388,6 +395,7 @@ class StudioLinkDestination:
"intake": ("intake_enabled",),
"intake_traces": ("intake_enabled",),
"intake_spans": ("intake_enabled",),
"intake_session": ("intake_enabled",),
"intake_trace": ("intake_enabled",),
"intake_span": ("intake_enabled",),
"data_designer": ("data_designer_enabled",),
Expand Down Expand Up @@ -450,7 +458,11 @@ class StudioLinkDestination:
},
"trace_id": {
"type": "string",
"description": "Trace ID for span-specific intake destinations.",
"description": "Trace ID for trace- and span-specific intake destinations.",
},
"session_id": {
"type": "string",
"description": "Session ID for session-, trace-, and span-specific intake destinations.",
},
"span_id": {
"type": "string",
Expand Down Expand Up @@ -549,8 +561,16 @@ def tool_for_destinations(destinations: Mapping[str, StudioLinkDestination]) ->
)
if "model_chat" in destinations:
description_parts.append("When the user wants to chat with or try a model, use destination='model_chat'.")
if "intake_session" in destinations:
description_parts.append("For an intake session link, use destination='intake_session' with session_id.")
if "intake_trace" in destinations:
description_parts.append(
"For an intake trace link, use destination='intake_trace' with session_id and trace_id."
)
if "intake_span" in destinations:
description_parts.append("For an intake span link, use destination='intake_span' with trace_id and span_id.")
description_parts.append(
"For an intake span link, use destination='intake_span' with session_id, trace_id, and span_id."
)
description_parts.append("Include the returned markdown link exactly in your final response.")
tool["description"] = " ".join(description_parts)
destination_schema = tool["inputSchema"]["properties"]["destination"]
Expand Down
46 changes: 43 additions & 3 deletions services/studio/tests/unit/test_coding_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -794,8 +794,8 @@ def test_studio_link_destinations_cover_registered_workspace_routes():
"index": "workspace",
"inferenceProviders": "inference_providers",
"intake": "intake",
"intakeSession": "intake_session",
"intakeSpans": "intake_spans",
"intakeTrace": "intake_trace",
"intakeTraces": "intake_traces",
"jobDetail": "job",
"jobs": "jobs",
Expand Down Expand Up @@ -1130,6 +1130,7 @@ def test_mcp_studio_link_returns_intake_span_markdown(monkeypatch: pytest.Monkey
"name": "studio_link",
"arguments": {
"destination": "intake_span",
"session_id": "session 00",
"trace_id": "trace 01",
"span_id": "span 02",
},
Expand All @@ -1142,12 +1143,51 @@ def test_mcp_studio_link_returns_intake_span_markdown(monkeypatch: pytest.Monkey
assert json.loads(result_text) == {
"workspace": "default",
"destination": "intake_span",
"path": "/workspaces/default/intake/traces/trace%2001?spanId=span%2002",
"path": "/workspaces/default/intake/sessions/session%2000?traceId=trace%2001&spanId=span%2002",
"url": None,
"markdown": "[Span span 02](/workspaces/default/intake/traces/trace%2001?spanId=span%2002)",
"markdown": "[Span span 02](/workspaces/default/intake/sessions/session%2000?traceId=trace%2001&spanId=span%2002)",
}


@pytest.mark.parametrize(
("destination", "arguments", "expected_path"),
[
(
"intake_session",
{"session_id": "session 00"},
"/workspaces/default/intake/sessions/session%2000",
),
(
"intake_trace",
{"session_id": "session 00", "trace_id": "trace 01"},
"/workspaces/default/intake/sessions/session%2000?traceId=trace%2001",
),
],
)
def test_build_studio_link_result_returns_canonical_intake_session_paths(
destination: str,
arguments: dict[str, str],
expected_path: str,
):
result = studio_links.build_studio_link_result(
"default",
None,
{"destination": destination, **arguments},
)

assert result["path"] == expected_path


def test_build_studio_link_result_requires_session_for_intake_trace():
result = studio_links.build_studio_link_result(
"default",
None,
{"destination": "intake_trace", "trace_id": "trace 01"},
)

assert result == {"error": "session_id is required for Studio destination: intake_trace"}


def test_mcp_studio_link_returns_started_evaluation_result_markdown(service_client: TestClient):
session_id = str(uuid.uuid4())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ describe('AnnotationsPanel', () => {
const user = userEvent.setup();

renderRoute(<AnnotationsPanel workspace="default" spanId={SPAN_ID} sessionId={SESSION_ID} />, {
history: '/workspaces/default/intake/traces/trace-agent-run-001?spanId=span-root-001',
history:
'/workspaces/default/intake/sessions/session-agent-run-001?traceId=trace-agent-run-001&spanId=span-root-001',
});

expect(
Expand All @@ -40,7 +41,8 @@ describe('AnnotationsPanel', () => {
const user = userEvent.setup();

renderRoute(<AnnotationsPanel workspace="default" spanId={SPAN_ID} sessionId={SESSION_ID} />, {
history: '/workspaces/default/intake/traces/trace-agent-run-001?spanId=span-root-001',
history:
'/workspaces/default/intake/sessions/session-agent-run-001?traceId=trace-agent-run-001&spanId=span-root-001',
});

const note = await screen.findByRole('article', { name: 'Note annotation' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ describe('spanKeyValues', () => {
);
});

it('links the trace metadata field to the bare trace route', () => {
it('links the trace metadata field to its canonical session route', () => {
const span = mockSpanById('span-llm-001');
expect(span).toBeDefined();

Expand All @@ -140,7 +140,7 @@ describe('spanKeyValues', () => {

expect(isValidElement(traceEntry?.value)).toBe(true);
expect((traceEntry!.value as ReactElement<{ to: string }>).props.to).toBe(
'/workspaces/default/intake/traces/trace-agent-run-001'
'/workspaces/default/intake/sessions/session-agent-run-001?traceId=trace-agent-run-001'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import type { KeyValueEntry } from '@studio/components/IntakeDetail/IntakeComponents/keyValueTypes';
import { parseRawAttributes } from '@studio/components/IntakeDetail/SpanTemplates/rawAttributes';
import { getSpanTemplate } from '@studio/components/IntakeDetail/SpanTemplates/registry';
import { getIntakeTraceRoute, getIntakeTraceSpanRoute } from '@studio/routes/utils';
import { getIntakeSessionTraceRoute } from '@studio/routes/utils';
import {
EMPTY_VALUE,
formatCost,
Expand Down Expand Up @@ -188,7 +188,9 @@ const SPAN_SUMMARY_DESCRIPTORS: readonly SpanFieldDescriptor[] = [
label: 'Trace',
resolve: (span, { workspace }) =>
span.trace_id ? (
<Link to={getIntakeTraceRoute(workspace, span.trace_id)}>{span.trace_id}</Link>
<Link to={getIntakeSessionTraceRoute(workspace, span.session_id, span.trace_id)}>
{span.trace_id}
</Link>
) : (
EMPTY_VALUE
),
Expand All @@ -200,7 +202,11 @@ const SPAN_SUMMARY_DESCRIPTORS: readonly SpanFieldDescriptor[] = [
resolve: (span, { workspace }) => {
if (!span.parent_span_id) return EMPTY_VALUE;
return span.trace_id ? (
<Link to={getIntakeTraceSpanRoute(workspace, span.trace_id, span.parent_span_id)}>
<Link
to={getIntakeSessionTraceRoute(workspace, span.session_id, span.trace_id, {
spanId: span.parent_span_id,
})}
>
{span.parent_span_id}
</Link>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@

import {
buildEvaluationContextEntries,
buildTraceHighlightMetrics,
buildSessionHighlightMetrics,
buildTraceSummaryEntries,
} from '@studio/components/IntakeDetail/IntakeComponents/traceKeyValues';
import { mockTraceById } from '@studio/mocks/intake/telemetry';
import { EMPTY_VALUE } from '@studio/util/intakeTelemetry';
import { mockSessionById, mockTraceById } from '@studio/mocks/intake/telemetry';

describe('traceKeyValues', () => {
it('builds trace summary entries without headline metrics', () => {
Expand All @@ -34,35 +33,17 @@ describe('traceKeyValues', () => {
);
});

it('builds headline metrics for the top metrics card', () => {
const trace = mockTraceById('trace-agent-run-001');
expect(trace).toBeDefined();
it('builds session headline metrics without a trace error count', () => {
const session = mockSessionById('session-agent-run-001');
expect(session).toBeDefined();

const metrics = buildTraceHighlightMetrics(trace!);
const metrics = buildSessionHighlightMetrics(session!);

expect(metrics).toEqual([
{ id: 'span_count', label: 'Spans', value: '4' },
{ id: 'error_count', label: 'Errors', value: '0' },
{ id: 'duration_ms', label: 'Duration', value: '12s 230ms' },
{
id: 'total_tokens',
label: 'Total Tokens',
value: '1,754',
details: [
{ id: 'input_tokens', label: 'Input Tokens', value: '1,240' },
{ id: 'output_tokens', label: 'Output Tokens', value: '386' },
{ id: 'cached_tokens', label: 'Cached Tokens', value: '128' },
],
},
{
id: 'cost_usd',
label: 'Total Cost',
value: '$0.0032',
details: [
{ id: 'cost_input_usd', label: 'Input Cost', value: EMPTY_VALUE },
{ id: 'cost_output_usd', label: 'Output Cost', value: EMPTY_VALUE },
],
},
expect(metrics.map(({ id }) => id)).toEqual([
'span_count',
'duration_ms',
'total_tokens',
'cost_usd',
]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// produce JSX such as status badges and links. Not a component module.
/* eslint-disable react-refresh/only-export-components */

import type { EvaluationContext, Trace } from '@nemo/sdk/generated/platform/schema';
import type { EvaluationContext, Session, Trace } from '@nemo/sdk/generated/platform/schema';
import {
formatUnknownKeyValue,
isMeaningfulValue,
Expand All @@ -14,7 +14,7 @@ import type {
HighlightMetric,
KeyValueEntry,
} from '@studio/components/IntakeDetail/IntakeComponents/keyValueTypes';
import { getIntakeTraceSpanRoute } from '@studio/routes/utils';
import { getIntakeSessionTraceRoute } from '@studio/routes/utils';
import {
EMPTY_VALUE,
formatCost,
Expand Down Expand Up @@ -100,7 +100,9 @@ const TRACE_SUMMARY_DESCRIPTORS: readonly TraceFieldDescriptor[] = [
resolve: (trace, { workspace }) =>
trace.root_span_id ? (
<Link
to={getIntakeTraceSpanRoute(workspace, trace.id, trace.root_span_id)}
to={getIntakeSessionTraceRoute(workspace, trace.session_id, trace.id, {
spanId: trace.root_span_id,
})}
className="break-all"
>
{trace.root_span_id}
Expand Down Expand Up @@ -187,42 +189,53 @@ export const buildTraceSummaryEntries = (
* their totals; the per-direction breakdown rides along as `details`, surfaced
* in a hover popover.
*/
export const buildTraceHighlightMetrics = (trace: Trace): TraceHighlightMetric[] => [
export const buildSessionHighlightMetrics = (session: Session): TraceHighlightMetric[] => [
{
id: 'span_count',
label: 'Spans',
value: trace.span_count != null ? formatInteger(trace.span_count) : EMPTY_VALUE,
},
{
id: 'error_count',
label: 'Errors',
value: trace.error_count != null ? formatInteger(trace.error_count) : EMPTY_VALUE,
value: session.span_count != null ? formatInteger(session.span_count) : EMPTY_VALUE,
},
{
id: 'duration_ms',
label: 'Duration',
value: trace.duration_ms != null ? formatDurationMs(trace.duration_ms) : EMPTY_VALUE,
value: session.duration_ms != null ? formatDurationMs(session.duration_ms) : EMPTY_VALUE,
},
{
id: 'total_tokens',
label: 'Total Tokens',
value: formatTokens(trace.total_tokens),
value: formatTokens(session.total_tokens),
details: [
{ id: 'input_tokens', label: 'Input Tokens', value: formatTokens(trace.input_tokens) },
{ id: 'output_tokens', label: 'Output Tokens', value: formatTokens(trace.output_tokens) },
{ id: 'cached_tokens', label: 'Cached Tokens', value: formatTokens(trace.cached_tokens) },
{
id: 'input_tokens',
label: 'Input Tokens',
value: formatTokens(session.input_tokens),
},
{
id: 'output_tokens',
label: 'Output Tokens',
value: formatTokens(session.output_tokens),
},
{
id: 'cached_tokens',
label: 'Cached Tokens',
value: formatTokens(session.cached_tokens),
},
],
},
{
id: 'cost_usd',
label: 'Total Cost',
value: formatCostValue(trace.cost_usd),
value: formatCostValue(session.cost_usd),
details: [
{ id: 'cost_input_usd', label: 'Input Cost', value: formatCostValue(trace.cost_input_usd) },
{
id: 'cost_input_usd',
label: 'Input Cost',
value: formatCostValue(session.cost_input_usd),
},
{
id: 'cost_output_usd',
label: 'Output Cost',
value: formatCostValue(trace.cost_output_usd),
value: formatCostValue(session.cost_output_usd),
},
],
},
Expand Down
Loading
Loading