Skip to content

Commit 3d2854b

Browse files
authored
fix(lightspeed): fix tool call response (#3049)
* fix(lightspeed): fix tool call response * fix sonarqube issues * fix tool-call codeblock in dark theme
1 parent fcc1d34 commit 3d2854b

4 files changed

Lines changed: 157 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-lightspeed': patch
3+
---
4+
5+
fixed tool call response

workspaces/lightspeed/plugins/lightspeed/src/components/ToolCallContent.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { makeStyles } from '@material-ui/core';
1718
import { Message } from '@patternfly/chatbot';
1819
import {
1920
Content,
@@ -37,6 +38,15 @@ interface ToolCallContentProps {
3738
role?: 'user' | 'bot';
3839
}
3940

41+
const useStyles = makeStyles(() => ({
42+
codeBlock: {
43+
'& .pf-chatbot__message-code-block': {
44+
border: '1px solid var(--pf-t--global--border--color--default)',
45+
borderRadius: 'var(--pf-t--global--border--radius--small)',
46+
},
47+
},
48+
}));
49+
4050
/**
4151
* Lightweight component for rendering tool call expandable content.
4252
* Used inside PatternFly's ToolCall component's expandableContent prop.
@@ -45,6 +55,7 @@ export const ToolCallContent = ({
4555
toolCall,
4656
role = 'bot',
4757
}: ToolCallContentProps) => {
58+
const classes = useStyles();
4859
const { t } = useTranslation();
4960

5061
const formatExecutionTime = (seconds?: number): string => {
@@ -225,7 +236,7 @@ export const ToolCallContent = ({
225236
direction={{ default: 'column' }}
226237
spaceItems={{ default: 'spaceItemsXs' }}
227238
>
228-
<FlexItem>
239+
<FlexItem className={classes.codeBlock}>
229240
<Message
230241
content={formatToolResponseForMarkdown(
231242
toolCall.response,

workspaces/lightspeed/plugins/lightspeed/src/utils/__tests__/formatToolResponseForMarkdown.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,52 @@ describe('formatToolResponseForMarkdown', () => {
5252
expect(out).toContain('```');
5353
expect(out).toContain(long);
5454
});
55+
56+
it('unwraps SSE tool_result envelope with data prefix', () => {
57+
const input =
58+
'data: {"event":"tool_result","data":{"id":"abc","status":"completed","content":"{\\"results\\":[{\\"score\\":1.23}]}"}}';
59+
const out = formatToolResponseForMarkdown(input);
60+
expect(out).toContain('```json');
61+
expect(out).toContain('"results"');
62+
expect(out).toContain('"score": 1.23');
63+
expect(out).not.toContain('"event"');
64+
});
65+
66+
it('unwraps tool_result envelope object and formats content field', () => {
67+
const input = JSON.stringify({
68+
event: 'tool_result',
69+
data: {
70+
id: 'fc_123',
71+
status: 'completed',
72+
content: JSON.stringify({
73+
results: [
74+
{
75+
attributes: {
76+
title: 'Sample title',
77+
},
78+
},
79+
],
80+
}),
81+
},
82+
});
83+
84+
const out = formatToolResponseForMarkdown(input);
85+
expect(out).toContain('```json');
86+
expect(out).toContain('"results"');
87+
expect(out).toContain('"title": "Sample title"');
88+
expect(out).not.toContain('"event"');
89+
});
90+
91+
it('parses status-prefixed JSON payloads from tool result logs', () => {
92+
const input =
93+
'[completed] {"results":[{"attributes":{"title":"Evaluate project health using Scorecards"},"score":1.0647}]}';
94+
95+
const out = formatToolResponseForMarkdown(input);
96+
expect(out).toContain('```json');
97+
expect(out).toContain('"results"');
98+
expect(out).toContain('"score": 1.0647');
99+
expect(out).toContain(
100+
'"title": "Evaluate project health using Scorecards"',
101+
);
102+
});
55103
});

workspaces/lightspeed/plugins/lightspeed/src/utils/formatToolResponseForMarkdown.ts

Lines changed: 92 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,25 +35,103 @@ const prettyPrintToolJson = (value: unknown): string => {
3535
return JSON.stringify(value);
3636
};
3737

38-
export const formatToolResponseForMarkdown = (raw: string): string => {
39-
if (raw === null) return '';
40-
const trimmed = raw.trim();
41-
if (!trimmed) return '';
38+
const tryParseJson = (value: string) => {
39+
try {
40+
return JSON.parse(value);
41+
} catch {
42+
return undefined;
43+
}
44+
};
4245

43-
if (/^```/m.test(trimmed)) {
44-
return raw;
46+
const deepParseJson = (value: unknown): unknown => {
47+
let current = value;
48+
49+
while (typeof current === 'string') {
50+
try {
51+
const parsed = JSON.parse(current);
52+
53+
// stop if parsing doesn't change type
54+
if (parsed === current) break;
55+
56+
current = parsed;
57+
} catch {
58+
break;
59+
}
4560
}
4661

47-
let parsed: unknown;
48-
try {
49-
parsed = JSON.parse(trimmed);
50-
} catch {
51-
if (trimmed.length > 120 || trimmed.includes('\n')) {
52-
return `\`\`\`\n${trimmed}\n\`\`\``;
62+
return current;
63+
};
64+
65+
const formatPayload = (payload: unknown): string => {
66+
if (typeof payload === 'string') {
67+
const nested = deepParseJson(payload);
68+
if (nested !== undefined && nested !== null && typeof nested === 'object') {
69+
return `\`\`\`json\n${JSON.stringify(nested, null, 2)}\n\`\`\``;
70+
}
71+
if (payload.length > 120 || payload.includes('\n')) {
72+
return `\`\`\`\n${payload}\n\`\`\``;
5373
}
54-
return trimmed;
74+
return payload;
5575
}
5676

57-
const body = prettyPrintToolJson(parsed);
77+
const body = prettyPrintToolJson(payload);
5878
return `\`\`\`json\n${body}\n\`\`\``;
5979
};
80+
81+
const STATUS_PREFIX_REGEX = /^\[([^\]]+)\]\s+([\s\S]+)$/;
82+
83+
const extractToolResultPayload = (raw: string): unknown | undefined => {
84+
const trimmed = raw.trim();
85+
86+
const match = STATUS_PREFIX_REGEX.exec(trimmed);
87+
if (match) {
88+
const [, , payload] = match;
89+
90+
const parsed = tryParseJson(payload.trim());
91+
if (parsed !== undefined) {
92+
return parsed;
93+
}
94+
}
95+
96+
const jsonSegment = trimmed.startsWith('data:')
97+
? trimmed.slice('data:'.length).trim()
98+
: trimmed;
99+
100+
const parsed = tryParseJson(jsonSegment);
101+
if (!parsed || typeof parsed !== 'object') {
102+
return undefined;
103+
}
104+
105+
const parsedRecord = parsed as Record<string, unknown>;
106+
107+
if (
108+
parsedRecord.event === 'tool_result' &&
109+
parsedRecord.data &&
110+
typeof parsedRecord.data === 'object' &&
111+
'content' in parsedRecord.data
112+
) {
113+
const content = (parsedRecord.data as Record<string, unknown>).content;
114+
115+
if (typeof content === 'string') {
116+
return deepParseJson(content);
117+
}
118+
119+
return content;
120+
}
121+
122+
return parsed;
123+
};
124+
125+
export const formatToolResponseForMarkdown = (raw: string): string => {
126+
if (!raw?.trim()) return '';
127+
128+
if (/^```/.test(raw)) return raw;
129+
130+
const payload = extractToolResultPayload(raw);
131+
132+
if (payload !== undefined) {
133+
return formatPayload(payload);
134+
}
135+
136+
return formatPayload(raw);
137+
};

0 commit comments

Comments
 (0)