mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(tui): a restored image-only turn is still a turn
The terminal's hand-written HistoryMessage dropped images, via and modelRef. A turn that was only an image has empty text, so Restore saw nothing to draw and dropped it from the transcript, and every reload lost the device and the model of turns it did keep. Carry the fields that decide that a turn exists and what it was, restore the image-only row with an honest line, and register the pair in repolint so a dropped field fails make check.
This commit is contained in:
1 parent
a001b16563
commit
6c1f68df48
10 files changed
+168
-23
No files matched your search
@@ -108,6 +108,8 @@ type Messages struct {
|
||||
ChatThinking string // live reasoning marker label, e.g. "thinking…"
|
||||
ChatThoughtForFmt string // collapsed reasoning summary, "%d" = elapsed s
|
||||
ChatWorkedForFmt string // end-of-turn line, "%s" = the turn's run time
|
||||
ChatImageAttachedFmt string // a restored turn's attachment line, "%d" = how many images
|
||||
ChatFromDeviceFmt string // where a restored user turn came from, "%d" = device number
|
||||
ChatStatusThinkingFmt string // "%s thinking… (%s · %s cancels)" — %s = spinner, %s = elapsed, %s = interrupt key
|
||||
TurnPhaseWorking string // host turn_phase label: working
|
||||
TurnPhaseChecking string // host turn_phase label: checking
|
||||
|
||||
@@ -115,6 +115,8 @@ var English = Messages{
|
||||
ChatThinking: "thinking…",
|
||||
ChatThoughtForFmt: "thought for %ds",
|
||||
ChatWorkedForFmt: "worked for %s",
|
||||
ChatImageAttachedFmt: "%d image(s) attached — this terminal shows no image",
|
||||
ChatFromDeviceFmt: "from device %d",
|
||||
ChatStatusThinkingFmt: "%s thinking… (%s · %s cancels)",
|
||||
TurnPhaseWorking: "working",
|
||||
TurnPhaseChecking: "checking",
|
||||
|
||||
@@ -115,6 +115,8 @@ var Chinese = Messages{
|
||||
ChatThinking: "思考中…",
|
||||
ChatThoughtForFmt: "思考了 %d 秒",
|
||||
ChatWorkedForFmt: "耗时 %s",
|
||||
ChatImageAttachedFmt: "附有 %d 张图片 — 终端不显示图片",
|
||||
ChatFromDeviceFmt: "来自 设备 %d",
|
||||
ChatStatusThinkingFmt: "%s 思考中… (%s · %s 取消)",
|
||||
TurnPhaseWorking: "工作中",
|
||||
TurnPhaseChecking: "检查中",
|
||||
|
||||
@@ -116,6 +116,8 @@ var ChineseTraditional = Messages{
|
||||
ChatThinking: "思考中…",
|
||||
ChatThoughtForFmt: "思考了 %d 秒",
|
||||
ChatWorkedForFmt: "耗時 %s",
|
||||
ChatImageAttachedFmt: "附有 %d 張圖片 — 終端不顯示圖片",
|
||||
ChatFromDeviceFmt: "來自 裝置 %d",
|
||||
ChatStatusThinkingFmt: "%s 思考中… (%s · %s 取消)",
|
||||
TurnPhaseWorking: "工作中",
|
||||
TurnPhaseChecking: "檢查中",
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"reasonix/internal/contract/eventwire"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
@@ -162,12 +163,24 @@ func (c *Client) SetApprovalMode(ctx context.Context, mode string) error {
|
||||
|
||||
// HistoryMessage is one record of the session as /history returns it.
|
||||
type HistoryMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
MsgIndex int `json:"msgIndex"`
|
||||
HostAuthored bool `json:"hostAuthored,omitempty"`
|
||||
Steer bool `json:"steer,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
MsgIndex int `json:"msgIndex"`
|
||||
HostAuthored bool `json:"hostAuthored,omitempty"`
|
||||
Steer bool `json:"steer,omitempty"`
|
||||
// Images is how many attachments the turn carried. A turn that was only an
|
||||
// image has no text left once the host's blocks come off, so without the
|
||||
// count Restore cannot tell it from host chrome and drops it.
|
||||
Images int `json:"images,omitempty"`
|
||||
// Via is the paired device a user message was sent from; absent is the
|
||||
// window. The record wrote it when the message landed, so a reload can
|
||||
// still say where the line came from.
|
||||
Via *eventwire.Via `json:"via,omitempty"`
|
||||
// ModelRef is which model wrote an assistant turn. A reopened transcript
|
||||
// has no turn_started to read it off, and the composer's current setting is
|
||||
// a different fact that has usually moved on.
|
||||
ModelRef string `json:"modelRef,omitempty"`
|
||||
ThoughtMs int64 `json:"thoughtMs,omitempty"`
|
||||
WorkDurationMs int64 `json:"workDurationMs,omitempty"`
|
||||
ToolCalls []HistoryToolCall `json:"toolCalls,omitempty"`
|
||||
|
||||
@@ -27,16 +27,7 @@ var diffFoldLines = diffPreviewLines
|
||||
func renderItem(it *Item, width, shown int, hideRail bool) string {
|
||||
switch it.Kind {
|
||||
case ItemUser:
|
||||
mark := "› "
|
||||
if it.Steer {
|
||||
mark = "↳ "
|
||||
}
|
||||
rows := strings.Split(termrender.Hardwrap(strings.TrimRight(it.Text, "\n"), max(width-5, 10)), "\n")
|
||||
for i, r := range rows {
|
||||
rows[i] = " " + termrender.Accent(mark+r)
|
||||
mark = " "
|
||||
}
|
||||
return "\n" + strings.Join(rows, "\n")
|
||||
return renderUser(it, width)
|
||||
case ItemSay:
|
||||
// Thinking with nothing said after it is a step, not an answer: it gets
|
||||
// its marker and no speaker header.
|
||||
@@ -79,6 +70,35 @@ func renderItem(it *Item, width, shown int, hideRail bool) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderUser draws the person's line and what came with it. A turn can carry
|
||||
// no words and still be a turn — the record says so by its image count — so
|
||||
// the attachment gets its own line rather than the row quietly disappearing.
|
||||
// Via is only ever set on a restored turn, and says which paired device the
|
||||
// line came from without touching the window's own rows.
|
||||
func renderUser(it *Item, width int) string {
|
||||
mark := "› "
|
||||
if it.Steer {
|
||||
mark = "↳ "
|
||||
}
|
||||
var lines []string
|
||||
if strings.TrimSpace(it.Text) != "" {
|
||||
for r := range strings.SplitSeq(termrender.Hardwrap(strings.TrimRight(it.Text, "\n"), max(width-5, 10)), "\n") {
|
||||
lines = append(lines, " "+termrender.Accent(mark+r))
|
||||
mark = " "
|
||||
}
|
||||
}
|
||||
if it.Images > 0 {
|
||||
lines = append(lines, " "+termrender.Dim(mark+fmt.Sprintf(i18n.M.ChatImageAttachedFmt, it.Images)))
|
||||
}
|
||||
if it.Via != nil {
|
||||
lines = append(lines, termrender.Dim(" "+fmt.Sprintf(i18n.M.ChatFromDeviceFmt, it.Via.Ordinal)))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderSayPart renders a stretch of an answer. Only the first stretch carries
|
||||
// the speaker's header; the rest continue under it. A stretch is cut after a
|
||||
// blank line, so each later one starts a new block and gets that line back.
|
||||
|
||||
@@ -13,14 +13,16 @@ func (t *Transcript) Restore(msgs []HistoryMessage) {
|
||||
for _, m := range msgs {
|
||||
switch m.Role {
|
||||
case "user":
|
||||
// A line the host composed is not something the person said.
|
||||
if m.HostAuthored || m.Content == "" {
|
||||
// A line the host composed is not something the person said. A turn
|
||||
// with neither words nor an image is host chrome; one with a count
|
||||
// is a turn that carried only an image.
|
||||
if m.HostAuthored || (m.Content == "" && m.Images == 0) {
|
||||
continue
|
||||
}
|
||||
t.Items = append(t.Items, Item{ID: t.id(), Kind: ItemUser, Text: m.Content, Steer: m.Steer})
|
||||
t.Items = append(t.Items, Item{ID: t.id(), Kind: ItemUser, Text: m.Content, Steer: m.Steer, Images: m.Images, Via: m.Via})
|
||||
case "assistant":
|
||||
if m.Content != "" || m.Reasoning != "" {
|
||||
t.Items = append(t.Items, Item{ID: t.id(), Kind: ItemSay, Text: m.Content, Reasoning: m.Reasoning, Done: true, Framed: true, ThoughtMs: m.ThoughtMs})
|
||||
t.Items = append(t.Items, Item{ID: t.id(), Kind: ItemSay, Text: m.Content, Reasoning: m.Reasoning, Done: true, Framed: true, ThoughtMs: m.ThoughtMs, ModelRef: m.ModelRef})
|
||||
}
|
||||
t.addWorked(m.WorkDurationMs)
|
||||
for _, c := range m.ToolCalls {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"reasonix/internal/contract/eventwire"
|
||||
)
|
||||
|
||||
// A turn whose text is empty because it was only an image is still a turn:
|
||||
// /history carries the count exactly because the text has nothing left once
|
||||
// the host's blocks come off. Restore has to keep it and say what it was.
|
||||
func TestRestoreKeepsATurnThatWasOnlyAnImage(t *testing.T) {
|
||||
var m HistoryMessage
|
||||
if err := json.Unmarshal([]byte(`{"role":"user","content":"","images":1}`), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := &Transcript{}
|
||||
tr.Restore([]HistoryMessage{m})
|
||||
if len(tr.Items) != 1 || tr.Items[0].Kind != ItemUser {
|
||||
t.Fatalf("an image-only turn restored as %d rows (%+v)", len(tr.Items), tr.Items)
|
||||
}
|
||||
if tr.Items[0].Images != 1 {
|
||||
t.Fatalf("the restored row lost the image count: %+v", tr.Items[0])
|
||||
}
|
||||
if got := ansi.Strip(renderItem(&tr.Items[0], 80, 0, false)); !strings.Contains(got, "1 image") {
|
||||
t.Fatalf("the restored row says nothing about the image: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A turn that carried words and an image keeps both facts.
|
||||
func TestRestoreKeepsTheWordsAndTheImageOfATurn(t *testing.T) {
|
||||
tr := &Transcript{}
|
||||
tr.Restore([]HistoryMessage{{Role: "user", Content: "what is this?", Images: 2}})
|
||||
if len(tr.Items) != 1 {
|
||||
t.Fatalf("restored %d rows, want one: %+v", len(tr.Items), tr.Items)
|
||||
}
|
||||
row := tr.Items[0]
|
||||
if row.Text != "what is this?" || row.Images != 2 {
|
||||
t.Fatalf("restored row = %+v, want its words and two images", row)
|
||||
}
|
||||
got := ansi.Strip(renderItem(&row, 80, 0, false))
|
||||
if !strings.Contains(got, "what is this?") || !strings.Contains(got, "2 image") {
|
||||
t.Fatalf("the row does not show both facts: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A normal text turn restores exactly as it did before: the person's line and
|
||||
// a settled answer, with nothing added to either.
|
||||
func TestRestoreOfANormalTextTurnIsUnchanged(t *testing.T) {
|
||||
tr := &Transcript{}
|
||||
tr.Restore([]HistoryMessage{
|
||||
{Role: "user", Content: "fix the build"},
|
||||
{Role: "assistant", Content: "fixed"},
|
||||
})
|
||||
if len(tr.Items) != 2 {
|
||||
t.Fatalf("restored %d rows, want two: %+v", len(tr.Items), tr.Items)
|
||||
}
|
||||
if tr.Items[0].Kind != ItemUser || tr.Items[0].Text != "fix the build" || tr.Items[0].Images != 0 || tr.Items[0].Via != nil {
|
||||
t.Fatalf("user row = %+v", tr.Items[0])
|
||||
}
|
||||
if tr.Items[1].Kind != ItemSay || tr.Items[1].Text != "fixed" || !tr.Items[1].Done {
|
||||
t.Fatalf("answer row = %+v", tr.Items[1])
|
||||
}
|
||||
if got := ansi.Strip(renderItem(&tr.Items[0], 80, 0, false)); strings.Contains(got, "image") || strings.Contains(got, "device") {
|
||||
t.Fatalf("a normal text turn gained a line: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The contract keeps the provenance of a turn: the device a person wrote from
|
||||
// and the model that answered. Restore keeps both on the row that carries them,
|
||||
// so a reload can still say them.
|
||||
func TestRestoreKeepsWhereATurnCameFrom(t *testing.T) {
|
||||
tr := &Transcript{}
|
||||
tr.Restore([]HistoryMessage{
|
||||
{Role: "user", Content: "go", Via: &eventwire.Via{Device: "dev-a", Ordinal: 2}},
|
||||
{Role: "assistant", Content: "ok", ModelRef: "deepseek/deepseek-v4"},
|
||||
})
|
||||
if len(tr.Items) != 2 {
|
||||
t.Fatalf("restored %d rows, want two: %+v", len(tr.Items), tr.Items)
|
||||
}
|
||||
if via := tr.Items[0].Via; via == nil || via.Device != "dev-a" || via.Ordinal != 2 {
|
||||
t.Fatalf("the user row lost where it came from: %+v", tr.Items[0])
|
||||
}
|
||||
if got := ansi.Strip(renderItem(&tr.Items[0], 80, 0, false)); !strings.Contains(got, "device 2") {
|
||||
t.Fatalf("the restored row does not say where it came from: %q", got)
|
||||
}
|
||||
if tr.Items[1].ModelRef != "deepseek/deepseek-v4" {
|
||||
t.Fatalf("the answer row lost the model that wrote it: %+v", tr.Items[1])
|
||||
}
|
||||
}
|
||||
@@ -35,18 +35,21 @@ type Item struct {
|
||||
ReceiptLine bool
|
||||
|
||||
// ItemUser. Pending is input the kernel has not taken into a turn yet;
|
||||
// Steer is input a running turn read at a tool boundary. MsgIndex is the
|
||||
// kernel's name for the message once a turn has started on it.
|
||||
// Steer is guidance a running turn read; Images is all an image-only turn
|
||||
// has, and Via is the paired device it came from (nil: the window).
|
||||
Text string
|
||||
Pending bool
|
||||
Steer bool
|
||||
QueueID string
|
||||
MsgIndex int
|
||||
Images int
|
||||
Via *eventwire.Via
|
||||
|
||||
// ItemSay.
|
||||
// ItemSay. ModelRef is the model the record says wrote this answer.
|
||||
Reasoning string
|
||||
Done bool
|
||||
ThoughtMs int64
|
||||
ModelRef string
|
||||
// Framed is set once the kernel's message frame has settled the answer; a
|
||||
// dispatch closes one before its frame arrives.
|
||||
Framed bool
|
||||
|
||||
@@ -361,6 +361,11 @@ var partialWireMirrors = []partialWireMirror{
|
||||
// reopened session shows the rewrite as the model's own.
|
||||
{"internal/frontend/serve/history.go", "historyToolCall", "internal/frontend/tui/client.go", "HistoryToolCall",
|
||||
[]string{"id", "name", "arguments", "argumentNormalization", "resolvedName", "capabilityId"}},
|
||||
// The terminal's copy of /history's message. It draws no picture, but the
|
||||
// count tells an image-only turn from host chrome, and via/modelRef are
|
||||
// what a reload can no longer say once the reader drops them.
|
||||
{"internal/frontend/serve/history.go", "historyMessage", "internal/frontend/tui/client.go", "HistoryMessage",
|
||||
[]string{"images", "via", "modelRef"}},
|
||||
}
|
||||
|
||||
// partialWireMirror is one declared subset: readerFile's readerType must carry
|
||||
|
||||
Reference in new issue
Block a user