Compare commits

..
Author SHA1 Message Date
Vasanthdev2004 1b220a37d2 fix(workspaceindex): import gitsafe under the Twigpine module path
#1149 was opened before #1105 moved the module to github.com/Twigpine/zero
and merged right after it, so its two imports of internal/gitsafe still
used the old path. Nothing provides github.com/Gitlawb/zero any more, so
main stopped building at 2ffe1831.
2026-10-17 11:23:11 +05:30
8 changed files with 12 additions and 442 deletions

No files matched your search

+8 -15
View File
@@ -528,6 +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))
visionCache := make(map[string]bool)
var visionCacheMu sync.Mutex
supportsVision := func(modelID string) bool {
@@ -551,9 +553,6 @@ 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,
@@ -594,7 +593,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, sessions.MessageAttachments{}))
queue(messageEvent("assistant", result.FinalAnswer))
}
reason, stopErr := stopReasonFor(result, runErr)
if stopErr != nil {
@@ -615,7 +614,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: attachments.WithContent(userText), assistant: result.FinalAnswer})
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
a.warnPersistence(
note,
"save session history",
@@ -931,14 +930,10 @@ func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput
return err
}
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
}
func messageEvent(role, content string) sessions.AppendEventInput {
return sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: payload,
Payload: map[string]any{"role": role, "content": content},
}
}
@@ -1099,16 +1094,14 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
continue
}
var msg struct {
Role string `json:"role"`
Content string `json:"content"`
Attachments sessions.MessageAttachments `json:"attachments"`
Role string `json:"role"`
Content string `json:"content"`
}
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})
+1 -5
View File
@@ -960,11 +960,7 @@ func TestACPHardTurnFailureDoesNotCommitHistory(t *testing.T) {
t.Fatal(err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
SessionID: created.SessionID,
Prompt: []ContentBlock{
TextBlock("failed question"),
ImageBlock(base64.StdEncoding.EncodeToString([]byte("failed image")), "image/png"),
},
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failed question")},
}, &PromptResult{}); err == nil {
t.Fatal("hard RunAgent failure was reported as a successful turn")
}
-181
View File
@@ -1,181 +0,0 @@
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)
}
}
}
+2 -8
View File
@@ -434,8 +434,6 @@ 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
@@ -616,14 +614,10 @@ 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)
userPayload := map[string]any{
sessionRecorder.append(sessions.EventMessage, 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
-107
View File
@@ -4,16 +4,11 @@ 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"
)
@@ -23,7 +18,6 @@ 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) {
@@ -31,7 +25,6 @@ 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
}
}
@@ -44,111 +37,12 @@ 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) {
// The shared isolation also turns off the models.dev refresh that
// runExec starts in the background. Without it, the refresh downloads
// the catalog into this HOME and races the TempDir cleanup.
isolateCLIUserState(t)
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
// user turn). Before the fix, stream-json images were parsed and validated but
// silently dropped — never threaded into the agent run.
func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
// A minimal PNG, base64-encoded for the stream-json image payload.
@@ -218,7 +112,6 @@ func TestRunExecStreamJSONImageReachesAgent(t *testing.T) {
// the fix, ResolvePrompt's "must include at least one prompt or user message
// event" error was returned before images were considered, rejecting the turn.
func TestRunExecStreamJSONImageOnlyMessageProceeds(t *testing.T) {
isolateCLIUserState(t)
cwd := t.TempDir()
png := []byte{
+1 -27
View File
@@ -182,11 +182,7 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
lines := []string{}
for _, event := range events {
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)))
lines = append(lines, fmt.Sprintf("- #%d %s: %s", event.Sequence, event.Type, summarizePayload(event.Payload)))
}
label := "Continuing"
sessionID := prepared.Session.SessionID
@@ -516,28 +512,6 @@ 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), " ")
-68
View File
@@ -1,68 +0,0 @@
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, " ")
}
@@ -1,31 +0,0 @@
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)
}
})
}
}