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
24 changes: 24 additions & 0 deletions services/studio/src/nmp/studio/coding_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import logging
import os
import re
import shutil
import uuid
from collections.abc import AsyncIterator, Awaitable, Mapping
Expand Down Expand Up @@ -104,6 +105,7 @@ class HistorySessionResponse(BaseModel):

session_id: str
mtime: float
title: str | None = None
first_prompt: str
message_count: int
token_count: int
Expand Down Expand Up @@ -131,6 +133,7 @@ class SessionHistoryResponse(BaseModel):
class HistorySummary:
"""Aggregated metadata from a Claude session history file."""

title: str | None = None
first_prompt: str | None = None
message_count: int = 0
token_count: int = 0
Expand Down Expand Up @@ -323,6 +326,7 @@ def _build_studio_system_prompt(
f"Start the summary block on its own line with {STUDIO_MESSAGE_SUMMARY_START} and end it on its own line with {STUDIO_MESSAGE_SUMMARY_END}.",
"Inside the summary block, use exactly these fields on separate lines:",
f"{STUDIO_MESSAGE_SUMMARY_START}",
"title: <meaningful 3-7 word title naming the user's overall task; keep it stable across messages unless the topic clearly changes>",
"worked_for: <elapsed time if you know it, otherwise unknown>",
"summary: <concise Markdown, at most 60 words, describing the user-visible result and current state>",
"details_label: worked for <same elapsed time or unknown>",
Expand Down Expand Up @@ -440,6 +444,24 @@ def _append_tool_call(summary: HistorySummary, tool_name: str) -> None:
record_tool_name(summary.chat_artifacts, tool_name)


def _studio_message_title(text: str) -> str | None:
summary_start = text.find(STUDIO_MESSAGE_SUMMARY_START)
if summary_start < 0:
return None

summary_end = text.find(STUDIO_MESSAGE_SUMMARY_END, summary_start)
block = text[summary_start + len(STUDIO_MESSAGE_SUMMARY_START) : summary_end if summary_end >= 0 else None]
match = re.search(
r"(?:^|\s)title:\s*(.+?)(?=\s+(?:worked_for|summary|details_label):\s*|$)",
block,
flags=re.IGNORECASE | re.DOTALL,
)
if not match:
return None

return _trimmed_string(" ".join(match.group(1).split()))


def _record_assistant_tool_calls(
summary: HistorySummary,
message: dict[str, Any],
Expand All @@ -452,6 +474,7 @@ def _record_assistant_tool_calls(
if part.get("type") == "text":
text = string_value(part.get("text"))
if text:
summary.title = _studio_message_title(text) or summary.title
record_spec_text_artifacts(summary.chat_artifacts, text)
continue
if part.get("type") != "tool_use":
Expand Down Expand Up @@ -595,6 +618,7 @@ def list_history_sessions() -> list[HistorySessionResponse]:
HistorySessionResponse(
session_id=history_file.stem,
mtime=mtime,
title=summary.title,
first_prompt=summary.first_prompt or "",
message_count=summary.message_count,
token_count=summary.token_count,
Expand Down
39 changes: 39 additions & 0 deletions services/studio/tests/unit/test_coding_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ def test_list_and_get_history_sessions(
{
"session_id": session_id,
"mtime": history.stat().st_mtime,
"title": None,
"first_prompt": "first prompt",
"message_count": 1,
"token_count": 30,
Expand Down Expand Up @@ -695,6 +696,7 @@ def test_build_studio_system_prompt_includes_message_summary_contract():
assert "Required message-summary behavior:" in prompt
assert coding_agents.STUDIO_MESSAGE_SUMMARY_START in prompt
assert coding_agents.STUDIO_MESSAGE_SUMMARY_END in prompt
assert "title: <meaningful 3-7 word title" in prompt
assert "worked_for: <elapsed time if you know it, otherwise unknown>" in prompt
assert "summary: <concise Markdown" in prompt
assert "details_label: worked for <same elapsed time or unknown>" in prompt
Expand All @@ -712,6 +714,43 @@ def test_build_studio_system_prompt_includes_message_summary_contract():
assert "Do not omit the summary block because the message is short." in prompt


def test_history_summary_reads_model_generated_session_title(tmp_path: Path):
history = tmp_path / "session.jsonl"
history.write_text(
"\n".join(
[
json.dumps({"type": "user", "message": {"content": "A long initial request"}}),
json.dumps(
{
"type": "assistant",
"message": {
"content": [
{
"type": "text",
"text": "\n".join(
[
coding_agents.STUDIO_MESSAGE_SUMMARY_START,
"title: Create Spam Detector Agent",
"worked_for: unknown",
"summary: Created the requested agent.",
"details_label: worked for unknown",
coding_agents.STUDIO_MESSAGE_SUMMARY_END,
]
),
}
]
},
}
),
]
)
)

summary = coding_agents._summarize_history_session(history)

assert summary.title == "Create Spam Detector Agent"


def test_studio_link_destinations_cover_registered_workspace_routes():
repo_root = Path(__file__).resolve().parents[4]
routes_index = (repo_root / "web/packages/studio/src/routes/index.tsx").read_text()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,46 @@ import type { MessageRenderProps } from '@nemo/common/src/components/AssistantCh
import { MessageContent } from '@nemo/common/src/components/Chat/MessageContent';
import { Banner } from '@nvidia/foundations-react-core';

interface AssistantChatMessageContentProps extends MessageRenderProps {
contentSurfaceClassName?: string;
}

export const AssistantChatMessageContent = ({
contentSurfaceClassName,
messageContentProps,
toolCallPartComponent,
}: MessageRenderProps) => (
}: AssistantChatMessageContentProps) => (
<>
<MessagePrimitive.Parts
components={{
Text: ({ text }) => <MessageContent content={text} {...messageContentProps} />,
Image: ({ image, filename }) => (
<img
src={image}
alt={filename ?? 'Attached image'}
className="mt-density-xs max-h-64 w-auto rounded-lg border border-base object-contain"
/>
),
Text: ({ text }) => {
if (!text.trim()) return null;

const content = <MessageContent content={text} {...messageContentProps} />;
return contentSurfaceClassName ? (
<div className={contentSurfaceClassName} data-testid="assistant-chat-message-surface">
{content}
</div>
) : (
content
);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Image: ({ image, filename }) => {
const content = (
<img
src={image}
alt={filename ?? 'Attached image'}
className="mt-density-xs max-h-64 w-auto rounded-lg border border-base object-contain"
/>
);
return contentSurfaceClassName ? (
<div className={contentSurfaceClassName} data-testid="assistant-chat-message-surface">
{content}
</div>
) : (
content
);
},
tools: { Fallback: toolCallPartComponent },
}}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import type { MessageRenderProps } from '@nemo/common/src/components/AssistantCh
import { Skeleton, Tooltip } from '@nvidia/foundations-react-core';
import { RefreshCw } from 'lucide-react';

const ASSISTANT_MESSAGE_SURFACE_CLASS =
'w-full max-w-full rounded-lg border border-base border-l-4 border-l-[var(--border-color-brand)] bg-surface-base px-density-lg py-density-md shadow ring-1 ring-black/5 dark:ring-white/10';

export const AssistantMessage = ({
hideAssistantMessageActions,
messageContentProps,
Expand All @@ -26,33 +29,23 @@ export const AssistantMessage = ({
hideAssistantMessageActions?: boolean;
showRunningIndicator?: boolean;
}) => {
const isToolOnlyMessage = useAuiState((state) => {
const { parts } = state.message;
return parts.length > 0 && parts.every((part) => part.type === 'tool-call');
});
const hasRenderableContent = useAuiState((state) =>
state.message.parts.some((part) => part.type !== 'text' || part.text.trim().length > 0)
);

return (
<MessagePrimitive.Root
data-testid="assistant-chat-message"
data-testspeaker="assistant"
className="group/message flex w-full flex-col items-start gap-density-xs whitespace-normal"
>
{isToolOnlyMessage ? (
{hasRenderableContent ? (
<AssistantChatMessageContent
contentSurfaceClassName={ASSISTANT_MESSAGE_SURFACE_CLASS}
messageContentProps={messageContentProps}
toolCallPartComponent={toolCallPartComponent}
/>
) : (
<div
className="w-full max-w-full rounded-lg border border-base border-l-4 border-l-[var(--border-color-brand)] bg-surface-base px-density-lg py-density-md shadow ring-1 ring-black/5 dark:ring-white/10"
data-testid="assistant-chat-message-surface"
>
<AssistantChatMessageContent
messageContentProps={messageContentProps}
toolCallPartComponent={toolCallPartComponent}
/>
</div>
)}
) : null}
{showRunningIndicator ? (
<MessagePrimitive.If last>
<ThreadPrimitive.If running>
Expand Down
110 changes: 109 additions & 1 deletion web/packages/common/src/components/AssistantChat/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,20 @@ const renderAssistantChat = (element: ReactElement) =>

const StaticAssistantChatThread = ({
hideAssistantMessageActions,
isRunning = false,
messages: initialMessages = defaultStaticMessages,
toolCallPartComponent,
}: {
hideAssistantMessageActions?: boolean;
isRunning?: boolean;
messages?: readonly ThreadMessageLike[];
toolCallPartComponent?: ToolCallMessagePartComponent;
}) => {
const [messages, setMessages] = useState<readonly ThreadMessageLike[]>(initialMessages);
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
messages,
setMessages,
isRunning: false,
isRunning,
onNew: async () => undefined,
onEdit: async () => undefined,
onReload: async () => undefined,
Expand Down Expand Up @@ -295,6 +297,112 @@ describe('AssistantChat', () => {
).not.toBeInTheDocument();
});

it('renders streamed text and tool-call parts as separate message blocks', () => {
renderAssistantChat(
<StaticAssistantChatThread
hideAssistantMessageActions
messages={[
{
role: 'assistant',
content: [
{ type: 'text', text: 'I will inspect the repository.' },
{
type: 'tool-call',
toolCallId: 'toolu_read',
toolName: 'Read',
args: { file_path: 'README.md' },
},
{ type: 'text', text: 'I found the relevant file.' },
],
status: { type: 'running' },
},
]}
toolCallPartComponent={({ toolName }) => (
<div data-testid="assistant-chat-tool-pill">{toolName}</div>
)}
/>
);

const assistantMessage = screen.getByTestId('assistant-chat-message');
const messageSurfaces = within(assistantMessage).getAllByTestId(
'assistant-chat-message-surface'
);
const toolCall = within(assistantMessage).getByTestId('assistant-chat-tool-pill');

expect(messageSurfaces).toHaveLength(2);
expect(messageSurfaces[0]).toHaveTextContent('I will inspect the repository.');
expect(messageSurfaces[1]).toHaveTextContent('I found the relevant file.');
expect(toolCall).toHaveTextContent('Read');
for (const messageSurface of messageSurfaces) {
expect(
within(messageSurface).queryByTestId('assistant-chat-tool-pill')
).not.toBeInTheDocument();
}
});

it('shows the running skeleton without an empty assistant response surface', () => {
renderAssistantChat(
<StaticAssistantChatThread
hideAssistantMessageActions
isRunning
messages={[
{
role: 'assistant',
content: [{ type: 'text', text: '' }],
status: { type: 'running' },
},
]}
/>
);

const assistantMessage = screen.getByTestId('assistant-chat-message');

expect(
within(assistantMessage).queryByTestId('assistant-chat-message-surface')
).not.toBeInTheDocument();
expect(
within(assistantMessage).getByTestId('assistant-chat-running-indicator')
).toBeInTheDocument();
expect(within(assistantMessage).getByTestId('assistant-chat-skeleton')).toBeInTheDocument();
});

it('hides an empty text surface while a tool is running', () => {
renderAssistantChat(
<StaticAssistantChatThread
hideAssistantMessageActions
isRunning
messages={[
{
role: 'assistant',
content: [
{ type: 'text', text: ' \n' },
{
type: 'tool-call',
toolCallId: 'toolu_read',
toolName: 'Read',
args: { file_path: 'README.md' },
},
],
status: { type: 'running' },
},
]}
toolCallPartComponent={({ toolName }) => (
<div data-testid="assistant-chat-tool-pill">{toolName}</div>
)}
/>
);

const assistantMessage = screen.getByTestId('assistant-chat-message');

expect(within(assistantMessage).getByTestId('assistant-chat-tool-pill')).toHaveTextContent(
'Read'
);
expect(
within(assistantMessage).queryByTestId('assistant-chat-message-surface')
).not.toBeInTheDocument();
expect(within(assistantMessage).getByTestId('assistant-chat-skeleton')).toBeInTheDocument();
});

it('offers an enabled add-image affordance for a vision model when enabled', () => {
renderAssistantChat(
<AssistantChat model="test-vision-model" workspace="default" enableImageAttachments />
Expand Down
21 changes: 0 additions & 21 deletions web/packages/studio/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,24 +88,3 @@ body {
.nv-radio-group-item[data-disabled] {
cursor: not-allowed;
}

@keyframes claude-code-tool-call-running-color {
0%,
100% {
color: var(--text-color-subtle);
}

50% {
color: var(--text-color-brand);
}
}

.claude-code-tool-call-running {
color: var(--text-color-brand);
}

@media (prefers-reduced-motion: no-preference) {
.claude-code-tool-call-running {
animation: claude-code-tool-call-running-color 1.6s ease-in-out infinite;
}
}
Loading