Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f3b06af54 | ||
|
|
788dae1bf7 | ||
|
|
3812145c74 | ||
|
|
bfc69d5598 | ||
|
|
a946785c7b | ||
|
|
2104d1d54c | ||
|
|
6c1d19704e | ||
|
|
579751f54a | ||
|
|
87e3181a2a | ||
|
|
199d39b347 | ||
|
|
43cbb6bb7f | ||
|
|
8cfb81d071 | ||
|
|
2687450c2d | ||
|
|
796fe5b52f | ||
|
|
4924bb12e8 | ||
|
|
54c1af7edf | ||
|
|
113892ab67 | ||
|
|
5e55c2dbb9 | ||
|
|
c64c199e70 | ||
|
|
aa1da698f4 | ||
|
|
25a731f6c4 | ||
|
|
5a2745987f | ||
|
|
8a364bee5e | ||
|
|
3d2cf44608 | ||
|
|
eb92b49531 | ||
|
|
09d10625c3 | ||
|
|
05aa019738 | ||
|
|
d965aeb886 | ||
|
|
32f65c3ddc | ||
|
|
8fd0483b42 | ||
|
|
029f6d7cb4 | ||
|
|
07a6fa1e55 | ||
|
|
6b52dd790b | ||
|
|
3c015e9266 | ||
|
|
107b09e506 | ||
|
|
1f47880e8a | ||
|
|
4920c2a11c | ||
|
|
5dd9cc91bb | ||
|
|
a6822156cd | ||
|
|
5eb1832907 | ||
|
|
ad23798da5 | ||
|
|
132ddeff70 | ||
|
|
c8d45b6ff5 | ||
|
|
962c479933 | ||
|
|
72d9a21782 | ||
|
|
cc2a428a7d |
@@ -0,0 +1,3 @@
|
||||
# Claude configuration
|
||||
|
||||
This directory contains project-specific guidance and local configuration for Claude Code. The guidance records repository hygiene and verification expectations for agents working on Aego.
|
||||
@@ -0,0 +1,3 @@
|
||||
# CI workflows
|
||||
|
||||
The workflows in this directory automate checks, builds, tests, and release publishing for Aego. Optional platform-runner jobs cover Windows and macOS alongside the main Linux checks.
|
||||
@@ -9,8 +9,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GLSLANG_APT_VERSION: 15.1.0-2~ubuntu0.24.04.2
|
||||
LINUX_PKGS: >-
|
||||
build-essential cmake ninja-build pkg-config
|
||||
build-essential cmake ninja-build pkg-config openssl
|
||||
libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev
|
||||
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
|
||||
libwayland-dev wayland-protocols libdecor-0-dev
|
||||
@@ -41,7 +42,7 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
- name: linux packages
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
|
||||
- name: msys2
|
||||
if: runner.os == 'Windows'
|
||||
id: msys2
|
||||
|
||||
@@ -10,8 +10,9 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GLSLANG_APT_VERSION: 15.1.0-2~ubuntu0.24.04.2
|
||||
LINUX_PKGS: >-
|
||||
build-essential cmake ninja-build pkg-config
|
||||
build-essential cmake ninja-build pkg-config openssl
|
||||
libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev
|
||||
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
|
||||
libwayland-dev wayland-protocols libdecor-0-dev
|
||||
@@ -26,7 +27,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: system packages
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
|
||||
- uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
@@ -61,7 +62,7 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
- name: linux packages
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
|
||||
- name: msys2
|
||||
if: runner.os == 'Windows'
|
||||
id: msys2
|
||||
@@ -137,7 +138,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: linux packages
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
|
||||
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
|
||||
- uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
|
||||
@@ -17,6 +17,7 @@ tools/genicons/genicons
|
||||
|
||||
/tests/golden/testdata/actual-*
|
||||
tools/websmoke/node_modules/
|
||||
/tools/websmoke/__pycache__/
|
||||
tools/websmoke/golden/actual-*.png
|
||||
|
||||
# build output belongs in .cache/bin; these catch stray builds in the root
|
||||
|
||||
+108
-8
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
# Aego
|
||||
# Aego — A 2D game engine built in Go.
|
||||
|
||||
Make 2D games in Go. One editor, one project, desktop and web.
|
||||
Build games visually, write gameplay logic in Aego Script, and extend the engine with native Go.
|
||||
|
||||
Build scenes in the editor, script them in a small Go-like language, extend the engine with Go when you need to, and ship the same project to Linux, Windows, macOS and the browser. Deterministic simulation, rewind, physics, animation, audio and cameras come built in.
|
||||
A complete development environment with a visual editor, hot reload, deterministic simulation, frame-by-frame rewind, and cross-platform export. From your first scene to a playable game on Windows, Linux, macOS, and the web.
|
||||
|
||||
- Documentation and the playground: <https://aego.akarpov.ru>
|
||||
- Installing and building from source: [engine/res/docs/install.md](engine/res/docs/install.md)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Animation
|
||||
|
||||
`anim` provides the shared animation kernel for clips, keyframe tracks, events, and controller graphs. It samples tracks, evaluates state transitions and blending, and encodes clips and controllers in text and binary forms for use by the engine and editor.
|
||||
@@ -0,0 +1,3 @@
|
||||
# app
|
||||
|
||||
The `app` packages assemble the engine, modules, asset pipeline, and scripting workspace into development hosts. They coordinate workflows such as project creation, editing, running, and exporting outside the engine core.
|
||||
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"aego/art"
|
||||
)
|
||||
|
||||
// ArtHost is supplied only by the running editor. Offline commands must not
|
||||
// reopen files and silently replace the user's dirty graphical documents.
|
||||
// Like Host.ApplyScene, these calls run on the editor mutation thread.
|
||||
type ArtHost interface {
|
||||
ReadArt(file, section string, offset, limit int) (*Output, error)
|
||||
CaptureArt(file string, frame art.ID, channel art.Channel) (*Output, error)
|
||||
PrepareArt(file string, revision uint64, label string, ops []art.Operation) (*Output, error)
|
||||
}
|
||||
|
||||
// ArtProposal is an opaque, prepared transaction carried by the existing agent
|
||||
// proposal queue. Commit is invoked only after its normal approval decision.
|
||||
// Keeping the prepared transaction also preserves IDs returned by its preview.
|
||||
type ArtProposal interface {
|
||||
Commit() (*Output, error)
|
||||
RetainedBytes() int64
|
||||
}
|
||||
|
||||
func artHost(s *Session) (ArtHost, error) {
|
||||
if s != nil {
|
||||
if h, ok := s.host.(ArtHost); ok {
|
||||
return h, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("art: connect to the running Aego editor; offline writes to graphical documents are disabled")
|
||||
}
|
||||
func artCommands() []*Command {
|
||||
fileArg := Arg{Name: "file", Type: "string", Doc: "Project-relative .aeart source, including unsaved editor content", Required: true, Pos: true}
|
||||
return []*Command{
|
||||
{Name: "art.read", Doc: "Read the current art document, not its last saved file. Lists are paginated; revision is a decimal string for art.apply.", Args: []Arg{
|
||||
fileArg, {Name: "section", Type: "string", Doc: "summary, layers, frames, cels, palette, tags or slices"},
|
||||
{Name: "offset", Type: "integer", Doc: "First item, default 0"}, {Name: "limit", Type: "integer", Doc: "1..1024 items; default 256"},
|
||||
}, Run: func(s *Session, a Args) (*Output, error) {
|
||||
h, err := artHost(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
offset, err := a.Int("offset", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit, err := a.Int("limit", 256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if offset < 0 || limit < 1 || limit > 1024 {
|
||||
return nil, fmt.Errorf("art: invalid page")
|
||||
}
|
||||
return h.ReadArt(a.Str("file"), a.Str("section"), offset, limit)
|
||||
}},
|
||||
{Name: "art.capture", Doc: "Capture unsaved CPU pixels with document/frame/channel identity and revision. MCP returns a PNG image; the CLI writes it only with --out to a new file.", Args: []Arg{
|
||||
fileArg, {Name: "frame", Type: "string", Doc: "Stable frame ID; defaults to current frame"},
|
||||
{Name: "channel", Type: "string", Doc: "albedo, normal, height or emission; defaults to current channel"},
|
||||
}, Run: func(s *Session, a Args) (*Output, error) {
|
||||
h, err := artHost(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.CaptureArt(a.Str("file"), art.ID(a.Str("frame")), art.Channel(a.Str("channel")))
|
||||
}},
|
||||
{Name: "art.apply", Doc: "Propose one atomic graphics edit at the revision returned by art.read. Agent panel edits require approval; CLI and external agents follow the editor's confirmation setting. All use the document's common Undo. Name new entities with as and refer to them as $name later.", Write: true, Destroys: true, Args: []Arg{
|
||||
fileArg, {Name: "revision", Type: "string", Doc: "Exact decimal revision returned by art.read", Required: true},
|
||||
{Name: "label", Type: "string", Doc: "Human-readable operation label", Required: true},
|
||||
{Name: "operations", Type: "array", Doc: "1..4096 typed art.Operation objects; no paths or shell commands", Required: true, Items: map[string]any{"type": "object"}},
|
||||
}, Run: func(s *Session, a Args) (*Output, error) {
|
||||
h, err := artHost(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !a.Bool("dry-run") {
|
||||
return nil, fmt.Errorf("art: edits must pass through the editor proposal queue")
|
||||
}
|
||||
revision, err := strconv.ParseUint(a.Str("revision"), 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("art: revision must be an unsigned decimal integer")
|
||||
}
|
||||
label := strings.TrimSpace(a.Str("label"))
|
||||
if label == "" || len(label) > 256 {
|
||||
return nil, fmt.Errorf("art: label must have 1..256 bytes")
|
||||
}
|
||||
ops, err := art.DecodeOperations([]byte(a.Str("operations")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.PrepareArt(a.Str("file"), revision, label, ops)
|
||||
}},
|
||||
}
|
||||
}
|
||||
+32
-90
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"aego/project"
|
||||
@@ -14,6 +15,7 @@ type cliFlags struct {
|
||||
json bool
|
||||
quiet bool
|
||||
strict bool
|
||||
artOut string
|
||||
}
|
||||
|
||||
func Usage(w io.Writer) {
|
||||
@@ -101,6 +103,22 @@ func parseArgs(c *Command, toks []string) (Args, cliFlags, error) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
case "out":
|
||||
if c.Name != "art.capture" {
|
||||
break
|
||||
}
|
||||
if !has {
|
||||
if i+1 >= len(toks) {
|
||||
return nil, f, fmt.Errorf("--out needs a value")
|
||||
}
|
||||
i++
|
||||
val = toks[i]
|
||||
}
|
||||
if val == "" || f.artOut != "" {
|
||||
return nil, f, fmt.Errorf("--out requires one non-empty path")
|
||||
}
|
||||
f.artOut = val
|
||||
continue
|
||||
}
|
||||
x, ok := byName[name]
|
||||
if !ok {
|
||||
@@ -129,6 +147,9 @@ func parseArgs(c *Command, toks []string) (Args, cliFlags, error) {
|
||||
}
|
||||
|
||||
func report(stdout, stderr io.Writer, f cliFlags, out *Output, err error) int {
|
||||
if err == nil && f.artOut != "" {
|
||||
err = writeArtCapture(f.artOut, out)
|
||||
}
|
||||
switch {
|
||||
case f.json:
|
||||
var v any = out
|
||||
@@ -151,3 +172,26 @@ func report(stdout, stderr io.Writer, f cliFlags, out *Output, err error) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// An explicit output path never overwrites a user's existing file.
|
||||
func writeArtCapture(path string, out *Output) error {
|
||||
if out == nil || len(out.Images) != 1 || out.Images[0].Mime != "image/png" {
|
||||
return errors.New("art: editor did not return a PNG capture")
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("art: cannot create capture: %w", err)
|
||||
}
|
||||
n, err := f.Write(out.Images[0].Data)
|
||||
if err == nil && n != len(out.Images[0].Data) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if closeErr := f.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return fmt.Errorf("art: cannot write capture: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+25
-3
@@ -18,6 +18,7 @@ type Arg struct {
|
||||
Doc string
|
||||
Required bool
|
||||
Pos bool
|
||||
Items map[string]any
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
@@ -45,7 +46,9 @@ type Output struct {
|
||||
Value any `json:"value,omitempty"`
|
||||
Text string `json:"-"`
|
||||
// Images go to the model as image content, such as the screenshots of a run.
|
||||
Images []Image `json:"-"`
|
||||
Images []Image `json:"-"`
|
||||
Drawing *DrawingEdit `json:"-"`
|
||||
Art ArtProposal `json:"-"`
|
||||
}
|
||||
|
||||
// Image is a picture an answer carries.
|
||||
@@ -67,12 +70,24 @@ func catalog() []*Command {
|
||||
out = append(out, ctrlCommands()...)
|
||||
out = append(out, refsCommands()...)
|
||||
out = append(out, assetCommands()...)
|
||||
out = append(out, variantCommand())
|
||||
out = append(out, materialCommands()...)
|
||||
out = append(out, postEffectCommands()...)
|
||||
out = append(out, textureCommands()...)
|
||||
out = append(out, artCommands()...)
|
||||
out = append(out, schemaCommands()...)
|
||||
out = append(out, projectCommands()...)
|
||||
out = append(out, scriptCommands()...)
|
||||
ls := looks()
|
||||
for _, c := range out {
|
||||
c.Look = ls[c.Name]
|
||||
if c.Name == "asset.import-variant" {
|
||||
c.Look = ls["asset.pixels"]
|
||||
}
|
||||
if c.Look.Title == nil && (strings.HasPrefix(c.Name, "postfx.") || strings.HasPrefix(c.Name, "material.") || strings.HasPrefix(c.Name, "shader.") || strings.HasPrefix(c.Name, "texture.") || strings.HasPrefix(c.Name, "art.")) {
|
||||
name := strings.ReplaceAll(c.Name, ".", " ")
|
||||
c.Look = Look{Icon: "comp.sprite", Title: func(a Args) string { return name + " " + file(a) }, Summary: changes}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
@@ -130,6 +145,9 @@ func (c *Command) Usage() string {
|
||||
if c.Write {
|
||||
parts = append(parts, "[--dry-run]")
|
||||
}
|
||||
if c.Name == "art.capture" {
|
||||
parts = append(parts, "[--out path]")
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
@@ -137,7 +155,11 @@ func (c *Command) Schema() map[string]any {
|
||||
props := map[string]any{}
|
||||
var req []string
|
||||
for _, x := range c.AllArgs() {
|
||||
props[x.Name] = map[string]any{"type": x.Type, "description": x.Doc}
|
||||
prop := map[string]any{"type": x.Type, "description": x.Doc}
|
||||
if x.Items != nil {
|
||||
prop["items"] = x.Items
|
||||
}
|
||||
props[x.Name] = prop
|
||||
if x.Required {
|
||||
req = append(req, x.Name)
|
||||
}
|
||||
@@ -175,7 +197,7 @@ func Run(s *Session, name string, a Args) (*Output, error) {
|
||||
}
|
||||
out, err := c.Run(s, a)
|
||||
if out != nil {
|
||||
out.OK = err == nil
|
||||
out.OK = err == nil && !out.Errors()
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"aego/art"
|
||||
"aego/asset"
|
||||
)
|
||||
|
||||
// Source-only scene validation must not treat a partial sprite sheet as a
|
||||
// complete generic material texture when native runtime rejects that sampler.
|
||||
func TestAgentSourceMaterialRejectsPartialArtSheet(t *testing.T) {
|
||||
for _, multi := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("multi=%v", multi), func(t *testing.T) {
|
||||
s := materialSession(t)
|
||||
doc, err := art.New(4, 4, art.RGBA, art.DefaultLimits())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer doc.Close()
|
||||
if multi {
|
||||
edit, err := doc.Begin(doc.Revision(), "Add frame")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := edit.AddFrame(doc.Info().Frames[0].ID, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
change, err := edit.Prepare()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := change.Apply(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
data, err := doc.Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artID, err := s.create("assets/art.aeart", data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := fmt.Sprintf("material version=1 shader=asset(%q) { params { Noise=asset(%q) } }", asset.BuiltinShaderDissolve.String(), artID.String())
|
||||
materialID, err := s.create("assets/art.aemat", []byte(content))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info, ok := (sources{s.Pipeline}).TextureMetadata(artID); !ok || info.Region != multi || info.Width != 4 || info.Height != 4 {
|
||||
t.Fatalf("source-only metadata lost first-frame bounds: %+v/%v", info, ok)
|
||||
}
|
||||
if _, ok := (sources{s.Pipeline}).Material(materialID); ok == multi {
|
||||
t.Fatalf("source-only material resolver result=%v for multi=%v", ok, multi)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+14
-8
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -16,7 +17,7 @@ const maxBody = 8 << 20
|
||||
// HTTP serves MCP as JSON-RPC over POST on loopback, for an agent the editor runs (D341).
|
||||
type HTTP struct {
|
||||
URL string
|
||||
Token string
|
||||
token atomic.Pointer[string]
|
||||
srv *http.Server
|
||||
s *Server
|
||||
}
|
||||
@@ -27,12 +28,8 @@ func ListenHTTP(opt Options) (*HTTP, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var key [32]byte
|
||||
if _, err := rand.Read(key[:]); err != nil {
|
||||
ln.Close()
|
||||
return nil, err
|
||||
}
|
||||
h := &HTTP{URL: "http://" + ln.Addr().String() + "/mcp", Token: hex.EncodeToString(key[:]), s: newServer(opt)}
|
||||
h := &HTTP{URL: "http://" + ln.Addr().String() + "/mcp", s: newServer(opt)}
|
||||
h.RotateToken()
|
||||
h.s.ready.Store(true)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/mcp", h.serve)
|
||||
@@ -41,9 +38,18 @@ func ListenHTTP(opt Options) (*HTTP, error) {
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *HTTP) Token() string { return *h.token.Load() }
|
||||
|
||||
func (h *HTTP) RotateToken() {
|
||||
var key [32]byte
|
||||
rand.Read(key[:])
|
||||
token := hex.EncodeToString(key[:])
|
||||
h.token.Store(&token)
|
||||
}
|
||||
|
||||
func (h *HTTP) authorized(r *http.Request) bool {
|
||||
got := r.Header.Get("Authorization")
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+h.Token)) == 1
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+h.Token())) == 1
|
||||
}
|
||||
|
||||
// local refuses a request whose Host is not loopback, so a web page cannot reach the server by DNS rebinding.
|
||||
|
||||
@@ -41,7 +41,7 @@ var versions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}
|
||||
|
||||
var skipDirs = []string{".aego", ".git", "exports", "native"}
|
||||
|
||||
const instructions = `Tools for one Aego project. Paths are relative to the project root; a node is a path from the root (Player/Sprite) or #<id> from scene_tree; values use scene syntax: 2.5, [1, 2], #ff8800ff, Kinematic, "text", asset("<id>") or a project path. Read schema_show before setting fields of an unfamiliar component and docs_search / docs_read for how the engine works. Every write tool takes dry-run; scene_apply applies a whole transaction at once. Check results with scene_validate, project_validate and scene_run. A sprite with no texture yet is drawn, not built from colored rectangles: asset_pixels takes a character grid and a palette (frames give an atlas and a flipbook), asset_svg smooth shapes for tiles, UI and icons; both answer with a preview to check. Draw in the style of project.aego's art section (project_get art): its palette, and without a palette argument 0-9 and a-z are its colors.`
|
||||
const instructions = `Tools for one Aego project. Paths are relative to the project root; a node is a path from the root (Player/Sprite) or #<id> from scene_tree; values use scene syntax: 2.5, [1, 2], #ff8800ff, Kinematic, "text", asset("<id>") or a project path. Read schema_show before setting fields of an unfamiliar component and docs_search / docs_read for how the engine works. Every write tool takes dry-run; scene_apply applies a whole transaction at once. For editable .aeart in a running editor, art_read and art_capture inspect the unsaved document; art_apply proposes an undoable batch at the returned revision. Check results with scene_validate, project_validate and scene_run. A sprite with no texture yet is drawn, not built from colored rectangles: asset_pixels takes a character grid and a palette (frames give an atlas and a flipbook), asset_svg smooth shapes for tiles, UI and icons; both answer with a preview to check. Draw in the style of project.aego's art section (project_get art): its palette, and without a palette argument 0-9 and a-z are its colors.`
|
||||
|
||||
type request struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
@@ -335,6 +335,13 @@ func (s *Server) call(params json.RawMessage) (any, *rpcError) {
|
||||
}
|
||||
for _, t := range s.opt.Tools {
|
||||
if t.Name == p.Name {
|
||||
if t.Output != nil {
|
||||
out, err := t.Output(p.Arguments)
|
||||
if err != nil {
|
||||
return result(err.Error(), true), nil
|
||||
}
|
||||
return result(out, out != nil && out.Errors()), nil
|
||||
}
|
||||
text, failed := t.Run(p.Arguments)
|
||||
return result(text, failed), nil
|
||||
}
|
||||
@@ -364,7 +371,7 @@ func (s *Server) call(params json.RawMessage) (any, *rpcError) {
|
||||
if err != nil {
|
||||
return result(err.Error(), true), nil
|
||||
}
|
||||
return result(out, false), nil
|
||||
return result(out, out != nil && out.Errors()), nil
|
||||
}
|
||||
|
||||
func (s *Server) registry() (*docs.Registry, error) {
|
||||
|
||||
@@ -29,7 +29,8 @@ type Tool struct {
|
||||
Schema map[string]any
|
||||
ReadOnly bool
|
||||
// Run answers the call; it may block while the user decides.
|
||||
Run func(args map[string]any) (text string, failed bool)
|
||||
Run func(args map[string]any) (text string, failed bool)
|
||||
Output func(map[string]any) (*agent.Output, error)
|
||||
}
|
||||
|
||||
type kitSource struct {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"aego/modules/render2d"
|
||||
"aego/scene/mutate"
|
||||
)
|
||||
|
||||
type postCameraHost struct {
|
||||
Host
|
||||
doc *mutate.Document
|
||||
edits int
|
||||
}
|
||||
|
||||
func (h *postCameraHost) OpenScene(string) (*mutate.Document, bool) { return h.doc, true }
|
||||
func (h *postCameraHost) ApplyScene(_ string, _ string, tx mutate.Tx) (*mutate.Result, error) {
|
||||
r, err := h.doc.Apply(tx)
|
||||
if err == nil {
|
||||
h.edits++
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func TestPostEffectCommandTargetsDirtyCameraByStableID(t *testing.T) {
|
||||
s := materialSession(t)
|
||||
const file = "scenes/test.aescene"
|
||||
disk := []byte(`scene version=1 {
|
||||
node "43980000000000000000000000000001" name="Camera" {
|
||||
component "engine.Camera2D" version=1 {}
|
||||
component "render2d.PostProcess" version=1 { enabled=true effects=[{typeID="postfx.bloom" instanceID="first" enabled=true version=1 parameters=[]}] }
|
||||
}
|
||||
}`)
|
||||
full := filepath.Join(s.Project.Root, file)
|
||||
if err := os.WriteFile(full, disk, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, doc, err := s.sceneDisk(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = doc.Apply(mutate.Tx{Label: "Unsaved rename", Ops: []mutate.Op{mutate.Rename("Camera", "UnsavedCamera")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &postCameraHost{doc: doc}
|
||||
s.host = h
|
||||
a := Args{"file": file, "node": "UnsavedCamera", "instance": "first", "field": "Intensity", "value": "0.37", "dry-run": true}
|
||||
if _, err := Run(s, "postfx.set", a); err != nil || h.edits != 0 {
|
||||
t.Fatalf("dry run: %v / %d", err, h.edits)
|
||||
}
|
||||
delete(a, "dry-run")
|
||||
if _, err := Run(s, "postfx.set", a); err != nil || h.edits != 1 {
|
||||
t.Fatalf("apply: %v / %d", err, h.edits)
|
||||
}
|
||||
o, err := Run(s, "postfx.get", Args{"file": file, "node": "UnsavedCamera"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stack := o.Value.(render2d.PostProcess)
|
||||
e, ok := stack.Effect("first")
|
||||
if !ok || len(e.Parameters) != 1 || e.Parameters[0].Value.X != 0.37 {
|
||||
t.Fatal(stack)
|
||||
}
|
||||
for _, bad := range []string{"-1", "true", "[1,2]"} {
|
||||
a["value"] = bad
|
||||
if _, err := Run(s, "postfx.set", a); err == nil || h.edits != 1 {
|
||||
t.Fatalf("invalid %q wrote scene", bad)
|
||||
}
|
||||
}
|
||||
a["instance"] = "deleted"
|
||||
if _, err := Run(s, "postfx.set", a); err == nil {
|
||||
t.Fatal("missing instance accepted")
|
||||
}
|
||||
if b, err := os.ReadFile(full); err != nil || string(b) != string(disk) {
|
||||
t.Fatal("saved document changed")
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,9 @@ func looks() map[string]Look {
|
||||
"schema.show": {"cat.engine", func(a Args) string {
|
||||
return i18n.Tr("editor.agent.step.show_type", "Read the fields of {type}", i18n.Str("type", a.Str("type")))
|
||||
}, ok},
|
||||
"asset.paint": {"file.texture", func(a Args) string {
|
||||
return i18n.Tr("editor.agent.step.draw_pixels", "Draw {file} in pixels", i18n.Str("file", path.Base(a.Str("path"))))
|
||||
}, ok},
|
||||
"asset.pixels": {"file.texture", func(a Args) string {
|
||||
return i18n.Tr("editor.agent.step.draw_pixels", "Draw {file} in pixels", i18n.Str("file", path.Base(a.Str("path"))))
|
||||
}, ok},
|
||||
@@ -255,6 +258,12 @@ func looks() map[string]Look {
|
||||
"refs.fix": {"panel.references", func(a Args) string {
|
||||
return i18n.Tr("editor.agent.step.fix_refs", "Point references at {to}", i18n.Str("to", path.Base(a.Str("to"))))
|
||||
}, changes},
|
||||
"project.main-scene": {"file.scene", func(a Args) string {
|
||||
return i18n.Tr("editor.agent.step.set_project", "Set {target}", i18n.Str("target", "mainScene › "+a.Str("file")))
|
||||
}, changes},
|
||||
"project.input-action": {"doc.project", func(a Args) string {
|
||||
return i18n.Tr("editor.agent.step.set_project", "Set {target}", i18n.Str("target", "input › "+a.Str("name")))
|
||||
}, changes},
|
||||
"project.get": {"doc.project", func(Args) string {
|
||||
return i18n.Tr("editor.agent.step.read_project", "Read project settings")
|
||||
}, ok},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ import (
|
||||
|
||||
func projectCommands() []*Command {
|
||||
return []*Command{
|
||||
{Name: "project.main-scene", Doc: "Set an imported scene as the startup scene by project-relative path.", Args: []Arg{argFile}, Write: true, Run: (*Session).mainScene},
|
||||
{Name: "project.input-action", Doc: "Create/update one action and add it to a context. Binding strings use the project input syntax. Only an empty initial stack is initialized.", Write: true, Args: []Arg{{Name: "name", Type: "string", Required: true, Pos: true}, {Name: "type", Type: "string", Required: true}, {Name: "bind", Type: "array", Items: map[string]any{"type": "string"}, Required: true}, {Name: "context", Type: "string", Doc: "Gameplay by default"}, {Name: "active", Type: "boolean", Doc: "Add the context to the initial stack if absent"}}, Run: (*Session).inputAction},
|
||||
{Name: "project.validate", Doc: "missing assets and nodes, import errors, prefabs and scenes that do not load", Run: (*Session).projectValidate},
|
||||
{Name: "project.get", Doc: "module sections of project.aego (physics, …) with their current values",
|
||||
Args: []Arg{{Name: "section", Type: "string", Doc: "only this section", Pos: true}}, Run: (*Session).projectGet},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+17
-1
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -91,7 +92,7 @@ func proxyRun(addr, token string, c *Command, a Args) (*Output, error) {
|
||||
var rep struct {
|
||||
Result struct {
|
||||
Content []struct {
|
||||
Type, Text string
|
||||
Type, Text, Data, MimeType string
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
} `json:"result"`
|
||||
@@ -122,6 +123,21 @@ func proxyRun(addr, token string, c *Command, a Args) (*Output, error) {
|
||||
return &Output{OK: true, Text: text}, nil
|
||||
}
|
||||
o := out.Output
|
||||
if c.Name == "art.capture" {
|
||||
for _, part := range rep.Result.Content {
|
||||
if part.Type != "image" || part.MimeType != "image/png" {
|
||||
continue
|
||||
}
|
||||
if len(part.Data) > base64.StdEncoding.EncodedLen(16<<20) {
|
||||
return nil, errors.New("art: PNG exceeds agent capture budget")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(part.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("art: invalid PNG response: %w", err)
|
||||
}
|
||||
o.Images = append(o.Images, Image{Mime: part.MimeType, Data: data})
|
||||
}
|
||||
}
|
||||
var s string
|
||||
switch {
|
||||
case c.Name == "scene.run":
|
||||
|
||||
+74
-2
File diff suppressed because it is too large
Load Diff
+25
-6
@@ -42,6 +42,7 @@ const (
|
||||
)
|
||||
|
||||
type RunRequest struct {
|
||||
Camera string `json:"camera,omitempty"`
|
||||
Scene string `json:"scene,omitempty"`
|
||||
Frames int `json:"frames"`
|
||||
Seed uint64 `json:"seed"`
|
||||
@@ -67,12 +68,14 @@ type Check struct {
|
||||
|
||||
// Shot is a captured frame: the full image under .aego/cache/runs, and a scaled PNG for the answer.
|
||||
type Shot struct {
|
||||
Frame int `json:"frame"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
PNG []byte `json:"png,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ViewSignature string `json:"viewSignature,omitempty"`
|
||||
CaptureID string `json:"captureID,omitempty"`
|
||||
Frame int `json:"frame"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
PNG []byte `json:"png,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Track is where a watched node was: at the start, at each quarter of the run and at the end.
|
||||
@@ -356,6 +359,14 @@ func (r sources) Texture(ids.AssetID) (texture.Region, bool) {
|
||||
return texture.Region{}, false
|
||||
}
|
||||
|
||||
// Disk scene validation reads the same prepared data/colour and atlas metadata as Play.
|
||||
func (r sources) TextureMetadata(id ids.AssetID) (asset.TextureMetadata, bool) {
|
||||
return asset.ReadTextureMetadata(id, func(id ids.AssetID) (*asset.Entry, bool) {
|
||||
e, ok := r.pl.Entry(id)
|
||||
return &e, ok && !e.Missing && e.Error == ""
|
||||
}, func(a asset.Artifact) ([]byte, error) { return r.pl.Artifacts().ReadFile(a.Path) })
|
||||
}
|
||||
|
||||
func (r sources) Mesh(ids.AssetID) (*mesh.Mesh, bool) {
|
||||
return nil, false
|
||||
}
|
||||
@@ -414,6 +425,11 @@ func (s *Session) asset(text string) (ids.AssetID, error) {
|
||||
if id, err := ids.ParseAssetID(text); err == nil {
|
||||
return id, nil
|
||||
}
|
||||
for _, builtin := range asset.Builtins() {
|
||||
if strings.EqualFold(text, builtin.Name) {
|
||||
return builtin.ID, nil
|
||||
}
|
||||
}
|
||||
rel, err := s.rel(text)
|
||||
if err != nil {
|
||||
return ids.AssetID{}, err
|
||||
@@ -425,6 +441,9 @@ func (s *Session) asset(text string) (ids.AssetID, error) {
|
||||
}
|
||||
|
||||
func (s *Session) label(id ids.AssetID) string {
|
||||
if builtin, ok := asset.LookupBuiltin(id); ok {
|
||||
return builtin.Name
|
||||
}
|
||||
if rel, ok := s.Pipeline.PathOf(id); ok {
|
||||
return rel
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"aego/asset"
|
||||
"aego/format/aetext"
|
||||
)
|
||||
|
||||
func textureCommandFixture(t *testing.T) *Session {
|
||||
t.Helper()
|
||||
s := materialSession(t)
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
|
||||
for y := 0; y < 4; y++ {
|
||||
for x := 0; x < 4; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: byte(x * 60), A: 255})
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := png.Encode(&b, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.Project.Root, "assets/height.png"), b.Bytes(), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Pipeline.Scan(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch, err := s.Pipeline.ImportAll(); err != nil || len(batch.Failed) != 0 {
|
||||
t.Fatalf("fixture import %v %v", err, batch)
|
||||
}
|
||||
return s
|
||||
}
|
||||
func TestTextureCommandsShareAtomicMetadataMutationsAndImageRevision(t *testing.T) {
|
||||
s := textureCommandFixture(t)
|
||||
file := "assets/height.png"
|
||||
get := materialCommand(t, s, "texture.get", Args{"file": file}).Value.(map[string]any)
|
||||
hash := get["sourceHash"].(string)
|
||||
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "role", "value": "height", "expected": hash})
|
||||
before, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
|
||||
if _, err := Run(s, "texture.set", Args{"file": file, "setting": "height_strength", "value": "2", "expected": hash}); err == nil {
|
||||
t.Fatal("stale metadata hash accepted")
|
||||
}
|
||||
if _, err := Run(s, "texture.apply", Args{"file": file, "tx": `tx version=1 {op "texture-setting" {path="generate_normal" value=true} op "texture-setting" {path="height_strength" value="bad"}}`}); err == nil {
|
||||
t.Fatal("invalid typed batch accepted")
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("failed batch partially wrote metadata")
|
||||
}
|
||||
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true", "dry-run": true})
|
||||
after, _ = os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("dry run wrote metadata")
|
||||
}
|
||||
hash = materialCommand(t, s, "texture.get", Args{"file": file}).Value.(map[string]any)["sourceHash"].(string)
|
||||
image, _ := os.ReadFile(filepath.Join(s.Project.Root, file))
|
||||
image = append(image, 0)
|
||||
if err := os.WriteFile(filepath.Join(s.Project.Root, file), image, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Run(s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true", "expected": hash}); err == nil {
|
||||
t.Fatal("source image change was not in optimistic revision")
|
||||
}
|
||||
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true"})
|
||||
out := materialCommand(t, s, "texture.validate", Args{"file": file})
|
||||
if out.Value.(map[string]any)["importChecked"] != true || out.Value.(map[string]any)["gpuLinked"] != false {
|
||||
t.Fatal("validation claimed GPU link or did not run importer")
|
||||
}
|
||||
}
|
||||
func TestTextureCommandsUseDirtySharedMetadataDocument(t *testing.T) {
|
||||
s := textureCommandFixture(t)
|
||||
file := "assets/height.png"
|
||||
before, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
|
||||
m, err := asset.ParseSourceMeta(before, file+".meta")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Settings["role"], m.Settings["generate_normal"] = aetext.Ident("height"), aetext.Bool(true)
|
||||
host := &auditMaterialHost{text: map[string]string{file + ".meta": string(asset.MarshalSourceMeta(m))}}
|
||||
s.host = host
|
||||
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "height_strength", "value": "4"})
|
||||
edited, err := asset.ParseSourceMeta([]byte(host.text[file+".meta"]), file+".meta")
|
||||
if err != nil || edited.Settings.Float("height_strength", 1) != 4 || !edited.Settings.Bool("generate_normal", false) {
|
||||
t.Fatal("mutation ignored dirty metadata", err)
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("editor-session texture mutation wrote disk behind history")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
func variantCommand() *Command {
|
||||
return &Command{Name: "asset.import-variant", Write: true, Doc: "Import an inspected generated PNG variant from this editor conversation as a stable project source asset", Args: []Arg{{Name: "variant", Type: "string", Required: true}, {Name: "path", Type: "string", Required: true}, {Name: "replace", Type: "boolean"}}, Look: Look{}, Run: func(s *Session, a Args) (*Output, error) {
|
||||
host, ok := s.host.(interface{ GeneratedImage(string) ([]byte, bool) })
|
||||
if !ok {
|
||||
return nil, errors.New("requires an active editor generator session")
|
||||
}
|
||||
data, ok := host.GeneratedImage(a.Str("variant"))
|
||||
if !ok {
|
||||
return nil, errors.New("variant is missing or belongs to a previous conversation")
|
||||
}
|
||||
path, err := pngPath(a.Str("path"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.drawn(a, path, img, img, nil, false, nil, nil)
|
||||
}}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package backend runs a model for the Agent panel behind one interface: the Claude Code CLI, or a scripted fake for tests (D341).
|
||||
// Package backend runs Claude Code or Codex for the Agent panel behind one interface.
|
||||
package backend
|
||||
|
||||
import (
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
const ToolPrefix = "mcp__aego__"
|
||||
|
||||
type Event struct {
|
||||
Images [][]byte
|
||||
Kind EventKind
|
||||
Text string
|
||||
Tool string
|
||||
@@ -32,6 +33,9 @@ type Event struct {
|
||||
Result string
|
||||
Failed bool
|
||||
Err error
|
||||
// Delta appends Text verbatim to MessageID instead of starting a complete text block.
|
||||
Delta bool
|
||||
MessageID string
|
||||
// Cost in US dollars and Millis of the answer, on EvTurnEnd.
|
||||
Cost float64
|
||||
Millis int
|
||||
@@ -42,11 +46,15 @@ type Event struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Binding string
|
||||
Dir string
|
||||
Command string
|
||||
MCPURL string
|
||||
Token string
|
||||
System string
|
||||
// Empty Model and Effort inherit the selected CLI's configuration.
|
||||
Model string
|
||||
Effort string
|
||||
// Record gets every line the CLI writes, for recording sessions the tests replay.
|
||||
Record io.Writer
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClaudeProcessFixture(t *testing.T) {
|
||||
if os.Getenv("AEGO_CLAUDE_PROCESS_FIXTURE") != "1" {
|
||||
return
|
||||
}
|
||||
for i := 0; i < 200; i++ {
|
||||
fmt.Fprintln(os.Stdout, `{"type":"assistant","message":{"content":[{"type":"text","text":"stream"}]}}`)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, os.Stdin)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func TestClaudeCloseReapsProcessWithUndrainedEvents(t *testing.T) {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestClaudeProcessFixture$")
|
||||
cmd.Env = append(os.Environ(), "AEGO_CLAUDE_PROCESS_FIXTURE=1")
|
||||
conf := filepath.Join(t.TempDir(), "mcp.json")
|
||||
if err := os.WriteFile(conf, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &claudeConv{cmd: cmd, conf: conf, events: make(chan Event, 64), done: make(chan struct{})}
|
||||
var err error
|
||||
c.in, err = cmd.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Stderr = &c.stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go c.read(out)
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for len(c.events) < cap(c.events) && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if len(c.events) != cap(c.events) {
|
||||
t.Fatal("fixture did not reach a blocked event delivery")
|
||||
}
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
timeout := time.NewTimer(5 * time.Second)
|
||||
defer timeout.Stop()
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-c.Events():
|
||||
if !ok {
|
||||
if cmd.ProcessState == nil {
|
||||
t.Fatal("child was not reaped")
|
||||
}
|
||||
if _, err := os.Stat(conf); !os.IsNotExist(err) {
|
||||
t.Fatalf("temporary MCP credentials still exist: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-timeout.C:
|
||||
t.Fatal("closing a replaced Claude conversation leaked its process or reader")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -57,6 +58,11 @@ func (e rpcErr) Error() string {
|
||||
|
||||
// CallRaw is the result of a tool call as the server sent it, images included.
|
||||
func (c *Client) CallRaw(tool string, args map[string]any) (json.RawMessage, error) {
|
||||
return c.CallRawContext(context.Background(), tool, args)
|
||||
}
|
||||
|
||||
// CallRawContext cancels an outstanding question/tool when its turn is stopped.
|
||||
func (c *Client) CallRawContext(ctx context.Context, tool string, args map[string]any) (json.RawMessage, error) {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0", "id": c.next.Add(1), "method": "tools/call",
|
||||
"params": map[string]any{"name": tool, "arguments": args},
|
||||
@@ -64,7 +70,7 @@ func (c *Client) CallRaw(tool string, args map[string]any) (json.RawMessage, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, c.URL, bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEditorCatalogRejectsInvalidOrMismatchedTools(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
ok bool
|
||||
}{
|
||||
{"named", `{"name":"scene_tree"}`, true},
|
||||
{"key-only", `{}`, true},
|
||||
{"null", `null`, false},
|
||||
{"mismatch", `{"name":"different"}`, false},
|
||||
{"invalid-type", `"scene_tree"`, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := verifyEditorCatalog(map[string]json.RawMessage{"scene_tree": json.RawMessage(tc.raw)}, []string{"scene_tree"})
|
||||
if (err == nil) != tc.ok || err != nil && !strings.Contains(err.Error(), "scene_tree") {
|
||||
t.Fatalf("catalog verification: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package backend
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func hideCatalogProcess(*exec.Cmd) {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func hideCatalogProcess(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClaudeDoesNotConfuseTextSessionWithSceneAccess(t *testing.T) {
|
||||
c := &claudeConv{}
|
||||
c.status([]byte(`{"type":"system","subtype":"init","session_id":"one","model":"fixture","mcp_servers":[{"name":"aego","status":"connected"}],"tools":["mcp__aego__scene_tree"]}`))
|
||||
if d := c.Connection(); d.Tools != 1 || d.State != "missing tools" || d.Thread != "one" {
|
||||
t.Fatal(d)
|
||||
}
|
||||
c.status([]byte(`{"type":"control_response","response":{"request_id":"aego-status","subtype":"success","response":{"mcpServers":[{"name":"aego","status":"connected","tools":[{"name":"editor_scene_context"},{"name":"scene_tree"},{"name":"script_read"}]}]}}}`))
|
||||
if d := c.Connection(); d.State != "tools listed" || d.Tools != 3 {
|
||||
t.Fatal(d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRefusesMissingToolAndStaleEditorBinding(t *testing.T) {
|
||||
for _, tt := range []struct{ mode, state, reason string }{{"missing-scene-tool", "missing tools", "scene_tree"}, {"missing-context", "missing tools", "editor_scene_context"}, {"old-editor", "call rejected", "different editor session"}} {
|
||||
t.Run(tt.mode, func(t *testing.T) {
|
||||
c := startCodexFixture(t, tt.mode, func(cfg *Config) { cfg.Binding = "new-editor" })
|
||||
evs := codexEventUntil(t, c, func(e Event) bool { return e.Kind == EvError })
|
||||
err := evs[len(evs)-1].Err
|
||||
if err == nil || !strings.Contains(err.Error(), tt.reason) || c.Connection().State != tt.state {
|
||||
t.Fatalf("%+v: %v", c.Connection(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
func toolImages(raw json.RawMessage) [][]byte {
|
||||
// Claude nests the base64 source; Codex reports MCP content directly.
|
||||
var b []struct {
|
||||
Type, MimeType, Data string
|
||||
Source struct {
|
||||
Type string
|
||||
Mime string `json:"media_type"`
|
||||
Data string
|
||||
}
|
||||
}
|
||||
var r struct{ Content json.RawMessage }
|
||||
if len(raw) > 0 && raw[0] == '{' {
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
return nil
|
||||
}
|
||||
raw = r.Content
|
||||
}
|
||||
if json.Unmarshal(raw, &b) != nil {
|
||||
return nil
|
||||
}
|
||||
var out [][]byte
|
||||
for _, v := range b {
|
||||
if v.Type != "image" {
|
||||
continue
|
||||
}
|
||||
data, mime := v.Data, v.MimeType
|
||||
if v.Source.Type == "base64" {
|
||||
data, mime = v.Source.Data, v.Source.Mime
|
||||
}
|
||||
if mime != "image/png" || len(data) > 7<<20 {
|
||||
continue
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil || len(decoded) > 5<<20 {
|
||||
continue
|
||||
}
|
||||
c, err := png.DecodeConfig(bytes.NewReader(decoded))
|
||||
if err != nil || c.Width < 1 || c.Height < 1 || c.Width > 16384 || c.Height > (16<<20)/c.Width {
|
||||
continue
|
||||
}
|
||||
out = append(out, decoded)
|
||||
if len(out) == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user