Implement image attachments for Codex (#190)

This commit is contained in:
João Salavisa
2026-02-05 23:52:23 +00:00
committed by GitHub
parent 3321bb8b81
commit 50d53cc710
@@ -4,12 +4,16 @@ package codex
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
@@ -181,8 +185,6 @@ func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error {
return nil
}
// sanitizeCodexServerName converts a server name to a valid TOML table name.
// Replaces spaces and special characters with underscores.
func sanitizeCodexServerName(name string) string {
@@ -383,8 +385,7 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error {
// Prompt sends a prompt to the agent, starting a new turn.
// This method blocks until the turn completes (turn/completed notification received).
// Note: attachments are not yet supported in Codex protocol - they are ignored.
func (a *Adapter) Prompt(ctx context.Context, message string, _ []v1.MessageAttachment) error {
func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.MessageAttachment) error {
a.mu.Lock()
client := a.client
threadID := a.threadID
@@ -400,13 +401,34 @@ func (a *Adapter) Prompt(ctx context.Context, message string, _ []v1.MessageAtta
return fmt.Errorf("adapter not initialized")
}
a.logger.Info("sending prompt", zap.String("thread_id", threadID))
inputs := make([]codex.UserInput, 0, 1+len(attachments))
if strings.TrimSpace(message) != "" {
inputs = append(inputs, codex.UserInput{Type: "text", Text: message})
}
if len(attachments) > 0 {
imagePaths, err := a.saveImageAttachments(a.cfg.WorkDir, attachments)
if err != nil {
a.logger.Warn("failed to save image attachments", zap.Error(err))
} else {
for _, imagePath := range imagePaths {
inputs = append(inputs, codex.UserInput{Type: "localImage", Path: imagePath})
}
}
}
if len(inputs) == 0 {
return fmt.Errorf("prompt requires message text or attachments")
}
a.logger.Info("sending prompt",
zap.String("thread_id", threadID),
zap.Int("inputs", len(inputs)),
zap.Int("image_attachments", len(attachments)))
params := &codex.TurnStartParams{
ThreadID: threadID,
Input: []codex.UserInput{
{Type: "text", Text: message},
},
Input: inputs,
}
resp, err := client.Call(ctx, codex.MethodTurnStart, params)
@@ -474,6 +496,58 @@ func (a *Adapter) Prompt(ctx context.Context, message string, _ []v1.MessageAtta
}
}
// saveImageAttachments saves image attachments to temp files in the workspace.
func (a *Adapter) saveImageAttachments(workDir string, attachments []v1.MessageAttachment) ([]string, error) {
var imagePaths []string
if workDir == "" {
return nil, fmt.Errorf("workDir is required to save attachments")
}
tempDir := filepath.Join(workDir, ".kandev", "temp", "images")
if err := os.MkdirAll(tempDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
for _, att := range attachments {
if att.Type != "image" {
continue
}
imageData, err := base64.StdEncoding.DecodeString(att.Data)
if err != nil {
a.logger.Warn("failed to decode image attachment", zap.Error(err))
continue
}
ext := ".png"
switch att.MimeType {
case "image/jpeg":
ext = ".jpg"
case "image/gif":
ext = ".gif"
case "image/webp":
ext = ".webp"
case "image/png":
ext = ".png"
}
filename := fmt.Sprintf("image-%s%s", uuid.New().String()[:8], ext)
filePath := filepath.Join(tempDir, filename)
if err := os.WriteFile(filePath, imageData, 0644); err != nil {
a.logger.Warn("failed to write image file", zap.Error(err), zap.String("path", filePath))
continue
}
imagePaths = append(imagePaths, filePath)
a.logger.Info("saved image attachment",
zap.String("path", filePath),
zap.Int("size", len(imageData)))
}
return imagePaths, nil
}
// Cancel interrupts the current turn.
func (a *Adapter) Cancel(ctx context.Context) error {
a.mu.RLock()