From d62d5608f631d2ab19a118b83d8e18b8edcf7a9b Mon Sep 17 00:00:00 2001 From: lhk Date: Fri, 16 Oct 2026 15:29:17 +0800 Subject: [PATCH 1/3] fix(sessions): preserve image attachment summaries on resume Persist TUI-compatible attachment counts for ACP and exec user events. Restore readable markers in live history, session replay and resumed prompts without storing or resending historical image data. Cover image-only and mixed prompts, restart recovery, malformed metadata and the aborted-turn boundary with regression tests. Fixes #1135 --- internal/acp/agent.go | 21 +- internal/acp/agent_test.go | 6 +- internal/acp/image_history_test.go | 181 ++++++++++++++++++ internal/cli/exec.go | 9 +- internal/cli/exec_streamjson_images_test.go | 105 ++++++++++ internal/sessions/exec_session.go | 28 ++- internal/sessions/message_attachments.go | 68 +++++++ internal/sessions/message_attachments_test.go | 31 +++ 8 files changed, 438 insertions(+), 11 deletions(-) create mode 100644 internal/acp/image_history_test.go create mode 100644 internal/sessions/message_attachments.go create mode 100644 internal/sessions/message_attachments_test.go diff --git a/internal/acp/agent.go b/internal/acp/agent.go index cf09af1ee..9aab8d36b 100644 --- a/internal/acp/agent.go +++ b/internal/acp/agent.go @@ -528,7 +528,8 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string, queue := func(input sessions.AppendEventInput) { pendingEvents = append(pendingEvents, input) } - queue(messageEvent("user", userText)) + attachments := sessions.MessageAttachments{Images: len(images)} + queue(messageEvent("user", userText, attachments)) visionCache := make(map[string]bool) var visionCacheMu sync.Mutex @@ -593,7 +594,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string, agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt) result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts) if result.FinalAnswer != "" { - queue(messageEvent("assistant", result.FinalAnswer)) + queue(messageEvent("assistant", result.FinalAnswer, sessions.MessageAttachments{})) } reason, stopErr := stopReasonFor(result, runErr) if stopErr != nil { @@ -614,7 +615,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string, // one file write, which is the contiguity restored here — not a disk // transaction, and not a promise about crashes mid-fsync. Reported by @jatmn. persistenceErr := a.persistTurn(sess.id, pendingEvents) - sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer}) + sess.appendHistory(turnRecord{user: attachments.WithContent(userText), assistant: result.FinalAnswer}) a.warnPersistence( note, "save session history", @@ -930,10 +931,14 @@ func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput return err } -func messageEvent(role, content string) sessions.AppendEventInput { +func messageEvent(role, content string, attachments sessions.MessageAttachments) sessions.AppendEventInput { + payload := map[string]any{"role": role, "content": content} + if role == "user" && (attachments.Images > 0 || attachments.Documents > 0) { + payload["attachments"] = attachments + } return sessions.AppendEventInput{ Type: sessions.EventMessage, - Payload: map[string]any{"role": role, "content": content}, + Payload: payload, } } @@ -1094,14 +1099,16 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec continue } var msg struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content"` + Attachments sessions.MessageAttachments `json:"attachments"` } if json.Unmarshal(raw, &msg) != nil { continue } switch msg.Role { case "user": + msg.Content = msg.Attachments.WithContent(msg.Content) messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content}) if havePending { records = append(records, turnRecord{user: pendingUser}) diff --git a/internal/acp/agent_test.go b/internal/acp/agent_test.go index 165fe3682..21061be19 100644 --- a/internal/acp/agent_test.go +++ b/internal/acp/agent_test.go @@ -960,7 +960,11 @@ func TestACPHardTurnFailureDoesNotCommitHistory(t *testing.T) { t.Fatal(err) } if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{ - SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failed question")}, + SessionID: created.SessionID, + Prompt: []ContentBlock{ + TextBlock("failed question"), + ImageBlock(base64.StdEncoding.EncodeToString([]byte("failed image")), "image/png"), + }, }, &PromptResult{}); err == nil { t.Fatal("hard RunAgent failure was reported as a successful turn") } diff --git a/internal/acp/image_history_test.go b/internal/acp/image_history_test.go new file mode 100644 index 000000000..5e84a7445 --- /dev/null +++ b/internal/acp/image_history_test.go @@ -0,0 +1,181 @@ +package acp + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/Twigpine/zero/internal/agent" + "github.com/Twigpine/zero/internal/config" + "github.com/Twigpine/zero/internal/providermodeldiscovery" + "github.com/Twigpine/zero/internal/sessions" + "github.com/Twigpine/zero/internal/zeroruntime" +) + +func TestACPImageAttachmentHistory(t *testing.T) { + for _, tc := range []struct { + name string + text string + images int + withoutVision bool + want string + }{ + {name: "image only", images: 1, want: "[1 image]"}, + {name: "text and images", text: "compare these", images: 2, want: "compare these [2 images]"}, + {name: "ignored by model", images: 1, withoutVision: true, want: "[1 image]"}, + } { + t.Run(tc.name, func(t *testing.T) { + deps := testDeps(t) + deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) { + modalities := []string{"text", "image"} + if tc.withoutVision { + modalities = []string{"text"} + } + return []providermodeldiscovery.Model{{ID: "fake-model", InputModalities: modalities}}, nil + } + type observedTurn struct { + prompt string + images int + } + turns := make(chan observedTurn, 8) + deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) { + turns <- observedTurn{prompt: prompt, images: len(opts.Images)} + return agent.Result{FinalAnswer: "received"}, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + workspace := t.TempDir() + h := newHarness(t, deps) + defer h.stop() + var created NewSessionResult + if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil { + t.Fatal(err) + } + const imageData = "image-history-bytes-not-for-storage" + encoded := base64.StdEncoding.EncodeToString([]byte(imageData)) + var blocks []ContentBlock + if tc.text != "" { + blocks = append(blocks, TextBlock(tc.text)) + } + for range tc.images { + blocks = append(blocks, ImageBlock(encoded, "image/png")) + } + if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: blocks}, &PromptResult{}); err != nil { + t.Fatal(err) + } + live := <-turns + wantLiveImages := tc.images + if tc.withoutVision { + wantLiveImages = 0 + } + if live.images != wantLiveImages { + t.Fatalf("live images = %d, want %d", live.images, wantLiveImages) + } + events, err := deps.Store.ReadEvents(created.SessionID) + if err != nil || len(events) < 2 { + t.Fatalf("read completed turn: events=%+v err=%v", events, err) + } + var user struct { + Role string `json:"role"` + Content string `json:"content"` + Attachments struct { + Images int `json:"images"` + } `json:"attachments"` + } + if err := json.Unmarshal(events[0].Payload, &user); err != nil { + t.Fatal(err) + } + if user.Role != "user" || user.Content != tc.text || user.Attachments.Images != tc.images { + t.Errorf("stored user event = %s, want original text and attachments.images=%d", events[0].Payload, tc.images) + } + for _, event := range events { + if bytes.Contains(event.Payload, []byte(encoded)) || bytes.Contains(event.Payload, []byte(imageData)) { + t.Errorf("image data leaked into session event: %s", event.Payload) + } + } + followUp := func(t *testing.T, client *clientHarness) { + t.Helper() + if err := client.client.Call(ctx, MethodSessionPrompt, PromptParams{ + SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("what did I send?")}, + }, &PromptResult{}); err != nil { + t.Fatal(err) + } + got := <-turns + if !strings.Contains(got.prompt, "User: "+tc.want+"\n") { + t.Errorf("seeded prompt missing attachment marker %q: %q", tc.want, got.prompt) + } + if got.images != 0 { + t.Errorf("historical images reattached: %d", got.images) + } + } + followUp(t, h) + h.stop() + // Fresh agents and stores reproduce a process restart, with no shared + // in-memory history to hide a missing attachment in the event log. + for _, method := range []string{MethodSessionLoad, MethodSessionResume} { + t.Run(method, func(t *testing.T) { + restoredDeps := deps + restoredDeps.Store = sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir}) + loader := newHarness(t, restoredDeps) + defer loader.stop() + if err := loader.client.Call(ctx, method, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace}, &LoadSessionResult{}); err != nil { + t.Fatal(err) + } + if method == MethodSessionLoad { + select { + case update := <-loader.notifications: + if update.SessionUpdate != UpdateUserMessageChunk || update.Content.Text != tc.want { + t.Errorf("replayed user = %+v, want %q", update, tc.want) + } + case <-ctx.Done(): + t.Fatal("user turn was not replayed") + } + } else { + select { + case update := <-loader.notifications: + t.Errorf("resume replayed transcript: %+v", update) + default: + } + } + followUp(t, loader) + }) + } + }) + } +} + +func TestACPAttachmentHistoryReadsExistingSessionMessages(t *testing.T) { + deps := testDeps(t) + meta, err := deps.Store.Create(sessions.CreateInput{SessionID: "attachment-compatibility", Cwd: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + for i, payload := range []string{ + `{"role":"user","content":"old text"}`, + `{"role":"user","content":"","attachments":{"images":1,"documents":2}}`, + `{"role":"user","content":"still text","attachments":{"images":-1,"documents":1.5}}`, + `{"role":"assistant","content":"answer","attachments":{"images":1}}`, + } { + if _, err := deps.Store.AppendEvent(meta.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: json.RawMessage(payload)}); err != nil { + t.Fatalf("append message %d: %v", i, err) + } + } + a := &Agent{deps: deps} + _, messages, _, err := a.loadHistory(meta.SessionID, false) + if err != nil { + t.Fatal(err) + } + want := []string{"old text", "[1 image] [2 documents]", "still text", "answer"} + if len(messages) != len(want) { + t.Fatalf("messages = %+v", messages) + } + for i, text := range want { + if messages[i].content != text { + t.Errorf("message %d = %q, want %q", i, messages[i].content, text) + } + } +} diff --git a/internal/cli/exec.go b/internal/cli/exec.go index bffe59427..88e10f4d6 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -425,6 +425,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // agent.Options.Images wiring below. Without this merge, images sent over // stream-json are parsed and validated but never reach the agent. images = append(images, streamImages...) + attachments := sessions.MessageAttachments{Images: len(images)} // Gate against the EFFECTIVE resolved model (not the --model override). An // unknown/custom id can't be confirmed vision-capable, so drop+warn rather // than error: image input is best-effort, never fatal to the run. @@ -614,10 +615,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in sessionRecorder := execSessionRecorder{prepared: preparedSession} // Surface a best-effort session-recording failure once, on every exit path. defer sessionRecorder.warnIfRecordingFailed(stderr) - sessionRecorder.append(sessions.EventMessage, map[string]any{ + userPayload := map[string]any{ "role": "user", "content": prompt, - }) + } + if attachments.Images > 0 { + userPayload["attachments"] = attachments + } + sessionRecorder.append(sessions.EventMessage, userPayload) // OnAskUser is intentionally left unset: headless runs have no interactive // user, so ask_user degrades to a "proceed with your best assumption" result diff --git a/internal/cli/exec_streamjson_images_test.go b/internal/cli/exec_streamjson_images_test.go index cdf2d083b..6a125a5db 100644 --- a/internal/cli/exec_streamjson_images_test.go +++ b/internal/cli/exec_streamjson_images_test.go @@ -4,11 +4,16 @@ import ( "bytes" "context" "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" "strings" "sync" "testing" "github.com/Twigpine/zero/internal/config" + "github.com/Twigpine/zero/internal/sessions" "github.com/Twigpine/zero/internal/zeroruntime" ) @@ -18,6 +23,7 @@ import ( type capturingImageProvider struct { mu sync.Mutex images []zeroruntime.ImageBlock + prompt string } func (provider *capturingImageProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) { @@ -25,6 +31,7 @@ func (provider *capturingImageProvider) StreamCompletion(ctx context.Context, re for index := len(request.Messages) - 1; index >= 0; index-- { if request.Messages[index].Role == zeroruntime.MessageRoleUser { provider.images = append([]zeroruntime.ImageBlock(nil), request.Messages[index].Images...) + provider.prompt = request.Messages[index].Content break } } @@ -37,6 +44,104 @@ func (provider *capturingImageProvider) StreamCompletion(ctx context.Context, re return ch, nil } +func TestRunExecImageAttachmentHistory(t *testing.T) { + for _, tc := range []struct { + name, model string + fileImage bool + }{ + {name: "vision", model: "gpt-4.1"}, + {name: "ignored by model", model: "gpt-3.5-turbo"}, + {name: "stream and file images", model: "gpt-4.1", fileImage: true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"} { + t.Setenv(name, root) + } + cwd := t.TempDir() + png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, 0x49, 0x48, 0x44, 0x52} + encoded := base64.StdEncoding.EncodeToString(png) + input := `{"schemaVersion":2,"type":"message","role":"user","content":"","images":[{"mediaType":"image/png","data":"` + encoded + `"}]}` + "\n" + provider := &capturingImageProvider{} + deps := appDeps{ + stdin: strings.NewReader(input), + getwd: func() (string, error) { return cwd, nil }, + resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) { + cfg := execResolvedConfig() + cfg.Provider.Model = tc.model + return cfg, nil + }, + newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return provider, nil }, + } + var stdout, stderr bytes.Buffer + args := []string{"exec", "--input-format", "stream-json", "--init-session-id", "image_history"} + wantImages := 1 + if tc.fileImage { + if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o600); err != nil { + t.Fatal(err) + } + args = append(args, "--image", "shot.png") + wantImages++ + } + code := runWithDeps(args, &stdout, &stderr, deps) + if code != exitSuccess { + t.Fatalf("image-only exec exit=%d stderr=%s", code, stderr.String()) + } + provider.mu.Lock() + liveImages := len(provider.images) + provider.mu.Unlock() + wantLiveImages := wantImages + if tc.model == "gpt-3.5-turbo" { + wantLiveImages = 0 + } + if liveImages != wantLiveImages { + t.Fatalf("live images = %d, want %d", liveImages, wantLiveImages) + } + store := sessions.NewStore(sessions.StoreOptions{}) + events, err := store.ReadEvents("image_history") + if err != nil || len(events) == 0 { + t.Fatalf("read exec events: %v, %v", events, err) + } + var user struct { + Content string `json:"content"` + Attachments struct { + Images int `json:"images"` + } `json:"attachments"` + } + if err := json.Unmarshal(events[0].Payload, &user); err != nil { + t.Fatal(err) + } + if user.Content != "" || user.Attachments.Images != wantImages { + t.Errorf("stored exec user event = %s, want empty content and attachments.images=%d", events[0].Payload, wantImages) + } + for _, event := range events { + if bytes.Contains(event.Payload, []byte(encoded)) { + t.Errorf("image data leaked into session: %s", event.Payload) + } + } + stdout.Reset() + stderr.Reset() + deps.stdin = strings.NewReader("") + code = runWithDeps([]string{"exec", "--resume", "image_history", "what did I send?"}, &stdout, &stderr, deps) + if code != exitSuccess { + t.Fatalf("resume exec exit=%d stderr=%s", code, stderr.String()) + } + provider.mu.Lock() + defer provider.mu.Unlock() + marker := "[1 image]" + if wantImages > 1 { + marker = fmt.Sprintf("[%d images]", wantImages) + } + if !strings.Contains(provider.prompt, marker) { + t.Errorf("resume prompt missing attachment marker: %q", provider.prompt) + } + if len(provider.images) != 0 { + t.Errorf("resume reattached %d historical images", len(provider.images)) + } + }) + } +} + // TestRunExecStreamJSONImageReachesAgent locks the end-to-end wiring: a base64 // image carried on a stream-json `message` event, fed via stdin on a vision // model, must reach agent.Options.Images (and therefore the provider request's diff --git a/internal/sessions/exec_session.go b/internal/sessions/exec_session.go index 6ec309bb1..ec45e37a3 100644 --- a/internal/sessions/exec_session.go +++ b/internal/sessions/exec_session.go @@ -182,7 +182,11 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string { lines := []string{} for _, event := range events { - lines = append(lines, fmt.Sprintf("- #%d %s: %s", event.Sequence, event.Type, summarizePayload(event.Payload))) + payload := any(event.Payload) + if event.Type == EventMessage { + payload = messageContextPayload(event.Payload) + } + lines = append(lines, fmt.Sprintf("- #%d %s: %s", event.Sequence, event.Type, summarizePayload(payload))) } label := "Continuing" sessionID := prepared.Session.SessionID @@ -512,6 +516,28 @@ func forkTitle(title string) string { return title + " (fork)" } +// Project attachment counts into readable text before the generic summary walks +// the payload. Keep other message metadata and non-message events unchanged. +func messageContextPayload(payload json.RawMessage) any { + var message map[string]any + if json.Unmarshal(payload, &message) != nil || message["role"] != "user" { + return payload + } + content, ok := message["content"].(string) + if !ok { + return payload + } + var fields struct { + Attachments MessageAttachments `json:"attachments"` + } + if json.Unmarshal(payload, &fields) != nil { + return payload + } + message["content"] = fields.Attachments.WithContent(content) + delete(message, "attachments") + return message +} + func summarizePayload(payload any) string { text := extractText(payload) text = strings.Join(strings.Fields(text), " ") diff --git a/internal/sessions/message_attachments.go b/internal/sessions/message_attachments.go new file mode 100644 index 000000000..319c6365c --- /dev/null +++ b/internal/sessions/message_attachments.go @@ -0,0 +1,68 @@ +package sessions + +import ( + "encoding/json" + "fmt" + "math" + "strings" +) + +// MessageAttachments is the count-only summary stored on user message events. +// It matches the TUI's attachments payload; image data is never persisted here. +type MessageAttachments struct { + Images int `json:"images"` + Documents int `json:"documents"` +} + +// Match the TUI's bound when reading untrusted persisted attachment counts. +const maxMessageAttachments = 64 + +// UnmarshalJSON ignores malformed counts without dropping the message's text. +func (a *MessageAttachments) UnmarshalJSON(data []byte) error { + var raw struct { + Images json.RawMessage `json:"images"` + Documents json.RawMessage `json:"documents"` + } + *a = MessageAttachments{} + if json.Unmarshal(data, &raw) != nil { + return nil + } + a.Images = messageAttachmentCount(raw.Images) + a.Documents = messageAttachmentCount(raw.Documents) + return nil +} + +func messageAttachmentCount(raw json.RawMessage) int { + var count float64 + if json.Unmarshal(raw, &count) != nil || count <= 0 || count != math.Trunc(count) { + return 0 + } + return int(min(count, maxMessageAttachments)) +} + +// WithContent renders an attachment marker beside the original message text for +// history and replay. The stored content itself stays unchanged. +func (a MessageAttachments) WithContent(content string) string { + parts := []string{} + for _, item := range []struct { + count int + name string + }{{a.Images, "image"}, {a.Documents, "document"}} { + count := min(max(item.count, 0), maxMessageAttachments) + if count == 0 { + continue + } + name := item.name + if count != 1 { + name += "s" + } + parts = append(parts, fmt.Sprintf("[%d %s]", count, name)) + } + if len(parts) == 0 { + return content + } + if content != "" { + return content + " " + strings.Join(parts, " ") + } + return strings.Join(parts, " ") +} diff --git a/internal/sessions/message_attachments_test.go b/internal/sessions/message_attachments_test.go new file mode 100644 index 000000000..9e9caa081 --- /dev/null +++ b/internal/sessions/message_attachments_test.go @@ -0,0 +1,31 @@ +package sessions + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestFormatExecPromptAttachmentSummary(t *testing.T) { + for _, tc := range []struct{ name, payload, want string }{ + {"image only", `{"role":"user","content":"","attachments":{"images":1,"documents":0}}`, "[1 image] user"}, + {"text and images", `{"role":"user","content":"compare","attachments":{"images":2}}`, "compare [2 images] user"}, + {"TUI documents", `{"role":"user","content":"read","attachments":{"documents":1}}`, "read [1 document] user"}, + {"old text", `{"role":"user","content":"old"}`, "old user"}, + {"malformed counts", `{"role":"user","content":"text","attachments":{"images":-2,"documents":1.5}}`, "text user"}, + {"wrong attachment type", `{"role":"user","content":"text","attachments":"wrong"}`, "text user"}, + {"wrong count type", `{"role":"user","content":"text","attachments":{"images":"1","documents":true}}`, "text user"}, + {"bounded counts", `{"role":"user","content":"","attachments":{"images":1e100,"documents":64.5}}`, "[64 images] user"}, + {"preserve metadata", `{"role":"user","content":"read","origin":"cross_session","from":"peer","attachments":{"images":1}}`, "read [1 image] peer cross_session user"}, + } { + t.Run(tc.name, func(t *testing.T) { + got := FormatExecPrompt("continue", PreparedExec{ + Mode: ModeResume, + ContextEvents: []Event{{Sequence: 1, Type: EventMessage, Payload: json.RawMessage(tc.payload)}}, + }) + if !strings.Contains(got, "- #1 message: "+tc.want+"\n") { + t.Errorf("resume context = %q, want message %q", got, tc.want) + } + }) + } +} From d8a6f716345246877d785393d0a21db867834cc9 Mon Sep 17 00:00:00 2001 From: lhk Date: Sat, 17 Oct 2026 11:58:14 +0800 Subject: [PATCH 2/3] fix(workspaceindex): follow the current module path Use github.com/Twigpine/zero/internal/gitsafe in the ignore scanner and its tests. The upstream module rename preceded the scanner's gitsafe change, leaving these two imports on the old path and preventing the rebased tree from compiling. --- internal/workspaceindex/gitignore.go | 2 +- internal/workspaceindex/gitignore_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/workspaceindex/gitignore.go b/internal/workspaceindex/gitignore.go index 2b4dc41b7..cf09da158 100644 --- a/internal/workspaceindex/gitignore.go +++ b/internal/workspaceindex/gitignore.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/Gitlawb/zero/internal/gitsafe" + "github.com/Twigpine/zero/internal/gitsafe" ) // gitIgnoredPathsTimeout bounds how long Scan waits on git before giving up and falling diff --git a/internal/workspaceindex/gitignore_test.go b/internal/workspaceindex/gitignore_test.go index e1802b406..2544affbc 100644 --- a/internal/workspaceindex/gitignore_test.go +++ b/internal/workspaceindex/gitignore_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/Gitlawb/zero/internal/gitsafe" + "github.com/Twigpine/zero/internal/gitsafe" ) // isolateGitConfig keeps the git child processes away from the developer's real global and From 49f77f4c9e44194b3214c2c8b89c004406a6f8f3 Mon Sep 17 00:00:00 2001 From: lhk Date: Sat, 17 Oct 2026 11:58:21 +0800 Subject: [PATCH 3/3] fix(sessions): count only images accepted by the model Take ACP and exec attachment counts after the vision capability gate, matching the TUI's persisted attachment semantics. Update the ignored-image regressions to require zero saved images and no historical image marker. Refs #1135 --- internal/acp/agent.go | 6 +++--- internal/acp/image_history_test.go | 10 +++++----- internal/cli/exec.go | 3 ++- internal/cli/exec_streamjson_images_test.go | 8 ++++---- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/internal/acp/agent.go b/internal/acp/agent.go index 9aab8d36b..1e1470976 100644 --- a/internal/acp/agent.go +++ b/internal/acp/agent.go @@ -528,9 +528,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string, queue := func(input sessions.AppendEventInput) { pendingEvents = append(pendingEvents, input) } - attachments := sessions.MessageAttachments{Images: len(images)} - queue(messageEvent("user", userText, attachments)) - visionCache := make(map[string]bool) var visionCacheMu sync.Mutex supportsVision := func(modelID string) bool { @@ -554,6 +551,9 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string, effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText) images = nil } + // Match the TUI: only images actually sent to the model enter history. + attachments := sessions.MessageAttachments{Images: len(images)} + queue(messageEvent("user", userText, attachments)) opts := agent.Options{ Cwd: sess.cwd, diff --git a/internal/acp/image_history_test.go b/internal/acp/image_history_test.go index 5e84a7445..f893b90ec 100644 --- a/internal/acp/image_history_test.go +++ b/internal/acp/image_history_test.go @@ -26,7 +26,7 @@ func TestACPImageAttachmentHistory(t *testing.T) { }{ {name: "image only", images: 1, want: "[1 image]"}, {name: "text and images", text: "compare these", images: 2, want: "compare these [2 images]"}, - {name: "ignored by model", images: 1, withoutVision: true, want: "[1 image]"}, + {name: "ignored by model", images: 1, withoutVision: true, want: ""}, } { t.Run(tc.name, func(t *testing.T) { deps := testDeps(t) @@ -89,8 +89,8 @@ func TestACPImageAttachmentHistory(t *testing.T) { if err := json.Unmarshal(events[0].Payload, &user); err != nil { t.Fatal(err) } - if user.Role != "user" || user.Content != tc.text || user.Attachments.Images != tc.images { - t.Errorf("stored user event = %s, want original text and attachments.images=%d", events[0].Payload, tc.images) + if user.Role != "user" || user.Content != tc.text || user.Attachments.Images != wantLiveImages { + t.Errorf("stored user event = %s, want original text and attachments.images=%d", events[0].Payload, wantLiveImages) } for _, event := range events { if bytes.Contains(event.Payload, []byte(encoded)) || bytes.Contains(event.Payload, []byte(imageData)) { @@ -105,8 +105,8 @@ func TestACPImageAttachmentHistory(t *testing.T) { t.Fatal(err) } got := <-turns - if !strings.Contains(got.prompt, "User: "+tc.want+"\n") { - t.Errorf("seeded prompt missing attachment marker %q: %q", tc.want, got.prompt) + if want := "User: " + tc.want + "\n"; !strings.Contains(got.prompt, want) { + t.Errorf("seeded prompt missing user history %q: %q", want, got.prompt) } if got.images != 0 { t.Errorf("historical images reattached: %d", got.images) diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 88e10f4d6..15842b2bb 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -425,7 +425,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in // agent.Options.Images wiring below. Without this merge, images sent over // stream-json are parsed and validated but never reach the agent. images = append(images, streamImages...) - attachments := sessions.MessageAttachments{Images: len(images)} // Gate against the EFFECTIVE resolved model (not the --model override). An // unknown/custom id can't be confirmed vision-capable, so drop+warn rather // than error: image input is best-effort, never fatal to the run. @@ -435,6 +434,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in } images = nil } + // Match the TUI: only images actually sent to the model enter history. + attachments := sessions.MessageAttachments{Images: len(images)} runReasoningEffort := options.reasoningEffort if options.useSpec && options.specReasoningEffort != "" { runReasoningEffort = options.specReasoningEffort diff --git a/internal/cli/exec_streamjson_images_test.go b/internal/cli/exec_streamjson_images_test.go index 6a125a5db..6e9f9c080 100644 --- a/internal/cli/exec_streamjson_images_test.go +++ b/internal/cli/exec_streamjson_images_test.go @@ -111,8 +111,8 @@ func TestRunExecImageAttachmentHistory(t *testing.T) { if err := json.Unmarshal(events[0].Payload, &user); err != nil { t.Fatal(err) } - if user.Content != "" || user.Attachments.Images != wantImages { - t.Errorf("stored exec user event = %s, want empty content and attachments.images=%d", events[0].Payload, wantImages) + if user.Content != "" || user.Attachments.Images != wantLiveImages { + t.Errorf("stored exec user event = %s, want empty content and attachments.images=%d", events[0].Payload, wantLiveImages) } for _, event := range events { if bytes.Contains(event.Payload, []byte(encoded)) { @@ -132,8 +132,8 @@ func TestRunExecImageAttachmentHistory(t *testing.T) { if wantImages > 1 { marker = fmt.Sprintf("[%d images]", wantImages) } - if !strings.Contains(provider.prompt, marker) { - t.Errorf("resume prompt missing attachment marker: %q", provider.prompt) + if hasMarker := strings.Contains(provider.prompt, marker); hasMarker != (wantLiveImages > 0) { + t.Errorf("resume marker %q present = %v, want %v: %q", marker, hasMarker, wantLiveImages > 0, provider.prompt) } if len(provider.images) != 0 { t.Errorf("resume reattached %d historical images", len(provider.images))