mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2e7c14637 | ||
|
|
ef4da1323f | ||
|
|
1e848492e5 |
No files matched your search
+15
-8
@@ -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})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,12 +44,111 @@ 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.
|
||||
@@ -112,6 +218,7 @@ 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{
|
||||
|
||||
@@ -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), " ")
|
||||
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user