Skip to content

Commit 7cfc9c1

Browse files
committed
fix: empty assistant messages on sessions with tool-calling chains (fixes #8)
1 parent 940c607 commit 7cfc9c1

8 files changed

Lines changed: 147 additions & 17 deletions

File tree

changelog.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,27 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66

77
## [Unreleased]
88

9+
## [1.1.0] - 2025-01-21
10+
911
### Added
1012

1113
- Full Factory Droid (FD) support as third sync source alongside OpenCode and Claude Code
12-
13-
### Fixed
14-
15-
- Fixed Claude Code assistant output not displaying in Dashboard session viewer (fixes #7)
16-
- MessageBubble component now properly extracts text from object formats (`{ text: "..." }` or `{ content: "..." }`)
17-
- Added content normalization helpers (getPartTextContent, getToolName) to handle different plugin formats
18-
- Added tool-result part type rendering to Dashboard MessageBubble (was missing)
19-
- Added fallback to message.textContent when parts have no displayable content
2014
- Source utilities file (src/lib/source.ts) with getSourceLabel and getSourceColorClass for consistent badge rendering
2115
- Factory Droid plugin card in Dashboard setup banner (3-column grid with OC, CC, FD)
2216
- Factory Droid stat card on Evals page showing FD session count
2317
- droid-sync link in Settings Plugin Setup section with GitHub and npm links
2418
- factoryDroid stats tracking in convex/evals.ts listEvalSessions query
19+
- Content normalization helpers (getPartTextContent, getToolName) to handle different plugin formats
20+
- tool-result part type rendering to Dashboard MessageBubble
21+
22+
### Fixed
23+
24+
- Claude Code assistant output not displaying in Dashboard session viewer (fixes #7)
25+
- MessageBubble component now properly extracts text from object formats (`{ text: "..." }` or `{ content: "..." }`)
26+
- textContent fallback when parts have no displayable content
27+
- Empty assistant messages on sessions with tool-calling chains (fixes #8)
28+
- hasDisplayableParts/hasPartsContent now checks tool-call and tool-result content, not just type existence
29+
- Applied fix across all 4 message rendering components (Dashboard, SessionViewer, Context, PublicSession)
2530

2631
### Changed
2732

docs/fix-blank-sessions.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Fix: Empty Assistant Messages in Sessions
2+
3+
**Related Issues:** [#7](https://github.com/waynesutton/opensync/issues/7), [#8](https://github.com/waynesutton/opensync/issues/8)
4+
5+
**Status:** Fixed (2025-01-22)
6+
7+
## Problem
8+
9+
Users reported empty assistant message bubbles in Claude Code sessions, particularly with long tool-calling chains. Messages showed timestamps but no content. The issue affected:
10+
11+
- Dashboard session viewer
12+
- SessionViewer component
13+
- Context page slide-over
14+
- Public session pages
15+
16+
## Root Cause
17+
18+
The `hasDisplayableParts` / `hasPartsContent` check only verified if a part TYPE existed (tool-call or tool-result), not whether the part had actual extractable content.
19+
20+
**Before (buggy):**
21+
```typescript
22+
const hasDisplayableParts = message.parts?.some((part: any) => {
23+
if (part.type === "text") {
24+
const text = getPartTextContent(part.content);
25+
return text && text.trim().length > 0; // Correctly checks content
26+
}
27+
return part.type === "tool-call" || part.type === "tool-result"; // BUG: Only checks type!
28+
});
29+
```
30+
31+
This caused:
32+
1. `hasDisplayableParts` = `true` (because part type existed)
33+
2. `showFallback` = `false` (skipped textContent fallback)
34+
3. Empty bubbles rendered because part.content was null/undefined/malformed
35+
36+
## Fix
37+
38+
Added content validation for tool-call and tool-result parts:
39+
40+
**After (fixed):**
41+
```typescript
42+
const hasDisplayableParts = message.parts?.some((part: any) => {
43+
if (part.type === "text") {
44+
const text = getPartTextContent(part.content);
45+
return text && text.trim().length > 0;
46+
}
47+
if (part.type === "tool-call") {
48+
// Check if tool-call has extractable name
49+
return part.content && (part.content.name || part.content.toolName);
50+
}
51+
if (part.type === "tool-result") {
52+
// Check if tool-result has extractable result
53+
const result = part.content?.result || part.content?.output || part.content;
54+
return result !== null && result !== undefined;
55+
}
56+
return false;
57+
});
58+
```
59+
60+
## Files Modified
61+
62+
1. `src/pages/Dashboard.tsx` - MessageBubble component
63+
2. `src/components/SessionViewer.tsx` - MessageBlock component
64+
3. `src/pages/Context.tsx` - SlideOverMessageBlock component
65+
4. `src/pages/PublicSession.tsx` - MessageBlock component
66+
67+
## Testing
68+
69+
After fix:
70+
- Messages with empty/null parts but valid `textContent` now show the textContent
71+
- Tool-call parts with actual names render correctly
72+
- Tool-result parts with actual results render correctly
73+
- Messages with no extractable content from parts OR textContent show empty (correct behavior)
74+
75+
## Previous Related Fixes
76+
77+
This is the second fix for content extraction issues:
78+
79+
1. **First fix (Issue #7 initial):** Added content normalization helpers (`getPartTextContent`, `getTextContent`) to handle object formats like `{ text: "..." }` or `{ content: "..." }` from Claude Code.
80+
81+
2. **Second fix (Issue #7, #8):** Fixed the displayable content detection to properly check tool-call/tool-result content, not just type existence.

files.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Documentation files.
9898
| `add-package-to-home-prompt.md` | Reusable prompt template for adding CLI/npm packages to homepage Getting Started section |
9999
| `WORKOS-AUTH.md` | WorkOS AuthKit integration architecture and security model |
100100
| `NETLIFY-WORKOS-DEPLOYMENT.md` | Deployment guide for Netlify, WorkOS, and Convex integration |
101+
| `fix-blank-sessions.md` | Tracking document for empty session messages fix (Issues #7, #8) |
101102

102103
## public/
103104

src/components/SessionViewer.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,22 @@ function MessageBlock({ message }: { message: any }) {
212212
const isUser = message.role === "user";
213213
const isSystem = message.role === "system";
214214

215-
// Check if parts have any displayable content
215+
// Check if parts have any displayable content (text, tool-call, or tool-result with actual data)
216216
const hasPartsContent = message.parts?.some((part: any) => {
217217
if (part.type === "text") {
218218
const text = getTextContent(part.content);
219219
return text && text.trim().length > 0;
220220
}
221-
return part.type === "tool-call" || part.type === "tool-result";
221+
if (part.type === "tool-call") {
222+
// Check if tool-call has extractable name
223+
return part.content && (part.content.name || part.content.toolName);
224+
}
225+
if (part.type === "tool-result") {
226+
// Check if tool-result has extractable result
227+
const result = part.content?.result || part.content?.output || part.content;
228+
return result !== null && result !== undefined;
229+
}
230+
return false;
222231
});
223232

224233
// Use textContent as fallback if no parts have content

src/pages/Context.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -988,13 +988,22 @@ function SlideOverMessageBlock({
988988
const isUser = message.role === "user";
989989
const isSystem = message.role === "system";
990990

991-
// Check if parts have any displayable content
991+
// Check if parts have any displayable content (text, tool-call, or tool-result with actual data)
992992
const hasPartsContent = message.parts?.some((part: any) => {
993993
if (part.type === "text") {
994994
const text = getTextContentFromPart(part.content);
995995
return text && text.trim().length > 0;
996996
}
997-
return part.type === "tool-call" || part.type === "tool-result";
997+
if (part.type === "tool-call") {
998+
// Check if tool-call has extractable name
999+
return part.content && (part.content.name || part.content.toolName);
1000+
}
1001+
if (part.type === "tool-result") {
1002+
// Check if tool-result has extractable result
1003+
const result = part.content?.result || part.content?.output || part.content;
1004+
return result !== null && result !== undefined;
1005+
}
1006+
return false;
9981007
});
9991008

10001009
// Use textContent as fallback if no parts have content

src/pages/Dashboard.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2450,13 +2450,22 @@ function MessageBubble({ message, theme }: { message: any; theme: "dark" | "tan"
24502450
const t = getThemeClasses(theme);
24512451
const isUser = message.role === "user";
24522452

2453-
// Check if parts have any displayable text content
2453+
// Check if parts have any displayable content (text, tool-call, or tool-result with actual data)
24542454
const hasDisplayableParts = message.parts?.some((part: any) => {
24552455
if (part.type === "text") {
24562456
const text = getPartTextContent(part.content);
24572457
return text && text.trim().length > 0;
24582458
}
2459-
return part.type === "tool-call" || part.type === "tool-result";
2459+
if (part.type === "tool-call") {
2460+
// Check if tool-call has extractable name
2461+
return part.content && (part.content.name || part.content.toolName);
2462+
}
2463+
if (part.type === "tool-result") {
2464+
// Check if tool-result has extractable result
2465+
const result = part.content?.result || part.content?.output || part.content;
2466+
return result !== null && result !== undefined;
2467+
}
2468+
return false;
24602469
});
24612470

24622471
// Use textContent fallback if no parts have displayable content

src/pages/PublicSession.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,22 @@ function MessageBlock({ message, theme }: { message: any; theme: "dark" | "tan"
139139
const isUser = message.role === "user";
140140
const isSystem = message.role === "system";
141141

142-
// Check if parts have any displayable content
142+
// Check if parts have any displayable content (text, tool-call, or tool-result with actual data)
143143
const hasPartsContent = message.parts?.some((part: any) => {
144144
if (part.type === "text") {
145145
const text = getTextContent(part.content);
146146
return text && text.trim().length > 0;
147147
}
148-
return part.type === "tool-call" || part.type === "tool-result";
148+
if (part.type === "tool-call") {
149+
// Check if tool-call has extractable name
150+
return part.content && (part.content.name || part.content.toolName);
151+
}
152+
if (part.type === "tool-result") {
153+
// Check if tool-result has extractable result
154+
const result = part.content?.result || part.content?.output || part.content;
155+
return result !== null && result !== undefined;
156+
}
157+
return false;
149158
});
150159

151160
// Use textContent as fallback if no parts have content

task.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ OpenSync supports two AI coding tools: **OpenCode** and **Claude Code**.
88

99
- [ ] (add next task here)
1010

11-
## Recently Completed (Claude Code Output Display Fix)
11+
## Recently Completed (Empty Sessions Fix - Issue #7, #8)
1212

1313
- [x] Fixed Claude Code assistant output not displaying in Dashboard (GitHub #7)
1414
- MessageBubble component was rendering object content directly instead of extracting text
@@ -18,6 +18,13 @@ OpenSync supports two AI coding tools: **OpenCode** and **Claude Code**.
1818
- Added textContent fallback when parts are empty
1919
- Added tool-result part type rendering (was missing from MessageBubble)
2020

21+
- [x] Fixed empty assistant messages on sessions with tool-calling chains (GitHub #8)
22+
- hasDisplayableParts/hasPartsContent was only checking part TYPE, not content
23+
- Now validates tool-call parts have extractable name (content.name or content.toolName)
24+
- Now validates tool-result parts have extractable result (content.result, content.output, or content itself)
25+
- Applied fix to all 4 message rendering components: Dashboard.tsx, SessionViewer.tsx, Context.tsx, PublicSession.tsx
26+
- Created docs/fix-blank-sessions.md tracking document
27+
2128
## Recently Completed (Factory Droid Full Integration)
2229

2330
- [x] Full Factory Droid support as third sync source

0 commit comments

Comments
 (0)