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
10 changes: 10 additions & 0 deletions apps/connect/internal/hermes/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ func RoutePath(path string) Route {
return Route{Kind: RouteControlForward, Path: mapped}
}
}
if isSessionDisplayMessagesPath(path) {
return Route{
Kind: RouteControlForward,
Path: strings.TrimSuffix(path, "/display-messages") + "/messages",
}
}
if isSessionMessagesPath(path) || isSessionDetailPath(path) || isSessionModelPath(path) {
return Route{Kind: RouteForward, Path: path}
}
Expand Down Expand Up @@ -259,6 +265,10 @@ func isSessionMessagesPath(path string) bool {
return isSessionTailPath(path, "messages")
}

func isSessionDisplayMessagesPath(path string) bool {
return isSessionTailPath(path, "display-messages")
}

func isSessionDetailPath(path string) bool {
const prefix = "/api/sessions/"
if !strings.HasPrefix(path, prefix) {
Expand Down
2 changes: 2 additions & 0 deletions apps/connect/internal/hermes/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func TestRoutePath(t *testing.T) {
{path: "/api/sessions/sess_1", kind: RouteForward, forwardTo: "/api/sessions/sess_1"},
{path: "/api/sessions/search", kind: RouteControlForward, forwardTo: "/api/sessions/search"},
{path: "/api/sessions/sess_1/messages", kind: RouteForward, forwardTo: "/api/sessions/sess_1/messages"},
{path: "/api/sessions/sess_1/display-messages", kind: RouteControlForward, forwardTo: "/api/sessions/sess_1/messages"},
{path: "/api/sessions/sess_1/model", kind: RouteForward, forwardTo: "/api/sessions/sess_1/model"},
{path: "/api/model/options", kind: RouteForward, forwardTo: "/api/model/options"},
{path: "/files", kind: RouteControlForward, forwardTo: "/api/files"},
Expand Down Expand Up @@ -90,6 +91,7 @@ func TestRoutePath(t *testing.T) {
{path: "/v1/sessions/sess_1", kind: RouteUnknown},
{path: "/v1/sessions/sess_1/messages/extra", kind: RouteUnknown},
{path: "/api/sessions/sess_1/messages/extra", kind: RouteUnknown},
{path: "/api/sessions/sess_1/display-messages/extra", kind: RouteUnknown},
{path: "/api/sessions/sess_1/model/extra", kind: RouteUnknown},
{path: "/api/sessions/sess_1/models", kind: RouteUnknown},
{path: "/api/sessions/sess_1/", kind: RouteUnknown},
Expand Down
130 changes: 130 additions & 0 deletions apps/mobile/scripts/chat-presentation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
cleanArchivedSearchSnippet,
hasMatchingUserMessage,
isSafeArchivedUserSnippet,
isVisibleConversationMessage,
runActivityLabel,
toVisibleConversationMessage,
toolActivityLabel,
upsertChatActivity,
} from '../src/lib/chat-presentation.ts';

test('chat history excludes Hermes internals and empty transcript records', () => {
const messages = [
{ role: 'system', content: 'private instructions' },
{ role: 'tool', content: 'raw command output' },
{ role: 'assistant', content: '' },
{ role: 'user', content: 'Build the screen' },
{ role: 'assistant', content: 'Done.' },
];

assert.deepEqual(messages.filter(isVisibleConversationMessage), [
{ role: 'user', content: 'Build the screen' },
{ role: 'assistant', content: 'Done.' },
]);
});

test('synthetic user-role runtime prompts never appear as messages from the person', () => {
const internalMessages = [
{
role: 'user',
content: "You've reached the maximum number of tool-calling iterations allowed. Please provide a final response summarizing what you've found and accomplished so far, without calling any more tools.",
},
{
role: 'user',
content: '[Your active task list was preserved across context compression]\n- Reload skills\n\n[Skills pruned during compression — reload before acting on these tasks]\nThe task list above crossed the compression boundary verbatim.',
},
{
role: 'user',
content: '[System: Your previous response was truncated by the output length limit. Continue exactly where you left off.]',
},
{ role: 'user', content: '[IMPORTANT: Background process 123 completed]' },
{ role: 'user', content: 'internal event', display_kind: 'internal_notification' },
{ role: 'assistant', content: '(empty)' },
{ role: 'assistant', content: "Task Snapshot\nUser asked (deterministic, from compacted turns): 'Create a form'" },
{ role: 'assistant', content: 'I will inspect it.', tool_calls: [{ id: 'tool-1' }] },
];

assert.deepEqual(internalMessages.map(toVisibleConversationMessage), internalMessages.map(() => null));
});

test('compaction carriers keep human-authored text while removing internal context', () => {
const todoCarrier = {
role: 'user',
content: 'Please finish the form.\n\n[Your active task list was preserved across context compression]\n- Internal task',
};
const summaryCarrier = {
role: 'user',
content: '[CONTEXT COMPACTION — REFERENCE ONLY] internal summary\n--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---\nPlease use the blue theme.',
};
const mergedCarrier = {
role: 'user',
content: '[PRIOR CONTEXT — for reference only; not a new message]\nMy earlier real request\n[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]\n[CONTEXT COMPACTION — REFERENCE ONLY] internal summary',
};

assert.equal(toVisibleConversationMessage(todoCarrier)?.content, 'Please finish the form.');
assert.equal(toVisibleConversationMessage(summaryCarrier)?.content, 'Please use the blue theme.');
assert.equal(toVisibleConversationMessage(mergedCarrier)?.content, 'My earlier real request');
});

test('dashboard display projections take precedence over physical compaction content', () => {
const projected = toVisibleConversationMessage({
role: 'user',
content: '[CONTEXT COMPACTION — REFERENCE ONLY] internal summary',
display_content: 'Please create the registration form.',
});
assert.equal(projected?.content, 'Please create the registration form.');
});

test('archived history search cleans FTS markup and rejects internal summaries', () => {
assert.equal(
cleanArchivedSearchSnippet('...crea un >>>formulario<<< de pagos...'),
'...crea un formulario de pagos...',
);
assert.equal(isSafeArchivedUserSnippet('Quiero un formulario para ABBA'), true);
assert.equal(isSafeArchivedUserSnippet('[CONTEXT COMPACTION] formulario ABBA'), false);
assert.equal(isSafeArchivedUserSnippet('[tool: skill_view] ## Active State'), false);
assert.equal(
isSafeArchivedUserSnippet("...Task Snapshot User asked (deterministic, from compacted turns): 'Create a form'..."),
false,
);
});

test('optimistic prompts deduplicate even after Hermes has appended a response', () => {
const messages = [
{ role: 'user', content: 'Check the project' },
{ role: 'assistant', content: 'Everything looks good.' },
];

assert.equal(hasMatchingUserMessage(messages, 'Check the project'), true);
assert.equal(hasMatchingUserMessage(messages, 'Another prompt'), false);
});

test('tool activity uses safe category labels instead of names or payloads', () => {
assert.equal(toolActivityLabel('terminal', false), 'Running a command');
assert.equal(toolActivityLabel('terminal', true), 'Command finished');
assert.equal(toolActivityLabel('web_search', false), 'Searching sources');
assert.equal(toolActivityLabel('apply_patch', true), 'Files updated');
assert.equal(toolActivityLabel('skill_loader', false), 'Preparing the task');
assert.equal(toolActivityLabel('secret-internal-tool', false), 'Working on the next step');
});

test('run labels never expose raw unknown event names', () => {
assert.equal(runActivityLabel('reasoning.delta'), 'Planning the next step');
assert.equal(runActivityLabel('tool.start', { id: '1', label: 'Searching sources', status: 'running' }), 'Searching sources');
assert.equal(runActivityLabel('private.skill.bootstrap'), 'Hermes is working');
});

test('activity list updates in place and stays compact', () => {
let activity = upsertChatActivity([], { id: 'tool-1', label: 'Running a command', status: 'running' });
activity = upsertChatActivity(activity, { id: 'tool-1', label: 'Command finished', status: 'complete' });
assert.deepEqual(activity, [{ id: 'tool-1', label: 'Command finished', status: 'complete' }]);

for (let index = 2; index <= 7; index += 1) {
activity = upsertChatActivity(activity, { id: `tool-${index}`, label: `Step ${index}`, status: 'complete' });
}
assert.deepEqual(activity.map((item) => item.id), ['tool-4', 'tool-5', 'tool-6', 'tool-7']);
});
44 changes: 40 additions & 4 deletions apps/mobile/src/features/home/hermes-home-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AppText, AppTextInput, EmptyState, StatusDot } from '@/components/t3-ui
import { SPLIT_LAYOUT_MIN_WIDTH, T3Radius, T3Spacing, T3Typography } from '@/constants/t3-theme';
import { HermesThreadScreen } from '@/features/threads/hermes-thread-screen';
import { useT3Theme } from '@/hooks/use-t3-theme';
import { cleanArchivedSearchSnippet, isSafeArchivedUserSnippet } from '@/lib/chat-presentation';
import {
getHealth,
listSessions,
Expand Down Expand Up @@ -47,6 +48,7 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection }
const [settingsOpen, setSettingsOpen] = useState(false);
const [search, setSearch] = useState('');
const [activeSessionId, setActiveSessionId] = useState('new');
const [threadSearchQuery, setThreadSearchQuery] = useState('');
const [conversationEpoch, setConversationEpoch] = useState(0);
const agentId = environmentId(connection);
const storedProfiles = useProfileStore((state) => state.activeProfiles);
Expand Down Expand Up @@ -114,6 +116,20 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection }
() => new Set((searchResults.data?.results ?? []).map((result) => result.session_id)),
[searchResults.data?.results],
);
const searchHits = useMemo(
() => new Map(
(searchResults.data?.results ?? [])
.filter((result) => (
(result.role === 'user' || result.role === 'assistant')
&& isSafeArchivedUserSnippet(result.snippet)
))
.map((result) => [
result.session_id,
cleanArchivedSearchSnippet(result.snippet),
]),
),
[searchResults.data?.results],
);
const visibleSessions = useMemo(() => {
const all = sessions.data?.sessions ?? [];
if (!search.trim()) return all;
Expand Down Expand Up @@ -147,10 +163,12 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection }
const openThread = (sessionId: string) => {
setHistoryOpen(false);
setActiveSessionId(sessionId);
setThreadSearchQuery(search.trim());
setConversationEpoch((current) => current + 1);
};
const startNewChat = () => {
setActiveSessionId('new');
setThreadSearchQuery('');
setConversationEpoch((current) => current + 1);
};
const openTool = (href: Href) => {
Expand Down Expand Up @@ -203,6 +221,7 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection }
key={`${activeProfile}:${conversationEpoch}`}
connection={connection}
embedded
initialSearchQuery={threadSearchQuery}
onSessionCreated={(sessionId) => setActiveSessionId(sessionId)}
profile={activeProfile}
routeSessionId={activeSessionId}
Expand Down Expand Up @@ -278,7 +297,11 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection }
/>
}
renderItem={({ item }) => (
<SessionRow session={item} onPress={() => openThread(item.id)} />
<SessionRow
searchSnippet={search.trim() ? searchHits.get(item.id) : undefined}
session={item}
onPress={() => openThread(item.id)}
/>
)}
/>
</SafeAreaView>
Expand Down Expand Up @@ -423,7 +446,15 @@ function MenuRow({ detail, label, onPress }: { detail: string; label: string; on
);
}

