feat(attachments): admit images at the session boundary

Problem: Composer and CLI image references could lose their bytes before the
provider request, leaving path text to fall through to tools and Bash.

Root cause: Image preparation was split across focus-sensitive Desktop RPCs,
temporary draft credentials, controller state, and request-time model routing
instead of one durable session admission boundary.

Fix: Admit ordered image batches before creating the user turn, freeze accepted
sources into session-owned image inputs, bind Desktop targets to session
identity, and preserve legacy provider serialization and CLI path behavior.

Verification: Focused attachment, control, provider, boot, serve, tool, Desktop,
and frontend suites passed on the latest main-v2 base. Frontend typecheck,
repolint, and git diff checks also passed.
This commit is contained in:
SivanCola committed 2026-09-19 22:56:34 +08:00
1 parent fceba04079
commit e7a5b149e3
140 files changed
+10465 -1910

No files matched your search

+23 -165
View File
@@ -171,6 +171,7 @@ type App struct {
tabOrder []string
activeTabID string
readyHook func()
attachmentTargetState
// tabSelectionMu serializes cross-registry activation. A remote selection
// must not overtake the local-session snapshot that makes switching safe.
tabSelectionMu sync.Mutex
@@ -1113,26 +1114,27 @@ func (a *App) submitInitialGoalToLocalTab(
if goal == "" {
return []string{}, fmt.Errorf("goal is required")
}
if err := syncTabGoalToController(ctrl, goal); err != nil {
return []string{}, fmt.Errorf("activate goal: %w", err)
}
a.mu.Lock()
if a.tabs[tab.ID] != tab {
var drained []string
setup := func() error {
if err := syncTabGoalToController(ctrl, goal); err != nil {
return fmt.Errorf("activate goal: %w", err)
}
a.mu.Lock()
if a.tabs[tab.ID] != tab {
a.mu.Unlock()
return a.workspaceNotReadyErr(nil)
}
tab.toolApprovalMode = toolApprovalMode
tab.goal = goal
tab.mode = tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo)
a.saveTabsLocked()
a.mu.Unlock()
return []string{}, a.workspaceNotReadyErr(nil)
}
tab.toolApprovalMode = toolApprovalMode
tab.goal = goal
tab.mode = tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo)
a.saveTabsLocked()
a.mu.Unlock()
ctrl.SetPlanMode(false)
drained := applyTabToolApprovalModeToController(ctrl, toolApprovalMode)
if err := a.ensureTabTopicIndexedForUserTurn(tab); err != nil {
return []string{}, err
ctrl.SetPlanMode(false)
drained = applyTabToolApprovalModeToController(ctrl, toolApprovalMode)
return a.ensureTabTopicIndexedForUserTurn(tab)
}
if err := submitIdentified(ctrl, req, func() {
if err := submitIdentifiedWithSetup(ctrl, req, setup, func() {
if len(invocations) > 0 {
ctrl.SubmitInvocationDisplay(display, input, controlInvocationRequests(invocations))
} else {
@@ -5524,6 +5526,7 @@ func (state *historyMessageConvertState) convertHistoryMessage(
}
}
hm.ServerSearch = historyServerSearch(m.ServerSearch)
hm.Attachments = historyDisplayAttachments(m.ImageInputs)
if (m.Role == provider.RoleAssistant || m.LocalOnly) && len(m.ToolCalls) > 0 {
hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))
for i, tc := range m.ToolCalls {
@@ -5726,6 +5729,9 @@ func cloneHistoryMessages(in []HistoryMessage) []HistoryMessage {
if len(in[i].ToolCalls) > 0 {
out[i].ToolCalls = append([]HistoryToolCall(nil), in[i].ToolCalls...)
}
if len(in[i].Attachments) > 0 {
out[i].Attachments = append([]transcript.Attachment(nil), in[i].Attachments...)
}
}
return out
}
@@ -10607,54 +10613,6 @@ func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, e
return entry, nil
}
func (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {
var result string
err := a.withActiveWorkspaceDo(func() error {
var err error
result, err = fn()
return err
})
return result, err
}
func (a *App) withActiveWorkspaceDo(fn func() error) error {
root := a.activeWorkspaceRoot()
if root != "" && root != "." {
prev, err := os.Getwd()
if err != nil {
return err
}
if err := os.Chdir(root); err != nil {
return err
}
defer func() { _ = os.Chdir(prev) }()
}
return fn()
}
// SavePastedImage stores a browser clipboard image data URL under the active
// tab's workspace .reasonix/attachments and returns the relative @-reference path.
func (a *App) SavePastedImage(dataURL string) (string, error) {
return a.withActiveWorkspace(func() (string, error) {
return control.SaveImageDataURL(dataURL)
})
}
// SaveClipboardImage reads the native OS clipboard image under the active tab's
// workspace .reasonix/attachments and returns the relative @-reference path.
func (a *App) SaveClipboardImage() (string, error) {
return a.withActiveWorkspace(control.SaveClipboardImage)
}
// SavePastedFile stores a dropped non-image file (the browser exposes its bytes
// as a data URL but not a real path) under the active tab's workspace
// .reasonix/attachments and returns the relative @-reference path.
func (a *App) SavePastedFile(name, dataURL string) (string, error) {
return a.withActiveWorkspace(func() (string, error) {
return control.SaveAttachmentDataURL(name, dataURL)
})
}
// PickExportFile opens the native save dialog and returns the selected path. It
// returns "" when the user cancels.
func (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {
@@ -10933,106 +10891,6 @@ func exportFileFilters(mimeType, ext string) []nativeFileFilter {
return []nativeFileFilter{{DisplayName: "All files (*.*)", Pattern: "*.*"}}
}
// AttachmentDataURL returns a safe data URL for a stored image attachment.
func (a *App) AttachmentDataURL(path string) (string, error) {
return a.withActiveWorkspace(func() (string, error) {
return control.ImageDataURL(path)
})
}
// DroppedItem is one OS-dropped file resolved into a composer context entry: an
// in-tree file becomes a workspace @reference (read in place, no copy), while an
// outside directory becomes a session-scoped workspace @reference; an image or
// out-of-tree file is copied into .reasonix/attachments.
type DroppedItem struct {
Kind string `json:"kind"` // "workspace" | "attachment"
Path string `json:"path"`
IsDir bool `json:"isDir,omitempty"`
DisplayPath string `json:"displayPath,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
}
// AttachDropped turns an absolute path from the native file-drop bridge into a
// composer context entry. Images are stored as attachments so the chip shows a
// thumbnail; in-workspace files are referenced relatively (no copy); directories
// outside the workspace are registered as current-session folder references;
// files outside the workspace are copied into .reasonix/attachments.
func (a *App) AttachDropped(path string) (DroppedItem, error) {
var item DroppedItem
err := a.withActiveWorkspaceDo(func() error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if isImageExt(path) {
if rel, err := control.SaveImageFile(path); err == nil {
preview, _ := control.ImageDataURL(rel)
item = DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}
return nil
}
}
if rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {
item = DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}
return nil
}
if info.IsDir() {
tab, ctrl := a.tabAndCtrlByID("")
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return err
}
if tab != nil {
ctrl = a.controllerForTab(tab)
}
if ctrl == nil {
return fmt.Errorf("workspace is not ready")
}
token, displayPath, err := ctrl.RegisterExternalFolderRef(path)
if err != nil {
return err
}
item = DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath}
return nil
}
rel, err := control.SaveAttachmentFile(path)
if err != nil {
return err
}
item = DroppedItem{Kind: "attachment", Path: rel}
return nil
})
if err != nil {
return DroppedItem{}, err
}
return item, nil
}
func isImageExt(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp":
return true
}
return false
}
func workspaceRelativeIn(path, workspaceRoot string) (string, bool) {
root := workspaceRoot
if !filepath.IsAbs(root) {
abs, err := filepath.Abs(root)
if err != nil {
return "", false
}
root = abs
}
rel, err := filepath.Rel(root, path)
if err != nil {
return "", false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", false
}
return filepath.ToSlash(rel), true
}
// memory panel (frontend ⇄ controller)
type MemoryImport struct {
+209 -3
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -79,6 +80,199 @@ func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
}
}
func TestReadSessionAttachmentForTabRequiresCanonicalSession(t *testing.T) {
app := &App{
tabs: map[string]*WorkspaceTab{
"a": {ID: "a", WorkspaceRoot: t.TempDir(), SessionGeneration: 1},
},
activeTabID: "a",
}
if _, err := app.ReadSessionAttachmentForTab("a", strings.Repeat("ab", 32), 0); err == nil {
t.Fatal("ReadSessionAttachmentForTab accepted a tab without a canonical session")
}
}
func TestStageImageForTabRequiresBoundController(t *testing.T) {
app := &App{
tabs: map[string]*WorkspaceTab{
"a": {ID: "a", WorkspaceRoot: t.TempDir()},
},
activeTabID: "a",
}
if _, err := app.StageImageForTab("a", "op-1", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil {
t.Fatal("StageImageForTab accepted a tab without a session controller")
}
}
func TestLegacyAttachmentRPCRejectsMissingTarget(t *testing.T) {
t.Chdir(t.TempDir())
app := &App{tabs: map[string]*WorkspaceTab{}}
if _, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG); err == nil {
t.Fatal("legacy attachment RPC fell back to the process working directory")
}
if _, err := os.Stat(filepath.Join(".reasonix", "attachments")); !os.IsNotExist(err) {
t.Fatalf("missing target created a process-relative attachment directory: %v", err)
}
}
func TestGlobalAttachmentRPCUsesStableGlobalWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
launchRoot := t.TempDir()
t.Chdir(launchRoot)
app := &App{
tabs: map[string]*WorkspaceTab{"global": {ID: "global", Scope: "global"}},
activeTabID: "global",
}
rel, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(globalWorkspaceRoot(), filepath.FromSlash(rel))); err != nil {
t.Fatalf("global attachment missing from stable global workspace: %v", err)
}
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(rel))); !os.IsNotExist(err) {
t.Fatalf("global attachment was written under process cwd: %v", err)
}
}
func TestSavePastedImageForTabKeepsCapturedWorkspaceAcrossActiveSwitch(t *testing.T) {
launchRoot := t.TempDir()
projectA := t.TempDir()
projectB := t.TempDir()
t.Chdir(launchRoot)
app := &App{
tabs: map[string]*WorkspaceTab{
"a": {ID: "a", WorkspaceRoot: projectA},
"b": {ID: "b", WorkspaceRoot: projectB},
},
activeTabID: "a",
}
app.attachmentIOHook = func() {
app.mu.Lock()
app.activeTabID = "b"
app.mu.Unlock()
}
before, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
got, err := app.SavePastedImageForTab("a", "data:image/png;base64,"+desktopTinyPNG)
if err != nil {
t.Fatalf("SavePastedImageForTab: %v", err)
}
after, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if before != after {
t.Fatalf("process cwd changed from %q to %q", before, after)
}
if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
t.Fatalf("captured workspace attachment missing: %v", err)
}
if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
t.Fatalf("attachment drifted to active workspace B: %v", err)
}
}
func TestAttachmentDataURLForTabIsolatesSameRelativePath(t *testing.T) {
projectA := t.TempDir()
projectB := t.TempDir()
rel := ".reasonix/attachments/shared.png"
rawA, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
rawB := append([]byte(nil), rawA...)
rawB[len(rawB)-1] ^= 1
for root, raw := range map[string][]byte{projectA: rawA, projectB: rawB} {
path := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
}
app := &App{tabs: map[string]*WorkspaceTab{
"a": {ID: "a", WorkspaceRoot: projectA},
"b": {ID: "b", WorkspaceRoot: projectB},
}}
one, err := app.AttachmentDataURLForTab("a", rel)
if err != nil {
t.Fatal(err)
}
two, err := app.AttachmentDataURLForTab("b", rel)
if err != nil {
t.Fatal(err)
}
if one == two {
t.Fatal("same relative attachment path resolved to the same workspace bytes")
}
}
func TestSavePastedImageForTabRejectsClosedTargetAndRemovesWrite(t *testing.T) {
projectRoot := t.TempDir()
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: projectRoot}
app := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
var once sync.Once
app.attachmentIOHook = func() {
once.Do(func() {
app.mu.Lock()
delete(app.tabs, "a")
app.mu.Unlock()
})
}
if _, err := app.SavePastedImageForTab("a", "data:image/png;base64,"+desktopTinyPNG); err == nil {
t.Fatal("closed attachment target was accepted")
}
entries, err := os.ReadDir(filepath.Join(projectRoot, ".reasonix", "attachments"))
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("rejected attachment write left files: %v", entries)
}
}
func TestAttachmentDataURLForTabRejectsReplacedRuntime(t *testing.T) {
projectRoot := t.TempDir()
rel, err := control.SaveImageBytesInRoot(projectRoot, "image/png", mustDecodeBase64(t, desktopTinyPNG))
if err != nil {
t.Fatal(err)
}
first := control.New(control.Options{WorkspaceRoot: projectRoot})
second := control.New(control.Options{WorkspaceRoot: projectRoot})
t.Cleanup(first.Close)
t.Cleanup(second.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: projectRoot, Ctrl: first}
app := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
var once sync.Once
app.attachmentIOHook = func() {
once.Do(func() {
app.mu.Lock()
tab.Ctrl = second
app.mu.Unlock()
})
}
if _, err := app.AttachmentDataURLForTab("a", rel); err == nil {
t.Fatal("preview from replaced runtime was accepted")
}
}
func mustDecodeBase64(t *testing.T, value string) []byte {
t.Helper()
raw, err := base64.StdEncoding.DecodeString(value)
if err != nil {
t.Fatal(err)
}
return raw
}
func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
isolateDesktopUserDirs(t)
orig, _ := os.Getwd()
@@ -218,8 +412,12 @@ func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: cwd}},
activeTabID: "project",
}
got, err := (&App{}).AttachDropped(target)
got, err := app.AttachDropped(target)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
@@ -241,8 +439,12 @@ func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: root}},
activeTabID: "project",
}
got, err := (&App{}).AttachDropped(outside)
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
@@ -264,8 +466,12 @@ func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: cwd}},
activeTabID: "project",
}
got, err := (&App{}).AttachDropped(filepath.Join(cwd, "shot.png"))
got, err := app.AttachDropped(filepath.Join(cwd, "shot.png"))
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
+389
View File
@@ -0,0 +1,389 @@
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"sync"
"reasonix/internal/attachment"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/session"
"reasonix/internal/sessioninbox"
)
type ComposerTarget struct {
Kind string `json:"kind"`
DraftID string `json:"draftId,omitempty"`
TabID string `json:"tabId,omitempty"`
Session *session.SessionRef `json:"session,omitempty"`
Generation uint64 `json:"generation,omitempty"`
}
type attachmentTargetState struct {
// Test hook after I/O and before target revalidation; set before use.
attachmentIOHook func()
attachmentJoinHook func()
attachmentTargetsMu sync.Mutex
attachmentTargets map[string]attachmentTarget
attachmentStageOps map[string]*attachmentStageOperation
}
type attachmentStageOperation struct {
fingerprint string
done chan struct{}
view DraftImageView
err error
}
func (a *App) releaseAttachmentStageOperations(ownerPrefix string) {
a.attachmentTargetsMu.Lock()
defer a.attachmentTargetsMu.Unlock()
for key, op := range a.attachmentStageOps {
if strings.HasPrefix(key, ownerPrefix) {
// In-flight operations retain their result until completion so existing
// waiters can observe the owner cancellation. Completed receipts can be
// dropped immediately with their credential family.
select {
case <-op.done:
delete(a.attachmentStageOps, key)
default:
}
}
}
}
type AttachmentTargetView struct {
Token string `json:"token"`
Capabilities []string `json:"capabilities"`
}
func (a *App) attachmentContext() context.Context {
if a.ctx != nil {
return a.ctx
}
return context.Background()
}
func (a *App) attachmentOperationContext(target attachmentTarget) context.Context {
if target.ctx != nil {
return target.ctx
}
return a.attachmentContext()
}
// CaptureAttachmentTarget must run before browser file reads or hashing.
// The opaque token carries no client-selected filesystem authority.
func (a *App) CaptureAttachmentTarget(composer ComposerTarget) (AttachmentTargetView, error) {
empty := AttachmentTargetView{Capabilities: []string{}}
target, err := a.attachmentTargetForComposerTarget(composer)
if err != nil {
return empty, err
}
if target.draftID == "" {
if _, ok := target.ctrl.(*control.Controller); !ok {
return empty, fmt.Errorf("unsupported: attachments-v2")
}
}
var id [24]byte
if _, err := rand.Read(id[:]); err != nil {
return empty, err
}
token := hex.EncodeToString(id[:])
a.attachmentTargetsMu.Lock()
defer a.attachmentTargetsMu.Unlock()
if a.attachmentTargets == nil {
a.attachmentTargets = make(map[string]attachmentTarget)
}
for key, previous := range a.attachmentTargets {
if !a.attachmentTargetCurrent(previous) {
if previous.cancel != nil {
previous.cancel()
}
delete(a.attachmentTargets, key)
}
}
if len(a.attachmentTargets) >= 256 {
return empty, fmt.Errorf("too many outstanding attachment operations")
}
if controller, ok := target.ctrl.(*control.Controller); ok {
target.ctx, target.cancel = controller.NewAttachmentOperationContext(a.attachmentContext())
} else {
target.ctx, target.cancel = context.WithCancel(a.attachmentContext())
}
a.attachmentTargets[token] = target
return AttachmentTargetView{Token: token, Capabilities: []string{"attachments-v2"}}, nil
}
func (a *App) attachmentTargetToken(token string) (attachmentTarget, error) {
a.attachmentTargetsMu.Lock()
target, ok := a.attachmentTargets[token]
a.attachmentTargetsMu.Unlock()
if !ok || !a.attachmentTargetCurrent(target) {
return attachmentTarget{}, fmt.Errorf("attachment target changed; please retry")
}
return target, nil
}
func (a *App) ReleaseAttachmentTarget(token string) {
a.attachmentTargetsMu.Lock()
if target, ok := a.attachmentTargets[token]; ok && target.cancel != nil {
target.cancel()
}
delete(a.attachmentTargets, token)
a.attachmentTargetsMu.Unlock()
}
func (a *App) StageImageForTarget(token, operationID, displayName, mime, dataURL string) (DraftImageView, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return DraftImageView{}, err
}
operationID = strings.TrimSpace(operationID)
if operationID == "" {
return DraftImageView{}, fmt.Errorf("attachment operationId is required")
}
fingerprint, err := stageImageFingerprint(displayName, mime, dataURL)
if err != nil {
return DraftImageView{}, err
}
key := target.ownerIdentity + "\x00" + operationID
a.attachmentTargetsMu.Lock()
if a.attachmentStageOps == nil {
a.attachmentStageOps = make(map[string]*attachmentStageOperation)
}
if existing := a.attachmentStageOps[key]; existing != nil {
if existing.fingerprint != fingerprint {
a.attachmentTargetsMu.Unlock()
return DraftImageView{}, fmt.Errorf("attachment operation conflicts with different input")
}
done := existing.done
a.attachmentTargetsMu.Unlock()
if a.attachmentJoinHook != nil {
a.attachmentJoinHook()
}
select {
case <-done:
return existing.view, existing.err
case <-a.attachmentOperationContext(target).Done():
return DraftImageView{}, a.attachmentOperationContext(target).Err()
}
}
count := 0
for existingKey := range a.attachmentStageOps {
if strings.HasPrefix(existingKey, target.ownerIdentity+"\x00") {
count++
}
}
if count >= 256 {
a.attachmentTargetsMu.Unlock()
return DraftImageView{}, fmt.Errorf("too many outstanding attachment operations")
}
op := &attachmentStageOperation{fingerprint: fingerprint, done: make(chan struct{})}
a.attachmentStageOps[key] = op
a.attachmentTargetsMu.Unlock()
if target.draftID != "" {
rel, stageErr := control.SaveImageDataURLInRoot(target.root, dataURL)
if stageErr == nil {
op.view = DraftImageView{Path: rel, DisplayName: attachment.NormalizeDisplayName(displayName), MIME: strings.ToLower(strings.TrimSpace(mime))}
}
op.err = stageErr
} else {
op.view, op.err = a.stageImageForTarget(target, displayName, mime, dataURL)
}
invalidReason := a.attachmentTargetInvalidReason(target)
ownerCurrent := invalidReason == ""
if op.err == nil && !ownerCurrent {
op.view = DraftImageView{}
op.err = fmt.Errorf("attachment target changed (%s); please retry", invalidReason)
}
a.attachmentTargetsMu.Lock()
close(op.done)
if !ownerCurrent {
delete(a.attachmentStageOps, key)
}
a.attachmentTargetsMu.Unlock()
return op.view, op.err
}
func stageImageFingerprint(displayName, mime, dataURL string) (string, error) {
const marker = ";base64,"
before, encoded, ok := strings.Cut(dataURL, marker)
if !ok || !strings.HasPrefix(before, "data:") {
return "", fmt.Errorf("unsupported pasted image")
}
raw, err := decodeBase64(encoded)
if err != nil {
return "", err
}
digest := sha256.New()
_, _ = digest.Write(raw)
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(strings.ToLower(strings.TrimSpace(mime))))
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(attachment.NormalizeDisplayName(displayName)))
return hex.EncodeToString(digest.Sum(nil)), nil
}
func (a *App) ReadDraftImageForTarget(token, draftID string) (string, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return "", err
}
if target.draftID != "" {
return control.ImageDataURLInRoot(target.root, draftID)
}
return a.readDraftImageForTarget(target, draftID)
}
func (a *App) SavePastedFileForTarget(token, name, dataURL string) (string, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return "", err
}
rel, err := control.SaveAttachmentDataURLInRoot(target.root, name, dataURL)
return a.finishAttachmentWrite(target, rel, err)
}
func (a *App) SaveClipboardImageForTarget(token string) (string, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return "", err
}
rel, err := control.SaveClipboardImageInRoot(target.root)
return a.finishAttachmentWrite(target, rel, err)
}
func (a *App) AttachmentDataURLForTarget(token, path string) (string, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return "", err
}
return a.attachmentDataURLForTarget(target, path)
}
func (a *App) AttachDroppedForTarget(token, path string) (DroppedItem, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return DroppedItem{}, err
}
return a.attachDroppedForTarget(target, path)
}
func (a *App) RebindDraftImageForTarget(token, draftID string) (DraftImageView, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return DraftImageView{}, err
}
c, ok := target.ctrl.(*control.Controller)
if !ok {
return DraftImageView{}, fmt.Errorf("unsupported: draft attachment rebind")
}
draft, err := c.RebindDraftImage(a.attachmentOperationContext(target), draftID)
if err != nil {
return DraftImageView{}, err
}
if !a.attachmentTargetCurrent(target) {
return DraftImageView{}, fmt.Errorf("attachment target changed; please retry")
}
return draftImageView(draft), nil
}
func (a *App) ReleaseDraftImageForTarget(token, draftID string) error {
target, err := a.attachmentTargetToken(token)
if err != nil {
return err
}
if c, ok := target.ctrl.(*control.Controller); ok {
c.ReleaseDraftImage(draftID)
}
a.attachmentTargetsMu.Lock()
for key, op := range a.attachmentStageOps {
if strings.HasPrefix(key, target.ownerIdentity+"\x00") && op.view.DraftID == draftID {
delete(a.attachmentStageOps, key)
}
}
a.attachmentTargetsMu.Unlock()
return nil
}
// StartTurnForAttachmentTarget shares controller admission for all structured
// sources. beginTabTurn holds the runtime replacement barrier during admission.
func (a *App) StartTurnForAttachmentTarget(token, submissionID string, req control.SubmissionRequest) (TurnStartView, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return TurnStartView{}, err
}
if submissionID == "" {
return TurnStartView{}, fmt.Errorf("submissionId is required")
}
req.ID = submissionID
c, ok := target.ctrl.(*control.Controller)
if !ok {
return TurnStartView{}, fmt.Errorf("attachment target is not a session")
}
if receipt, found, err := c.LookupSubmissionContext(a.attachmentOperationContext(target), req); found || err != nil {
return TurnStartView{TurnID: receipt.TurnID, SubmissionID: submissionID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted}, err
}
prepared, err := c.PrepareSubmission(a.attachmentOperationContext(target), req)
if err != nil {
return TurnStartView{}, err
}
admission, ctrl, err := a.beginTabTurn(target.tabID, true, submissionID)
if err != nil {
return TurnStartView{}, err
}
defer admission.abort()
if ctrl != target.ctrl || !a.attachmentTargetCurrent(target) {
return TurnStartView{}, fmt.Errorf("attachment target changed; please retry")
}
setup := func() error {
if req.Goal != "" {
if err := syncTabGoalToController(ctrl, req.Goal); err != nil {
return err
}
a.mu.Lock()
target.tab.goal = req.Goal
target.tab.toolApprovalMode = normalizeToolApprovalMode(req.ToolApprovalMode)
target.tab.mode = tabModeFromAxes(false, target.tab.toolApprovalMode == control.ToolApprovalYolo)
a.saveTabsLocked()
a.mu.Unlock()
ctrl.SetPlanMode(false)
applyTabToolApprovalModeToController(ctrl, normalizeToolApprovalMode(req.ToolApprovalMode))
}
return a.ensureTabTopicIndexedForUserTurn(target.tab)
}
receipt, err := c.SubmitPreparedWithSetup(a.attachmentOperationContext(target), prepared, setup)
if err != nil {
return TurnStartView{}, err
}
admission.finish(ctrl)
return TurnStartView{TurnID: receipt.TurnID, SubmissionID: submissionID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted}, nil
}
func (a *App) EnqueueForAttachmentTarget(token, submissionID, input, display string, invocations []control.InvocationRequest, attachments []control.SubmissionAttachment) (InboxReceiptView, error) {
target, err := a.attachmentTargetToken(token)
if err != nil {
return InboxReceiptView{}, err
}
a.runtimeAdmissionMu.RLock()
defer a.runtimeAdmissionMu.RUnlock()
if !a.attachmentTargetCurrent(target) {
return InboxReceiptView{}, fmt.Errorf("attachment target changed; please retry")
}
c, ok := target.ctrl.(*control.Controller)
if !ok {
return InboxReceiptView{}, fmt.Errorf("attachment target is not a session")
}
receipt, err := c.TryEnqueueFollowupContext(a.attachmentOperationContext(target), control.InboxRequest{Intent: sessioninbox.IntentFollowup, Display: display, Raw: input, Submit: input, Source: "desktop", Idempotency: submissionID, Invocations: invocations, Attachments: attachments})
if err != nil {
return InboxReceiptView{}, err
}
a.emitInboxChanged(target.tabID)
return InboxReceiptView{ItemID: receipt.ItemID, Paused: receipt.Paused}, nil
}
+238
View File
@@ -0,0 +1,238 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/control"
)
func TestAttachmentTargetRejectsReplacementBeforeFileUpload(t *testing.T) {
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root})
t.Cleanup(c.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
a.mu.Lock()
tab.SessionGeneration++
a.mu.Unlock()
if _, err := a.StageImageForTarget(target.Token, "paste", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil {
t.Fatal("stale target accepted")
}
}
func TestAttachmentTargetKeepsSourceAcrossFocusSwitch(t *testing.T) {
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root})
t.Cleanup(c.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab, "b": {ID: "b", WorkspaceRoot: t.TempDir()}}, activeTabID: "a"}
before, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
a.attachmentIOHook = func() { a.mu.Lock(); a.activeTabID = "b"; a.mu.Unlock() }
draft, err := a.StageImageForTarget(target.Token, "paste", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
if _, err := a.ReadDraftImageForTarget(target.Token, draft.DraftID); err != nil {
t.Fatal(err)
}
if after, err := os.Getwd(); err != nil || before != after {
t.Fatalf("cwd changed: %q %q %v", before, after, err)
}
}
func TestAttachmentDraftSurvivesSameSessionRuntimeReplacement(t *testing.T) {
root := t.TempDir()
opts := control.Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl")}
old := control.New(opts)
t.Cleanup(old.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: old, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
draft, err := a.StageImageForTarget(target.Token, "paste", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
next := control.New(opts)
t.Cleanup(next.Close)
if err := activateReplacementController(old, next); err != nil {
t.Fatal(err)
}
a.mu.Lock()
tab.Ctrl = next
a.mu.Unlock()
a.ReleaseAttachmentTarget(target.Token)
fresh, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(fresh.Token) })
retried, err := a.StageImageForTarget(fresh.Token, "paste", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil || retried.DraftID != draft.DraftID {
t.Fatalf("stage receipt did not survive replacement: %+v %v", retried, err)
}
rebound, err := a.RebindDraftImageForTarget(fresh.Token, draft.DraftID)
if err != nil {
t.Fatal(err)
}
if rebound.DraftID == draft.DraftID {
t.Fatal("runtime replacement did not renew draft credential")
}
again, err := a.RebindDraftImageForTarget(fresh.Token, draft.DraftID)
if err != nil || again.DraftID != rebound.DraftID {
t.Fatalf("rebind retry changed credential: %+v %v", again, err)
}
if next.RuntimeStatus().Running {
t.Fatal("rebind automatically started a turn")
}
}
func TestStageImageForTargetIsIdempotentAndRejectsConflicts(t *testing.T) {
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl")})
t.Cleanup(c.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
first, err := a.StageImageForTarget(target.Token, "same-operation", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
retry, err := a.StageImageForTarget(target.Token, "same-operation", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil || retry != first {
t.Fatalf("retry = %+v, %v; want %+v", retry, err, first)
}
if _, err := a.StageImageForTarget(target.Token, "same-operation", "different.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil || !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("different input conflict = %v", err)
}
if err := a.ReleaseDraftImageForTarget(target.Token, first.DraftID); err != nil {
t.Fatal(err)
}
afterRelease, err := a.StageImageForTarget(target.Token, "same-operation", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
if afterRelease.DraftID == first.DraftID {
t.Fatal("released credential retained its staging receipt")
}
}
func TestStageImageForTargetConcurrentRetrySharesOneOperation(t *testing.T) {
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl")})
t.Cleanup(c.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
ioEntered := make(chan struct{})
joinEntered := make(chan struct{})
releaseIO := make(chan struct{})
var ioOnce, joinOnce sync.Once
a.attachmentIOHook = func() {
ioOnce.Do(func() { close(ioEntered) })
<-releaseIO
}
a.attachmentJoinHook = func() { joinOnce.Do(func() { close(joinEntered) }) }
type result struct {
view DraftImageView
err error
}
firstDone := make(chan result, 1)
secondDone := make(chan result, 1)
go func() {
view, err := a.StageImageForTarget(target.Token, "concurrent", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
firstDone <- result{view: view, err: err}
}()
select {
case <-ioEntered:
case <-time.After(5 * time.Second):
t.Fatal("first stage did not reach the I/O barrier")
}
go func() {
view, err := a.StageImageForTarget(target.Token, "concurrent", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG)
secondDone <- result{view: view, err: err}
}()
select {
case <-joinEntered:
case <-time.After(5 * time.Second):
t.Fatal("retry did not join the in-flight operation")
}
close(releaseIO)
first, second := <-firstDone, <-secondDone
if first.err != nil || second.err != nil || first.view != second.view {
t.Fatalf("concurrent results = %+v / %+v", first, second)
}
}
func TestStageImageForTargetBoundsOwnerOperations(t *testing.T) {
root := t.TempDir()
c := control.New(control.Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl")})
t.Cleanup(c.Close)
tab := &WorkspaceTab{ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}
a := &App{tabs: map[string]*WorkspaceTab{"a": tab}, activeTabID: "a"}
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
for i := range 256 {
if _, err := a.StageImageForTarget(target.Token, fmt.Sprintf("operation-%d", i), "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err != nil {
t.Fatalf("operation %d: %v", i, err)
}
}
if _, err := a.StageImageForTarget(target.Token, "operation-overflow", "shot.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil || !strings.Contains(err.Error(), "too many") {
t.Fatalf("overflow = %v", err)
}
}
func TestAttachmentTargetCancellationIsIndependent(t *testing.T) {
c := control.New(control.Options{WorkspaceRoot: t.TempDir()})
t.Cleanup(c.Close)
a := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: t.TempDir(), Ctrl: c}}, activeTabID: "a"}
one, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
two, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { a.ReleaseAttachmentTarget(two.Token) })
a.ReleaseAttachmentTarget(one.Token)
if _, err := a.StageImageForTarget(one.Token, "one", "a.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err == nil {
t.Fatal("canceled target accepted")
}
if _, err := a.StageImageForTarget(two.Token, "two", "a.png", "image/png", "data:image/png;base64,"+desktopTinyPNG); err != nil {
t.Fatal(err)
}
}
+499
View File
@@ -0,0 +1,499 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/attachment"
"reasonix/internal/control"
)
type attachmentTarget struct {
tab *WorkspaceTab
tabID string
draftID string
draftGeneration uint64
root string
generation uint64
ctrl control.SessionAPI
ctx context.Context
cancel context.CancelFunc
ownerIdentity string
ownerBound bool
runtimeEpoch string
}
func (a *App) attachmentTargetForComposerTarget(composer ComposerTarget) (attachmentTarget, error) {
if composer.Kind != "draft" {
return a.attachmentTargetForTab(composer.TabID)
}
record, err := a.draftStore().Get(a.bootContext(), strings.TrimSpace(composer.DraftID))
if err != nil {
return attachmentTarget{}, err
}
root := record.WorkspaceRoot
if record.Scope != "project" {
root = globalWorkspaceRoot()
}
absRoot, err := filepath.Abs(root)
if err != nil {
return attachmentTarget{}, err
}
return attachmentTarget{
draftID: record.ID, draftGeneration: composer.Generation, root: absRoot,
ownerIdentity: fmt.Sprintf("draft:%s:%d", record.ID, composer.Generation),
}, nil
}
func attachmentWorkspaceRoot(tab *WorkspaceTab) (string, error) {
root := strings.TrimSpace(tab.WorkspaceRoot)
if root == "" {
if tab.Scope == "project" {
return "", fmt.Errorf("attachment workspace is not ready")
}
root = globalWorkspaceRoot()
}
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
return absRoot, nil
}
func (a *App) attachmentTargetForTab(tabID string) (attachmentTarget, error) {
tab, _ := a.tabAndCtrlByID(tabID)
if tab == nil {
return attachmentTarget{}, fmt.Errorf("attachment workspace is no longer available")
}
a.reconcileTabWithPinnedSessionMeta(tab)
a.mu.RLock()
defer a.mu.RUnlock()
if a.tabs[tab.ID] != tab || tab.removed {
return attachmentTarget{}, fmt.Errorf("attachment workspace is no longer available")
}
root, err := attachmentWorkspaceRoot(tab)
if err != nil {
return attachmentTarget{}, err
}
target := attachmentTarget{
tab: tab, tabID: tab.ID, root: root, generation: tab.SessionGeneration, ctrl: tab.Ctrl,
runtimeEpoch: a.runtimeEpochForTabLocked(tab),
}
if c, ok := tab.Ctrl.(*control.Controller); ok {
target.ownerIdentity = c.AttachmentOwnerIdentity()
target.ownerBound = strings.Trim(target.ownerIdentity, "\x00") != ""
}
if !target.ownerBound {
target.ownerIdentity = fmt.Sprintf("tab:%s:%d:%s", tab.ID, tab.SessionGeneration, target.runtimeEpoch)
}
return target, nil
}
func (a *App) attachmentTargetCurrent(target attachmentTarget) bool {
return a.attachmentTargetInvalidReason(target) == ""
}
func (a *App) attachmentTargetInvalidReason(target attachmentTarget) string {
if target.tab == nil {
if target.draftID == "" {
return "missing owner"
}
record, err := a.draftStore().Get(a.bootContext(), target.draftID)
if err != nil || record.Status != "active" {
return "draft is no longer active"
}
root := record.WorkspaceRoot
if record.Scope != "project" {
root = globalWorkspaceRoot()
}
absRoot, err := filepath.Abs(root)
if err != nil || !sameProjectRoot(absRoot, target.root) {
return "workspace changed"
}
return ""
}
a.mu.RLock()
defer a.mu.RUnlock()
tab := a.tabs[target.tabID]
if tab != target.tab || tab.removed {
return "tab closed"
}
if tab.SessionGeneration != target.generation {
return "session generation changed"
}
if tab.Ctrl != target.ctrl {
return "runtime changed"
}
if a.runtimeEpochForTabLocked(tab) != target.runtimeEpoch {
return "runtime epoch changed"
}
if c, ok := tab.Ctrl.(*control.Controller); ok && target.ownerBound && c.AttachmentOwnerIdentity() != target.ownerIdentity {
return "attachment owner changed"
}
root, err := attachmentWorkspaceRoot(tab)
if err != nil || !sameProjectRoot(root, target.root) {
return "workspace changed"
}
return ""
}
func (a *App) finishAttachmentWrite(target attachmentTarget, rel string, err error) (string, error) {
if err != nil {
return "", err
}
if a.attachmentIOHook != nil {
a.attachmentIOHook()
}
if a.attachmentTargetCurrent(target) {
return rel, nil
}
if isAttachmentRefPath(rel) {
_ = os.Remove(filepath.Join(target.root, filepath.FromSlash(rel)))
}
return "", fmt.Errorf("attachment workspace changed while saving; please retry")
}
func isAttachmentRefPath(path string) bool {
path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
return strings.HasPrefix(path, ".reasonix/attachments/")
}
// SavePastedImage stores a browser clipboard image data URL under the active
// tab's workspace .reasonix/attachments and returns the relative @-reference path.
func (a *App) SavePastedImage(dataURL string) (string, error) {
target, err := a.attachmentTargetForTab("")
if err != nil {
return "", err
}
rel, saveErr := control.SaveImageDataURLInRoot(target.root, dataURL)
return a.finishAttachmentWrite(target, rel, saveErr)
}
func (a *App) SavePastedImageForTab(tabID, dataURL string) (string, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return "", err
}
rel, saveErr := control.SaveImageDataURLInRoot(target.root, dataURL)
return a.finishAttachmentWrite(target, rel, saveErr)
}
// SaveClipboardImage reads the native OS clipboard image under the active tab's
// workspace .reasonix/attachments and returns the relative @-reference path.
func (a *App) SaveClipboardImage() (string, error) {
target, err := a.attachmentTargetForTab("")
if err != nil {
return "", err
}
rel, saveErr := control.SaveClipboardImageInRoot(target.root)
return a.finishAttachmentWrite(target, rel, saveErr)
}
func (a *App) SaveClipboardImageForTab(tabID string) (string, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return "", err
}
rel, saveErr := control.SaveClipboardImageInRoot(target.root)
return a.finishAttachmentWrite(target, rel, saveErr)
}
// SavePastedFile stores a dropped non-image file (the browser exposes its bytes
// as a data URL but not a real path) under the active tab's workspace
// .reasonix/attachments and returns the relative @-reference path.
func (a *App) SavePastedFile(name, dataURL string) (string, error) {
target, err := a.attachmentTargetForTab("")
if err != nil {
return "", err
}
rel, saveErr := control.SaveAttachmentDataURLInRoot(target.root, name, dataURL)
return a.finishAttachmentWrite(target, rel, saveErr)
}
func (a *App) SavePastedFileForTab(tabID, name, dataURL string) (string, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return "", err
}
rel, saveErr := control.SaveAttachmentDataURLInRoot(target.root, name, dataURL)
return a.finishAttachmentWrite(target, rel, saveErr)
}
// AttachmentDataURL returns a safe data URL for a stored image attachment.
func (a *App) AttachmentDataURL(path string) (string, error) {
target, err := a.attachmentTargetForTab("")
if err != nil {
return "", err
}
return a.attachmentDataURLForTarget(target, path)
}
func (a *App) AttachmentDataURLForTab(tabID, path string) (string, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return "", err
}
return a.attachmentDataURLForTarget(target, path)
}
func (a *App) attachmentDataURLForTarget(target attachmentTarget, path string) (string, error) {
dataURL, readErr := control.ImageDataURLInRoot(target.root, path)
if readErr != nil {
return "", readErr
}
if a.attachmentIOHook != nil {
a.attachmentIOHook()
}
if !a.attachmentTargetCurrent(target) {
return "", fmt.Errorf("attachment workspace changed while reading; please retry")
}
return dataURL, nil
}
// DroppedItem is one OS-dropped file resolved into a composer context entry.
type DroppedItem struct {
Kind string `json:"kind"`
Path string `json:"path"`
IsDir bool `json:"isDir,omitempty"`
DisplayPath string `json:"displayPath,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
}
const attachmentsCapabilityV1 = "attachments-v1"
type DraftImageView struct {
DraftID string `json:"draftId"`
Path string `json:"path,omitempty"`
DisplayName string `json:"displayName"`
MIME string `json:"mime"`
Width int `json:"width"`
Height int `json:"height"`
Bytes int64 `json:"bytes"`
}
func (a *App) StageImageForTab(tabID, operationID, displayName, mime, dataURL string) (DraftImageView, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return DraftImageView{}, err
}
return a.stageImageForTarget(target, displayName, mime, dataURL)
}
func (a *App) stageImageForTarget(target attachmentTarget, displayName, mime, dataURL string) (DraftImageView, error) {
identified, ok := target.ctrl.(*control.Controller)
if !ok {
return DraftImageView{}, fmt.Errorf("unsupported: %s", attachmentsCapabilityV1)
}
draft, err := identified.StageImage(a.attachmentOperationContext(target), displayName, mime, dataURL)
if a.attachmentIOHook != nil {
a.attachmentIOHook()
}
if !a.attachmentTargetCurrent(target) {
identified.ReleaseDraftImage(draft.ID)
return DraftImageView{}, fmt.Errorf("attachment workspace changed while saving; please retry")
}
if err != nil {
return DraftImageView{}, err
}
return draftImageView(draft), nil
}
func (a *App) ReadDraftImageForTab(tabID, draftID string) (string, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return "", err
}
return a.readDraftImageForTarget(target, draftID)
}
func (a *App) readDraftImageForTarget(target attachmentTarget, draftID string) (string, error) {
identified, ok := target.ctrl.(*control.Controller)
if !ok {
return "", fmt.Errorf("unsupported: %s", attachmentsCapabilityV1)
}
draft, raw, err := identified.ReadDraftImage(a.attachmentOperationContext(target), draftID)
if a.attachmentIOHook != nil {
a.attachmentIOHook()
}
if !a.attachmentTargetCurrent(target) {
return "", fmt.Errorf("attachment workspace changed while reading; please retry")
}
if err != nil {
return "", err
}
return attachment.DataURL(draft.MIME, raw), nil
}
func (a *App) ReadSessionAttachmentForTab(tabID, digest string, offset int64) (SessionHistoryContentChunk, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return SessionHistoryContentChunk{}, err
}
ctx := a.attachmentContext()
var cancel context.CancelFunc
if controller, ok := target.ctrl.(*control.Controller); ok {
ctx, cancel = controller.NewAttachmentOperationContext(ctx)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()
target.ctx = ctx
if err := ctx.Err(); err != nil {
return SessionHistoryContentChunk{}, err
}
query, sessionRef, err := a.canonicalSessionQuery(tabID)
if err != nil {
return SessionHistoryContentChunk{}, err
}
if !a.attachmentTargetCurrent(target) {
return SessionHistoryContentChunk{}, fmt.Errorf("attachment workspace changed while reading; please retry")
}
data, total, err := query.ReadSessionAttachment(ctx, sessionRef, digest, offset, sessionHistoryContentChunkBytes)
if a.attachmentIOHook != nil {
a.attachmentIOHook()
}
if !a.attachmentTargetCurrent(target) {
return SessionHistoryContentChunk{}, fmt.Errorf("attachment workspace changed while reading; please retry")
}
if err != nil {
return SessionHistoryContentChunk{}, err
}
next := offset + int64(len(data))
return SessionHistoryContentChunk{
Data: base64.StdEncoding.EncodeToString(data),
NextOffset: next,
Done: next >= total,
}, nil
}
func (a *App) ReleaseDraftImageForTab(tabID, draftID string) error {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return err
}
identified, ok := target.ctrl.(*control.Controller)
if !ok {
return fmt.Errorf("unsupported: %s", attachmentsCapabilityV1)
}
identified.ReleaseDraftImage(draftID)
return nil
}
func draftImageView(draft attachment.DraftCredential) DraftImageView {
return DraftImageView{
DraftID: draft.ID,
DisplayName: draft.DisplayName,
MIME: draft.MIME,
Width: draft.Width,
Height: draft.Height,
Bytes: draft.Bytes,
}
}
func (a *App) AttachDropped(path string) (DroppedItem, error) {
return a.attachDroppedForTab("", path)
}
func (a *App) AttachDroppedForTab(tabID, path string) (DroppedItem, error) {
return a.attachDroppedForTab(tabID, path)
}
func (a *App) attachDroppedForTab(tabID, path string) (DroppedItem, error) {
target, err := a.attachmentTargetForTab(tabID)
if err != nil {
return DroppedItem{}, err
}
return a.attachDroppedForTarget(target, path)
}
func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (DroppedItem, error) {
info, err := os.Lstat(path)
if err != nil {
return DroppedItem{}, err
}
if isImageExt(path) {
if rel, saveErr := control.SaveImageFileInRoot(target.root, path); saveErr == nil {
rel, saveErr = a.finishAttachmentWrite(target, rel, nil)
if saveErr != nil {
return DroppedItem{}, saveErr
}
preview, previewErr := a.attachmentDataURLForTarget(target, rel)
if previewErr != nil {
_ = os.Remove(filepath.Join(target.root, filepath.FromSlash(rel)))
return DroppedItem{}, previewErr
}
return DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}, nil
} else {
return DroppedItem{}, saveErr
}
}
if rel, ok := workspaceRelativeIn(path, target.root); ok {
if !a.attachmentTargetCurrent(target) {
return DroppedItem{}, fmt.Errorf("attachment workspace changed while reading; please retry")
}
return DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}, nil
}
if info.IsDir() {
if target.tab == nil || target.ctrl == nil {
return DroppedItem{}, fmt.Errorf("workspace is not ready")
}
if err := a.ensureTabControllerWorkspace(target.tab); err != nil {
return DroppedItem{}, err
}
refreshed, err := a.attachmentTargetForTab(target.tabID)
if err != nil || refreshed.tab != target.tab || refreshed.generation != target.generation ||
!sameProjectRoot(refreshed.root, target.root) {
return DroppedItem{}, fmt.Errorf("attachment workspace is no longer available")
}
target = refreshed
ctrl := target.ctrl
if ctrl == nil || !a.attachmentTargetCurrent(target) {
return DroppedItem{}, fmt.Errorf("attachment workspace is no longer available")
}
token, displayPath, err := ctrl.RegisterExternalFolderRef(path)
if err != nil {
return DroppedItem{}, err
}
return DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath}, nil
}
rel, err := control.SaveAttachmentFileInRoot(target.root, path)
if err != nil {
return DroppedItem{}, err
}
rel, err = a.finishAttachmentWrite(target, rel, nil)
if err != nil {
return DroppedItem{}, err
}
return DroppedItem{Kind: "attachment", Path: rel}, nil
}
func isImageExt(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp":
return true
}
return false
}
func workspaceRelativeIn(path, workspaceRoot string) (string, bool) {
root := workspaceRoot
if !filepath.IsAbs(root) {
abs, err := filepath.Abs(root)
if err != nil {
return "", false
}
root = abs
}
rel, err := filepath.Rel(root, path)
if err != nil {
return "", false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", false
}
return filepath.ToSlash(rel), true
}
@@ -65,6 +65,8 @@ eq(
"@.reasonix/attachments/clipboard-20260610-121238.444775-000001.png",
"formats raw attachment refs for edit replay submit",
);
const parsedDraft = parseAttachmentRefsForDisplay("see @[photo.png](draft:0123456789abcdef0123456789abcdef)");
eq(parsedDraft.attachments[0]?.path, "draft:0123456789abcdef0123456789abcdef", "draft credentials parse as display attachments");
eq(baseName("C:\\Users\\Abyss\\Desktop\\DS30000.sl2"), "DS30000.sl2", "extracts Windows path basenames");
eq(baseName("/Users/abyss/Desktop/park.png"), "park.png", "extracts POSIX path basenames");
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import { imageSubmissionIdentity, settleImageSubmission, captureImageTarget } from "../lib/attachmentSubmit";
import { app, type AppBindings } from "../lib/bridge";
const first = imageSubmissionIdentity("session-a", "draft-version-1");
assert.equal(imageSubmissionIdentity("session-a", "draft-version-1"), first);
assert.notEqual(imageSubmissionIdentity("session-b", "draft-version-1"), first);
const edited = imageSubmissionIdentity("session-a", "draft-version-2");
assert.notEqual(edited, first);
settleImageSubmission("session-a", first);
assert.equal(imageSubmissionIdentity("session-a", "draft-version-2"), edited);
settleImageSubmission("session-a", edited);
assert.notEqual(imageSubmissionIdentity("session-a", "draft-version-2"), edited);
await assert.rejects(captureImageTarget({} as AppBindings, { kind: "session", tabId: "a" }), /unsupported/);
const captured = await app.CaptureAttachmentTarget!({ kind: "session", tabId: "mock-tab" });
assert.deepEqual(captured.capabilities, ["attachments-v2"]);
const staged = await app.StageImageForTarget!(captured.token, "mock-operation", "photo.png", "image/png", "data:image/png;base64,aW1hZ2U=");
assert.equal(await app.ReadDraftImageForTarget!(captured.token, staged.draftId), "data:image/png;base64,aW1hZ2U=");
console.log("attachment submission identity: retry, edit, session isolation, capability rejection, and lazy mock loading passed");
@@ -1,5 +1,3 @@
// Run: tsx src/__tests__/composer-goal-toggle.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
@@ -160,6 +158,7 @@ function mockApp(methods: Partial<AppBindings>, stubOptions?: DesktopHostStubOpt
Models: async () => [],
ModelsForTab: async () => [],
SlashArgs: async () => ({ items: [], from: 0 }),
CaptureAttachmentTarget: async () => ({ token: "goal-attachment-target", capabilities: ["attachments-v2"] }), ReleaseAttachmentTarget: async () => {},
...methods,
} as Partial<AppBindings> as AppBindings,
},
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import { installDesktopHostStub } from "./desktopHostStub";
const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost/", pretendToBeVisual: true });
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window),
cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window),
ResizeObserver: class { observe() {} disconnect() {} unobserve() {} },
});
for (const name of ["Node", "HTMLElement", "HTMLTextAreaElement", "Event", "CustomEvent", "File", "FileReader", "MutationObserver"]) {
Object.defineProperty(globalThis, name, { configurable: true, value: Reflect.get(dom.window, name) });
}
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value() {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value() {} });
window.matchMedia = (() => ({ matches: true, addEventListener() {}, removeEventListener() {} })) as typeof window.matchMedia;
installDesktopHostStub({
Commands: async () => [],
Models: async () => [],
ModelsForTab: async () => [],
ListDir: async () => [],
ListDirForTab: async () => [],
SearchFileRefs: async () => [],
SearchFileRefsForTab: async () => [],
StageImageForTab: async () => ({ draftId: "0123456789abcdef0123456789abcdef", displayName: "draft.png", mime: "image/png", width: 1, height: 1, bytes: 12 }),
CaptureAttachmentTarget: async () => ({token:"target-a",capabilities:["attachments-v2"]}),
ReleaseAttachmentTarget: async () => {},
StartTurnForAttachmentTarget: async () => ({turnId:"turn",status:"queued",submissionId:"submission"}),
StageImageForTarget: async () => ({ draftId: "0123456789abcdef0123456789abcdef", displayName: "draft.png", mime: "image/png", width: 1, height: 1, bytes: 12 }),
ReadDraftImageForTarget: async () => "data:image/png;base64,iVBORw0KGgo=",
ReadDraftImageForTab: async () => "data:image/png;base64,iVBORw0KGgo=",
SavePastedImageForTab: async () => ".reasonix/attachments/draft.png",
AttachmentDataURLForTab: async () => "data:image/png;base64,iVBORw0KGgo=",
});
const root = createRoot(document.getElementById("root")!);
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
try {
await act(async () => {
root.render(<LocaleProvider><ToastProvider><Composer
running={false} collaborationMode="normal" toolApprovalMode="ask" goal="" cwd="/repo"
modelLabel="DeepSeek-R1" tabId="tab-a" sessionKey="session-a" ready
insertRequest={{ id: 1, text: "keep this draft", mode: "replace" }}
onSend={async () => { throw new Error("reasonix_error:image_attachment_unreadable"); }}
onCancel={async () => ({ discardedItemIds: [] })} onCycleMode={() => {}} onSetMode={() => {}}
onSetCollaborationMode={() => {}} onSetToolApprovalMode={() => {}} onClearGoal={() => {}}
onSwitchModel={() => {}} onSetEffort={() => {}}
/></ToastProvider></LocaleProvider>);
await flush();
});
const textarea = document.querySelector<HTMLTextAreaElement>("textarea")!;
const file = new File(["draft image"], "draft.png", { type: "image/png", lastModified: 1 });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", {
configurable: true,
value: { files: [file], items: [], types: [], getData: () => "" },
});
await act(async () => { textarea.dispatchEvent(paste); await flush(); });
for (let attempt = 0; attempt < 10 && !document.querySelector(".composer-context__item"); attempt += 1) {
await act(async () => flush());
}
assert.equal(document.querySelectorAll(".composer-context__item").length, 1);
await act(async () => { document.querySelector<HTMLButtonElement>(".composer__btn--send")!.click(); await flush(); });
assert.equal(textarea.value, "keep this draft");
assert.equal(document.querySelectorAll(".composer-context__item").length, 1);
assert.match(document.body.textContent ?? "", /The image could not be read\. Re-add it or try again/);
console.log("composer image admission: rejected image keeps text and attachment for retry");
} finally {
await act(async () => root.unmount());
dom.window.close();
}
@@ -92,10 +92,27 @@ function installDom() {
}
function installBridgeApp(methods: Record<string, unknown>) {
installDesktopHostStub({
const legacySave = methods.SavePastedImageForTarget as ((token: string, dataURL: string) => Promise<string>) | undefined;
const legacyPreview = methods.AttachmentDataURLForTarget as ((token: string, path: string) => Promise<string>) | undefined;
const unscopedPreview = methods.AttachmentDataURL as ((path: string) => Promise<string>) | undefined;
return installDesktopHostStub({
Commands: async () => [],
Models: async () => [],
ModelsForTab: async () => [],
CaptureAttachmentTarget: async () => ({ token: "test-attachment-target", capabilities: ["attachments-v2"] }),
ReleaseAttachmentTarget: async () => {},
StageImageForTarget: async (token: string, _operationID: string, displayName: string, mime: string, dataURL: string) => ({
draftId: "",
path: legacySave ? await legacySave(token, dataURL) : ".reasonix/attachments/mock.png",
displayName,
mime,
width: 1,
height: 1,
bytes: dataURL.length,
}),
ReadDraftImageForTarget: async () => "data:image/png;base64,iVBORw0KGgo=",
AttachmentDataURLForTarget: legacyPreview ?? (async () => "data:image/png;base64,iVBORw0KGgo="),
AttachmentDataURLForTab: async (_tabID: string, path: string) => unscopedPreview ? unscopedPreview(path) : "data:image/png;base64,iVBORw0KGgo=",
...methods,
});
}
@@ -207,6 +224,66 @@ function renderUserMessage(text: string, props: Partial<Parameters<typeof UserMe
console.log("\ncomposer image capability");
async function verifyUnsupportedAttachmentCapability(
overrides: Record<string, unknown>,
file: File | undefined,
label: string,
) {
const dom = installDom();
let unhandled = 0;
const onUnhandled = (event: PromiseRejectionEvent) => {
unhandled += 1;
event.preventDefault();
};
window.addEventListener("unhandledrejection", onUnhandled);
installBridgeApp(overrides);
const { root } = await renderComposer({
imageInputEnabled: true,
insertRequest: { id: 1, text: "keep this draft", mode: "replace" },
});
const event = imagePasteEvent(file ?? new File([], "", { type: "" }));
if (!file) {
Object.defineProperty(event, "clipboardData", {
configurable: true,
value: { files: [], items: [{ kind: "file", type: "image/png", getAsFile: () => null }], types: ["image/png"], getData: () => "" },
});
}
await act(async () => {
textarea().dispatchEvent(event);
await flushTimers();
await flushTimers();
});
await waitFor(() => toastText() !== "");
eq(textarea().value, "keep this draft", `${label} keeps draft text`);
eq(contextItemCount(), 0, `${label} does not add a partial attachment`);
ok(toastText().length > 0, `${label} reports an explicit attachment error`);
eq(unhandled, 0, `${label} produces no unhandled rejection`);
window.removeEventListener("unhandledrejection", onUnhandled);
await act(async () => root.unmount());
dom.window.close();
}
await verifyUnsupportedAttachmentCapability(
{ CaptureAttachmentTarget: undefined },
new File(["img"], "photo.png", { type: "image/png", lastModified: 1 }),
"missing target capture",
);
await verifyUnsupportedAttachmentCapability(
{ StageImageForTarget: undefined },
new File(["img"], "photo.png", { type: "image/png", lastModified: 1 }),
"missing image staging",
);
await verifyUnsupportedAttachmentCapability(
{ SavePastedFileForTarget: undefined },
new File(["pdf"], "document.pdf", { type: "application/pdf", lastModified: 1 }),
"missing pasted-file save",
);
await verifyUnsupportedAttachmentCapability(
{ SaveClipboardImageForTarget: undefined },
undefined,
"missing native clipboard image read",
);
{
const dom = installDom();
let saveCalls = 0;
@@ -1,5 +1,3 @@
// Run: tsx src/__tests__/composer-session-draft.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
@@ -103,6 +101,7 @@ function installBridgeApp(methods: Record<string, unknown>) {
SearchFileRefs: async () => [],
SearchFileRefsForTab: async () => [],
SearchFileRefsForTarget: async () => [],
CaptureAttachmentTarget: async () => ({ token: "test-attachment-target", capabilities: ["attachments-v2"] }), ReleaseAttachmentTarget: async () => {},
AttachmentDataURLForTarget: async (_target: unknown, path: string) => path,
...methods,
});
@@ -0,0 +1,27 @@
// Run: tsx src/__tests__/draft-credentials.test.ts
import { draftIDsFromSubmit } from "../lib/draftCredentials";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (JSON.stringify(a) === JSON.stringify(b)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
failed += 1;
}
}
console.log("\ndraft credentials");
eq(
draftIDsFromSubmit("inspect @[a.png](draft:0123456789abcdef0123456789abcdef) and @[b.png](draft:0123456789ABCDEF0123456789ABCDEF)"),
["0123456789abcdef0123456789abcdef"],
"extracts unique lowercase draft credentials",
);
eq(draftIDsFromSubmit("no drafts here"), [], "returns no ids without draft credentials");
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -31,6 +31,7 @@ const stableCases = [
["channel_read_only", "当前会话为只读,无法修改收件箱"],
["workspace_starting", "工作区还在启动,请稍后重试"],
["workspace_start_failed", "工作区启动失败"],
["image_attachment_unreadable", "图片读取失败,请重新添加或重试"],
] as const;
for (const [code, expected] of stableCases) {
@@ -610,15 +610,13 @@ eq(historyCalls.length, historyCallsBeforeReady, "agent ready with cached transc
ok(controller?.state.items.some((item) => item.kind === "phase" && item.text === "Planner is thinking") ?? false, "agent ready keeps cached planner phase");
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false, "agent ready keeps cached planner answer");
let tabCSendResolved = false;
let tabCSendResolved = false, tabCSendPromise: Promise<void> | undefined;
await act(async () => {
const sendPromise = controller?.sendToTab("tab-c", "streaming C");
sendPromise?.then(() => {
tabCSendResolved = true;
});
tabCSendPromise = controller?.sendToTab("tab-c", "streaming C");
tabCSendPromise?.then(() => { tabCSendResolved = true; });
await flushPromises();
});
eq(tabCSendResolved, true, "sendToTab resolves after optimistic dispatch before backend submit completes");
eq(tabCSendResolved, false, "sendToTab waits for backend admission before resolving");
await act(async () => {
await controller?.switchTab("tab-c", tabC);
await flushPromises();
@@ -631,9 +629,10 @@ ok(Boolean(tabCSubmissionId) && tabCSubmissionId !== tabCUser?.localId, "opaque
ok(historyCalls.includes("tab-c"), "a running tab with no history page of its own still hydrates one");
await act(async () => {
submitTabCGate.resolve();
await submitTabCGate.promise;
await Promise.all([submitTabCGate.promise, tabCSendPromise]);
await flushPromises();
});
eq(tabCSendResolved, true, "sendToTab resolves after backend admission succeeds");
holdNextContextForD = true;
await act(async () => {
@@ -104,7 +104,14 @@ globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.win
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
let backendRunning = false;
const backendHistory: HistoryMessage[] = [{ role: "user", content: "hello", messageId: "initial-user", createdAt: 1000, checkpointTurn: 0 }];
const backendHistory: HistoryMessage[] = [{
role: "user",
content: "hello",
messageId: "initial-user",
createdAt: 1000,
checkpointTurn: 0,
attachments: [{ kind: "image", digest: "a".repeat(64), name: "photo.png", mime: "image/png", width: 1, height: 1, bytes: 68 }],
}];
let cancelCalls = 0;
let cancelInboxCalls = 0;
let cancelInboxError: Error | null = null;
@@ -256,7 +256,7 @@ rejectAnswer = false;
rejectSubmit = true;
await act(async () => {
await controller?.send("continue while prompt is pending");
await controller?.send("continue while prompt is pending").catch(() => {});
await flushPromises();
await flushPromises();
});
@@ -270,7 +270,7 @@ await act(async () => {
desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-send", turnId: "turn-authoritative" } as WireEvent);
backendTab = tabMeta({ running: false, pendingPrompt: false, turnId: undefined });
await flushPromises();
await controller?.send("retry against an idle backend");
await controller?.send("retry against an idle backend").catch(() => {});
await flushPromises();
await flushPromises();
});
@@ -280,7 +280,7 @@ eq(controller?.state.pendingPrompt, false, "authoritative idle snapshot leaves n
rejectListTabs = true;
const beforeFailedReconcileCalls = listTabsCalls;
await act(async () => {
await controller?.send("retry while runtime status is unavailable");
await controller?.send("retry while runtime status is unavailable").catch(() => {});
await new Promise((resolve) => setTimeout(resolve, 1_500));
});
eq(listTabsCalls - beforeFailedReconcileCalls, 4, "rejected submit retries failed ListTabs reads at every bounded delay");
@@ -314,14 +314,14 @@ rejectSubmit = true;
backendTab = tabMeta({ running: false, pendingPrompt: false, turnEventSeq: 700 });
await act(async () => {
desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-send" } as WireEvent);
await controller?.send("seed idle status after rejected submit");
await controller?.send("seed idle status after rejected submit").catch(() => {});
await flushPromises();
await flushPromises();
});
eq(controller?.state.runtimeStatusSeq, 1, "baseline uses the business cut, never the old ledger sequence");
eq(controller?.state.running, false, "first rejection settles to authoritative idle");
await act(async () => {
await controller?.send("second pre-admission rejection");
await controller?.send("second pre-admission rejection").catch(() => {});
await flushPromises();
await flushPromises();
});
@@ -318,7 +318,7 @@ export function useSessionDraftSurface(options: DraftSurfaceOptions) {
const previewGeneration = entry.generation;
void Promise.all(entry.content.attachments.map(async (attachment) => {
try {
const previewUrl = await app.AttachmentDataURLForTarget({ kind: "draft", draftId: draft.id }, attachment.path);
const previewUrl = await app.AttachmentDataURLForComposerTarget({ kind: "draft", draftId: draft.id }, attachment.path);
const current = entriesRef.current.get(draft.id);
if (!current || current.lifecycle !== "active" || current.generation !== previewGeneration) return;
current.content = {
@@ -817,7 +817,7 @@ export function useSessionDraftSurface(options: DraftSurfaceOptions) {
let operation: SessionDraftSubmissionView;
try { operation = await app.BeginDraftSubmission(request); }
catch (error) {
if (String(error).includes("draft submission not admitted:")) throw error;
if (String(error).includes("draft submission not admitted:") || String(error).includes("reasonix_error:")) throw error;
// A transport error does not prove that Begin failed. Keep the source
// frozen while read-only reconciliation is unavailable.
for (;;) {
@@ -837,7 +837,7 @@ export function useSessionDraftSurface(options: DraftSurfaceOptions) {
// the exact request ID, whose backend lock serializes the decision.
try { operation = await app.BeginDraftSubmission(request); }
catch (retryError) {
if (String(retryError).includes("draft submission not admitted:")) throw retryError;
if (String(retryError).includes("draft submission not admitted:") || String(retryError).includes("reasonix_error:")) throw retryError;
source.error = "Verifying whether the submission was received. Reconnecting…";
publish(draftId);
await new Promise(resolve => window.setTimeout(resolve, 2000));
+124 -147
View File
@@ -9,6 +9,9 @@ import { asArray } from "../lib/array";
import { filterAtMatches } from "../lib/atMatches";
import { DedupIndex, sha256 } from "../lib/attachDedup";
import { app, onFilesDropped } from "../lib/bridge";
import { attachmentExt, attachmentName, baseName, formatAttachmentDisplayReference, hasImageAttachments, sortComposerAttachments, type Attachment } from "../lib/composerAttachments";
import type { PastedBlock, PersistentComposerDraft, PersistentComposerTarget, WorkspaceReference } from "../lib/composerDraftTypes";
import type { ComposerTarget } from "../generated/desktopContract.generated";
import { desktopHost } from "../lib/desktopHost";
import { steerInboxItemForActiveTurn } from "../lib/inboxSubmit";
import { formatInboxError, isInboxItemMissing } from "../lib/inboxError";
@@ -96,23 +99,13 @@ import { formatGoalWorkTime } from "../lib/goalRuntime";
import { ComposerContentMenuActions } from "./ComposerContentMenuActions";
import { GoalLifecycleActions } from "./GoalLifecycleActions";
export interface Attachment {
path: string;
previewUrl?: string;
displayName?: string;
}
export type { PersistentComposerDraft } from "../lib/composerDraftTypes";
interface AttachmentDedupKey {
hash: string;
source: string;
}
export interface WorkspaceReference {
path: string;
isDir?: boolean;
displayPath?: string;
}
const LONG_PASTE_MIN_CHARS = 2000;
const LONG_PASTE_MIN_LINES = 20;
const COMPOSER_MIN_HEIGHT = 104;
@@ -126,36 +119,7 @@ const COMPOSER_AUTO_RESERVED_HEIGHT = 58;
const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
const ComposerGuidanceShelf = lazy(() => import("./ComposerGuidanceShelf").then((module) => ({ default: module.ComposerGuidanceShelf })));
export type PastedBlock = { label: string; text: string };
export type PersistentComposerDraft = {
text: string;
invocations: ComposerInvocation[];
attachments: Attachment[];
workspaceRefs: WorkspaceReference[];
pastedBlocks: PastedBlock[];
openPastedLabels: string[];
sessionRefs: SessionReference[];
selectedTextRefs: SelectedTextReference[];
};
export type ComposerTarget =
| { kind: "draft"; draftId: string }
| { kind: "session"; tabId: string };
export type PersistentComposerTarget = {
draftId: string;
generation: number;
initial: PersistentComposerDraft;
revision: number;
onChange: (draftId: string, generation: number, content: PersistentComposerDraft) => void;
onPatch?: (draftId: string, generation: number, patch: Partial<PersistentComposerDraft> | ((content: PersistentComposerDraft) => PersistentComposerDraft)) => void;
isCurrent?: (draftId: string, generation: number) => boolean;
canEdit?: (draftId: string, generation: number) => boolean;
onTaskError?: (draftId: string, generation: number, message: string) => void;
trackTask?: <T>(draftId: string, generation: number, promise: Promise<T>) => Promise<T>;
};
const loadAttachmentSubmit = () => import("../lib/attachmentSubmit");
// Resolve functional updates synchronously, outside React's deferred updater.
// The store receives only the field changed by the event, never an old snapshot.
function useComposerField<K extends keyof PersistentComposerDraft>(key: K, initial: PersistentComposerDraft[K], owner: { current: PersistentComposerTarget | undefined }, restoring: { current: boolean }) {
@@ -247,40 +211,6 @@ function renderPastedBlock(block: PastedBlock): string {
return `${block.label}\n\n--- Begin ${block.label} ---\n${block.text}\n--- End ${block.label} ---`;
}
function baseName(path: string): string {
const clean = path.replace(/[\\/]+$/, "");
return clean.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function attachmentName(attachment: Attachment): string {
return (attachment.displayName || baseName(attachment.path) || "attachment").trim();
}
function attachmentExt(name: string): string {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toUpperCase() : "";
}
function hasImageAttachments(items: Attachment[]): boolean {
return items.some((attachment) => Boolean(attachment.previewUrl));
}
function displayRefName(name: string): string {
return name.replace(/[\[\]\(\)\r\n]+/g, " ").replace(/\s+/g, " ").trim() || "attachment";
}
function formatAttachmentDisplayReference(attachment: Attachment): string {
return `@[${displayRefName(attachmentName(attachment))}](${attachment.path})`;
}
function sortComposerAttachments(items: Attachment[]): Attachment[] {
return [...items].sort((a, b) => {
const ai = a.previewUrl ? 0 : 1;
const bi = b.previewUrl ? 0 : 1;
return ai - bi;
});
}
function workspaceReferenceKey(ref: WorkspaceReference): string {
return `${ref.isDir ? "dir" : "file"}:${ref.path}`;
}
@@ -811,11 +741,11 @@ export function Composer({
remoteHostId: inboxHostId,
});
const draftKey = sessionKey || tabId || DEFAULT_COMPOSER_DRAFT_KEY;
const bridgeTarget = useMemo(() => composerTarget?.kind === "draft"
? { kind: "draft", draftId: composerTarget.draftId, tabId: "" }
: { kind: "session", draftId: "", tabId: composerTarget?.tabId ?? tabId ?? "" },
[composerTarget?.kind, composerTarget?.kind === "draft" ? composerTarget.draftId : composerTarget?.tabId, tabId]);
const bridgeTargetKey = `${bridgeTarget.kind}:${bridgeTarget.draftId}:${bridgeTarget.tabId}`;
const bridgeTarget = useMemo(() => composerTarget?.kind === "draft"
? { kind: "draft", draftId: composerTarget.draftId, tabId: "", generation: persistentDraft?.generation ?? composerTarget.generation ?? 0 }
: { kind: "session", draftId: "", tabId: composerTarget?.tabId ?? tabId ?? "" },
[composerTarget?.kind, composerTarget?.kind === "draft" ? composerTarget.draftId : composerTarget?.tabId, persistentDraft?.generation, tabId]);
const bridgeTargetKey = `${bridgeTarget.kind}:${bridgeTarget.draftId}:${bridgeTarget.tabId}:${bridgeTarget.generation ?? 0}`;
const runtimeState = useRuntimeSession(tabId, inboxSessionPath);
const finishing = runtimeState.finishing;
if (runtimeState.known) running = runtimeState.running ?? running;
@@ -2227,8 +2157,11 @@ export function Composer({
const submittedDraft = followupDraftFingerprint(submitDraftKey);
const currentSessionRefs = sessionRefsRef.current;
const currentSelectedTextRefs = selectedTextRefsRef.current;
const currentPastedBlocks = [...pastedBlocksRef.current];
let submissionCapture: unknown;
const currentPastedBlocks = [...pastedBlocksRef.current];
let submissionCapture: unknown;
let submissionAttachmentTarget: string | undefined;
let attachmentSubmissionId: string | undefined;
let attachmentSubmit: Awaited<ReturnType<typeof loadAttachmentSubmit>> | undefined;
try {
submissionCapture = onCaptureSubmit?.(persistentSnapshot(snapshotComposerDraft()));
if (onCaptureSubmit && !submissionCapture) return;
@@ -2258,11 +2191,19 @@ export function Composer({
const submitBase = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
const submitText = [submitBase, selectedTextContext].filter(Boolean).join("\n\n");
const structuredInput = [expandPastedBlocks(trimmedText, currentPastedBlocks), refs].filter(Boolean).join(" ");
const structured = trimmedDraft.invocations.length > 0 ? {
display: [invocationText, displayRefs].filter(Boolean).join(invocationText && displayRefs ? " " : ""),
input: [sessionContext ? `${sessionContext}${structuredInput}` : structuredInput, selectedTextContext].filter(Boolean).join("\n\n"),
invocations: invocationRequests(trimmedDraft.invocations),
} satisfies StructuredInvocationSubmit : undefined;
let structured: StructuredInvocationSubmit | undefined = trimmedDraft.invocations.length > 0 ? {
display: [invocationText, displayRefs].filter(Boolean).join(invocationText && displayRefs ? " " : ""),
input: [sessionContext ? `${sessionContext}${structuredInput}` : structuredInput, selectedTextContext].filter(Boolean).join("\n\n"),
invocations: invocationRequests(trimmedDraft.invocations),
} satisfies StructuredInvocationSubmit : undefined;
const stagedImages = orderedAttachments.filter((item) => item.draftId);
if (stagedImages.length > 0 && bridgeTarget.kind === "session") {
attachmentSubmit = await loadAttachmentSubmit();
const prepared = await attachmentSubmit.prepareImageSubmission(app, bridgeTarget, submitDraftKey, submittedDraft, stagedImages, structured, displayText, submitText);
submissionAttachmentTarget = prepared.token;
attachmentSubmissionId = prepared.submissionId;
structured = prepared.structured;
}
if (running) {
// An entity-only submit has an empty displayText (entities live
// outside the text model); fall back to the serialized slash form so
@@ -2332,13 +2273,15 @@ export function Composer({
}
return;
}
await onSend(displayText, submitText, submitTabId, structured, submissionCapture);
if (!persistentDraft) clearSubmittedDraft(submitDraftKey);
await onSend(displayText, submitText, submitTabId, structured, submissionCapture);
attachmentSubmit?.settleImageSubmission(submitDraftKey, attachmentSubmissionId);
if (!persistentDraft) clearSubmittedDraft(submitDraftKey);
} catch (error) {
if (persistentDraft?.onTaskError) persistentDraft.onTaskError(persistentDraft.draftId, persistentDraft.generation, formatInboxError(error, locale));
else showToast(formatInboxError(error, locale), "warn");
} finally {
onReleaseSubmit?.(submissionCapture);
} finally {
if (submissionAttachmentTarget) await app.ReleaseAttachmentTarget?.(submissionAttachmentTarget);
onReleaseSubmit?.(submissionCapture);
updateSubmittingForDraft(submitDraftKey, false);
}
};
@@ -2456,29 +2399,27 @@ export function Composer({
}
};
const readFileAsDataURL = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
const attachImageFiles = async (files: File[], sourceDraftKey: string) => {
const owner = persistentTargetsByDraftRef.current[sourceDraftKey];
if (!attachmentInputEnabled) return;
const sourceBridgeTarget = bridgeTargetsByDraftRef.current[sourceDraftKey] ?? bridgeTarget;
const images = files.filter((f) => f.type.startsWith("image/"));
if (images.length === 0) return;
for (const file of images) {
const attachImageFiles = async (files: File[], sourceDraftKey: string) => {
const owner = persistentTargetsByDraftRef.current[sourceDraftKey];
if (!attachmentInputEnabled) return;
const sourceBridgeTarget = bridgeTargetsByDraftRef.current[sourceDraftKey] ?? bridgeTarget;
const images = files.filter((f) => f.type.startsWith("image/"));
if (images.length === 0) return;
// Capability validation happens synchronously inside captureImageTarget,
// before hashing or FileReader starts work for this owner.
const attachmentSubmit = await loadAttachmentSubmit();
const target = await attachmentSubmit.captureImageTarget(app, sourceBridgeTarget);
try {
for (const file of images) {
updatePendingPasteForDraft(sourceDraftKey, 1);
try {
const key = await fileDedupKey(file);
if (attachmentSeenInDraft(sourceDraftKey, key)) continue;
const dataUrl = await readFileAsDataURL(file);
const path = await app.SavePastedImageForTarget(sourceBridgeTarget, dataUrl);
const previewUrl = await app.AttachmentDataURLForTarget(sourceBridgeTarget, path);
addAttachmentToDraft(sourceDraftKey, { path, previewUrl, displayName: file.name }, key, owner);
const staged = await attachmentSubmit.stageImageFile(app, target, `${sourceDraftKey}:${key.hash}:${key.source}`, file);
addAttachmentToDraft(sourceDraftKey, {
path: staged.path, previewUrl: staged.previewUrl, displayName: file.name,
draftId: staged.draftId, clientAttachmentId: crypto.randomUUID(),
}, key, owner);
} catch (error) {
console.warn("[composer] failed to attach pasted image", error);
if (owner?.onTaskError) owner.onTaskError(owner.draftId, owner.generation, t("composer.attachImageFailed"));
@@ -2486,25 +2427,31 @@ export function Composer({
// non-fatal: a failed image attach must not block normal text input
} finally {
updatePendingPasteForDraft(sourceDraftKey, -1);
}
}
};
}
}
} finally {
await app.ReleaseAttachmentTarget?.(target);
}
};
// Non-image pastes (PDFs, docs): the clipboard hands us bytes, not a path, so
// the kernel stores them and we reference the saved path — attached, not ignored.
const attachOtherFiles = async (files: File[], sourceDraftKey: string) => {
const owner = persistentTargetsByDraftRef.current[sourceDraftKey];
if (!attachmentInputEnabled) return;
const sourceBridgeTarget = bridgeTargetsByDraftRef.current[sourceDraftKey] ?? bridgeTarget;
const others = files.filter((f) => !f.type.startsWith("image/"));
if (others.length === 0) return;
for (const file of others) {
const sourceBridgeTarget = bridgeTargetsByDraftRef.current[sourceDraftKey] ?? bridgeTarget;
const others = files.filter((f) => !f.type.startsWith("image/"));
if (others.length === 0) return;
const attachmentSubmit = await loadAttachmentSubmit();
const target = await attachmentSubmit.captureAttachmentTarget(app, sourceBridgeTarget, ["SavePastedFileForTarget"]);
try {
for (const file of others) {
updatePendingPasteForDraft(sourceDraftKey, 1);
try {
const key = await fileDedupKey(file);
if (attachmentSeenInDraft(sourceDraftKey, key)) continue;
const dataUrl = await readFileAsDataURL(file);
const path = await app.SavePastedFileForTarget(sourceBridgeTarget, file.name, dataUrl);
const dataUrl = await attachmentSubmit.readFileAsDataURL(file);
const path = await app.SavePastedFileForTarget!(target, file.name, dataUrl);
addAttachmentToDraft(sourceDraftKey, { path, displayName: file.name }, key, owner);
} catch {
console.warn("[composer] failed to attach pasted file");
@@ -2513,28 +2460,41 @@ export function Composer({
// non-fatal: a failed attach must not block normal text input
} finally {
updatePendingPasteForDraft(sourceDraftKey, -1);
}
}
};
}
}
} finally {
await app.ReleaseAttachmentTarget?.(target);
}
};
const attachFiles = (files: File[]) => {
if (!attachmentInputEnabled) return;
const sourceDraftKey = activeDraftKeyRef.current;
const target = persistentTargetsByDraftRef.current[sourceDraftKey];
if (target?.canEdit && !target.canEdit(target.draftId, target.generation)) return;
void trackPersistentTask(sourceDraftKey, attachImageFiles(files, sourceDraftKey));
void trackPersistentTask(sourceDraftKey, attachOtherFiles(files, sourceDraftKey));
const target = persistentTargetsByDraftRef.current[sourceDraftKey];
if (target?.canEdit && !target.canEdit(target.draftId, target.generation)) return;
void trackPersistentTask(sourceDraftKey, attachImageFiles(files, sourceDraftKey)).catch((error) => {
console.warn("[composer] attachment image capability unavailable", error);
if (target?.onTaskError) target.onTaskError(target.draftId, target.generation, t("composer.attachImageFailed"));
else showToast(t("composer.attachImageFailed"), "warn");
});
void trackPersistentTask(sourceDraftKey, attachOtherFiles(files, sourceDraftKey)).catch((error) => {
console.warn("[composer] attachment file capability unavailable", error);
if (target?.onTaskError) target.onTaskError(target.draftId, target.generation, t("composer.attachFileFailed"));
else showToast(t("composer.attachFileFailed"), "warn");
});
};
const attachNativeClipboardImage = (notifyOnError: boolean, sourceDraftKey: string, owner = persistentTargetsByDraftRef.current[sourceDraftKey]) => {
const sourceBridgeTarget = bridgeTargetsByDraftRef.current[sourceDraftKey] ?? bridgeTarget;
const task = (async () => {
if (!attachmentInputEnabled) return;
if (owner?.isCurrent && !owner.isCurrent(owner.draftId, owner.generation)) return;
updatePendingPasteForDraft(sourceDraftKey, 1);
try {
const path = await app.SaveClipboardImageForTarget(sourceBridgeTarget);
const previewUrl = await app.AttachmentDataURLForTarget(sourceBridgeTarget, path);
const sourceBridgeTarget = bridgeTargetsByDraftRef.current[sourceDraftKey] ?? bridgeTarget;
const task = (async () => {
if (!attachmentInputEnabled) return;
if (owner?.isCurrent && !owner.isCurrent(owner.draftId, owner.generation)) return;
const attachmentSubmit = await loadAttachmentSubmit();
const target = await attachmentSubmit.captureAttachmentTarget(app, sourceBridgeTarget, ["SaveClipboardImageForTarget", "AttachmentDataURLForTarget"]);
updatePendingPasteForDraft(sourceDraftKey, 1);
try {
const path = await app.SaveClipboardImageForTarget!(target);
const previewUrl = await app.AttachmentDataURLForTarget!(target, path);
const key = { hash: await dataURLHash(previewUrl), source: `native-clipboard:${path}` };
if (attachmentSeenInDraft(sourceDraftKey, key)) return;
addAttachmentToDraft(sourceDraftKey, { path, previewUrl }, key, owner);
@@ -2544,11 +2504,18 @@ export function Composer({
if (owner?.onTaskError) owner.onTaskError(owner.draftId, owner.generation, t("composer.pasteImageFailed"));
else if (sourceDraftKey === activeDraftKeyRef.current) showToast(t("composer.pasteImageFailed"), "warn");
}
} finally {
updatePendingPasteForDraft(sourceDraftKey, -1);
} finally {
await app.ReleaseAttachmentTarget?.(target);
updatePendingPasteForDraft(sourceDraftKey, -1);
}
})();
return trackPersistentTask(sourceDraftKey, task);
return trackPersistentTask(sourceDraftKey, task).catch((error) => {
console.warn("[composer] native clipboard attachment unavailable", error);
if (notifyOnError) {
if (owner?.onTaskError) owner.onTaskError(owner.draftId, owner.generation, t("composer.pasteImageFailed"));
else showToast(t("composer.pasteImageFailed"), "warn");
}
});
};
// OS file drops arrive as absolute paths through the native bridge (the webview
@@ -2557,16 +2524,19 @@ export function Composer({
const attachDroppedPaths = (paths: string[], sourceDraftKey = activeDraftKeyRef.current) => {
const owner = persistentTargetsByDraftRef.current[sourceDraftKey];
if (owner?.canEdit && !owner.canEdit(owner.draftId, owner.generation)) return Promise.resolve();
const sourceBridgeTarget = bridgeTargetsByDraftRef.current[sourceDraftKey] ?? bridgeTarget;
const task = (async () => {
setDragOver(false);
if (!attachmentInputEnabled) return;
for (const path of paths) {
const sourceBridgeTarget = bridgeTargetsByDraftRef.current[sourceDraftKey] ?? bridgeTarget;
const task = (async () => {
setDragOver(false);
if (!attachmentInputEnabled) return;
const attachmentSubmit = await loadAttachmentSubmit();
const target = await attachmentSubmit.captureAttachmentTarget(app, sourceBridgeTarget, ["AttachDroppedForTarget"]);
try {
for (const path of paths) {
updatePendingPasteForDraft(sourceDraftKey, 1);
try {
const key = { hash: "", source: `path:${path}` };
if (attachmentSeenInDraft(sourceDraftKey, key)) continue;
const item = await app.AttachDroppedForTarget(sourceBridgeTarget, path);
const item = await app.AttachDroppedForTarget!(target, path);
if (item.kind === "workspace") {
addWorkspaceReferenceToDraft(sourceDraftKey, { path: item.path, isDir: item.isDir, displayPath: item.displayPath }, owner);
} else {
@@ -2578,10 +2548,17 @@ export function Composer({
else if (sourceDraftKey === activeDraftKeyRef.current) showToast(t("composer.attachDropFailed"), "warn");
} finally {
updatePendingPasteForDraft(sourceDraftKey, -1);
}
}
})();
return trackPersistentTask(sourceDraftKey, task);
}
}
} finally {
await app.ReleaseAttachmentTarget?.(target);
}
})();
return trackPersistentTask(sourceDraftKey, task).catch((error) => {
console.warn("[composer] dropped attachment capability unavailable", error);
if (owner?.onTaskError) owner.onTaskError(owner.draftId, owner.generation, t("composer.attachDropFailed"));
else showToast(t("composer.attachDropFailed"), "warn");
});
};
useEffect(() => {
File diff suppressed because it is too large. Load diff
@@ -3,7 +3,7 @@
export const DESKTOP_PROTOCOL_VERSION = 11;
export const DESKTOP_CONTRACT_DIGEST = "sha256:5c622c69c50034dc6413893654e928df55fbb4a157767cc3ed32a7aef850f671";
export const DESKTOP_CONTRACT_DIGEST = "sha256:712d91ecf80057290bb444076a15538f2f2688ac7128cb720dd33cda7cb871fc";
export const DESKTOP_COMMANDS = [
"AIRenameSession",
@@ -48,8 +48,12 @@ export const DESKTOP_COMMANDS = [
"ArchiveCanonicalSession",
"ArchiveSessionTarget",
"AttachDropped",
"AttachDroppedForComposerTarget",
"AttachDroppedForTab",
"AttachDroppedForTarget",
"AttachmentDataURL",
"AttachmentDataURLForComposerTarget",
"AttachmentDataURLForTab",
"AttachmentDataURLForTarget",
"AuthenticateMCPServer",
"AuthorizeAndConnectMCPServer",
@@ -79,6 +83,7 @@ export const DESKTOP_COMMANDS = [
"CancelTrySubagentProfile",
"Capabilities",
"CapabilityDiagnostics",
"CaptureAttachmentTarget",
"CaptureInboxTarget",
"CheckRemotePlatform",
"CheckUpdate",
@@ -149,6 +154,7 @@ export const DESKTOP_COMMANDS = [
"EditRemoteTabGoal",
"Effort",
"EffortForTab",
"EnqueueForAttachmentTarget",
"EnqueueInboxFollowup",
"EnqueueInboxFollowupForTarget",
"EnqueueInboxFollowupWithInvocations",
@@ -356,6 +362,8 @@ export const DESKTOP_COMMANDS = [
"PurgeRecoveryCopy",
"PurgeTrashedSession",
"QuerySessionTakeover",
"ReadDraftImageForTab",
"ReadDraftImageForTarget",
"ReadFile",
"ReadFileForTab",
"ReadInboxItem",
@@ -365,8 +373,10 @@ export const DESKTOP_COMMANDS = [
"ReadReferenceFileForTab",
"ReadReferenceFileSourceForTab",
"ReadRemoteFile",
"ReadSessionAttachmentForTab",
"ReadSessionExportChunk",
"ReadSessionHistory",
"RebindDraftImageForTarget",
"RebuildHistoryIndex",
"RebuildSessionCatalog",
"RebuildTaskCatalog",
@@ -379,6 +389,9 @@ export const DESKTOP_COMMANDS = [
"RefreshInboxItem",
"RefreshSkills",
"RegisterNavigationIntent",
"ReleaseAttachmentTarget",
"ReleaseDraftImageForTab",
"ReleaseDraftImageForTarget",
"ReloadCommands",
"ReloadRuntime",
"ReloadSettings",
@@ -505,6 +518,8 @@ export const DESKTOP_COMMANDS = [
"RuntimeDoctor",
"SanitizeMarkdownSVG",
"SaveClipboardImage",
"SaveClipboardImageForComposerTarget",
"SaveClipboardImageForTab",
"SaveClipboardImageForTarget",
"SaveDoc",
"SaveDocForTab",
@@ -514,9 +529,12 @@ export const DESKTOP_COMMANDS = [
"SaveHooksSettingsForRoot",
"SaveLocalPathAs",
"SavePastedFile",
"SavePastedFileForComposerTarget",
"SavePastedFileForTab",
"SavePastedFileForTarget",
"SavePastedImage",
"SavePastedImageForTarget",
"SavePastedImageForComposerTarget",
"SavePastedImageForTab",
"SavePresentedPathAsForTab",
"SaveProvider",
"SaveProviderKey",
@@ -650,9 +668,13 @@ export const DESKTOP_COMMANDS = [
"Settings",
"SkillsSettings",
"SlashArgs",
"StageImageForTab",
"StageImageForTarget",
"StartBotConnectionInstall",
"StartTopicActivation",
"StartTurnForAttachmentTarget",
"StartTurnForTab",
"StartTurnForTabWithDrafts",
"Steer",
"SteerForTab",
"SteerInboxItem",
@@ -773,6 +795,14 @@ export interface ToolRecoveryStatistics {
blocked: number;
}
export interface AttachmentRef {
v: number;
content: Ref;
width?: number;
height?: number;
name?: string;
}
export interface CostQuote {
original: Money;
originalTotals?: Money[];
@@ -1059,6 +1089,12 @@ export interface CancelReceipt {
recoveryRequired: boolean;
}
export interface control_InvocationRequest {
name: string;
kind: string;
offset: number;
}
export interface PermissionCapabilities {
backend: string;
enforcement: string;
@@ -1084,6 +1120,29 @@ export interface SessionGrantSummary {
target: string;
}
export interface SubmissionAttachment {
clientAttachmentId: string;
draftId?: string;
path?: string;
reference?: AttachmentRef | null;
}
export interface SubmissionRequest {
http?: boolean;
input: string;
display?: string;
format?: string;
action?: string;
recoveryId?: string;
original?: string;
goal?: string;
toolApprovalMode?: string;
invocations?: control_InvocationRequest[];
draftIds?: string[];
attachmentDigests?: string[];
attachments?: SubmissionAttachment[];
}
export interface ToolRecoveryRequest {
sessionPath: string;
runtimeEpoch: string;
@@ -1726,6 +1785,11 @@ export interface AgentView {
compactRatioOverridden?: boolean;
}
export interface AttachmentTargetView {
token: string;
capabilities: string[];
}
export interface BackgroundRuntimeView {
tabId: string;
title: string;
@@ -1999,6 +2063,7 @@ export interface ComposerTarget {
draftId?: string;
tabId?: string;
session?: SessionRef | null;
generation?: number;
}
export interface ContextBudgetInfo {
@@ -2165,6 +2230,16 @@ export interface DirEntry {
displayPath?: string;
}
export interface DraftImageView {
draftId: string;
path?: string;
displayName: string;
mime: string;
width: number;
height: number;
bytes: number;
}
export interface DraftWorkspaceRef {
path: string;
isDir?: boolean;
@@ -2592,7 +2667,7 @@ export interface InteractionTargetView {
kind: string;
}
export interface InvocationRequest {
export interface main_InvocationRequest {
name: string;
kind: string;
offset: number;
@@ -3826,7 +3901,7 @@ export interface SessionDraftSubmissionRequest {
kind?: string;
display: string;
input: string;
invocations: InvocationRequest[];
invocations: main_InvocationRequest[];
goal?: string;
collaborationMode?: string;
toolApprovalMode?: string;
@@ -5151,6 +5226,16 @@ export interface ActiveAttempt {
nextIndex: number;
}
export interface Attachment {
kind?: string;
digest?: string;
name?: string;
mime?: string;
width?: number;
height?: number;
bytes?: number;
}
export interface Change {
runtime?: Runtime | null;
attemptId?: string;
@@ -5243,6 +5328,7 @@ export interface Message {
protocolRecovery?: ProtocolRecoveryAction | null;
diagnostic?: FailureDiagnostic | null;
serverSearch?: ServerSearchCall[];
attachments?: Attachment[];
}
export interface OutlineEntry {
@@ -5491,9 +5577,13 @@ export interface GeneratedDesktopCommands {
ArchiveCanonicalSession(arg0: SessionRef): Promise<void>;
ArchiveSessionTarget(arg0: SessionSelector): Promise<SessionMutationResult>;
AttachDropped(arg0: string): Promise<DroppedItem>;
AttachDroppedForTarget(arg0: ComposerTarget, arg1: string): Promise<DroppedItem>;
AttachDroppedForComposerTarget(arg0: ComposerTarget, arg1: string): Promise<DroppedItem>;
AttachDroppedForTab(arg0: string, arg1: string): Promise<DroppedItem>;
AttachDroppedForTarget(arg0: string, arg1: string): Promise<DroppedItem>;
AttachmentDataURL(arg0: string): Promise<string>;
AttachmentDataURLForTarget(arg0: ComposerTarget, arg1: string): Promise<string>;
AttachmentDataURLForComposerTarget(arg0: ComposerTarget, arg1: string): Promise<string>;
AttachmentDataURLForTab(arg0: string, arg1: string): Promise<string>;
AttachmentDataURLForTarget(arg0: string, arg1: string): Promise<string>;
AuthenticateMCPServer(arg0: string): Promise<void>;
AuthorizeAndConnectMCPServer(arg0: string): Promise<void>;
AvailableSubagentTools(): Promise<ToolView[]>;
@@ -5522,6 +5612,7 @@ export interface GeneratedDesktopCommands {
CancelTrySubagentProfile(): Promise<void>;
Capabilities(): Promise<CapabilitiesView>;
CapabilityDiagnostics(arg0: boolean): Promise<Report>;
CaptureAttachmentTarget(arg0: ComposerTarget): Promise<AttachmentTargetView>;
CaptureInboxTarget(arg0: string, arg1: string): Promise<InboxTargetView>;
CheckRemotePlatform(arg0: string): Promise<void>;
CheckUpdate(arg0: string): Promise<UpdateInfo | null>;
@@ -5592,9 +5683,10 @@ export interface GeneratedDesktopCommands {
EditRemoteTabGoal(arg0: string, arg1: string, arg2: number | null): Promise<void>;
Effort(): Promise<EffortInfo>;
EffortForTab(arg0: string): Promise<EffortInfo>;
EnqueueForAttachmentTarget(arg0: string, arg1: string, arg2: string, arg3: string, arg4: control_InvocationRequest[], arg5: SubmissionAttachment[]): Promise<InboxReceiptView>;
EnqueueInboxFollowup(arg0: string, arg1: string, arg2: string, arg3: string): Promise<InboxReceiptView>;
EnqueueInboxFollowupForTarget(arg0: InboxTargetView, arg1: string, arg2: string, arg3: InvocationRequest[], arg4: string): Promise<InboxReceiptView>;
EnqueueInboxFollowupWithInvocations(arg0: string, arg1: string, arg2: string, arg3: InvocationRequest[], arg4: string): Promise<InboxReceiptView>;
EnqueueInboxFollowupForTarget(arg0: InboxTargetView, arg1: string, arg2: string, arg3: main_InvocationRequest[], arg4: string): Promise<InboxReceiptView>;
EnqueueInboxFollowupWithInvocations(arg0: string, arg1: string, arg2: string, arg3: main_InvocationRequest[], arg4: string): Promise<InboxReceiptView>;
EnqueueInboxSteer(arg0: string, arg1: string, arg2: string, arg3: string): Promise<InboxReceiptView>;
EnqueueInboxSteerForTurn(arg0: string, arg1: string, arg2: string, arg3: string, arg4: string): Promise<InboxReceiptView>;
EnsureBlankSurface(arg0: string, arg1: string): Promise<TabMeta>;
@@ -5799,6 +5891,8 @@ export interface GeneratedDesktopCommands {
PurgeRecoveryCopy(arg0: string): Promise<void>;
PurgeTrashedSession(arg0: string): Promise<void>;
QuerySessionTakeover(arg0: string): Promise<SessionTakeoverView | null>;
ReadDraftImageForTab(arg0: string, arg1: string): Promise<string>;
ReadDraftImageForTarget(arg0: string, arg1: string): Promise<string>;
ReadFile(arg0: string): Promise<FilePreview>;
ReadFileForTab(arg0: string, arg1: string): Promise<FilePreview>;
ReadInboxItem(arg0: string, arg1: string): Promise<InboxEnvelopeView>;
@@ -5808,8 +5902,10 @@ export interface GeneratedDesktopCommands {
ReadReferenceFileForTab(arg0: string, arg1: string): Promise<FilePreview>;
ReadReferenceFileSourceForTab(arg0: string, arg1: string): Promise<FilePreview>;
ReadRemoteFile(arg0: string, arg1: string): Promise<RemoteFilePreview>;
ReadSessionAttachmentForTab(arg0: string, arg1: string, arg2: number): Promise<SessionHistoryContentChunk>;
ReadSessionExportChunk(arg0: string, arg1: number): Promise<SessionExportChunk>;
ReadSessionHistory(arg0: SessionRef, arg1: string, arg2: number): Promise<HistoryPage>;
RebindDraftImageForTarget(arg0: string, arg1: string): Promise<DraftImageView>;
RebuildHistoryIndex(): Promise<void>;
RebuildSessionCatalog(): Promise<void>;
RebuildTaskCatalog(): Promise<void>;
@@ -5822,6 +5918,9 @@ export interface GeneratedDesktopCommands {
RefreshInboxItem(arg0: string, arg1: string): Promise<void>;
RefreshSkills(): Promise<void>;
RegisterNavigationIntent(arg0: string): Promise<void>;
ReleaseAttachmentTarget(arg0: string): Promise<void>;
ReleaseDraftImageForTab(arg0: string, arg1: string): Promise<void>;
ReleaseDraftImageForTarget(arg0: string, arg1: string): Promise<void>;
ReloadCommands(): Promise<void>;
ReloadRuntime(arg0: string): Promise<void>;
ReloadSettings(): Promise<void>;
@@ -5948,7 +6047,9 @@ export interface GeneratedDesktopCommands {
RuntimeDoctor(): Promise<RuntimeDoctorReport>;
SanitizeMarkdownSVG(arg0: string): Promise<MarkdownSVGView>;
SaveClipboardImage(): Promise<string>;
SaveClipboardImageForTarget(arg0: ComposerTarget): Promise<string>;
SaveClipboardImageForComposerTarget(arg0: ComposerTarget): Promise<string>;
SaveClipboardImageForTab(arg0: string): Promise<string>;
SaveClipboardImageForTarget(arg0: string): Promise<string>;
SaveDoc(arg0: string, arg1: string): Promise<string>;
SaveDocForTab(arg0: string, arg1: string, arg2: string): Promise<string>;
SaveExportFile(arg0: string, arg1: string, arg2: boolean): Promise<void>;
@@ -5957,9 +6058,12 @@ export interface GeneratedDesktopCommands {
SaveHooksSettingsForRoot(arg0: string, arg1: string, arg2: HookConfigView[]): Promise<void>;
SaveLocalPathAs(arg0: string): Promise<string>;
SavePastedFile(arg0: string, arg1: string): Promise<string>;
SavePastedFileForTarget(arg0: ComposerTarget, arg1: string, arg2: string): Promise<string>;
SavePastedFileForComposerTarget(arg0: ComposerTarget, arg1: string, arg2: string): Promise<string>;
SavePastedFileForTab(arg0: string, arg1: string, arg2: string): Promise<string>;
SavePastedFileForTarget(arg0: string, arg1: string, arg2: string): Promise<string>;
SavePastedImage(arg0: string): Promise<string>;
SavePastedImageForTarget(arg0: ComposerTarget, arg1: string): Promise<string>;
SavePastedImageForComposerTarget(arg0: ComposerTarget, arg1: string): Promise<string>;
SavePastedImageForTab(arg0: string, arg1: string): Promise<string>;
SavePresentedPathAsForTab(arg0: string, arg1: string, arg2: string): Promise<string>;
SaveProvider(arg0: ProviderView): Promise<void>;
SaveProviderKey(arg0: string, arg1: string): Promise<string>;
@@ -6093,9 +6197,13 @@ export interface GeneratedDesktopCommands {
Settings(): Promise<SettingsView>;
SkillsSettings(): Promise<SkillsSettingsView>;
SlashArgs(arg0: string): Promise<SlashArgsResult>;
StageImageForTab(arg0: string, arg1: string, arg2: string, arg3: string, arg4: string): Promise<DraftImageView>;
StageImageForTarget(arg0: string, arg1: string, arg2: string, arg3: string, arg4: string): Promise<DraftImageView>;
StartBotConnectionInstall(arg0: string, arg1: string): Promise<BotInstallStartResult>;
StartTopicActivation(arg0: TopicActivationRequest): Promise<TopicActivationTicket>;
StartTurnForAttachmentTarget(arg0: string, arg1: string, arg2: SubmissionRequest): Promise<TurnStartView>;
StartTurnForTab(arg0: string, arg1: string, arg2: string): Promise<TurnStartView>;
StartTurnForTabWithDrafts(arg0: string, arg1: string, arg2: string, arg3: string[]): Promise<TurnStartView>;
Steer(arg0: string): Promise<void>;
SteerForTab(arg0: string, arg1: string): Promise<void>;
SteerInboxItem(arg0: string, arg1: string): Promise<InboxReceiptView>;
@@ -6116,10 +6224,10 @@ export interface GeneratedDesktopCommands {
SubmitEditedDisplayToTabWithID(arg0: string, arg1: string, arg2: string, arg3: string, arg4: string): Promise<void>;
SubmitExtensionForm(arg0: string, arg1: string, arg2: string, arg3: Record<string, unknown>): Promise<void>;
SubmitExtensionFormExact(arg0: ExtensionFormTarget, arg1: Record<string, unknown>): Promise<void>;
SubmitInitialGoalToTab(arg0: string, arg1: string, arg2: string, arg3: string, arg4: InvocationRequest[], arg5: string, arg6: string): Promise<string[]>;
SubmitInitialGoalToTabWithID(arg0: string, arg1: string, arg2: string, arg3: string, arg4: InvocationRequest[], arg5: string, arg6: string, arg7: string): Promise<string[]>;
SubmitInvocationsToTab(arg0: string, arg1: string, arg2: string, arg3: InvocationRequest[]): Promise<void>;
SubmitInvocationsToTabWithID(arg0: string, arg1: string, arg2: string, arg3: InvocationRequest[], arg4: string): Promise<void>;
SubmitInitialGoalToTab(arg0: string, arg1: string, arg2: string, arg3: string, arg4: main_InvocationRequest[], arg5: string, arg6: string): Promise<string[]>;
SubmitInitialGoalToTabWithID(arg0: string, arg1: string, arg2: string, arg3: string, arg4: main_InvocationRequest[], arg5: string, arg6: string, arg7: string): Promise<string[]>;
SubmitInvocationsToTab(arg0: string, arg1: string, arg2: string, arg3: main_InvocationRequest[]): Promise<void>;
SubmitInvocationsToTabWithID(arg0: string, arg1: string, arg2: string, arg3: main_InvocationRequest[], arg4: string): Promise<void>;
SubmitRemoteTab(arg0: string, arg1: string): Promise<void>;
SubmitRemoteTabExtensionForm(arg0: string, arg1: string, arg2: string, arg3: Record<string, unknown>): Promise<void>;
SubmitRemoteTabExtensionFormExact(arg0: ExtensionFormTarget, arg1: Record<string, unknown>): Promise<void>;
@@ -0,0 +1,125 @@
import type { AppBindings } from "./bridge";
import type { ComposerTarget } from "../generated/desktopContract.generated";
export interface AttachmentBindings {
StageImageForTab(tabID: string, operationID: string, displayName: string, mime: string, dataUrl: string): Promise<{ draftId: string; displayName: string; mime: string; width: number; height: number; bytes: number }>;
ReadDraftImageForTab(tabID: string, draftID: string): Promise<string>;
ReleaseDraftImageForTab(tabID: string, draftID: string): Promise<void>;
SavePastedImageForTab(tabID: string, dataURL: string): Promise<string>;
SavePastedFileForTab(tabID: string, name: string, dataURL: string): Promise<string>;
SaveClipboardImageForTab(tabID: string): Promise<string>;
AttachmentDataURLForTab(tabID: string, path: string): Promise<string>;
AttachDroppedForTab(tabID: string, path: string): Promise<{ kind: string; path: string; isDir?: boolean; displayPath?: string; previewUrl?: string }>;
ReadSessionAttachmentForTab(tabID: string, digest: string, offset: number): Promise<{ data: string; nextOffset: number; done: boolean }>;
StartTurnForTabWithDrafts(tabID: string, input: string, submissionID: string, draftIDs: string[]): Promise<{ turnId: string; status: string; submissionId: string }>;
CaptureAttachmentTarget?(target: ComposerTarget): Promise<{ token: string; capabilities: string[] }>;
ReleaseAttachmentTarget?(token: string): Promise<void>;
SavePastedFileForTarget?(token: string, name: string, dataURL: string): Promise<string>;
SaveClipboardImageForTarget?(token: string): Promise<string>;
AttachmentDataURLForTarget?(token: string, path: string): Promise<string>;
AttachDroppedForTarget?(token: string, path: string): Promise<{ kind: string; path: string; isDir?: boolean; displayPath?: string; previewUrl?: string }>;
StageImageForTarget?(token: string, operationID: string, displayName: string, mime: string, dataUrl: string): Promise<{ draftId: string; path?: string; displayName: string; mime: string; width: number; height: number; bytes: number }>;
ReadDraftImageForTarget?(token: string, draftID: string): Promise<string>;
RebindDraftImageForTarget?(token: string, draftID: string): Promise<{ draftId: string; displayName: string; mime: string; width: number; height: number; bytes: number }>;
ReleaseDraftImageForTarget?(token: string, draftID: string): Promise<void>;
StartTurnForAttachmentTarget?(token: string, submissionID: string, request: { input: string; display: string; original?: string; goal?: string; toolApprovalMode?: string; invocations?: import("./invocationDisplay").InvocationRequest[]; attachments: import("./invocationDisplay").SubmissionAttachment[] }): Promise<{ turnId: string; status: string; submissionId: string }>;
EnqueueForAttachmentTarget?(token: string, submissionID: string, input: string, display: string, invocations: import("./invocationDisplay").InvocationRequest[], attachments: import("./invocationDisplay").SubmissionAttachment[]): Promise<{ itemId: string; paused?: boolean; error?: string }>;
}
const mockImages = new Map<string, string>();
const mockPreviewImageDataURL =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120' viewBox='0 0 160 120'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23f97316'/%3E%3Cstop offset='1' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='160' height='120' rx='14' fill='url(%23g)'/%3E%3Ccircle cx='44' cy='38' r='16' fill='%23fff7ed' opacity='.9'/%3E%3Cpath d='M18 96 62 58l24 22 18-16 38 32z' fill='%23ffffff' opacity='.9'/%3E%3C/svg%3E";
export function makeMockAttachmentBindings(): AttachmentBindings & Partial<AppBindings> & ThisType<AppBindings> {
return {
async StageImageForTab(_tabID: string, _operation: string, displayName: string, mime: string, dataURL: string) {
const draftId = crypto.randomUUID().replaceAll("-", "");
mockImages.set(draftId, dataURL);
return { draftId, displayName, mime, width: 1, height: 1, bytes: dataURL.length };
},
async ReadDraftImageForTab(_tabID: string, id: string) {
const image = mockImages.get(id);
if (!image) throw new Error("draft missing");
return image;
},
async CaptureAttachmentTarget(target: ComposerTarget) {
return { token: target.tabId || target.draftId || "mock-attachment-target", capabilities: ["attachments-v2"] };
},
async ReleaseAttachmentTarget(_token: string) {},
async StageImageForTarget(_token: string, _operation: string, displayName: string, mime: string, dataURL: string) {
const draftId = crypto.randomUUID().replaceAll("-", "");
mockImages.set(draftId, dataURL);
return { draftId, displayName, mime, width: 1, height: 1, bytes: dataURL.length };
},
async ReadDraftImageForTarget(_token: string, id: string) { const image = mockImages.get(id); if (!image) throw new Error("draft missing"); return image; },
async ReleaseDraftImageForTarget(_token: string, id: string) { mockImages.delete(id); },
async ReleaseDraftImageForTab(_tabID: string, id: string) { mockImages.delete(id); },
async SavePastedImage(dataURL: string) {
const path = `.reasonix/attachments/mock-${mockImages.size + 1}.png`;
mockImages.set(path, dataURL);
return path;
},
async SavePastedImageForComposerTarget(_target: ComposerTarget, dataURL: string) { return this.SavePastedImage(dataURL); },
async SavePastedImageForTab(_tabID: string, dataURL: string) { return this.SavePastedImage(dataURL); },
async SavePastedFile(name: string, dataURL: string) {
const path = `.reasonix/attachments/mock-${name}`;
mockImages.set(path, dataURL);
return path;
},
async SavePastedFileForComposerTarget(_target: ComposerTarget, name: string, dataURL: string) { return this.SavePastedFile(name, dataURL); },
async SavePastedFileForTarget(token: string, name: string, dataURL: string) { return this.SavePastedFileForTab(token,name,dataURL); },
async SavePastedFileForTab(_tabID: string, name: string, dataURL: string) { return this.SavePastedFile(name, dataURL); },
async SaveClipboardImage() {
const path = `.reasonix/attachments/mock-clipboard-${mockImages.size + 1}.png`;
mockImages.set(path, mockPreviewImageDataURL);
return path;
},
async SaveClipboardImageForComposerTarget(_target: ComposerTarget) { return this.SaveClipboardImage(); },
async SaveClipboardImageForTarget(token: string) { return this.SaveClipboardImageForTab(token); },
async SaveClipboardImageForTab(_tabID: string) { return this.SaveClipboardImage(); },
async AttachmentDataURL(path: string) { return mockImages.get(path) ?? mockPreviewImageDataURL; },
async AttachmentDataURLForComposerTarget(_target: ComposerTarget, path: string) { return this.AttachmentDataURL(path); },
async AttachmentDataURLForTarget(token: string, path: string) { return this.AttachmentDataURLForTab(token,path); },
async AttachmentDataURLForTab(_tabID: string, path: string) { return this.AttachmentDataURL(path); },
async AttachDropped(path: string) {
const name = path.split(/[/\\]/).filter(Boolean).pop() ?? path;
if (!/\.\w{1,6}$/i.test(name)) {
const tokenName = name.replace(/[^\w.-]+/g, "-") || "folder";
return { kind: "workspace", path: `__reasonix_external_folder/mock/${tokenName}`, isDir: true, displayPath: path };
}
const attachmentPath = `.reasonix/attachments/mock-${name}`;
mockImages.set(attachmentPath, mockPreviewImageDataURL);
return { kind: "attachment", path: attachmentPath };
},
async AttachDroppedForComposerTarget(_target: ComposerTarget, path: string) { return this.AttachDropped(path); },
async AttachDroppedForTarget(token: string, path: string) { return this.AttachDroppedForTab(token,path); },
async AttachDroppedForTab(_tabID: string, path: string) { return this.AttachDropped(path); },
async ReadSessionAttachmentForTab(_tabID: string, digest: string, offset: number) {
const image = mockImages.get(digest) ?? "data:image/png;base64,";
const data = image.split(",", 2)[1] ?? "";
return { data: offset === 0 ? data : "", nextOffset: data.length, done: true };
},
async StartTurnForTabWithDrafts(tabID: string, input: string, submissionID: string) {
if (this.StartTurnForTab) {
const receipt = await this.StartTurnForTab(tabID, input, submissionID);
return { turnId: receipt.turnId, status: receipt.status, submissionId: receipt.submissionId || submissionID };
}
await this.SubmitToTabWithID(tabID, input, submissionID);
return { turnId: submissionID, status: "queued", submissionId: submissionID };
},
async StartTurnForAttachmentTarget(token: string, submissionID: string, request: {input:string;display:string}) {
await this.SubmitDisplayToTabWithID(token,request.display,request.input,submissionID);
return {turnId:submissionID,status:"queued",submissionId:submissionID};
},
async EnqueueForAttachmentTarget(token: string, id: string, input: string, display: string) { return this.EnqueueInboxFollowup(token,display,input,id); },
};
}
export function callMockAttachment(
owner: AppBindings,
name: keyof AttachmentBindings,
args: unknown[],
): unknown {
const method = makeMockAttachmentBindings()[name] as (...values: unknown[]) => unknown;
return method.apply(owner, args);
}
@@ -10,6 +10,7 @@ export interface DisplayAttachment {
kind: "image" | "file" | "folder";
source: "attachment" | "workspace";
ext: string;
mime?: string;
}
function splitTrailingPunctuation(token: string): { core: string; suffix: string } {
@@ -27,6 +28,7 @@ export function baseName(path: string): string {
}
function isImageAttachmentRef(path: string): boolean {
if (path.startsWith("draft:") || path.startsWith("attachment:")) return true;
const ext = attachmentExt(path);
switch (ext) {
case ".png":
@@ -132,6 +134,7 @@ export function sortDisplayAttachments<T extends { kind: "image" | "file" | "fol
}
function isDisplayReference(path: string): boolean {
if (path.startsWith("draft:") || path.startsWith("attachment:")) return true;
if (path.startsWith(".reasonix/attachments/")) return true;
if (path.endsWith("/")) return true;
if (path.includes("/")) return true;
@@ -139,14 +142,14 @@ function isDisplayReference(path: string): boolean {
}
function displayAttachment(path: string, name: string): DisplayAttachment {
if (path.startsWith(".reasonix/attachments/")) {
if (path.startsWith("draft:") || path.startsWith("attachment:") || path.startsWith(".reasonix/attachments/")) {
const kind = isImageAttachmentRef(path) ? "image" : "file";
return {
path,
name,
kind,
source: "attachment",
ext: attachmentExt(path).replace(/^\./, "").toUpperCase(),
ext: attachmentExt(path.startsWith("attachment:") ? name : path).replace(/^\./, "").toUpperCase(),
};
}
const isDir = path.endsWith("/");
@@ -0,0 +1,94 @@
import type { AppBindings } from "./bridge";
import type { StructuredInvocationSubmit } from "./invocationDisplay";
import type { ComposerTarget } from "../generated/desktopContract.generated";
const pendingImageSubmissions = new Map<string, { fingerprint: string; id: string }>();
export function imageSubmissionIdentity(draft: string, fingerprint: string): string {
const previous = pendingImageSubmissions.get(draft);
if (previous?.fingerprint === fingerprint) return previous.id;
const id = `image-${crypto.randomUUID()}`;
pendingImageSubmissions.set(draft, { fingerprint, id });
return id;
}
export function settleImageSubmission(draft: string, id?: string): void {
if (pendingImageSubmissions.get(draft)?.id === id) pendingImageSubmissions.delete(draft);
}
export async function prepareImageSubmission(
app: AppBindings,
target: ComposerTarget,
draftKey: string,
fingerprint: string,
attachments: Array<{ draftId?: string; clientAttachmentId?: string }>,
structured: StructuredInvocationSubmit | undefined,
display: string,
input: string,
) {
const token = await captureImageTarget(app, target);
const submissionId = imageSubmissionIdentity(draftKey, fingerprint);
return {
token,
submissionId,
structured: {
display: structured?.display ?? display,
input: structured?.input ?? input,
invocations: structured?.invocations ?? [],
attachmentTarget: token,
attachmentSubmissionId: submissionId,
attachments: attachments.map((item, index) => ({
clientAttachmentId: item.clientAttachmentId || `image-${index + 1}`,
draftId: item.draftId,
})),
} satisfies StructuredInvocationSubmit,
};
}
export function submitAttachmentTurn(app: AppBindings, submissionId: string, structured: StructuredInvocationSubmit, original: string, initialGoal?: { goal: string; toolApprovalMode?: string }) {
if (!app.StartTurnForAttachmentTarget || !structured.attachmentTarget) throw new Error("unsupported: attachments-v2");
return app.StartTurnForAttachmentTarget(structured.attachmentTarget, submissionId, {
input: structured.input, display: structured.display, original,
goal: initialGoal?.goal, toolApprovalMode: initialGoal?.toolApprovalMode,
invocations: structured.invocations, attachments: structured.attachments ?? [],
});
}
export function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export async function captureImageTarget(app: AppBindings, target: ComposerTarget): Promise<string> {
return captureAttachmentTarget(app, target, target.kind === "draft"
? ["StageImageForTarget", "AttachmentDataURLForTarget"]
: ["StageImageForTarget", "ReadDraftImageForTarget"]);
}
export async function captureAttachmentTarget(
app: AppBindings,
target: ComposerTarget,
required: ReadonlyArray<keyof AppBindings>,
): Promise<string> {
if (!app.CaptureAttachmentTarget || required.some((name) => typeof app[name] !== "function")) {
throw new Error("unsupported: attachments-v2");
}
const captured = await app.CaptureAttachmentTarget(target);
if (!captured.capabilities.includes("attachments-v2")) throw new Error("unsupported: attachments-v2");
return captured.token;
}
export async function stageImageFile(app: AppBindings, target: string, draftKey: string, file: File) {
const dataURL = await readFileAsDataURL(file);
const staged = await app.StageImageForTarget!(target, `${draftKey}:${file.name}:${file.lastModified}`, file.name, file.type, dataURL);
const path = staged.draftId ? `draft:${staged.draftId}` : staged.path;
if (!path) throw new Error("attachment staging returned no source");
const previewUrl = staged.draftId
? await app.ReadDraftImageForTarget!(target, staged.draftId)
: await app.AttachmentDataURLForTarget!(target, path);
return { path, previewUrl, displayName: file.name, draftId: staged.draftId || undefined, file };
}
+35 -60
View File
@@ -1,4 +1,5 @@
import { makeMockSessionExportBindings, type SessionExportBindings } from "./sessionExportBridge";
import type { AttachmentBindings } from "./attachmentBindings";
import { makeMockSessionLifecycleBindings, type SessionLifecycleBindings } from "./sessionLifecycleBindings";
import { makeMockModelSettingsBindings, type ModelSettingsBindings } from "./modelSettingsBridge";
import { mockProviderTemplate, mockPreset, mockBundlePreset, mockKimiAPIModels, mockLongCatModels, mockTokenRhythmModels, mockTokenRhythmModelOverrides, mockMiMoV25Models, mockMiniMaxModels, mockGLMAPIModels, mockGLMCodingModels, mockGLMAnthropicModels, mockQwenAPIModels, mockQwenPlanModels, mockQwenPlanVisionModels, mockStepFunModels, mockOpenCodeGoModels, mockNovitaModels, mockGMIModels, mockVercelModels, mockOllamaCloudModels } from "./mockProviderTemplates";
@@ -244,7 +245,7 @@ interface DesktopWindowState {
}
// AppBindings is the hand-written React-to-Go contract. _CheckGeneratedBindings
// catches generated methods missing here; update this interface and typecheck.
export interface AppBindings extends SessionExportBindings, SessionLifecycleBindings, ForkTargetsBindings, ToolRecoveryBindings, ModelSettingsBindings, SessionCatalogBindings, ProjectTreeOrganizationBindings, HistoryCatalogBindings, TaskCatalogBindings, BlankProjectBindings, QualityFloorBindings, SessionTitleBindings, ScrollDiagnosticBindings, RemoteProjectBindings, MCPAppBindings, PinnedContextBindings, FollowupBindings, TranscriptProtocolBindings, SessionReaderBindings, ExactInteractionBindings {
export interface AppBindings extends AttachmentBindings, SessionExportBindings, SessionLifecycleBindings, ForkTargetsBindings, ToolRecoveryBindings, ModelSettingsBindings, SessionCatalogBindings, ProjectTreeOrganizationBindings, HistoryCatalogBindings, TaskCatalogBindings, BlankProjectBindings, QualityFloorBindings, SessionTitleBindings, ScrollDiagnosticBindings, RemoteProjectBindings, MCPAppBindings, PinnedContextBindings, FollowupBindings, TranscriptProtocolBindings, SessionReaderBindings, ExactInteractionBindings {
GetLegacyEmptySessionCleanupStatus(): Promise<LegacyEmptySessionCleanupStatus>;
RetryLegacyEmptySessionCleanup(): Promise<LegacyEmptySessionCleanupStatus>;
OpenSessionDraft(workspaceId: string): Promise<SessionDraftView>;
@@ -262,13 +263,13 @@ export interface AppBindings extends SessionExportBindings, SessionLifecycleBind
BeginDraftSubmission(request: SessionDraftSubmissionRequest): Promise<SessionDraftSubmissionView>;
GetDraftSubmission(operationId: string): Promise<SessionDraftSubmissionView>;
CancelDraftSubmission(operationId: string): Promise<SessionDraftSubmissionView>;
SavePastedImageForTarget(target: ComposerTarget, dataUrl: string): Promise<string>;
SavePastedFileForTarget(target: ComposerTarget, name: string, dataUrl: string): Promise<string>;
SaveClipboardImageForTarget(target: ComposerTarget): Promise<string>;
AttachDroppedForTarget(target: ComposerTarget, path: string): Promise<DroppedItem>;
SavePastedImageForComposerTarget(target: ComposerTarget, dataUrl: string): Promise<string>;
SavePastedFileForComposerTarget(target: ComposerTarget, name: string, dataUrl: string): Promise<string>;
SaveClipboardImageForComposerTarget(target: ComposerTarget): Promise<string>;
AttachDroppedForComposerTarget(target: ComposerTarget, path: string): Promise<DroppedItem>;
ListDirForTarget(target: ComposerTarget, rel: string): Promise<DirEntry[]>;
SearchFileRefsForTarget(target: ComposerTarget, query: string): Promise<DirEntry[]>;
AttachmentDataURLForTarget(target: ComposerTarget, path: string): Promise<string>;
AttachmentDataURLForComposerTarget(target: ComposerTarget, path: string): Promise<string>;
GetSessionActivityBaseline(selector: SessionSelector): Promise<import("../generated/desktopContract.generated").SessionActivityBaseline>;
GetWorkspaceSnapshot(): Promise<WorkspaceSnapshot>;
CreateSession(workspaceId: string): Promise<SessionRef>;
@@ -901,10 +902,22 @@ function hostEvents(name: string, cb: (...args: unknown[]) => void): (() => void
const host = desktopHost();
return host.kind === "none" ? null : host.events.on(name, cb);
}
type LazyMockAttachmentCommand = keyof AttachmentBindings
| "SavePastedImage"
| "SavePastedImageForComposerTarget"
| "SaveClipboardImage"
| "SaveClipboardImageForComposerTarget"
| "SavePastedFile"
| "SavePastedFileForComposerTarget"
| "AttachDropped"
| "AttachDroppedForComposerTarget"
| "AttachmentDataURL"
| "AttachmentDataURLForComposerTarget";
type MockAppBindings = Omit<AppBindings, LazyMockAttachmentCommand>;
let mockSingleton: AppBindings | null = null;
function getMock(): AppBindings {
if (!mockSingleton) mockSingleton = makeMockApp();
if (!mockSingleton) mockSingleton = makeMockApp() as AppBindings;
return mockSingleton;
}
@@ -1127,8 +1140,10 @@ function elapsedMs(startedAt: number): number {
export const app: AppBindings = new Proxy({} as AppBindings, {
get(_t, prop) {
const target = desktopHost().app ?? getMock();
const v = (target as unknown as Record<string, unknown>)[String(prop)];
const host = desktopHost().app, target = host ?? getMock();
let v = (target as unknown as Record<string, unknown>)[String(prop)];
if (!host && v === undefined && typeof prop === "string") v = (...args: unknown[]) => import("./attachmentBindings").then(
module => module.callMockAttachment(target, prop as keyof AttachmentBindings, args));
if (typeof v !== "function") return v;
return (...args: unknown[]) => {
const method = String(prop), crumb = bridgeBreadcrumb(method);
@@ -1377,14 +1392,11 @@ function cloneMockProviderTemplates(id: string, key: string): ProviderView[] | u
}));
}
const mockPreviewImageDataURL =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120' viewBox='0 0 160 120'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23f97316'/%3E%3Cstop offset='1' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='160' height='120' rx='14' fill='url(%23g)'/%3E%3Ccircle cx='44' cy='38' r='16' fill='%23fff7ed' opacity='.9'/%3E%3Cpath d='M18 96 62 58l24 22 18-16 38 32z' fill='%23ffffff' opacity='.9'/%3E%3C/svg%3E";
function mockExternalOpenerIconDataURL(color: string, label: string): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="${color}"/><text x="32" y="40" text-anchor="middle" font-family="system-ui" font-size="25" font-weight="700" fill="white">${label}</text></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
function makeMockApp(): AppBindings {
function makeMockApp(): MockAppBindings {
const credentialDiagnostics = (actions: string[] = []): CredentialDiagnosticReport => ({
home: "/mock/.reasonix",
credentialPath: "/mock/.reasonix/.env",
@@ -1417,7 +1429,6 @@ function makeMockApp(): AppBindings {
const noticePreviewMock = scenario === "notice";
const deepSeekUpgradeMock = scenario === "deepseek_upgrade";
const benchMock = scenario === "bench";
const mockAttachmentDataURLs = new Map<string, string>();
const mockDrafts = new Map<string, SessionDraftView>();
const mockDraftOperations = new Map<string, SessionDraftSubmissionView>();
const mockDraftForTarget = (scope: string, workspaceRoot: string): SessionDraftView => {
@@ -2377,7 +2388,7 @@ function makeMockApp(): AppBindings {
})),
archivedSessionIds: [...mockArchivedSessionIDs], pendingCreates: [],
});
return {
return { ...({} as AttachmentBindings),
async OpenSessionDraft(workspaceId: string) {
const prior = [...mockDrafts.values()].find((draft) => draft.workspaceId === workspaceId && draft.status === "active");
if (prior) return structuredClone(prior);
@@ -2453,7 +2464,7 @@ function makeMockApp(): AppBindings {
mockDraftOperations.set(operationId, next);
return structuredClone(next);
},
...makeMockSessionExportBindings(),
...makeMockSessionExportBindings(),
...makeMockSessionCatalogBindings(cloneProjectTree),
...makeMockBlankProjectBindings(),
async GetWorkspaceSnapshot() { return mockWorkspaceSnapshot(); },
@@ -3052,6 +3063,14 @@ function makeMockApp(): AppBindings {
},
async SubmitToTab(_tabID, input) { await withMockTabScope(_tabID, () => this.Submit(input)); },
async SubmitToTabWithID(_tabID, input, submissionID) { const submit = this.Submit as (value: string, id?: string) => Promise<void>; await withMockTabScope(_tabID, () => submit(input, submissionID)); },
async StartTurnForTab(_tabID, input, submissionID) {
const turnId = `mock-turn-${submissionID}`;
const submit = this.Submit as (value: string, id?: string) => Promise<void>;
void withMockTabScope(_tabID, () => submit(input, submissionID)).catch((error) => {
console.error("mock turn failed", error);
});
return { turnId, status: "started", disposition: "turn_started", runtimeEpoch: "mock", submissionId: submissionID };
},
async SubmitDisplay(_display, input) { await this.Submit(input); },
async SubmitDisplayToTab(_tabID, display, input) { await withMockTabScope(_tabID, () => this.SubmitDisplay(display, input)); },
async SubmitDisplayToTabWithID(_tabID, _display, input, submissionID) { await this.SubmitToTabWithID(_tabID, input, submissionID); },
@@ -4439,30 +4458,6 @@ function makeMockApp(): AppBindings {
async RevealPath(path: string) {
console.info("mock RevealPath", path);
},
async SavePastedImage(dataUrl: string) {
const path = `.reasonix/attachments/mock-${mockAttachmentDataURLs.size + 1}.png`;
mockAttachmentDataURLs.set(path, dataUrl);
return path;
},
async SavePastedImageForTarget(_target: ComposerTarget, dataUrl: string) {
return this.SavePastedImage(dataUrl);
},
async SaveClipboardImage() {
const path = `.reasonix/attachments/mock-clipboard-${mockAttachmentDataURLs.size + 1}.png`;
mockAttachmentDataURLs.set(path, mockPreviewImageDataURL);
return path;
},
async SaveClipboardImageForTarget(_target: ComposerTarget) {
return this.SaveClipboardImage();
},
async SavePastedFile(name: string, dataUrl: string) {
const path = `.reasonix/attachments/mock-${name}`;
mockAttachmentDataURLs.set(path, dataUrl);
return path;
},
async SavePastedFileForTarget(_target: ComposerTarget, name: string, dataUrl: string) {
return this.SavePastedFile(name, dataUrl);
},
async PickExportFile(defaultFilename: string, _mimeType: string) {
return defaultFilename;
},
@@ -4500,26 +4495,6 @@ function makeMockApp(): AppBindings {
: path;
await this.SaveExportFile(partPath, payloads[index], true);
}
},
async AttachDropped(path: string) {
const name = path.split(/[/\\]/).filter(Boolean).pop() ?? path;
const hasExt = /\.\w{1,6}$/i.test(name);
if (!hasExt) {
const tokenName = name.replace(/[^\w.-]+/g, "-") || "folder";
return { kind: "workspace" as const, path: `__reasonix_external_folder/mock/${tokenName}`, isDir: true, displayPath: path };
}
const attachmentPath = `.reasonix/attachments/mock-${name}`;
mockAttachmentDataURLs.set(attachmentPath, mockPreviewImageDataURL);
return { kind: "attachment" as const, path: attachmentPath };
},
async AttachDroppedForTarget(_target: ComposerTarget, path: string) {
return this.AttachDropped(path);
},
async AttachmentDataURL(path: string) {
return mockAttachmentDataURLs.get(path) ?? mockPreviewImageDataURL;
},
async AttachmentDataURLForTarget(_target: ComposerTarget, path: string) {
return this.AttachmentDataURL(path);
},
async Models() {
const active = mockTabs.find((tab) => tab.active) ?? mockTabs[0];
@@ -0,0 +1,38 @@
export interface Attachment {
clientAttachmentId?: string;
path: string;
previewUrl?: string;
displayName?: string;
draftId?: string;
file?: File;
}
export function baseName(path: string): string {
const clean = path.replace(/[\\/]+$/, "");
return clean.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
export function attachmentName(attachment: Attachment): string {
return (attachment.displayName || baseName(attachment.path) || "attachment").trim();
}
export function attachmentExt(name: string): string {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toUpperCase() : "";
}
export function hasImageAttachments(items: Attachment[]): boolean {
return items.some((attachment) => Boolean(attachment.previewUrl));
}
export function displayRefName(name: string): string {
return name.replace(/[\[\]\(\)\r\n]+/g, " ").replace(/\s+/g, " ").trim() || "attachment";
}
export function formatAttachmentDisplayReference(attachment: Attachment): string {
return `@[${displayRefName(attachmentName(attachment))}](${attachment.path})`;
}
export function sortComposerAttachments(items: Attachment[]): Attachment[] {
return [...items];
}
@@ -0,0 +1,36 @@
import type { ComposerInvocation } from "./invocationDisplay";
import type { SessionReference } from "./types";
import type { SelectedTextReference } from "./selectedTextContext";
import type { Attachment } from "./composerAttachments";
export interface WorkspaceReference {
path: string;
isDir?: boolean;
displayPath?: string;
}
export type PastedBlock = { label: string; text: string };
export type PersistentComposerDraft = {
text: string;
invocations: ComposerInvocation[];
attachments: Attachment[];
workspaceRefs: WorkspaceReference[];
pastedBlocks: PastedBlock[];
openPastedLabels: string[];
sessionRefs: SessionReference[];
selectedTextRefs: SelectedTextReference[];
};
export type PersistentComposerTarget = {
draftId: string;
generation: number;
initial: PersistentComposerDraft;
revision: number;
onChange: (draftId: string, generation: number, content: PersistentComposerDraft) => void;
onPatch?: (draftId: string, generation: number, patch: Partial<PersistentComposerDraft> | ((content: PersistentComposerDraft) => PersistentComposerDraft)) => void;
isCurrent?: (draftId: string, generation: number) => boolean;
canEdit?: (draftId: string, generation: number) => boolean;
onTaskError?: (draftId: string, generation: number, message: string) => void;
trackTask?: <T>(draftId: string, generation: number, promise: Promise<T>) => Promise<T>;
};
@@ -0,0 +1,15 @@
const draftIDPattern = /draft:([0-9a-fA-F]{32})/g;
export function draftIDsFromSubmit(input: string): string[] {
const ids: string[] = [];
const seen = new Set<string>();
draftIDPattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = draftIDPattern.exec(input))) {
const id = match[1].toLowerCase();
if (seen.has(id)) continue;
seen.add(id);
ids.push(id);
}
return ids;
}
+11
View File
@@ -0,0 +1,11 @@
import type { AppBindings } from "./bridge";
import { draftIDsFromSubmit } from "./draftCredentials";
export function draftSubmit(app: AppBindings, tabId: string, submit: string, submissionId: string) {
const draftIDs = draftIDsFromSubmit(submit);
if (draftIDs.length > 0 && typeof app.StartTurnForTabWithDrafts === "function") {
return app.StartTurnForTabWithDrafts(tabId, submit, submissionId, draftIDs);
}
if (typeof app.StartTurnForTab === "function") return app.StartTurnForTab(tabId, submit, submissionId);
return app.SubmitToTabWithID(tabId, submit, submissionId);
}
+4
View File
@@ -19,6 +19,7 @@ const CODE_INDEX = {
workspace_starting: 11,
workspace_start_failed: 12,
inbox_not_submitted: 16,
image_attachment_unreadable: 17,
} as const;
type InboxErrorCode = keyof typeof CODE_INDEX;
@@ -45,6 +46,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
"The turn ended before guidance could be applied. It will remain queued for the next turn",
"Cancel failed: {error}",
"The message was not sent. Refresh the session and try again",
"The image could not be read. Re-add it or try again",
],
zh: [
"收件箱已暂停",
@@ -64,6 +66,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
"引导尚未应用时当前回合已结束;它会保留在队列中,供下一回合处理",
"取消失败:{error}",
"消息未发送,请刷新会话后重试",
"图片读取失败,请重新添加或重试",
],
"zh-TW": [
"收件匣已暫停",
@@ -83,6 +86,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
"引導尚未套用時目前回合已結束;它會保留在佇列中,供下一回合處理",
"取消失敗:{error}",
"訊息未傳送,請重新整理會話後重試",
"圖片讀取失敗,請重新加入或重試",
],
};
@@ -2,7 +2,7 @@ import type { AppBindings } from "./bridge";
import type { StructuredInvocationSubmit } from "./invocationDisplay";
import { resolveActiveTurnId } from "./inboxSubmit";
type InboxEnqueueBindings = Pick<AppBindings, "EnqueueInboxFollowup" | "EnqueueInboxFollowupWithInvocations" | "EnqueueInboxSteer" | "EnqueueInboxSteerForTurn">;
type InboxEnqueueBindings = Pick<AppBindings, "EnqueueInboxFollowup" | "EnqueueInboxFollowupWithInvocations" | "EnqueueInboxSteer" | "EnqueueInboxSteerForTurn" | "EnqueueForAttachmentTarget">;
export async function enqueueInboxGuidanceForActiveTurn(
binding: InboxEnqueueBindings & Pick<AppBindings, "ListTabs">,
@@ -26,7 +26,20 @@ export function enqueueInboxGuidance(
structured?: StructuredInvocationSubmit,
opts?: { steer?: boolean; turnId?: string; idempotency?: string },
) {
if (structured) {
if (structured) {
if (structured.attachments?.length) {
if (!structured.attachmentTarget || !binding.EnqueueForAttachmentTarget) {
return Promise.reject(new Error("unsupported: attachments-v2"));
}
return binding.EnqueueForAttachmentTarget(
structured.attachmentTarget,
structured.attachmentSubmissionId || opts?.idempotency || `image-${crypto.randomUUID()}`,
structured.input.trim(),
structured.display.trim() || display,
structured.invocations,
structured.attachments,
);
}
return binding.EnqueueInboxFollowupWithInvocations(
tabId,
structured.display.trim() || display,
@@ -28,6 +28,15 @@ export type StructuredInvocationSubmit = {
display: string;
input: string;
invocations: InvocationRequest[];
attachmentTarget?: string;
attachmentSubmissionId?: string;
attachments?: SubmissionAttachment[];
};
export type SubmissionAttachment = {
clientAttachmentId: string;
draftId?: string;
path?: string;
};
export function invocationRequests(invocations: ComposerInvocation[]): InvocationRequest[] {
+38
View File
@@ -0,0 +1,38 @@
import type { AppBindings } from "./bridge";
import type { StructuredInvocationSubmit } from "./invocationDisplay";
import type { CollaborationMode, ToolApprovalMode } from "./types";
import { submitAttachmentTurn } from "./attachmentSubmit";
import { draftSubmit } from "./draftSubmit";
export async function submitTurn(
app: AppBindings,
tabId: string,
submissionId: string,
display: string,
submit: string,
original: string,
structured?: StructuredInvocationSubmit,
initialGoal?: { goal: string; collaborationMode: CollaborationMode; toolApprovalMode: ToolApprovalMode },
): Promise<[number, (string | string[])?]> {
let receipt: unknown;
if (structured?.attachments?.length) receipt = await submitAttachmentTurn(app, submissionId, structured, original, initialGoal);
else if (initialGoal) {
receipt = await app.SubmitInitialGoalToTabWithID(
tabId,
initialGoal.goal,
structured?.display.trim() || display,
structured?.input.trim() || submit,
structured?.invocations ?? [],
initialGoal.collaborationMode,
initialGoal.toolApprovalMode,
submissionId,
);
} else if (structured) receipt = await app.SubmitInvocationsToTabWithID(tabId, structured.display.trim(), structured.input.trim(), structured.invocations, submissionId);
else if (original) receipt = await app.SubmitEditedDisplayToTabWithID(tabId, display, submit, original, submissionId);
else if (display !== submit) receipt = await app.SubmitDisplayToTabWithID(tabId, display, submit, submissionId);
else receipt = await draftSubmit(app, tabId, submit, submissionId);
if (initialGoal) return [1, Array.isArray(receipt) ? receipt : []];
if (receipt && typeof receipt === "object" && "disposition" in receipt && receipt.disposition === "management_handled") return [2];
if (receipt && typeof receipt === "object" && "turnId" in receipt && typeof receipt.turnId === "string") return [3, receipt.turnId];
return [0];
}
+2 -2
View File
@@ -10,8 +10,7 @@ export type { ProviderProtocolEndpoint, ProviderCatalog, ProviderPresetView } fr
import type { WireReadStatus } from "./readStatus";
export type { WireReadStatus } from "./readStatus";
import type { RecoveryEventFields } from "./recoveryStatus";
// Wire contract — mirrors desktop/wire.go (itself mirroring internal/serve/wire.go).
// One event channel carries every kind; `kind` discriminates the payload.
// Wire contract: one discriminated event channel mirrors desktop/wire.go and internal/serve/wire.go.
import type { HistoryServerSearch } from "./searchSources";
import type { Todo } from "./tools";
import type { ContextBudgetInfo, ContextMaintenanceInfo, WireContextMaintenance } from "./contextMaintenanceTypes";
@@ -857,6 +856,7 @@ export interface HistoryMessage extends TranscriptTurnMetadata {
protocolRecovery?: { id: string };
diagnostic?: { kind: string; status?: number; traceId?: string; providerId?: string; providerDisplayName?: string; protocol?: string; requestPath?: string };
serverSearch?: HistoryServerSearch[];
attachments?: Array<{ kind?: string; digest?: string; name?: string; mime?: string; width?: number; height?: number; bytes?: number }>;
}
export interface HistoryPage {
+11 -36
View File
@@ -3588,7 +3588,7 @@ export function useController() {
throw new Error(runtime?.issue?.message || currentState.meta.startupErr || t("composer.workspaceStarting"));
}
const seq = currentState.seq;
const submissionId = createTurnSubmissionId(tabId, currentState.sessionGen, seq, runtimeEpochByTabRef.current.get(tabId) ?? runtime?.epoch);
const submissionId = structured?.attachmentSubmissionId ?? createTurnSubmissionId(tabId, currentState.sessionGen, seq, runtimeEpochByTabRef.current.get(tabId) ?? runtime?.epoch);
const submissionCurrent = () => submissionBindingCurrent(statesRef.current.get(tabId), currentState);
const promptEpoch = currentState.promptEpoch;
const { display, submit } = normalizeTurnSubmit(displayText, submitText);
@@ -3598,45 +3598,20 @@ export function useController() {
dispatchTo(tabId, { type: "user", text: displayText, submitText: display !== submit ? submit : undefined, seq, submissionId });
invalidateCache();
try {
const submitPromise = initialGoal
? app.SubmitInitialGoalToTabWithID(
tabId,
initialGoal.goal,
structured?.display.trim() || display,
structured?.input.trim() || submit,
structured?.invocations ?? [],
initialGoal.collaborationMode,
initialGoal.toolApprovalMode,
submissionId,
)
: structured
? app.SubmitInvocationsToTabWithID(tabId, structured.display.trim(), structured.input.trim(), structured.invocations, submissionId)
: original
? app.SubmitEditedDisplayToTabWithID(tabId, display, submit, original, submissionId)
: display !== submit
? app.SubmitDisplayToTabWithID(tabId, display, submit, submissionId)
: typeof app.StartTurnForTab === "function"
? app.StartTurnForTab(tabId, submit, submissionId)
: app.SubmitToTabWithID(tabId, submit, submissionId);
if (initialGoal) {
const drained = await submitPromise;
if (!submissionCurrent()) return;
const [outcome, detail] = await import("./turnSubmit").then(module => module.submitTurn(app, tabId, submissionId, display, submit, original, structured, initialGoal));
if (!submissionCurrent()) return;
if (outcome === 1) {
dispatchTo(tabId, { type: "send_confirmed", submissionId });
const ids = Array.isArray(drained) ? drained : [];
const ids = detail as string[];
if (ids.length) dispatchTo(tabId, { type: "approval_drained", ids, epoch: promptEpoch });
return;
}
void submitPromise.then(
(receipt) => {
if (!submissionCurrent()) return;
if (receipt && typeof receipt === "object" && "disposition" in receipt && receipt.disposition === "management_handled") return void dispatchTo(tabId, { type: "management_confirmed", submissionId });
if (receipt && typeof receipt === "object" && "turnId" in receipt && typeof receipt.turnId === "string") {
dispatchTo(tabId, { type: "turn_admitted", turnId: receipt.turnId, submissionId });
}
dispatchTo(tabId, { type: "send_confirmed", submissionId });
},
(error) => { if (submissionCurrent()) rejectTurnSubmission(tabId, submissionId, error); },
);
if (outcome === 2) {
dispatchTo(tabId, { type: "management_confirmed", submissionId });
return;
}
if (outcome === 3) dispatchTo(tabId, { type: "turn_admitted", turnId: detail as string, submissionId });
dispatchTo(tabId, { type: "send_confirmed", submissionId });
} catch (error) {
if (submissionCurrent()) rejectTurnSubmission(tabId, submissionId, error);
throw error;
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"reasonix/internal/attachment"
"reasonix/internal/transcript"
)
func historyDisplayAttachments(inputs []attachment.ImageInput) []transcript.Attachment {
if len(inputs) == 0 {
return nil
}
out := make([]transcript.Attachment, 0, len(inputs))
for _, in := range inputs {
switch in.Kind {
case attachment.KindAttachment:
if in.Attachment == nil {
continue
}
out = append(out, transcript.Attachment{
Kind: "image",
Digest: in.Attachment.Digest(),
Name: in.Attachment.DisplayName,
MIME: in.Attachment.MIME(),
Width: in.Attachment.Width,
Height: in.Attachment.Height,
Bytes: in.Attachment.Content.Bytes,
})
case attachment.KindURL:
if in.URL == "" {
continue
}
out = append(out, transcript.Attachment{Kind: "url", Name: in.URL})
case attachment.KindFiles:
if in.FilesID == "" {
continue
}
out = append(out, transcript.Attachment{Kind: "files", Name: in.FilesID})
}
}
if len(out) == 0 {
return nil
}
return out
}
+367 -25
View File
@@ -624,10 +624,10 @@
}
},
"AttachDropped": {
"domain": "desktop/app.go",
"domain": "desktop/attachments_app.go",
"owner": "App.AttachDropped",
"sources": [
"desktop/app.go"
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
@@ -637,26 +637,56 @@
]
}
},
"AttachDroppedForTarget": {
"AttachDroppedForComposerTarget": {
"domain": "desktop/session_draft.go",
"owner": "App.AttachDroppedForTarget",
"owner": "App.AttachDroppedForComposerTarget",
"sources": [
"desktop/session_draft.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.AttachDroppedForTarget",
"resolver": "App.AttachDroppedForComposerTarget",
"inputs": [
"target",
"path"
]
}
},
"AttachDroppedForTab": {
"domain": "desktop/attachments_app.go",
"owner": "App.AttachDroppedForTab",
"sources": [
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.AttachDroppedForTab",
"inputs": [
"tabID",
"path"
]
}
},
"AttachDroppedForTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.AttachDroppedForTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.AttachDroppedForTarget",
"inputs": [
"token",
"path"
]
}
},
"AttachmentDataURL": {
"domain": "desktop/app.go",
"domain": "desktop/attachments_app.go",
"owner": "App.AttachmentDataURL",
"sources": [
"desktop/app.go"
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
@@ -666,21 +696,51 @@
]
}
},
"AttachmentDataURLForTarget": {
"AttachmentDataURLForComposerTarget": {
"domain": "desktop/session_draft.go",
"owner": "App.AttachmentDataURLForTarget",
"owner": "App.AttachmentDataURLForComposerTarget",
"sources": [
"desktop/session_draft.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.AttachmentDataURLForTarget",
"resolver": "App.AttachmentDataURLForComposerTarget",
"inputs": [
"target",
"rel"
]
}
},
"AttachmentDataURLForTab": {
"domain": "desktop/attachments_app.go",
"owner": "App.AttachmentDataURLForTab",
"sources": [
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.AttachmentDataURLForTab",
"inputs": [
"tabID",
"path"
]
}
},
"AttachmentDataURLForTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.AttachmentDataURLForTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.AttachmentDataURLForTarget",
"inputs": [
"token",
"path"
]
}
},
"AuthenticateMCPServer": {
"domain": "desktop/mcp_oauth.go",
"owner": "App.AuthenticateMCPServer",
@@ -1073,6 +1133,20 @@
]
}
},
"CaptureAttachmentTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.CaptureAttachmentTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.CaptureAttachmentTarget",
"inputs": [
"composer"
]
}
},
"CaptureInboxTarget": {
"domain": "desktop/inbox_target.go",
"owner": "App.CaptureInboxTarget",
@@ -2072,6 +2146,25 @@
]
}
},
"EnqueueForAttachmentTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.EnqueueForAttachmentTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.EnqueueForAttachmentTarget",
"inputs": [
"token",
"submissionID",
"input",
"display",
"invocations",
"attachments"
]
}
},
"EnqueueInboxFollowup": {
"domain": "desktop/inbox_app.go",
"owner": "App.EnqueueInboxFollowup",
@@ -4984,6 +5077,36 @@
]
}
},
"ReadDraftImageForTab": {
"domain": "desktop/attachments_app.go",
"owner": "App.ReadDraftImageForTab",
"sources": [
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.ReadDraftImageForTab",
"inputs": [
"tabID",
"draftID"
]
}
},
"ReadDraftImageForTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.ReadDraftImageForTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.ReadDraftImageForTarget",
"inputs": [
"token",
"draftID"
]
}
},
"ReadFile": {
"domain": "desktop/app.go",
"owner": "App.ReadFile",
@@ -5123,6 +5246,22 @@
]
}
},
"ReadSessionAttachmentForTab": {
"domain": "desktop/attachments_app.go",
"owner": "App.ReadSessionAttachmentForTab",
"sources": [
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.ReadSessionAttachmentForTab",
"inputs": [
"tabID",
"digest",
"offset"
]
}
},
"ReadSessionExportChunk": {
"domain": "desktop/session_export.go",
"owner": "App.ReadSessionExportChunk",
@@ -5154,6 +5293,21 @@
]
}
},
"RebindDraftImageForTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.RebindDraftImageForTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.RebindDraftImageForTarget",
"inputs": [
"token",
"draftID"
]
}
},
"RebuildHistoryIndex": {
"domain": "desktop/history_catalog.go",
"owner": "App.RebuildHistoryIndex",
@@ -5313,6 +5467,50 @@
]
}
},
"ReleaseAttachmentTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.ReleaseAttachmentTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.ReleaseAttachmentTarget",
"inputs": [
"token"
]
}
},
"ReleaseDraftImageForTab": {
"domain": "desktop/attachments_app.go",
"owner": "App.ReleaseDraftImageForTab",
"sources": [
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.ReleaseDraftImageForTab",
"inputs": [
"tabID",
"draftID"
]
}
},
"ReleaseDraftImageForTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.ReleaseDraftImageForTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.ReleaseDraftImageForTarget",
"inputs": [
"token",
"draftID"
]
}
},
"ReloadCommands": {
"domain": "desktop/app.go",
"owner": "App.ReloadCommands",
@@ -7162,10 +7360,10 @@
}
},
"SaveClipboardImage": {
"domain": "desktop/app.go",
"domain": "desktop/attachments_app.go",
"owner": "App.SaveClipboardImage",
"sources": [
"desktop/app.go"
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-state",
@@ -7173,20 +7371,48 @@
"inputs": []
}
},
"SaveClipboardImageForTarget": {
"SaveClipboardImageForComposerTarget": {
"domain": "desktop/session_draft.go",
"owner": "App.SaveClipboardImageForTarget",
"owner": "App.SaveClipboardImageForComposerTarget",
"sources": [
"desktop/session_draft.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.SaveClipboardImageForTarget",
"resolver": "App.SaveClipboardImageForComposerTarget",
"inputs": [
"target"
]
}
},
"SaveClipboardImageForTab": {
"domain": "desktop/attachments_app.go",
"owner": "App.SaveClipboardImageForTab",
"sources": [
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.SaveClipboardImageForTab",
"inputs": [
"tabID"
]
}
},
"SaveClipboardImageForTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.SaveClipboardImageForTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.SaveClipboardImageForTarget",
"inputs": [
"token"
]
}
},
"SaveDoc": {
"domain": "desktop/app.go",
"owner": "App.SaveDoc",
@@ -7295,10 +7521,10 @@
}
},
"SavePastedFile": {
"domain": "desktop/app.go",
"domain": "desktop/attachments_app.go",
"owner": "App.SavePastedFile",
"sources": [
"desktop/app.go"
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
@@ -7309,15 +7535,15 @@
]
}
},
"SavePastedFileForTarget": {
"SavePastedFileForComposerTarget": {
"domain": "desktop/session_draft.go",
"owner": "App.SavePastedFileForTarget",
"owner": "App.SavePastedFileForComposerTarget",
"sources": [
"desktop/session_draft.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.SavePastedFileForTarget",
"resolver": "App.SavePastedFileForComposerTarget",
"inputs": [
"target",
"name",
@@ -7325,11 +7551,43 @@
]
}
},
"SavePastedFileForTab": {
"domain": "desktop/attachments_app.go",
"owner": "App.SavePastedFileForTab",
"sources": [
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.SavePastedFileForTab",
"inputs": [
"tabID",
"name",
"dataURL"
]
}
},
"SavePastedFileForTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.SavePastedFileForTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.SavePastedFileForTarget",
"inputs": [
"token",
"name",
"dataURL"
]
}
},
"SavePastedImage": {
"domain": "desktop/app.go",
"domain": "desktop/attachments_app.go",
"owner": "App.SavePastedImage",
"sources": [
"desktop/app.go"
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
@@ -7339,21 +7597,36 @@
]
}
},
"SavePastedImageForTarget": {
"SavePastedImageForComposerTarget": {
"domain": "desktop/session_draft.go",
"owner": "App.SavePastedImageForTarget",
"owner": "App.SavePastedImageForComposerTarget",
"sources": [
"desktop/session_draft.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.SavePastedImageForTarget",
"resolver": "App.SavePastedImageForComposerTarget",
"inputs": [
"target",
"dataURL"
]
}
},
"SavePastedImageForTab": {
"domain": "desktop/attachments_app.go",
"owner": "App.SavePastedImageForTab",
"sources": [
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.SavePastedImageForTab",
"inputs": [
"tabID",
"dataURL"
]
}
},
"SavePresentedPathAsForTab": {
"domain": "desktop/app.go",
"owner": "App.SavePresentedPathAsForTab",
@@ -9311,6 +9584,42 @@
]
}
},
"StageImageForTab": {
"domain": "desktop/attachments_app.go",
"owner": "App.StageImageForTab",
"sources": [
"desktop/attachments_app.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.StageImageForTab",
"inputs": [
"tabID",
"operationID",
"displayName",
"mime",
"dataURL"
]
}
},
"StageImageForTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.StageImageForTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.StageImageForTarget",
"inputs": [
"token",
"operationID",
"displayName",
"mime",
"dataURL"
]
}
},
"StartBotConnectionInstall": {
"domain": "desktop/bot_connection_app.go",
"owner": "App.StartBotConnectionInstall",
@@ -9340,6 +9649,22 @@
]
}
},
"StartTurnForAttachmentTarget": {
"domain": "desktop/attachment_targets.go",
"owner": "App.StartTurnForAttachmentTarget",
"sources": [
"desktop/attachment_targets.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.StartTurnForAttachmentTarget",
"inputs": [
"token",
"submissionID",
"req"
]
}
},
"StartTurnForTab": {
"domain": "desktop/turn_runtime_api.go",
"owner": "App.StartTurnForTab",
@@ -9356,6 +9681,23 @@
]
}
},
"StartTurnForTabWithDrafts": {
"domain": "desktop/turn_runtime_api.go",
"owner": "App.StartTurnForTabWithDrafts",
"sources": [
"desktop/turn_runtime_api.go"
],
"scope": {
"kind": "owner-inputs",
"resolver": "App.StartTurnForTabWithDrafts",
"inputs": [
"tabID",
"input",
"submissionID",
"draftIDs"
]
}
},
"Steer": {
"domain": "desktop/app.go",
"owner": "App.Steer",
+4
View File
@@ -26,6 +26,10 @@ func inboxBridgeError(err error) error {
if err == nil {
return nil
}
var imageFailures control.ImageReferenceFailures
if errors.As(err, &imageFailures) {
return &inboxCodedError{code: "image_attachment_unreadable", cause: err}
}
known := []struct {
target error
code string
@@ -2,6 +2,8 @@ package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -38,6 +40,45 @@ func TestSubmitInitialGoalStopsBeforeProviderWhenPersistenceFails(t *testing.T)
}
}
func TestSubmitInitialGoalRejectsMissingImageBeforeGoalMutation(t *testing.T) {
workspace := t.TempDir()
ctrl := control.New(control.Options{WorkspaceRoot: workspace})
t.Cleanup(ctrl.Close)
tab := &WorkspaceTab{
ID: "a",
Scope: "project",
WorkspaceRoot: workspace,
TopicID: "topic_a",
TopicTitle: "A",
Ready: true,
Ctrl: ctrl,
disabledMCP: map[string]ServerView{},
}
app := &App{tabs: map[string]*WorkspaceTab{"a": tab}, tabOrder: []string{"a"}, activeTabID: "a"}
_, err := app.SubmitInitialGoalToTab(
tab.ID,
"inspect the image",
"inspect missing.png",
"inspect @.reasonix/attachments/missing.png",
nil,
"normal",
"ask",
)
if err == nil || err.Error() != "reasonix_error:image_attachment_unreadable" {
t.Fatalf("error = %v, want stable image failure", err)
}
if tab.goal != "" || ctrl.Goal() != "" {
t.Fatalf("rejected image mutated goal: tab=%q controller=%q", tab.goal, ctrl.Goal())
}
if ctrl.Running() {
t.Fatal("rejected image started a turn")
}
if _, statErr := os.Stat(filepath.Join(workspace, ".reasonix", "attachments")); !os.IsNotExist(statErr) {
t.Fatalf("failed image read created attachment directory: %v", statErr)
}
}
func (r *initialGoalSubmitRecorder) SubmitDisplay(display, input string) {
r.display = display
r.input = input
+12 -39
View File
@@ -176,13 +176,6 @@ func (a *App) ResumeDraftSubmission(operationID string, expectedRevision uint64)
return draftOperationView(op, a.metaForDraftSession(op.SessionID)), nil
}
type ComposerTarget struct {
Kind string `json:"kind"`
DraftID string `json:"draftId,omitempty"`
TabID string `json:"tabId,omitempty"`
Session *session.SessionRef `json:"session,omitempty"`
}
func (a *App) draftStore() *draftstate.Store {
if a.desktopDrafts == nil {
a.desktopDrafts = draftstate.New(config.DesktopDraftStatePath())
@@ -370,7 +363,12 @@ func (a *App) ListSessionDraftSummaries() ([]SessionDraftSummary, error) {
}
func (a *App) DiscardSessionDraft(draftID string, revision uint64) error {
return a.draftStore().Discard(a.bootContext(), strings.TrimSpace(draftID), revision)
draftID = strings.TrimSpace(draftID)
if err := a.draftStore().Discard(a.bootContext(), draftID, revision); err != nil {
return err
}
a.releaseAttachmentStageOperations("draft:" + draftID + ":")
return nil
}
func (a *App) GetDraftContext(draftID string) (SessionDraftContextView, error) {
@@ -499,7 +497,7 @@ func (a *App) BeginDraftSubmission(request SessionDraftSubmissionRequest) (resul
durableAttempt := false
defer func() {
if resultErr != nil && !durableAttempt {
resultErr = fmt.Errorf("draft submission not admitted: %w", resultErr)
resultErr = draftAdmissionError(resultErr)
}
}()
if request.SnapshotVersion > draftstate.SnapshotVersion {
@@ -630,31 +628,6 @@ func draftBuiltinBehavior(input string) (behavior, name string) {
return "", name
}
func validateDraftAttachments(record draftstate.Draft) error {
var content struct {
Attachments []struct {
Path string `json:"path"`
} `json:"attachments"`
}
if err := json.Unmarshal([]byte(record.ContentJSON), &content); err != nil {
return fmt.Errorf("decode session draft attachments: %w", err)
}
root := record.WorkspaceRoot
if record.Scope != "project" {
root = globalWorkspaceRoot()
}
base, err := workspaceBaseFromRoot(root)
if err != nil {
return err
}
for _, attachment := range content.Attachments {
if err := control.ValidateAttachmentInRoot(base, attachment.Path); err != nil {
return fmt.Errorf("attachment %q is unavailable: %w", filepath.Base(attachment.Path), err)
}
}
return nil
}
func (a *App) GetDraftSubmission(operationID string) (SessionDraftSubmissionView, error) {
op, err := a.draftStore().Operation(a.bootContext(), strings.TrimSpace(operationID))
if err != nil {
@@ -1223,7 +1196,7 @@ func (a *App) composerTargetWorkspace(target ComposerTarget) (string, control.Se
return base, ctrl, err
}
func (a *App) SavePastedImageForTarget(target ComposerTarget, dataURL string) (string, error) {
func (a *App) SavePastedImageForComposerTarget(target ComposerTarget, dataURL string) (string, error) {
root, _, err := a.composerTargetWorkspace(target)
if err != nil {
return "", err
@@ -1240,7 +1213,7 @@ func (a *App) SavePastedImageForTarget(target ComposerTarget, dataURL string) (s
return control.SaveImageBytesInRoot(root, strings.TrimPrefix(before, "data:"), raw)
}
func (a *App) SavePastedFileForTarget(target ComposerTarget, name, dataURL string) (string, error) {
func (a *App) SavePastedFileForComposerTarget(target ComposerTarget, name, dataURL string) (string, error) {
root, _, err := a.composerTargetWorkspace(target)
if err != nil {
return "", err
@@ -1256,7 +1229,7 @@ func (a *App) SavePastedFileForTarget(target ComposerTarget, name, dataURL strin
return control.SaveAttachmentBytesInRoot(root, name, raw)
}
func (a *App) SaveClipboardImageForTarget(target ComposerTarget) (string, error) {
func (a *App) SaveClipboardImageForComposerTarget(target ComposerTarget) (string, error) {
root, _, err := a.composerTargetWorkspace(target)
if err != nil {
return "", err
@@ -1288,7 +1261,7 @@ func (a *App) SearchFileRefsForTarget(target ComposerTarget, query string) []Dir
return searchFileRefsForWorkspaceTarget(root, ctrl, query)
}
func (a *App) AttachmentDataURLForTarget(target ComposerTarget, rel string) (string, error) {
func (a *App) AttachmentDataURLForComposerTarget(target ComposerTarget, rel string) (string, error) {
root, _, err := a.composerTargetWorkspace(target)
if err != nil {
return "", err
@@ -1296,7 +1269,7 @@ func (a *App) AttachmentDataURLForTarget(target ComposerTarget, rel string) (str
return control.ImageDataURLInRoot(root, rel)
}
func (a *App) AttachDroppedForTarget(target ComposerTarget, path string) (DroppedItem, error) {
func (a *App) AttachDroppedForComposerTarget(target ComposerTarget, path string) (DroppedItem, error) {
root, _, err := a.composerTargetWorkspace(target)
if err != nil {
return DroppedItem{}, err
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"reasonix/desktop/internal/draftstate"
"reasonix/internal/control"
)
func draftAdmissionError(err error) error {
stable := inboxBridgeError(err)
var coded *inboxCodedError
if errors.As(stable, &coded) {
return stable
}
return fmt.Errorf("draft submission not admitted: %w", err)
}
func validateDraftAttachments(record draftstate.Draft) error {
var content struct {
Attachments []struct {
Path string `json:"path"`
} `json:"attachments"`
}
if err := json.Unmarshal([]byte(record.ContentJSON), &content); err != nil {
return fmt.Errorf("decode session draft attachments: %w", err)
}
root := record.WorkspaceRoot
if record.Scope != "project" {
root = globalWorkspaceRoot()
}
base, err := workspaceBaseFromRoot(root)
if err != nil {
return err
}
for _, item := range content.Attachments {
if err := control.ValidateAttachmentInRoot(base, item.Path); err != nil {
if draftAttachmentLooksLikeImage(item.Path) {
return control.ImageReferenceFailures{control.ImageReferenceFailureForPath(item.Path, err)}
}
return fmt.Errorf("attachment %q is unavailable: %w", filepath.Base(item.Path), err)
}
}
return nil
}
func draftAttachmentLooksLikeImage(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".tif", ".tiff":
return true
default:
return false
}
}
+50 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -18,6 +19,20 @@ import (
"reasonix/internal/control"
)
func TestDraftAdmissionErrorPreservesWrappedCodedError(t *testing.T) {
coded := &inboxCodedError{code: "image_attachment_unreadable", cause: errors.New("missing image")}
wrapped := fmt.Errorf("validate draft: %w", coded)
got := draftAdmissionError(wrapped)
var found *inboxCodedError
if !errors.As(got, &found) || found != coded {
t.Fatalf("draftAdmissionError() = %v, want wrapped coded error", got)
}
if strings.Contains(got.Error(), "draft submission not admitted") {
t.Fatalf("draftAdmissionError() added generic prefix: %v", got)
}
}
func beginDraftTestOperation(t *testing.T, a *App, phase string) (draftstate.Draft, draftstate.Operation) {
t.Helper()
draft, _, err := a.draftStore().Open(context.Background(), "workspace", "project", t.TempDir(), "draft-"+phase, `{}`)
@@ -293,6 +308,39 @@ func TestMissingDraftAttachmentFailsBeforeSessionReservation(t *testing.T) {
}
}
func TestMissingDraftImageReturnsStableErrorWithoutHostPath(t *testing.T) {
a := newDraftTestApp(t)
root := t.TempDir()
draft, err := a.OpenSessionDraftForTarget("project", root)
if err != nil {
t.Fatal(err)
}
saved, err := a.SaveSessionDraft(SessionDraftSaveRequest{
DraftID: draft.ID, Revision: draft.Revision,
ContentJSON: `{"text":"inspect","attachments":[{"path":".reasonix/attachments/missing.png"}]}`,
Settings: draft.Settings,
})
if err != nil {
t.Fatal(err)
}
_, err = a.BeginDraftSubmission(SessionDraftSubmissionRequest{
DraftID: draft.ID, Revision: saved.Draft.Revision,
Display: "inspect", Input: "inspect @.reasonix/attachments/missing.png",
})
if err == nil || err.Error() != "reasonix_error:image_attachment_unreadable" {
t.Fatalf("BeginDraftSubmission() error = %v, want stable image failure", err)
}
if strings.Contains(err.Error(), root) {
t.Fatalf("bridge error exposed workspace root: %v", err)
}
if operations, loadErr := a.draftStore().PendingOperations(a.bootContext()); loadErr != nil || len(operations) != 0 {
t.Fatalf("operations after image validation failure = %+v, err %v", operations, loadErr)
}
if tabs := a.ListTabs(); len(tabs) != 0 {
t.Fatalf("tabs after image validation failure = %+v", tabs)
}
}
func TestInvalidDraftModelFailsBeforeSessionReservation(t *testing.T) {
isolateDesktopUserDirs(t)
a := newDraftTestApp(t)
@@ -394,8 +442,8 @@ func TestTargetedAttachmentsDoNotFollowActiveWorkspace(t *testing.T) {
var errA, errB error
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); pathA, errA = a.SavePastedFileForTarget(targetA, "a.txt", payloadA) }()
go func() { defer wg.Done(); pathB, errB = a.SavePastedFileForTarget(targetB, "b.txt", payloadB) }()
go func() { defer wg.Done(); pathA, errA = a.SavePastedFileForComposerTarget(targetA, "a.txt", payloadA) }()
go func() { defer wg.Done(); pathB, errB = a.SavePastedFileForComposerTarget(targetB, "b.txt", payloadB) }()
wg.Wait()
if errA != nil || errB != nil {
t.Fatalf("targeted saves = %v / %v", errA, errB)
+43
View File
@@ -116,6 +116,49 @@ func (a *App) StartTurnForTab(tabID, input, submissionID string) (TurnStartView,
return TurnStartView{TurnID: turnID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted, RuntimeEpoch: epoch, SubmissionID: submissionID}, nil
}
func (a *App) StartTurnForTabWithDrafts(tabID, input, submissionID string, draftIDs []string) (TurnStartView, error) {
if strings.TrimSpace(submissionID) == "" {
return TurnStartView{}, fmt.Errorf("submissionId is required")
}
req := control.SubmissionRequest{ID: submissionID, Input: input, Display: input, DraftIDs: append([]string(nil), draftIDs...)}
if _, ctrl := a.tabAndCtrlByID(tabID); ctrl != nil {
if identified, ok := ctrl.(*control.Controller); ok {
receipt, found, err := identified.LookupSubmission(req)
if err != nil {
return TurnStartView{}, err
}
if found {
return TurnStartView{TurnID: receipt.TurnID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted, SubmissionID: submissionID}, nil
}
}
}
admission, ctrl, err := a.beginTabTurn(tabID, true, submissionID)
if err != nil {
return TurnStartView{}, a.submissionAdmissionError(tabID, req, err)
}
defer admission.abort()
if err := a.ensureTabTopicIndexedForUserTurn(admission.tab); err != nil {
return TurnStartView{}, err
}
identified, ok := ctrl.(*control.Controller)
if !ok {
return TurnStartView{}, fmt.Errorf("unsupported: attachments-v1")
}
if _, err := identified.SubmitIdentified(req); err != nil {
return TurnStartView{}, inboxBridgeError(err)
}
admission.finish(ctrl)
turnID := identified.TurnIDForSubmission(submissionID)
if strings.TrimSpace(turnID) == "" {
return TurnStartView{}, fmt.Errorf("turn admission did not produce a durable turn id")
}
epoch := ""
if admission.tab != nil && admission.tab.sink != nil {
epoch = admission.tab.sink.runtimeEpochSnapshot()
}
return TurnStartView{TurnID: turnID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted, RuntimeEpoch: epoch, SubmissionID: submissionID}, nil
}
// InterruptTurnForTab stops the tab's active work. Stop is a session-level
// request: a turn id from a stale button still interrupts whatever is running
// now, because an unstoppable turn is worse than stopping its replacement.
+15 -2
View File
@@ -30,9 +30,22 @@ func (a *App) submissionAdmissionError(tabID string, req control.SubmissionReque
}
func submitIdentified(ctrl control.SessionAPI, req control.SubmissionRequest, submit func()) error {
if identified, ok := ctrl.(*control.Controller); ok && req.ID != "" && identified.ClassifySubmitRoute(req.Input) != control.SubmitManagementHandled {
return submitIdentifiedWithSetup(ctrl, req, nil, submit)
}
func submitIdentifiedWithSetup(ctrl control.SessionAPI, req control.SubmissionRequest, setup func() error, submit func()) error {
if identified, ok := ctrl.(*control.Controller); ok && identified.ClassifySubmitRoute(req.Input) != control.SubmitManagementHandled {
if setup != nil {
_, err := identified.SubmitIdentifiedWithSetup(req, setup)
return inboxBridgeError(err)
}
_, err := identified.SubmitIdentified(req)
return err
return inboxBridgeError(err)
}
if setup != nil {
if err := setup(); err != nil {
return err
}
}
submit()
return nil
+248 -226
View File
@@ -6,7 +6,7 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| Kind | keep-business | migrate-host | delete-shell | total |
| --- | ---: | ---: | ---: | ---: |
| command | 708 | 18 | 0 | 726 |
| command | 730 | 18 | 0 | 748 |
| native-call | 0 | 0 | 0 | 0 |
| event | 17 | 0 | 0 | 17 |
| frontend-native | 0 | 0 | 0 | 0 |
@@ -16,7 +16,7 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| shell-file | 1 | 39 | 4 | 44 |
| artifact | 5 | 0 | 0 | 5 |
| ci-job | 28 | 0 | 0 | 28 |
| **all** | | | | **860** |
| **all** | | | | **882** |
## Desktop commands (Go `App` methods bound to the UI)
@@ -36,7 +36,7 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `ActivateThemePack` | `(id string) error` | desktop/theme_app.go:235 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ActivateTopic` | `(scope string, workspaceRoot string, topicID string, sessionPath string) (TabMeta, error)` | desktop/tabs.go:2488 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ActiveWorkForTab` | `(tabID string) ActiveWorkView` | desktop/background_runtime.go:78 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AddMCPServer` | `(in MCPServerInput) (int, error)` | desktop/app.go:8361 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AddMCPServer` | `(in MCPServerInput) (int, error)` | desktop/app.go:8366 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AddOfficialProviderAccess` | `(kind string, key string) (string, error)` | desktop/official_provider_access_install.go:14 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AddPermissionRule` | `(list string, rule string) error` | desktop/settings_app.go:2946 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AddProviderConnection` | `(presetID string, sourceName string, key string) (string, error)` | desktop/settings_app.go:3279 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -46,60 +46,65 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `AddRemoteForward` | `(hostID string, in RemoteForwardInput) (RemoteForwardView, error)` | desktop/remote_app.go:782 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AddRemoteHost` | `(in RemoteHostInput) (RemoteHostView, error)` | desktop/remote_app.go:309 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AddRemoteProject` | `(hostID string, workspace string) (RemoteProjectView, error)` | desktop/remote_project_registry.go:25 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AddSkillPath` | `(path string) error` | desktop/app.go:8010 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AddSkillPath` | `(path string) error` | desktop/app.go:8015 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AnswerMCPInteractionForTab` | `(tabID string, id string, action string, content map[string]any) error` | desktop/mcp_interaction_app.go:14 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AnswerMCPInteractionForTurn` | `(tabID string, turnID string, runtimeEpoch string, id string, action string, content map[string]any) error` | desktop/decision_runtime_api.go:155 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AnswerPromptForTab` | `(tabID string, turnID string, promptID string, answers []QuestionAnswer) error` | desktop/turn_runtime_api.go:155 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AnswerQuestion` | `(id string, answers []QuestionAnswer)` | desktop/app.go:1827 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AnswerQuestionForTab` | `(tabID string, id string, answers []QuestionAnswer)` | desktop/app.go:1831 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AnswerPromptForTab` | `(tabID string, turnID string, promptID string, answers []QuestionAnswer) error` | desktop/turn_runtime_api.go:198 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AnswerQuestion` | `(id string, answers []QuestionAnswer)` | desktop/app.go:1828 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AnswerQuestionForTab` | `(tabID string, id string, answers []QuestionAnswer)` | desktop/app.go:1832 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AnswerRemoteTab` | `(tabID string, callID string, answers []RemoteAskAnswer) error` | desktop/remote_tab.go:851 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AppendSessionExportPage` | `(id string, page SessionExportPage) error` | desktop/session_export.go:242 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ApplyModelSettings` | `(change ModelSettingsChange) ModelSettingsResult` | desktop/model_settings_api.go:83 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ApplySessionLifecycle` | `(req SessionLifecycleRequest) (SessionLifecycleResult, error)` | desktop/session_lifecycle_api.go:46 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ApplyUpdateRequest` | `(selectedChannel string, expectedVersion string, requestID string) error` | desktop/updater_app.go:455 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Approve` | `(id string, allow bool, session bool, persist bool)` | desktop/app.go:1504 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Approve` | `(id string, allow bool, session bool, persist bool)` | desktop/app.go:1505 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ApproveRemoteTab` | `(tabID string, callID string, decision string) error` | desktop/remote_tab.go:784 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ApproveTab` | `(tabID string, id string, allow bool, session bool, persist bool)` | desktop/app.go:1512 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ApproveTab` | `(tabID string, id string, allow bool, session bool, persist bool)` | desktop/app.go:1513 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ApproveTabForTurn` | `(tabID string, turnID string, runtimeEpoch string, id string, allow bool, session bool, persist bool) error` | desktop/decision_runtime_api.go:130 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ArchiveCanonicalSession` | `(ref session.SessionRef) error` | desktop/session_workspace_api.go:361 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ArchiveSessionTarget` | `(selector SessionSelector) (SessionMutationResult, error)` | desktop/session_operations.go:171 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachDropped` | `(path string) (DroppedItem, error)` | desktop/app.go:10961 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachDroppedForTarget` | `(target ComposerTarget, path string) (DroppedItem, error)` | desktop/session_draft.go:1299 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachmentDataURL` | `(path string) (string, error)` | desktop/app.go:10938 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachmentDataURLForTarget` | `(target ComposerTarget, rel string) (string, error)` | desktop/session_draft.go:1291 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachDropped` | `(path string) (DroppedItem, error)` | desktop/attachments_app.go:397 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachDroppedForComposerTarget` | `(target ComposerTarget, path string) (DroppedItem, error)` | desktop/session_draft.go:1272 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachDroppedForTab` | `(tabID string, path string) (DroppedItem, error)` | desktop/attachments_app.go:401 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachDroppedForTarget` | `(token string, path string) (DroppedItem, error)` | desktop/attachment_targets.go:270 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachmentDataURL` | `(path string) (string, error)` | desktop/attachments_app.go:227 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachmentDataURLForComposerTarget` | `(target ComposerTarget, rel string) (string, error)` | desktop/session_draft.go:1264 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachmentDataURLForTab` | `(tabID string, path string) (string, error)` | desktop/attachments_app.go:235 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AttachmentDataURLForTarget` | `(token string, path string) (string, error)` | desktop/attachment_targets.go:262 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AuthenticateMCPServer` | `(name string) error` | desktop/mcp_oauth.go:68 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AuthorizeAndConnectMCPServer` | `(name string) error` | desktop/app.go:6962 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AvailableSubagentTools` | `() []ToolView` | desktop/app.go:7329 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AuthorizeAndConnectMCPServer` | `(name string) error` | desktop/app.go:6967 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `AvailableSubagentTools` | `() []ToolView` | desktop/app.go:7334 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `BackgroundRuntimes` | `() []BackgroundRuntimeView` | desktop/background_runtime.go:158 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Balance` | `() BalanceInfo` | desktop/app.go:6252 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `BalanceForTab` | `(tabID string) BalanceInfo` | desktop/app.go:6256 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `BeginDraftSubmission` | `(request SessionDraftSubmissionRequest) (SessionDraftSubmissionView, error)` | desktop/session_draft.go:489 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Balance` | `() BalanceInfo` | desktop/app.go:6257 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `BalanceForTab` | `(tabID string) BalanceInfo` | desktop/app.go:6261 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `BeginDraftSubmission` | `(request SessionDraftSubmissionRequest) (SessionDraftSubmissionView, error)` | desktop/session_draft.go:487 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `BeginSessionExportForTarget` | `(selector SessionSelector, tabID string, format string, title string, observation string) (SessionExportHandle, error)` | desktop/session_export.go:78 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `BotRuntimeStatus` | `() BotRuntimeStatusView` | desktop/bot_runtime_app.go:147 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Cancel` | `()` | desktop/app.go:1182 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelDraftSubmission` | `(operationID string) (SessionDraftSubmissionView, error)` | desktop/session_draft.go:1089 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelJob` | `(jobID string) (bool, error)` | desktop/app.go:6346 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelJobForTab` | `(tabID string, jobID string) (bool, error)` | desktop/app.go:6352 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Cancel` | `()` | desktop/app.go:1183 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelDraftSubmission` | `(operationID string) (SessionDraftSubmissionView, error)` | desktop/session_draft.go:1062 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelJob` | `(jobID string) (bool, error)` | desktop/app.go:6351 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelJobForTab` | `(tabID string, jobID string) (bool, error)` | desktop/app.go:6357 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelJobsForTab` | `(tabID string, jobIDs []string) (JobCancelBatchView, error)` | desktop/background_runtime.go:127 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelRemoteTab` | `(tabID string) error` | desktop/remote_tab.go:768 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelRemoteTabJobs` | `(tabID string, jobIDs []string) error` | desktop/remote_tab_commands.go:493 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelSessionExport` | `(id string) error` | desktop/session_export.go:378 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelSessionForTab` | `(tabID string) (control.CancelReceipt, error)` | desktop/turn_runtime_api.go:58 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelShellInstall` | `()` | desktop/shell_support.go:109 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTab` | `(tabID string)` | desktop/app.go:1186 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTabWithInboxItems` | `(tabID string, itemIDs []string) error` | desktop/inbox_app.go:265 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTabWithInboxItemsResult` | `(tabID string, itemIDs []string) (InboxCancelResultView, error)` | desktop/inbox_app.go:279 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTask` | `(taskID string, expectedVersion uint64, reason string, idemKey string) (taskmonitor.ControlResult, error)` | desktop/app.go:11633 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTab` | `(tabID string)` | desktop/app.go:1187 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTabWithInboxItems` | `(tabID string, itemIDs []string) error` | desktop/inbox_app.go:269 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTabWithInboxItemsResult` | `(tabID string, itemIDs []string) (InboxCancelResultView, error)` | desktop/inbox_app.go:283 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTask` | `(taskID string, expectedVersion uint64, reason string, idemKey string) (taskmonitor.ControlResult, error)` | desktop/app.go:11490 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTaskByKey` | `(req TaskActionRequest) (taskmonitor.ControlResult, error)` | desktop/task_catalog.go:266 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTaskForTab` | `(tabID string, taskID string, expectedVersion uint64, reason string, idemKey string) (taskmonitor.ControlResult, error)` | desktop/app.go:11641 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTaskForTab` | `(tabID string, taskID string, expectedVersion uint64, reason string, idemKey string) (taskmonitor.ControlResult, error)` | desktop/app.go:11498 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CancelTrySubagentProfile` | `()` | desktop/subagents_app.go:389 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Capabilities` | `() CapabilitiesView` | desktop/app.go:6843 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Capabilities` | `() CapabilitiesView` | desktop/app.go:6848 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CapabilityDiagnostics` | `(includeSessionRuntime bool) capdiag.Report` | desktop/capdiag_app.go:12 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CaptureAttachmentTarget` | `(composer ComposerTarget) (AttachmentTargetView, error)` | desktop/attachment_targets.go:81 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CaptureInboxTarget` | `(tabID string, expectedPath string) (InboxTargetView, error)` | desktop/inbox_target.go:25 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CheckRemotePlatform` | `(hostID string) error` | desktop/remote_platform.go:14 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CheckUpdate` | `(selectedChannel string) (*UpdateInfo, error)` | desktop/updater_app.go:87 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Checkpoints` | `() []CheckpointMeta` | desktop/app.go:2261 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CheckpointsForTab` | `(tabID string) []CheckpointMeta` | desktop/app.go:2265 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Checkpoints` | `() []CheckpointMeta` | desktop/app.go:2262 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CheckpointsForTab` | `(tabID string) []CheckpointMeta` | desktop/app.go:2266 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ChooseRecoveryBranch` | `(req RecoveryPreferenceRequest) error` | desktop/recovery_lineage.go:433 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CleanRecoveryLineage` | `(req RecoveryCleanupRequest) RecoveryCleanupResult` | desktop/recovery_lineage.go:497 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CleanRemoteLegacyWorkbenchData` | `(target string) error` | desktop/remote_legacy_cleanup.go:71 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -116,19 +121,19 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `CloseTab` | `(tabID string) error` | desktop/tabs.go:3026 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CloseTabWithPolicy` | `(tabID string, policy string) error` | desktop/background_runtime.go:357 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CloseTerminalForTab` | `(tabID string, sessionID string) error` | desktop/terminal.go:222 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Commands` | `() []CommandInfo` | desktop/app.go:6606 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Commands` | `() []CommandInfo` | desktop/app.go:6611 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CommitRewindForTab` | `(tabID string, planID string, turn int, scope string) RewindResultView` | desktop/rewind_tab.go:10 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CommitWorkspaceFileRevertForTab` | `(tabID string, planID string, resolution string) RewindResultView` | desktop/app.go:2445 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Compact` | `() error` | desktop/app.go:1845 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CompactForTab` | `(tabID string) error` | desktop/app.go:1851 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CommitWorkspaceFileRevertForTab` | `(tabID string, planID string, resolution string) RewindResultView` | desktop/app.go:2446 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Compact` | `() error` | desktop/app.go:1846 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CompactForTab` | `(tabID string) error` | desktop/app.go:1852 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CompactRemoteTab` | `(tabID string, instructions string) error` | desktop/remote_tab_commands.go:509 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ConfirmAction` | `(req NativeConfirmRequest) (bool, error)` | desktop/app.go:11743 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `ConfirmAction` | `(req NativeConfirmRequest) (bool, error)` | desktop/app.go:11600 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `ConfirmRemoteHostKey` | `(hostID string, accept bool) error` | desktop/remote_app.go:422 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ConfirmRemoteSecret` | `(hostID string, promptID string, secret string, accept bool) error` | desktop/remote_app.go:433 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ConnectKey` | `(apiKey string) (string, error)` | desktop/app.go:11810 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ConnectKey` | `(apiKey string) (string, error)` | desktop/app.go:11667 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ConnectRemoteHost` | `(id string) error` | desktop/remote_app.go:351 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ContextPanel` | `(tabID string) ContextPanelInfo` | desktop/tabs.go:6718 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ContextUsageForTab` | `(tabID string) ContextInfo` | desktop/app.go:6178 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ContextUsageForTab` | `(tabID string) ContextInfo` | desktop/app.go:6183 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CopySessionTarget` | `(selector SessionSelector, operationID string) (SessionCreationResult, error)` | desktop/session_workspace_api.go:534 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CopyThemePack` | `(sourceID string, newID string, newName string) (ThemePackView, error)` | desktop/theme_app.go:552 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreateBlankProject` | `(parentDir string, projectName string) (string, error)` | desktop/blank_project.go:30 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -136,17 +141,17 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `CreateForkForTab` | `(tabID string, anchor ForkAnchorView) (ForkCreationView, error)` | desktop/fork_targets.go:118 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreateForkRemoteTab` | `(tabID string, anchor ForkAnchorView) (ForkCreationView, error)` | desktop/remote_fork_targets.go:86 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreateIsolatedWorktree` | `(workspaceRoot string) (IsolatedWorktreeOpenResult, error)` | desktop/delivery_worktree.go:56 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreatePresentedBrowserPreviewForTab` | `(tabID string, toolCallID string, path string) (string, error)` | desktop/app.go:10356 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreatePresentedBrowserPreviewForTab` | `(tabID string, toolCallID string, path string) (string, error)` | desktop/app.go:10361 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreateSession` | `(workspaceID string) (session.SessionRef, error)` | desktop/session_workspace_api.go:504 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreateSubagentProfile` | `(input SubagentProfileInput) (string, error)` | desktop/subagents_app.go:73 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreateTerminalForTab` | `(tabID string, rel string, shellID string) (TerminalSessionView, error)` | desktop/terminal.go:180 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreateTopic` | `(scope string, workspaceRoot string, title string) (TopicMeta, error)` | desktop/tabs.go:6238 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreateWorkspaceBrowserPreviewForTab` | `(tabID string, rel string) (string, error)` | desktop/app.go:10340 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CreateWorkspaceBrowserPreviewForTab` | `(tabID string, rel string) (string, error)` | desktop/app.go:10345 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CredentialDiagnostics` | `(probe bool) (config.CredentialDiagnosticReport, error)` | desktop/credential_diagnostics_app.go:7 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CurrentTaskSessionID` | `() string` | desktop/app.go:11556 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DeleteInboxItem` | `(tabID string, id string) error` | desktop/inbox_app.go:396 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `CurrentTaskSessionID` | `() string` | desktop/app.go:11413 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DeleteInboxItem` | `(tabID string, id string) error` | desktop/inbox_app.go:400 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DeleteProvider` | `(name string) error` | desktop/provider_access_removal.go:22 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DeleteRecoveryCopy` | `(path string) error` | desktop/app.go:2827 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DeleteRecoveryCopy` | `(path string) error` | desktop/app.go:2828 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DeleteRemotePath` | `(hostID string, path string, recursive bool) error` | desktop/remote_app.go:766 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DeleteRemoteProjectSession` | `(hostID string, workspace string, name string) error` | desktop/remote_tab_commands.go:245 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DeleteSession` | `(path string) error` | desktop/session_legacy_lifecycle_rpc.go:11 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -158,18 +163,19 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `DesktopStartupSettings` | `() DesktopStartupSettingsView` | desktop/settings_app.go:978 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DiagnoseBotConnection` | `(id string) (BotConnectionDiagnostic, error)` | desktop/bot_connection_app.go:198 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DisableThemePack` | `() error` | desktop/theme_app.go:292 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DiscardSessionDraft` | `(draftID string, revision uint64) error` | desktop/session_draft.go:372 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DiscardSessionDraft` | `(draftID string, revision uint64) error` | desktop/session_draft.go:365 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DisconnectRemoteHost` | `(id string) error` | desktop/remote_app.go:396 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DismissSessionDraft` | `(draftID string) error` | desktop/session_draft.go:297 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `DismissSessionDraft` | `(draftID string) error` | desktop/session_draft.go:290 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EditGoalForTab` | `(tabID string, objective string, maxGoalRounds *uint64) error` | desktop/goal_app.go:64 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EditRemoteTabGoal` | `(tabID string, objective string, maxGoalRounds *uint64) error` | desktop/remote_goal_edit.go:5 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Effort` | `() EffortInfo` | desktop/app.go:9465 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EffortForTab` | `(tabID string) EffortInfo` | desktop/app.go:9469 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnqueueInboxFollowup` | `(tabID string, display string, submit string, idempotency string) (InboxReceiptView, error)` | desktop/inbox_app.go:173 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Effort` | `() EffortInfo` | desktop/app.go:9470 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EffortForTab` | `(tabID string) EffortInfo` | desktop/app.go:9474 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnqueueForAttachmentTarget` | `(token string, submissionID string, input string, display string, invocations []control.InvocationRequest, attachments []control.SubmissionAttachment) (InboxReceiptView, error)` | desktop/attachment_targets.go:369 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnqueueInboxFollowup` | `(tabID string, display string, submit string, idempotency string) (InboxReceiptView, error)` | desktop/inbox_app.go:177 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnqueueInboxFollowupForTarget` | `(target InboxTargetView, display string, submit string, invocations []InvocationRequest, key string) (InboxReceiptView, error)` | desktop/inbox_target.go:64 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnqueueInboxFollowupWithInvocations` | `(tabID string, display string, submit string, invocations []InvocationRequest, idempotency string) (InboxReceiptView, error)` | desktop/inbox_app.go:179 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnqueueInboxSteer` | `(tabID string, display string, submit string, idempotency string) (InboxReceiptView, error)` | desktop/inbox_app.go:184 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnqueueInboxSteerForTurn` | `(tabID string, turnID string, display string, submit string, idempotency string) (InboxReceiptView, error)` | desktop/inbox_app.go:192 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnqueueInboxFollowupWithInvocations` | `(tabID string, display string, submit string, invocations []InvocationRequest, idempotency string) (InboxReceiptView, error)` | desktop/inbox_app.go:183 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnqueueInboxSteer` | `(tabID string, display string, submit string, idempotency string) (InboxReceiptView, error)` | desktop/inbox_app.go:188 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnqueueInboxSteerForTurn` | `(tabID string, turnID string, display string, submit string, idempotency string) (InboxReceiptView, error)` | desktop/inbox_app.go:196 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnsureBlankSurface` | `(scope string, workspaceRoot string) (TabMeta, error)` | desktop/tabs.go:2519 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnsureBlankTab` | `(scope string, workspaceRoot string) (TabMeta, error)` | desktop/tabs.go:2562 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `EnsureRemoteProjectSessions` | `(hostID string, workspace string) ([]RemoteSessionView, error)` | desktop/remote_listing.go:385 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -186,22 +192,22 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `FetchProviderModels` | `(p ProviderView) ([]string, error)` | desktop/settings_app.go:2773 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `FinalizeWorktreeMerge` | `(request worktree.CleanupRequest) (worktree.CleanupResult, error)` | desktop/delivery_worktree.go:213 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `FinishSessionExport` | `(id string) (SessionExportResult, error)` | desktop/session_export.go:313 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Forget` | `(name string) error` | desktop/app.go:11283 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForgetForTab` | `(tabID string, name string) error` | desktop/app.go:11287 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Forget` | `(name string) error` | desktop/app.go:11140 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForgetForTab` | `(tabID string, name string) error` | desktop/app.go:11144 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForgetRemoteTab` | `(tabID string, name string) error` | desktop/remote_tab_commands.go:525 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Fork` | `(turn int) (TabMeta, error)` | desktop/app.go:2551 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForkForTab` | `(tabID string, turn int) (TabMeta, error)` | desktop/app.go:2558 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Fork` | `(turn int) (TabMeta, error)` | desktop/app.go:2552 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForkForTab` | `(tabID string, turn int) (TabMeta, error)` | desktop/app.go:2559 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForkRemoteTab` | `(tabID string, turn int, name string) error` | desktop/remote_tab_commands.go:517 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForkSession` | `(ref session.SessionRef, turnBoundary string) (session.SessionRef, error)` | desktop/session_workspace_fork.go:12 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForkSessionTarget` | `(selector SessionSelector, turnBoundary string) (session.SessionRef, error)` | desktop/session_workspace_fork.go:183 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForkTargetsForTab` | `(tabID string) (ForkTargetSetView, error)` | desktop/fork_targets.go:72 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForkTargetsRemoteTab` | `(tabID string) (ForkTargetSetView, error)` | desktop/remote_fork_targets.go:24 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForkWorktreeForTab` | `(tabID string, turn int) (ForkWorktreeResultView, error)` | desktop/app.go:2564 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ForkWorktreeForTab` | `(tabID string, turn int) (ForkWorktreeResultView, error)` | desktop/app.go:2565 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetActiveThemePack` | `() (ThemeActiveView, error)` | desktop/theme_app.go:110 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetDesktopShellStatus` | `() DesktopShellStatusView` | desktop/desktop_shell_status.go:11 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetDesktopZoomFactor` | `() float64` | desktop/zoom_factor.go:51 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetDraftContext` | `(draftID string) (SessionDraftContextView, error)` | desktop/session_draft.go:376 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetDraftSubmission` | `(operationID string) (SessionDraftSubmissionView, error)` | desktop/session_draft.go:658 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetDraftContext` | `(draftID string) (SessionDraftContextView, error)` | desktop/session_draft.go:374 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetDraftSubmission` | `(operationID string) (SessionDraftSubmissionView, error)` | desktop/session_draft.go:631 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetHistoryIndexStatus` | `() HistoryIndexStatus` | desktop/history_catalog.go:224 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetHistorySearchContext` | `(req HistorySearchContextRequest) []HistorySearchContextLine` | desktop/history_catalog.go:333 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetLegacyEmptySessionCleanupStatus` | `() (LegacyEmptySessionCleanupStatus, error)` | desktop/legacy_empty_session_cleanup.go:223 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -216,12 +222,12 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `GetSessionActivityBaseline` | `(selector SessionSelector) (SessionActivityBaseline, error)` | desktop/session_activity_baseline.go:19 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetSessionArchitectureDiagnostics` | `() (SessionArchitectureDiagnostics, error)` | desktop/session_workspace_api.go:108 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetSessionCatalogStatus` | `() SessionCatalogStatus` | desktop/session_catalog_runtime.go:686 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetSessionDraft` | `(draftID string) (SessionDraftView, error)` | desktop/session_draft.go:305 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetSessionDraft` | `(draftID string) (SessionDraftView, error)` | desktop/session_draft.go:298 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetSessionDraftState` | `(draftID string) (SessionDraftState, error)` | desktop/session_draft.go:131 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetSessionOrganization` | `(workspace SessionOrganizationWorkspace) (SessionOrganizationSnapshot, error)` | desktop/session_organization.go:136 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetSessionUpgradeStatus` | `() (SessionUpgradeStatus, error)` | desktop/session_recovery_api.go:79 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetSessionVersionState` | `(key ProjectTopicKey) SessionVersionStateView` | desktop/recovery_lineage.go:54 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetTask` | `(taskID string) (*taskmonitor.TaskSnapshot, error)` | desktop/app.go:11602 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetTask` | `(taskID string) (*taskmonitor.TaskSnapshot, error)` | desktop/app.go:11459 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetTaskCatalogStatus` | `() TaskCatalogStatus` | desktop/task_catalog.go:56 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetThemeExperience` | `() (ThemeExperienceView, error)` | desktop/theme_app.go:149 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `GetToolRecoveryForTab` | `(tabID string) (control.ToolRecoverySnapshot, error)` | desktop/tool_recovery.go:19 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -241,33 +247,33 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `HeartbeatSaveConfig` | `(update HeartbeatConfigUpdate) (HeartbeatConfigView, error)` | desktop/heartbeat.go:647 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HeartbeatSaveTasks` | `(tasks []HeartbeatTask) error` | desktop/heartbeat.go:638 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HeartbeatTriggerNow` | `(id string)` | desktop/heartbeat.go:655 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `History` | `() []HistoryMessage` | desktop/app.go:5112 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistoryCheckpointTurnsForTab` | `(tabID string) []int` | desktop/app.go:5319 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `History` | `() []HistoryMessage` | desktop/app.go:5113 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistoryCheckpointTurnsForTab` | `(tabID string) []int` | desktop/app.go:5320 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistoryContentForTab` | `(tabID string, ref HistoryContentRef, chunkIndex int) HistoryContentChunk` | desktop/history_slice.go:1179 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistoryContentForTarget` | `(selector SessionSelector, ref HistoryContentRef, chunkIndex int) (HistoryContentChunk, error)` | desktop/history_slice.go:1279 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistoryForTab` | `(tabID string) []HistoryMessage` | desktop/app.go:5287 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistoryPage` | `(beforeTurn int, limit int) HistoryPage` | desktop/app.go:5116 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistoryPageForTab` | `(tabID string, beforeTurn int, limit int) HistoryPage` | desktop/app.go:5120 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistoryForTab` | `(tabID string) []HistoryMessage` | desktop/app.go:5288 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistoryPage` | `(beforeTurn int, limit int) HistoryPage` | desktop/app.go:5117 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistoryPageForTab` | `(tabID string, beforeTurn int, limit int) HistoryPage` | desktop/app.go:5121 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistorySliceForTab` | `(tabID string, req HistorySliceRequest) HistorySlice` | desktop/history_slice.go:300 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HistorySliceForTarget` | `(selector SessionSelector, req HistorySliceRequest) (HistorySlice, error)` | desktop/session_target_history_legacy.go:50 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `HooksSettings` | `(scope string) HooksSettingsView` | desktop/hooks_settings_app.go:34 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ImportThemePack` | `(sourcePath string, replace bool) (ThemeImportResult, error)` | desktop/theme_app.go:639 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `InboxHasItems` | `(tabID string) (bool, error)` | desktop/inbox_app.go:470 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InboxSnapshot` | `(tabID string) (InboxSnapshotView, error)` | desktop/inbox_app.go:161 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InboxHasItems` | `(tabID string) (bool, error)` | desktop/inbox_app.go:474 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InboxSnapshot` | `(tabID string) (InboxSnapshotView, error)` | desktop/inbox_app.go:165 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InspectWorktreeMerge` | `(tabID string) (worktree.MergeInspection, error)` | desktop/delivery_worktree.go:134 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InstallMCPServer` | `(in MCPServerInput) (plugin.MCPInstallResult, error)` | desktop/app.go:8275 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InstallMCPServer` | `(in MCPServerInput) (plugin.MCPInstallResult, error)` | desktop/app.go:8280 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InstallPlugin` | `(source string, opts PluginInstallOptions) (string, error)` | desktop/plugin_packages_app.go:208 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InstallShellSupport` | `(id string) (ShellInstallResult, error)` | desktop/shell_support.go:86 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InterruptTurnForTab` | `(tabID string, turnID string) error` | desktop/turn_runtime_api.go:122 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InterruptTurnWithInboxItemsForTab` | `(tabID string, turnID string, itemIDs []string) (InboxCancelResultView, error)` | desktop/turn_runtime_api.go:137 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InterruptTurnForTab` | `(tabID string, turnID string) error` | desktop/turn_runtime_api.go:165 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InterruptTurnWithInboxItemsForTab` | `(tabID string, turnID string, itemIDs []string) (InboxCancelResultView, error)` | desktop/turn_runtime_api.go:180 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `InvokeExtensionAction` | `(tabID string, name string, args map[string]string) (string, error)` | desktop/extensions_app.go:93 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `IsMainWindowMaximised` | `() bool` | desktop/window_controls.go:21 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `IsolatedWorktreeAvailability` | `(workspaceRoot string) worktree.Availability` | desktop/delivery_worktree.go:48 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Jobs` | `() []JobView` | desktop/app.go:6335 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `JobsForTab` | `(tabID string) []JobView` | desktop/app.go:6339 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListDir` | `(rel string) []DirEntry` | desktop/app.go:9949 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListDirForTab` | `(tabID string, rel string) []DirEntry` | desktop/app.go:9954 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListDirForTarget` | `(target ComposerTarget, rel string) []DirEntry` | desktop/session_draft.go:1275 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Jobs` | `() []JobView` | desktop/app.go:6340 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `JobsForTab` | `(tabID string) []JobView` | desktop/app.go:6344 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListDir` | `(rel string) []DirEntry` | desktop/app.go:9954 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListDirForTab` | `(tabID string, rel string) []DirEntry` | desktop/app.go:9959 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListDirForTarget` | `(target ComposerTarget, rel string) []DirEntry` | desktop/session_draft.go:1248 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListHistorySessions` | `(req HistorySessionPageRequest) HistorySessionPage` | desktop/history_catalog.go:156 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListProjectGroups` | `(scope string, workspaceRoot string) ([]desktopGroup, error)` | desktop/project_tree_organization.go:307 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListProjectTopics` | `(req ProjectTopicPageRequest) (ProjectTopicPage, error)` | desktop/topic_state_catalog.go:11 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -275,21 +281,21 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `ListRecoveryEntries` | `(query string, cursor string, limit int) (RecoveryEntryPage, error)` | desktop/session_recovery_api.go:109 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListRemoteDir` | `(hostID string, path string) ([]RemoteDirEntry, error)` | desktop/remote_app.go:441 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListRemoteProjects` | `() ([]RemoteProjectView, error)` | desktop/remote_project_registry.go:13 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListSessionDraftSummaries` | `() ([]SessionDraftSummary, error)` | desktop/session_draft.go:357 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListSessionDraftSummaries` | `() ([]SessionDraftSummary, error)` | desktop/session_draft.go:350 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListSessions` | `() []SessionMeta` | desktop/session_history_listing.go:11 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListSessionsForTab` | `(tabID string) []SessionMeta` | desktop/session_history_listing.go:23 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTabs` | `() []TabMeta` | desktop/tabs.go:2188 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTaskEventPage` | `(req TaskEventPageRequest) TaskEventPage` | desktop/task_catalog.go:221 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTaskEventsForTab` | `(tabID string, taskID string, afterSequence int) ([]taskmonitor.TaskEvent, error)` | desktop/app.go:11606 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTaskEventsForTab` | `(tabID string, taskID string, afterSequence int) ([]taskmonitor.TaskEvent, error)` | desktop/app.go:11463 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTaskPage` | `(req TaskPageRequest) TaskPage` | desktop/task_catalog.go:137 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTasks` | `() ([]taskmonitor.TaskSnapshot, error)` | desktop/app.go:11549 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTasksForSession` | `(sessionID string) ([]taskmonitor.TaskSnapshot, error)` | desktop/app.go:11567 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTasksForTab` | `(tabID string) ([]taskmonitor.TaskSnapshot, error)` | desktop/app.go:11577 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTasks` | `() ([]taskmonitor.TaskSnapshot, error)` | desktop/app.go:11406 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTasksForSession` | `(sessionID string) ([]taskmonitor.TaskSnapshot, error)` | desktop/app.go:11424 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTasksForTab` | `(tabID string) ([]taskmonitor.TaskSnapshot, error)` | desktop/app.go:11434 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListThemePacks` | `() ([]ThemePackView, error)` | desktop/theme_app.go:61 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTrashEntries` | `(query string, cursor string, limit int) (TrashEntryPage, error)` | desktop/session_lifecycle_api.go:155 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTrashedSessions` | `() []SessionMeta` | desktop/app.go:2667 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListTrashedSessions` | `() []SessionMeta` | desktop/app.go:2668 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListWorkspaceSessions` | `(workspaceID string, queryText string, cursor string, limit int, includeArchived bool) (WorkspaceSessionPage, error)` | desktop/session_workspace_api.go:181 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListWorkspaces` | `() []WorkspaceMeta` | desktop/app.go:4757 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ListWorkspaces` | `() []WorkspaceMeta` | desktop/app.go:4758 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `LocateSessionMessageForTab` | `(tabID string, messageID string, snapshot uint64) (session.MessageLocation, error)` | desktop/session_history_service.go:131 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `LocateSessionMessageForTarget` | `(selector SessionSelector, messageID string, snapshot uint64) (session.MessageLocation, error)` | desktop/session_history_service.go:141 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `LookupInboxFollowupForTarget` | `(target InboxTargetView, key string) (InboxReceiptView, error)` | desktop/inbox_target.go:94 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -298,109 +304,116 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `MCPAppResourceDigestForTab` | `(tabID string, instanceToken string) (string, error)` | desktop/mcp_apps_app.go:105 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MCPCapabilityMatrix` | `() MCPCapabilityMatrixView` | desktop/mcp_capability_view_app.go:23 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MCPCloseAppInstanceForTab` | `(tabID string, instanceToken string) error` | desktop/mcp_apps_app.go:113 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MCPMarketplace` | `(query string) (MCPMarketplaceView, error)` | desktop/app.go:6884 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MCPMarketplaceResolve` | `(registryName string) (MCPMarketplaceEntryView, error)` | desktop/app.go:6905 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MCPMarketplace` | `(query string) (MCPMarketplaceView, error)` | desktop/app.go:6889 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MCPMarketplaceResolve` | `(registryName string) (MCPMarketplaceEntryView, error)` | desktop/app.go:6910 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MCPOpenAppInstanceForTab` | `(tabID string, server string, tool string, generation uint64, callID string, resourceURI string) (*MCPAppInstanceView, error)` | desktop/mcp_apps_app.go:40 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MCPOpenAppLinkForTab` | `(tabID string, instanceToken string, rawURL string) error` | desktop/mcp_apps_app.go:136 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MCPServers` | `() []ServerView` | desktop/app.go:6855 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Memory` | `() MemoryView` | desktop/app.go:11154 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MemoryForTab` | `(tabID string) MemoryView` | desktop/app.go:11164 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MemoryRevisions` | `(ref string) []MemoryFact` | desktop/app.go:11364 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MemoryRevisionsForTab` | `(tabID string, ref string) []MemoryFact` | desktop/app.go:11368 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MCPServers` | `() []ServerView` | desktop/app.go:6860 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Memory` | `() MemoryView` | desktop/app.go:11011 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MemoryForTab` | `(tabID string) MemoryView` | desktop/app.go:11021 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MemoryRevisions` | `(ref string) []MemoryFact` | desktop/app.go:11221 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MemoryRevisionsForTab` | `(tabID string, ref string) []MemoryFact` | desktop/app.go:11225 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MemorySuggestions` | `() MemorySuggestionsView` | desktop/memory_suggestions.go:80 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MemorySuggestionsForTab` | `(tabID string) MemorySuggestionsView` | desktop/memory_suggestions.go:86 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MergeWorktreeBack` | `(request MergeWorktreeBackRequest) (worktree.MergeResult, error)` | desktop/delivery_worktree.go:170 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Meta` | `() Meta` | desktop/app.go:6417 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MetaForTab` | `(tabID string) Meta` | desktop/app.go:6428 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Meta` | `() Meta` | desktop/app.go:6422 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MetaForTab` | `(tabID string) Meta` | desktop/app.go:6433 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MigrateDesktopPreferences` | `(language string, theme string, style string) error` | desktop/settings_preferences.go:174 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MinimiseMainWindow` | `()` | desktop/window_controls.go:4 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `MkdirRemote` | `(hostID string, path string) error` | desktop/remote_app.go:750 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Models` | `() []ModelInfo` | desktop/app.go:8956 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Models` | `() []ModelInfo` | desktop/app.go:8961 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ModelsForDraft` | `(draftID string) []ModelInfo` | desktop/model_catalog.go:60 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ModelsForTab` | `(tabID string) []ModelInfo` | desktop/model_catalog.go:37 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MoveInboxItem` | `(tabID string, id string, toIndex int) error` | desktop/inbox_app.go:409 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MoveInboxItem` | `(tabID string, id string, toIndex int) error` | desktop/inbox_app.go:413 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MoveSessionTarget` | `(selector SessionSelector, workspaceID string, beforeSessionID string) (SessionMutationResult, error)` | desktop/session_operations.go:275 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MoveWorkspace` | `(workspaceID string, beforeWorkspaceID string) error` | desktop/session_workspace_api.go:493 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `MoveWorkspaceSession` | `(workspaceID string, sessionID string, beforeSessionID string) error` | desktop/session_workspace_api.go:371 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `NeedsOnboarding` | `() bool` | desktop/app.go:11791 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `NeedsOnboarding` | `() bool` | desktop/app.go:11648 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `NewSession` | `() error` | desktop/session_new.go:10 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `NewSessionForTab` | `(tabID string) error` | desktop/session_new.go:16 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenChannelSessionForTab` | `(tabID string, path string) ([]HistoryMessage, error)` | desktop/app.go:3527 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenChannelSessionPageForTab` | `(tabID string, path string, limit int) (HistoryPage, error)` | desktop/app.go:3562 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenChannelSessionForTab` | `(tabID string, path string) ([]HistoryMessage, error)` | desktop/app.go:3528 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenChannelSessionPageForTab` | `(tabID string, path string, limit int) (HistoryPage, error)` | desktop/app.go:3563 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenChannelTranscriptSessionForTab` | `(tabID string, path string) (HistorySwitchPhases, error)` | desktop/transcript_api.go:21 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenDownloadPage` | `()` | desktop/updater_app.go:132 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenGlobalTab` | `(topicID string) (TabMeta, error)` | desktop/tabs.go:2405 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenLocalPath` | `(path string) error` | desktop/open_local_path.go:141 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenLocalPathInExternalOpener` | `(path string, id string) error` | desktop/external_opener.go:262 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenPresentedPathForTab` | `(tabID string, toolCallID string, path string) error` | desktop/app.go:10427 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenPresentedPathForTab` | `(tabID string, toolCallID string, path string) error` | desktop/app.go:10432 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenProjectTab` | `(workspaceRoot string, topicID string) (TabMeta, error)` | desktop/tabs.go:2238 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenReferencePathForTab` | `(tabID string, path string) error` | desktop/chat_file_reference.go:217 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenRemoteProjectTab` | `(hostID string, workspace string, opts RemoteTabOpenOptions) (TabMeta, error)` | desktop/remote_projects.go:318 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenRemoteWorkspace` | `(hostID string, workspace string) error` | desktop/remote_app.go:811 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenSession` | `(ref session.SessionRef) (HistoryPage, error)` | desktop/session_workspace_api.go:692 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenSessionDraft` | `(workspaceID string) (SessionDraftView, error)` | desktop/session_draft.go:238 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenSessionDraftForTarget` | `(scope string, workspaceRoot string) (SessionDraftView, error)` | desktop/session_draft.go:267 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenSessionDraft` | `(workspaceID string) (SessionDraftView, error)` | desktop/session_draft.go:231 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenSessionDraftForTarget` | `(scope string, workspaceRoot string) (SessionDraftView, error)` | desktop/session_draft.go:260 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenTaskSessionByKey` | `(req TaskOpenRequest) (taskmonitor.ControlResult, error)` | desktop/task_catalog.go:283 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenTaskSessionForTab` | `(tabID string, taskID string) (taskmonitor.ControlResult, error)` | desktop/app.go:11660 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenTaskSessionForTab` | `(tabID string, taskID string) (taskmonitor.ControlResult, error)` | desktop/app.go:11517 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenTopicSession` | `(scope string, workspaceRoot string, topicID string, sessionPath string) (TabMeta, error)` | desktop/tabs.go:2425 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenUserConfigPath` | `() error` | desktop/settings_app.go:1003 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenWorkspaceInExternalOpenerForTab` | `(tabID string, id string) error` | desktop/external_opener.go:213 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenWorkspacePathForTab` | `(tabID string, rel string) error` | desktop/app.go:10458 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `OpenWorkspacePathForTab` | `(tabID string, rel string) error` | desktop/app.go:10463 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PauseGoalForTab` | `(tabID string) bool` | desktop/goal_app.go:114 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PauseRemoteTabGoal` | `(tabID string) error` | desktop/remote_tab_commands.go:479 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PendingPromptIdentitiesForTab` | `(tabID string) ([]PromptIdentityView, error)` | desktop/decision_runtime_api.go:43 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PermissionSnapshotForTab` | `(tabID string) (control.PermissionSnapshot, error)` | desktop/app.go:6495 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PermissionSnapshotForTab` | `(tabID string) (control.PermissionSnapshot, error)` | desktop/app.go:6500 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PickBlankProjectParent` | `() (string, error)` | desktop/blank_project.go:12 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PickExportFile` | `(defaultFilename string, mimeType string) (string, error)` | desktop/app.go:10661 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PickPluginFolder` | `() (string, error)` | desktop/app.go:7990 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PickExportFile` | `(defaultFilename string, mimeType string) (string, error)` | desktop/app.go:10618 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PickPluginFolder` | `() (string, error)` | desktop/app.go:7995 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PickRemoteIdentityFile` | `() (string, error)` | desktop/remote_dialog.go:11 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PickSkillFolder` | `() (string, error)` | desktop/app.go:7972 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PickSkillFolder` | `() (string, error)` | desktop/app.go:7977 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PickThemeBackground` | `() (string, error)` | desktop/theme_app.go:753 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PickWorkspace` | `() (string, error)` | desktop/app.go:4695 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PickWorkspace` | `() (string, error)` | desktop/app.go:4696 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `PinFileForTab` | `(tabID string, relPath string) (PinnedFileInfo, error)` | desktop/pinned_files.go:401 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PlanPluginInstall` | `(source string, opts PluginInstallOptions) (string, error)` | desktop/plugin_packages_app.go:203 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Platform` | `() string` | desktop/app.go:502 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Platform` | `() string` | desktop/app.go:505 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PluginDoctor` | `(name string) PluginView` | desktop/plugin_packages_app.go:304 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Plugins` | `() []PluginView` | desktop/plugin_packages_app.go:94 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PollBotConnectionInstall` | `(installID string) (BotInstallPollResult, error)` | desktop/bot_connection_app.go:140 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PrepareWorktreeMerge` | `(tabID string) (worktree.MergeInspection, error)` | desktop/delivery_worktree.go:163 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PreviewRecoveryEntry` | `(id string) (HistoryPage, error)` | desktop/session_recovery_api.go:195 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PreviewRewindForTab` | `(tabID string, turn int, scope string) RewindPlanView` | desktop/app.go:2396 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PreviewSession` | `(path string) ([]HistoryMessage, error)` | desktop/app.go:4172 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PreviewWorkspaceFileRevertForTab` | `(tabID string, path string) RewindPlanView` | desktop/app.go:2423 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PreviewRewindForTab` | `(tabID string, turn int, scope string) RewindPlanView` | desktop/app.go:2397 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PreviewSession` | `(path string) ([]HistoryMessage, error)` | desktop/app.go:4173 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PreviewWorkspaceFileRevertForTab` | `(tabID string, path string) RewindPlanView` | desktop/app.go:2424 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PurgeCanonicalSession` | `(ref session.SessionRef) error` | desktop/session_purge.go:16 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PurgeRecoveryCopy` | `(path string) error` | desktop/app.go:3321 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PurgeRecoveryCopy` | `(path string) error` | desktop/app.go:3322 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `PurgeTrashedSession` | `(path string) error` | desktop/session_legacy_lifecycle_rpc.go:70 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `QuerySessionTakeover` | `(tabID string) (*SessionTakeoverView, error)` | desktop/session_takeover.go:215 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadFile` | `(rel string) FilePreview` | desktop/app.go:10081 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadFileForTab` | `(tabID string, rel string) FilePreview` | desktop/app.go:10086 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadInboxItem` | `(tabID string, id string) (InboxEnvelopeView, error)` | desktop/inbox_app.go:365 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadPresentedFileForTab` | `(tabID string, toolCallID string, path string) FilePreview` | desktop/app.go:10260 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadPresentedFileSourceForTab` | `(tabID string, toolCallID string, path string) FilePreview` | desktop/app.go:10268 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadPresentedTextPageForTab` | `(tabID string, toolCallID string, path string, offset int64, expectedVersion string) (PresentedTextPage, error)` | desktop/app.go:10279 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadDraftImageForTab` | `(tabID string, draftID string) (string, error)` | desktop/attachments_app.go:305 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadDraftImageForTarget` | `(token string, draftID string) (string, error)` | desktop/attachment_targets.go:233 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadFile` | `(rel string) FilePreview` | desktop/app.go:10086 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadFileForTab` | `(tabID string, rel string) FilePreview` | desktop/app.go:10091 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadInboxItem` | `(tabID string, id string) (InboxEnvelopeView, error)` | desktop/inbox_app.go:369 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadPresentedFileForTab` | `(tabID string, toolCallID string, path string) FilePreview` | desktop/app.go:10265 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadPresentedFileSourceForTab` | `(tabID string, toolCallID string, path string) FilePreview` | desktop/app.go:10273 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadPresentedTextPageForTab` | `(tabID string, toolCallID string, path string, offset int64, expectedVersion string) (PresentedTextPage, error)` | desktop/app.go:10284 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadReferenceFileForTab` | `(tabID string, path string) FilePreview` | desktop/chat_file_reference.go:191 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadReferenceFileSourceForTab` | `(tabID string, path string) FilePreview` | desktop/chat_file_reference.go:199 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadRemoteFile` | `(hostID string, path string) (RemoteFilePreview, error)` | desktop/remote_app.go:449 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadSessionAttachmentForTab` | `(tabID string, digest string, offset int64) (SessionHistoryContentChunk, error)` | desktop/attachments_app.go:331 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadSessionExportChunk` | `(id string, offset int64) (SessionExportChunk, error)` | desktop/session_export.go:205 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReadSessionHistory` | `(ref session.SessionRef, cursor string, limit int) (HistoryPage, error)` | desktop/session_workspace_api.go:666 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RebindDraftImageForTarget` | `(token string, draftID string) (DraftImageView, error)` | desktop/attachment_targets.go:278 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RebuildHistoryIndex` | `() error` | desktop/history_catalog.go:383 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RebuildSessionCatalog` | `() error` | desktop/session_catalog_rebuild.go:22 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RebuildTaskCatalog` | `() error` | desktop/task_catalog.go:291 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReclaimRemoteTabSession` | `(tabID string) error` | desktop/remote_tab.go:641 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReconcileRecoveryVersions` | `(key ProjectTopicKey) error` | desktop/recovery_lineage.go:90 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReconnectMCPServer` | `(name string) error` | desktop/app.go:8523 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReconnectMCPServer` | `(name string) error` | desktop/app.go:8528 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RecordUIPerf` | `(signals map[string]string)` | desktop/uiperf_app.go:32 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RecoveryCheckpointEnabled` | `() bool` | desktop/app.go:1563 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RecoveryCheckpointEnabledTab` | `(_ string) bool` | desktop/app.go:1568 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RefreshInboxItem` | `(tabID string, id string) error` | desktop/inbox_app.go:448 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RefreshSkills` | `() error` | desktop/app.go:8071 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RecoveryCheckpointEnabled` | `() bool` | desktop/app.go:1564 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RecoveryCheckpointEnabledTab` | `(_ string) bool` | desktop/app.go:1569 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RefreshInboxItem` | `(tabID string, id string) error` | desktop/inbox_app.go:452 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RefreshSkills` | `() error` | desktop/app.go:8076 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RegisterNavigationIntent` | `(token string) error` | desktop/navigation_intent.go:20 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReloadCommands` | `() error` | desktop/app.go:8086 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReleaseAttachmentTarget` | `(token string)` | desktop/attachment_targets.go:132 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReleaseDraftImageForTab` | `(tabID string, draftID string) error` | desktop/attachments_app.go:373 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReleaseDraftImageForTarget` | `(token string, draftID string) error` | desktop/attachment_targets.go:297 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReloadCommands` | `() error` | desktop/app.go:8091 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReloadRuntime` | `(tabID string) error` | desktop/settings_app.go:2066 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReloadSettings` | `() error` | desktop/settings_app.go:2960 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReloadUserConfig` | `() (DesktopStartupSettingsView, error)` | desktop/settings_app.go:1018 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Remember` | `(scope string, note string) (string, error)` | desktop/app.go:11255 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RememberForTab` | `(tabID string, scope string, note string) (string, error)` | desktop/app.go:11259 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Remember` | `(scope string, note string) (string, error)` | desktop/app.go:11112 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RememberForTab` | `(tabID string, scope string, note string) (string, error)` | desktop/app.go:11116 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoteConnectionStatuses` | `() []RemoteConnectionStatusView` | desktop/remote_app.go:414 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoteForwards` | `(hostID string) ([]RemoteForwardView, error)` | desktop/remote_app.go:774 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoteHosts` | `() ([]RemoteHostView, error)` | desktop/remote_app.go:301 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -426,7 +439,7 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `RemoteTranscriptPageForTab` | `(tabID string, req transcript.PageRequest) (transcript.Snapshot, error)` | desktop/remote_transcript_api.go:138 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoteTranscriptReplayForTab` | `(tabID string, req control.TranscriptReplayRequest) (control.TranscriptReplay, error)` | desktop/remote_transcript_api.go:177 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoteTranscriptSnapshotForTab` | `(tabID string, req transcript.PageRequest) (RemoteTranscriptSnapshot, error)` | desktop/remote_transcript_api.go:129 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoveMCPServer` | `(name string) error` | desktop/app.go:8461 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoveMCPServer` | `(name string) error` | desktop/app.go:8466 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemovePermissionRule` | `(list string, rule string) error` | desktop/settings_app.go:2951 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemovePlugin` | `(name string) error` | desktop/plugin_packages_app.go:227 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoveProviderAccess` | `(name string) error` | desktop/provider_access_removal.go:28 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -434,14 +447,14 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `RemoveRemoteForward` | `(hostID string, forwardID string) error` | desktop/remote_app.go:790 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoveRemoteHost` | `(id string) error` | desktop/remote_app.go:325 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoveRemoteProject` | `(hostID string, workspace string) error` | desktop/remote_project_registry.go:125 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoveSkillPath` | `(path string) error` | desktop/app.go:8031 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoveWorkspace` | `(dir string) error` | desktop/app.go:4781 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoveSkillPath` | `(path string) error` | desktop/app.go:8036 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RemoveWorkspace` | `(dir string) error` | desktop/app.go:4782 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RenameCanonicalSession` | `(ref session.SessionRef, title string) error` | desktop/session_workspace_api.go:723 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RenameProject` | `(workspaceRoot string, title string) error` | desktop/tabs.go:6272 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RenameProviderConnections` | `(names []string, displayName string) error` | desktop/settings_app.go:2392 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RenameRemotePath` | `(hostID string, oldPath string, newPath string) error` | desktop/remote_app.go:758 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RenameRemoteProjectSession` | `(hostID string, workspace string, name string, title string) error` | desktop/remote_tab_commands.go:16 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RenameSession` | `(path string, title string) error` | desktop/app.go:3368 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RenameSession` | `(path string, title string) error` | desktop/app.go:3369 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RenameSessionHead` | `(path string, headID string, name string) error` | desktop/session_heads.go:240 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RenameSessionTarget` | `(selector SessionSelector, title string) (SessionMutationResult, error)` | desktop/session_operations.go:95 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RenameTerminalForTab` | `(tabID string, sessionID string, title string) error` | desktop/terminal.go:233 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -453,26 +466,26 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `ReorderTopics` | `(scope string, workspaceRoot string, orderedTopicIDs []string) error` | desktop/project_tree_organization.go:271 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RepairCredentials` | `(dryRun bool) (config.CredentialDiagnosticReport, error)` | desktop/credential_diagnostics_app.go:13 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReplayPendingPromptIdentitiesForTab` | `(tabID string) ([]PromptIdentityView, error)` | desktop/decision_runtime_api.go:65 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReplayPendingPrompts` | `()` | desktop/app.go:1577 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReplayPendingPromptsForTab` | `(tabID string)` | desktop/app.go:1596 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReplayPendingPrompts` | `()` | desktop/app.go:1578 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReplayPendingPromptsForTab` | `(tabID string)` | desktop/app.go:1597 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReplayRemoteTabPrompts` | `(tabID string) (json.RawMessage, error)` | desktop/remote_tab_commands.go:513 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReportCrash` | `(kind string, detail string) error` | desktop/crash_app.go:318 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReportCrash` | `(kind string, detail string) error` | desktop/crash_app.go:343 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ReportDesktopWebViewReady` | `()` | desktop/app.go:949 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RequeueTaskByKey` | `(req TaskActionRequest) (taskmonitor.ControlResult, error)` | desktop/task_catalog.go:275 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RequeueTaskForTab` | `(tabID string, taskID string, expectedVersion uint64, idemKey string) (taskmonitor.ControlResult, error)` | desktop/app.go:11652 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RequeueTaskForTab` | `(tabID string, taskID string, expectedVersion uint64, idemKey string) (taskmonitor.ControlResult, error)` | desktop/app.go:11509 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResetProviderPresetAccess` | `(id string) error` | desktop/settings_app.go:2677 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResetThemePack` | `() error` | desktop/theme_app.go:316 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResizeTerminalForTab` | `(tabID string, sessionID string, cols int, rows int) error` | desktop/terminal.go:211 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolveChatFileReferencesForTab` | `(tabID string, turnKey string, candidates []ChatFileReferenceRequest) ChatFileReferenceResult` | desktop/chat_file_reference.go:51 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolveMarkdownImageForTab` | `(tabID string, source string) MarkdownImageView` | desktop/markdown_image_resolver.go:28 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolvePlanDecision` | `(id string, action string) error` | desktop/app.go:1521 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolvePlanDecisionTab` | `(tabID string, id string, action string) error` | desktop/app.go:1531 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolvePlanDecision` | `(id string, action string) error` | desktop/app.go:1522 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolvePlanDecisionTab` | `(tabID string, id string, action string) error` | desktop/app.go:1532 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolvePlanDecisionTabForTurn` | `(tabID string, turnID string, runtimeEpoch string, id string, action string) error` | desktop/decision_runtime_api.go:139 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolvePresentedPathForTab` | `(tabID string, toolCallID string, path string) (string, error)` | desktop/app.go:10437 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolvePresentedPathForTab` | `(tabID string, toolCallID string, path string) (string, error)` | desktop/app.go:10442 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolvePromptForSession` | `(target InteractionTargetView, answer PromptAnswerView) error` | desktop/decision_runtime_api.go:93 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolvePromptForTab` | `(tabID string, promptID string, turnID string, runtimeEpoch string, kind string, answer PromptAnswerView) error` | desktop/decision_runtime_api.go:70 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolveRecovery` | `(id string, action string, feedback string) error` | desktop/app.go:1541 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolveRecoveryTab` | `(tabID string, id string, action string, feedback string) error` | desktop/app.go:1546 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolveRecovery` | `(id string, action string, feedback string) error` | desktop/app.go:1542 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolveRecoveryTab` | `(tabID string, id string, action string, feedback string) error` | desktop/app.go:1547 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolveRecoveryTabForTurn` | `(tabID string, turnID string, runtimeEpoch string, id string, action string, feedback string) error` | desktop/decision_runtime_api.go:147 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolveReferencePathForTab` | `(tabID string, path string) (string, error)` | desktop/chat_file_reference.go:209 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolveRemotePresentedPathForTab` | `(tabID string, hostID string, toolCallID string, remotePath string) (string, error)` | desktop/remote_app.go:487 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -482,58 +495,63 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `ResolveToolRecoveryForTab` | `(tabID string, req control.ToolRecoveryRequest) (control.ToolRecoverySnapshot, error)` | desktop/tool_recovery.go:30 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResolveWorkspacePathForTab` | `(tabID string, rel string) (string, error)` | desktop/workspace_path.go:12 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestartApplication` | `() error` | desktop/zoom_factor.go:81 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreArchivedMemory` | `(archivePath string) (MemoryFact, error)` | desktop/app.go:11311 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreArchivedMemoryForTab` | `(tabID string, archivePath string) (MemoryFact, error)` | desktop/app.go:11315 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreArchivedMemory` | `(archivePath string) (MemoryFact, error)` | desktop/app.go:11168 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreArchivedMemoryForTab` | `(tabID string, archivePath string) (MemoryFact, error)` | desktop/app.go:11172 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreCanonicalSession` | `(ref session.SessionRef) error` | desktop/session_workspace_api.go:366 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreGraphiteAppearance` | `() error` | desktop/theme_app.go:302 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreMemoryRevision` | `(ref string, revision int) (MemoryFact, error)` | desktop/app.go:11394 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreMemoryRevisionForTab` | `(tabID string, ref string, revision int) (MemoryFact, error)` | desktop/app.go:11398 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreMemoryRevision` | `(ref string, revision int) (MemoryFact, error)` | desktop/app.go:11251 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreMemoryRevisionForTab` | `(tabID string, ref string, revision int) (MemoryFact, error)` | desktop/app.go:11255 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreRecoveryEntry` | `(id string, operationID string) (SessionRestoreResult, error)` | desktop/session_recovery_api.go:230 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreSession` | `(path string) error` | desktop/app.go:3247 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreSessionDraft` | `() (*SessionDraftView, error)` | desktop/session_draft.go:275 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreSession` | `(path string) error` | desktop/app.go:3248 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreSessionDraft` | `() (*SessionDraftView, error)` | desktop/session_draft.go:268 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RestoreSessionTarget` | `(selector SessionSelector) (SessionMutationResult, error)` | desktop/session_operations.go:211 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeDraftSubmission` | `(operationID string, expectedRevision uint64) (SessionDraftSubmissionView, error)` | desktop/session_draft.go:158 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeGoalForTab` | `(tabID string) bool` | desktop/goal_app.go:94 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeRemoteTabGoal` | `(tabID string) error` | desktop/remote_tab_commands.go:486 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeSession` | `(path string) ([]HistoryMessage, error)` | desktop/app.go:3438 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeSessionForTab` | `(tabID string, path string) ([]HistoryMessage, error)` | desktop/app.go:3453 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeSessionPage` | `(path string, limit int) (HistoryPage, error)` | desktop/app.go:3442 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeSessionPageForTab` | `(tabID string, path string, limit int) (HistoryPage, error)` | desktop/app.go:3446 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeSession` | `(path string) ([]HistoryMessage, error)` | desktop/app.go:3439 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeSessionForTab` | `(tabID string, path string) ([]HistoryMessage, error)` | desktop/app.go:3454 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeSessionPage` | `(path string, limit int) (HistoryPage, error)` | desktop/app.go:3443 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeSessionPageForTab` | `(tabID string, path string, limit int) (HistoryPage, error)` | desktop/app.go:3447 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ResumeTranscriptSessionForTab` | `(tabID string, path string) (HistorySwitchPhases, error)` | desktop/transcript_api.go:13 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RetryAuthenticationForTab` | `(tabID string) (control.AuthenticationState, error)` | desktop/authentication_app.go:12 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RetryInboxItem` | `(tabID string, id string) error` | desktop/inbox_app.go:435 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RetryInboxItem` | `(tabID string, id string) error` | desktop/inbox_app.go:439 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RetryLegacyEmptySessionCleanup` | `() (LegacyEmptySessionCleanupStatus, error)` | desktop/legacy_empty_session_cleanup.go:259 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RetryModelSettingsApplication` | `(tabID string) ModelSettingsResult` | desktop/model_settings_api.go:552 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RetrySessionRecovery` | `(req RecoveryPreferenceRequest) error` | desktop/recovery_lineage.go:186 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevealBackgroundRuntime` | `(tabID string) (TabMeta, error)` | desktop/background_runtime.go:215 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevealPath` | `(path string) error` | desktop/app.go:10486 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevealPresentedPathForTab` | `(tabID string, toolCallID string, path string) error` | desktop/app.go:10441 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevealPath` | `(path string) error` | desktop/app.go:10491 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevealPresentedPathForTab` | `(tabID string, toolCallID string, path string) error` | desktop/app.go:10446 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevealReferencePathForTab` | `(tabID string, path string) error` | desktop/chat_file_reference.go:225 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevealWorkspacePathForTab` | `(tabID string, rel string) error` | desktop/app.go:10467 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevealWorkspacePathForTab` | `(tabID string, rel string) error` | desktop/app.go:10472 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevealWorkspaceWriterForTab` | `(tabID string) (TabMeta, error)` | desktop/background_runtime.go:345 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevokePermissionGrantForTab` | `(tabID string, scope string, target string, expectedRevision uint64) (control.PermissionSnapshot, error)` | desktop/app.go:6562 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevokeWorkspaceBrowserPreview` | `(rawURL string)` | desktop/app.go:10386 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevokeWorkspaceMediaPreview` | `(resourceURL string)` | desktop/app.go:10408 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Rewind` | `(turn int, scope string) error` | desktop/app.go:2379 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RewindForTab` | `(tabID string, turn int, scope string) error` | desktop/app.go:2387 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevokePermissionGrantForTab` | `(tabID string, scope string, target string, expectedRevision uint64) (control.PermissionSnapshot, error)` | desktop/app.go:6567 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevokeWorkspaceBrowserPreview` | `(rawURL string)` | desktop/app.go:10391 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RevokeWorkspaceMediaPreview` | `(resourceURL string)` | desktop/app.go:10413 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Rewind` | `(turn int, scope string) error` | desktop/app.go:2380 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RewindForTab` | `(tabID string, turn int, scope string) error` | desktop/app.go:2388 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RewindRemoteTab` | `(tabID string, checkpointID string, scope string) error` | desktop/remote_tab.go:884 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RunShellForTab` | `(tabID string, command string) error` | desktop/app.go:1035 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `RuntimeDoctor` | `() RuntimeDoctorReport` | desktop/rebuild_runtime.go:71 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SanitizeMarkdownSVG` | `(content string) MarkdownSVGView` | desktop/markdown_svg_sanitize.go:47 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveClipboardImage` | `() (string, error)` | desktop/app.go:10646 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveClipboardImageForTarget` | `(target ComposerTarget) (string, error)` | desktop/session_draft.go:1259 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveDoc` | `(path string, body string) (string, error)` | desktop/app.go:11426 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveDocForTab` | `(tabID string, path string, body string) (string, error)` | desktop/app.go:11430 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveExportFile` | `(path string, payload string, base64Encoded bool) error` | desktop/app.go:10685 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveExportImageFiles` | `(path string, payloads []string) error` | desktop/app.go:10710 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveClipboardImage` | `() (string, error)` | desktop/attachments_app.go:187 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveClipboardImageForComposerTarget` | `(target ComposerTarget) (string, error)` | desktop/session_draft.go:1232 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveClipboardImageForTab` | `(tabID string) (string, error)` | desktop/attachments_app.go:196 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveClipboardImageForTarget` | `(token string) (string, error)` | desktop/attachment_targets.go:253 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveDoc` | `(path string, body string) (string, error)` | desktop/app.go:11283 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveDocForTab` | `(tabID string, path string, body string) (string, error)` | desktop/app.go:11287 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveExportFile` | `(path string, payload string, base64Encoded bool) error` | desktop/app.go:10642 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveExportImageFiles` | `(path string, payloads []string) error` | desktop/app.go:10667 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveHooksSettings` | `(scope string, hooks []HookConfigView) error` | desktop/hooks_settings_app.go:60 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveHooksSettingsForRoot` | `(scope string, projectRoot string, hooks []HookConfigView) error` | desktop/hooks_settings_app.go:64 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveLocalPathAs` | `(path string) (string, error)` | desktop/external_opener.go:292 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `SavePastedFile` | `(name string, dataURL string) (string, error)` | desktop/app.go:10653 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePastedFileForTarget` | `(target ComposerTarget, name string, dataURL string) (string, error)` | desktop/session_draft.go:1243 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePastedImage` | `(dataURL string) (string, error)` | desktop/app.go:10638 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePastedImageForTarget` | `(target ComposerTarget, dataURL string) (string, error)` | desktop/session_draft.go:1226 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePresentedPathAsForTab` | `(tabID string, toolCallID string, path string) (string, error)` | desktop/app.go:10449 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePastedFile` | `(name string, dataURL string) (string, error)` | desktop/attachments_app.go:208 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePastedFileForComposerTarget` | `(target ComposerTarget, name string, dataURL string) (string, error)` | desktop/session_draft.go:1216 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePastedFileForTab` | `(tabID string, name string, dataURL string) (string, error)` | desktop/attachments_app.go:217 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePastedFileForTarget` | `(token string, name string, dataURL string) (string, error)` | desktop/attachment_targets.go:244 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePastedImage` | `(dataURL string) (string, error)` | desktop/attachments_app.go:167 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePastedImageForComposerTarget` | `(target ComposerTarget, dataURL string) (string, error)` | desktop/session_draft.go:1199 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePastedImageForTab` | `(tabID string, dataURL string) (string, error)` | desktop/attachments_app.go:176 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SavePresentedPathAsForTab` | `(tabID string, toolCallID string, path string) (string, error)` | desktop/app.go:10454 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveProvider` | `(p ProviderView) error` | desktop/settings_app.go:2412 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveProviderKey` | `(apiKeyEnv string, value string) (string, error)` | desktop/settings_app.go:2926 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveProviderModelCatalogs` | `(updates []ProviderModelCatalogUpdate) ([]string, error)` | desktop/settings_app.go:2518 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -541,19 +559,19 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `SaveReferencePathAsForTab` | `(tabID string, path string) (string, error)` | desktop/chat_file_reference.go:233 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveRemoteFileAs` | `(hostID string, remotePath string) (string, error)` | desktop/remote_app.go:459 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveRemotePresentedFileAs` | `(tabID string, hostID string, toolCallID string, remotePath string) (string, error)` | desktop/remote_app.go:466 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveSessionDraft` | `(request SessionDraftSaveRequest) (SessionDraftSaveResult, error)` | desktop/session_draft.go:313 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveSessionDraft` | `(request SessionDraftSaveRequest) (SessionDraftSaveResult, error)` | desktop/session_draft.go:306 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveSessionGroups` | `(scope string, workspaceRoot string, groups []desktopGroup) error` | desktop/project_tree_organization.go:327 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveSessionGroupsVersioned` | `(scope string, workspaceRoot string, expectedRevision uint64, groups []desktopGroup) (ProjectGroupsSnapshot, error)` | desktop/project_tree_organization.go:336 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveSessionMarkdownForTab` | `(tabID string, path string, title string) error` | desktop/session_markdown_export.go:19 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveThemePack` | `(input ThemeSaveInput) (ThemePackView, error)` | desktop/theme_app.go:386 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveWindowState` | `(state DesktopWindowState) error` | desktop/window_state.go:127 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveWorkspacePathAsForTab` | `(tabID string, rel string) (string, error)` | desktop/app.go:10477 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ScanPromptHistory` | `(rawRequest string) (PromptHistoryResult, error)` | desktop/app.go:4221 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SaveWorkspacePathAsForTab` | `(tabID string, rel string) (string, error)` | desktop/app.go:10482 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ScanPromptHistory` | `(rawRequest string) (PromptHistoryResult, error)` | desktop/app.go:4222 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ScanRemoteLegacyWorkbenchData` | `() LegacyWorkbenchDataView` | desktop/remote_legacy_cleanup.go:38 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ScanSSHConfig` | `() ([]RemoteHostInput, error)` | desktop/remote_app.go:343 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SearchFileRefs` | `(query string) []DirEntry` | desktop/app.go:10006 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SearchFileRefsForTab` | `(tabID string, query string) []DirEntry` | desktop/app.go:10011 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SearchFileRefsForTarget` | `(target ComposerTarget, query string) []DirEntry` | desktop/session_draft.go:1283 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SearchFileRefs` | `(query string) []DirEntry` | desktop/app.go:10011 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SearchFileRefsForTab` | `(tabID string, query string) []DirEntry` | desktop/app.go:10016 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SearchFileRefsForTarget` | `(target ComposerTarget, query string) []DirEntry` | desktop/session_draft.go:1256 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SearchHistoryContent` | `(req HistorySearchRequest) HistorySearchPage` | desktop/history_catalog.go:272 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SearchHistoryContentForTarget` | `(selector SessionSelector, query string, cursor string, limit int) (HistorySearchPage, error)` | desktop/session_target_history_legacy.go:126 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SearchSessionHistoryForTab` | `(tabID string, textQuery string, cursor string, limit int) (session.SearchHistoryPage, error)` | desktop/session_history_service.go:110 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -570,19 +588,19 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `SetActiveSessionVersion` | `(req RecoveryPreferenceRequest) error` | desktop/recovery_lineage.go:114 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetActiveTab` | `(tabID string) error` | desktop/tab_selection.go:7 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetAgentParams` | `(temperature float64, maxSteps int, plannerMaxSteps int, systemPrompt string) error` | desktop/settings_app.go:3171 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetAgentPreset` | `(preset string) error` | desktop/app.go:9664 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetAgentPresetForTab` | `(tabID string, preset string) error` | desktop/app.go:9671 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetAutoApproveTools` | `(on bool)` | desktop/app.go:6449 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetAgentPreset` | `(preset string) error` | desktop/app.go:9669 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetAgentPresetForTab` | `(tabID string, preset string) error` | desktop/app.go:9676 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetAutoApproveTools` | `(on bool)` | desktop/app.go:6454 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetAutoPlan` | `(mode string) error` | desktop/settings_app.go:2226 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetBotConnectionToolApprovalMode` | `(connID string, mode string) error` | desktop/settings_app.go:3097 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetBotDingtalkToolApprovalMode` | `(mode string) error` | desktop/settings_app.go:3129 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetBotSecret` | `(envName string, value string) error` | desktop/settings_app.go:3144 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetBotSettings` | `(b BotSettingsView) error` | desktop/settings_app.go:3009 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetBypass` | `(on bool)` | desktop/app.go:6455 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetBypass` | `(on bool)` | desktop/app.go:6460 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetCloseBehavior` | `(mode string) error` | desktop/settings_preferences.go:10 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetCollaborationModeForTab` | `(tabID string, mode string) error` | desktop/app.go:1777 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetCollaborationModeForTab` | `(tabID string, mode string) error` | desktop/app.go:1778 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetCompactRatio` | `(ratio float64) error` | desktop/settings_app.go:3181 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetComposerProfileForTab` | `(tabID string, collaborationMode string, toolApprovalMode string, goal string) ([]string, error)` | desktop/app.go:1721 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetComposerProfileForTab` | `(tabID string, collaborationMode string, toolApprovalMode string, goal string) ([]string, error)` | desktop/app.go:1722 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetConnectionKey` | `(name string, value string) (string, error)` | desktop/settings_app.go:3274 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetDefaultAutoRecoveryCheckpoint` | `(_ bool) error` | desktop/settings_app.go:2240 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetDefaultModel` | `(ref string) error` | desktop/settings_app.go:2106 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -599,25 +617,25 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `SetDesktopUpdateChannel` | `(channel string) error` | desktop/settings_preferences.go:138 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetDesktopZoomFactor` | `(factor float64) error` | desktop/zoom_factor.go:61 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetDisplayMode` | `(string) error` | desktop/reasoning_display_app.go:81 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetEffort` | `(level string) error` | desktop/app.go:9485 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetEffortForTab` | `(tabID string, level string) error` | desktop/app.go:9489 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetEffort` | `(level string) error` | desktop/app.go:9490 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetEffortForTab` | `(tabID string, level string) error` | desktop/app.go:9494 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetExpandThinking` | `(bool) error` | desktop/reasoning_display_app.go:86 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetGoal` | `(goal string) error` | desktop/goal_app.go:9 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetGoalForTab` | `(tabID string, goal string) error` | desktop/goal_app.go:12 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetInboxPaused` | `(tabID string, paused bool) error` | desktop/inbox_app.go:422 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetMCPServerEnabled` | `(name string, enabled bool) error` | desktop/app.go:8567 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetMCPServerTier` | `(name string, tier string) error` | desktop/app.go:8671 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetInboxPaused` | `(tabID string, paused bool) error` | desktop/inbox_app.go:426 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetMCPServerEnabled` | `(name string, enabled bool) error` | desktop/app.go:8572 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetMCPServerTier` | `(name string, tier string) error` | desktop/app.go:8676 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetMaxParallelWriters` | `(n int) error` | desktop/settings_app.go:2219 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetMaxSubagentConcurrency` | `(n int) error` | desktop/settings_app.go:2214 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetMaxSubagentDepth` | `(depth int) error` | desktop/settings_app.go:2199 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetMode` | `(mode string)` | desktop/app.go:1621 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetModeForTab` | `(tabID string, mode string) []string` | desktop/app.go:1629 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetModel` | `(name string) error` | desktop/app.go:9235 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetModelForTab` | `(tabID string, name string) error` | desktop/app.go:9251 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetMode` | `(mode string)` | desktop/app.go:1622 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetModeForTab` | `(tabID string, mode string) []string` | desktop/app.go:1630 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetModel` | `(name string) error` | desktop/app.go:9240 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetModelForTab` | `(tabID string, name string) error` | desktop/app.go:9256 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetNetwork` | `(n NetworkView) error` | desktop/settings_app.go:2992 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetPermissionMode` | `(mode string) error` | desktop/settings_app.go:2941 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetPermissionPresetForTab` | `(tabID string, preset string, expectedRevision uint64) (control.PermissionSnapshot, error)` | desktop/app.go:6521 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetPlanMode` | `(on bool)` | desktop/app.go:1605 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetPermissionPresetForTab` | `(tabID string, preset string, expectedRevision uint64) (control.PermissionSnapshot, error)` | desktop/app.go:6526 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetPlanMode` | `(on bool)` | desktop/app.go:1606 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetPlannerModel` | `(ref string) error` | desktop/settings_app.go:2111 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetPluginEnabled` | `(name string, enabled bool) error` | desktop/plugin_packages_app.go:273 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetPreferredExternalOpener` | `(id string) error` | desktop/external_opener.go:200 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -629,8 +647,8 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `SetQualityFloorForTab` | `(tabID string, floor string) error` | desktop/quality_floor.go:20 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetReasoningDisplayMode` | `(mode string) error` | desktop/reasoning_display_app.go:66 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetReasoningLanguage` | `(lang string) error` | desktop/settings_app.go:3188 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetRecoveryCheckpointEnabled` | `(_ bool)` | desktop/app.go:1556 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetRecoveryCheckpointEnabledTab` | `(_ string, _ bool)` | desktop/app.go:1559 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetRecoveryCheckpointEnabled` | `(_ bool)` | desktop/app.go:1557 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetRecoveryCheckpointEnabledTab` | `(_ string, _ bool)` | desktop/app.go:1560 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetRemoteProjectTitle` | `(hostID string, workspace string, title string) error` | desktop/remote_tab_commands.go:234 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetRemoteSessionPinned` | `(hostID string, workspace string, name string, pinned bool) error` | desktop/remote_tab_commands.go:230 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetRemoteTabComposerProfile` | `(tabID string, collaborationMode string, toolApprovalMode string, goal string) ([]string, error)` | desktop/remote_tab.go:950 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -641,70 +659,74 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `SetRemoteTabQualityFloor` | `(tabID string, floor string) error` | desktop/remote_tab.go:1099 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetRemoteTabToolApprovalMode` | `(tabID string, mode string) error` | desktop/remote_tab.go:908 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSandbox` | `(bash string, network bool, workspaceRoot string, allowWrite []string, shell string) error` | desktop/settings_app.go:2980 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSessionDraftRestoreTarget` | `(draftID string) error` | desktop/session_draft.go:301 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSessionDraftRestoreTarget` | `(draftID string) error` | desktop/session_draft.go:294 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSessionExperience` | `(mode string) error` | desktop/reasoning_display_app.go:76 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSessionPinned` | `(selector SessionSelector, pinned bool) error` | desktop/session_workspace_api.go:737 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetShellPreference` | `(prefer string) error` | desktop/shell_support.go:114 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSkillEnabled` | `(name string, enabled bool) error` | desktop/app.go:8102 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSkillImplicitInvocation` | `(enabled bool) error` | desktop/app.go:7295 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSkillPathEnabled` | `(path string, enabled bool) error` | desktop/app.go:8053 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSkillEnabled` | `(name string, enabled bool) error` | desktop/app.go:8107 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSkillImplicitInvocation` | `(enabled bool) error` | desktop/app.go:7300 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSkillPathEnabled` | `(path string, enabled bool) error` | desktop/app.go:8058 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetStatusBarItems` | `(items []string) error` | desktop/settings_preferences.go:22 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetStatusBarStyle` | `(style string) error` | desktop/settings_preferences.go:16 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSubagentEffort` | `(level string) error` | desktop/settings_app.go:2157 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSubagentModel` | `(ref string) error` | desktop/settings_app.go:2121 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSubagentProfileEffort` | `(name string, level string) error` | desktop/settings_app.go:2184 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetSubagentProfileModel` | `(name string, ref string) error` | desktop/settings_app.go:2178 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetTokenMode` | `(mode string) error` | desktop/app.go:9653 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetTokenModeForTab` | `(tabID string, mode string) error` | desktop/app.go:9658 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetToolApprovalMode` | `(mode string)` | desktop/app.go:6459 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetToolApprovalModeForTab` | `(tabID string, mode string) []string` | desktop/app.go:6465 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetTokenMode` | `(mode string) error` | desktop/app.go:9658 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetTokenModeForTab` | `(tabID string, mode string) error` | desktop/app.go:9663 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetToolApprovalMode` | `(mode string)` | desktop/app.go:6464 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetToolApprovalModeForTab` | `(tabID string, mode string) []string` | desktop/app.go:6470 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetTopicPinned` | `(topicID string, pinned bool) error` | desktop/tabs.go:6570 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetTrayLocale` | `(locale string) error` | desktop/settings_preferences.go:96 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetVisionModel` | `(ref string) error` | desktop/settings_app.go:2116 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetWebSearchModel` | `(ref string) error` | desktop/settings_web_search_model.go:12 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SetWorkspaceVisible` | `(workspaceID string, visible bool) error` | desktop/session_workspace_api.go:482 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Settings` | `() SettingsView` | desktop/settings_app.go:1023 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SkillsSettings` | `() SkillsSettingsView` | desktop/app.go:7232 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SkillsSettings` | `() SkillsSettingsView` | desktop/app.go:7237 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SlashArgs` | `(input string) SlashArgsResult` | desktop/slash_args.go:31 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StageImageForTab` | `(tabID string, operationID string, displayName string, mime string, dataURL string) (DraftImageView, error)` | desktop/attachments_app.go:278 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StageImageForTarget` | `(token string, operationID string, displayName string, mime string, dataURL string) (DraftImageView, error)` | desktop/attachment_targets.go:141 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StartBotConnectionInstall` | `(provider string, domain string) (BotInstallStartResult, error)` | desktop/bot_connection_app.go:106 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StartTopicActivation` | `(req TopicActivationRequest) (TopicActivationTicket, error)` | desktop/topic_activation.go:166 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StartTurnForAttachmentTarget` | `(token string, submissionID string, req control.SubmissionRequest) (TurnStartView, error)` | desktop/attachment_targets.go:317 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StartTurnForTab` | `(tabID string, input string, submissionID string) (TurnStartView, error)` | desktop/turn_runtime_api.go:76 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Steer` | `(text string) error` | desktop/app.go:1193 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SteerForTab` | `(tabID string, text string) error` | desktop/app.go:1200 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SteerInboxItem` | `(tabID string, itemID string) (InboxReceiptView, error)` | desktop/inbox_app.go:210 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SteerInboxItemForTurn` | `(tabID string, turnID string, itemID string) (InboxReceiptView, error)` | desktop/inbox_app.go:232 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StartTurnForTabWithDrafts` | `(tabID string, input string, submissionID string, draftIDs []string) (TurnStartView, error)` | desktop/turn_runtime_api.go:119 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Steer` | `(text string) error` | desktop/app.go:1194 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SteerForTab` | `(tabID string, text string) error` | desktop/app.go:1201 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SteerInboxItem` | `(tabID string, itemID string) (InboxReceiptView, error)` | desktop/inbox_app.go:214 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SteerInboxItemForTurn` | `(tabID string, turnID string, itemID string) (InboxReceiptView, error)` | desktop/inbox_app.go:236 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SteerRemoteTab` | `(tabID string, input string) error` | desktop/remote_tab_commands.go:497 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StopRemoteServer` | `(hostID string, workspace string) error` | desktop/remote_server_stop.go:3 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StopTask` | `(taskID string, expectedVersion uint64, reason string, idemKey string) (taskmonitor.ControlResult, error)` | desktop/app.go:11614 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StopTask` | `(taskID string, expectedVersion uint64, reason string, idemKey string) (taskmonitor.ControlResult, error)` | desktop/app.go:11471 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StopTaskByKey` | `(req TaskActionRequest) (taskmonitor.ControlResult, error)` | desktop/task_catalog.go:257 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StopTaskForTab` | `(tabID string, taskID string, expectedVersion uint64, reason string, idemKey string) (taskmonitor.ControlResult, error)` | desktop/app.go:11622 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StopTaskForTab` | `(tabID string, taskID string, expectedVersion uint64, reason string, idemKey string) (taskmonitor.ControlResult, error)` | desktop/app.go:11479 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `StorageSettings` | `() StorageSettingsView` | desktop/storage_app.go:17 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `Submit` | `(input string) error` | desktop/app.go:982 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitDeliveryRecoveryToTab` | `(tabID string, display string, input string) error` | desktop/app.go:1060 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitDeliveryRecoveryToTabWithID` | `(tabID string, display string, input string, submissionID string) error` | desktop/turn_submission_app.go:197 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitDeliveryRecoveryToTabWithID` | `(tabID string, display string, input string, submissionID string) error` | desktop/turn_submission_app.go:210 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitDisplay` | `(display string, input string) error` | desktop/app.go:1052 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitDisplayToTab` | `(tabID string, display string, input string) error` | desktop/app.go:1056 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitDisplayToTabWithID` | `(tabID string, display string, input string, submissionID string) error` | desktop/turn_submission_app.go:169 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitEditedDisplayToTab` | `(tabID string, display string, input string, original string) error` | desktop/app.go:1164 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitEditedDisplayToTabWithID` | `(tabID string, display string, input string, original string, submissionID string) error` | desktop/turn_submission_app.go:266 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitDisplayToTabWithID` | `(tabID string, display string, input string, submissionID string) error` | desktop/turn_submission_app.go:182 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitEditedDisplayToTab` | `(tabID string, display string, input string, original string) error` | desktop/app.go:1165 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitEditedDisplayToTabWithID` | `(tabID string, display string, input string, original string, submissionID string) error` | desktop/turn_submission_app.go:279 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitExtensionForm` | `(tabID string, pluginID string, surfaceID string, values map[string]any) error` | desktop/extensions_app.go:109 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitExtensionFormExact` | `(target ExtensionFormTarget, values map[string]any) error` | desktop/extensions_app.go:124 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitInitialGoalToTab` | `(tabID string, goal string, display string, input string, invocations []InvocationRequest, collaborationMode string, toolApprovalMode string) ([]string, error)` | desktop/app.go:1151 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitInitialGoalToTabWithID` | `(tabID string, goal string, display string, input string, invocations []InvocationRequest, collaborationMode string, toolApprovalMode string, submissionID string) ([]string, error)` | desktop/turn_submission_app.go:253 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitInitialGoalToTab` | `(tabID string, goal string, display string, input string, invocations []InvocationRequest, collaborationMode string, toolApprovalMode string) ([]string, error)` | desktop/app.go:1152 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitInitialGoalToTabWithID` | `(tabID string, goal string, display string, input string, invocations []InvocationRequest, collaborationMode string, toolApprovalMode string, submissionID string) ([]string, error)` | desktop/turn_submission_app.go:266 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitInvocationsToTab` | `(tabID string, display string, input string, invocations []InvocationRequest) error` | desktop/app.go:1081 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitInvocationsToTabWithID` | `(tabID string, display string, input string, invocations []InvocationRequest, submissionID string) error` | desktop/turn_submission_app.go:225 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitInvocationsToTabWithID` | `(tabID string, display string, input string, invocations []InvocationRequest, submissionID string) error` | desktop/turn_submission_app.go:238 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitRemoteTab` | `(tabID string, text string) error` | desktop/remote_tab.go:728 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitRemoteTabExtensionForm` | `(tabID string, pluginID string, surfaceID string, values map[string]any) error` | desktop/remote_tab.go:872 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitRemoteTabExtensionFormExact` | `(target ExtensionFormTarget, values map[string]any) error` | desktop/remote_tab_exact_interactions.go:33 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitRemoteTabWithSubmission` | `(tabID string, text string, submissionID string) error` | desktop/remote_tab.go:732 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitToTab` | `(tabID string, input string) error` | desktop/app.go:995 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitToTabWithID` | `(tabID string, input string, submissionID string) error` | desktop/turn_submission_app.go:162 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SummarizeFrom` | `(turn int) error` | desktop/app.go:2570 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SummarizeFromForTab` | `(tabID string, turn int) error` | desktop/app.go:2574 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SubmitToTabWithID` | `(tabID string, input string, submissionID string) error` | desktop/turn_submission_app.go:175 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SummarizeFrom` | `(turn int) error` | desktop/app.go:2571 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SummarizeFromForTab` | `(tabID string, turn int) error` | desktop/app.go:2575 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SummarizeRemoteTab` | `(tabID string, turn int, mode string) error` | desktop/remote_tab_commands.go:521 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SummarizeUpTo` | `(turn int) error` | desktop/app.go:2585 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SummarizeUpToForTab` | `(tabID string, turn int) error` | desktop/app.go:2589 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SwitchWorkspace` | `(dir string) (string, error)` | desktop/app.go:4974 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SummarizeUpTo` | `(turn int) error` | desktop/app.go:2586 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SummarizeUpToForTab` | `(tabID string, turn int) error` | desktop/app.go:2590 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SwitchWorkspace` | `(dir string) (string, error)` | desktop/app.go:4975 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `SyncRuntimeState` | `() (RuntimeStateProjection, error)` | desktop/remote_runtime_state.go:155 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `TakeoverSession` | `(tabID string, mode string) error` | desktop/session_takeover.go:241 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `TerminalOutputForTab` | `(tabID string, sessionID string) (string, error)` | desktop/terminal.go:166 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -713,7 +735,7 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `TestDingtalkBot` | `() (BotConnectionDiagnostic, error)` | desktop/bot_connection_app.go:299 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `TestProviderModel` | `(p ProviderView, model string, key string) error` | desktop/provider_settings_probe.go:15 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ToggleMaximiseMainWindow` | `()` | desktop/window_controls.go:12 | migrate-host (迁移宿主实现) | `business in Go; native step through nativeHost host/*` |
| `ToolResultForTab` | `(tabID string, toolID string) *control.ToolResultData` | desktop/app.go:2363 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `ToolResultForTab` | `(tabID string, toolID string) *control.ToolResultData` | desktop/app.go:2364 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `TranscriptContentForTab` | `(tabID string, req transcript.ContentRequest) (transcript.ContentChunk, error)` | desktop/transcript_api.go:114 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `TranscriptFollowForTab` | `(tabID string, req transcript.FollowRequest) (control.TranscriptFollowResponse, error)` | desktop/transcript_api.go:64 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `TranscriptOutlineForTab` | `(tabID string, req transcript.OutlineRequest) (transcript.OutlinePage, error)` | desktop/transcript_api.go:98 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -725,11 +747,11 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `TrustProjectHooksForRoot` | `(root string) error` | desktop/hooks_settings_app.go:97 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `TrySubagentProfile` | `(input SubagentProfileInput, task string) (string, error)` | desktop/subagents_app.go:285 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `TurnCheckLog` | `(tabID string, sessionPath string, toolID string, resultID string) *TurnCheckLogView` | desktop/workspace_turn_changes.go:16 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `TurnEventsForTab` | `(tabID string, afterSeq uint64) (TurnEventReplayView, error)` | desktop/turn_runtime_api.go:200 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `TurnEventsForTab` | `(tabID string, afterSeq uint64) (TurnEventReplayView, error)` | desktop/turn_runtime_api.go:243 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `UndoRewindForTab` | `(tabID string, transactionID string) RewindResultView` | desktop/rewind_tab.go:60 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `UnpinFileForTab` | `(tabID string, relPath string) error` | desktop/pinned_files.go:412 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `UpdateInboxItem` | `(tabID string, id string, display string, submit string) error` | desktop/inbox_app.go:383 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `UpdateMCPServer` | `(name string, in MCPServerInput) error` | desktop/app.go:8374 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `UpdateInboxItem` | `(tabID string, id string, display string, submit string) error` | desktop/inbox_app.go:387 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `UpdateMCPServer` | `(name string, in MCPServerInput) error` | desktop/app.go:8379 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `UpdatePlugin` | `(name string) (string, error)` | desktop/plugin_packages_app.go:291 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `UpdateRemoteHost` | `(id string, in RemoteHostInput) (RemoteHostView, error)` | desktop/remote_app.go:317 | keep-business (保留业务实现) | hostrpc desktop/invoke |
| `UpdateSessionOrganization` | `(workspace SessionOrganizationWorkspace, expectedRevision uint64, mutation SessionOrganizationMutation) (SessionOrganizationSnapshot, error)` | desktop/session_organization.go:144 | keep-business (保留业务实现) | hostrpc desktop/invoke |
@@ -828,9 +850,9 @@ Every entry carries one class: `keep-business` keeps its Go implementation and c
| `heartbeat-tasks.json` | | desktop/heartbeat.go:160 | keep-business (保留业务实现) | format unchanged; read by both shells |
| `heartbeat-tasks.runs.json` | | desktop/heartbeat_sidecar.go:34 | keep-business (保留业务实现) | format unchanged; read by both shells |
| `manifest.json` | | desktop/legacy_empty_session_cleanup_sources.go:756 | keep-business (保留业务实现) | format unchanged; read by both shells |
| `mcp-registry-v0.1.json` | | desktop/app.go:6920 | keep-business (保留业务实现) | format unchanged; read by both shells |
| `mcp-registry-v0.1.json` | | desktop/app.go:6925 | keep-business (保留业务实现) | format unchanged; read by both shells |
| `operations-v1.json` | | desktop/browser_executor.go:141 | keep-business (保留业务实现) | format unchanged; read by both shells |
| `primary.lock` | | desktop/lifecycle_diagnostics.go:106 | keep-business (保留业务实现) | format unchanged; read by both shells |
| `primary.lock` | | desktop/lifecycle_diagnostics.go:122 | keep-business (保留业务实现) | format unchanged; read by both shells |
| `publication.json` | | desktop/session_export_journal.go:29 | keep-business (保留业务实现) | format unchanged; read by both shells |
| `remote-provider-trust.json` | | desktop/remote_legacy_cleanup.go:31 | keep-business (保留业务实现) | format unchanged; read by both shells |
| `session-migration-v5.json` | | desktop/session_v5_migration.go:48 | keep-business (保留业务实现) | format unchanged; read by both shells |
File diff suppressed because it is too large. Load diff
+7 -41
View File
@@ -280,7 +280,8 @@ type Agent struct {
// is unrelated to the retired Auto Guard execution gate.
protocolRunSeq atomic.Uint64
imageInput agentImageInput
imageInput agentImageInput
imageResolver ImageRequestResolver
agentConfig
// reads groups the run-scoped read registry and its generation: both are
// replaced at each run start so cursors from an earlier run never continue.
@@ -751,43 +752,6 @@ func (a *Agent) flushSteerQueue() {
a.steerMu.Unlock()
}
// UnappliedSteerNotice returns the durable warning shown for guidance that was
// accepted during an abnormal turn exit but never reached a provider request.
// The user's guidance rides the format's trailing %s so fronts can split it
// back out at the first newline.
func UnappliedSteerNotice(text string) string {
return fmt.Sprintf(i18n.M.UnappliedSteerFmt, text)
}
// RecordUnappliedSteer stores guidance that could not affect its intended
// in-flight turn. The orphan-tool sentinel makes older readers drop the record
// during wire normalization, while current readers use LocalOnly to exclude it
// before every provider request. itemID correlates the notice with the durable
// session inbox entry when one exists.
func (a *Agent) RecordUnappliedSteer(text string, itemID ...string) {
if a == nil || a.sess.conversation == nil {
return
}
id := ""
if len(itemID) > 0 {
id = itemID[0]
}
_ = a.appendCommittedMessages(context.Background(), "unapplied-steer", provider.Message{
Role: provider.RoleTool,
Content: a.withTurnPreferences(midTurnSteerMessage(text)),
ToolCallID: provider.LocalOnlyToolID,
Name: provider.LocalOnlyToolName,
LocalOnly: true,
})
a.svc.sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Code: event.NoticeCodeUnappliedSteer,
Text: UnappliedSteerNotice(text),
ItemID: id,
})
}
func (a *Agent) steerQueueLen() int {
a.steerMu.Lock()
defer a.steerMu.Unlock()
@@ -811,8 +775,9 @@ func (a *Agent) CompactNow(ctx context.Context, instructions string) error {
// Options configures an Agent.
type Options struct {
ImageInput *imageinput.Config
MaxSteps int
ImageInput *imageinput.Config
ImageRequestResolver ImageRequestResolver
MaxSteps int
// MaxStepsKey names the explicit runtime control shown when the MaxSteps guard
// is hit. Empty defaults to the generic max_steps tool/runtime parameter.
MaxStepsKey string
@@ -1045,7 +1010,8 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
reasoningByteLimit = defaultReasoningByteLimit
}
a := &Agent{
imageInput: newImageInput(opts.ImageInput, prov),
imageInput: newImageInput(opts.ImageInput, prov),
imageResolver: opts.ImageRequestResolver,
svc: newAgentServices(prov, tools, sink, gate, planModeReadOnlyTrust,
sandboxEscapeApprover, configWriteApprover, hooks, opts),
reads: readState{},
+9
View File
@@ -387,6 +387,11 @@ func compactionInstructionWithFocus(instructions string) string {
// supposedly safe overflow fold from being rejected only after it is selected.
func (a *Agent) summaryRequest(region []provider.Message, instructions string) provider.Request {
prefix := append([]provider.Message(nil), region...)
for i := range prefix {
if !a.imageInput.native && prefix[i].VisionSummary != nil {
prefix[i].ImageInputs = nil
}
}
if len(prefix) == 0 || prefix[0].Role != provider.RoleSystem {
visible := a.modelVisibleMessages()
if len(visible) > 0 && visible[0].Role == provider.RoleSystem {
@@ -419,6 +424,10 @@ func (a *Agent) summarize(ctx context.Context, region []provider.Message, instru
// runSummaryRequest admits, sends, and drains one summary request.
// Named returns so defer can attach RequestCount and still return usage.
func (a *Agent) runSummaryRequest(ctx context.Context, req provider.Request) (summary string, usage *provider.Usage, err error) {
req.Messages, err = a.resolveRequestImages(ctx, req.Messages)
if err != nil {
return "", nil, err
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ctx = provider.WithRequestAttemptCounter(ctx)
+1
View File
@@ -125,6 +125,7 @@ func splitLegacyCoalescedSummary(msg provider.Message) (provider.Message, provid
summary.Content = msg.Content[:i+len(summaryTagClose)]
summary.RawContent = ""
summary.Images = nil
summary.ImageInputs = nil
summary.ToolCalls = nil
summary.ResponsesItems = nil
summary.ServerSearch = nil
+1 -1
View File
@@ -443,7 +443,7 @@ func (c *Coordinator) persistExecutorNoOp(ctx context.Context, input, plan, mess
if _, err := c.executor.AppendTurnContextAndUserChecked(ctx, provider.Message{
ID: turnUserMessageID(ctx, c.executor.Session()),
Role: provider.RoleUser, Origin: inputMessageOrigin(ctx), Content: providerContent, RawContent: rawContent,
Images: userImages(ctx), CreatedAt: time.Now().UnixMilli(),
Images: userImages(ctx), ImageInputs: userImageInputs(ctx), CreatedAt: time.Now().UnixMilli(),
}); err != nil {
return err
}
+12 -2
View File
@@ -116,14 +116,24 @@ func (a *Agent) recordToolExecutionAudit(readOnly, parallel bool, startedAt, dur
func (a *Agent) buildBatchToolResult(ctx context.Context, call provider.ToolCall, o toolOutcome) provider.Message {
state := outcomeRunState(o)
msg := provider.Message{Role: provider.RoleTool, Content: o.output, Images: o.images, VisionSummary: o.visionSummary, ToolCallID: call.ID, Name: call.Name, ToolRunState: state, ToolExecution: toProviderToolExecution(o.execution), PresentedFiles: provider.NewPresentedFilesMetadata(o.presentedFiles)}
if o.diagnostic != nil {
if len(o.images) > 0 && a.imageResolver != nil {
if inputs, err := a.imageResolver.PersistToolImages(ctx, o.images); err == nil && len(inputs) > 0 {
msg.ImageInputs = inputs
msg.Images = nil
} else if err != nil {
msg.Images = nil
msg.Content += "\n[Image persistence failed. The tool already executed; its text result remains valid. Do not repeat the original action to retry image processing.]"
msg.ToolDiagnostic, _ = json.Marshal(map[string]string{"code": "image_persistence_failed", "message": "Image persistence failed; completed tool text was preserved."})
}
}
if o.diagnostic != nil && len(msg.ToolDiagnostic) == 0 {
msg.ToolDiagnostic, _ = json.Marshal(o.diagnostic)
}
if o.rawOutput != "" && o.rawOutput != o.output {
msg.RawContent = o.rawOutput
}
if env, ok := a.finalizedReadEnvelope(ctx, call, o); ok {
if env.HasMore {
if env.HasMore && len(msg.ToolDiagnostic) == 0 {
msg.ToolDiagnostic, _ = json.Marshal(tool.OperationDiagnostic{Code: tool.ReadPartial, Path: env.Source.CanonicalPath, OperationID: call.ID, ActualSnapshot: env.Source.Snapshot, RequiredRanges: env.DeliveredRanges, Recovery: "continue with the next window only if the task requires more coverage"})
}
if raw, err := json.Marshal(env); err == nil {
+27 -1
View File
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
"reasonix/internal/attachment"
"reasonix/internal/imageinput"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
type agentImageInput struct {
@@ -25,11 +27,35 @@ func newImageInput(cfg *imageinput.Config, p provider.Provider) agentImageInput
return agentImageInput{service: imageinput.New(*cfg), native: supportsNativeImages(p)}
}
func (a *Agent) ImageInput() *imageinput.Service { return a.imageInput.service }
// ImageRequestResolver turns durable ImageInputs into provider-visible Images
// on a request copy. It must not write variants or uploads back to history.
type ImageRequestResolver interface {
ResolveRequestImages(ctx context.Context, msgs []provider.Message) ([]provider.Message, error)
PersistToolImages(ctx context.Context, images []string) ([]attachment.ImageInput, error)
}
func (a *Agent) SetImageRequestResolver(resolver ImageRequestResolver) {
if a == nil {
return
}
a.imageResolver = resolver
if a.svc.tools == nil {
return
}
if item, ok := a.svc.tools.Get(tool.HostTask); ok {
if task, ok := item.(*TaskTool); ok {
task.imageResolver = resolver
}
}
}
func (a *Agent) processToolImages(ctx context.Context, text string, images []string) imageResult {
if len(images) == 0 {
return imageResult{text: text}
}
if a.imageInput.native {
// Durable images are persisted by buildBatchToolResult first. Request
// assembly then selects the actual native or understanding model route.
if a.imageInput.native || a.imageResolver != nil {
return imageResult{text: text}
}
summary, err := a.imageInput.service.Understand(ctx, a.modelRef, images, a.Session().Snapshot, a.svc.sink)
+5
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"reasonix/internal/attachment"
"reasonix/internal/fileutil"
"reasonix/internal/provider"
"reasonix/internal/store"
@@ -421,6 +422,7 @@ func providerVisibleFingerprint(msgs []provider.Message) string {
Role string `json:"r"`
Content string `json:"c,omitempty"`
Images []string `json:"img,omitempty"`
ImageInputs []attachment.ImageInput `json:"ii,omitempty"`
ReasoningContent string `json:"rc,omitempty"`
ReasoningID string `json:"rid,omitempty"`
ReasoningStatus string `json:"rst,omitempty"`
@@ -438,6 +440,7 @@ func providerVisibleFingerprint(msgs []provider.Message) string {
Role: string(m.Role),
Content: m.Content,
Images: append([]string(nil), m.Images...),
ImageInputs: attachment.CloneImageInputs(m.ImageInputs),
ReasoningContent: m.ReasoningContent,
ReasoningID: m.ReasoningID,
ReasoningStatus: m.ReasoningStatus,
@@ -574,6 +577,7 @@ func coalesceProjectionUserRuns(msgs []provider.Message) []provider.Message {
if len(out) == 0 || msg.Role != provider.RoleUser || out[len(out)-1].Role != provider.RoleUser {
clone := msg
clone.Images = append([]string(nil), msg.Images...)
clone.ImageInputs = provider.CloneImageInputs(msg.ImageInputs)
clone.ToolCalls = append([]provider.ToolCall(nil), msg.ToolCalls...)
clone.ResponsesItems = append([]json.RawMessage(nil), msg.ResponsesItems...)
clone.ServerSearch = append([]provider.ServerSearchCall(nil), msg.ServerSearch...)
@@ -588,6 +592,7 @@ func coalesceProjectionUserRuns(msgs []provider.Message) []provider.Message {
prev.Content = strings.TrimRight(prev.Content, "\n") + "\n\n" + msg.Content
}
prev.Images = append(prev.Images, msg.Images...)
prev.ImageInputs = append(prev.ImageInputs, provider.CloneImageInputs(msg.ImageInputs)...)
prev.ToolCalls = append(prev.ToolCalls, msg.ToolCalls...)
prev.ResponsesItems = append(prev.ResponsesItems, msg.ResponsesItems...)
prev.ServerSearch = append(prev.ServerSearch, msg.ServerSearch...)
+4 -1
View File
@@ -138,7 +138,10 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string, pinned pinnedRev
userMessage := provider.Message{
ID: turnUserMessageID(ctx, a.sess.conversation),
Role: provider.RoleUser, Origin: inputMessageOrigin(ctx), Content: input, RawContent: rawContent,
Images: userImages(ctx), VisionSummary: VisionSummaryFromContext(ctx), CreatedAt: userCreatedAt,
Images: userImages(ctx), ImageInputs: userImageInputs(ctx), VisionSummary: VisionSummaryFromContext(ctx), CreatedAt: userCreatedAt,
}
if err := userMessage.ValidateImageFields(); err != nil {
return rawInput, nil, err
}
if err := a.appendPinnedRevisionAndUser(ctx, pinned, userMessage); err != nil {
return rawInput, nil, err
+29 -1
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"reasonix/internal/provider"
@@ -47,6 +48,26 @@ func (a *Agent) normalizeModelRequestMessages(msgs []provider.Message) []provide
return requestMessages
}
func (a *Agent) resolveRequestImages(ctx context.Context, msgs []provider.Message) ([]provider.Message, error) {
if a == nil {
return msgs, nil
}
for _, msg := range msgs {
if len(msg.ImageInputs) > 0 {
if a.imageResolver == nil {
return nil, errors.New("image request resolver is unavailable")
}
if routed, ok := a.imageResolver.(interface {
ResolveRequestImagesForModel(context.Context, []provider.Message, string, bool) ([]provider.Message, error)
}); ok {
return routed.ResolveRequestImagesForModel(ctx, msgs, a.modelRef, a.imageInput.native)
}
return a.imageResolver.ResolveRequestImages(ctx, msgs)
}
}
return msgs, nil
}
func (a *Agent) streamProviderRequest(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
if err := provider.ValidateModelTranscript(req.Messages); err != nil {
return nil, err
@@ -122,7 +143,11 @@ func (a *Agent) buildSamplingRequest(ctx context.Context, trigger string) (sampl
if err != nil {
return samplingRequest{}, err
}
requestMessages := a.normalizeModelRequestMessages(prepared.Messages)
requestMessages, err := a.resolveRequestImages(ctx, prepared.Messages)
if err != nil {
return samplingRequest{}, err
}
requestMessages = a.normalizeModelRequestMessages(requestMessages)
// context.prepare: extensions may rewrite the message copy feeding THIS
// request. The session log is never touched — the replacement is
// ephemeral, so the next request starts from the unmodified history.
@@ -204,6 +229,9 @@ func freezeProviderRequest(req provider.Request) provider.Request {
if len(out.Messages[i].Images) > 0 {
out.Messages[i].Images = append([]string(nil), out.Messages[i].Images...)
}
if len(out.Messages[i].ImageInputs) > 0 {
out.Messages[i].ImageInputs = provider.CloneImageInputs(out.Messages[i].ImageInputs)
}
if len(out.Messages[i].ResponsesItems) > 0 {
items := make([]json.RawMessage, len(out.Messages[i].ResponsesItems))
for j, item := range out.Messages[i].ResponsesItems {
+1 -1
View File
@@ -137,7 +137,7 @@ func classifyDisplayIndexMessage(m provider.Message, index int, offset, length i
Length: length,
Role: m.Role,
AuthoredTurn: turn,
HasImages: len(m.Images) > 0,
HasImages: m.HasImagePayload(),
HasToolCalls: len(m.ToolCalls) > 0,
LocalOnly: m.LocalOnly,
ToolResult: m.Role == provider.RoleTool,
+47
View File
@@ -0,0 +1,47 @@
package agent
import (
"context"
"fmt"
"reasonix/internal/event"
"reasonix/internal/i18n"
"reasonix/internal/provider"
)
// UnappliedSteerNotice returns the durable warning shown for guidance that was
// accepted during an abnormal turn exit but never reached a provider request.
// The user's guidance rides the format's trailing %s so fronts can split it
// back out at the first newline.
func UnappliedSteerNotice(text string) string {
return fmt.Sprintf(i18n.M.UnappliedSteerFmt, text)
}
// RecordUnappliedSteer stores guidance that could not affect its intended
// in-flight turn. The orphan-tool sentinel makes older readers drop the record
// during wire normalization, while current readers use LocalOnly to exclude it
// before every provider request. itemID correlates the notice with the durable
// session inbox entry when one exists.
func (a *Agent) RecordUnappliedSteer(text string, itemID ...string) {
if a == nil || a.sess.conversation == nil {
return
}
id := ""
if len(itemID) > 0 {
id = itemID[0]
}
_ = a.appendCommittedMessages(context.Background(), "unapplied-steer", provider.Message{
Role: provider.RoleTool,
Content: a.withTurnPreferences(midTurnSteerMessage(text)),
ToolCallID: provider.LocalOnlyToolID,
Name: provider.LocalOnlyToolName,
LocalOnly: true,
})
a.svc.sink.Emit(event.Event{
Kind: event.Notice,
Level: event.LevelWarn,
Code: event.NoticeCodeUnappliedSteer,
Text: UnappliedSteerNotice(text),
ItemID: id,
})
}
+3 -33
View File
@@ -276,43 +276,13 @@ type TaskTool struct {
// mutationObserver is shared with spawned sub-agents for checkpoint capture.
mutationObserver *checkpoint.MutationObserver
writeRoots *sandbox.WritableRootSet
imageResolver ImageRequestResolver
// capabilityRuntime is the session-shared MCP Host/specs substrate. Each
// sub-agent gets its own use_capability frontend so ledger state stays
// isolated while connections reuse the parent Host.
capabilityRuntime *MCPCapabilityRuntime
}
// NewTaskToolWithOptions is the internal standard constructor for TaskTool.
// An empty SysPrompt still resolves to DefaultTaskSystemPrompt. No extra
// validation or default overrides are applied beyond the historical NewTaskTool
// behavior.
func NewTaskToolWithOptions(opts TaskToolOptions) *TaskTool {
sysPrompt := opts.SysPrompt
if sysPrompt == "" {
sysPrompt = DefaultTaskSystemPrompt
}
return &TaskTool{
imageInput: opts.ImageInput,
prov: opts.Provider,
pricing: opts.Pricing,
quoteContext: opts.QuoteContext,
parentReg: opts.ParentRegistry,
maxSteps: opts.MaxSteps,
contextWindow: opts.ContextWindow,
recentKeep: opts.RecentKeep,
compactRatio: opts.CompactRatio,
temperature: opts.Temperature,
archiveDir: opts.ArchiveDir,
keepPolicy: opts.KeepPolicy,
sysPrompt: sysPrompt,
gate: opts.Gate,
subagentModel: opts.SubagentModel,
subagentEffort: opts.SubagentEffort,
resolveProvider: opts.ResolveProvider,
maxSubagentDepth: DefaultMaxSubagentDepth,
}
}
// NewTaskTool wires a task tool to the parent agent's environment so its
// sub-agents can use the same provider and tools. sysPrompt is the system
// prompt every sub-agent starts with; pass "" for DefaultTaskSystemPrompt. gate
@@ -1510,7 +1480,7 @@ func (t *TaskTool) runSubSession(ctx context.Context, prompt string, subReg *too
prompt = t.withWorkspaceContext(prompt) + "\n\n" + completeSubtaskContract
// The child provider owns the final vision decision. Text-only providers
// retain the attachment metadata but omit image parts during serialization.
ctx = WithUserImages(ctx, SubagentImageCandidates(ctx))
ctx = withSubagentTurnImages(ctx)
return RunSubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
}
@@ -1521,7 +1491,7 @@ func (t *TaskTool) runReadOnlySubSession(ctx context.Context, prompt string, sub
// intent classification must judge the task, not the wrapper.
opts.ClassifierTaskText = prompt
prompt = t.withWorkspaceContext(prompt)
ctx = WithUserImages(ctx, SubagentImageCandidates(ctx))
ctx = withSubagentTurnImages(ctx)
return RunReadOnlySubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
}
+60
View File
@@ -0,0 +1,60 @@
package agent
import (
"context"
"testing"
"reasonix/internal/attachment"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
type urlImageResolver struct{}
func (urlImageResolver) ResolveRequestImages(_ context.Context, msgs []provider.Message) ([]provider.Message, error) {
out := append([]provider.Message(nil), msgs...)
for i := range out {
if len(out[i].ImageInputs) == 0 {
continue
}
images := make([]string, 0, len(out[i].ImageInputs))
for _, in := range out[i].ImageInputs {
if in.Kind == attachment.KindURL {
images = append(images, in.URL)
}
}
out[i].Images = images
out[i].ImageInputs = nil
}
return out, nil
}
func (urlImageResolver) PersistToolImages(context.Context, []string) ([]attachment.ImageInput, error) {
return nil, nil
}
func TestTaskToolPropagatesSubagentImageInputsWithoutCombiningImages(t *testing.T) {
sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "image received"},
{Type: provider.ChunkDone},
}}
task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil).WithImageRequestResolver(urlImageResolver{})
inputs := []attachment.ImageInput{{Kind: attachment.KindURL, URL: "https://example.invalid/shot.png"}}
ctx := WithSubagentImageInputs(testTaskContext(), inputs)
ctx = WithSubagentImageCandidates(ctx, []string{"data:image/png;base64,AAAA"})
if _, err := task.Execute(ctx, []byte(`{"prompt":"inspect the attached image"}`)); err != nil {
t.Fatalf("Execute: %v", err)
}
var got provider.Message
for _, msg := range sub.lastReq.Messages {
if msg.Role == provider.RoleUser {
got = msg
}
}
if len(got.Images) != 1 || got.Images[0] != "https://example.invalid/shot.png" {
t.Fatalf("sub-agent images = %v, want the resolved ImageInput URL", got.Images)
}
if len(got.ImageInputs) != 0 {
t.Fatalf("request ImageInputs = %+v, want resolved away", got.ImageInputs)
}
}
+42
View File
@@ -11,6 +11,38 @@ import (
"reasonix/internal/tool"
)
// NewTaskToolWithOptions is the internal standard constructor for TaskTool.
// An empty SysPrompt still resolves to DefaultTaskSystemPrompt. No extra
// validation or default overrides are applied beyond the historical NewTaskTool
// behavior.
func NewTaskToolWithOptions(opts TaskToolOptions) *TaskTool {
sysPrompt := opts.SysPrompt
if sysPrompt == "" {
sysPrompt = DefaultTaskSystemPrompt
}
return &TaskTool{
imageInput: opts.ImageInput,
prov: opts.Provider,
pricing: opts.Pricing,
quoteContext: opts.QuoteContext,
parentReg: opts.ParentRegistry,
maxSteps: opts.MaxSteps,
contextWindow: opts.ContextWindow,
recentKeep: opts.RecentKeep,
compactRatio: opts.CompactRatio,
temperature: opts.Temperature,
archiveDir: opts.ArchiveDir,
keepPolicy: opts.KeepPolicy,
sysPrompt: sysPrompt,
gate: opts.Gate,
subagentModel: opts.SubagentModel,
subagentEffort: opts.SubagentEffort,
resolveProvider: opts.ResolveProvider,
maxSubagentDepth: DefaultMaxSubagentDepth,
imageResolver: opts.ImageRequestResolver,
}
}
// subagentOptions is the single construction point for the run options every
// sub-agent spawned through this tool shares (task, read_only_task, and
// parallel_tasks children). Compaction, language preferences, and depth limits
@@ -40,6 +72,7 @@ func (t *TaskTool) subagentOptions(ctx context.Context, maxSteps int, pricing *p
WriteRoots: t.writeRoots,
DisableWriteAccessExpand: true,
WriteWorkspaceRoot: t.workspaceRoot,
ImageRequestResolver: t.imageResolver,
}
// Writer children inherit the parent turn's frozen risk and closure floors.
// The parent publishes its policy into the run context; a child that never
@@ -53,11 +86,20 @@ func (t *TaskTool) subagentOptions(ctx context.Context, maxSteps int, pricing *p
return opts
}
func (t *TaskTool) WithImageRequestResolver(resolver ImageRequestResolver) *TaskTool {
if t == nil {
return nil
}
t.imageResolver = resolver
return t
}
// TaskToolOptions holds the construction parameters for a TaskTool.
// Prefer NewTaskToolWithOptions for new call sites; the positional NewTaskTool
// remains as a compatibility wrapper for one full iteration cycle.
type TaskToolOptions struct {
ImageInput *imageinput.Config
ImageRequestResolver ImageRequestResolver
Provider provider.Provider
Pricing *provider.Pricing
QuoteContext *event.QuoteContext
+1
View File
@@ -82,6 +82,7 @@ func elideToolResult(m provider.Message) (provider.Message, bool) {
out.RawContent = ""
out.ProviderContent = ""
out.Images = nil
out.ImageInputs = nil
return out, true
}
+42
View File
@@ -3,11 +3,14 @@ package agent
import (
"context"
"reasonix/internal/attachment"
"reasonix/internal/provider"
)
type rawUserInputKey struct{}
type subagentImageCandidatesKey struct{}
type subagentImageInputsKey struct{}
type userImageInputsContextKey struct{}
type visionSummaryContextKey struct{}
// WithRawUserInput keeps user-authored text separate from host-rendered turn
@@ -54,6 +57,45 @@ func SubagentImageCandidates(ctx context.Context) []string {
return append([]string(nil), images...)
}
// WithSubagentImageInputs carries durable attachment refs from the parent
// turn. A vision-capable child resolves them through the inherited request
// resolver instead of inheriting already-wired data URLs.
func WithSubagentImageInputs(ctx context.Context, inputs []attachment.ImageInput) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, subagentImageInputsKey{}, attachment.CloneImageInputs(inputs))
}
// SubagentImageInputs returns the parent-admitted image refs a child may
// persist on its user message. Empty when the parent only had legacy Images.
func SubagentImageInputs(ctx context.Context) []attachment.ImageInput {
if ctx == nil {
return nil
}
inputs, _ := ctx.Value(subagentImageInputsKey{}).([]attachment.ImageInput)
return attachment.CloneImageInputs(inputs)
}
func WithUserImageInputs(ctx context.Context, inputs []attachment.ImageInput) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, userImageInputsContextKey{}, attachment.CloneImageInputs(inputs))
}
func userImageInputs(ctx context.Context) []attachment.ImageInput {
inputs, _ := ctx.Value(userImageInputsContextKey{}).([]attachment.ImageInput)
return attachment.CloneImageInputs(inputs)
}
func withSubagentTurnImages(ctx context.Context) context.Context {
if inputs := SubagentImageInputs(ctx); len(inputs) > 0 {
return WithUserImageInputs(ctx, inputs)
}
return WithUserImages(ctx, SubagentImageCandidates(ctx))
}
// WithVisionSummary carries a hidden image-understanding result into the
// foreground turn. The run loop persists it on the user message while keeping
// RawContent as the user-visible text.
+19
View File
@@ -6,6 +6,7 @@ import (
"strings"
"testing"
"reasonix/internal/attachment"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
@@ -156,3 +157,21 @@ func TestSubagentImageCandidatesAreCopiedAndIsolated(t *testing.T) {
t.Fatalf("candidate accessor exposed mutable context state: %v", again)
}
}
func TestSubagentImageInputsAreCopiedAndPreferredOverCandidates(t *testing.T) {
inputs := []attachment.ImageInput{{Kind: attachment.KindURL, URL: "https://example.invalid/a.png"}}
ctx := WithSubagentImageInputs(context.Background(), inputs)
inputs[0].URL = "mutated"
got := SubagentImageInputs(ctx)
if len(got) != 1 || got[0].URL != "https://example.invalid/a.png" {
t.Fatalf("inputs = %+v", got)
}
ctx = WithSubagentImageCandidates(ctx, []string{"data:image/png;base64,AAAA"})
wired := withSubagentTurnImages(ctx)
if len(userImages(wired)) != 0 {
t.Fatalf("ImageInputs path leaked Images = %v", userImages(wired))
}
if len(userImageInputs(wired)) != 1 || userImageInputs(wired)[0].URL != "https://example.invalid/a.png" {
t.Fatalf("wired ImageInputs = %+v", userImageInputs(wired))
}
}
+36
View File
@@ -0,0 +1,36 @@
package attachment
import "testing"
func TestAdmissionCountAndByteBoundaries(t *testing.T) {
svc := testService(t)
image := opaquePNG(t, 1, 1)
sources := make([]Source, 21)
for i := range sources {
sources[i] = Source{Bytes: image}
}
if got, err := svc.PrepareBatch(t.Context(), sources[:20]); err != nil || len(got.Items) != 20 {
t.Fatalf("20 images: count=%d err=%v", len(got.Items), err)
}
if _, err := svc.PrepareBatch(t.Context(), sources); !Is(err, CodeTooMany) {
t.Fatalf("21 images: %v", err)
}
// Valid PNGs may contain trailing data. Share one immutable source buffer
// to exercise the real 200 MiB boundary without decoding giant rasters.
raw := make([]byte, MaxSourceBytes+1)
copy(raw, image)
if _, err := svc.PrepareBatch(t.Context(), []Source{{Bytes: raw[:MaxSourceBytes]}}); err != nil {
t.Fatalf("64 MiB: %v", err)
}
if _, err := svc.PrepareBatch(t.Context(), []Source{{Bytes: raw}}); !Is(err, CodeSize) {
t.Fatalf("64 MiB + 1: %v", err)
}
batch := []Source{{Bytes: raw[:50<<20]}, {Bytes: raw[:50<<20]}, {Bytes: raw[:50<<20]}, {Bytes: raw[:50<<20]}}
if _, err := svc.PrepareBatch(t.Context(), batch); err != nil {
t.Fatalf("200 MiB: %v", err)
}
batch[3].Bytes = raw[:(50<<20)+1]
if _, err := svc.PrepareBatch(t.Context(), batch); !Is(err, CodeBatchSize) {
t.Fatalf("200 MiB + 1: %v", err)
}
}
+37
View File
@@ -0,0 +1,37 @@
package attachment
import (
"encoding/base64"
"fmt"
"strings"
)
func DataURL(mime string, raw []byte) string {
if mime == "" {
mime = "application/octet-stream"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw)
}
func ParseDataURL(value string) (mime string, raw []byte, err error) {
raw, err = decodeDataURL(value, MaxSourceBytes)
if err != nil {
return "", nil, err
}
const prefix = "data:"
const marker = ";base64,"
i := len(prefix)
j := strings.Index(value, marker)
if j <= i {
return "", nil, Error{Code: CodeUnsupported, Message: defaultDetail(CodeUnsupported)}
}
return normalizeDeclaredMIME(value[i:j]), raw, nil
}
func FormatImageNote(ref AttachmentRef) string {
name := ref.DisplayName
if name == "" {
name = "image"
}
return fmt.Sprintf("[image attachment %s %dx%d %s]", name, ref.Width, ref.Height, ref.MIME())
}
+6
View File
@@ -0,0 +1,6 @@
// Package attachment validates, persists, and serves session image objects.
//
// Bytes live in sessioncontent. Session authorization and Desktop identity
// stay in their owners. This package never infers a store root from process
// cwd, the focused tab, or an attachment id.
package attachment
+25
View File
@@ -0,0 +1,25 @@
package attachment
// RebindVerified renews an owner-transferred capability after the caller has
// validated its original. Possession of a digest is never sufficient. Previous
// credentials remain proofs for response-loss retries until explicit release.
func (d *DraftStore) RebindVerified(scope, id string) (DraftCredential, error) {
d.mu.Lock()
defer d.mu.Unlock()
item, ok := d.items[id]
if !ok || item.scope != scope {
return DraftCredential{}, Error{Code: CodeMissing, Message: "draft credential is not valid", Retry: true}
}
if !item.needsRebind {
return item.draft, nil
}
for _, bound := range d.items {
if bound.scope == scope && bound.family == item.family && !bound.needsRebind {
return bound.draft, nil
}
}
item.draft.ID = newID()
item.needsRebind = false
d.items[item.draft.ID] = item
return item.draft, nil
}
+112
View File
@@ -0,0 +1,112 @@
package attachment
import (
"errors"
"fmt"
"strings"
)
type Code string
const (
CodeMissing Code = "missing"
CodeUnreadable Code = "unreadable"
CodeUnsafe Code = "unsafe_path"
CodeUnsupported Code = "unsupported_format"
CodeCorrupt Code = "corrupt"
CodeSize Code = "size_limit"
CodeChanged Code = "changed"
CodeCanceled Code = "canceled"
CodeTooMany Code = "too_many"
CodeBatchSize Code = "batch_size"
)
// Error is safe to return across UI/RPC: Name is a display name, not a host path.
type Error struct {
Code Code
Name string
Index int
Retry bool
Cause error
Message string
Diagnose string
}
func (e Error) Error() string {
name := strings.TrimSpace(e.Name)
if name == "" {
name = "image"
}
detail := strings.TrimSpace(e.Message)
if detail == "" {
detail = defaultDetail(e.Code)
}
if e.Index > 0 {
return fmt.Sprintf("image attachment %d %q %s (%s)", e.Index, name, detail, e.Code)
}
return fmt.Sprintf("image attachment %q %s (%s)", name, detail, e.Code)
}
func (e Error) Unwrap() error { return e.Cause }
func (e Error) Retryable() bool { return e.Retry }
type BatchError []Error
func (e BatchError) Error() string {
if len(e) == 0 {
return "image attachments could not be read"
}
parts := make([]string, 0, len(e))
for _, item := range e {
parts = append(parts, item.Error())
}
return strings.Join(parts, "; ")
}
func Is(err error, code Code) bool {
var item Error
if errors.As(err, &item) {
return item.Code == code
}
var batch BatchError
if errors.As(err, &batch) {
for _, item := range batch {
if item.Code == code {
return true
}
}
}
return false
}
func canceledError(err error) Error {
return Error{Code: CodeCanceled, Message: "was canceled", Cause: err, Retry: true}
}
func defaultDetail(code Code) string {
switch code {
case CodeMissing:
return "does not exist"
case CodeUnreadable:
return "could not be read"
case CodeUnsafe:
return "has an unsafe path"
case CodeUnsupported:
return "is not an image or uses an unsupported format"
case CodeCorrupt:
return "is damaged"
case CodeSize:
return "exceeds the allowed size"
case CodeChanged:
return "changed while it was being read"
case CodeCanceled:
return "was canceled"
case CodeTooMany:
return "exceeds the allowed count"
case CodeBatchSize:
return "exceeds the allowed batch size"
default:
return "could not be read"
}
}
+57
View File
@@ -0,0 +1,57 @@
package attachment
const (
MaxSourceBytes = 64 << 20
MaxSourcePixels = 50_000_000
DefaultMaxCount = 20
DefaultBatchBytes = 200 << 20
ViewImageMaxBytes = 3 << 20
ViewImageMaxPixels = 40_000_000
VariantMaxDim = 1568
VariantJPEGQuality = 85
VariantPolicyV1 = 1
DefaultCacheBytes = 512 << 20
DefaultTransforms = 2
)
// Policy is the shared budget object. Host is the final enforcer.
type Policy struct {
MaxBytes int64
MaxPixels int64
MaxCount int
MaxBatchBytes int64
}
func DefaultPolicy() Policy {
return Policy{
MaxBytes: MaxSourceBytes,
MaxPixels: MaxSourcePixels,
MaxCount: DefaultMaxCount,
MaxBatchBytes: DefaultBatchBytes,
}
}
func ViewImagePolicy() Policy {
return Policy{
MaxBytes: ViewImageMaxBytes,
MaxPixels: ViewImageMaxPixels,
MaxCount: 1,
MaxBatchBytes: ViewImageMaxBytes,
}
}
func (p Policy) withDefaults() Policy {
if p.MaxBytes <= 0 {
p.MaxBytes = MaxSourceBytes
}
if p.MaxPixels <= 0 {
p.MaxPixels = MaxSourcePixels
}
if p.MaxCount <= 0 {
p.MaxCount = DefaultMaxCount
}
if p.MaxBatchBytes <= 0 {
p.MaxBatchBytes = DefaultBatchBytes
}
return p
}
+310
View File
@@ -0,0 +1,310 @@
package attachment
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"io"
"maps"
"sync"
"reasonix/internal/sessioncontent"
)
// Service validates images and publishes originals into a session content store.
type Service struct {
store *sessioncontent.Store
policy Policy
drafts *DraftStore
}
func NewService(store *sessioncontent.Store, _ ...any) *Service {
return &Service{store: store, policy: DefaultPolicy(), drafts: NewDraftStore()}
}
func (s *Service) WithPolicy(policy Policy) *Service {
if s == nil {
return nil
}
out := *s
out.policy = policy.withDefaults()
return &out
}
func (s *Service) Store() *sessioncontent.Store {
if s == nil {
return nil
}
return s.store
}
func (s *Service) Drafts() *DraftStore {
if s == nil {
return nil
}
return s.drafts
}
func (s *Service) PrepareBatch(ctx context.Context, sources []Source) (PreparedImages, error) {
if s == nil {
return PreparedImages{}, Error{Code: CodeUnreadable, Message: "attachment service unavailable"}
}
if err := ctx.Err(); err != nil {
return PreparedImages{}, canceledError(err)
}
policy := s.policy.withDefaults()
if len(sources) > policy.MaxCount {
return PreparedImages{}, Error{Code: CodeTooMany, Message: defaultDetail(CodeTooMany)}
}
out := PreparedImages{Items: make([]PreparedImage, 0, len(sources))}
var failures BatchError
var total int64
for i, source := range sources {
if err := ctx.Err(); err != nil {
return PreparedImages{}, canceledError(err)
}
item, err := s.prepareSource(ctx, source, policy)
if err != nil {
itemErr := asError(err)
itemErr.Index = i + 1
if itemErr.Name == "" {
itemErr.Name = source.displayName()
}
failures = append(failures, itemErr)
continue
}
size := int64(len(item.Bytes))
if item.Existing != nil {
size = item.Existing.Content.Bytes
}
total += size
if total > policy.MaxBatchBytes {
failures = append(failures, Error{Code: CodeBatchSize, Name: item.DisplayName, Index: i + 1, Message: defaultDetail(CodeBatchSize)})
continue
}
out.Items = append(out.Items, item)
}
if len(failures) > 0 {
return PreparedImages{}, failures
}
return out, nil
}
func (s *Service) prepareSource(ctx context.Context, source Source, policy Policy) (PreparedImage, error) {
if err := ctx.Err(); err != nil {
return PreparedImage{}, canceledError(err)
}
if source.Existing == nil {
return source.Read(ctx, policy)
}
ref := source.Existing
if ref.Content.Bytes > policy.MaxBytes {
return PreparedImage{}, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
}
raw, err := s.ReadVerified(ctx, *ref)
if err != nil {
return PreparedImage{}, err
}
item, err := (Source{Bytes: raw, DeclaredMIME: ref.MIME(), DisplayName: source.displayName()}).Read(ctx, policy)
if err != nil {
return PreparedImage{}, err
}
if item.Width != ref.Width || item.Height != ref.Height {
return PreparedImage{}, Error{Code: CodeCorrupt, Message: defaultDetail(CodeCorrupt)}
}
item.Existing = ref
item.Bytes = nil
return item, nil
}
func (s *Service) CommitBatch(ctx context.Context, prepared PreparedImages) ([]AttachmentRef, error) {
if s == nil || s.store == nil {
return nil, Error{Code: CodeUnreadable, Message: "attachment store unavailable"}
}
if err := ctx.Err(); err != nil {
return nil, canceledError(err)
}
refs := make([]AttachmentRef, 0, len(prepared.Items))
for i, item := range prepared.Items {
if err := ctx.Err(); err != nil {
return nil, canceledError(err)
}
if item.Existing != nil {
if err := s.store.Verify(ctx, item.Existing.Content); err != nil {
return nil, Error{Code: CodeCorrupt, Name: item.DisplayName, Index: i + 1, Message: defaultDetail(CodeCorrupt), Cause: err}
}
refs = append(refs, *item.Existing)
continue
}
ref, err := s.store.Put(ctx, bytes.NewReader(item.Bytes), sessioncontent.Metadata{MediaType: item.MIME, Name: item.DisplayName})
if err != nil {
return nil, Error{Code: CodeUnreadable, Name: item.DisplayName, Index: i + 1, Message: "could not persist the image", Cause: err, Retry: true}
}
refs = append(refs, AttachmentRef{
Version: RefVersion,
Content: ref,
Width: item.Width,
Height: item.Height,
DisplayName: item.DisplayName,
})
}
return refs, nil
}
func (s *Service) ReadVerified(ctx context.Context, ref AttachmentRef) ([]byte, error) {
if s == nil || s.store == nil {
return nil, Error{Code: CodeUnreadable, Message: "attachment store unavailable"}
}
if err := ref.Validate(); err != nil {
return nil, err
}
f, err := s.store.Open(ctx, ref.Content)
if err != nil {
return nil, Error{Code: CodeUnreadable, Name: ref.DisplayName, Message: defaultDetail(CodeUnreadable), Cause: err}
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
return nil, Error{Code: CodeUnreadable, Name: ref.DisplayName, Message: defaultDetail(CodeUnreadable), Cause: err}
}
if int64(len(raw)) != ref.Content.Bytes {
return nil, Error{Code: CodeCorrupt, Name: ref.DisplayName, Message: defaultDetail(CodeCorrupt)}
}
return raw, nil
}
func (s *Service) InputsFromRefs(refs []AttachmentRef) []ImageInput {
out := make([]ImageInput, 0, len(refs))
for i := range refs {
ref := refs[i]
out = append(out, ImageInput{Kind: KindAttachment, Attachment: &ref})
}
return out
}
func asError(err error) Error {
var item Error
if err != nil && errorsAs(err, &item) {
return item
}
return Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
}
func newID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic("attachment: random id: " + err.Error())
}
return hex.EncodeToString(b[:])
}
// DraftStore issues opaque draft credentials bound to a caller-chosen key.
type DraftStore struct {
mu sync.Mutex
items map[string]draftRecord
}
type draftRecord struct {
scope string
draft DraftCredential
family string
needsRebind bool
}
func NewDraftStore() *DraftStore {
return &DraftStore{items: map[string]draftRecord{}}
}
func (d *DraftStore) Issue(scope string, ref AttachmentRef) DraftCredential {
if d == nil {
d = NewDraftStore()
}
draft := DraftCredential{
ID: newID(),
Ref: ref,
DisplayName: ref.DisplayName,
MIME: ref.MIME(),
Width: ref.Width,
Height: ref.Height,
Bytes: ref.Content.Bytes,
}
d.mu.Lock()
d.items[draft.ID] = draftRecord{scope: scope, draft: draft, family: draft.ID}
d.mu.Unlock()
return draft
}
func (d *DraftStore) Lookup(scope, id string) (DraftCredential, bool) {
if d == nil {
return DraftCredential{}, false
}
d.mu.Lock()
defer d.mu.Unlock()
item, ok := d.items[id]
if !ok || item.scope != scope {
return DraftCredential{}, false
}
return item.draft, true
}
func (d *DraftStore) Resolve(scope string, ids []string) ([]AttachmentRef, error) {
refs := make([]AttachmentRef, 0, len(ids))
for i, id := range ids {
draft, ok := d.Lookup(scope, id)
if !ok {
return nil, Error{Code: CodeMissing, Name: "image", Index: i + 1, Message: "draft credential is not valid", Retry: true}
}
refs = append(refs, draft.Ref)
}
return refs, nil
}
func (d *DraftStore) Release(scope, id string) {
if d == nil {
return
}
d.mu.Lock()
defer d.mu.Unlock()
item, ok := d.items[id]
if ok && item.scope == scope {
for key, related := range d.items {
if related.scope == scope && related.family == item.family {
delete(d.items, key)
}
}
}
}
func (d *DraftStore) ReleaseScope(scope string) {
if d == nil {
return
}
d.mu.Lock()
defer d.mu.Unlock()
for id, item := range d.items {
if item.scope == scope {
delete(d.items, id)
}
}
}
// CopyScopeTo transfers credentials during an owner-authorized replacement.
// It cannot grant access to another session or storage generation.
func (d *DraftStore) CopyScopeTo(scope string, target *DraftStore) {
if d == nil || target == nil || d == target {
return
}
d.mu.Lock()
items := make(map[string]draftRecord)
for id, item := range d.items {
if item.scope == scope {
item.needsRebind = true
items[id] = item
}
}
d.mu.Unlock()
target.mu.Lock()
defer target.mu.Unlock()
maps.Copy(target.items, items)
}
+229
View File
@@ -0,0 +1,229 @@
package attachment
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/sessioncontent"
)
func TestPrepareAndCommitRoundTrip(t *testing.T) {
svc := testService(t)
raw := opaquePNG(t, 8, 8)
prepared, err := svc.PrepareBatch(t.Context(), []Source{{DisplayName: "shot.png", Bytes: raw, DeclaredMIME: "image/png"}})
if err != nil {
t.Fatal(err)
}
refs, err := svc.CommitBatch(t.Context(), prepared)
if err != nil {
t.Fatal(err)
}
if len(refs) != 1 || refs[0].Width != 8 || refs[0].MIME() != "image/png" {
t.Fatalf("refs = %+v", refs)
}
got, err := svc.ReadVerified(t.Context(), refs[0])
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, raw) {
t.Fatal("persisted bytes diverged from the original")
}
sum := sha256.Sum256(raw)
if refs[0].Content.Digest != hex.EncodeToString(sum[:]) {
t.Fatal("object identity is not the original digest")
}
}
func TestPrepareBatchRejectsPartialFailure(t *testing.T) {
svc := testService(t)
_, err := svc.PrepareBatch(t.Context(), []Source{
{DisplayName: "ok.png", Bytes: opaquePNG(t, 2, 2)},
{DisplayName: "bad.bin", Bytes: []byte("not-an-image")},
})
if !Is(err, CodeUnsupported) {
t.Fatalf("err = %v", err)
}
}
func TestPrepareBatchRejectsDeclaredMIMEMismatch(t *testing.T) {
svc := testService(t)
_, err := svc.PrepareBatch(t.Context(), []Source{{
DisplayName: "spoof.png", Bytes: opaquePNG(t, 2, 2), DeclaredMIME: "image/jpeg",
}})
if !Is(err, CodeUnsupported) {
t.Fatalf("err = %v", err)
}
}
func TestPrepareBatchUsesWorkspaceRootNotProcessCWD(t *testing.T) {
svc := testService(t)
cwd := t.TempDir()
workspace := t.TempDir()
original, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(cwd); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(original) })
if err := os.WriteFile(filepath.Join(cwd, "same.png"), opaquePNG(t, 2, 2), 0o600); err != nil {
t.Fatal(err)
}
want := opaquePNG(t, 4, 4)
if err := os.WriteFile(filepath.Join(workspace, "same.png"), want, 0o600); err != nil {
t.Fatal(err)
}
prepared, err := svc.PrepareBatch(t.Context(), []Source{{Path: "same.png", WorkspaceRoot: workspace}})
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(prepared.Items[0].Bytes, want) || prepared.Items[0].Width != 4 {
t.Fatal("prepared the process-directory image")
}
}
func TestPrepareBatchRejectsSymlinkAndChangedFile(t *testing.T) {
svc := testService(t)
root := t.TempDir()
target := filepath.Join(root, "real.png")
if err := os.WriteFile(target, opaquePNG(t, 2, 2), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "link.png")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err := svc.PrepareBatch(t.Context(), []Source{{Path: link}}); !Is(err, CodeUnsafe) {
t.Fatalf("symlink err = %v", err)
}
path := filepath.Join(root, "changing.png")
if err := os.WriteFile(path, opaquePNG(t, 2, 2), 0o600); err != nil {
t.Fatal(err)
}
src := Source{Path: path}
raw, err := src.loadBytes(DefaultPolicy())
if err != nil {
t.Fatal(err)
}
if len(raw) == 0 {
t.Fatal("expected bytes")
}
}
func TestCommitDoesNotReturnPartialRefs(t *testing.T) {
svc := NewService(nil, nil)
_, err := svc.CommitBatch(t.Context(), PreparedImages{Items: []PreparedImage{{DisplayName: "a", Bytes: opaquePNG(t, 2, 2)}}})
if err == nil {
t.Fatal("expected store failure")
}
}
func TestDraftsCannotBeGuessedByDigest(t *testing.T) {
svc := testService(t)
prepared, err := svc.PrepareBatch(t.Context(), []Source{{Bytes: opaquePNG(t, 2, 2)}})
if err != nil {
t.Fatal(err)
}
refs, err := svc.CommitBatch(t.Context(), prepared)
if err != nil {
t.Fatal(err)
}
draft := svc.Drafts().Issue("session-a", refs[0])
if _, ok := svc.Drafts().Lookup("session-b", draft.ID); ok {
t.Fatal("draft leaked across scopes")
}
if _, ok := svc.Drafts().Lookup("session-a", refs[0].Content.Digest); ok {
t.Fatal("digest acted as a draft credential")
}
got, err := svc.Drafts().Resolve("session-a", []string{draft.ID})
if err != nil || got[0].Content.Digest != refs[0].Content.Digest {
t.Fatalf("resolve = %v %v", got, err)
}
svc.Drafts().Release("session-a", draft.ID)
if _, err := svc.Drafts().Resolve("session-a", []string{draft.ID}); !Is(err, CodeMissing) {
t.Fatalf("released draft err = %v", err)
}
}
func TestCollectJSONRefsFindsNestedAttachments(t *testing.T) {
ref := sessioncontent.Ref{Digest: strings.Repeat("ab", 32), Bytes: 12, IndexDigest: strings.Repeat("cd", 32)}
payload := jsonMarshal(map[string]any{
"message": map[string]any{
"image_inputs": []any{map[string]any{
"kind": "attachment",
"attachment": map[string]any{
"v": 1,
"content": map[string]any{"digest": ref.Digest, "bytes": ref.Bytes, "indexDigest": ref.IndexDigest},
},
}},
},
})
got := CollectJSONRefs(payload)
if len(got) != 1 || got[0].Digest != ref.Digest || got[0].Bytes != ref.Bytes {
t.Fatalf("got = %+v", got)
}
}
func TestNormalizeDisplayNameStripsPathAndControls(t *testing.T) {
if got := NormalizeDisplayName("../secret/\x00shot.png"); got != "shot.png" {
t.Fatalf("got %q", got)
}
}
func TestViewImagePolicyRejectsOversize(t *testing.T) {
svc := testService(t).WithPolicy(Policy{MaxBytes: 32, MaxPixels: 4, MaxCount: 1, MaxBatchBytes: 32})
_, err := svc.PrepareBatch(t.Context(), []Source{{Bytes: opaquePNG(t, 8, 8)}})
if !Is(err, CodeSize) {
t.Fatalf("err = %v", err)
}
}
func testService(t *testing.T) *Service {
t.Helper()
return NewService(sessioncontent.New(t.TempDir()))
}
func opaquePNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{R: 20, G: 40, B: 60, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func jsonMarshal(v any) []byte {
buf, err := json.Marshal(v)
if err != nil {
panic(err)
}
return buf
}
func TestTinyPNGDetect(t *testing.T) {
raw, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
if err != nil {
t.Fatal(err)
}
if DetectMIME(raw) != "image/png" {
t.Fatal(DetectMIME(raw))
}
}
+239
View File
@@ -0,0 +1,239 @@
package attachment
import (
"context"
"encoding/base64"
"errors"
"io"
"os"
"path/filepath"
"strings"
)
// Source is one local image to admit. Exactly one of Bytes, Path, or DataURL
// must be set. Path is resolved against WorkspaceRoot when relative.
type Source struct {
DisplayName string
DeclaredMIME string
Bytes []byte
Path string
WorkspaceRoot string
Confine string
DataURL string
Existing *AttachmentRef
}
func (s Source) displayName() string {
if strings.TrimSpace(s.DisplayName) != "" {
return NormalizeDisplayName(s.DisplayName)
}
if s.Path != "" {
return NormalizeDisplayName(filepath.Base(s.Path))
}
return "image"
}
func (s Source) Read(ctx context.Context, policy Policy) (PreparedImage, error) {
if err := ctx.Err(); err != nil {
return PreparedImage{}, canceledError(err)
}
if s.Existing != nil {
if err := s.Existing.Validate(); err != nil {
return PreparedImage{}, err
}
return PreparedImage{DisplayName: s.displayName(), MIME: s.Existing.MIME(), Width: s.Existing.Width, Height: s.Existing.Height, Existing: s.Existing}, nil
}
raw, err := s.loadBytes(policy)
if err != nil {
err = annotateName(err, s.displayName())
return PreparedImage{}, err
}
verified, err := verifyImageBytes(raw, s.DeclaredMIME, policy)
if err != nil {
err = annotateName(err, s.displayName())
return PreparedImage{}, err
}
return PreparedImage{
DisplayName: s.displayName(),
MIME: verified.MIME,
Width: verified.Width,
Height: verified.Height,
Bytes: verified.Bytes,
}, nil
}
func (s Source) loadBytes(policy Policy) ([]byte, error) {
switch {
case len(s.Bytes) > 0 && s.Path == "" && s.DataURL == "":
if int64(len(s.Bytes)) > policy.MaxBytes {
return nil, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
}
return s.Bytes, nil
case s.DataURL != "" && s.Path == "" && len(s.Bytes) == 0:
return decodeDataURL(s.DataURL, policy.MaxBytes)
case s.Path != "" && s.DataURL == "" && len(s.Bytes) == 0:
return readPathBytes(s.WorkspaceRoot, s.Path, s.Confine, policy.MaxBytes)
default:
return nil, Error{Code: CodeUnsupported, Message: "image source is malformed"}
}
}
func decodeDataURL(dataURL string, maxBytes int64) ([]byte, error) {
const marker = ";base64,"
if !strings.HasPrefix(dataURL, "data:") {
return nil, Error{Code: CodeUnsupported, Message: defaultDetail(CodeUnsupported)}
}
i := strings.Index(dataURL, marker)
if i <= len("data:") {
return nil, Error{Code: CodeUnsupported, Message: defaultDetail(CodeUnsupported)}
}
encoded := dataURL[i+len(marker):]
if int64(len(encoded)) > ((maxBytes+2)/3)*4 {
return nil, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
}
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, Error{Code: CodeCorrupt, Message: defaultDetail(CodeCorrupt), Cause: err}
}
if len(raw) == 0 || int64(len(raw)) > maxBytes {
return nil, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
}
return raw, nil
}
func readPathBytes(root, path, confine string, maxBytes int64) ([]byte, error) {
resolved, err := resolveSourcePath(root, path)
if err != nil {
return nil, err
}
if confine != "" {
absRoot, rootErr := filepath.Abs(root)
if rootErr != nil {
return nil, Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe), Cause: rootErr}
}
confineAbs := filepath.Join(absRoot, filepath.FromSlash(confine))
rel, relErr := filepath.Rel(confineAbs, resolved)
if relErr != nil || strings.HasPrefix(rel, "..") {
return nil, Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe)}
}
}
// Resolve through an open workspace root. Parent symlink replacement must
// never turn a validated attachment path into a host filesystem read.
var bounded *os.Root
readName := resolved
if confine != "" {
bounded, err = os.OpenRoot(root)
if err != nil {
return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err}
}
defer bounded.Close()
absRoot, _ := filepath.Abs(root)
readName, err = filepath.Rel(absRoot, resolved)
if err != nil || !filepath.IsLocal(readName) {
return nil, Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe)}
}
}
lstat := os.Lstat
open := func(name string) (*os.File, error) { return os.OpenFile(name, imageReadFlags, 0) }
if bounded != nil {
lstat = bounded.Lstat
open = func(name string) (*os.File, error) { return bounded.OpenFile(name, imageReadFlags, 0) }
}
info, err := lstat(readName)
if err != nil {
if os.IsNotExist(err) {
return nil, Error{Code: CodeMissing, Message: defaultDetail(CodeMissing), Cause: err, Retry: true}
}
return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe)}
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxBytes {
return nil, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
}
f, err := open(readName)
if err != nil {
if os.IsNotExist(err) {
return nil, Error{Code: CodeMissing, Message: defaultDetail(CodeMissing), Cause: err, Retry: true}
}
return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
}
defer f.Close()
opened, err := f.Stat()
if err != nil {
return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
}
if !os.SameFile(info, opened) {
return nil, Error{Code: CodeChanged, Message: defaultDetail(CodeChanged), Retry: true}
}
if !sameSourceEntry(lstat, readName, opened) {
return nil, Error{Code: CodeChanged, Message: defaultDetail(CodeChanged), Retry: true}
}
raw, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
if err != nil {
return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
}
if len(raw) == 0 || int64(len(raw)) > maxBytes {
return nil, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
}
if after, err := f.Stat(); err != nil {
return nil, Error{Code: CodeUnreadable, Message: defaultDetail(CodeUnreadable), Cause: err, Retry: true}
} else if !os.SameFile(opened, after) || after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) {
return nil, Error{Code: CodeChanged, Message: defaultDetail(CodeChanged), Retry: true}
}
if !sameSourceEntry(lstat, readName, opened) {
return nil, Error{Code: CodeChanged, Message: defaultDetail(CodeChanged), Retry: true}
}
return raw, nil
}
func sameSourceEntry(lstat func(string) (os.FileInfo, error), name string, opened os.FileInfo) bool {
current, err := lstat(name)
return err == nil && current.Mode()&os.ModeSymlink == 0 && os.SameFile(opened, current)
}
// ReadWorkspaceImageBytes shares confined, race-resistant file access with
// legacy preview callers, which retain their own decoding policy.
func ReadWorkspaceImageBytes(root, path string, maxBytes int64) ([]byte, error) {
return readPathBytes(root, path, ".reasonix/attachments", maxBytes)
}
func resolveSourcePath(root, path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe)}
}
if filepath.IsAbs(path) {
return filepath.Clean(path), nil
}
root = strings.TrimSpace(root)
if root == "" {
return "", Error{Code: CodeUnsafe, Message: "workspace root is required for relative image paths"}
}
absRoot, err := filepath.Abs(root)
if err != nil {
return "", Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe), Cause: err}
}
joined := filepath.Join(absRoot, filepath.FromSlash(path))
rel, err := filepath.Rel(absRoot, joined)
if err != nil || strings.HasPrefix(rel, "..") {
return "", Error{Code: CodeUnsafe, Message: defaultDetail(CodeUnsafe)}
}
return joined, nil
}
func annotateName(err error, name string) error {
var item Error
if errorsAs(err, &item) {
if item.Name == "" {
item.Name = name
}
return item
}
return err
}
func errorsAs(err error, target *Error) bool {
return errors.As(err, target)
}
@@ -0,0 +1,7 @@
//go:build !unix
package attachment
import "os"
const imageReadFlags = os.O_RDONLY
+11
View File
@@ -0,0 +1,11 @@
//go:build unix
package attachment
import (
"os"
"syscall"
)
// A file replaced by a FIFO between Lstat and Open must not stall admission.
const imageReadFlags = os.O_RDONLY | syscall.O_NONBLOCK | syscall.O_NOFOLLOW
+140
View File
@@ -0,0 +1,140 @@
package attachment
import "reasonix/internal/sessioncontent"
const (
RefVersion = 1
KindAttachment Kind = "attachment"
KindURL Kind = "url"
KindFiles Kind = "files"
)
// Kind selects one ImageInput arm. Empty is invalid.
type Kind string
// AttachmentRef is the durable identity of one verified original image.
// Object identity is the SHA-256 of the original bytes (Content.Digest).
type AttachmentRef struct {
Version int `json:"v"`
Content sessioncontent.Ref `json:"content"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
DisplayName string `json:"name,omitempty"`
}
// ImageInput is an ordered union stored on new messages. A message must not
// combine this field with legacy Images []string.
type ImageInput struct {
Kind Kind `json:"kind"`
Attachment *AttachmentRef `json:"attachment,omitempty"`
URL string `json:"url,omitempty"`
FilesID string `json:"filesId,omitempty"`
}
// PreparedImage is one verified original, held only for the current submit.
type PreparedImage struct {
DisplayName string
MIME string
Width int
Height int
Bytes []byte
Existing *AttachmentRef
}
// PreparedImages is an ordered, all-or-nothing batch. Commit publishes every
// object or none of the returned refs.
type PreparedImages struct {
Items []PreparedImage
}
// DraftCredential is a host-issued handle. Clients cannot mint one from a digest.
type DraftCredential struct {
ID string `json:"id"`
Ref AttachmentRef `json:"-"`
DisplayName string `json:"displayName"`
MIME string `json:"mime"`
Width int `json:"width"`
Height int `json:"height"`
Bytes int64 `json:"bytes"`
}
// Variant is a deterministic request encoding of one original.
type Variant struct {
Bytes []byte
MIME string
Width int
Height int
SourceWidth int
SourceHeight int
PolicyVersion int
Digest string
}
func (r AttachmentRef) Digest() string {
return r.Content.Digest
}
func (r AttachmentRef) MIME() string {
return r.Content.MediaType
}
func (r AttachmentRef) Validate() error {
if r.Version != RefVersion {
return Error{Code: CodeUnsupported, Message: "unsupported attachment reference version"}
}
if r.Content.Digest == "" || r.Content.Bytes <= 0 {
return Error{Code: CodeCorrupt, Message: "attachment reference is incomplete"}
}
if r.Width <= 0 || r.Height <= 0 {
return Error{Code: CodeCorrupt, Message: "attachment reference is missing dimensions"}
}
return nil
}
func (in ImageInput) Validate() error {
switch in.Kind {
case KindAttachment:
if in.Attachment == nil || in.URL != "" || in.FilesID != "" {
return Error{Code: CodeUnsupported, Message: "attachment image input is malformed"}
}
return in.Attachment.Validate()
case KindURL:
if in.Attachment != nil || in.FilesID != "" || in.URL == "" {
return Error{Code: CodeUnsupported, Message: "url image input is malformed"}
}
return nil
case KindFiles:
if in.Attachment != nil || in.URL != "" || in.FilesID == "" {
return Error{Code: CodeUnsupported, Message: "files image input is malformed"}
}
return nil
default:
return Error{Code: CodeUnsupported, Message: "unknown image input kind"}
}
}
func CloneImageInputs(in []ImageInput) []ImageInput {
if len(in) == 0 {
return nil
}
out := make([]ImageInput, len(in))
copy(out, in)
for i := range out {
if out[i].Attachment != nil {
ref := *out[i].Attachment
out[i].Attachment = &ref
}
}
return out
}
func CollectContentRefs(inputs []ImageInput) []sessioncontent.Ref {
var refs []sessioncontent.Ref
for _, in := range inputs {
if in.Kind == KindAttachment && in.Attachment != nil && in.Attachment.Content.Digest != "" {
refs = append(refs, in.Attachment.Content)
}
}
return refs
}
+155
View File
@@ -0,0 +1,155 @@
package attachment
import (
"bytes"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"strings"
"unicode"
"unicode/utf8"
_ "golang.org/x/image/webp"
)
var sniffHeaders = []struct {
mime string
prefix []byte
}{
{"image/png", []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}},
{"image/gif", []byte("GIF87a")},
{"image/gif", []byte("GIF89a")},
{"image/webp", []byte("RIFF")},
{"image/jpeg", []byte{0xff, 0xd8, 0xff}},
}
func DetectMIME(raw []byte) string {
for _, h := range sniffHeaders {
if !bytes.HasPrefix(raw, h.prefix) {
continue
}
if h.mime == "image/webp" && (len(raw) < 12 || string(raw[8:12]) != "WEBP") {
continue
}
return h.mime
}
return ""
}
func NormalizeDisplayName(name string) string {
name = strings.ReplaceAll(name, "\\", "/")
if i := strings.LastIndex(name, "/"); i >= 0 {
name = name[i+1:]
}
var b strings.Builder
for _, r := range name {
if r < 32 || r == 127 || !utf8.ValidRune(r) || unicode.Is(unicode.C, r) {
continue
}
b.WriteRune(r)
}
out := strings.TrimSpace(b.String())
if out == "" {
return "image"
}
return out
}
type verifiedImage struct {
MIME string
Width int
Height int
Bytes []byte
}
func ValidateImage(raw []byte, declaredMIME string, policy Policy) (mime string, width, height int, err error) {
verified, err := verifyImageBytes(raw, declaredMIME, policy)
if err != nil {
return "", 0, 0, err
}
return verified.MIME, verified.Width, verified.Height, nil
}
func verifyImageBytes(raw []byte, declaredMIME string, policy Policy) (verifiedImage, error) {
policy = policy.withDefaults()
if len(raw) == 0 || int64(len(raw)) > policy.MaxBytes {
return verifiedImage{}, Error{Code: CodeSize, Message: defaultDetail(CodeSize)}
}
mime := DetectMIME(raw)
if mime == "" {
return verifiedImage{}, Error{Code: CodeUnsupported, Message: defaultDetail(CodeUnsupported)}
}
if declared := normalizeDeclaredMIME(declaredMIME); declared != "" && declared != mime {
return verifiedImage{}, Error{Code: CodeUnsupported, Message: "declared MIME does not match image bytes"}
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return verifiedImage{}, Error{Code: CodeCorrupt, Message: defaultDetail(CodeCorrupt), Cause: err}
}
if imageMIME(format) != mime {
return verifiedImage{}, Error{Code: CodeUnsupported, Message: "decoded format does not match image bytes"}
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return verifiedImage{}, Error{Code: CodeCorrupt, Message: defaultDetail(CodeCorrupt)}
}
if int64(cfg.Width)*int64(cfg.Height) > policy.MaxPixels {
return verifiedImage{}, Error{Code: CodeSize, Message: "exceeds the allowed pixel count"}
}
decoded, decodedFormat, err := image.Decode(bytes.NewReader(raw))
if err != nil || decoded == nil {
return verifiedImage{}, Error{Code: CodeCorrupt, Message: defaultDetail(CodeCorrupt), Cause: err}
}
bounds := decoded.Bounds()
if imageMIME(decodedFormat) != mime || bounds.Dx() <= 0 || bounds.Dy() <= 0 {
return verifiedImage{}, Error{Code: CodeCorrupt, Message: defaultDetail(CodeCorrupt)}
}
return verifiedImage{MIME: mime, Width: cfg.Width, Height: cfg.Height, Bytes: append([]byte(nil), raw...)}, nil
}
func normalizeDeclaredMIME(mime string) string {
mime = strings.ToLower(strings.TrimSpace(strings.SplitN(mime, ";", 2)[0]))
switch mime {
case "image/png", "image/jpeg", "image/gif", "image/webp":
return mime
case "image/jpg":
return "image/jpeg"
default:
return ""
}
}
func imageMIME(format string) string {
switch format {
case "png":
return "image/png"
case "jpeg":
return "image/jpeg"
case "gif":
return "image/gif"
case "webp":
return "image/webp"
default:
return ""
}
}
func hasAlpha(img image.Image) bool {
switch img.(type) {
case *image.NRGBA, *image.NRGBA64, *image.RGBA, *image.RGBA64, *image.Alpha, *image.Alpha16:
return true
}
if img == nil {
return false
}
b := img.Bounds()
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
_, _, _, a := img.At(x, y).RGBA()
if a != 0xffff {
return true
}
}
}
return false
}
+114
View File
@@ -0,0 +1,114 @@
package attachment
import (
"encoding/json"
"reasonix/internal/sessioncontent"
)
// CollectJSONRefs extracts attachment objects from any JSON value that may
// contain ImageInput or AttachmentRef objects. It is used by export, import,
// and authorization rebuilds.
func CollectJSONRefs(raw []byte) []sessioncontent.Ref {
if len(raw) == 0 {
return nil
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return nil
}
seen := map[string]sessioncontent.Ref{}
walkValue(value, seen)
if len(seen) == 0 {
return nil
}
out := make([]sessioncontent.Ref, 0, len(seen))
for _, ref := range seen {
out = append(out, ref)
}
return out
}
func walkValue(value any, seen map[string]sessioncontent.Ref) {
switch item := value.(type) {
case map[string]any:
if ref, ok := attachmentRefFromMap(item); ok {
key := ref.Digest + ":" + itoa(ref.Bytes) + ":" + ref.IndexDigest
seen[key] = ref
}
for _, nested := range item {
walkValue(nested, seen)
}
case []any:
for _, nested := range item {
walkValue(nested, seen)
}
}
}
func attachmentRefFromMap(item map[string]any) (sessioncontent.Ref, bool) {
content, _ := item["content"].(map[string]any)
if content == nil {
if _, ok := item["digest"].(string); ok && item["v"] == nil {
content = item
}
}
if content == nil {
return sessioncontent.Ref{}, false
}
digest, _ := content["digest"].(string)
if digest == "" {
return sessioncontent.Ref{}, false
}
ref := sessioncontent.Ref{
Digest: digest,
MediaType: stringField(content, "mediaType"),
Name: stringField(content, "name"),
IndexDigest: stringField(content, "indexDigest"),
}
switch bytes := content["bytes"].(type) {
case float64:
ref.Bytes = int64(bytes)
case json.Number:
n, _ := bytes.Int64()
ref.Bytes = n
}
switch block := content["integrityBlockBytes"].(type) {
case float64:
ref.IntegrityBlock = int64(block)
case json.Number:
n, _ := block.Int64()
ref.IntegrityBlock = n
}
if ref.Bytes <= 0 {
return sessioncontent.Ref{}, false
}
return ref, true
}
func stringField(item map[string]any, key string) string {
value, _ := item[key].(string)
return value
}
func itoa(n int64) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+185
View File
@@ -0,0 +1,185 @@
package boot
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/plugin"
"reasonix/internal/provider"
"strings"
"sync/atomic"
"testing"
)
func TestBuildDeepSeekTextParentCanUseImageReturningMCPAndVisionSubagent(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootSubagentTestProvider()
prov := &bootSubagentTestProvider{combinedVision: true}
setBootSubagentTestProvider(t, prov)
if _, err := config.SetCredential("BOOT_DEEPSEEK_TEST_KEY", "test-key"); err != nil {
t.Fatalf("store test DeepSeek credential: %v", err)
}
writeFile(t, dir, "reasonix.toml", `
default_model = "parent"
[agent]
system_prompt = "BASE"
subagent_model = "vision-model"
vision_model = "vision-model"
[[providers]]
name = "parent"
kind = "boot-subagent-test"
base_url = "https://api.deepseek.com/anthropic"
model = "x"
api_key_env = "BOOT_DEEPSEEK_TEST_KEY"
[[providers]]
name = "vision-model"
kind = "boot-subagent-test"
base_url = "https://vision.example.invalid"
model = "x"
vision = true
`)
png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
if err != nil {
t.Fatalf("decode test png: %v", err)
}
if err := os.MkdirAll(filepath.Join(dir, ".reasonix", "attachments"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".reasonix", "attachments", "shot.png"), png, 0o644); err != nil {
t.Fatal(err)
}
mcpImage := base64.StdEncoding.EncodeToString(png)
var mcpCalls atomic.Int32
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID *int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if request.ID == nil {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch request.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2024-11-05",
"serverInfo": map[string]any{"name": "vision-reader", "version": "1"},
"capabilities": map[string]any{"tools": map[string]any{}},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "inspect",
"description": "Inspect an image file by path.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{"path": map[string]any{"type": "string"}},
"required": []string{"path"},
},
"annotations": map[string]any{"readOnlyHint": true},
}}}
case "tools/call":
mcpCalls.Add(1)
result = map[string]any{"content": []map[string]any{
{"type": "text", "text": "vision-mcp-ok"},
{"type": "image", "mimeType": "image/png", "data": mcpImage},
}}
default:
http.Error(w, "unsupported method", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
}))
defer mcpServer.Close()
ctrl, err := Build(context.Background(), Options{
Sink: event.Discard,
ExtraPlugins: []plugin.Spec{{
Name: "vision-reader",
Type: "http",
URL: mcpServer.URL,
Authorized: true,
}},
})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if ctrl.ImageInputEnabled() {
loaded, loadErr := config.LoadForRoot(dir)
resolved, _ := loaded.ResolveModel(ctrl.ModelRef())
t.Fatalf("official DeepSeek parent unexpectedly enables direct images: ref=%q entry=%+v load_err=%v", ctrl.ModelRef(), resolved, loadErr)
}
if err := ctrl.Run(context.Background(), "review @.reasonix/attachments/shot.png"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.requestsSnapshot()
if len(reqs) < 4 {
t.Fatalf("provider requests = %d, want parent MCP call, parent subagent call, vision child, and parent final", len(reqs))
}
if got := mcpCalls.Load(); got != 1 {
t.Fatalf("vision MCP calls = %d, want 1", got)
}
// MCP tools stay behind use_capability; review is registered for dispatch.
if !requestHasTool(reqs[0], "use_capability") {
t.Fatalf("parent tools = %v, want use_capability for MCP/review dispatch", toolSchemaNames(reqs[0].Tools))
}
registered := map[string]bool{}
for _, e := range ctrl.AllToolContractEntries() {
registered[e.Name] = true
}
if !registered["review"] && !registered["run_skill"] {
t.Fatalf("capability registry missing review/run_skill: %v", registered)
}
if got := bootLastUser(reqs[0]); !strings.Contains(got, "@.reasonix/attachments/shot.png") {
t.Fatalf("text-only parent lost the attachment reference needed by MCP: %q", got)
}
// The text parent receives summaries; native images remain available to
// the independently routed vision child.
for _, requestIndex := range []int{0, 1, len(reqs) - 1} {
for _, msg := range reqs[requestIndex].Messages {
if msg.Role == provider.RoleUser && len(msg.Images) != 0 {
t.Fatalf("text-only parent request %d embedded %d direct attachment(s): %+v", requestIndex, len(msg.Images), reqs[requestIndex].Messages)
}
}
}
if !requestMessageContains(reqs[1].Messages, provider.RoleTool, "vision-mcp-ok") {
t.Fatalf("parent did not receive the vision MCP text result: %+v", reqs[1].Messages)
}
var parentMCPImageCount int
for _, msg := range reqs[1].Messages {
if msg.Role == provider.RoleTool && msg.Name == "mcp__vision-reader__inspect" {
parentMCPImageCount += len(msg.Images)
}
}
if parentMCPImageCount != 0 {
t.Fatalf("text parent MCP result images = %d, want summary only", parentMCPImageCount)
}
var childImageCount int
for _, msg := range reqs[2].Messages {
if msg.Role == provider.RoleUser {
childImageCount = len(msg.Images)
}
}
if childImageCount != 1 {
t.Fatalf("vision child user images = %d, want one attachment; request = %+v", childImageCount, reqs[2].Messages)
}
}
+3 -3
View File
@@ -1176,7 +1176,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
WithProfileConfigResolvers(profileConfigModel, profileConfigEffort).
WithBashSandboxEnforced(bashSandboxEnforced).
WithCapabilityRuntime(capRuntime).
WithWriteRoots(writeRootSet)
WithWriteRoots(writeRootSet).WithImageRequestResolver(controllerImageResolver{ctrlRef.Load})
}
addTaskTool := func() string {
if opts.Ablation.Off(ablation.Subagent) {
@@ -1279,7 +1279,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
// read_only_task, so they cannot write, install, mutate memory, resume/fork
// transcripts, or delegate further.
//
subagentSkillOptions := newSubagentSkillOptionsFactory(cfg.Agent, quoteCtx, headlessGate, keepPolicy, maxSubagentDepth, opts.Ablation, workspaceLease, writeRootSet)
subagentSkillOptions := newSubagentSkillOptionsFactory(cfg.Agent, quoteCtx, headlessGate, keepPolicy, maxSubagentDepth, opts.Ablation, workspaceLease, writeRootSet, childImageRouting{ctrlRef.Load, imageConfig})
readOnlySkillRunner := func(sctx context.Context, sk skill.Skill, task string, runOpts skill.SubagentRunOptions) (string, error) {
if strings.TrimSpace(runOpts.ContinueFrom) != "" || strings.TrimSpace(runOpts.ForkFrom) != "" {
return "", fmt.Errorf("read_only_skill does not support continue_from/fork_from")
@@ -1946,7 +1946,7 @@ func build(ctx context.Context, opts Options) (*BuildResult, error) {
// Goal evaluator is not implied by the main model, guardian, or recovery
// reviewer. Controllers that want one inject it explicitly; otherwise Goal
// uses the deterministic host policy.
ctrl := control.New(ctrlOpts)
ctrl := newControllerWithImageRoutes(ctrlOpts, cfg)
// Validate and consume retired role inputs without changing runtime policy.
_, _ = agentpreset.Normalize(firstNonEmpty(opts.AgentPreset, opts.TokenMode))
// Publish the controller to the extension UI hub's indirection: from here
+21 -170
View File
@@ -4,7 +4,6 @@ import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -19,7 +18,6 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -610,173 +608,6 @@ model = "x"
}
}
func TestBuildDeepSeekTextParentCanUseImageReturningMCPAndVisionSubagent(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
registerBootSubagentTestProvider()
prov := &bootSubagentTestProvider{combinedVision: true}
setBootSubagentTestProvider(t, prov)
if _, err := config.SetCredential("BOOT_DEEPSEEK_TEST_KEY", "test-key"); err != nil {
t.Fatalf("store test DeepSeek credential: %v", err)
}
writeFile(t, dir, "reasonix.toml", `
default_model = "parent"
[agent]
system_prompt = "BASE"
subagent_model = "vision-model"
[[providers]]
name = "parent"
kind = "boot-subagent-test"
base_url = "https://api.deepseek.com/anthropic"
model = "x"
api_key_env = "BOOT_DEEPSEEK_TEST_KEY"
[[providers]]
name = "vision-model"
kind = "boot-subagent-test"
base_url = "https://vision.example.invalid"
model = "x"
vision = true
`)
png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==")
if err != nil {
t.Fatalf("decode test png: %v", err)
}
if err := os.MkdirAll(filepath.Join(dir, ".reasonix", "attachments"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".reasonix", "attachments", "shot.png"), png, 0o644); err != nil {
t.Fatal(err)
}
mcpImage := base64.StdEncoding.EncodeToString(png)
var mcpCalls atomic.Int32
mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID *int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if request.ID == nil {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch request.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2024-11-05",
"serverInfo": map[string]any{"name": "vision-reader", "version": "1"},
"capabilities": map[string]any{"tools": map[string]any{}},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "inspect",
"description": "Inspect an image file by path.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{"path": map[string]any{"type": "string"}},
"required": []string{"path"},
},
"annotations": map[string]any{"readOnlyHint": true},
}}}
case "tools/call":
mcpCalls.Add(1)
result = map[string]any{"content": []map[string]any{
{"type": "text", "text": "vision-mcp-ok"},
{"type": "image", "mimeType": "image/png", "data": mcpImage},
}}
default:
http.Error(w, "unsupported method", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result})
}))
defer mcpServer.Close()
ctrl, err := Build(context.Background(), Options{
Sink: event.Discard,
ExtraPlugins: []plugin.Spec{{
Name: "vision-reader",
Type: "http",
URL: mcpServer.URL,
Authorized: true,
}},
})
if err != nil {
t.Fatalf("Build: %v", err)
}
defer ctrl.Close()
if ctrl.ImageInputEnabled() {
loaded, loadErr := config.LoadForRoot(dir)
resolved, _ := loaded.ResolveModel(ctrl.ModelRef())
t.Fatalf("official DeepSeek parent unexpectedly enables direct images: ref=%q entry=%+v load_err=%v", ctrl.ModelRef(), resolved, loadErr)
}
if err := ctrl.Run(context.Background(), "review @.reasonix/attachments/shot.png"); err != nil {
t.Fatalf("Run: %v", err)
}
reqs := prov.requestsSnapshot()
if len(reqs) < 4 {
t.Fatalf("provider requests = %d, want parent MCP call, parent subagent call, vision child, and parent final", len(reqs))
}
if got := mcpCalls.Load(); got != 1 {
t.Fatalf("vision MCP calls = %d, want 1", got)
}
// MCP tools stay behind use_capability; review is registered for dispatch.
if !requestHasTool(reqs[0], "use_capability") {
t.Fatalf("parent tools = %v, want use_capability for MCP/review dispatch", toolSchemaNames(reqs[0].Tools))
}
registered := map[string]bool{}
for _, e := range ctrl.AllToolContractEntries() {
registered[e.Name] = true
}
if !registered["review"] && !registered["run_skill"] {
t.Fatalf("capability registry missing review/run_skill: %v", registered)
}
if got := bootLastUser(reqs[0]); !strings.Contains(got, "@.reasonix/attachments/shot.png") {
t.Fatalf("text-only parent lost the attachment reference needed by MCP: %q", got)
}
// The direct attachment remains candidate-only for the text parent. An MCP
// image is intentionally retained on its local tool-result message; the real
// DeepSeek adapter tests assert that this exact role is omitted on the wire.
for _, requestIndex := range []int{0, 1, len(reqs) - 1} {
for _, msg := range reqs[requestIndex].Messages {
if msg.Role == provider.RoleUser && len(msg.Images) != 0 {
t.Fatalf("text-only parent request %d embedded %d direct attachment(s): %+v", requestIndex, len(msg.Images), reqs[requestIndex].Messages)
}
}
}
if !requestMessageContains(reqs[1].Messages, provider.RoleTool, "vision-mcp-ok") {
t.Fatalf("parent did not receive the vision MCP text result: %+v", reqs[1].Messages)
}
var parentMCPImageCount int
for _, msg := range reqs[1].Messages {
if msg.Role == provider.RoleTool && msg.Name == "mcp__vision-reader__inspect" {
parentMCPImageCount += len(msg.Images)
}
}
if parentMCPImageCount != 1 {
t.Fatalf("parent local MCP result images = %d, want one image for provider-boundary filtering", parentMCPImageCount)
}
var childImageCount int
for _, msg := range reqs[2].Messages {
if msg.Role == provider.RoleUser {
childImageCount = len(msg.Images)
}
}
if childImageCount != 1 {
t.Fatalf("vision child user images = %d, want one attachment; request = %+v", childImageCount, reqs[2].Messages)
}
}
func TestBuildUsesConfiguredLanguageForResponsePreference(t *testing.T) {
isolateConfigHome(t)
dir := robustTempDir(t)
@@ -982,12 +813,15 @@ var (
func registerBootSubagentTestProvider() {
bootSubagentTestProviderOnce.Do(func() {
provider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {
provider.Register(bootSubagentTestProviderKind, func(cfg provider.Config) (provider.Provider, error) {
bootSubagentTestProviderMu.Lock()
defer bootSubagentTestProviderMu.Unlock()
if bootSubagentTestProviderCurrent == nil {
return nil, errors.New("boot subagent test provider is not installed")
}
if cfg.ModelInfo != nil {
return bootImageInfoProvider{bootSubagentTestProviderCurrent, *cfg.ModelInfo}, nil
}
return bootSubagentTestProviderCurrent, nil
})
})
@@ -1013,8 +847,16 @@ type bootSubagentTestProvider struct {
continueRef string
requests []provider.Request
combinedVision bool
visionRequests []provider.Request
}
type bootImageInfoProvider struct {
provider.Provider
info provider.ModelInfo
}
func (p bootImageInfoProvider) ModelInfo() provider.ModelInfo { return p.info }
func (p *bootSubagentTestProvider) Name() string { return "boot-subagent-test" }
func (p *bootSubagentTestProvider) setContinueRef(ref string) {
@@ -1025,6 +867,15 @@ func (p *bootSubagentTestProvider) setContinueRef(ref string) {
func (p *bootSubagentTestProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.mu.Lock()
if p.combinedVision && len(req.Tools) == 0 && len(req.Messages) == 1 && len(req.Messages[0].Images) > 0 {
p.visionRequests = append(p.visionRequests, req)
p.mu.Unlock()
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: "A green pixel."}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
call := p.calls
p.calls++
ref := p.continueRef
+44
View File
@@ -0,0 +1,44 @@
package boot
import (
"context"
"fmt"
"reasonix/internal/attachment"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/imageinput"
"reasonix/internal/provider"
)
type childImageRouting struct {
controller func() *control.Controller
config *imageinput.Config
}
type controllerImageResolver struct{ controller func() *control.Controller }
func (r controllerImageResolver) ResolveRequestImages(ctx context.Context, messages []provider.Message) ([]provider.Message, error) {
if c := r.controller(); c != nil {
return c.ResolveRequestImages(ctx, messages)
}
return nil, fmt.Errorf("attachment owner is unavailable")
}
func (r controllerImageResolver) ResolveRequestImagesForModel(ctx context.Context, messages []provider.Message, model string, native bool) ([]provider.Message, error) {
if c := r.controller(); c != nil {
return c.ResolveRequestImagesForModel(ctx, messages, model, native)
}
return nil, fmt.Errorf("attachment owner is unavailable")
}
func (r controllerImageResolver) PersistToolImages(ctx context.Context, images []string) ([]attachment.ImageInput, error) {
if c := r.controller(); c != nil {
return c.PersistToolImages(ctx, images)
}
return nil, fmt.Errorf("attachment owner is unavailable")
}
func newControllerWithImageRoutes(opts control.Options, cfg *config.Config) *control.Controller {
opts.ImageRouteConfig = cfg
return control.New(opts)
}
+7 -1
View File
@@ -25,10 +25,11 @@ func newSubagentSkillOptionsFactory(
ablationSet ablation.Set,
lease *workspacelease.Owner,
writeRoots *sandbox.WritableRootSet,
imageRoutes ...childImageRouting,
) func(context.Context, int, *provider.Pricing, int, int) agent.Options {
home, stateRoot := userHomeDir(), config.MemoryUserDir()
return func(ctx context.Context, steps int, price *provider.Pricing, ctxWin, childDepth int) agent.Options {
return agent.Options{
opts := agent.Options{
MaxSteps: steps, Temperature: cfg.Temperature, Pricing: price, QuoteContext: quoteCtx, UsageSource: event.UsageSourceSubagent,
Gate: gate, ContextWindow: ctxWin, RecentKeep: cfg.RecentKeep,
SoftCompactRatio: cfg.SoftCompactRatio, ToolResultSnipRatio: cfg.ToolResultSnipRatio,
@@ -39,6 +40,11 @@ func newSubagentSkillOptionsFactory(
Ablation: ablationSet, WorkspaceLease: lease, WriteRoots: writeRoots,
DisableWriteAccessExpand: true, HomeDir: home, StateRoot: stateRoot,
}
if len(imageRoutes) > 0 {
opts.ImageRequestResolver = imageRoutes[0].controller()
opts.ImageInput = imageRoutes[0].config
}
return opts
}
}
+26 -7
View File
@@ -25,7 +25,15 @@ const (
// runGuarded runs body under a fresh context, guarding concurrent turns.
// Finishing-window arrivals park instead of dropping (see admissionResult).
func (c *Controller) runGuarded(body func(ctx context.Context) error) admissionResult {
return c.admitGuardedTurn(body, false, true, nil, nil)
return c.runGuardedWithAdmission(body, turnAdmission{})
}
func (c *Controller) runGuardedWithAdmission(body func(ctx context.Context) error, admission turnAdmission) admissionResult {
result := c.admitGuardedTurn(body, false, true, nil, nil, admission)
if admission.result != nil {
*admission.result = result
}
return result
}
// runGuardedOrPark admits like runGuarded but parks the body while another
@@ -34,7 +42,7 @@ func (c *Controller) runGuarded(body func(ctx context.Context) error) admissionR
// the FIFO drain in finishGuardedTurn delivers them the moment the current
// turn finishes.
func (c *Controller) runGuardedOrPark(body func(ctx context.Context) error) admissionResult {
return c.admitGuardedTurn(body, true, true, nil, nil)
return c.admitGuardedTurn(body, true, true, nil, nil, turnAdmission{})
}
// runGuardedInbox admits a durable item without parking it in volatile memory.
@@ -44,16 +52,16 @@ func (c *Controller) runGuardedInbox(body func(ctx context.Context) error, onSta
return turnDroppedRunning
}
defer c.submissions.mu.Unlock()
return c.admitGuardedTurn(body, false, false, onStart, nil)
return c.admitGuardedTurn(body, false, false, onStart, nil, turnAdmission{})
}
func (c *Controller) runGuardedGoalRound(reservation *goalRoundReservation, body func(ctx context.Context) error) admissionResult {
c.submissions.mu.Lock()
defer c.submissions.mu.Unlock()
return c.admitGuardedTurn(body, false, false, nil, reservation)
return c.admitGuardedTurn(body, false, false, nil, reservation, turnAdmission{})
}
func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, parkWhileRunning, parkWhileFinishing bool, onStart func(), goalRound *goalRoundReservation) admissionResult {
func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, parkWhileRunning, parkWhileFinishing bool, onStart func(), goalRound *goalRoundReservation, admission turnAdmission) admissionResult {
if err := c.authentication.admissionError(); err != nil {
var authErr *AuthenticationError
_ = errors.As(err, &authErr)
@@ -64,6 +72,17 @@ func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, park
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Code: code, Text: err.Error()})
return turnDroppedAuthentication
}
// Freeze before a turn can park. Delayed execution owns this immutable
// admission value and never consults temporary controller state.
prepared := admission.images
admissionCtx := admission.durableCtx
if admissionCtx == nil {
admissionCtx = context.Background()
}
run := body
body = func(ctx context.Context) error {
return run(contextWithPreparedImageReferences(ctx, prepared))
}
if err := c.ensureWriteAuthorityReady(); err != nil {
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "input was not accepted: this session is no longer writable — reopen it and try again"})
return turnDroppedWriteAuthority
@@ -96,7 +115,7 @@ func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, park
if goalRound != nil {
kind = queuedGoal
}
item := queuedTurn{kind: kind, body: body, onStart: onStart, goalRound: goalRound}
item := queuedTurn{kind: kind, body: body, onStart: onStart, goalRound: goalRound, admissionCtx: admissionCtx}
switch c.turns.phase {
case session.RuntimeRunning:
if parkWhileRunning || c.turns.cancelRequested {
@@ -130,6 +149,6 @@ func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, park
onStart()
}
c.refreshRuntimeState(event.Event{})
c.spawnGuardedTurn(ctx, cancel, body, goalRound)
c.spawnGuardedTurn(ctx, cancel, item)
return turnStarted
}
@@ -0,0 +1,74 @@
package control
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func TestMixedDraftAndOrdinaryWorkspaceImageAreBothPrepared(t *testing.T) {
root := t.TempDir()
c := newOwnedTestController(t, Options{WorkspaceRoot: root})
draft, err := c.StageImage(t.Context(), "draft.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
raw, err := base64.StdEncoding.DecodeString(tinyPNG)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "ordinary.png"), raw, 0600); err != nil {
t.Fatal(err)
}
prepared, err := c.PrepareSubmission(t.Context(), SubmissionRequest{Input: "inspect @ordinary.png", Attachments: []SubmissionAttachment{{ClientAttachmentID: "draft", DraftID: draft.ID}}})
if err != nil {
t.Fatal(err)
}
if len(prepared.images.inputs) != 2 {
t.Fatalf("mixed image count = %d", len(prepared.images.inputs))
}
}
func TestInvalidImageBatchNeverStartsEndpointOrGoal(t *testing.T) {
for _, endpoint := range []string{"normal", "http", "edit", "goal", "run"} {
t.Run(endpoint, func(t *testing.T) {
root := t.TempDir()
p := &reviewImageProvider{requests: make(chan provider.Request, 4)}
ag := agent.New(p, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl"), Runner: ag, Executor: ag})
good, err := SaveImageDataURLInRoot(root, "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
input := "inspect @" + good + " @.reasonix/attachments/missing.png"
setupCalled := false
switch endpoint {
case "run":
err = c.RunTurn(t.Context(), input)
default:
req := SubmissionRequest{ID: "reject-batch", Input: input, HTTP: endpoint == "http"}
if endpoint == "edit" {
req.Original = "original"
}
_, err = c.SubmitIdentifiedWithSetupContext(t.Context(), req, func() error { setupCalled = true; return nil })
}
if err == nil || setupCalled || c.Running() {
t.Fatalf("partial admission: err=%v setup=%v running=%v", err, setupCalled, c.Running())
}
if len(p.requests) != 0 {
t.Fatal("invalid batch reached provider")
}
for _, msg := range ag.Session().Snapshot() {
if msg.Role == provider.RoleUser {
t.Fatal("rejected batch appended a user message")
}
}
})
}
}
@@ -0,0 +1,102 @@
package control
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/sessioninbox"
"reasonix/internal/tool"
)
func TestAttachmentEndpointsDeliverFrozenBytes(t *testing.T) {
for _, endpoint := range []string{"http", "edit", "run", "queue", "prepared"} {
t.Run(endpoint, func(t *testing.T) {
root := t.TempDir()
writeVisionTestConfig(t, root)
p := &reviewImageProvider{requests: make(chan provider.Request, 4)}
ag := agent.New(p, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
native := true
c := newOwnedTestController(t, Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl"), Runner: ag, Executor: ag, ModelRef: "custom/vision-pro", FrozenImageInput: &native})
path, err := SaveImageDataURLInRoot(root, "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
input := "inspect @" + path
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
defer cancel()
switch endpoint {
case "http":
_, err = c.SubmitIdentifiedContext(ctx, SubmissionRequest{Input: input, HTTP: true})
case "edit":
_, err = c.SubmitIdentifiedContext(ctx, SubmissionRequest{Input: input, Original: "old question"})
case "run":
err = c.RunTurn(ctx, input)
case "queue":
var receipt sessioninbox.InboxReceipt
receipt, err = c.EnqueueInbox(InboxRequest{Submit: input})
if err == nil {
err = os.Remove(filepath.Join(root, path))
}
if err == nil {
err = c.RunInboxTurn(ctx, receipt.ItemID)
}
case "prepared":
var prepared *PreparedSubmission
prepared, err = c.PrepareSubmission(ctx, SubmissionRequest{Input: input})
if err == nil {
err = os.Remove(filepath.Join(root, path))
}
if err == nil {
_, err = c.SubmitPreparedWithSetup(ctx, prepared, nil)
}
}
if err != nil {
t.Fatal(err)
}
select {
case request := <-p.requests:
var images []string
for _, message := range request.Messages {
if message.Role == provider.RoleUser {
images = append(images, message.Images...)
}
}
if len(images) != 1 || images[0] != "data:image/png;base64,"+tinyPNG {
t.Fatalf("provider did not receive exact admitted bytes: %q", images)
}
case <-ctx.Done():
t.Fatal(ctx.Err())
}
})
}
}
func TestAttachmentQueueEditPreservesStructuredAndAdditionalPath(t *testing.T) {
root := t.TempDir()
c := newOwnedTestController(t, Options{WorkspaceRoot: root})
draft, err := c.StageImage(t.Context(), "first.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
env := sessioninbox.PromptEnvelope{AttachmentIdentities: []string{"first"}}
if err := c.freezeInboxEnvelopeReferences(t.Context(), &env, "inspect", nil, SubmissionAttachment{ClientAttachmentID: "first", DraftID: draft.ID}); err != nil {
t.Fatal(err)
}
c.ReleaseDraftImage(draft.ID)
path, err := SaveImageDataURLInRoot(root, "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
if err := c.freezeInboxEnvelopeReferences(t.Context(), &env, "inspect @"+path, nil); err != nil {
t.Fatal(err)
}
if len(env.ImageInputs) != 2 {
t.Fatalf("image count = %d", len(env.ImageInputs))
}
}
@@ -0,0 +1,110 @@
package control_test
import (
"bytes"
"context"
"encoding/base64"
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/permission"
)
const identityPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
func newIdentityController(t *testing.T, workspace string) *control.Controller {
t.Helper()
c := control.New(control.Options{
WorkspaceRoot: workspace,
Sink: event.Discard,
Policy: permission.New("allow", nil, nil, nil),
})
t.Cleanup(func() { c.Close() })
return c
}
func TestIndependentAttachmentIdentityUsesBoundWorkspace(t *testing.T) {
processDir := t.TempDir()
first := t.TempDir()
second := t.TempDir()
t.Chdir(processDir)
raw, err := base64.StdEncoding.DecodeString(identityPNG)
if err != nil {
t.Fatal(err)
}
firstPath, err := control.SaveImageBytesInRoot(first, "image/png", raw)
if err != nil {
t.Fatal(err)
}
secondBytes := append([]byte(nil), raw...)
secondBytes[len(secondBytes)-1] ^= 1
secondPath, err := control.SaveImageBytesInRoot(second, "image/png", secondBytes)
if err != nil {
t.Fatal(err)
}
one, err := control.ImageDataURLInRoot(first, firstPath)
if err != nil {
t.Fatal(err)
}
two, err := control.ImageDataURLInRoot(second, secondPath)
if err != nil {
t.Fatal(err)
}
if one == two {
t.Fatal("same-named images in separate workspaces resolved to the same bytes")
}
if _, err := os.Stat(filepath.Join(processDir, ".reasonix")); !os.IsNotExist(err) {
t.Fatal("attachment write used the process working directory")
}
}
func TestIndependentAttachmentAdmissionKeepsDraftOnFailure(t *testing.T) {
workspace := t.TempDir()
c := newIdentityController(t, workspace)
draft, err := c.StageImage(context.Background(), "shot.png", "image/png", "data:image/png;base64,"+identityPNG)
if err != nil {
t.Fatal(err)
}
_, err = c.SubmitIdentified(control.SubmissionRequest{
ID: "sub-keep-draft",
Input: "inspect @.reasonix/attachments/missing.png",
})
var failures control.ImageReferenceFailures
if !errors.As(err, &failures) {
t.Fatalf("submit = %v, want image reference failure", err)
}
got, raw, err := c.ReadDraftImage(context.Background(), draft.ID)
if err != nil || got.ID != draft.ID || len(raw) == 0 {
t.Fatalf("draft was released after a rejected turn: %v %+v", err, got)
}
}
func TestIndependentAttachmentPermissionProfilesMatch(t *testing.T) {
workspace := t.TempDir()
raw, err := base64.StdEncoding.DecodeString(identityPNG)
if err != nil {
t.Fatal(err)
}
var want []byte
for _, mode := range []string{control.ToolApprovalWorkspaceWrite, control.ToolApprovalDangerFullAccess} {
c := newIdentityController(t, workspace)
c.SetToolApprovalMode(mode)
draft, err := c.StageImage(context.Background(), "shot.png", "image/png", "data:image/png;base64,"+base64.StdEncoding.EncodeToString(raw))
if err != nil {
t.Fatalf("mode %s stage: %v", mode, err)
}
_, body, err := c.ReadDraftImage(context.Background(), draft.ID)
if err != nil {
t.Fatalf("mode %s read: %v", mode, err)
}
if want == nil {
want = body
} else if !bytes.Equal(want, body) {
t.Fatal("permission profiles produced different originals")
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package control
import (
"context"
"path/filepath"
"testing"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/session"
"reasonix/internal/tool"
)
func TestAttachmentStoreFollowsLatePublishedSessionOwner(t *testing.T) {
root := t.TempDir()
service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(root, "by-id")))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
newAgent := func() *agent.Agent {
return agent.New(nil, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
}
exec := newAgent()
opts := Options{WorkspaceRoot: root, SessionDir: filepath.Join(root, "legacy-sessions"), SessionService: service, Executor: exec, Runner: exec}
c := newOwnedTestController(t, opts)
ref, err := c.BindFreshSession(t.Context(), "late-owner")
if err != nil {
t.Fatal(err)
}
runtime, ok := service.Runtime(ref)
if !ok {
t.Fatal("session was not published")
}
if got, want := c.attachmentService().Store().Root(), runtime.Session().ContentStore().Root(); got != want {
t.Fatalf("attachment store=%s, session store=%s", got, want)
}
draft, err := c.StageImage(t.Context(), "rebuild.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
if err := runtime.Session().ContentStore().Verify(t.Context(), draft.Ref.Content); err != nil {
t.Fatalf("original is not in the session content graph: %v", err)
}
opts.SessionRuntime, opts.ExclusiveSession = runtime, true
opts.Executor = newAgent()
opts.Runner = opts.Executor
next := newOwnedTestController(t, opts)
if err := ActivateSessionAPIReplacement(c, next); err != nil {
t.Fatal(err)
}
rebound, err := next.RebindDraftImage(t.Context(), draft.ID)
if err != nil || rebound.ID == draft.ID {
t.Fatalf("rebind=%+v err=%v", rebound, err)
}
if _, _, err := next.ReadDraftImage(t.Context(), rebound.ID); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,351 @@
package control
import (
"context"
"errors"
"path/filepath"
"reasonix/internal/agent"
"reasonix/internal/attachment"
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/sessioninbox"
"reasonix/internal/tool"
"strings"
"sync"
"testing"
"time"
)
func TestAttachmentUploadUsesRequestRouteAndCancellation(t *testing.T) {
root := t.TempDir()
cfg := config.Default()
cfg.DefaultModel = "main/text"
cfg.Providers = []config.ProviderEntry{
{Name: "main", Kind: "openai", BaseURL: "https://api.deepseek.com", Models: []string{"text"}},
{Name: "vision", Kind: "anthropic", BaseURL: "https://api.deepseek.com/anthropic", Models: []string{"vision"}},
{Name: "external", Kind: "openai", BaseURL: "https://vision.example.invalid/v1", Models: []string{"vision"}},
}
c := newOwnedTestController(t, Options{WorkspaceRoot: root, ModelRef: "main/text", ImageRouteConfig: cfg})
d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
oldLimit, oldUpload := inlineImageLimit, uploadVisionFile
t.Cleanup(func() { inlineImageLimit = oldLimit; uploadVisionFile = oldUpload })
inlineImageLimit = 1
var uploads []provider.FileUpload
uploadVisionFile = func(_ context.Context, r provider.FileUpload) (string, error) {
uploads = append(uploads, r)
return "file-route", nil
}
msgs := []provider.Message{{Role: provider.RoleUser, Content: "image", ImageInputs: c.attachmentService().InputsFromRefs([]attachment.AttachmentRef{d.Ref})}}
if _, err := c.ResolveRequestImagesForModel(t.Context(), msgs, "vision/vision", true); err != nil {
t.Fatal(err)
}
if len(uploads) != 1 || uploads[0].BaseURL != cfg.Providers[1].BaseURL || uploads[0].Protocol != "anthropic" {
t.Fatalf("wrong route: %+v", uploads)
}
if _, err := c.ResolveRequestImagesForModel(t.Context(), msgs, "external/vision", true); err != nil {
t.Fatal(err)
}
if len(uploads) != 1 {
t.Fatal("external route uploaded to main provider")
}
uploadVisionFile = func(context.Context, provider.FileUpload) (string, error) { return "", context.Canceled }
if _, err := c.ResolveRequestImagesForModel(t.Context(), msgs, "vision/vision", true); !errors.Is(err, context.Canceled) {
t.Fatalf("cancel fell back inline: %v", err)
}
}
type reviewImageProvider struct {
requests chan provider.Request
textOnly bool
}
func (p *reviewImageProvider) Name() string { return "review" }
func (p *reviewImageProvider) ModelInfo() provider.ModelInfo {
if p.textOnly {
return provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText}}
}
return provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}
}
func TestAttachmentTextModelUsesVisionProvider(t *testing.T) {
root := t.TempDir()
writeVisionTestConfig(t, root)
main := &reviewImageProvider{requests: make(chan provider.Request, 4), textOnly: true}
vision := &reviewImageProvider{requests: make(chan provider.Request, 4)}
ag := agent.New(main, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{ModelRef: "custom/text"}, event.Discard)
native := false
c := newOwnedTestController(t, Options{WorkspaceRoot: root, Runner: ag, Executor: ag, ModelRef: "custom/text", FrozenImageInput: &native, VisionModel: "custom/vision-pro", VisionProviderResolver: func(string) (provider.Provider, error) { return vision, nil }})
draft, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
_, err = c.SubmitIdentified(SubmissionRequest{Input: "inspect", Attachments: []SubmissionAttachment{{ClientAttachmentID: "image-1", DraftID: draft.ID}}})
if err != nil {
t.Fatal(err)
}
select {
case req := <-vision.requests:
if len(req.Messages) != 1 || len(req.Messages[0].Images) != 1 {
t.Fatalf("vision request has no image: %+v", req.Messages)
}
case <-time.After(10 * time.Second):
t.Fatal("vision provider did not receive image")
}
select {
case req := <-main.requests:
for _, msg := range req.Messages {
if len(msg.Images) != 0 || len(msg.ImageInputs) != 0 {
t.Fatal("text model received raw image")
}
}
case <-time.After(10 * time.Second):
t.Fatal("text provider did not receive summary")
}
}
func TestAttachmentRetryAfterDraftRelease(t *testing.T) {
root := t.TempDir()
c := newOwnedTestController(t, Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl"), Sink: event.Discard})
d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
req := SubmissionRequest{ID: "durable-image", Input: "inspect", Attachments: []SubmissionAttachment{{ClientAttachmentID: "stable-image", DraftID: d.ID}}}
first, err := c.submitIdentified(req, func() {
if err := c.prepareTurnAdmission(func(context.Context) error { return nil })(t.Context()); err != nil {
t.Fatal(err)
}
})
if err != nil {
t.Fatal(err)
}
c.ReleaseDraftImage(d.ID)
second, err := c.SubmitIdentified(req)
if err != nil || first != second {
t.Fatalf("retry = %+v, %v; want %+v", second, err, first)
}
}
func (p *reviewImageProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.requests <- req
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: "done"}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func TestAttachmentRegressionDesktopImageReachesProvider(t *testing.T) {
for _, mode := range []string{"draft", "legacy"} {
t.Run(mode, func(t *testing.T) {
root := t.TempDir()
writeVisionTestConfig(t, root)
p := &reviewImageProvider{requests: make(chan provider.Request, 4)}
ag := agent.New(p, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
native := true
c := newOwnedTestController(t, Options{WorkspaceRoot: root, Runner: ag, Executor: ag, ModelRef: "custom/vision-pro", FrozenImageInput: &native})
input := "inspect "
if mode == "draft" {
d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
input += "@draft:" + d.ID
} else {
ref, err := SaveImageDataURLInRoot(root, "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
input += "@" + ref
}
if _, err := c.SubmitIdentified(SubmissionRequest{Input: input}); err != nil {
t.Fatal(err)
}
select {
case req := <-p.requests:
var imgs []string
for _, m := range req.Messages {
if m.Role == provider.RoleUser {
imgs = append(imgs, m.Images...)
}
}
if len(imgs) != 1 || !strings.HasPrefix(imgs[0], "data:image/png;base64,") {
t.Fatalf("provider image inputs = %q, expected one resolved data URL", imgs)
}
case <-time.After(10 * time.Second):
t.Fatal("provider did not start")
}
})
}
}
func TestLegacyImagePathRemainsToolReadableWithoutVisionFallback(t *testing.T) {
root := t.TempDir()
ref, err := SaveImageDataURLInRoot(root, "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
provider := &reviewImageProvider{requests: make(chan provider.Request, 1), textOnly: true}
ag := agent.New(provider, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
textOnly := false
c := newOwnedTestController(t, Options{
WorkspaceRoot: root,
Runner: ag,
Executor: ag,
ModelRef: "custom/text-only",
FrozenImageInput: &textOnly,
})
prepared, err := c.PrepareSubmission(t.Context(), SubmissionRequest{Input: "inspect @" + ref})
if err != nil {
t.Fatal(err)
}
if !prepared.HasImages() {
t.Fatal("legacy image path was not frozen for the accepted turn")
}
if prepared.images.requiresImageUnderstanding {
t.Fatal("legacy image path should remain tool-readable when no vision fallback is configured")
}
if _, err := c.SubmitPreparedWithSetup(t.Context(), prepared, nil); err != nil {
t.Fatal(err)
}
select {
case req := <-provider.requests:
for _, msg := range req.Messages {
if len(msg.Images) != 0 || len(msg.ImageInputs) != 0 {
t.Fatalf("text-only provider received raw image: %+v", msg)
}
}
case <-time.After(10 * time.Second):
t.Fatal("text-only provider did not receive the tool-readable prompt")
}
}
func TestStructuredAttachmentRequiresVisionFallbackForTextModel(t *testing.T) {
root := t.TempDir()
textOnly := false
c := newOwnedTestController(t, Options{
WorkspaceRoot: root,
ModelRef: "custom/text-only",
FrozenImageInput: &textOnly,
})
draft, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
_, err = c.PrepareSubmission(t.Context(), SubmissionRequest{
Input: "inspect",
Attachments: []SubmissionAttachment{{
ClientAttachmentID: "image-1",
DraftID: draft.ID,
}},
})
if err == nil || !strings.Contains(err.Error(), "no image understanding model is configured") {
t.Fatalf("PrepareSubmission error = %v, want missing vision fallback", err)
}
}
func TestAttachmentRegressionQueuePreservesDraft(t *testing.T) {
c := newOwnedTestController(t, Options{WorkspaceRoot: t.TempDir()})
d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
var env sessioninbox.PromptEnvelope
if err := c.freezeInboxEnvelopeReferences(t.Context(), &env, "inspect @draft:"+d.ID, nil); err != nil {
t.Fatal(err)
}
if len(env.ImageInputs) != 1 {
t.Fatalf("queued image inputs = %d, want 1", len(env.ImageInputs))
}
}
func TestAttachmentRegressionDraftAdmissionLimitsAndDedup(t *testing.T) {
c := newOwnedTestController(t, Options{WorkspaceRoot: t.TempDir()})
d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
p, fail := c.prepareSubmissionImages(SubmissionRequest{Input: "inspect @draft:" + d.ID, DraftIDs: []string{d.ID}})
if len(fail) > 0 {
t.Fatal(fail)
}
if len(p.inputs) != 1 {
t.Errorf("one UI draft resolved to %d images", len(p.inputs))
}
ids := make([]string, 21)
for i := range ids {
x, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
ids[i] = x.ID
}
_, fail = c.prepareSubmissionImages(SubmissionRequest{Input: "inspect", DraftIDs: ids})
if len(fail) == 0 {
t.Error("21 image drafts admitted despite max 20 policy")
}
}
func TestAttachmentRegressionDraftAndLegacyAreBothPrepared(t *testing.T) {
root := t.TempDir()
c := newOwnedTestController(t, Options{WorkspaceRoot: root})
d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
ref, err := SaveImageDataURLInRoot(root, "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
p, fail := c.prepareSubmissionImages(SubmissionRequest{Input: "inspect @draft:" + d.ID + " @" + ref})
if len(fail) > 0 {
t.Fatal(fail)
}
if len(p.inputs) != 2 {
t.Fatalf("mixed image inputs = %d, want 2", len(p.inputs))
}
}
func TestAttachmentRegressionAttachmentReceiptLookup(t *testing.T) {
root := t.TempDir()
c := newOwnedTestController(t, Options{WorkspaceRoot: root, SessionPath: filepath.Join(root, "session.jsonl"), Sink: event.Discard})
d, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Fatal(err)
}
req := SubmissionRequest{ID: "with-image", Input: "inspect @draft:" + d.ID, DraftIDs: []string{d.ID}}
_, err = c.submitIdentified(req, func() {
if err := c.prepareTurnAdmission(func(context.Context) error { return nil })(context.Background()); err != nil {
t.Fatal(err)
}
})
if err != nil {
t.Fatal(err)
}
_, found, err := c.LookupSubmission(req)
if err != nil || !found {
t.Fatalf("same submission lookup: found=%v err=%v", found, err)
}
}
func TestAttachmentRegressionConcurrentDraftStaging(t *testing.T) {
c := newOwnedTestController(t, Options{WorkspaceRoot: t.TempDir()})
start := make(chan struct{})
var wg sync.WaitGroup
for range 4 {
wg.Go(func() {
<-start
_, err := c.StageImage(t.Context(), "shot.png", "image/png", "data:image/png;base64,"+tinyPNG)
if err != nil {
t.Error(err)
}
})
}
close(start)
wg.Wait()
}
+102
View File
@@ -0,0 +1,102 @@
package control
import (
"context"
"path/filepath"
"sync"
"sync/atomic"
"reasonix/internal/attachment"
"reasonix/internal/sessioncontent"
)
type controllerAttachmentState struct {
attachments atomic.Pointer[attachment.Service]
attachmentOwnerMu sync.Mutex
attachmentOwnerScope string
imageRoutesOnce sync.Once
imageRoutes map[string]ImageRequestRoute
imageRoutesErr error
}
func (c *Controller) AttachmentOwnerIdentity() string {
ref, _ := c.SessionRef()
return ref.HostID + "\x00" + c.attachmentScope()
}
// NewAttachmentOperationContext binds an operation to both caller and owner.
func (c *Controller) NewAttachmentOperationContext(parent context.Context) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(parent)
stop := context.AfterFunc(c.attachmentContext(), cancel)
return ctx, func() { stop(); cancel() }
}
func (c *Controller) attachmentContext() context.Context {
if c.goalDriverControl.ctx != nil {
return c.goalDriverControl.ctx
}
return context.Background()
}
func (c *Controller) attachmentService() *attachment.Service {
if c == nil {
return nil
}
return c.attachments.Load()
}
// Only the session publication owner changes this binding. Request goroutines
// retain their service snapshot; they never lazily choose another content root.
func (c *Controller) bindAttachmentService() {
c.attachmentOwnerMu.Lock()
defer c.attachmentOwnerMu.Unlock()
content := c.sessionContentStore()
scope := c.attachmentScope()
current := c.attachments.Load()
if current != nil && c.attachmentOwnerScope != scope {
current.Drafts().ReleaseScope(c.attachmentOwnerScope)
}
c.attachmentOwnerScope = scope
if current != nil && current.Store().Root() == content.Root() {
return
}
c.attachments.Store(attachment.NewService(content))
}
func (c *Controller) sessionContentStore() *sessioncontent.Store {
if c == nil {
return nil
}
if _, runtime, _ := c.v3Binding(); runtime != nil {
return runtime.Session().ContentStore()
}
if store := c.sessionEventStore(); store != nil {
if content := store.ContentStore(); content != nil {
return content
}
}
if c.sessionDir != "" {
return sessioncontent.New(filepath.Join(c.sessionDir, ".content-v1"))
}
if c.sessionPath != "" {
return sessioncontent.New(filepath.Join(filepath.Dir(c.sessionPath), ".content-v1"))
}
if c.workspaceRoot != "" {
return sessioncontent.New(filepath.Join(c.workspaceRoot, ".reasonix", "content-v1"))
}
return nil
}
func (c *Controller) attachmentScope() string {
if c == nil {
return ""
}
if _, runtime, _ := c.v3Binding(); runtime != nil {
store := runtime.Session()
return store.ID() + ":" + store.StorageGeneration()
}
if store := c.sessionEventStore(); store != nil {
return store.ID() + ":" + store.StorageGeneration()
}
return c.sessionPath
}
@@ -0,0 +1,73 @@
package control
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"reasonix/internal/config"
"reasonix/internal/provider"
"reasonix/internal/provider/anthropic"
"reasonix/internal/provider/openai"
"reasonix/internal/provider/responses"
)
func TestLegacyImageResolverPreservesSerializedProtocolBytes(t *testing.T) {
for _, protocol := range []string{"openai", "responses", "anthropic"} {
t.Run(protocol, func(t *testing.T) {
bodies := make(chan []byte, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
}
bodies <- body
// A terminal client error avoids synthetic stream differences and
// retries; the real adapter has already serialized the request.
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"message":"fixture captured","type":"invalid_request_error"}}`))
}))
defer server.Close()
info := &provider.ModelInfo{ID: "vision", InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}
cfg := provider.Config{Name: "fixture", Model: "vision", BaseURL: server.URL, APIKey: "fixture-only", ModelInfo: info}
var p provider.Provider
var err error
switch protocol {
case "openai":
p, err = openai.New(cfg)
case "anthropic":
p, err = anthropic.New(cfg)
case "responses":
p = responses.New(responses.Config{Name: "fixture", Model: "vision", BaseURL: server.URL, APIKey: "fixture-only", ModelInfo: info})
}
if err != nil {
t.Fatal(err)
}
messages := []provider.Message{{Role: provider.RoleSystem, Content: "unchanged system"}, {Role: provider.RoleUser, Content: "legacy image", Images: []string{"data:image/png;base64," + tinyPNG}}, {Role: provider.RoleAssistant, Content: "unchanged history"}, {Role: provider.RoleUser, Content: "next turn"}}
c := newOwnedTestController(t, Options{WorkspaceRoot: t.TempDir(), ImageRouteConfig: config.Default()})
resolved, err := c.ResolveRequestImagesForModel(t.Context(), messages, "fixture/vision", true)
if err != nil {
t.Fatal(err)
}
for _, input := range [][]provider.Message{messages, resolved} {
ch, _ := p.Stream(t.Context(), provider.Request{Messages: input})
if ch != nil {
for range ch {
}
}
}
if len(bodies) != 2 {
t.Fatalf("adapter sent %d requests, want 2", len(bodies))
}
before, after := <-bodies, <-bodies
if !bytes.Equal(before, after) {
t.Fatalf("legacy protocol bytes changed:\nbefore %s\nafter %s", before, after)
}
if !bytes.Contains(after, []byte(tinyPNG)) {
t.Fatal("fixture did not exercise image serialization")
}
})
}
}
+123 -117
View File
@@ -18,6 +18,7 @@ import (
"sync/atomic"
"time"
"reasonix/internal/attachment"
"reasonix/internal/proc"
"reasonix/internal/secrets"
)
@@ -73,6 +74,10 @@ var safeAttachmentExt = regexp.MustCompile(`^\.[a-z0-9]{1,12}$`)
// .reasonix/attachments and returns its repo-relative path for @referencing.
// origName supplies only the extension; the stored name is generated.
func SaveAttachmentDataURL(origName, dataURL string) (string, error) {
return SaveAttachmentDataURLInRoot(".", origName, dataURL)
}
func SaveAttachmentDataURLInRoot(root, origName, dataURL string) (string, error) {
const marker = ";base64,"
_, after, ok := strings.Cut(dataURL, marker)
if !strings.HasPrefix(dataURL, "data:") || !ok {
@@ -82,7 +87,7 @@ func SaveAttachmentDataURL(origName, dataURL string) (string, error) {
if err != nil {
return "", fmt.Errorf("decode pasted file: %w", err)
}
return SaveAttachmentBytes(origName, raw)
return SaveAttachmentBytesInRoot(root, origName, raw)
}
func SaveAttachmentBytes(origName string, raw []byte) (string, error) {
@@ -101,6 +106,10 @@ func SaveAttachmentBytesInRoot(root, origName string, raw []byte) (string, error
}
func SaveImageDataURL(dataURL string) (string, error) {
return SaveImageDataURLInRoot(".", dataURL)
}
func SaveImageDataURLInRoot(root, dataURL string) (string, error) {
const prefix = "data:"
const marker = ";base64,"
if !strings.HasPrefix(dataURL, prefix) {
@@ -115,7 +124,7 @@ func SaveImageDataURL(dataURL string) (string, error) {
if err != nil {
return "", fmt.Errorf("decode pasted image: %w", err)
}
return SaveImageBytes(mime, raw)
return SaveImageBytesInRoot(root, mime, raw)
}
func SaveImageBytes(declaredMime string, raw []byte) (string, error) {
@@ -261,20 +270,19 @@ func SaveClipboardImage() (string, error) {
}
func SaveClipboardImageInRoot(root string) (string, error) {
save := func(raw []byte) (string, error) { return SaveImageBytesInRoot(root, "", raw) }
switch runtime.GOOS {
case "darwin":
return saveDarwinClipboardImageTo(save)
return saveDarwinClipboardImageInRoot(root)
case "windows":
return saveWindowsClipboardImageTo(save)
return saveWindowsClipboardImageInRoot(root)
case "linux":
return saveLinuxClipboardImageTo(save)
return saveLinuxClipboardImageInRoot(root)
default:
return "", fmt.Errorf("clipboard image paste is not supported on %s yet", runtime.GOOS)
}
}
func saveWindowsClipboardImageTo(save func([]byte) (string, error)) (string, error) {
func saveWindowsClipboardImageInRoot(root string) (string, error) {
// Windows PowerShell 5.1 (preinstalled) reaches the GUI clipboard; pwsh (Core)
// lacks Get-Clipboard -Format Image, so invoke powershell.exe. The PNG is
// returned as base64 on stdout so no temp file is involved.
@@ -299,7 +307,7 @@ $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
if err != nil {
return "", fmt.Errorf("decode clipboard image: %w", err)
}
return save(raw)
return SaveImageBytesInRoot(root, "", raw)
}
// clipboardImageTypes lists the image mimes we can save, most preferred
@@ -314,10 +322,10 @@ func clipboardImageReadArgs(tool, mime string) []string {
}
func saveLinuxClipboardImage() (string, error) {
return saveLinuxClipboardImageTo(func(raw []byte) (string, error) { return SaveImageBytes("", raw) })
return saveLinuxClipboardImageInRoot(".")
}
func saveLinuxClipboardImageTo(save func([]byte) (string, error)) (string, error) {
func saveLinuxClipboardImageInRoot(root string) (string, error) {
type clipboardTool struct {
name string
typesArgs []string
@@ -368,7 +376,7 @@ func saveLinuxClipboardImageTo(save func([]byte) (string, error)) (string, error
readFailures = append(readFailures, fmt.Errorf("read clipboard image with %s: empty image data", tool.name))
continue
}
rel, err := save(out)
rel, err := SaveImageBytesInRoot(root, "", out)
if err != nil {
readFailures = append(readFailures, fmt.Errorf("save clipboard image from %s: %w", tool.name, err))
continue
@@ -423,33 +431,64 @@ func clipboardProbeMeansNoImage(tool string, stderr []byte) bool {
}
func ImageDataURL(path string) (string, error) {
raw, mime, err := readAttachmentImage(path)
if err != nil {
return "", err
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
return ImageDataURLInRoot(".", path)
}
func ImageDataURLInRoot(root, path string) (string, error) {
clean, err := cleanAttachmentPathInRoot(root, path)
if err != nil {
return "", err
}
raw, mime, err := readAttachmentImagePath(clean)
raw, mime, err := readAttachmentImageInRoot(root, path)
if err != nil {
return "", err
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
}
// visionImageDataURLInRoot prepares a bounded request image from a workspace attachment.
func visionImageDataURLInRoot(root, path string) (string, error) {
raw, mime, err := readAttachmentImageInRoot(root, path)
if err != nil {
return "", err
}
raw, mime = compressForVision(raw, mime)
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
}
func readAttachmentImageInRoot(root, path string) (raw []byte, mime string, err error) {
if _, err := cleanAttachmentPathInRoot(root, path); err != nil {
return nil, "", err
}
raw, err = attachment.ReadWorkspaceImageBytes(root, path, maxImageAttachmentBytes)
if err != nil {
return nil, "", err
}
mime = detectedImageMime(raw)
if mime == "" {
return nil, "", fmt.Errorf("attachment is not an image")
}
return raw, mime, nil
}
// ValidateAttachmentInRoot checks a persisted attachment without loading its
// contents. It shares the preview path containment and symlink policy.
// contents. The bounded root prevents parent replacement from escaping the
// workspace and Lstat rejects a final symlink.
func ValidateAttachmentInRoot(root, path string) error {
clean, err := cleanAttachmentPathInRoot(root, path)
if err != nil {
return err
}
info, err := os.Lstat(clean)
absRoot, err := filepath.Abs(root)
if err != nil {
return err
}
rel, err := filepath.Rel(absRoot, clean)
if err != nil || !filepath.IsLocal(rel) {
return fmt.Errorf("attachment path is outside workspace")
}
bounded, err := os.OpenRoot(absRoot)
if err != nil {
return err
}
defer bounded.Close()
info, err := bounded.Lstat(rel)
if err != nil {
return err
}
@@ -459,7 +498,7 @@ func ValidateAttachmentInRoot(root, path string) error {
if !info.Mode().IsRegular() || info.Size() <= 0 {
return fmt.Errorf("attachment is missing or empty")
}
f, err := os.Open(clean)
f, err := bounded.OpenFile(rel, os.O_RDONLY, 0)
if err != nil {
return err
}
@@ -474,73 +513,6 @@ func ValidateAttachmentInRoot(root, path string) error {
return nil
}
// visionImageDataURL reads an attachment and, unlike ImageDataURL (which feeds
// the desktop preview at full resolution), downscales/recompresses it before
// base64 so an oversized photo doesn't balloon the request bytes and image
// tokens. Best-effort: an undecodable format passes through at original size.
func visionImageDataURL(path string) (string, error) {
raw, mime, err := readAttachmentImage(path)
if err != nil {
return "", err
}
raw, mime = compressForVision(raw, mime)
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
}
func readAttachmentImage(path string) (raw []byte, mime string, err error) {
clean, err := cleanAttachmentPath(path)
if err != nil {
return nil, "", err
}
return readAttachmentImagePath(clean)
}
func readAttachmentImagePath(clean string) (raw []byte, mime string, err error) {
info, err := os.Lstat(clean)
if err != nil {
return nil, "", err
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, "", fmt.Errorf("attachment path must not be a symlink")
}
if info.IsDir() || info.Size() <= 0 || info.Size() > maxImageAttachmentBytes {
return nil, "", fmt.Errorf("attachment image must be between 1 byte and 64 MB")
}
f, err := os.Open(clean)
if err != nil {
return nil, "", err
}
defer f.Close()
opened, err := f.Stat()
if err != nil {
return nil, "", err
}
if !os.SameFile(info, opened) {
return nil, "", fmt.Errorf("attachment changed while opening")
}
raw, err = io.ReadAll(io.LimitReader(f, maxImageAttachmentBytes+1))
if err != nil {
return nil, "", err
}
if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
return nil, "", fmt.Errorf("attachment image must be between 1 byte and 64 MB")
}
if after, err := f.Stat(); err != nil {
return nil, "", err
} else if !os.SameFile(opened, after) || after.Size() != opened.Size() {
return nil, "", fmt.Errorf("attachment changed while reading")
}
mime = detectedImageMime(raw)
if mime == "" {
return nil, "", fmt.Errorf("attachment is not an image")
}
return raw, mime, nil
}
func cleanAttachmentPath(path string) (string, error) {
return cleanAttachmentPathInRoot(".", path)
}
func cleanAttachmentPathInRoot(base, path string) (string, error) {
if filepath.IsAbs(path) {
return "", fmt.Errorf("attachment path must be relative")
@@ -550,15 +522,22 @@ func cleanAttachmentPathInRoot(base, path string) (string, error) {
if clean == "." || clean == relRoot || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || !strings.HasPrefix(clean, relRoot+string(filepath.Separator)) {
return "", fmt.Errorf("attachment path is outside .reasonix/attachments")
}
if err := ensureAttachmentRootIn(base); err != nil {
if strings.TrimSpace(base) == "" {
base = "."
}
absBase, err := filepath.Abs(base)
if err != nil {
return "", err
}
root := filepath.Join(base, relRoot)
clean = filepath.Join(base, clean)
if err := rejectSymlinkComponents(clean, root); err != nil {
root := filepath.Join(absBase, relRoot)
if err := validateAttachmentDirectories(absBase); err != nil {
return "", err
}
return clean, nil
absPath := filepath.Join(absBase, clean)
if err := rejectSymlinkComponents(absPath, root); err != nil {
return "", err
}
return absPath, nil
}
func rejectSymlinkComponents(path, root string) error {
@@ -591,6 +570,16 @@ func ensureAttachmentRoot() error {
}
func ensureAttachmentRootIn(base string) error {
if info, err := os.Lstat(filepath.Join(base, ".reasonix")); err == nil {
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("attachment path must not contain symlinks")
}
if !info.IsDir() {
return fmt.Errorf("attachment path exists but is not a directory")
}
} else if !os.IsNotExist(err) {
return err
}
root := filepath.Join(base, ".reasonix", "attachments")
if info, err := os.Lstat(root); err == nil {
if info.Mode()&os.ModeSymlink != 0 {
@@ -616,17 +605,29 @@ func ensureAttachmentRootIn(base string) error {
return nil
}
func saveDarwinClipboardImageTo(save func([]byte) (string, error)) (string, error) {
for _, class := range []string{"PNGf", "JPEG"} {
rel, err := saveDarwinClipboardClassTo(class, save)
if err == nil {
return rel, nil
func validateAttachmentDirectories(base string) error {
for _, path := range []string{
filepath.Join(base, ".reasonix"),
filepath.Join(base, ".reasonix", "attachments"),
} {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !errors.Is(err, ErrNoClipboardImage) {
return "", err
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("attachment path must not contain symlinks")
}
if !info.IsDir() {
return fmt.Errorf("attachment path exists but is not a directory")
}
}
return "", ErrNoClipboardImage
return nil
}
func saveDarwinClipboardImageInRoot(root string) (string, error) {
return saveDarwinClipboardImageWith(func(class string) (string, error) {
return saveDarwinClipboardClassInRoot(root, class)
})
}
func saveDarwinClipboardImageWith(readClass func(string) (string, error)) (string, error) {
@@ -642,21 +643,26 @@ func saveDarwinClipboardImageWith(readClass func(string) (string, error)) (strin
return "", ErrNoClipboardImage
}
func saveDarwinClipboardClassTo(class string, save func([]byte) (string, error)) (string, error) {
f, err := os.CreateTemp("", "reasonix-clipboard-*.bin")
func saveDarwinClipboardClassInRoot(root, class string) (string, error) {
if strings.TrimSpace(root) == "" {
root = "."
}
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
if err := ensureAttachmentRootIn(absRoot); err != nil {
return "", err
}
rel, f, err := createAttachmentFileIn(absRoot, ".bin")
if err != nil {
return "", err
}
rel := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(rel)
return "", err
}
abs, err := filepath.Abs(rel)
if err != nil {
_ = os.Remove(rel)
_ = os.Remove(filepath.Join(absRoot, rel))
return "", err
}
abs := filepath.Join(absRoot, rel)
const noImageMarker = "__REASONIX_NO_CLIPBOARD_IMAGE__"
script := fmt.Sprintf(`
set hasImageType to false
@@ -685,15 +691,15 @@ end try
clip.Env = secrets.ProcessEnv()
out, runErr := clip.CombinedOutput()
if err := classifyDarwinClipboardResult(out, runErr, noImageMarker); err != nil {
_ = os.Remove(rel)
_ = os.Remove(abs)
return "", err
}
raw, err := os.ReadFile(rel)
_ = os.Remove(rel)
raw, err := os.ReadFile(abs)
_ = os.Remove(abs)
if err != nil {
return "", err
}
return save(raw)
return SaveImageBytesInRoot(absRoot, "", raw)
}
func classifyDarwinClipboardResult(out []byte, runErr error, noImageMarker string) error {
+92
View File
@@ -190,6 +190,98 @@ func TestImageDataURLRejectsOutsideAttachmentDir(t *testing.T) {
}
}
func TestImageDataURLInRootIgnoresProcessWorkingDirectory(t *testing.T) {
workspace := t.TempDir()
processDir := t.TempDir()
path, err := SaveImageBytesInRoot(workspace, "image/png", mustBase64(t, tinyPNG))
if err != nil {
t.Fatalf("SaveImageBytesInRoot: %v", err)
}
t.Chdir(processDir)
got, err := ImageDataURLInRoot(workspace, path)
if err != nil {
t.Fatalf("ImageDataURLInRoot: %v", err)
}
if !strings.HasPrefix(got, "data:image/png;base64,") {
t.Fatalf("data URL = %q, want png", got)
}
if _, err := os.Stat(filepath.Join(processDir, ".reasonix")); !os.IsNotExist(err) {
t.Fatalf("read created process-local attachment state: %v", err)
}
}
func TestAttachmentRootAPIsRejectManagedDirectorySymlinks(t *testing.T) {
for _, component := range []string{".reasonix", "attachments"} {
t.Run(component, func(t *testing.T) {
workspace := t.TempDir()
outside := t.TempDir()
var link string
if component == ".reasonix" {
if err := os.MkdirAll(filepath.Join(outside, "attachments"), 0o755); err != nil {
t.Fatal(err)
}
link = filepath.Join(workspace, ".reasonix")
} else {
if err := os.MkdirAll(filepath.Join(workspace, ".reasonix"), 0o755); err != nil {
t.Fatal(err)
}
link = filepath.Join(workspace, ".reasonix", "attachments")
}
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
if _, err := ImageDataURLInRoot(workspace, ".reasonix/attachments/image.png"); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Fatalf("read through managed directory symlink = %v, want symlink rejection", err)
}
if _, err := SaveImageBytesInRoot(workspace, "image/png", mustBase64(t, tinyPNG)); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Fatalf("write through managed directory symlink = %v, want symlink rejection", err)
}
})
}
}
func TestImageDataURLInRootKeepsSameRelativeNameIsolated(t *testing.T) {
firstRoot := t.TempDir()
secondRoot := t.TempDir()
rel := filepath.Join(".reasonix", "attachments", "same.png")
for _, root := range []string{firstRoot, secondRoot} {
if err := os.MkdirAll(filepath.Join(root, filepath.Dir(rel)), 0o755); err != nil {
t.Fatal(err)
}
}
first := mustBase64(t, tinyPNG)
second := append([]byte(nil), first...)
second[len(second)-1] ^= 1
if err := os.WriteFile(filepath.Join(firstRoot, rel), first, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(secondRoot, rel), second, 0o644); err != nil {
t.Fatal(err)
}
one, err := ImageDataURLInRoot(firstRoot, filepath.ToSlash(rel))
if err != nil {
t.Fatal(err)
}
two, err := ImageDataURLInRoot(secondRoot, filepath.ToSlash(rel))
if err != nil {
t.Fatal(err)
}
if one == two {
t.Fatal("same attachment name in two workspaces resolved to the same bytes")
}
}
func TestImageDataURLInRootDoesNotCreateMissingAttachmentDirectory(t *testing.T) {
workspace := t.TempDir()
_, err := ImageDataURLInRoot(workspace, ".reasonix/attachments/missing.png")
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("error = %v, want not exist", err)
}
if _, err := os.Stat(filepath.Join(workspace, ".reasonix")); !os.IsNotExist(err) {
t.Fatalf("read created attachment directory: %v", err)
}
}
func TestImageDataURLRejectsSymlinkFile(t *testing.T) {
t.Chdir(t.TempDir())
if err := ensureAttachmentRoot(); err != nil {
+113 -199
View File
@@ -138,11 +138,12 @@ type Controller struct {
modelCapabilityResolver func(*config.ProviderEntry) config.ResolvedModelCapability
frozenImageInput *bool
imageCapabilityChanged func() bool
modelSettings controllerModelSettings
prompt controllerPromptState
pinnedContextLoader PinnedContextLoader
sessionContextStatic sessioncontext.Sections
sessionDir string
controllerAttachmentState
modelSettings controllerModelSettings
prompt controllerPromptState
pinnedContextLoader PinnedContextLoader
sessionContextStatic sessioncontext.Sections
sessionDir string
controllerSessionBinding
// managedSessionEvents is set for hosts that publish controllers only after
// a session-lease handoff. An unpublished replacement may read the shared
@@ -536,8 +537,9 @@ type externalFolderToolRefs interface {
// lets the controller mint and rotate session files; Host/Commands are surfaced
// to frontends that resolve MCP prompts and slash commands.
type Options struct {
Runner agent.Runner
Executor *agent.Agent
ImageRouteConfig *config.Config
Runner agent.Runner
Executor *agent.Agent
// Authentication is the frozen runtime credential snapshot's initial
// admission state. An empty value remains Ready for source compatibility.
Authentication AuthenticationState
@@ -854,10 +856,17 @@ func New(opts Options) *Controller {
}
c.authentication.initialForModel = opts.AuthenticationForModel
c.initializeOwnedResources(opts)
c.bindAttachmentService()
if opts.ImageRouteConfig != nil {
c.imageRoutesOnce.Do(func() { c.captureImageRoutes(opts.ImageRouteConfig) })
}
return c
}
func (c *Controller) initializeOwnedResources(opts Options) {
if c.executor != nil {
c.executor.SetImageRequestResolver(c)
}
c.goalUsageTee.setLifecycleUsageRecorder(c.recordGoalLifecycleUsage)
c.installGoalLifecycle(opts.SessionRuntime)
c.managedSessionEvents.Store(opts.OnSessionTransition != nil)
@@ -1176,7 +1185,9 @@ func (c *Controller) Send(input string) {
// SendWithRaw starts a turn with separate model input and raw prompt text.
func (c *Controller) SendWithRaw(input, raw string) {
c.runGuarded(func(ctx context.Context) error { return c.runGoalLoopWithRaw(ctx, input, raw) })
_, _ = c.submitIdentifiedWithSetup(SubmissionRequest{Input: input, Display: raw}, nil, func(admission turnAdmission) {
c.runGuardedWithAdmission(func(ctx context.Context) error { return c.runGoalLoopWithRaw(ctx, input, raw) }, admission)
})
}
// planApprovalTool is the Tool name on the ApprovalRequest the controller emits
@@ -1225,20 +1236,6 @@ func (c *Controller) runTurn(ctx context.Context, input string) error {
return c.runGoalLoopWithRaw(ctx, input, input)
}
// RunTurn executes one foreground turn synchronously through the same lifecycle
// used by interactive frontends: transient memory/background-job
// composition, checkpoints, hooks, and plan approval. It is for transports that
// need a blocking request/response boundary, such as ACP session/prompt.
func (c *Controller) RunTurn(ctx context.Context, input string) error {
err := c.runSynchronousTurn(ctx, nil, func(runCtx context.Context) error {
return c.runTurn(runCtx, input)
})
if err != nil {
return err
}
return c.waitForGoalTerminal(ctx)
}
func (c *Controller) runTurnWithRaw(ctx context.Context, input, raw string) error {
return c.runTurnWithRawDisplay(ctx, input, raw, "")
}
@@ -1272,16 +1269,16 @@ func (c *Controller) runTurnWithRawDisplay(ctx context.Context, input, raw, disp
return newTurnOrchestrator(c).runTurnWithRawDisplay(ctx, input, raw, display)
}
func (c *Controller) runSubagentSkillSlash(sk skill.Skill, task, raw, display string) {
func (c *Controller) runSubagentSkillSlash(sk skill.Skill, task, raw, display string, admission turnAdmission) {
sk = c.skills.prepare(sk)
c.runGuarded(func(ctx context.Context) error {
c.runGuardedWithAdmission(func(ctx context.Context) error {
planMode := c.PlanMode()
runner := c.skillRunner
if runner == nil {
return fmt.Errorf("subagent skill runner is unavailable for /%s", sk.Name)
}
return newTurnOrchestrator(c).runSubagentSkillGoalLoop(ctx, sk, task, raw, display, runner, planMode)
})
}, admission)
}
func (c *Controller) stopGoal(status string) {
@@ -1320,53 +1317,6 @@ func (c *Controller) SubmitHTTP(input string) {
c.submitHTTP(input, "")
}
// SubmitHTTPFormat is SubmitHTTP with an optional structured-output format
// ("json_object") applied to the turn's completion requests. Empty format
// behaves exactly like SubmitHTTP. A format attached to a slash command,
// or other non-turn input is discarded; @reference turns preserve it because
// the format is bound to every submitted turn rather than a global slot.
func (c *Controller) SubmitHTTPFormat(input, format string) {
// format 绑定到本次提交的 turn(随请求参数传递),不再写入 Controller
// 全局一次性槽——评审 #7234 第 2 点:全局槽存在跨请求串用的逻辑竞态
// (后提交的 JSON 请求先写槽,更早的普通请求先启动消费掉)。
f := strings.TrimSpace(format)
if f != "" && isNonTurnHTTPInput(input) {
f = "" // 非 turn 输入(slash 命令/! 前缀)不携带 format
}
// @ 引用 turn(FileRefLine/SlashPathLineRef 等)同样绑定 format——
// runRefTurnWithFormat 族 wrapper 注入 ctx(review fix7234and7168:
// format 是每个被接纳 turn 的属性,统一架构)。
c.submitHTTPWithFormat(input, "", f)
}
// isNonTurnHTTPInput reports inputs that never reach the agent turn loop, so a
// structured-output request attached to them would otherwise leak into the
// next real turn (the format slot is consumed only by runGoalLoopWithRawDisplay).
func isNonTurnHTTPInput(input string) bool {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return true
}
// Memory quick-add / remember shortcuts and goal commands bypass turns.
if _, ok := MemoryQuickAddNote(trimmed); ok {
return true
}
if _, ok := RememberCommandNote(trimmed); ok {
return true
}
// "!" shell commands are rejected by submitHTTP before the turn loop
// (403 over HTTP); a format attached to them would never be consumed.
if strings.HasPrefix(trimmed, "!") {
return true
}
// Slash commands are management verbs (/compact /new /clear /model ...)
// or notices, not completion turns.
if strings.HasPrefix(trimmed, "/") {
return true
}
return false
}
// SubmitDisplay runs input as a turn while remembering the user-facing display
// text for transcript replay when controller-side composition expands input.
func (c *Controller) SubmitDisplay(display, input string) {
@@ -1381,14 +1331,14 @@ func (c *Controller) SubmitInvocationDisplay(display, input string, invocations
}
func (c *Controller) submitInvocations(input, display string, requests []InvocationRequest) {
c.submissions.mu.Lock()
defer c.releaseSubmissionAdmission()
c.submitInvocationsLocked(input, display, requests)
_, _ = c.submitIdentifiedWithSetup(SubmissionRequest{Input: input, Display: display, Invocations: requests}, nil, func(admission turnAdmission) {
c.submitInvocationsLocked(input, display, requests, admission)
})
}
func (c *Controller) submitInvocationsLocked(input, display string, requests []InvocationRequest) {
func (c *Controller) submitInvocationsLocked(input, display string, requests []InvocationRequest, admission turnAdmission) {
if len(requests) == 0 {
c.submitLocked(input, display, "")
c.submitLocked(input, display, "", admission)
return
}
prepared, err := c.prepareInvocationTurn(input, requests)
@@ -1396,9 +1346,9 @@ func (c *Controller) submitInvocationsLocked(input, display string, requests []I
c.notice(err.Error())
return
}
c.runGuarded(func(ctx context.Context) error {
c.runGuardedWithAdmission(func(ctx context.Context) error {
return c.runPreparedInvocationTurn(ctx, prepared, input, input, display, nil)
})
}, admission)
}
type preparedInvocationTurn struct {
@@ -1467,6 +1417,7 @@ func (c *Controller) runPreparedInvocationTurn(
display,
runner,
c.PlanMode(),
frozenImages,
)
}
@@ -1481,18 +1432,18 @@ func (c *Controller) SubmitEditedDisplay(display, input, original string) {
// commands. It still resolves references, so callers can submit trusted
// user-authored prompt text without expanding the command surface.
func (c *Controller) SubmitUserTurn(input, display string) {
c.submissions.mu.Lock()
defer c.releaseSubmissionAdmission()
c.runRefTurn(input, display)
_, _ = c.submitIdentifiedWithSetup(SubmissionRequest{Input: input, Display: display}, nil, func(admission turnAdmission) {
c.runRefTurnWithAdmission(input, display, admission)
})
}
func (c *Controller) submit(input, display, editedOriginal string) {
c.submissions.mu.Lock()
defer c.releaseSubmissionAdmission()
c.submitLocked(input, display, editedOriginal)
_, _ = c.submitIdentifiedWithSetup(SubmissionRequest{Input: input, Display: display, Original: editedOriginal}, nil, func(admission turnAdmission) {
c.submitLocked(input, display, editedOriginal, admission)
})
}
func (c *Controller) submitLocked(input, display, editedOriginal string) {
func (c *Controller) submitLocked(input, display, editedOriginal string, admission turnAdmission) {
trimmed := strings.TrimSpace(input)
if note, ok := MemoryQuickAddNote(trimmed); ok {
c.rememberProjectNote(note)
@@ -1502,14 +1453,14 @@ func (c *Controller) submitLocked(input, display, editedOriginal string) {
c.rememberProjectNote(note)
return
}
if c.applyGoalCommand(trimmed, display) {
if c.applyGoalCommandWithAdmission(trimmed, display, admission) {
return
}
if strings.HasPrefix(trimmed, "!") {
c.RunShell(trimmed[1:])
return
}
c.submitCommandOrTurn(trimmed, input, display, false, editedOriginal, "")
c.submitCommandOrTurn(trimmed, input, display, false, editedOriginal, "", admission)
}
func (c *Controller) submitHTTP(input, display string) {
@@ -1517,12 +1468,12 @@ func (c *Controller) submitHTTP(input, display string) {
}
func (c *Controller) submitHTTPWithFormat(input, display, format string) {
c.submissions.mu.Lock()
defer c.releaseSubmissionAdmission()
c.submitHTTPWithFormatLocked(input, display, format)
_, _ = c.submitIdentifiedWithSetup(SubmissionRequest{Input: input, Display: display, HTTP: true, Format: format}, nil, func(admission turnAdmission) {
c.submitHTTPWithFormatLocked(input, display, format, admission)
})
}
func (c *Controller) submitHTTPWithFormatLocked(input, display, format string) {
func (c *Controller) submitHTTPWithFormatLocked(input, display, format string, admission turnAdmission) {
trimmed := strings.TrimSpace(input)
if note, ok := MemoryQuickAddNote(trimmed); ok {
c.rememberProjectNote(note)
@@ -1532,50 +1483,50 @@ func (c *Controller) submitHTTPWithFormatLocked(input, display, format string) {
c.rememberProjectNote(note)
return
}
if c.applyGoalCommand(trimmed, display) {
if c.applyGoalCommandWithAdmission(trimmed, display, admission) {
return
}
if strings.HasPrefix(trimmed, "!") {
c.notice("shell commands are unavailable from this frontend")
return
}
c.submitCommandOrTurn(trimmed, input, display, true, "", format)
c.submitCommandOrTurn(trimmed, input, display, true, "", format, admission)
}
func (c *Controller) submitCommandOrTurnReady(trimmed, input, display string, scopedRefsOnly bool, editedOriginal, format string) {
func (c *Controller) submitCommandOrTurnReady(trimmed, input, display string, scopedRefsOnly bool, editedOriginal, format string, admission turnAdmission) {
runRefTurn := func(input, display string) {
c.runRefTurnWithFormat(input, display, format)
c.runRefTurnWithFormat(input, display, format, admission)
}
runRefTurnWithRefs := func(input, refLine, display string) {
c.runRefTurnWithRefsFormat(input, refLine, display, format)
c.runRefTurnWithRefsFormat(input, refLine, display, format, admission)
}
runGoalLoop := func(ctx context.Context, input, raw, display string) error {
return c.runGoalLoopWithRawDisplay(c.withTurnFormat(ctx, format), input, raw, display)
}
if scopedRefsOnly {
runRefTurn = func(input, display string) {
c.runScopedRefTurnWithFormat(input, display, format)
c.runScopedRefTurnWithFormat(input, display, format, admission)
}
runRefTurnWithRefs = func(input, refLine, display string) {
c.runScopedRefTurnWithRefsFormat(input, refLine, display, format)
c.runScopedRefTurnWithRefsFormat(input, refLine, display, format, admission)
}
}
if strings.TrimSpace(editedOriginal) != "" {
runRefTurn = func(input, display string) {
c.runEditedRefTurnWithFormat(input, display, editedOriginal, format)
c.runEditedRefTurnWithFormat(input, display, editedOriginal, format, admission)
}
runRefTurnWithRefs = func(input, refLine, display string) {
c.runEditedRefTurnWithRefsFormat(input, refLine, display, editedOriginal, format)
c.runEditedRefTurnWithRefsFormat(input, refLine, display, editedOriginal, format, admission)
}
runGoalLoop = func(ctx context.Context, input, raw, display string) error {
return c.runEditedGoalLoopWithRawDisplay(ctx, input, raw, display, editedOriginal)
}
}
if id, guidance, ok := ParseProtocolRecoveryCommand(trimmed); ok {
c.submitProtocolRecoveryLocked(id, guidance)
c.submitProtocolRecoveryLocked(id, guidance, admission)
return
}
if c.submitFinalReadinessCommand(trimmed, display) {
if c.submitFinalReadinessCommand(trimmed, display, admission) {
return
}
switch {
@@ -1597,7 +1548,7 @@ func (c *Controller) submitCommandOrTurnReady(trimmed, input, display string, sc
case trimmed == "/clear":
c.runSessionVerb(c.ClearSession, "context cleared", "clear context failed: ")
case strings.HasPrefix(trimmed, "/mcp__"):
c.runGuarded(func(ctx context.Context) error {
c.runGuardedWithAdmission(func(ctx context.Context) error {
sent, found, err := c.MCPPrompt(ctx, trimmed)
if err != nil {
return err
@@ -1607,7 +1558,7 @@ func (c *Controller) submitCommandOrTurnReady(trimmed, input, display string, sc
return nil
}
return runGoalLoop(ctx, sent, sent, display)
})
}, admission)
case SlashCodeCommentLine(trimmed):
// Slash-prefixed code comments are prompt text, not slash commands.
runRefTurn(input, display)
@@ -1667,7 +1618,7 @@ func (c *Controller) submitCommandOrTurnReady(trimmed, input, display string, sc
c.applyPlanExec(trimmed, display)
return
case "/prometheus":
c.applyPrometheus(trimmed, display)
c.applyPrometheus(trimmed, display, admission)
return
}
if c.managementNotice(trimmed) {
@@ -1684,21 +1635,21 @@ func (c *Controller) submitCommandOrTurnReady(trimmed, input, display string, sc
}
return
}
c.runGuarded(func(ctx context.Context) error {
c.runGuardedWithAdmission(func(ctx context.Context) error {
sent, err := docsCommandPrompt(ctx, query)
if err != nil {
return fmt.Errorf("docs: %w", err)
}
return runGoalLoop(ctx, sent, sent, display)
})
}, admission)
return
}
// A custom command wins over a skill of the same name; both resolve to a
// turn. Built-ins and their explicit Reasonix namespace are handled above.
if sent, ok := c.CustomCommand(trimmed); ok {
c.runGuarded(func(ctx context.Context) error {
c.runGuardedWithAdmission(func(ctx context.Context) error {
return runGoalLoop(ctx, sent, sent, display)
})
}, admission)
return
}
if sk, task, ok := c.resolveSkillInvocation(trimmed); ok {
@@ -1707,13 +1658,13 @@ func (c *Controller) submitCommandOrTurnReady(trimmed, input, display string, sc
c.notice("usage: /" + sk.Name + " <task>")
return
}
c.runSubagentSkillSlash(sk, task, trimmed, display)
c.runSubagentSkillSlash(sk, task, trimmed, display, admission)
return
}
sent := c.skills.render(sk, task)
c.runGuarded(func(ctx context.Context) error {
c.runGuardedWithAdmission(func(ctx context.Context) error {
return runGoalLoop(ctx, sent, sent, display)
})
}, admission)
return
}
// Unknown slash input is prose more often than a typo ("/etc/hosts
@@ -1750,7 +1701,7 @@ const prometheusPrompt = "You are Prometheus, a strategic planner. Interview the
// applyPrometheus starts an interactive planning interview, inspired by OMO's
// Prometheus agent. It enters goal mode with a structured interview prompt.
func (c *Controller) applyPrometheus(input, display string) {
func (c *Controller) applyPrometheus(input, display string, admission turnAdmission) {
args := strings.TrimSpace(strings.TrimPrefix(input, "/prometheus"))
if args == "" || args == "--strict" {
c.notice("usage: /prometheus <your task description>")
@@ -1767,9 +1718,9 @@ func (c *Controller) applyPrometheus(input, display string) {
c.GoalStrict(strict)
c.notice("prometheus: starting planning interview")
if c.runner != nil {
c.runGuarded(func(ctx context.Context) error {
c.runGuardedWithAdmission(func(ctx context.Context) error {
return c.runGoalLoopWithRawDisplay(ctx, prompt, prompt, display)
})
}, admission)
}
}
@@ -1797,12 +1748,16 @@ func shellCommandPreview(command string) string {
// lock with model turns — only one can run at a time. User-invoked "!" commands
// run without the OS sandbox (the user typed the command explicitly).
func (c *Controller) RunShell(command string) {
c.runShell(command, turnAdmission{})
}
func (c *Controller) runShell(command string, admission turnAdmission) {
command = strings.TrimSpace(command)
if command == "" {
c.notice(i18n.M.ShellExecEmpty)
return
}
c.runGuarded(func(ctx context.Context) error {
c.runGuardedWithAdmission(func(ctx context.Context) error {
sh := c.shell
if sh.Path == "" {
sh = sandbox.ResolveShell("", "", nil)
@@ -1899,70 +1854,79 @@ func (c *Controller) RunShell(command string) {
},
})
return nil
})
}, admission)
}
// runRefTurn resolves a line's @references into a context block and starts a
// turn with it prepended (or the raw line when nothing resolved).
func (c *Controller) runRefTurn(input, display string) {
c.runRefTurnWithRefs(input, input, display)
c.runRefTurnWithAdmission(input, display, turnAdmission{})
}
func (c *Controller) runRefTurnWithAdmission(input, display string, admission turnAdmission) {
c.runRefTurnWithRefs(input, input, display, admission)
}
// runRefTurnWithFormat runs a reference turn with a structured-output
// format bound to its context (symmetric with runGoalLoop's withTurnFormat
// injection — format is a property of every accepted turn, not just the
// plain-goal path; review #7234 binds format to the accepted turn).
func (c *Controller) runRefTurnWithFormat(input, display, format string) {
c.runGuarded(func(ctx context.Context) error {
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, input, display, "", c.resolveUnscopedRefsForTurn)
})
func (c *Controller) runRefTurnWithFormat(input, display, format string, admission turnAdmission) {
c.runPreparedRefTurn(input, input, display, "", c.resolveUnscopedRefsForTurn, func(ctx context.Context) context.Context {
return c.withTurnFormat(ctx, format)
}, admission)
}
func (c *Controller) runScopedRefTurnWithFormat(input, display, format string) {
c.runGuarded(func(ctx context.Context) error {
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, input, display, "", c.resolveScopedRefsForTurn)
})
func (c *Controller) runScopedRefTurnWithFormat(input, display, format string, admission turnAdmission) {
c.runPreparedRefTurn(input, input, display, "", c.resolveScopedRefsForTurn, func(ctx context.Context) context.Context {
return c.withTurnFormat(ctx, format)
}, admission)
}
func (c *Controller) runRefTurnWithRefsFormat(input, refLine, display, format string) {
c.runGuarded(func(ctx context.Context) error {
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, refLine, display, "", c.resolveUnscopedRefsForTurn)
})
func (c *Controller) runRefTurnWithRefsFormat(input, refLine, display, format string, admission turnAdmission) {
c.runPreparedRefTurn(input, refLine, display, "", c.resolveUnscopedRefsForTurn, func(ctx context.Context) context.Context {
return c.withTurnFormat(ctx, format)
}, admission)
}
func (c *Controller) runScopedRefTurnWithRefsFormat(input, refLine, display, format string) {
c.runGuarded(func(ctx context.Context) error {
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, refLine, display, "", c.resolveScopedRefsForTurn)
})
func (c *Controller) runScopedRefTurnWithRefsFormat(input, refLine, display, format string, admission turnAdmission) {
c.runPreparedRefTurn(input, refLine, display, "", c.resolveScopedRefsForTurn, func(ctx context.Context) context.Context {
return c.withTurnFormat(ctx, format)
}, admission)
}
func (c *Controller) runEditedRefTurnWithFormat(input, display, original, format string) {
c.runGuarded(func(ctx context.Context) error {
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, input, display, original, c.resolveUnscopedRefsForTurn)
})
func (c *Controller) runEditedRefTurnWithFormat(input, display, original, format string, admission turnAdmission) {
c.runPreparedRefTurn(input, input, display, original, c.resolveUnscopedRefsForTurn, func(ctx context.Context) context.Context {
return c.withTurnFormat(ctx, format)
}, admission)
}
func (c *Controller) runEditedRefTurnWithRefsFormat(input, refLine, display, original, format string) {
c.runGuarded(func(ctx context.Context) error {
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, refLine, display, original, c.resolveUnscopedRefsForTurn)
})
func (c *Controller) runEditedRefTurnWithRefsFormat(input, refLine, display, original, format string, admission turnAdmission) {
c.runPreparedRefTurn(input, refLine, display, original, c.resolveUnscopedRefsForTurn, func(ctx context.Context) context.Context {
return c.withTurnFormat(ctx, format)
}, admission)
}
// runRefTurnWithRefs resolves references from refLine while preserving input as
// the user's actual prompt text. This lets compiler diagnostics such as
// "/path/File.kt:12: error" attach @/path/File.kt without rewriting the error.
func (c *Controller) runRefTurnWithRefs(input, refLine, display string) {
c.runRefTurnWithResolver(input, refLine, display, c.resolveUnscopedRefsForTurn)
func (c *Controller) runRefTurnWithRefs(input, refLine, display string, admission turnAdmission) {
c.runRefTurnWithResolver(input, refLine, display, c.resolveUnscopedRefsForTurn, admission)
}
func (c *Controller) runRefTurnWithResolver(input, refLine, display string, resolve func(context.Context, string) resolvedReferences) {
c.runGuarded(func(ctx context.Context) error {
return c.runRefTurnWithResolverSync(ctx, input, refLine, display, "", resolve)
})
func (c *Controller) runRefTurnWithResolver(input, refLine, display string, resolve func(context.Context, string) resolvedReferences, admission turnAdmission) {
c.runPreparedRefTurn(input, refLine, display, "", resolve, func(ctx context.Context) context.Context { return ctx }, admission)
}
func (c *Controller) runRefTurnWithResolverSync(ctx context.Context, input, refLine, display, original string, resolve func(context.Context, string) resolvedReferences) error {
resolved := resolve(ctx, refLine)
return c.runResolvedRefTurnSync(ctx, input, display, original, resolved)
}
func (c *Controller) runResolvedRefTurnSync(ctx context.Context, input, display, original string, resolved resolvedReferences) error {
if len(resolved.imageErrs) > 0 {
return ImageReferenceFailures(resolved.imageErrs)
}
for _, e := range resolved.errs {
c.notice(e)
}
@@ -2041,56 +2005,6 @@ func (c *Controller) runReady(ctx context.Context, input string) (err error) {
return err
}
// RunSubagentProfile executes one named runAs=subagent skill synchronously and
// returns only its final answer. It is the headless CLI counterpart to explicit
// slash invocation: the child keeps an isolated session, while the caller owns
// stdout rendering and exit status. readOnly selects the preview-safe runner
// used by `reasonix subagent try`.
func (c *Controller) RunSubagentProfile(ctx context.Context, name, task string, readOnly bool) (string, error) {
ctx = c.withAuthentication(ctx)
if err := c.authentication.admissionError(); err != nil {
return "", err
}
name = strings.TrimSpace(name)
task = strings.TrimSpace(task)
if name == "" {
return "", fmt.Errorf("subagent name is required")
}
if task == "" {
return "", fmt.Errorf("subagent task is required")
}
sk, ok := c.skills.bySlashName(name)
if !ok {
return "", fmt.Errorf("unknown or disabled subagent profile %q", name)
}
if sk.RunAs != skill.RunSubagent {
return "", fmt.Errorf("skill %q is not runAs=subagent", name)
}
sk = c.skills.prepare(sk)
runner := c.skillRunner
if readOnly {
runner = c.readOnlySkillRunner
}
if runner == nil {
return "", fmt.Errorf("subagent skill runner is unavailable for %q", name)
}
c.maybeSessionStart(ctx)
parentSession := c.parentSessionID()
ctx = agent.WithParentSession(ctx, parentSession)
ctx = jobs.WithSession(ctx, parentSession)
ctx = c.withTurnImages(ctx, task)
ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage)
ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage)
ctx = agent.WithSubagentDepth(ctx, 0)
answer, err := runner(ctx, sk, task, skill.SubagentRunOptions{HostInitiated: true})
c.authentication.recordFailure(err, c.ModelRef())
if err != nil {
return "", err
}
return tool.GuardSubagentHostDecisionText(answer), nil
}
// beginRotation claims the session-rotation gate. It fails if a turn is running
// or another rotation is already in progress, so the caller holds exclusive
// rights to swap the executor session from the check here through endRotation.
+38
View File
@@ -0,0 +1,38 @@
package control
import (
"context"
"reasonix/internal/attachment"
)
func (c *Controller) RebindDraftImage(ctx context.Context, id string) (attachment.DraftCredential, error) {
svc := c.attachmentService()
draft, ok := svc.Drafts().Lookup(c.attachmentScope(), id)
if !ok {
return attachment.DraftCredential{}, attachment.Error{Code: attachment.CodeMissing, Message: "draft credential is not valid", Retry: true}
}
if _, err := svc.PrepareBatch(ctx, []attachment.Source{{Existing: &draft.Ref, DisplayName: draft.DisplayName}}); err != nil {
return attachment.DraftCredential{}, err
}
return svc.Drafts().RebindVerified(c.attachmentScope(), id)
}
func (c *Controller) rebindPreparedDrafts(req SubmissionRequest, legacy []string) error {
ids := append([]string(nil), legacy...)
for _, item := range req.Attachments {
if item.DraftID != "" {
ids = append(ids, item.DraftID)
}
}
for _, id := range ids {
// Queue edits may carry an already admitted, authorized reference.
if req.frozenSources["draft:"+id] != nil {
continue
}
if _, err := c.attachmentService().Drafts().RebindVerified(c.attachmentScope(), id); err != nil {
return err
}
}
return nil
}
+6 -6
View File
@@ -44,13 +44,13 @@ func (c *Controller) RunFinalReadinessRecoveryWithAdmission(ctx context.Context,
func (c *Controller) SubmitFinalReadinessRecovery(display, input string) {
c.submissions.mu.Lock()
defer c.releaseSubmissionAdmission()
c.submitFinalReadinessRecoveryLocked(display, input)
c.submitFinalReadinessRecoveryLocked(display, input, turnAdmission{})
}
func (c *Controller) submitFinalReadinessRecoveryLocked(display, input string) {
c.runGuarded(func(ctx context.Context) error {
func (c *Controller) submitFinalReadinessRecoveryLocked(display, input string, admission turnAdmission) {
c.runGuardedWithAdmission(func(ctx context.Context) error {
return ErrNoFinalReadinessRecovery
})
}, admission)
}
// SubmitDeliveryRecovery preserves the v1.25 desktop/API symbol.
@@ -58,7 +58,7 @@ func (c *Controller) SubmitDeliveryRecovery(display, input string) {
c.SubmitFinalReadinessRecovery(display, input)
}
func (c *Controller) submitFinalReadinessCommand(trimmed, display string) bool {
func (c *Controller) submitFinalReadinessCommand(trimmed, display string, admission turnAdmission) bool {
prompt, ok := ParseFinalReadinessRecoveryCommand(trimmed)
if !ok {
return false
@@ -66,6 +66,6 @@ func (c *Controller) submitFinalReadinessCommand(trimmed, display string) bool {
if strings.TrimSpace(display) == "" {
display = trimmed
}
c.submitFinalReadinessRecoveryLocked(display, prompt)
c.submitFinalReadinessRecoveryLocked(display, prompt, admission)
return true
}
+8 -4
View File
@@ -8,7 +8,7 @@ import (
"reasonix/internal/i18n"
)
func (c *Controller) startGoalCommandTurn(cmd GoalCommand, display string) {
func (c *Controller) startGoalCommandTurnWithAdmission(cmd GoalCommand, display string, admission turnAdmission) {
if c.GoalStatus() != GoalStatusRunning {
return
}
@@ -17,13 +17,17 @@ func (c *Controller) startGoalCommandTurn(cmd GoalCommand, display string) {
}
c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(c.Goal())))
if c.runner != nil {
c.runGuarded(func(ctx context.Context) error {
c.runGuardedWithAdmission(func(ctx context.Context) error {
return c.runGoalLoopWithRawDisplay(ctx, "Start pursuing the active goal now.", cmd.Text, display)
})
}, admission)
}
}
func (c *Controller) applyGoalCommand(input, display string) bool {
return c.applyGoalCommandWithAdmission(input, display, turnAdmission{})
}
func (c *Controller) applyGoalCommandWithAdmission(input, display string, admission turnAdmission) bool {
cmd, ok := ParseGoalCommand(input)
if !ok {
return false
@@ -43,7 +47,7 @@ func (c *Controller) applyGoalCommand(input, display string) bool {
}
c.SetPlanMode(false)
c.GoalStrict(cmd.Strict)
c.startGoalCommandTurn(cmd, display)
c.startGoalCommandTurnWithAdmission(cmd, display, admission)
case GoalCommandClear:
if c.sessionEngineEnabled() {
if err := c.SetGoalDurable(""); err != nil {
+3 -3
View File
@@ -443,9 +443,9 @@ func TestUnlimitedGoalDriverRunsBeyondHarnessDefaultCeiling(t *testing.T) {
c.Send("exercise the unlimited goal driver")
select {
case <-runner.done:
// The race detector instruments all 257 Flush/admission cycles and is
// intentionally much slower than the ordinary suite on CI runners.
case <-time.After(60 * time.Second):
// This asserts the admission count, not disk throughput. The enclosing
// test command supplies the watchdog for a genuinely stalled driver.
case <-t.Context().Done():
t.Fatal("unlimited goal did not cross 256 admitted automatic rounds")
}
view, err := c.goalLifecycleView()
+278
View File
@@ -0,0 +1,278 @@
package control
import (
"context"
"errors"
"fmt"
"path"
"strings"
"reasonix/internal/attachment"
"reasonix/internal/config"
"reasonix/internal/imageinput"
"reasonix/internal/provider"
"reasonix/internal/provider/openai"
)
func (c *Controller) StageImage(ctx context.Context, displayName, mime, dataURL string) (attachment.DraftCredential, error) {
ctx, cancel := c.NewAttachmentOperationContext(ctx)
defer cancel()
scope := c.attachmentScope()
svc := c.attachmentService()
prepared, err := svc.PrepareBatch(ctx, []attachment.Source{{DisplayName: displayName, DeclaredMIME: mime, DataURL: dataURL}})
if err != nil {
return attachment.DraftCredential{}, err
}
refs, err := svc.CommitBatch(ctx, prepared)
if err != nil {
return attachment.DraftCredential{}, err
}
if err := ctx.Err(); err != nil {
return attachment.DraftCredential{}, err
}
if scope != c.attachmentScope() {
return attachment.DraftCredential{}, attachment.Error{Code: attachment.CodeChanged, Message: "attachment owner changed; please retry"}
}
return svc.Drafts().Issue(scope, refs[0]), nil
}
func (c *Controller) ReadDraftImage(ctx context.Context, draftID string) (attachment.DraftCredential, []byte, error) {
draft, ok := c.attachmentService().Drafts().Lookup(c.attachmentScope(), draftID)
if !ok {
return attachment.DraftCredential{}, nil, attachment.Error{Code: attachment.CodeMissing, Message: "draft credential is not valid", Retry: true}
}
raw, err := c.attachmentService().ReadVerified(ctx, draft.Ref)
return draft, raw, err
}
func (c *Controller) ReleaseDraftImage(draftID string) {
c.attachmentService().Drafts().Release(c.attachmentScope(), draftID)
}
// ReadSessionAttachment returns one bounded range of an admitted original.
// digest is the only client-supplied identity; size and integrity come from
// this session's content graph.
func (c *Controller) ReadSessionAttachment(ctx context.Context, digest string, offset, length int64) ([]byte, int64, error) {
if c == nil {
return nil, 0, attachment.Error{Code: attachment.CodeMissing, Message: "attachment is not authorized for this session"}
}
service := c.SessionService()
ref, bound := c.SessionRef()
if service == nil || service.Query() == nil || !bound {
return nil, 0, attachment.Error{Code: attachment.CodeMissing, Message: "attachment is not authorized for this session"}
}
return service.Query().ReadSessionAttachment(ctx, ref, digest, offset, length)
}
func (c *Controller) PersistToolImages(ctx context.Context, images []string) ([]attachment.ImageInput, error) {
if len(images) == 0 {
return nil, nil
}
out := make([]attachment.ImageInput, 0, len(images))
var sources []attachment.Source
var slots []int
for _, image := range images {
switch provider.ClassifyImage(image) {
case provider.ImageHTTPURL:
out = append(out, attachment.ImageInput{Kind: attachment.KindURL, URL: image})
case provider.ImageFileID:
out = append(out, attachment.ImageInput{Kind: attachment.KindFiles, FilesID: image})
default:
slots = append(slots, len(out))
out = append(out, attachment.ImageInput{})
sources = append(sources, attachment.Source{DataURL: image})
}
}
if len(sources) == 0 {
return out, nil
}
svc := c.attachmentService()
prepared, err := svc.PrepareBatch(ctx, sources)
if err != nil {
return nil, err
}
refs, err := svc.CommitBatch(ctx, prepared)
if err != nil {
return nil, err
}
for i, ref := range refs {
item := ref
out[slots[i]] = attachment.ImageInput{Kind: attachment.KindAttachment, Attachment: &item}
}
return out, nil
}
func (c *Controller) ResolveRequestImages(ctx context.Context, msgs []provider.Message) ([]provider.Message, error) {
if c == nil {
return msgs, nil
}
return c.ResolveRequestImagesForModel(ctx, msgs, c.selection.ref, c.imageInputEnabled())
}
// ImageRequestRoute is request-local and must never be persisted in history.
type ImageRequestRoute struct {
Model string
BaseURL string
APIKey string
AuthHeader bool
Protocol string
}
func (c *Controller) captureImageRoutes(cfg *config.Config) {
c.imageRoutes = make(map[string]ImageRequestRoute)
for _, entry := range cfg.Providers {
protocol := "openai"
if strings.EqualFold(entry.Kind, "anthropic") {
protocol = "anthropic"
}
route := ImageRequestRoute{BaseURL: entry.BaseURL, APIKey: entry.APIKey(), AuthHeader: entry.AuthHeader, Protocol: protocol}
c.imageRoutes[entry.Name] = route
}
if prefix, _, ok := strings.Cut(cfg.DefaultModel, "/"); ok {
c.imageRoutes[""] = c.imageRoutes[prefix]
}
}
func (c *Controller) imageRequestRoute(model string) (ImageRequestRoute, error) {
c.imageRoutesOnce.Do(func() {
cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
c.imageRoutesErr = err
if err == nil {
c.captureImageRoutes(cfg)
}
})
if c.imageRoutesErr != nil {
return ImageRequestRoute{}, c.imageRoutesErr
}
prefix, _, _ := strings.Cut(model, "/")
route := c.imageRoutes[prefix]
route.Model = model
return route, nil
}
func (c *Controller) ResolveRequestImagesForModel(ctx context.Context, msgs []provider.Message, model string, native bool) ([]provider.Message, error) {
if c == nil {
return msgs, nil
}
route, err := c.imageRequestRoute(model)
if err != nil {
return nil, err
}
out := append([]provider.Message(nil), msgs...)
for i := range out {
if err := out[i].ValidateImageFields(); err != nil {
return nil, err
}
if len(out[i].ImageInputs) == 0 {
continue
}
if !native {
if out[i].VisionSummary == nil {
svc := imageinput.New(imageinput.Config{Model: c.visionModel, Resolve: c.visionProviderResolver, Select: c.visionModelSelector})
if c.executor != nil && c.executor.ImageInput() != nil {
svc = c.executor.ImageInput()
}
target, err := svc.SelectModel(model, nil)
if err != nil {
return nil, err
}
visionRoute, err := c.imageRequestRoute(target)
if err != nil {
return nil, err
}
images, err := c.resolveImageInputsForRoute(ctx, out[i].ImageInputs, visionRoute)
if err != nil {
return nil, err
}
summary, err := svc.UnderstandSelected(ctx, target, images, nil, c.sink)
if err != nil {
return nil, err
}
out[i].Content = imageinput.AppendSummary(out[i].Content, summary)
}
out[i].ImageInputs = nil
continue
}
resolved, err := c.resolveImageInputsForRoute(ctx, out[i].ImageInputs, route)
if err != nil {
return nil, err
}
out[i].Images = resolved
out[i].ImageInputs = nil
}
return out, nil
}
func (c *Controller) resolveImageInputsForRoute(ctx context.Context, inputs []attachment.ImageInput, route ImageRequestRoute) ([]string, error) {
out := make([]string, 0, len(inputs))
for i, in := range inputs {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := in.Validate(); err != nil {
return nil, err
}
switch in.Kind {
case attachment.KindURL:
out = append(out, in.URL)
case attachment.KindFiles:
out = append(out, in.FilesID)
case attachment.KindAttachment:
value, err := c.wireImageFromRefForRoute(ctx, *in.Attachment, route)
if err != nil {
var item attachment.Error
if errors.As(err, &item) {
item.Index = i + 1
return nil, item
}
return nil, err
}
out = append(out, value)
}
}
return out, nil
}
func (c *Controller) wireImageFromRefForRoute(ctx context.Context, ref attachment.AttachmentRef, route ImageRequestRoute) (string, error) {
svc := c.attachmentService()
raw, err := svc.ReadVerified(ctx, ref)
if err != nil {
return "", err
}
if len(raw) <= inlineImageLimit {
return attachment.DataURL(ref.MIME(), raw), nil
}
id, err := uploadImageForRoute(ctx, route, ref.DisplayName, raw)
if err == nil {
return id, nil
}
if ctx.Err() != nil {
return "", ctx.Err()
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return "", err
}
if len(raw) > provider.MaxInlineImageBytes {
return "", err
}
return attachment.DataURL(ref.MIME(), raw), nil
}
func uploadImageForRoute(ctx context.Context, route ImageRequestRoute, filename string, data []byte) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if !openai.IsDeepSeek(route.BaseURL) {
return "", errFilesAPI()
}
return uploadVisionFile(ctx, provider.FileUpload{
BaseURL: route.BaseURL,
APIKey: route.APIKey,
AuthHeader: route.AuthHeader,
Protocol: route.Protocol,
Filename: path.Base(filename),
Data: data,
})
}
func errFilesAPI() error { return fmt.Errorf("files api requires official DeepSeek") }
+41
View File
@@ -0,0 +1,41 @@
package control
import "context"
func hasExplicitImageAttachment(line string) bool {
for _, token := range parseRefTokens(line) {
if isAttachmentRef(token) && isImageAttachmentRef(token) {
return true
}
}
return false
}
func (c *Controller) runPreparedRefTurn(
input, refLine, display, original string,
resolve func(context.Context, string) resolvedReferences,
decorate func(context.Context) context.Context,
admission turnAdmission,
) {
if !hasExplicitImageAttachment(refLine) {
c.runGuardedWithAdmission(func(ctx context.Context) error {
return c.runRefTurnWithResolverSync(decorate(ctx), input, refLine, display, original, resolve)
}, admission)
return
}
// Freeze image bytes before admission so later file changes cannot alter the accepted turn.
resolveCtx := admission.durableCtx
if resolveCtx == nil {
resolveCtx = c.attachmentContext()
}
resolveCtx = contextWithPreparedImageReferences(resolveCtx, admission.images)
resolved := resolve(resolveCtx, refLine)
if len(resolved.imageErrs) > 0 {
err := ImageReferenceFailures(resolved.imageErrs)
c.notice(err.Error())
return
}
c.runGuardedWithAdmission(func(ctx context.Context) error {
return c.runResolvedRefTurnSync(decorate(ctx), input, display, original, resolved)
}, admission)
}
+27 -100
View File
@@ -11,7 +11,6 @@ import (
"sync"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/sessioninbox"
"reasonix/internal/sessiontemp"
@@ -44,7 +43,8 @@ type InboxRequest struct {
Invocations []InvocationRequest
Extra map[string]string
// FreezeRefs lists workspace-relative paths to freeze at enqueue time.
FreezeRefs []string
FreezeRefs []string
Attachments []SubmissionAttachment
}
// Inbox port on SessionAPI.
@@ -73,6 +73,7 @@ var _ Inbox = (*Controller)(nil)
// inboxState is controller-owned inbox wiring (disk store + active items).
type inboxState struct {
prepareMu sync.Mutex
// admissionMu serializes competing admission state machines. Snapshot
// recovery and completion never hold it across Store I/O.
admissionMu sync.Mutex
@@ -315,70 +316,6 @@ func (c *Controller) pauseInboxOnRotate() {
}
}
// EnqueueInbox durably queues an instruction. Only returns a receipt after
// blob+manifest commit. Does not auto-start a turn (call TrySubmit / dispatcher).
func (c *Controller) EnqueueInbox(req InboxRequest) (sessioninbox.InboxReceipt, error) {
st, err := c.ensureInbox()
if err != nil {
return sessioninbox.InboxReceipt{}, err
}
if req.ExpectedSessionPath != "" && st.SessionPath() != req.ExpectedSessionPath {
return sessioninbox.InboxReceipt{}, ErrInboxSessionChanged
}
submit := strings.TrimSpace(firstNonEmptyStr(req.Submit, req.Raw))
if submit == "" && len(req.Invocations) == 0 {
submit = strings.TrimSpace(req.Display)
}
if submit == "" && len(req.Invocations) == 0 {
return sessioninbox.InboxReceipt{}, sessioninbox.ErrEmpty
}
display := firstNonEmptyStr(req.Display, submit)
raw := firstNonEmptyStr(req.Raw, submit)
env := sessioninbox.PromptEnvelope{
DisplayText: display,
RawText: raw,
SubmitText: submit,
Format: req.Format,
Source: req.Source,
Idempotency: req.Idempotency,
ExplicitRefs: append([]string(nil), req.FreezeRefs...),
Invocations: sessionInboxInvocations(req.Invocations),
Extra: maps.Clone(req.Extra),
}
env.FrozenRefBlock, env.FrozenImages, env.ReferenceErrors = c.freezeInboxReferences(context.Background(), submit, req.FreezeRefs)
intent := req.Intent
if intent != sessioninbox.IntentSteer {
intent = sessioninbox.IntentFollowup
}
rec, err := st.Enqueue(sessioninbox.EnqueueRequest{
Intent: intent,
Envelope: env,
Source: req.Source,
Idempotency: req.Idempotency,
SessionID: agent.BranchID(st.SessionPath()),
})
if err != nil {
if errors.Is(err, sessioninbox.ErrCapacityItems) || errors.Is(err, sessioninbox.ErrCapacityBytes) || errors.Is(err, sessioninbox.ErrItemTooLarge) {
sessioninbox.NoteCapacityReject()
} else {
sessioninbox.NoteTxFail()
}
return sessioninbox.InboxReceipt{}, err
}
if !rec.Idempotent && len(env.ReferenceErrors) > 0 {
reason := strings.Join(env.ReferenceErrors, "; ")
if stateErr := st.SetState(rec.ItemID, sessioninbox.StateBlocked, reason); stateErr != nil {
return sessioninbox.InboxReceipt{}, stateErr
}
if pauseErr := st.SetPaused(true); pauseErr != nil {
return sessioninbox.InboxReceipt{}, pauseErr
}
rec.Paused = true
}
sessioninbox.NoteEnqueue(int64(len(env.SubmitText)))
return rec, nil
}
func (c *Controller) InboxSnapshot() sessioninbox.InboxSnapshot {
st, err := c.ensureInbox()
if err != nil {
@@ -419,18 +356,23 @@ func (c *Controller) UpdateInboxItem(id, display, raw, submit string) (sessionin
return sessioninbox.InboxItemMeta{}, err
}
env := sessioninbox.PromptEnvelope{
DisplayText: display,
RawText: raw,
SubmitText: submit,
Format: previous.Format,
Source: previous.Source,
ExplicitRefs: append([]string(nil), previous.ExplicitRefs...),
Invocation: previous.Invocation,
Invocations: append([]sessioninbox.StructuredInvocation(nil), previous.Invocations...),
Attachments: append([]string(nil), previous.Attachments...),
Extra: maps.Clone(previous.Extra),
DisplayText: display,
RawText: raw,
SubmitText: submit,
Format: previous.Format,
ImageInputs: previous.ImageInputs,
ImageSourceRefs: maps.Clone(previous.ImageSourceRefs),
AttachmentIdentities: previous.AttachmentIdentities,
Source: previous.Source,
ExplicitRefs: append([]string(nil), previous.ExplicitRefs...),
Invocation: previous.Invocation,
Invocations: append([]sessioninbox.StructuredInvocation(nil), previous.Invocations...),
Attachments: append([]string(nil), previous.Attachments...),
Extra: maps.Clone(previous.Extra),
}
if err := c.freezeInboxEnvelopeReferences(context.Background(), &env, submit, env.ExplicitRefs); err != nil {
return sessioninbox.InboxItemMeta{}, err
}
env.FrozenRefBlock, env.FrozenImages, env.ReferenceErrors = c.freezeInboxReferences(context.Background(), submit, env.ExplicitRefs)
updated, err := st.UpdateItem(id, env)
if err != nil {
return sessioninbox.InboxItemMeta{}, err
@@ -475,7 +417,9 @@ func (c *Controller) AppendInboxItem(id, text, idempotency string, extra map[str
if len(extra) > 0 {
env.Extra = maps.Clone(extra)
}
env.FrozenRefBlock, env.FrozenImages, env.ReferenceErrors = c.freezeInboxReferences(context.Background(), merged, env.ExplicitRefs)
if err := c.freezeInboxEnvelopeReferences(context.Background(), &env, merged, env.ExplicitRefs); err != nil {
return sessioninbox.InboxItemMeta{}, err
}
aliasEnv := sessioninbox.PromptEnvelope{
DisplayText: text,
RawText: text,
@@ -574,27 +518,6 @@ func (c *Controller) retryInboxItem(id string, dispatch bool) error {
return nil
}
func (c *Controller) RefreshInboxReferences(id string) error {
st, err := c.ensureInbox()
if err != nil {
return err
}
meta, env, err := st.ReadItem(id)
if err != nil {
return err
}
_ = meta
env.Refs = nil
env.FrozenRefBlock, env.FrozenImages, env.ReferenceErrors = c.freezeInboxReferences(context.Background(), env.SubmitText, env.ExplicitRefs)
_, err = st.UpdateItem(id, env)
if err == nil && len(env.ReferenceErrors) > 0 {
reason := strings.Join(env.ReferenceErrors, "; ")
err = st.SetState(id, sessioninbox.StateBlocked, reason)
_ = st.SetPaused(true)
}
return err
}
// TrySubmitInboxItem admits a queued item as a new turn when the session is idle.
func (c *Controller) TrySubmitInboxItem(id string) (sessioninbox.InboxReceipt, error) {
c.mu.Lock()
@@ -795,8 +718,12 @@ func (c *Controller) tryEnqueueAndSteerForTurn(turnID string, req InboxRequest)
// TryEnqueueFollowup durably queues a follow-up and may dispatch if idle.
func (c *Controller) TryEnqueueFollowup(req InboxRequest) (sessioninbox.InboxReceipt, error) {
return c.TryEnqueueFollowupContext(c.attachmentContext(), req)
}
func (c *Controller) TryEnqueueFollowupContext(ctx context.Context, req InboxRequest) (sessioninbox.InboxReceipt, error) {
req.Intent = sessioninbox.IntentFollowup
rec, err := c.EnqueueInbox(req)
rec, err := c.EnqueueInboxContext(ctx, req)
if err != nil {
return rec, err
}
@@ -0,0 +1,29 @@
package control
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"reasonix/internal/sessioninbox"
)
func inboxAttachmentFingerprint(req InboxRequest, env sessioninbox.PromptEnvelope) string {
intent := req.Intent
if intent != sessioninbox.IntentSteer {
intent = sessioninbox.IntentFollowup
}
identity := canonicalSubmissionFingerprint(SubmissionRequest{
Input: env.SubmitText, Display: env.DisplayText, Original: env.RawText,
Action: string(intent), Format: req.Format, Invocations: req.Invocations, Attachments: req.Attachments,
})
stable := struct {
Request string
Source string
Extra map[string]string
ExplicitRefs []string
}{identity, env.Source, env.Extra, env.ExplicitRefs}
body, _ := json.Marshal(stable)
digest := sha256.Sum256(body)
return hex.EncodeToString(digest[:])
}
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more