Skip to content
Closed
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
123 changes: 114 additions & 9 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8610,6 +8610,7 @@ def _message_window_for_display(messages, msg_limit=None, msg_before=None, expan


_LIMITED_TOOL_CONTENT_MAX_CHARS = 4096
_LIMITED_RENDERABLE_CONTENT_MAX_CHARS = 64 * 1024
# Server-side ceiling on the ?msg_limit= tail-window size. A client could
# otherwise request msg_limit=1000000 and force the server to assemble and
# serialize an unbounded message payload (the frontend's own pagination grows
Expand Down Expand Up @@ -8682,6 +8683,10 @@ def _state_db_backstop_limit_for_display(session, msg_before) -> int | None:
"\n\n[Tool output truncated in paginated session response; "
"load the full transcript to inspect the complete result.]"
)
_LIMITED_RENDERABLE_CONTENT_NOTICE = (
"\n\n[Message content truncated in paginated session response; "
"the complete content remains stored in the session.]"
)


def _tool_message_for_limited_payload(message):
Expand Down Expand Up @@ -8715,9 +8720,93 @@ def _tool_message_for_limited_payload(message):
return clipped


def _renderable_content_preview_for_limited_payload(content):
"""Return ``(bounded_content, original_chars)`` for oversized display text."""
preview_chars = max(
0,
_LIMITED_RENDERABLE_CONTENT_MAX_CHARS - len(_LIMITED_RENDERABLE_CONTENT_NOTICE),
)
if isinstance(content, str):
original_chars = len(content)
if original_chars <= _LIMITED_RENDERABLE_CONTENT_MAX_CHARS:
return None
return content[:preview_chars] + _LIMITED_RENDERABLE_CONTENT_NOTICE, original_chars

if not isinstance(content, list):
# msgContent() renders top-level objects only as "[object Object]".
# Keep their structure untouched rather than projecting fields into text.
return None

text_indexes = [
index
for index, part in enumerate(content)
if (
isinstance(part, dict)
and part.get("type") == "text"
and isinstance(part.get("text"), str)
)
]
if not text_indexes:
return None
joined_text = "".join(content[index]["text"] for index in text_indexes)
original_chars = len(joined_text)
if original_chars <= _LIMITED_RENDERABLE_CONTENT_MAX_CHARS:
return None

# Match msgContent()'s visible-text semantics without letting leading/trailing
# whitespace consume the payload budget. Keep each surviving character in
# its original text block so interleaved non-text blocks retain their order.
visible_text = joined_text.strip()
bounded_visible_chars = min(len(visible_text), preview_chars)
visible_start = len(joined_text) - len(joined_text.lstrip())
visible_end = visible_start + bounded_visible_chars
bounded_content = list(content)
cursor = 0
for index in text_indexes:
part = content[index]
text = part["text"]
part_start = cursor
part_end = cursor + len(text)
slice_start = max(part_start, visible_start)
slice_end = min(part_end, visible_end)
clipped_part = dict(part)
if slice_start < slice_end:
clipped_part["text"] = text[
slice_start - part_start : slice_end - part_start
]
else:
clipped_part["text"] = ""
bounded_content[index] = clipped_part
cursor = part_end
last_text_index = text_indexes[-1]
bounded_content[last_text_index]["text"] += _LIMITED_RENDERABLE_CONTENT_NOTICE
return bounded_content, original_chars


def _renderable_message_for_limited_payload(message):
"""Return a bounded copy of oversized user/assistant content for display loads."""
if not isinstance(message, dict):
return message
role = str(message.get("role") or "").strip().lower()
if role not in ("user", "assistant"):
return message
bounded = _renderable_content_preview_for_limited_payload(message.get("content"))
if bounded is None:
return message
bounded_content, original_chars = bounded
clipped = dict(message)
clipped["content"] = bounded_content
clipped["_content_truncated"] = True
clipped["_content_original_chars"] = original_chars
return clipped


def _messages_for_limited_payload(messages) -> list:
"""Bound hidden tool-result payloads before sending a msg_limit response."""
return [_tool_message_for_limited_payload(msg) for msg in list(messages or [])]
"""Bound oversized display content before sending a msg_limit response."""
return [
_renderable_message_for_limited_payload(_tool_message_for_limited_payload(msg))
for msg in list(messages or [])
]


