Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions git-hooks/spec-registry.txt
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@
1.23 1 43 main tag
1.18 1 44 main tag
1.19 1 45 main tag
1.14 1 46 main skip
1.24 1 47 acceptance tag
1.4a 1 48 sub skip
1.25 1 49 main tag
65 changes: 65 additions & 0 deletions internal/app/cli/llmapp/bullet_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2026 opendbx contributors. See LICENSE.
//
// Author: sqlrush

package llmapp

import (
"testing"

"github.com/sqlrush/opendbx/internal/app/cli/render/block"
)

// TestMarkAssistantBullet_FirstContentOnly: the bullet lands on the first
// non-empty content Message only; thinking/empty/tool nodes are skipped and
// the bullet is consumed (pending→false) once placed.
func TestMarkAssistantBullet_FirstContentOnly(t *testing.T) {
t.Parallel()
nodes := []block.RenderNode{
block.Message{Empty: true}, // thinking-only placeholder: skip
block.Message{Text: "first answer"}, // ← gets the bullet
block.Message{Text: "second line same turn"}, // no bullet (per-turn)
}
out, pending := markAssistantBullet(nodes, true)
if pending {
t.Errorf("bullet should be consumed (pending=false) after placement")
}
m0 := out[0].(block.Message)
m1 := out[1].(block.Message)
m2 := out[2].(block.Message)
if m0.Speaker == block.SpeakerAssistant {
t.Errorf("empty placeholder must NOT get the bullet")
}
if m1.Speaker != block.SpeakerAssistant {
t.Errorf("first content node should get SpeakerAssistant, got %v", m1.Speaker)
}
if m2.Speaker == block.SpeakerAssistant {
t.Errorf("second content node must NOT get a bullet (per-turn)")
}
}

// TestMarkAssistantBullet_StaysPendingNoContent: a drain with no content
// Message (e.g. thinking-only) keeps the bullet pending for a later drain in
// the same turn.
func TestMarkAssistantBullet_StaysPendingNoContent(t *testing.T) {
t.Parallel()
nodes := []block.RenderNode{block.Message{Empty: true}}
_, pending := markAssistantBullet(nodes, true)
if !pending {
t.Errorf("no content node → bullet must stay pending")
}
}

// TestMarkAssistantBullet_NotPendingNoop: when not pending, nodes are
// untouched.
func TestMarkAssistantBullet_NotPendingNoop(t *testing.T) {
t.Parallel()
nodes := []block.RenderNode{block.Message{Text: "x"}}
out, pending := markAssistantBullet(nodes, false)
if pending {
t.Errorf("pending should remain false")
}
if out[0].(block.Message).Speaker == block.SpeakerAssistant {
t.Errorf("not-pending must not mark any node")
}
}
108 changes: 104 additions & 4 deletions internal/app/cli/llmapp/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ type Options struct {
// Options keeps spec-1.21 behavior; production turns it on via config.
DedupEnabled bool
DedupWindow int

// spec-1.25 chrome fields. Welcome gates the startup welcome panel seed
// (D-2: option-gated so only the interactive bootstrap path seeds it —
// headless / error-fallback / test constructions leave it false). Cwd
// (already ~-abbreviated) + GitBranch feed the rich status bar
// (D-4 StatusSegments); both are read ONCE by the caller (no per-frame
// filesystem IO) and cached on the Model.
Welcome bool
Cwd string
Version string
GitBranch string
}

