Skip to content

Commit af3279b

Browse files
authored
handle reasoning deltas and display them in the UI (#2443)
1 parent ab2dbd6 commit af3279b

File tree

8 files changed

+153
-44
lines changed

8 files changed

+153
-44
lines changed

.roo/rules/rules.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,8 @@ Also when adding content to the end of files prefer to use the new append_file t
177177
- **ALWAYS verify the current working directory before executing commands**
178178
- Either run "pwd" first to verify the directory, or do a "cd" to the correct absolute directory before running commands
179179
- When running tests, do not "cd" to the pkg directory and then run the test. This screws up the cwd and you never recover. run the test from the project root instead.
180+
181+
### Testing / Compiling Go Code
182+
183+
No need to run a `go build` or a `go run` to just check if the Go code compiles. VSCode's errors/problems cover this well.
184+
If there are no Go errors in VSCode you can assume the code compiles fine.

cmd/server/main-server.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,15 @@ func sendTelemetryWrapper() {
129129
defer func() {
130130
panichandler.PanicHandler("sendTelemetryWrapper", recover())
131131
}()
132-
ctx, cancelFn := context.WithTimeout(context.Background(), 10*time.Second)
132+
ctx, cancelFn := context.WithTimeout(context.Background(), 15*time.Second)
133133
defer cancelFn()
134134
beforeSendActivityUpdate(ctx)
135135
client, err := wstore.DBGetSingleton[*waveobj.Client](ctx)
136136
if err != nil {
137137
log.Printf("[error] getting client data for telemetry: %v\n", err)
138138
return
139139
}
140-
err = wcloud.SendAllTelemetry(ctx, client.OID)
140+
err = wcloud.SendAllTelemetry(client.OID)
141141
if err != nil {
142142
log.Printf("[error] sending telemetry: %v\n", err)
143143
}

frontend/app/aipanel/aimessage.tsx

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,41 @@ import { getFileIcon } from "./ai-utils";
1111
import { WaveUIMessage, WaveUIMessagePart } from "./aitypes";
1212
import { WaveAIModel } from "./waveai-model";
1313

14-
const AIThinking = memo(({ message = "AI is thinking..." }: { message?: string }) => (
15-
<div className="flex items-center gap-2">
16-
<div className="animate-pulse flex items-center">
17-
<i className="fa fa-circle text-[10px]"></i>
18-
<i className="fa fa-circle text-[10px] mx-1"></i>
19-
<i className="fa fa-circle text-[10px]"></i>
14+
const AIThinking = memo(({ message = "AI is thinking...", reasoningText }: { message?: string; reasoningText?: string }) => {
15+
const scrollRef = useRef<HTMLDivElement>(null);
16+
17+
useEffect(() => {
18+
if (scrollRef.current && reasoningText) {
19+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
20+
}
21+
}, [reasoningText]);
22+
23+
const displayText = reasoningText ? (() => {
24+
const lastDoubleNewline = reasoningText.lastIndexOf("\n\n");
25+
return lastDoubleNewline !== -1 ? reasoningText.substring(lastDoubleNewline + 2) : reasoningText;
26+
})() : "";
27+
28+
return (
29+
<div className="flex flex-col gap-1">
30+
<div className="flex items-center gap-2">
31+
<div className="animate-pulse flex items-center">
32+
<i className="fa fa-circle text-[10px]"></i>
33+
<i className="fa fa-circle text-[10px] mx-1"></i>
34+
<i className="fa fa-circle text-[10px]"></i>
35+
</div>
36+
{message && <span className="text-sm text-gray-400">{message}</span>}
37+
</div>
38+
{displayText && (
39+
<div
40+
ref={scrollRef}
41+
className="text-sm text-gray-500 overflow-y-auto max-h-[2lh] max-w-[600px] pl-9"
42+
>
43+
{displayText}
44+
</div>
45+
)}
2046
</div>
21-
{message && <span className="text-sm text-gray-400">{message}</span>}
22-
</div>
23-
));
47+
);
48+
});
2449

2550
AIThinking.displayName = "AIThinking";
2651

@@ -428,35 +453,31 @@ const groupMessageParts = (parts: WaveUIMessagePart[]): MessagePart[] => {
428453
return grouped;
429454
};
430455

431-
const getThinkingMessage = (parts: WaveUIMessagePart[], isStreaming: boolean, role: string): string | null => {
456+
const getThinkingMessage = (parts: WaveUIMessagePart[], isStreaming: boolean, role: string): { message: string; reasoningText?: string } | null => {
432457
if (!isStreaming || role !== "assistant") {
433458
return null;
434459
}
435460

436-
// Check if there are any pending-approval tool calls - this takes priority
437461
const hasPendingApprovals = parts.some(
438462
(part) => part.type === "data-tooluse" && part.data?.approval === "needs-approval"
439463
);
440464

441465
if (hasPendingApprovals) {
442-
return "Waiting for Tool Approvals...";
466+
return { message: "Waiting for Tool Approvals..." };
443467
}
444468

445469
const lastPart = parts[parts.length - 1];
446470

447-
// Check if the last part is a reasoning part
448471
if (lastPart?.type === "reasoning") {
449-
return "AI is thinking...";
472+
const reasoningContent = lastPart.text || "";
473+
return { message: "AI is thinking...", reasoningText: reasoningContent };
450474
}
451475

452-
// Only hide thinking indicator if the last part is text and not empty
453-
// (this means text is actively streaming)
454476
if (lastPart?.type === "text" && lastPart.text) {
455477
return null;
456478
}
457479

458-
// For all other cases (including finish-step, tooluse, etc.), show dots
459-
return "";
480+
return { message: "" };
460481
};
461482

462483
export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
@@ -466,7 +487,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
466487
(part): part is WaveUIMessagePart & { type: "data-userfile" } => part.type === "data-userfile"
467488
);
468489

469-
const thinkingMessage = getThinkingMessage(parts, isStreaming, message.role);
490+
const thinkingData = getThinkingMessage(parts, isStreaming, message.role);
470491
const groupedParts = groupMessageParts(displayParts);
471492

472493
return (
@@ -477,7 +498,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
477498
message.role === "user" ? "py-2 bg-accent-800 text-white max-w-[calc(100%-20px)]" : null
478499
)}
479500
>
480-
{displayParts.length === 0 && !isStreaming && !thinkingMessage ? (
501+
{displayParts.length === 0 && !isStreaming && !thinkingData ? (
481502
<div className="whitespace-pre-wrap break-words">(no text content)</div>
482503
) : (
483504
<>
@@ -490,9 +511,9 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
490511
</div>
491512
)
492513
)}
493-
{thinkingMessage != null && (
514+
{thinkingData != null && (
494515
<div className="mt-2">
495-
<AIThinking message={thinkingMessage} />
516+
<AIThinking message={thinkingData.message} reasoningText={thinkingData.reasoningText} />
496517
</div>
497518
)}
498519
</>

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/aiusechat/openai/openai-backend.go

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,47 @@ type openaiResponseFunctionCallArgumentsDoneEvent struct {
221221
Arguments string `json:"arguments"`
222222
}
223223

224+
type openaiResponseReasoningSummaryPartAddedEvent struct {
225+
Type string `json:"type"`
226+
SequenceNumber int `json:"sequence_number"`
227+
ItemId string `json:"item_id"`
228+
OutputIndex int `json:"output_index"`
229+
SummaryIndex int `json:"summary_index"`
230+
Part openaiReasoningSummaryPart `json:"part"`
231+
}
232+
233+
type openaiResponseReasoningSummaryPartDoneEvent struct {
234+
Type string `json:"type"`
235+
SequenceNumber int `json:"sequence_number"`
236+
ItemId string `json:"item_id"`
237+
OutputIndex int `json:"output_index"`
238+
SummaryIndex int `json:"summary_index"`
239+
Part openaiReasoningSummaryPart `json:"part"`
240+
}
241+
242+
type openaiReasoningSummaryPart struct {
243+
Type string `json:"type"`
244+
Text string `json:"text"`
245+
}
246+
247+
type openaiResponseReasoningSummaryTextDeltaEvent struct {
248+
Type string `json:"type"`
249+
SequenceNumber int `json:"sequence_number"`
250+
ItemId string `json:"item_id"`
251+
OutputIndex int `json:"output_index"`
252+
SummaryIndex int `json:"summary_index"`
253+
Delta string `json:"delta"`
254+
}
255+
256+
type openaiResponseReasoningSummaryTextDoneEvent struct {
257+
Type string `json:"type"`
258+
SequenceNumber int `json:"sequence_number"`
259+
ItemId string `json:"item_id"`
260+
OutputIndex int `json:"output_index"`
261+
SummaryIndex int `json:"summary_index"`
262+
Text string `json:"text"`
263+
}
264+
224265
// ---------- OpenAI Response Structure Types ----------
225266

226267
type openaiResponse struct {
@@ -256,12 +297,12 @@ type openaiResponse struct {
256297
}
257298

258299
type openaiOutputItem struct {
259-
Id string `json:"id"`
260-
Type string `json:"type"`
261-
Status string `json:"status,omitempty"`
262-
Content []OpenAIMessageContent `json:"content,omitempty"`
263-
Role string `json:"role,omitempty"`
264-
Summary []string `json:"summary,omitempty"`
300+
Id string `json:"id"`
301+
Type string `json:"type"`
302+
Status string `json:"status,omitempty"`
303+
Content []OpenAIMessageContent `json:"content,omitempty"`
304+
Role string `json:"role,omitempty"`
305+
Summary []openaiReasoningSummaryPart `json:"summary,omitempty"`
265306

266307
// tools (type="function_call")
267308
Name string `json:"name,omitempty"`
@@ -320,10 +361,11 @@ const (
320361
)
321362

322363
type openaiBlockState struct {
323-
kind openaiBlockKind
324-
localID string // For SSE streaming to UI
325-
toolCallID string // For function calls
326-
toolName string // For function calls
364+
kind openaiBlockKind
365+
localID string // For SSE streaming to UI
366+
toolCallID string // For function calls
367+
toolName string // For function calls
368+
summaryCount int // For reasoning: number of summary parts seen
327369
}
328370

329371
type openaiStreamingState struct {
@@ -635,11 +677,12 @@ func handleOpenAIEvent(
635677

636678
switch ev.Item.Type {
637679
case "reasoning":
638-
// Handle reasoning item for UI streaming
680+
// Create reasoning block - emit start immediately
639681
id := uuid.New().String()
640682
state.blockMap[ev.Item.Id] = &openaiBlockState{
641-
kind: openaiBlockReasoning,
642-
localID: id,
683+
kind: openaiBlockReasoning,
684+
localID: id,
685+
summaryCount: 0,
643686
}
644687
_ = sse.AiMsgReasoningStart(id)
645688
case "message":
@@ -836,6 +879,40 @@ func handleOpenAIEvent(
836879
case "response.output_text.annotation.added":
837880
return nil, nil
838881

882+
case "response.reasoning_summary_part.added":
883+
var ev openaiResponseReasoningSummaryPartAddedEvent
884+
if err := json.Unmarshal([]byte(data), &ev); err != nil {
885+
_ = sse.AiMsgError(err.Error())
886+
return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil
887+
}
888+
889+
if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockReasoning {
890+
if st.summaryCount > 0 {
891+
// Not the first summary part, emit separator
892+
_ = sse.AiMsgReasoningDelta(st.localID, "\n\n")
893+
}
894+
st.summaryCount++
895+
}
896+
return nil, nil
897+
898+
case "response.reasoning_summary_part.done":
899+
return nil, nil
900+
901+
case "response.reasoning_summary_text.delta":
902+
var ev openaiResponseReasoningSummaryTextDeltaEvent
903+
if err := json.Unmarshal([]byte(data), &ev); err != nil {
904+
_ = sse.AiMsgError(err.Error())
905+
return &uctypes.WaveStopReason{Kind: uctypes.StopKindError, ErrorType: "decode", ErrorText: err.Error()}, nil
906+
}
907+
908+
if st := state.blockMap[ev.ItemId]; st != nil && st.kind == openaiBlockReasoning {
909+
_ = sse.AiMsgReasoningDelta(st.localID, ev.Delta)
910+
}
911+
return nil, nil
912+
913+
case "response.reasoning_summary_text.done":
914+
return nil, nil
915+
839916
default:
840917
logutil.DevPrintf("OpenAI: unknown event: %s, data: %s", eventName, data)
841918
return nil, nil

pkg/aiusechat/openai/openai-convertmessage.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ func debugPrintReq(req *OpenAIRequest, endpoint string) {
141141
if len(toolNames) > 0 {
142142
log.Printf("tools: %s\n", strings.Join(toolNames, ","))
143143
}
144+
// log.Printf("reasoning %v\n", req.Reasoning)
144145

145146
log.Printf("inputs (%d):", len(req.Input))
146147
for idx, input := range req.Input {
@@ -234,6 +235,9 @@ func buildOpenAIHTTPRequest(ctx context.Context, inputs []any, chatOpts uctypes.
234235
reqBody.Reasoning = &ReasoningType{
235236
Effort: opts.ThinkingLevel, // low, medium, high map directly
236237
}
238+
if opts.Model == "gpt-5" {
239+
reqBody.Reasoning.Summary = "auto"
240+
}
237241
}
238242

239243
// Set temperature if provided

pkg/wcloud/wcloud.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ func sendTEvents(clientId string) (int, error) {
211211
return totalEvents, nil
212212
}
213213

214-
func SendAllTelemetry(ctx context.Context, clientId string) error {
214+
func SendAllTelemetry(clientId string) error {
215215
defer func() {
216216
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
217217
defer cancelFn()
@@ -225,14 +225,16 @@ func SendAllTelemetry(ctx context.Context, clientId string) error {
225225
if err != nil {
226226
return err
227227
}
228-
err = sendTelemetry(ctx, clientId)
228+
err = sendTelemetry(clientId)
229229
if err != nil {
230230
return err
231231
}
232232
return nil
233233
}
234234

235-
func sendTelemetry(ctx context.Context, clientId string) error {
235+
func sendTelemetry(clientId string) error {
236+
ctx, cancelFn := context.WithTimeout(context.Background(), WCloudDefaultTimeout)
237+
defer cancelFn()
236238
activity, err := telemetry.GetNonUploadedActivity(ctx)
237239
if err != nil {
238240
return fmt.Errorf("cannot get activity: %v", err)

pkg/wshrpc/wshserver/wshserver.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -899,7 +899,7 @@ func (ws WshServer) SendTelemetryCommand(ctx context.Context) error {
899899
if err != nil {
900900
return fmt.Errorf("getting client data for telemetry: %v", err)
901901
}
902-
return wcloud.SendAllTelemetry(ctx, client.OID)
902+
return wcloud.SendAllTelemetry(client.OID)
903903
}
904904

905905
func (ws *WshServer) WaveAIEnableTelemetryCommand(ctx context.Context) error {
@@ -936,7 +936,7 @@ func (ws *WshServer) WaveAIEnableTelemetryCommand(ctx context.Context) error {
936936
}
937937

938938
// Immediately send telemetry to cloud
939-
err = wcloud.SendAllTelemetry(ctx, client.OID)
939+
err = wcloud.SendAllTelemetry(client.OID)
940940
if err != nil {
941941
log.Printf("error sending telemetry after enabling: %v", err)
942942
}

0 commit comments

Comments
 (0)