Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions acptext/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module spritz.sh/acptext

go 1.25.0
53 changes: 53 additions & 0 deletions acptext/text.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package acptext

import (
"fmt"
"strings"
)

// Extract returns the readable text content represented by one ACP payload
// without trimming or normalizing whitespace.
func Extract(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
case []any:
parts := make([]string, 0, len(typed))
for _, item := range typed {
text := Extract(item)
if text == "" {
continue
}
parts = append(parts, text)
}
return strings.Join(parts, "\n")
case map[string]any:
if text, ok := typed["text"].(string); ok {
return text
}
if content, ok := typed["content"]; ok {
return Extract(content)
}
if resource, ok := typed["resource"]; ok {
return Extract(resource)
}
if uri, ok := typed["uri"].(string); ok {
return uri
}
return ""
default:
return fmt.Sprint(typed)
}
}

// JoinChunks concatenates ACP chunk payloads without injecting separators or
// trimming whitespace at chunk boundaries.
func JoinChunks(values []any) string {
var builder strings.Builder
for _, value := range values {
builder.WriteString(Extract(value))
}
return builder.String()
}
37 changes: 37 additions & 0 deletions acptext/text_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package acptext

import "testing"

func TestExtractPreservesWhitespaceInTextBlocks(t *testing.T) {
got := Extract([]any{
map[string]any{"text": "hello"},
map[string]any{"text": " world"},
map[string]any{"text": "\nagain"},
})
want := "hello\n world\n\nagain"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}

func TestExtractSupportsResourceBlocks(t *testing.T) {
if got := Extract(map[string]any{"resource": map[string]any{"text": "resource text"}}); got != "resource text" {
t.Fatalf("expected resource text, got %q", got)
}
if got := Extract(map[string]any{"resource": map[string]any{"uri": "file://workspace/report.txt"}}); got != "file://workspace/report.txt" {
t.Fatalf("expected resource uri, got %q", got)
}
}

func TestJoinChunksPreservesChunkBoundaryWhitespaceAndNewlines(t *testing.T) {
got := JoinChunks([]any{
[]any{map[string]any{"text": "I'll "}},
[]any{map[string]any{"text": "spawn a dedicated agent for you using the"}},
[]any{map[string]any{"text": "\nSpritz controls.\n\nThe"}},
[]any{map[string]any{"text": " Slack account could not be resolved.\n"}},
})
want := "I'll spawn a dedicated agent for you using the\nSpritz controls.\n\nThe Slack account could not be resolved.\n"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
49 changes: 3 additions & 46 deletions api/acp_prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"strings"
"time"

"spritz.sh/acptext"
)

type acpPromptResult struct {
Expand Down Expand Up @@ -156,50 +158,5 @@ func assistantTextFromACPUpdates(updates []map[string]any) string {
}
chunks = append(chunks, update["content"])
}
return joinACPTextChunks(chunks)
}

func joinACPTextChunks(values []any) string {
var builder strings.Builder
for _, value := range values {
builder.WriteString(extractACPText(value))
}
return builder.String()
}

func extractACPText(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
case []any:
parts := make([]string, 0, len(typed))
for _, item := range typed {
text := extractACPText(item)
if text == "" {
continue
}
parts = append(parts, text)
}
return strings.Join(parts, "\n")
case map[string]any:
if text, ok := typed["text"].(string); ok {
return text
}
if content, ok := typed["content"]; ok {
return extractACPText(content)
}
if resource, ok := typed["resource"].(map[string]any); ok {
if text, ok := resource["text"].(string); ok {
return text
}
if uri, ok := resource["uri"].(string); ok {
return uri
}
}
return ""
default:
return fmt.Sprint(typed)
}
return acptext.JoinChunks(chunks)
}
10 changes: 10 additions & 0 deletions api/create_admission.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,16 @@ func applyPresetCreateResolverMutations(body *createRequest, response extensionR
return presetCreateMutationResult{}, err
}
body.Spec.AgentRef = mergedAgentRef
specAnnotations, err := mergeMetadataStrict(body.Spec.Annotations, response.Mutations.Spec.Annotations, "spec annotation")
if err != nil {
return presetCreateMutationResult{}, err
}
body.Spec.Annotations = specAnnotations
specLabels, err := mergeMetadataStrict(body.Spec.Labels, response.Mutations.Spec.Labels, "spec label")
if err != nil {
return presetCreateMutationResult{}, err
}
body.Spec.Labels = specLabels
}
annotations, err := mergeMetadataStrict(body.Annotations, response.Mutations.Annotations, "annotation")
if err != nil {
Expand Down
136 changes: 136 additions & 0 deletions api/create_admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,98 @@ func TestCreateSpritzStoresResolvedRuntimePolicy(t *testing.T) {
}
}

func TestCreateSpritzStoresResolvedSpecMetadata(t *testing.T) {
s := newCreateSpritzTestServer(t)
resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "resolved",
"mutations": map[string]any{
"spec": map[string]any{
"serviceAccountName": "dev-agent-ag-123",
"annotations": map[string]string{
"sidecar.istio.io/inject": "true",
},
"labels": map[string]string{
"example.com/network-profile": "github",
},
},
},
})
}))
defer resolver.Close()

s.presets = presetCatalog{
byID: []runtimePreset{{
ID: "devbox",
Name: "Devbox",
Image: "example.com/devbox:latest",
NamePrefix: "devbox",
InstanceClass: "dev-runtime",
}},
}
s.instanceClasses = instanceClassCatalog{
byID: map[string]instanceClass{
"dev-runtime": {
ID: "dev-runtime",
Version: "v1",
Creation: instanceClassCreationPolicy{
RequireOwner: true,
RequiredResolvedFields: []string{
requiredResolvedFieldServiceAccountName,
},
},
},
},
}
s.extensions = extensionRegistry{
resolvers: []configuredResolver{{
id: "runtime-binding",
extensionType: extensionTypeResolver,
operation: extensionOperationPresetCreateResolve,
match: extensionMatchRule{
presetIDs: map[string]struct{}{"devbox": {}},
},
transport: configuredHTTPTransport{
url: resolver.URL,
timeout: time.Second,
},
}},
}

