diff --git a/internal/acp/agent.go b/internal/acp/agent.go index cf09af1ee..1e1470976 100644 --- a/internal/acp/agent.go +++ b/internal/acp/agent.go @@ -528,8 +528,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string, queue := func(input sessions.AppendEventInput) { pendingEvents = append(pendingEvents, input) } - queue(messageEvent("user", userText)) - visionCache := make(map[string]bool) var visionCacheMu sync.Mutex supportsVision := func(modelID string) bool { @@ -553,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, @@ -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..f893b90ec --- /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: ""}, + } { + 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 != 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)) { + 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 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) + } + } + 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..15842b2bb 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -434,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 @@ -614,10 +616,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..6e9f9c080 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 != 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)) { + 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 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)) + } + }) + } +} + // 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) + } + }) + } +} 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