Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9612140432 | ||
|
|
31f2da9f79 | ||
|
|
a10633ec8d | ||
|
|
ee267286a3 | ||
|
|
e3d7b8c8a5 | ||
|
|
1ffd388350 | ||
|
|
747b6bc3b9 | ||
|
|
047132e05d | ||
|
|
ee235bbf14 | ||
|
|
216bdbb19d | ||
|
|
a54ce4902a | ||
|
|
2f768d4166 | ||
|
|
a0107b622a | ||
|
|
eb937b4470 | ||
|
|
1d7cee7cc7 | ||
|
|
85d9efc67b | ||
|
|
ab6ea7ef58 | ||
|
|
9c02975882 | ||
|
|
6a148e63e0 | ||
|
|
48d1eabef2 | ||
|
|
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,5 @@
|
||||
# Golden files and project sources are byte-stable across platforms.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Preserve the upstream font license exactly as committed (it uses CRLF).
|
||||
tools/gendocs/site/static/fonts/IBM-Plex-OFL.txt -text
|
||||
@@ -0,0 +1,41 @@
|
||||
# CI workflows
|
||||
|
||||
Gitea is the source of truth. The Linux workflow runs there; optional Gitea
|
||||
platform runners remain controlled by `PLATFORM_RUNNERS`.
|
||||
|
||||
`github-native.yml` is for the GitHub mirror. It builds and tests on native
|
||||
Windows, macOS (arm64 and amd64), and Linux runners, and uploads unsigned CI
|
||||
artifacts and diagnostic logs. The Windows installer smoke is restricted to an
|
||||
ephemeral GitHub-hosted runner. macOS bundles are ad-hoc signed, not notarized.
|
||||
These jobs are not evidence of platform support until they actually pass on
|
||||
their respective runners.
|
||||
|
||||
On Gitea, the GitHub-only matrix routes to the Linux runner before being
|
||||
skipped. This prevents Windows and macOS entries from waiting indefinitely for
|
||||
runners that Gitea does not have. On GitHub, the actual native labels are used.
|
||||
macOS and Windows build the pinned GLSL reference validator from verified
|
||||
source, rather than depending on a changing package-manager version.
|
||||
|
||||
To show native GitHub results on the Gitea commit, create the GitHub Actions
|
||||
secret `GITEA_STATUS_TOKEN` containing a Gitea API token with repository write
|
||||
permission. Prefer a non-admin bot that has access only to `sanspie/aego`:
|
||||
Gitea's repository-write token is broader than commit statuses. The reporter
|
||||
publishes pending and final statuses with links to the real GitHub jobs only
|
||||
for trusted `master` pushes or manual runs, never for pull requests. If the
|
||||
secret is missing, CI still runs but no Gitea statuses are published.
|
||||
|
||||
Keep GitHub Actions disabled through the first mirror sync: historical tags
|
||||
can contain older workflows. Before enabling Actions, verify mirrored refs and
|
||||
restrict the allowed workflows so historical release jobs cannot run. Use a
|
||||
separate repository-scoped credential for the mirror; do not put it in a URL.
|
||||
Gitea's build-and-publish job remains paused. `github-release-mirror.yml`
|
||||
copies already-published Gitea releases and assets to GitHub after the current
|
||||
master passes Gitea Linux CI and all four real GitHub native jobs. It also
|
||||
checks periodically and can be dispatched manually from master. Versioned
|
||||
stable releases are published normally, with the highest stable version marked
|
||||
Latest; nightly, beta versions, and the stable-channel pointer are prereleases.
|
||||
The mirror verifies matching git refs and asset sizes/checksums, stages new
|
||||
releases as drafts, and never moves tags or replaces existing assets. If a
|
||||
mutable nightly or another mirrored snapshot changes, the mirror stops rather
|
||||
than overwriting it; a separate reviewed update is needed in that case. Signed
|
||||
update manifests are copied unchanged and retain their original download URLs.
|
||||
@@ -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
|
||||
@@ -20,7 +21,7 @@ jobs:
|
||||
# Native runs on Windows and macOS machines; the Windows build itself is cross-compiled on Linux in ci.yml (D301).
|
||||
smoke:
|
||||
name: ${{ matrix.goos }} ${{ matrix.goarch }} smoke
|
||||
if: vars.PLATFORM_RUNNERS == 'true'
|
||||
if: github.server_url != 'https://github.com' && vars.PLATFORM_RUNNERS == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -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
|
||||
@@ -19,6 +20,7 @@ env:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
if: github.server_url != 'https://github.com'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -26,7 +28,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:
|
||||
@@ -43,6 +45,7 @@ jobs:
|
||||
run: make nsis-check
|
||||
|
||||
build:
|
||||
if: github.server_url != 'https://github.com'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -61,7 +64,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
|
||||
@@ -120,7 +123,9 @@ jobs:
|
||||
|
||||
release:
|
||||
needs: [checks, build]
|
||||
if: github.event_name == 'push'
|
||||
# Pause all automatic releases until the reviewed nightly/stable pipeline
|
||||
# enforces native gates and preserves existing tags and release assets.
|
||||
if: ${{ false }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -137,7 +142,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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
name: github-release-mirror
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [github-native]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: '17 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: github-release-mirror
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
if: github.server_url == 'https://github.com' && github.repository == 'Alexander-D-Karpov/aego' && github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: refs/heads/master
|
||||
persist-credentials: false
|
||||
- name: Test release mirror
|
||||
run: python3 -m unittest discover -s tools/ci -p mirror_releases_test.py -v
|
||||
- name: Mirror published Gitea releases
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: python3 tools/ci/mirror_releases.py --apply
|
||||
@@ -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.
|
||||
+32
-90
File diff suppressed because it is too large
Load Diff
+20
-3
@@ -18,6 +18,7 @@ type Arg struct {
|
||||
Doc string
|
||||
Required bool
|
||||
Pos bool
|
||||
Items map[string]any
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
@@ -45,7 +46,8 @@ 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:"-"`
|
||||
}
|
||||
|
||||
// Image is a picture an answer carries.
|
||||
@@ -67,12 +69,23 @@ 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, 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.")) {
|
||||
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
|
||||
@@ -137,7 +150,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 +192,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
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
+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
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToolImagesDecodeProviderShapes(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
if err := png.Encode(&b, image.NewNRGBA(image.Rect(0, 0, 7, 3))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := base64.StdEncoding.EncodeToString(b.Bytes())
|
||||
for _, raw := range []any{map[string]any{"content": []any{map[string]any{"type": "image", "mimeType": "image/png", "data": data}}}, []any{map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": "image/png", "data": data}}}} {
|
||||
s, _ := json.Marshal(raw)
|
||||
g := toolImages(s)
|
||||
if len(g) != 1 || !bytes.Equal(g[0], b.Bytes()) {
|
||||
t.Fatal("lost provider image")
|
||||
}
|
||||
}
|
||||
if len(toolImages(json.RawMessage(`[{"type":"text","text":"data:image/png;base64,test"}]`))) != 0 {
|
||||
t.Fatal("text is not an image block")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user