Skip to content
Merged
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
23 changes: 22 additions & 1 deletion .github/workflows/go-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: go-tests
on:
pull_request:
paths:
- "integrations/acptext/**"
- "api/**"
- "operator/**"
- "integrations/github-app/**"
Expand All @@ -12,6 +13,7 @@ on:
branches:
- main
paths:
- "integrations/acptext/**"
- "api/**"
- "operator/**"
- "integrations/github-app/**"
Expand All @@ -25,6 +27,8 @@ jobs:
fail-fast: false
matrix:
include:
- name: acptext
working-directory: integrations/acptext
- name: api
working-directory: api
- name: operator
Expand All @@ -44,7 +48,24 @@ jobs:
uses: actions/setup-go@v5
with:
go-version-file: ${{ matrix.working-directory }}/go.mod
cache-dependency-path: ${{ matrix.working-directory }}/go.sum
cache-dependency-path: ${{ matrix.working-directory }}/go.*

- name: Test
run: go test ./...

docker-build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: api
dockerfile: api/Dockerfile
- name: integrations-slack-gateway
dockerfile: integrations/slack-gateway/Dockerfile
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Build image
run: docker build -f "${{ matrix.dockerfile }}" . -t "spritz-${{ matrix.name }}-ci"
7 changes: 6 additions & 1 deletion api/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
# Build with the repository root as context so the shared local Go modules are available.
FROM golang:1.25-alpine AS build

WORKDIR /src
COPY integrations/acptext/go.mod ./integrations/acptext/go.mod
COPY api/go.mod api/go.sum ./api/
COPY operator/go.mod operator/go.sum ./operator/
WORKDIR /src/api
RUN go mod download
COPY api/ /src/api/
WORKDIR /src
COPY integrations/acptext/ /src/integrations/acptext/
COPY operator/ /src/operator/
COPY api/ /src/api/
WORKDIR /src/api
RUN CGO_ENABLED=0 go build -o /out/spritz-api .
RUN CGO_ENABLED=0 go build -o /out/spritz-shared-syncer ./cmd/shared-syncer

Expand Down
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)
}
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 => ../integrations/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
4 changes: 2 additions & 2 deletions e2e/acp-smoke-lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,10 @@ export function extractACPText(value) {
return value.map((item) => extractACPText(item)).filter(Boolean).join('\n');
}
if (typeof value !== 'object') return String(value);
if (typeof value.text === 'string') return value.text;
if (typeof value.text === 'string' && value.text !== '') return value.text;
if (value.content !== undefined) return extractACPText(value.content);
if (value.resource) {
if (typeof value.resource.text === 'string') return value.resource.text;
if (typeof value.resource.text === 'string' && value.resource.text !== '') return value.resource.text;
if (typeof value.resource.uri === 'string') return value.resource.uri;
}
return '';
Expand Down
7 changes: 7 additions & 0 deletions e2e/acp-smoke-lib.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ test('extractACPText flattens nested content blocks', () => {
);
});

test('extractACPText falls back to resource uri when text is empty', () => {
assert.equal(
extractACPText({ resource: { text: '', uri: 'file://smoke-fallback.txt' } }),
'file://smoke-fallback.txt',
);
});

test('joinACPTextChunks preserves chunked tokens without inserted separators', () => {
assert.equal(joinACPTextChunks([{ text: 'spr' }, { text: 'itz-smoke-openclaw' }]), 'spritz-smoke-openclaw');
});
Expand Down
3 changes: 3 additions & 0 deletions integrations/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 integrations/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 && text != "" {
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()
}
40 changes: 40 additions & 0 deletions integrations/acptext/text_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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)
}
if got := Extract(map[string]any{"resource": map[string]any{"text": "", "uri": "file://workspace/fallback.txt"}}); got != "file://workspace/fallback.txt" {
t.Fatalf("expected resource uri fallback, 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)
}
}
5 changes: 5 additions & 0 deletions integrations/slack-gateway/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
# Build with the repository root as context so the shared local Go modules are available.
FROM golang:1.25-alpine AS build

WORKDIR /src
COPY integrations/acptext/go.mod ./integrations/acptext/go.mod
COPY integrations/slack-gateway/go.mod ./integrations/slack-gateway/go.mod
COPY integrations/slack-gateway/go.sum ./integrations/slack-gateway/go.sum
WORKDIR /src/integrations/slack-gateway
RUN go mod download
WORKDIR /src
COPY integrations/acptext/ /src/integrations/acptext/
COPY integrations/slack-gateway/ /src/integrations/slack-gateway/
WORKDIR /src/integrations/slack-gateway
RUN CGO_ENABLED=0 go build -o /out/spritz-slack-gateway .

FROM alpine:3.20
Expand Down
Loading
Loading