// Model is the spec-1.20 production chat Model (replaces demoapp); under
Expand Down Expand Up @@ -93,6 +104,13 @@ type Model struct {
sawThinking bool // any thinking-channel token this turn (spec-1.20.2 D-5)
thinkingBuf string // accumulated thinking text when strip_think=false

// assistantBulletPending marks that the current assistant turn has not yet
// emitted its ⏺ speaker bullet (spec-1.25 D-5 / CRIT-A). Set at submit;
// the first drained content Message of the turn consumes it (per-turn, not
// per-line). render/streaming stays neutral — the bullet is a post-drain
// render-only decoration applied here in llmapp.
assistantBulletPending bool

// toolUseNames joins ToolResult.ToolUseID → ToolUse.Name within a
// single submit (spec-1.21 D-6 / spec-1.9b R3 HIGH-3: rendering a
// ToolResult requires the peer ToolUse.Name; empty → skip render
Expand All @@ -105,6 +123,12 @@ type Model struct {
// captured by the snapshotBuilder and sealed at EventFinish. /report reads
// it (spec-1.23 D-3/D-4). nil until the first completed run.
lastSnapshot *report.RunSnapshot

// spec-1.25 D-4 cached status fields (read once at New; StatusSegments
// reads these on every frame with zero filesystem IO). cwd is already
// ~-abbreviated; gitBranch is "" when not in a git repo / detached.
cwd string
gitBranch string
}

var (
Expand Down Expand Up @@ -141,7 +165,7 @@ func New(provider llm.Provider, opts Options) *Model {
// at wiring time; tests would catch this immediately.
panic("llmapp.New: " + err.Error())
}
return &Model{
m := &Model{
provider: provider,
loop: loop,
modelName: opts.ModelName,
Expand All @@ -151,9 +175,27 @@ func New(provider llm.Provider, opts Options) *Model {
stripThink: opts.StripThink,
thinkingMode: opts.ThinkingMode,
thinkingBudget: opts.ThinkingBudget,
cwd: opts.Cwd,
gitBranch: opts.GitBranch,
}
// spec-1.25 D-2: option-gated welcome seed at scrollback head. Only the
// interactive bootstrap path sets Welcome=true (Q6 ★A construct-time seed:
// deterministic, no first-frame gap, no Cmd timing). The welcome is a
// plain render-only node that scrolls with history and is NOT re-seeded
// on /clear (Q-life ★A CC parity).
if opts.Welcome {
m.scrollback = []block.RenderNode{
block.NewWelcome(opts.Version, opts.Cwd, welcomeTip),
}
}
return m
}

// welcomeTip is the single static onboarding tip shown in the welcome
// panel (spec-1.25 D-1; non-random so the render is replayable). DB-flavored
// and capability-honest (no reference to unbuilt slash commands).
const welcomeTip = "提示:直接用自然语言描述你的数据库问题即可开始诊断"

// Init has no startup Cmd.
func (m *Model) Init() scheduler.Cmd { return nil }

Expand Down Expand Up @@ -198,6 +240,7 @@ func (m *Model) Update(msg scheduler.Msg) (program.Model, scheduler.Cmd) {
if nodes := next.stream.Drain(); len(nodes) > 0 {
nodes = filterThinkingOnlyEmpty(nodes, next.sawThinking, next.sawContent)
if len(nodes) > 0 {
nodes, next.assistantBulletPending = markAssistantBullet(nodes, next.assistantBulletPending)
next.scrollback = appendNodes(next.scrollback, nodes)
}
}
Expand All @@ -208,6 +251,7 @@ func (m *Model) Update(msg scheduler.Msg) (program.Model, scheduler.Cmd) {
next.streaming = false
next.sawThinking = false
next.thinkingBuf = ""
next.assistantBulletPending = false // turn over; do not leak bullet
return &next, nil
}
return m, nil
Expand Down Expand Up @@ -243,7 +287,11 @@ func (m *Model) handleAction(msg program.KeyActionMsg) (program.Model, scheduler
// guard at the top of handleAction already blocks any submit mid-run,
// so /report cannot race a live diagnosis.)
if isReportCommand(next.buffer) {
return &next, next.dispatchReport()
next.buffer = ""
next.cursor = 0
nodes, cmd := next.dispatchReport()
next.scrollback = appendNodes(next.scrollback, nodes)
return &next, cmd
}
return next.submit()
case keybindings.ActionCancel:
Expand Down Expand Up @@ -281,7 +329,11 @@ func (m *Model) submit() (program.Model, scheduler.Cmd) {
Role: llm.RoleUser,
Content: []llm.ContentBlock{{Type: llm.BlockText, Text: userText}},
}, m.maxHistory)
next.scrollback = appendNode(m.scrollback, block.Message{Text: "> " + userText})
// spec-1.25 D-5: user echo is plain (no "> " prefix — CC parity), tagged
// SpeakerUser. Starting the assistant turn arms the ⏺ bullet for the first
// content node drained (CRIT-A per-turn).
next.scrollback = appendNode(m.scrollback, block.Message{Text: userText, Speaker: block.SpeakerUser})
next.assistantBulletPending = true
next.toolUseNames = map[string]string{} // reset per submit (spec-1.21 D-6)

return &next, loopStartCmd(ctx, m.loop, req, userText, ts, ctrl, m.stripThink)
Expand Down Expand Up @@ -330,6 +382,16 @@ func (m *Model) handleControl(msg streamControlMsg) (program.Model, scheduler.Cm
next.thinkingBuf += msg.ThinkingToken
}
case msg.ToolUse != nil:
// spec-1.25 D-5 / codex D5-HIGH-1 (DEFERRED): draining pending stream
// text here to order assistant prose before the tool node is NOT done —
// the TokenStream is shared across all loop turns and the loop goroutine
// can race ahead, so a boundary drain pulls FUTURE-turn text before this
// tool node (proven by TestModel_LoopAppendsToolBlocks failing under CI
// timing). A correct fix needs producer-side ordering (per-turn stream
// boundary or text-via-ordered-channel) — tracked as a spec-1.21
// follow-up. The ⏺ bullet still attaches correctly via markAssistantBullet
// at the View/streamDoneMsg drain.
//
// Mutate the per-submit map in place — next is already a shallow
// copy and toolUseNames is owned by this in-flight submit.
if next.toolUseNames == nil {
Expand Down Expand Up @@ -469,6 +531,7 @@ func (m *Model) View(cols, rows int) buffer.Buffer {
if m.stream != nil {
if nodes := m.stream.Drain(); len(nodes) > 0 {
nodes = filterThinkingOnlyEmpty(nodes, m.sawThinking, m.sawContent)
nodes, m.assistantBulletPending = markAssistantBullet(nodes, m.assistantBulletPending)
m.scrollback = append(m.scrollback, nodes...)
}
}
Expand Down Expand Up @@ -497,13 +560,26 @@ func (m *Model) InputState() program.InputState {
return program.InputState{Buffer: m.buffer, Cursor: m.cursor}
}

// StatusSegments shows the model name + a streaming indicator.
// StatusSegments shows model · cwd · git-branch + a streaming indicator
// (spec-1.25 D-4, CC PromptInputFooter parity). cwd + gitBranch are cached
// at New (read once from the filesystem by the interactive bootstrap) — this
// method performs NO filesystem IO, since it runs on every render frame.
// token/context values are intentionally absent until spec-3.8/3.10 wire
// them (原则 3: no fake values). The mode segment is appended by
// program.paintStatusLine (spec-1.16 D-5 append-only contract).
func (m *Model) StatusSegments() []program.StatusSegment {
name := m.modelName
if name == "" {
name = m.provider.Name()
}
dim := style.Style{FG: style.Palette(8)} // terminal-relative grey (R-10)
segs := []program.StatusSegment{{Text: name}}
if m.cwd != "" {
segs = append(segs, program.StatusSegment{Text: m.cwd, Style: dim})
}
if m.gitBranch != "" {
segs = append(segs, program.StatusSegment{Text: m.gitBranch, Style: dim})
}
if m.streaming {
segs = append(segs, program.StatusSegment{Text: "●", Style: style.Style{Bold: true}})
}
Expand Down Expand Up @@ -551,6 +627,30 @@ func appendNodes(sb []block.RenderNode, nodes []block.RenderNode) []block.Render
return next
}

// markAssistantBullet tags the FIRST content Message in nodes with
// SpeakerAssistant when a bullet is pending, returning the (possibly
// modified) nodes and the still-pending flag (spec-1.25 D-5 / CRIT-A).
// Per-turn: only the first assistant content node of a turn carries the ⏺
// bullet (CC AssistantTextMessage is per-message, not per-line). When no
// content Message is found (e.g. thinking-only / tool-only drain), the
// bullet stays pending for a later drain in the same turn. nodes are
// replaced by value (block.Message is a value type), not mutated in place.
func markAssistantBullet(nodes []block.RenderNode, pending bool) ([]block.RenderNode, bool) {
if !pending {
return nodes, false
}
for i, n := range nodes {
msg, ok := n.(block.Message)
if !ok || msg.Text == "" || msg.Empty {
continue
}
msg.Speaker = block.SpeakerAssistant
nodes[i] = msg
return nodes, false // consumed
}
return nodes, true // still pending
}

func filterThinkingOnlyEmpty(nodes []block.RenderNode, sawThinking, sawContent bool) []block.RenderNode {
if !sawThinking || sawContent || len(nodes) == 0 {
return nodes
Expand Down
30 changes: 19 additions & 11 deletions internal/app/cli/llmapp/report_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const (
reportStreamingBusyMsg = "诊断进行中,请等完成后再 /report。"
reportWrittenPrefix = "报告已写入: "
reportWriteFailPrefix = "报告已渲染,但写入文件失败: "
// reportHeaderTitle is the spec-1.25 D-7 SpeakerAssistant header prepended
// before the report Markdown node so the ⏺ bullet sits on a title line and
// the report body reads as an assistant turn (CC hierarchy parity).
reportHeaderTitle = "诊断报告"
)

// reportWrittenMsg / reportWriteFailedMsg carry the result of the write Cmd.
Expand All @@ -40,21 +44,25 @@ func isReportCommand(buffer string) bool {
input.ValueWithoutPrefix(buffer) == reportCommandName
}

// dispatchReport consumes a "/report" submission on the receiver (a *Model copy
// owned by handleAction). It clears the input, renders the report into
// scrollback (pure), and returns a Cmd that writes the file (IO deferred to the
// Cmd). A nil lastSnapshot yields a friendly note and no Cmd.
func (m *Model) dispatchReport() scheduler.Cmd {
m.buffer = ""
m.cursor = 0
// dispatchReport renders a "/report" submission into scrollback NODES and a
// file-write Cmd, WITHOUT mutating the receiver (go-reviewer H-1 / code-reviewer
// MED-1: keep the Update-path immutability discipline compile-visible — the
// caller, holding its own *Model copy, clears the input and appends the nodes).
// A nil lastSnapshot yields a friendly note and no Cmd.
func (m *Model) dispatchReport() ([]block.RenderNode, scheduler.Cmd) {
if m.lastSnapshot == nil {
m.scrollback = appendNode(m.scrollback, block.Message{Text: reportNoDiagnosisMsg})
return nil
return []block.RenderNode{block.Message{Text: reportNoDiagnosisMsg}}, nil
}
now := time.Now()
md := report.Generate(*m.lastSnapshot, now)
m.scrollback = appendNode(m.scrollback, block.NewMarkdown(md))
return writeReportCmd(md, now)
// spec-1.25 D-7: prepend a SpeakerAssistant header so the ⏺ bullet sits on
// the title and the Markdown body indents naturally beneath it (Markdown
// has no Speaker field; a header Message is the composition seam).
nodes := []block.RenderNode{
block.Message{Text: reportHeaderTitle, Speaker: block.SpeakerAssistant},
block.NewMarkdown(md),
}
return nodes, writeReportCmd(md, now)
}

// writeReportCmd performs the (blocking) file write off the pure Update path.
Expand Down
Loading
Loading