PeachJam engineering decision report
Issue #2663 · Shipped and production-verified · 23 July 2026

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.

Merged in PR #2664 Production verified Request payload unchanged Authenticated UI + API + video evidence

Decision in one minute

Shipped

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.

Shipped

Provider normalization

Explicit provider-visible reasoning_text, with summary_text as a fallback, is normalized into a dedicated internal ThinkingText field.

Shipped

Timeline projection

Sanitized thinking_text is projected onto the existing chronological model-call event. Raw replay and encrypted/opaque provider state remain unchanged.

Release: merged through PR #2664 and deployed from exact 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.

Lovable chat showing a compact successful work card without adjacent Thought labels
Lovable chat: a completed image-heavy build is one compact card with no per-call Thought flood. The reasoning still exists in Details.
PeachJam chat showing many Thought labels before the successful work card
PeachJam before: one completed turn produces 23 separate Thought labels before the terminal work card.
Lovable Details timeline with an expanded Thought row containing a planning summary
Lovable Details: “Thought for 19s” expands to provider-visible planning text interleaved with generated images and edits.
PeachJam Details timeline with non-expandable Thought rows
PeachJam before: chronology and duration exist, but each Thought row is disabled because no displayable detail reaches the frontend.
Lovable minimal sentinel Details panel with an expanded full thought
Fresh minimal sentinel: a real “Hello, world!” build produced three stored thought parts. Chat showed zero Thought markers; Details showed three expandable rows containing the exact stored thought content.
Lovable chat showing an answer-only Thought marker and work turns with selectively suppressed thoughts
Ownership cases in one conversation: answer-only prose has one Thought marker; a pure work card has none; a work turn with visible introductory prose has one marker before that prose and suppresses later internal thoughts.
Observed behaviorLovablePeachJam before
Completed work-card reasoning in main chatSuppressedEvery model call shown
Details chronology and durationPresentPresent
Expandable provider-visible thought contentPresentReceived but dropped; row disabled
Encrypted/opaque provider state exposedNoNo

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")
Evidence, not inference: the minimal Lovable sentinel contained three thought nodes and one edit/preview sequence. Completed chat showed zero Thought markers; Details showed 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.
Current-stack mapping: PeachJam does not yet persist a separate assistant text part before tool activity; it creates the final assistant message at the end of a turn. This change can therefore match Lovable exactly for completed work cards, answer-only turns, and the active pre-activity phase. Supporting Lovable’s rarer “initial prose, then tools” case requires a future first-class ordered text-part primitive, not a guess based on the final response.

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.

Concrete pre-fix sample: call 1 returned: “The user wants me to build a polished, responsive one-page website for ‘Clay House’… I'll need to: 1. Check the current project state, 2. Generate pottery-themed images…” The timeline showed only Thought for 5s with no expandable content. The shipped fix recovers this existing response content without a provider-request change.

Implemented narrow fix

  1. Normalize Lovable-equivalent content. Only reasoning.content[].reasoning_text is accepted; when absent, reasoning.summary[].summary_text is used. Empty values are trimmed, ordering is preserved, and multiple allowed entries are joined with paragraph breaks.
  2. Keep it separate from model transcript text. A dedicated ThinkingText field carries display metadata; transcript Text is untouched.
  3. Project it chronologically. Sanitized, bounded thinking_text is attached to the existing model-call completion event before the tool calls produced by that response.
  4. Use the existing Details contract. Each per-call Details row becomes expandable without another endpoint or fetch.
  5. 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.
  6. Leave absent content absent. When neither allowlisted field exists, the duration-only row remains non-expandable. No text is decrypted, inferred, or fabricated.
Why a dedicated field matters: 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.
Timing precision: Lovable computes each thought duration from trajectory event timestamps. PeachJam currently records one whole provider-call duration, not a separately streamed reasoning span. This narrow change keeps that existing duration as the honest nearest measurement; it will not invent finer timing. Exact sub-span timing would require a separate streaming/telemetry change.

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.

