Skip to content
Open
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
67 changes: 52 additions & 15 deletions src/sentry/seer/explorer/client_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,25 @@
class ToolCall(BaseModel):
"""A tool call in a message."""

id: str | None = None
function: str
args: str

class Config:
extra = "allow"
extra = "ignore"


class Message(BaseModel):
"""A message in the conversation."""

role: Literal["user", "assistant", "tool_use"]
content: str | None = None
thinking_content: str | None = None
tool_calls: list[ToolCall] | None = None
metadata: dict[str, str] | None = None

class Config:
extra = "allow"
extra = "ignore"


class Artifact(BaseModel):
Expand All @@ -42,7 +44,7 @@ class Artifact(BaseModel):
reason: str

class Config:
extra = "allow"
extra = "ignore"


class FilePatch(BaseModel):
Expand All @@ -65,7 +67,7 @@ class ExplorerFilePatch(BaseModel):
diff: str = ""

class Config:
extra = "allow"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FilePatch still allows extra fields to leak through

Low Severity

FilePatch retains extra = "allow" while every other model in the chat response serialization chain (ExplorerFilePatch, MemoryBlock, SeerRunState, etc.) was changed to extra = "ignore". Since FilePatch is nested inside ExplorerFilePatch which appears in MemoryBlock.file_patches and MemoryBlock.merged_file_patches, any undeclared fields Seer attaches to file patch objects can still leak through state.dict() to the API response.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 28e56e6. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was intentional since there were some nested fields that were a bit painful to add types for right now. Seems fine to keep as-is for the short term, can follow up.

extra = "ignore"


class RepoPRState(BaseModel):
Expand All @@ -83,7 +85,38 @@ class RepoPRState(BaseModel):
description: str | None = None

class Config:
extra = "allow"
extra = "ignore"


class TodoItem(BaseModel):
"""A todo item tracked by the agent."""

content: str
status: Literal["pending", "in_progress", "completed"]

class Config:
extra = "ignore"


class ToolLink(BaseModel):
"""A link to a Sentry resource referenced by a tool call."""

kind: str
params: dict[str, Any]

class Config:
extra = "ignore"


class ToolResult(BaseModel):
"""The result of a tool call execution."""

tool_call_id: str
tool_call_function: str
content: str

class Config:
extra = "ignore"


class MemoryBlock(BaseModel):
Expand All @@ -101,9 +134,12 @@ class MemoryBlock(BaseModel):
pr_commit_shas: dict[str, str] | None = (
None # repository name -> commit SHA. Used to track which commit was associated with each repo's PR at the time this block was created.
)
todos: list[TodoItem] | None = None
tool_links: list[ToolLink | None] | None = None
tool_results: list[ToolResult | None] | None = None

class Config:
extra = "allow"
extra = "ignore"


class PendingUserInput(BaseModel):
Expand All @@ -114,7 +150,7 @@ class PendingUserInput(BaseModel):
data: dict[str, Any]

class Config:
extra = "allow"
extra = "ignore"


class CodingAgentResult(BaseModel):
Expand All @@ -126,7 +162,7 @@ class CodingAgentResult(BaseModel):
pr_url: str | None = None

class Config:
extra = "allow"
extra = "ignore"


class ExplorerCodingAgentState(BaseModel):
Expand All @@ -142,7 +178,7 @@ class ExplorerCodingAgentState(BaseModel):
integration_id: int | None = None

class Config:
extra = "allow"
extra = "ignore"


class Usage(BaseModel):
Expand All @@ -158,7 +194,7 @@ class Usage(BaseModel):
model: str = ""

class Config:
extra = "allow"
extra = "ignore"


class UsageAccumulator(BaseModel):
Expand All @@ -167,7 +203,7 @@ class UsageAccumulator(BaseModel):
usages: list[Usage] = Field(default_factory=list)

class Config:
extra = "allow"
extra = "ignore"

@property
def total_dollar_cost(self) -> float:
Expand All @@ -185,14 +221,15 @@ class SeerRunState(BaseModel):
blocks: list[MemoryBlock]
status: Literal["processing", "completed", "error", "awaiting_user_input"]
updated_at: str
owner_user_id: int | None = None
pending_user_input: PendingUserInput | None = None
repo_pr_states: dict[str, RepoPRState] = Field(default_factory=dict)
metadata: dict[str, Any] | None = None
coding_agents: dict[str, ExplorerCodingAgentState] = Field(default_factory=dict)
usage: UsageAccumulator = Field(default_factory=UsageAccumulator)
metadata: dict[str, Any] | None = Field(default=None, exclude=True)
coding_agents: dict[str, ExplorerCodingAgentState] = Field(default_factory=dict, exclude=True)
usage: UsageAccumulator = Field(default_factory=UsageAccumulator, exclude=True)

class Config:
extra = "allow"
extra = "ignore"

def get_artifacts(self) -> dict[str, Artifact]:
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,46 @@ def test_get_with_run_id_calls_client(self, mock_client_class: MagicMock) -> Non
assert response.data["session"]["status"] == "completed"
mock_client.get_run.assert_called_once_with(run_id=123)

@patch("sentry.seer.endpoints.organization_seer_explorer_chat.SeerExplorerClient")
def test_get_excludes_private_fields(self, mock_client_class: MagicMock) -> None:
from sentry.seer.explorer.client_models import (
MemoryBlock,
Message,
SeerRunState,
Usage,
UsageAccumulator,
)

mock_state = SeerRunState(
run_id=123,
blocks=[
MemoryBlock(
id="b1",
message=Message(role="assistant", content="hello"),
timestamp="2024-01-01T00:00:00Z",
),
],
status="completed",
updated_at="2024-01-01T00:00:00Z",
usage=UsageAccumulator(
usages=[Usage(dollar_cost=0.42, model="claude", total_tokens=1000)]
),
metadata={"internal": "data"},
)
mock_client = MagicMock()
mock_client.get_run.return_value = mock_state
mock_client_class.return_value = mock_client

response = self.client.get(f"{self.url}123/")

assert response.status_code == 200
session = response.data["session"]
assert "usage" not in session
assert "metadata" not in session
assert "coding_agents" not in session
assert session["blocks"][0]["id"] == "b1"
assert session["blocks"][0]["message"]["content"] == "hello"

def test_post_without_query_returns_400(self) -> None:
data: dict[str, Any] = {}
response = self.client.post(self.url, data, format="json")
Expand Down
Loading