Keep reasoning out of chat noise—and make it useful in Details
PeachJam now matches Lovable’s observed reasoning-visibility contract. Provider-visible reasoning is preserved as ordered, expandable Details rows; completed work and image turns no longer flood chat; answer-only turns show exactly one aggregate Thought marker. The change is merged, deployed, and verified with three real production runs.
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
Explicit provider-visible reasoning_text, with summary_text as a fallback, is normalized into a dedicated internal ThinkingText field.
Timeline projection
Sanitized thinking_text is projected onto the existing chronological model-call event. Raw replay and encrypted/opaque provider state remain unchanged.
origin/main commit 313b85c283dae4df3745921eb68c240f0820103b. The provider request, raw response, transcript replay, IDs, and ordering are 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.
The provider gap we closed
The authenticated pre-fix PeachJam image-build trace contained 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. Before this fix, the adapter preserved complete raw JSON but copied none of the displayable content into the timeline contract.
Provider
Returns content:[{type:"reasoning_text", text:"…"}]; optional summary and opaque state may also exist.
Closed gap
Previously dropped displayable reasoning; now strictly normalizes allowlisted text into ThinkingText.
Timeline
Now emits bounded thinking_text alongside duration, model, provider, and call index.
Details
Creates an ordered row that expands to exact provider-visible text.
Thought for 5s with no expandable content. The shipped fix recovers this existing response content without a provider-request change.
Implemented narrow fix
- Normalize Lovable-equivalent content. Only
reasoning.content[].reasoning_textis accepted; when absent,reasoning.summary[].summary_textis used. Empty values are trimmed, ordering is preserved, and multiple allowed entries are joined with paragraph breaks. - Keep it separate from model transcript text. A dedicated
ThinkingTextfield carries display metadata; transcriptTextis untouched. - Project it chronologically. Sanitized, bounded
thinking_textis attached to the existing model-call completion event before the tool calls produced by that response. - Use the existing Details contract. Each per-call Details row becomes expandable without another endpoint or fetch.
- Aggregate only for chat. Individual thoughts remain in Details. Completed work-card turns have zero chat markers; answer-only turns have exactly one; active pre-activity thinking has at most one.
- Leave absent content absent. When neither allowlisted field exists, the duration-only row remains non-expandable. No text is decrypted, inferred, or fabricated.
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 proved it
Focused automated verification
- Adapter: one/multiple
reasoning_textentries,summary_textfallback, and empty/malformed/arbitrary/encrypted-only rejection all pass. - Replay invariant: the display field is not copied into transcript
Text; raw JSON, continuation IDs, and order remain stable. - Executor: redacted and bounded
thinking_textreaches the matching model-call completion event and is omitted when no allowed content exists. - Chronology: thought → tool/image/edit → next thought ordering is asserted 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: at most one active pre-activity marker is allowed and ownership transfers to the work card when activity begins.
Real provider-backed production acceptance
| Run | Lovable baseline | Observed on PeachJam production |
|---|---|---|
Minimal “Hello, world!” websiteproj_2ffb186b27fd96f2 | 0 chat Thought markers; 3 expandable Details thoughts | 0 chat markers; exactly 3 expandable Details thoughts, in order: 4s, 18s, 4s |
Answer-only follow-up, no editsturn_2528628f4f8fbe27 | Exactly 1 aggregate chat Thought marker | Exactly 1 chat marker (“Thought for 6s”); exactly 1 expandable Details row; no tool activity |
Image website buildproj_c2a9b93f6b94bf8b | 0 chat markers; thoughts interleaved with generated images and edits in Details | 0 chat markers; exactly 6 expandable thoughts interleaved with the real generated image, reads, edits, preview, and console checks |
Sanitized live-state captures: sentinel build, answer-only follow-up, and image build.
Production video demos
These are direct Playwright recordings of the authenticated production UI after deployment. No DOM was altered and no behavior was mocked. Each recording starts on the real chat surface and then opens the real Details timeline.
Video audit: VP9 WebM (sentinel 960×1138; image 1280×800) with full-resolution H.264 fast-start MP4 fallback. Sentinel: 8.0s. Image build: 11.2s. Beginning, middle, and end frames were visually inspected before deployment; deployed WebM playback and seeking were then verified in Chromium.
Implementation and verification status
Frontend and user-facing behavior
- 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
Merged PR: #2664
Backend, safety, and rollout
- Added display-only
ThinkingTextto the internal output item - Strict
reasoning_textparsing withsummary_textfallback - Redacted, bounded diagnostic projection; opaque and malformed content rejected
- Full focused provider and service Go suites passed
npm run api:testpassed- Deployed API and frontend from exact
origin/main; real release smoke passed - Three authenticated production acceptance scenarios passed
Tracking issue: #2663
Shipped code surface
| File | Shipped 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. |
Outcome
The parity gap is closed: PeachJam now copies only reasoning.content[].reasoning_text, falling back to reasoning.summary[].summary_text, into display-only ThinkingText; projects it as bounded thinking_text; and leaves replay and the provider request untouched.
Chat and Details now have separate ownership: chat stays compact under the observed Lovable rule, while Details preserves the full ordered evidence. The implementation is merged, production-deployed, and verified by both automated tests and real model-backed website-creation runs.