e := echo.New()
secured := e.Group("", s.authMiddleware())
secured.POST("/api/spritzes", s.createSpritz)

body := []byte(`{
"name":"devbox-ocean",
"presetId":"devbox",
"spec":{}
}`)
req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set("X-Spritz-User-Id", "user-1")
rec := httptest.NewRecorder()

e.ServeHTTP(rec, req)

if rec.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String())
}

stored := &spritzv1.Spritz{}
if err := s.client.Get(context.Background(), client.ObjectKey{Name: "devbox-ocean", Namespace: s.namespace}, stored); err != nil {
t.Fatalf("expected created spritz resource: %v", err)
}
if stored.Spec.Annotations["sidecar.istio.io/inject"] != "true" {
t.Fatalf("expected resolved spec annotation, got %#v", stored.Spec.Annotations)
}
if stored.Spec.Labels["example.com/network-profile"] != "github" {
t.Fatalf("expected resolved spec label, got %#v", stored.Spec.Labels)
}
}

func TestCreateSpritzProvisionerPresetResolverReplaysWithResolvedBinding(t *testing.T) {
s := newCreateSpritzTestServer(t)
configureProvisionerTestServer(s)
Expand Down Expand Up @@ -800,6 +892,50 @@ func TestCreateSpritzProvisionerRejectsManualRuntimePolicyForResolverRequiredFie
}
}

func TestCreateSpritzProvisionerRejectsManualSpecAnnotations(t *testing.T) {
s := newCreateSpritzTestServer(t)
configureProvisionerTestServer(s)
s.presets = presetCatalog{
byID: []runtimePreset{{
ID: "zeno",
Name: "Zeno",
Image: "example.com/zeno:latest",
NamePrefix: "zeno",
}},
}
s.provisioners.allowedPresetIDs = map[string]struct{}{"zeno": {}}

e := echo.New()
secured := e.Group("", s.authMiddleware())
secured.POST("/api/spritzes", s.createSpritz)

body := []byte(`{
"presetId":"zeno",
"ownerId":"user-123",
"idempotencyKey":"manual-spec-annotations",
"spec":{
"annotations":{
"sidecar.istio.io/inject":"true"
}
}
}`)
req := httptest.NewRequest(http.MethodPost, "/api/spritzes", bytes.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set("X-Spritz-User-Id", "zenobot")
req.Header.Set("X-Spritz-Principal-Type", "service")
req.Header.Set("X-Spritz-Principal-Scopes", "spritz.instances.create,spritz.instances.assign_owner")
rec := httptest.NewRecorder()

e.ServeHTTP(rec, req)

if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "spec.annotations are not allowed") {
t.Fatalf("expected spec.annotations request-surface error, got %s", rec.Body.String())
}
}

func TestCreateSpritzProvisionerRejectsManualRuntimePolicyWhenResolverOnlySetsServiceAccount(t *testing.T) {
s := newCreateSpritzTestServer(t)
configureProvisionerTestServer(s)
Expand Down
2 changes: 2 additions & 0 deletions api/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ type extensionResolverSpecMutation struct {
ServiceAccountName string `json:"serviceAccountName,omitempty"`
AgentRef *spritzv1.SpritzAgentRef `json:"agentRef,omitempty"`
RuntimePolicy *spritzv1.SpritzRuntimePolicy `json:"runtimePolicy,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}

type configuredResolver struct {
Expand Down
3 changes: 3 additions & 0 deletions api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
sigs.k8s.io/controller-runtime v0.22.4
spritz.sh/acptext v0.0.0-00010101000000-000000000000
spritz.sh/operator v0.0.0-00010101000000-000000000000
)

Expand Down Expand Up @@ -77,4 +78,6 @@ require (
sigs.k8s.io/yaml v1.6.0 // indirect
)

replace spritz.sh/acptext => ../acptext

replace spritz.sh/operator => ../operator
25 changes: 25 additions & 0 deletions docs/2026-03-24-slack-channel-gateway-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,29 @@ The gateway should also not mark delivery success just because session exchange
returned `resolved`. Success means the prompt has actually been handed off to
the runtime and the normal reply path can continue.

## ACP Reply Text Integrity

The Slack gateway must treat ACP assistant text as lossless content, not as
display text that may be normalized.

That means:

- `agent_message_chunk` text must be assembled without trimming individual
chunks
- spaces and newlines at chunk boundaries are part of the payload and must be
preserved
- the gateway may trim only for emptiness checks at the final boundary, not as
part of text extraction or chunk joining
- channel adapters should reuse one shared ACP text extraction and chunk-join
helper instead of reimplementing their own whitespace rules

If this contract is violated, the provider-visible reply can silently corrupt
content even when the runtime output is correct. Typical failures are:

- merged words across chunk boundaries
- lost paragraph breaks
- flattened lists or code blocks

## Threading Defaults

Phase 1 should keep channel behavior predictable:
Expand Down Expand Up @@ -519,6 +542,8 @@ Before calling Phase 1 done, verify:
16. Duplicate Slack webhook deliveries converge on the same pending delivery.
17. The first recovered Slack turn is not marked successful until the prompt is
actually accepted by ACP.
18. Multiline assistant replies preserve spaces and newlines across ACP chunk
boundaries.

## Follow-ups

Expand Down
Loading
Loading