def _limited_webui_messages_for_display(session, state_db_messages) -> list:
Expand Down Expand Up @@ -13055,22 +13144,33 @@ def handle_get(handler, parsed) -> bool:
if synth is None:
# 'no_foreign_state' / 'invalid_sid' — nothing to render.
return bad(handler, "Session not found", 404)
# Build the legacy dict response from the synthesized Session so
# the wire shape stays byte-equivalent to the previous inline
# synthesis (the frontend has been reading these exact keys).
msgs = list(synth.messages or [])
# Build the legacy dict response from the synthesized Session while
# applying the same display window as the ordinary sidecar path.
all_msgs = list(synth.messages or [])
if load_messages:
msgs, messages_offset = _message_window_for_display(
all_msgs,
msg_limit=msg_limit,
msg_before=msg_before,
expand_renderable=expand_renderable,
)
if msg_limit is not None:
msgs = _messages_for_limited_payload(msgs)
else:
msgs = []
messages_offset = 0
sess = {
"session_id": synth.session_id,
"title": synth.title,
"workspace": synth.workspace,
"model": synth.model,
"message_count": len(msgs),
"message_count": len(all_msgs),
"created_at": synth.created_at,
"updated_at": synth.updated_at,
"last_message_at": (
(cli_meta or {}).get("last_message_at")
or (cli_meta or {}).get("updated_at", 0)
or ((msgs or [{}])[-1].get("timestamp", 0))
or ((all_msgs or [{}])[-1].get("timestamp", 0))
),
"pinned": bool(getattr(synth, "pinned", False)),
"archived": bool(getattr(synth, "archived", False)),
Expand Down Expand Up @@ -13100,8 +13200,13 @@ def handle_get(handler, parsed) -> bool:
"read_only": bool(getattr(synth, "read_only", False)),
"messages": msgs,
"tool_calls": [],
"_messages_truncated": (
load_messages and msg_limit is not None and messages_offset > 0
),
"_messages_offset": messages_offset,
"_msg_limit_max": _MAX_MSG_LIMIT,
}
attach_todo_state(sess, msgs)
attach_todo_state(sess, all_msgs)
sess = _merge_cli_sidebar_metadata(sess, cli_meta)
return j(handler, {"session": redact_session_data(sess)})

Expand Down
6 changes: 4 additions & 2 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -3827,7 +3827,9 @@ async function _loadOlderMessages() {
// 2. Bump _messagesGeneration before mutating S.messages so any
// in-flight prefetch's post-await generation check bails out.
async function _ensureAllMessagesLoaded() {
if (!_messagesTruncated || !S.session) return;
const hasContentTruncatedMessage=()=>
(S.messages||[]).some(message=>message&&message._content_truncated===true);
if ((!_messagesTruncated&&!hasContentTruncatedMessage()) || !S.session) return;
if (_loadingOlder) {
// A prefetch is mid-flight (between the `_loadingOlder = true` line
// and its post-await guards). Bumping the generation token now
Expand All @@ -3840,7 +3842,7 @@ async function _ensureAllMessagesLoaded() {
while (_loadingOlder) {
await new Promise(resolve => setTimeout(resolve, 16));
}
if (!_messagesTruncated || !S.session) return;
if ((!_messagesTruncated&&!hasContentTruncatedMessage()) || !S.session) return;
}
_loadingOlder = true;
try {
Expand Down
36 changes: 25 additions & 11 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -18518,12 +18518,25 @@ function ensureLiveWorklogShell(){

// ── Edit + Regenerate ──

function editMessage(btn) {
if(S.busy) return;
const row = btn.closest('[data-msg-idx]');
async function editMessage(btn) {
if(S.busy||!S.session) return;
let row=btn.closest('[data-msg-idx]');
if(!row) return;
const msgIdx = parseInt(row.dataset.msgIdx, 10);
const originalText = row.dataset.rawText || '';
let msgIdx=parseInt(row.dataset.msgIdx,10);
const initialSid=S.session.session_id;
const absoluteMsgIdx=_oldestIdx+msgIdx;
const displayedMessage=S.messages[msgIdx];
if(displayedMessage&&displayedMessage._content_truncated===true){
if(typeof _ensureAllMessagesLoaded!=='function') return;
await _ensureAllMessagesLoaded();
if(!S.session||S.session.session_id!==initialSid) return;
renderMessages();
msgIdx=absoluteMsgIdx;
row=document.querySelector(`[data-msg-idx="${msgIdx}"]`);
if(!row) return;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
}
const authoritativeMessage=S.messages[msgIdx];
const originalText=authoritativeMessage?msgContent(authoritativeMessage):(row.dataset.rawText||'');
const body = row.querySelector('.msg-body');
if(!body || row.dataset.editing) return;
row.dataset.editing = '1';
Expand Down Expand Up @@ -18610,16 +18623,17 @@ async function regenerateResponse(btn) {
const assistantIdx = parseInt(row.dataset.msgIdx, 10);
const absoluteKeepCount = _oldestIdx + assistantIdx;
const initialSid = S.session.session_id;
let lastUserText = '';
for(let i = assistantIdx - 1; i >= 0; i--) {
const m = S.messages[i];
if(m && m.role === 'user') { lastUserText = msgContent(m); break; }
}
if(!lastUserText) return;
if(typeof _ensureAllMessagesLoaded==='function'){
await _ensureAllMessagesLoaded();
}
if(!S.session || S.session.session_id !== initialSid) return;
let lastUserText = '';
const lastUserSearchStart=Math.min(absoluteKeepCount-1,S.messages.length-1);
for(let i=lastUserSearchStart;i>=0;i--){
const m=S.messages[i];
if(m&&m.role==='user'){ lastUserText=msgContent(m); break; }
}
if(!lastUserText) return;
try {
await api('/api/session/truncate', {method:'POST', body:JSON.stringify({
session_id: initialSid,
Expand Down
Loading