完成统一方案 v0.1 剩余客户端要求(#10/#11/#12)+ 对接文档。 - #10 项目文件夹产物(§12):Manager 解析 runtime 的 markdown 多文件 artifact 成项目文件树, 新增 .../artifacts/{id}/manifest、/files/{path}、/archive 三接口(按需解析,zip 打包)。 - #11 本地修改 revision 协议(§12.7):新模型 AgentArtifactRevision + 迁移; .../local-edits、/local-edits/batch、/revisions;base_revision 冲突检测返回 ARTIFACT_REVISION_CONFLICT;Manager 持有 accepted 基线,回调 Runtime(审计事件)。 - #12 云部署控制面(§18):新模型 AgentCloudDeployment + 迁移; GET /api/heicode/deployment-targets;.../tasks/{id}/deployments(创建/列表); 生产环境进 waiting_approval;客户端只传 resource_binding_id(禁 inline secret); 真实云执行留 executor=pending_worker,等 Deploy Worker 接入。 - 文档:新增 docs/integration/heicode-desktop-unified-api.md(取代旧 sub-agile 文档, 覆盖 capabilities/统一任务路由/display_status/项目文件夹/本地修改/云部署/客户端约束/错误码)。 验证:go build ./... + go test(controller/router/model/middleware)全绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
5.4 KiB
Go
168 lines
5.4 KiB
Go
package controller
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// P2: deliverable judgment must read structured artifact_type / file signals,
|
|
// not just the legacy title/summary/uri string heuristics.
|
|
func TestRuntimeArtifactsAreSummaryOnly_StructuredFields(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
artifacts []gin.H
|
|
want bool
|
|
}{
|
|
{
|
|
name: "empty list is not summary-only",
|
|
artifacts: nil,
|
|
want: false,
|
|
},
|
|
{
|
|
// The regression the review flagged: Runtime now emits
|
|
// artifact_type=document under runtime://.../artifacts/<task>,
|
|
// which the old "/artifacts/summary" uri heuristic missed.
|
|
name: "document type with new uri scheme and no files is summary-only",
|
|
artifacts: []gin.H{{
|
|
"artifact_id": "art_1",
|
|
"artifact_type": "document",
|
|
"title": "Backend delivery",
|
|
"summary": "下面是方案总结",
|
|
"uri": "runtime://swm_x/artifacts/backend_1",
|
|
}},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "code_patch is a real deliverable",
|
|
artifacts: []gin.H{{
|
|
"artifact_type": "code_patch",
|
|
"uri": "runtime://swm_x/artifacts/backend_1",
|
|
}},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "deployment_manifest is a real deliverable",
|
|
artifacts: []gin.H{{"artifact_type": "deployment_manifest"}},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "document type but with file-change signal is a deliverable",
|
|
artifacts: []gin.H{{
|
|
"artifact_type": "document",
|
|
"metadata": map[string]any{"files_modified": float64(2)},
|
|
}},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "mixed set with one real deliverable is not summary-only",
|
|
artifacts: []gin.H{
|
|
{"artifact_type": "document"},
|
|
{"artifact_type": "code_patch"},
|
|
},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "legacy runtime execution summary marker still detected",
|
|
artifacts: []gin.H{{
|
|
"artifact_type": "other",
|
|
"title": "Runtime execution summary",
|
|
}},
|
|
want: true,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
require.Equal(t, tc.want, runtimeArtifactsAreSummaryOnly(tc.artifacts))
|
|
})
|
|
}
|
|
}
|
|
|
|
// P6a: Resource CRUD must reject a secret_ref that is not an azkv:// reference.
|
|
func TestNormalizeResourcePayload_SecretRefMustBeAzkv(t *testing.T) {
|
|
base := func(secretRef string) resourcePayload {
|
|
return resourcePayload{Name: "repo", ResourceType: "git", SecretRef: secretRef}
|
|
}
|
|
|
|
_, err := normalizeResourcePayload(base("https://example.com/token"))
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "azkv://")
|
|
|
|
_, err = normalizeResourcePayload(base("sk-live-plaintext-leak"))
|
|
require.Error(t, err)
|
|
|
|
got, err := normalizeResourcePayload(base("azkv://heicode-kv.vault.azure.net/secrets/repo"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, "azkv://heicode-kv.vault.azure.net/secrets/repo", got.SecretRef)
|
|
|
|
// Empty secret_ref stays allowed (secret can be set later via UpsertResourceSecret).
|
|
_, err = normalizeResourcePayload(base(""))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// P6b: plaintext-secret detection must scan string VALUES, not only key names.
|
|
func TestValueLooksLikeSecretAndPlaintextScan(t *testing.T) {
|
|
positives := []string{
|
|
"sk-abcdefghij1234567890",
|
|
"sk-live-abcdefghijklmnopqrst",
|
|
"ghp_abcdefghijklmnopqrstuvwxyz0123",
|
|
"AKIAIOSFODNN7EXAMPLE",
|
|
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.s5h6Qk0c5Qx2hQ",
|
|
"-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END",
|
|
}
|
|
for _, s := range positives {
|
|
require.Truef(t, valueLooksLikeSecret(s), "expected secret-like: %q", s)
|
|
}
|
|
|
|
negatives := []string{
|
|
"",
|
|
"main",
|
|
"https://github.com/org/repo.git",
|
|
"azkv://heicode-kv.vault.azure.net/secrets/repo",
|
|
"a normal sentence with sk in it",
|
|
}
|
|
for _, s := range negatives {
|
|
require.Falsef(t, valueLooksLikeSecret(s), "expected NOT secret-like: %q", s)
|
|
}
|
|
|
|
// Value hidden under an innocuous key must now be caught.
|
|
require.True(t, containsPlaintextSecret(map[string]any{"note": "sk-abcdefghij1234567890"}))
|
|
require.False(t, containsPlaintextSecret(map[string]any{"repo_url": "https://github.com/o/r.git"}))
|
|
}
|
|
|
|
// P5: a single source of truth for the default model; no placeholder fallback.
|
|
func TestDefaultAgentModelID_SingleSourceNoPlaceholder(t *testing.T) {
|
|
def := defaultAgentModelID()
|
|
require.Equal(t, "gpt-5.4", def)
|
|
|
|
// Draft builder must use the single default, never agent-model-<role>.
|
|
plan := buildAgentDraftAgentPlan("backend", "", nil)
|
|
require.Equal(t, def, plan.DefaultModelID)
|
|
require.NotContains(t, plan.DefaultModelID, "agent-model-")
|
|
|
|
// Explicit client model is still honored.
|
|
plan = buildAgentDraftAgentPlan("backend", "gpt-5.4-mini", nil)
|
|
require.Equal(t, "gpt-5.4-mini", plan.DefaultModelID)
|
|
|
|
// Every role template resolves to the single default, no claude-* hardcoding.
|
|
for _, tpl := range agentRoleTemplates() {
|
|
require.Equal(t, def, tpl.DefaultModel, "role %s", tpl.Key)
|
|
}
|
|
}
|
|
|
|
func TestParseMarkdownProject_SplitsFiles(t *testing.T) {
|
|
md := "intro text\n\n```python\n# app.py\nprint(1)\n```\n\n## 1) package.json\n```json\n{\"name\":\"x\"}\n```\n\n```text\nno name here\n```"
|
|
files := parseMarkdownProject(md)
|
|
require.Len(t, files, 3)
|
|
require.Equal(t, "app.py", files[0].Path)
|
|
require.Contains(t, files[0].content, "print(1)")
|
|
require.Equal(t, "package.json", files[1].Path)
|
|
require.Equal(t, "file1.txt", files[2].Path)
|
|
for _, f := range files {
|
|
require.NotEmpty(t, f.ContentHash)
|
|
require.Greater(t, f.SizeBytes, 0)
|
|
require.Equal(t, "file", f.Type)
|
|
}
|
|
}
|