function SessionRow({ session, onPress }: { session: HermesSession; onPress: () => void }) {
function SessionRow({
onPress,
searchSnippet,
session,
}: {
onPress: () => void;
searchSnippet?: string;
session: HermesSession;
}) {
const colors = useT3Theme();
const date = formatRelativeTime(session.started_at);
const title = session.title?.trim() || 'Untitled conversation';
Expand All @@ -443,9 +474,13 @@ function SessionRow({ session, onPress }: { session: HermesSession; onPress: ()
<AppText style={[styles.sessionDate, { color: colors.tertiary }]}>{date}</AppText>
</View>
<AppText numberOfLines={1} style={[styles.sessionMeta, { color: colors.muted }]}>
{session.message_count} {session.message_count === 1 ? 'message' : 'messages'}
{session.model ? ` · ${session.model}` : ''}
{session.model || 'Conversation'}
</AppText>
{searchSnippet ? (
<AppText numberOfLines={2} style={[styles.sessionMatch, { color: colors.secondary }]}>
{searchSnippet}
</AppText>
) : null}
</View>
<AppText style={{ color: colors.tertiary }}>›</AppText>
</Pressable>
Expand Down Expand Up @@ -525,6 +560,7 @@ const styles = StyleSheet.create({
sessionTitle: { flex: 1, fontFamily: T3Typography.medium, fontSize: 16 },
sessionDate: { fontSize: 12, lineHeight: 16 },
sessionMeta: { fontSize: 13, lineHeight: 17 },
sessionMatch: { fontSize: 13, lineHeight: 18, marginTop: 3 },
retry: { padding: T3Spacing.md },
settingsContent: {
gap: T3Spacing.lg,
Expand Down
Loading
Loading