Keep reasoning out of chat noise—and make it useful in Details
Authenticated captures now show the complete contract. Lovable stores thinking as first-class ordered message parts, keeps every part expandable in Details, and projects at most one aggregate Thought marker into chat. PeachJam already receives equivalent reasoning_text from its live provider, but drops it during normalization and renders every model-call duration as a separate chat marker.
Decision in one minute
Chat ownership
Match Lovable’s ownership rule: work-internal thinking stays in Details; chat receives one aggregate marker only for answer-only or initial-prose reasoning, never one marker per model call.
Provider normalization
Normalize explicit provider-visible reasoning_text, with summary_text as a fallback, into a dedicated internal ThinkingText field.
Timeline projection
Project sanitized thinking_text onto the existing chronological model-call event. Keep raw replay and encrypted/opaque provider state unchanged.
The issue
Lovable groups internal model work under the completed card: its main chat stays compact, while Details contains chronological, expandable thought content. PeachJam currently renders every model-call duration in chat and shows the same calls as disabled rows in Details.
| Observed behavior | Lovable | PeachJam before |
|---|---|---|
| Completed work-card reasoning in main chat | Suppressed | Every model call shown |
| Details chronology and duration | Present | Present |
| Expandable provider-visible thought content | Present | Received but dropped; row disabled |
| Encrypted/opaque provider state exposed | No | No |
What Lovable actually does
The current public Lovable client reduces its trajectory stream into ordered message parts. An agentOutputThinking event becomes a first-class object with content, its source event ID, a start timestamp, and a duration calculated when the next output event arrives.
{
"type": "thinking",
"content": "The user wants a minimal sentinel website…",
"durationMs": nextOutputTimestamp - thinkingStartTimestamp,
"startedAtMs": thinkingStartTimestamp,
"thinking_event": "trajectory-event-id"
}
For persisted legacy messages, the same information appears as ordered custom nodes inside the AI message:
<lov-tool-use name="lov-think" data="The user wants me to build something directly…"> <lov-tool-use name="imagegen--generate_image" …> <lov-tool-use name="lov-think" data="I'm setting up the design system…">
Its chat selector then joins thought content into one aggregate and applies this rule:
showChatThought = hasThinking && !(hasActivity && initialAssistantText == null) // Details does not use that suppression rule: detailsRows = orderedParts.filter(part => part.type == "thinking")
Thought for 27s, Thought for 13s, and Thought for 13s, each expandable to the exact node content. The image build likewise showed zero chat markers and two Details thoughts interleaved around five image calls.
PeachJam’s actual provider gap
The authenticated PeachJam image-build trace contains 23 DeepSeek model calls. Every call returned a reasoning item with a non-empty content[] entry typed reasoning_text; every summary[] was empty. Text lengths ranged from 29 to 1,579 characters. The adapter recognizes the reasoning item and preserves the complete raw JSON, but copies none of this content into a display field.
Provider
Returns content:[{type:"reasoning_text", text:"…"}]; optional summary and opaque state may also exist.
Adapter gap
Stores type, status, and raw JSON; drops both reasoning content and summary text.
Timeline
Emits call duration, model, provider, and call index only.
Details
Creates a duration label with empty detail, so expansion is disabled.
Thought for 5s with no expandable content. No request change is needed to recover it.
Suggested narrow fix
- Normalize Lovable-equivalent content. Accept only
reasoning.content[].reasoning_text; if absent, fall back toreasoning.summary[].summary_text. Trim empties, preserve order, and join multiple allowed entries with paragraph breaks. - Keep it separate from model transcript text. Add a dedicated
ThinkingTextfield to the internal provider output item. Do not reuseText. - Project it chronologically. Put sanitized
thinking_texton the existing model-call completion event. That event already sits before the tool calls produced by the same response, matching Lovable’s ordered part model. - Use the existing Details contract. The frontend already accepts
thinking_text; each per-call Details row becomes expandable without another endpoint or fetch. - Aggregate only for chat. Keep individual thoughts in Details. For chat, collapse eligible thinking to one marker. Completed work-card turns have zero chat markers; answer-only turns have exactly one; active pre-activity thinking may have one.
- Leave absent content absent. If neither allowlisted field is present, keep the duration-only non-expandable row. Never decrypt, infer, or fabricate text.
Text participates in model-visible transcript behavior. Reusing it could silently change replay input or IDs. ThinkingText is presentation metadata only, so model replay remains byte-for-byte equivalent at this boundary.
Exact before / after contract
The phrase “changing provider-response handling” means changing PeachJam’s normalization of response bytes it already receives. It does not mean changing the request sent to the model or requesting additional reasoning.
| Boundary | Before | After | Delta |
|---|---|---|---|
| HTTP request to provider | Current model, input, tools, limits, reasoning settings | Exactly the same | None |
| Raw HTTP response | Provider reasoning item with content, optional summary, optional opaque state | Exactly the same bytes | None |
| Internal output item | Type + status + raw JSON | Add display-only ThinkingText | One normalized field |
| Persisted model transcript | Empty text + original raw item; model/debug visible | Exactly the same | None |
| Execution event diagnostics | Duration, model, provider, call index | Add thinking_text when allowlisted content exists | One optional key |
| Main chat | One marker per model call | At most one aggregate marker under Lovable’s ownership rule | Projection rule |
| Details | Duration-only disabled Thought row | Individual chronological row, expandable when content exists | Useful detail |
1. Provider response: unchanged
Bytes already received
The live provider already returns explicit reasoning_text. Other compatible providers may return summary_text instead.
After this fix
The provider response itself is unchanged; only the adapter’s internal representation changes.
{
"type": "reasoning",
"id": "rs_123",
"content": [
{"type": "reasoning_text", "text": "I should inspect the app before changing the layout."}
],
"summary": []
}
2. Provider adapter: one new display-only field
Before
OutputItem{
Type: OutputItemTypeReasoning,
Status: "completed",
Text: "",
Raw: originalRawJSON,
}
After
OutputItem{
Type: OutputItemTypeReasoning,
Status: "completed",
ThinkingText: "I should inspect the app before changing the layout.",
Text: "",
Raw: originalRawJSON,
}
3. Persisted model transcript: unchanged
Both before and after, model replay receives the same empty text, original raw item, stable IDs, and visibility. The display field is deliberately not copied here.
{
"type": "reasoning",
"text": "",
"raw": {"...": "original reasoning item"},
"visibility": {
"model": true,
"chat": false,
"timeline": false,
"debug": true
}
}
4. Execution event: optional user-facing thought content
Before
{
"title": "Model call completed",
"payload": {"diagnostics": {
"call_index": 1,
"duration_ms": 4680,
"model": "deepseek/deepseek-v4-pro",
"provider": "responses-compatible"
}}
}
After, when valid content exists
{
"title": "Model call completed",
"payload": {"diagnostics": {
"call_index": 1,
"duration_ms": 4680,
"model": "deepseek/deepseek-v4-pro",
"provider": "responses-compatible",
"thinking_text": "I should inspect the app before changing the layout."
}}
}
5. User-facing behavior
| Scenario | Before | After |
|---|---|---|
| Completed work card with 23 calls | 23 Thought labels in chat | One work card; calls remain in Details |
| Call with valid provider-visible reasoning | Disabled duration-only row | Expandable row with exact allowlisted content |
| Call without valid content | Disabled duration-only row | Same behavior |
| Active generation before any activity | Multiple markers can accumulate | At most one active aggregate marker |
| Answer-only assistant turn | One marker per call | Exactly one aggregate chat marker |
Safety and non-goals
We will display
- Only
reasoning_textor fallbacksummary_textentries - Only non-empty string content
- Only after existing redaction and length limits
- Only in user-facing execution diagnostics
- Only when the provider already supplied it
We will not display or change
- Encrypted or opaque provider reasoning
- Arbitrary fields guessed to be reasoning
- Any reasoning not explicitly returned as displayable text
- Model replay content, ordering, or IDs
- Request parameters sent to the provider
How we will prove it
Focused automated tests
- Adapter: extract one/multiple
reasoning_textentries; fall back tosummary_text; ignore empty, malformed, arbitrary, and encrypted-only fields. - Replay invariant: prove the new display field is not copied into transcript
Text, raw JSON is unchanged, and continuation IDs/order remain stable. - Executor: project redacted and bounded
thinking_textonto the matching model-call completion event; omit it when no allowlisted content exists. - Chronology: assert thought → tool/image/edit → next thought ordering in Details.
- Chat exactly once: an answer-only fixture with several model calls renders one
chat-thinking-item, not one per call. - Work-card suppression: completed text-only and image builds render zero chat Thought markers while every thought remains expandable in Details.
- Active transition: allow at most one active pre-activity marker; remove it from chat once work activity owns the turn.
Real live-run acceptance
| Run | Lovable baseline | PeachJam assertion after deploy |
|---|---|---|
| Minimal “Hello, world!” website | 0 chat Thought markers; 3 expandable Details thoughts | 0 chat markers; ≥1 expandable Details thought; every normalized thought appears once in chronological Details |
| Answer-only follow-up, no edits | Exactly 1 aggregate chat Thought marker | Exactly 1 chat marker even if the provider used multiple calls; Details remains expandable |
| Image website build | 0 chat markers; thoughts interleaved with generated images and edits in Details | 0 chat markers; ≥1 thought exists and expands in Details; image/tool chronology preserved |
Implementation and verification status
Completed frontend slice
- Terminal completed work cards suppress the chat Thought flood
- Details retains the full thought/action sequence
- Answer-only turns aggregate several model calls into exactly one chat Thought marker
- Active pre-activity thinking aggregates to one marker, then transfers ownership to the work card when tool/image activity starts
npm run frontend:buildpassed- Timeline Playwright suite passed 20/20
Draft PR: #2664
Pending after approval
- Add
ThinkingTextto the internal output item - Parse strict
reasoning_textwithsummary_textfallback - Project sanitized content to completion diagnostics
- Run the three live acceptance scenarios above
Tracking issue: #2663
Planned code surface
| File | Planned responsibility |
|---|---|
backend/internal/provider/agentmodel/provider.go | Define the dedicated internal display field. |
backend/internal/provider/agentmodel/responses.go | Strictly normalize allowlisted provider reasoning text. |
backend/internal/provider/agentmodel/responses_test.go | Test valid, multiple, empty, malformed, and opaque-only reasoning items. |
backend/internal/service/backend_build_executor.go | Project sanitized thought content into model-call completion diagnostics. |
backend/internal/service/backend_build_executor_test.go | Prove event projection and unchanged model transcript behavior. |
frontend/src/routes/project.tsx | Keep individual Details rows; aggregate eligible chat thinking to one marker under Lovable’s ownership rule. |
frontend/e2e/project-timeline.spec.ts | Cover answer-only exactly once, completed work-card suppression, active transition, and image chronology. |
Recommendation
Approve the revised, evidence-backed response-normalization change: copy only reasoning.content[].reasoning_text, falling back to reasoning.summary[].summary_text, into display-only ThinkingText; project it as thinking_text; leave replay and the provider request untouched.
Then refine chat aggregation to Lovable’s at-most-one ownership rule, run focused Go and Playwright tests, and complete all three real provider-backed acceptance runs before the draft PR moves toward merge.