mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
fceba04079
commit
e7a5b149e3
140 files changed
+10465
-1910
No files matched your search
+23
-165
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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") }
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user