PeachJam engineering decision report
Issue #2663 · Operator review packet · 22 July 2026

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.

Frontend grouping implemented Provider normalization awaits operator approval Request payload unchanged Authenticated UI + API evidence

Decision in one minute

Already implemented

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.

Approval requested

Provider normalization

Normalize explicit provider-visible reasoning_text, with summary_text as a fallback, into a dedicated internal ThinkingText field.

Then implement

Timeline projection

Project sanitized thinking_text onto the existing chronological model-call event. Keep raw replay and encrypted/opaque provider state unchanged.

Approval boundary: this proposal changes how PeachJam interprets data received from an LLM provider. Per repository policy, the provider-layer portion must be reviewed by the operator before code is edited.

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.

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.

Concrete live 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. No request change is needed to recover it.

Suggested narrow fix

  1. Normalize Lovable-equivalent content. Accept only reasoning.content[].reasoning_text; if absent, fall back to reasoning.summary[].summary_text. Trim empties, preserve order, and join multiple allowed entries with paragraph breaks.
  2. Keep it separate from model transcript text. Add a dedicated ThinkingText field to the internal provider output item. Do not reuse Text.
  3. Project it chronologically. Put sanitized thinking_text on 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.
  4. Use the existing Details contract. The frontend already accepts thinking_text; each per-call Details row becomes expandable without another endpoint or fetch.
  5. 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.
  6. Leave absent content absent. If neither allowlisted field is present, keep the duration-only non-expandable row. Never decrypt, infer, or fabricate text.
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 will prove it

Focused automated tests

Real live-run acceptance

RunLovable baselinePeachJam assertion after deploy
Minimal “Hello, world!” website0 chat Thought markers; 3 expandable Details thoughts0 chat markers; ≥1 expandable Details thought; every normalized thought appears once in chronological Details
Answer-only follow-up, no editsExactly 1 aggregate chat Thought markerExactly 1 chat marker even if the provider used multiple calls; Details remains expandable
Image website build0 chat markers; thoughts interleaved with generated images and edits in Details0 chat markers; ≥1 thought exists and expands in Details; image/tool chronology preserved
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.

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

Draft PR: #2664

Pending after approval

  • Add ThinkingText to the internal output item
  • Parse strict reasoning_text with summary_text fallback
  • Project sanitized content to completion diagnostics
  • Run the three live acceptance scenarios above

Tracking issue: #2663

Planned code surface

FilePlanned 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.

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.