BoundaryBeforeAfterDelta
HTTP request to providerCurrent model, input, tools, limits, reasoning settingsExactly the sameNone
Raw HTTP responseProvider reasoning item with content, optional summary, optional opaque stateExactly the same bytesNone
Internal output itemType + status + raw JSONAdd display-only ThinkingTextOne normalized field
Persisted model transcriptEmpty text + original raw item; model/debug visibleExactly the sameNone
Execution event diagnosticsDuration, model, provider, call indexAdd thinking_text when allowlisted content existsOne optional key
Main chatOne marker per model callAt most one aggregate marker under Lovable’s ownership ruleProjection rule
DetailsDuration-only disabled Thought rowIndividual chronological row, expandable when content existsUseful 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

ScenarioBeforeAfter
Completed work card with 23 calls23 Thought labels in chatOne work card; calls remain in Details
Call with valid provider-visible reasoningDisabled duration-only rowExpandable row with exact allowlisted content
Call without valid contentDisabled duration-only rowSame behavior
Active generation before any activityMultiple markers can accumulateAt most one active aggregate marker
Answer-only assistant turnOne marker per callExactly one aggregate chat marker

Safety and non-goals

We will display

  • Only reasoning_text or fallback summary_text entries
  • 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
Important limitation: this fix surfaces reasoning text providers already return. If a provider returns neither allowlisted form, asking it to produce more reasoning would be a separate request-contract change and a separate operator review. This proposal does not do that.

How we proved it

Focused automated verification

Real provider-backed production acceptance

RunLovable baselineObserved on PeachJam production
Minimal “Hello, world!” website
proj_2ffb186b27fd96f2
0 chat Thought markers; 3 expandable Details thoughts0 chat markers; exactly 3 expandable Details thoughts, in order: 4s, 18s, 4s
Answer-only follow-up, no edits
turn_2528628f4f8fbe27
Exactly 1 aggregate chat Thought markerExactly 1 chat marker (“Thought for 6s”); exactly 1 expandable Details row; no tool activity
Image website build
proj_c2a9b93f6b94bf8b
0 chat markers; thoughts interleaved with generated images and edits in Details0 chat markers; exactly 6 expandable thoughts interleaved with the real generated image, reads, edits, preview, and console checks
Why “Hello, world!” is not the exactly-once chat test: direct Lovable evidence shows an app-changing sentinel build has zero chat Thought markers. The exactly-once chat assertion belongs to the answer-only follow-up. For creation runs, “exactly once” means each thought exists once in its Details owner and is not duplicated in chat.

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.

Sentinel + answer-only ownership: the build card has no adjacent Thought flood; the no-tool follow-up has exactly one “Thought for 6s”; Details exposes the answer thought and the build’s three ordered expandable thoughts.
Image/tool ownership: chat keeps the generated-image work card compact with zero Thought markers; Details retains six expandable thoughts interleaved with the real generated asset and tool activity.
PeachJam production Details with one expanded answer-only Thought row
Answer-only production Details: one thought, expandable to exact provider-visible content, followed by “Answered in chat.”
PeachJam production image build Details with six chronological Thought rows
Image-build production Details: six thoughts remain individually owned and chronologically interleaved with image generation, edits, preview, and console checks.

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:build passed
  • Timeline Playwright suite passed 20/20

Merged PR: #2664

Backend, safety, and rollout

  • Added display-only ThinkingText to the internal output item
  • Strict reasoning_text parsing with summary_text fallback
  • Redacted, bounded diagnostic projection; opaque and malformed content rejected
  • Full focused provider and service Go suites passed
  • npm run api:test passed
  • Deployed API and frontend from exact origin/main; real release smoke passed
  • Three authenticated production acceptance scenarios passed

Tracking issue: #2663

Shipped code surface

FileShipped responsibility
backend/internal/provider/agentmodel/provider.goDefine the dedicated internal display field.
backend/internal/provider/agentmodel/responses.goStrictly normalize allowlisted provider reasoning text.
backend/internal/provider/agentmodel/responses_test.goTest valid, multiple, empty, malformed, and opaque-only reasoning items.
backend/internal/service/backend_build_executor.goProject sanitized thought content into model-call completion diagnostics.
backend/internal/service/backend_build_executor_test.goProve event projection and unchanged model transcript behavior.
frontend/src/routes/project.tsxKeep individual Details rows; aggregate eligible chat thinking to one marker under Lovable’s ownership rule.
frontend/e2e/project-timeline.spec.tsCover 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.