From 39f409959173151909bd37011876510da04def21 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 1 Apr 2026 19:05:51 +0200 Subject: [PATCH 1/6] fix(slack-gateway): preserve ACP reply whitespace --- acptext/go.mod | 3 + acptext/text.go | 53 ++++++++ acptext/text_test.go | 37 ++++++ api/acp_prompt.go | 49 +------- api/go.mod | 3 + ...ack-channel-gateway-implementation-plan.md | 25 ++++ integrations/slack-gateway/acp_client.go | 53 ++------ integrations/slack-gateway/backend_client.go | 2 +- integrations/slack-gateway/gateway_test.go | 117 +++++++++++++++++- integrations/slack-gateway/go.mod | 7 +- 10 files changed, 253 insertions(+), 96 deletions(-) create mode 100644 acptext/go.mod create mode 100644 acptext/text.go create mode 100644 acptext/text_test.go diff --git a/acptext/go.mod b/acptext/go.mod new file mode 100644 index 0000000..e973c6b --- /dev/null +++ b/acptext/go.mod @@ -0,0 +1,3 @@ +module spritz.sh/acptext + +go 1.25.0 diff --git a/acptext/text.go b/acptext/text.go new file mode 100644 index 0000000..aa8c5fe --- /dev/null +++ b/acptext/text.go @@ -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() +} diff --git a/acptext/text_test.go b/acptext/text_test.go new file mode 100644 index 0000000..1698548 --- /dev/null +++ b/acptext/text_test.go @@ -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) + } +} diff --git a/api/acp_prompt.go b/api/acp_prompt.go index 78c4529..a0f222f 100644 --- a/api/acp_prompt.go +++ b/api/acp_prompt.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "time" + + "spritz.sh/acptext" ) type acpPromptResult struct { @@ -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) } diff --git a/api/go.mod b/api/go.mod index e6c5a2b..9b5d6d7 100644 --- a/api/go.mod +++ b/api/go.mod @@ -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 ) @@ -77,4 +78,6 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) +replace spritz.sh/acptext => ../acptext + replace spritz.sh/operator => ../operator diff --git a/docs/2026-03-24-slack-channel-gateway-implementation-plan.md b/docs/2026-03-24-slack-channel-gateway-implementation-plan.md index b317faa..d4df99a 100644 --- a/docs/2026-03-24-slack-channel-gateway-implementation-plan.md +++ b/docs/2026-03-24-slack-channel-gateway-implementation-plan.md @@ -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: @@ -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 diff --git a/integrations/slack-gateway/acp_client.go b/integrations/slack-gateway/acp_client.go index ce5c517..52e99f5 100644 --- a/integrations/slack-gateway/acp_client.go +++ b/integrations/slack-gateway/acp_client.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/gorilla/websocket" + "spritz.sh/acptext" ) type acpRPCMessage struct { @@ -57,7 +58,7 @@ func (g *slackGateway) promptConversation(ctx context.Context, serviceToken, nam }, nil); err != nil { return "", false, err } - var reply strings.Builder + chunks := make([]any, 0, 8) if _, promptSent, err := client.call(ctx, "session/prompt", map[string]any{ "sessionId": sessionID, "prompt": []map[string]any{{ @@ -74,15 +75,15 @@ func (g *slackGateway) promptConversation(ctx context.Context, serviceToken, nam if err := json.Unmarshal(message.Params, &payload); err != nil { return } - if strings.TrimSpace(stringValue(payload.Update["sessionUpdate"])) != "agent_message_chunk" { + if strings.TrimSpace(fmt.Sprint(payload.Update["sessionUpdate"])) != "agent_message_chunk" { return } - reply.WriteString(extractACPText(payload.Update["content"])) + chunks = append(chunks, payload.Update["content"]) }); err != nil { - return strings.TrimSpace(reply.String()), promptSent, err + return acptext.JoinChunks(chunks), promptSent, err } - text := strings.TrimSpace(reply.String()) - if text == "" { + text := acptext.JoinChunks(chunks) + if strings.TrimSpace(text) == "" { return "", true, fmt.Errorf("agent returned an empty reply") } return text, true, nil @@ -164,43 +165,3 @@ func (c *acpPromptClient) call(ctx context.Context, method string, params any, o return message.Result, delivered, nil } } - -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 { - if text := extractACPText(item); text != "" { - parts = append(parts, text) - } - } - return strings.Join(parts, "\n") - case map[string]any: - if text := stringValue(typed["text"]); text != "" { - return text - } - if content, ok := typed["content"]; ok { - return extractACPText(content) - } - if resource, ok := typed["resource"]; ok { - return extractACPText(resource) - } - if uri := stringValue(typed["uri"]); uri != "" { - return uri - } - } - return "" -} - -func stringValue(value any) string { - switch typed := value.(type) { - case string: - return strings.TrimSpace(typed) - default: - return "" - } -} diff --git a/integrations/slack-gateway/backend_client.go b/integrations/slack-gateway/backend_client.go index 5eea1e9..655bace 100644 --- a/integrations/slack-gateway/backend_client.go +++ b/integrations/slack-gateway/backend_client.go @@ -229,7 +229,7 @@ func (g *slackGateway) bootstrapConversation(ctx context.Context, serviceToken, func (g *slackGateway) postSlackMessage(ctx context.Context, token, channel, text, threadTS string) (string, error) { body := map[string]any{ "channel": strings.TrimSpace(channel), - "text": strings.TrimSpace(text), + "text": text, } if threadTS = strings.TrimSpace(threadTS); threadTS != "" { body["thread_ts"] = threadTS diff --git a/integrations/slack-gateway/gateway_test.go b/integrations/slack-gateway/gateway_test.go index be768bb..d3e6815 100644 --- a/integrations/slack-gateway/gateway_test.go +++ b/integrations/slack-gateway/gateway_test.go @@ -20,6 +20,7 @@ import ( "time" "github.com/gorilla/websocket" + "spritz.sh/acptext" ) func TestOAuthCallbackStoresInstallationAndUpsertsRegistry(t *testing.T) { @@ -266,7 +267,7 @@ func TestOAuthCallbackReturnsBadGatewayWhenBackendUpsertFails(t *testing.T) { } func TestExtractACPTextSupportsResourceBlocks(t *testing.T) { - resourceText := extractACPText(map[string]any{ + resourceText := acptext.Extract(map[string]any{ "resource": map[string]any{ "text": "resource text", }, @@ -275,7 +276,7 @@ func TestExtractACPTextSupportsResourceBlocks(t *testing.T) { t.Fatalf("expected resource text, got %q", resourceText) } - resourceURI := extractACPText(map[string]any{ + resourceURI := acptext.Extract(map[string]any{ "resource": map[string]any{ "uri": "file://workspace/report.txt", }, @@ -1648,6 +1649,118 @@ func TestPromptConversationRejectsInteractivePermissionRequests(t *testing.T) { } } +func TestPromptConversationPreservesChunkBoundaryWhitespaceAndNewlines(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + spritz := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/acp/conversations/conv-1/connect" { + t.Fatalf("unexpected spritz path %s", r.URL.Path) + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("upgrade failed: %v", err) + } + defer conn.Close() + for { + _, payload, err := conn.ReadMessage() + if err != nil { + return + } + var message map[string]any + if err := json.Unmarshal(payload, &message); err != nil { + t.Fatalf("decode ws payload: %v", err) + } + switch message["method"] { + case "initialize": + _ = conn.WriteJSON(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{"protocolVersion": 1}}) + case "session/load": + _ = conn.WriteJSON(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{}}) + case "session/prompt": + for _, chunk := range []string{ + "I'll ", + "spawn a dedicated agent for you using the", + "\nSpritz controls.\n\nThe", + " Slack account could not be resolved.\n", + } { + _ = conn.WriteJSON(map[string]any{ + "jsonrpc": "2.0", + "method": "session/update", + "params": map[string]any{ + "update": map[string]any{ + "sessionUpdate": "agent_message_chunk", + "content": []map[string]any{{ + "type": "text", + "text": chunk, + }}, + }, + }, + }) + } + _ = conn.WriteJSON(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{}}) + return + default: + t.Fatalf("unexpected ACP method %#v", message["method"]) + } + } + })) + defer spritz.Close() + + cfg := config{ + SpritzBaseURL: spritz.URL, + HTTPTimeout: 5 * time.Second, + } + gateway := newSlackGateway(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + + reply, promptSent, err := gateway.promptConversation( + t.Context(), + "owner-token", + "spritz-staging", + "conv-1", + "session-1", + "/home/dev", + "hello", + ) + if err != nil { + t.Fatalf("promptConversation returned error: %v", err) + } + if !promptSent { + t.Fatalf("expected prompt delivery to be marked as sent") + } + want := "I'll spawn a dedicated agent for you using the\nSpritz controls.\n\nThe Slack account could not be resolved.\n" + if reply != want { + t.Fatalf("expected reply %q, got %q", want, reply) + } +} + +func TestPostSlackMessagePreservesTextWhitespace(t *testing.T) { + var payload map[string]any + slackAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat.postMessage" { + t.Fatalf("unexpected slack path %s", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode slack post body: %v", err) + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ts": "1711387376.000100"}) + })) + defer slackAPI.Close() + + gateway := newSlackGateway( + config{ + SlackAPIBaseURL: slackAPI.URL, + HTTPTimeout: 5 * time.Second, + }, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + + text := "\nFirst line\n\n- bullet\n" + if _, err := gateway.postSlackMessage(t.Context(), "xoxb-installed", "C_1", text, ""); err != nil { + t.Fatalf("postSlackMessage returned error: %v", err) + } + if payload["text"] != text { + t.Fatalf("expected text %q, got %#v", text, payload["text"]) + } +} + func TestProcessMessageEventPostsFallbackAfterPromptTimeout(t *testing.T) { var slackPayloads struct { sync.Mutex diff --git a/integrations/slack-gateway/go.mod b/integrations/slack-gateway/go.mod index 6dd82f2..2ac93ab 100644 --- a/integrations/slack-gateway/go.mod +++ b/integrations/slack-gateway/go.mod @@ -2,4 +2,9 @@ module spritz.sh/integrations/slack-gateway go 1.25.0 -require github.com/gorilla/websocket v1.5.3 +require ( + github.com/gorilla/websocket v1.5.3 + spritz.sh/acptext v0.0.0-00010101000000-000000000000 +) + +replace spritz.sh/acptext => ../../acptext From 375a02b26dd01a662e64d3dd098e7b4a3b368913 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 1 Apr 2026 19:23:16 +0200 Subject: [PATCH 2/6] fix(build): stage acptext in image builds --- api/Dockerfile | 6 +++++- integrations/slack-gateway/Dockerfile | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/api/Dockerfile b/api/Dockerfile index 436597b..4bbfa5b 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,12 +1,16 @@ FROM golang:1.25-alpine AS build WORKDIR /src +COPY acptext/go.mod ./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 acptext/ /src/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 diff --git a/integrations/slack-gateway/Dockerfile b/integrations/slack-gateway/Dockerfile index 62087ce..ed69b18 100644 --- a/integrations/slack-gateway/Dockerfile +++ b/integrations/slack-gateway/Dockerfile @@ -1,11 +1,15 @@ FROM golang:1.25-alpine AS build WORKDIR /src +COPY acptext/go.mod ./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 acptext/ /src/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 From c3f87c863015aa8b94315af23ff1f42749764f19 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 1 Apr 2026 19:27:21 +0200 Subject: [PATCH 3/6] ci: cover acptext changes in go tests --- .github/workflows/go-tests.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go-tests.yml b/.github/workflows/go-tests.yml index b6a2afb..ba3038f 100644 --- a/.github/workflows/go-tests.yml +++ b/.github/workflows/go-tests.yml @@ -3,6 +3,7 @@ name: go-tests on: pull_request: paths: + - "acptext/**" - "api/**" - "operator/**" - "integrations/github-app/**" @@ -12,6 +13,7 @@ on: branches: - main paths: + - "acptext/**" - "api/**" - "operator/**" - "integrations/github-app/**" @@ -25,6 +27,8 @@ jobs: fail-fast: false matrix: include: + - name: acptext + working-directory: acptext - name: api working-directory: api - name: operator @@ -44,7 +48,7 @@ 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 ./... From eb6fc5fa047f620fd26b036c01002694ffce71b7 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 1 Apr 2026 19:31:01 +0200 Subject: [PATCH 4/6] fix(acptext): fall back to resource uri --- acptext/text.go | 2 +- acptext/text_test.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/acptext/text.go b/acptext/text.go index aa8c5fe..5fb081a 100644 --- a/acptext/text.go +++ b/acptext/text.go @@ -24,7 +24,7 @@ func Extract(value any) string { } return strings.Join(parts, "\n") case map[string]any: - if text, ok := typed["text"].(string); ok { + if text, ok := typed["text"].(string); ok && text != "" { return text } if content, ok := typed["content"]; ok { diff --git a/acptext/text_test.go b/acptext/text_test.go index 1698548..462f6c9 100644 --- a/acptext/text_test.go +++ b/acptext/text_test.go @@ -21,6 +21,9 @@ func TestExtractSupportsResourceBlocks(t *testing.T) { 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) { From 574be844fa25b3f19f8a1459a8364871c9cf4b10 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 1 Apr 2026 19:40:36 +0200 Subject: [PATCH 5/6] refactor(acp): relocate shared text helper --- .github/workflows/go-tests.yml | 6 +++--- api/Dockerfile | 4 ++-- api/go.mod | 2 +- e2e/acp-smoke-lib.mjs | 4 ++-- e2e/acp-smoke-lib.test.mjs | 7 +++++++ {acptext => integrations/acptext}/go.mod | 0 {acptext => integrations/acptext}/text.go | 0 {acptext => integrations/acptext}/text_test.go | 0 integrations/slack-gateway/Dockerfile | 4 ++-- integrations/slack-gateway/go.mod | 2 +- ui/src/lib/acp-client.test.ts | 4 ++++ ui/src/lib/acp-client.ts | 4 ++-- 12 files changed, 24 insertions(+), 13 deletions(-) rename {acptext => integrations/acptext}/go.mod (100%) rename {acptext => integrations/acptext}/text.go (100%) rename {acptext => integrations/acptext}/text_test.go (100%) diff --git a/.github/workflows/go-tests.yml b/.github/workflows/go-tests.yml index ba3038f..a51ffb9 100644 --- a/.github/workflows/go-tests.yml +++ b/.github/workflows/go-tests.yml @@ -3,7 +3,7 @@ name: go-tests on: pull_request: paths: - - "acptext/**" + - "integrations/acptext/**" - "api/**" - "operator/**" - "integrations/github-app/**" @@ -13,7 +13,7 @@ on: branches: - main paths: - - "acptext/**" + - "integrations/acptext/**" - "api/**" - "operator/**" - "integrations/github-app/**" @@ -28,7 +28,7 @@ jobs: matrix: include: - name: acptext - working-directory: acptext + working-directory: integrations/acptext - name: api working-directory: api - name: operator diff --git a/api/Dockerfile b/api/Dockerfile index 4bbfa5b..3e1d960 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,13 +1,13 @@ FROM golang:1.25-alpine AS build WORKDIR /src -COPY acptext/go.mod ./acptext/go.mod +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 WORKDIR /src -COPY acptext/ /src/acptext/ +COPY integrations/acptext/ /src/integrations/acptext/ COPY operator/ /src/operator/ COPY api/ /src/api/ WORKDIR /src/api diff --git a/api/go.mod b/api/go.mod index 9b5d6d7..3fe6f18 100644 --- a/api/go.mod +++ b/api/go.mod @@ -78,6 +78,6 @@ require ( sigs.k8s.io/yaml v1.6.0 // indirect ) -replace spritz.sh/acptext => ../acptext +replace spritz.sh/acptext => ../integrations/acptext replace spritz.sh/operator => ../operator diff --git a/e2e/acp-smoke-lib.mjs b/e2e/acp-smoke-lib.mjs index 6f48a4e..0763ddb 100644 --- a/e2e/acp-smoke-lib.mjs +++ b/e2e/acp-smoke-lib.mjs @@ -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 ''; diff --git a/e2e/acp-smoke-lib.test.mjs b/e2e/acp-smoke-lib.test.mjs index 266cdb2..8bef578 100644 --- a/e2e/acp-smoke-lib.test.mjs +++ b/e2e/acp-smoke-lib.test.mjs @@ -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'); }); diff --git a/acptext/go.mod b/integrations/acptext/go.mod similarity index 100% rename from acptext/go.mod rename to integrations/acptext/go.mod diff --git a/acptext/text.go b/integrations/acptext/text.go similarity index 100% rename from acptext/text.go rename to integrations/acptext/text.go diff --git a/acptext/text_test.go b/integrations/acptext/text_test.go similarity index 100% rename from acptext/text_test.go rename to integrations/acptext/text_test.go diff --git a/integrations/slack-gateway/Dockerfile b/integrations/slack-gateway/Dockerfile index ed69b18..adeabac 100644 --- a/integrations/slack-gateway/Dockerfile +++ b/integrations/slack-gateway/Dockerfile @@ -1,13 +1,13 @@ FROM golang:1.25-alpine AS build WORKDIR /src -COPY acptext/go.mod ./acptext/go.mod +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 acptext/ /src/acptext/ +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 . diff --git a/integrations/slack-gateway/go.mod b/integrations/slack-gateway/go.mod index 2ac93ab..1bd62b8 100644 --- a/integrations/slack-gateway/go.mod +++ b/integrations/slack-gateway/go.mod @@ -7,4 +7,4 @@ require ( spritz.sh/acptext v0.0.0-00010101000000-000000000000 ) -replace spritz.sh/acptext => ../../acptext +replace spritz.sh/acptext => ../acptext diff --git a/ui/src/lib/acp-client.test.ts b/ui/src/lib/acp-client.test.ts index 6129a6a..e6001fb 100644 --- a/ui/src/lib/acp-client.test.ts +++ b/ui/src/lib/acp-client.test.ts @@ -40,6 +40,10 @@ describe('extractACPText', () => { expect(extractACPText({ resource: { uri: 'file://foo.txt' } })).toBe('file://foo.txt'); }); + it('falls back to resource uri when text is empty', () => { + expect(extractACPText({ resource: { text: '', uri: 'file://fallback.txt' } })).toBe('file://fallback.txt'); + }); + it('handles nested arrays', () => { const input = [{ text: 'a' }, { text: 'b' }]; expect(extractACPText(input)).toBe('a\nb'); diff --git a/ui/src/lib/acp-client.ts b/ui/src/lib/acp-client.ts index 8c5dafe..887345c 100644 --- a/ui/src/lib/acp-client.ts +++ b/ui/src/lib/acp-client.ts @@ -8,12 +8,12 @@ export function extractACPText(value: unknown): string { } if (typeof value !== 'object') return String(value); const obj = value as Record; - if (typeof obj.text === 'string') return obj.text; + if (typeof obj.text === 'string' && obj.text !== '') return obj.text; if (obj.type === 'content' && obj.content) return extractACPText(obj.content); if (obj.content) return extractACPText(obj.content); if (obj.resource && typeof obj.resource === 'object') { const res = obj.resource as Record; - if (typeof res.text === 'string') return res.text; + if (typeof res.text === 'string' && res.text !== '') return res.text; if (typeof res.uri === 'string') return res.uri; } return ''; From b34ae1cb1b9de6a20b4c9c1dff1bcb937ea0b7d4 Mon Sep 17 00:00:00 2001 From: Onur Solmaz Date: Wed, 1 Apr 2026 19:51:09 +0200 Subject: [PATCH 6/6] ci(docker): verify shared-module image builds --- .github/workflows/go-tests.yml | 17 +++++++++++++++++ api/Dockerfile | 1 + integrations/slack-gateway/Dockerfile | 1 + 3 files changed, 19 insertions(+) diff --git a/.github/workflows/go-tests.yml b/.github/workflows/go-tests.yml index a51ffb9..a15eb62 100644 --- a/.github/workflows/go-tests.yml +++ b/.github/workflows/go-tests.yml @@ -52,3 +52,20 @@ jobs: - 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" diff --git a/api/Dockerfile b/api/Dockerfile index 3e1d960..8113346 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,3 +1,4 @@ +# Build with the repository root as context so the shared local Go modules are available. FROM golang:1.25-alpine AS build WORKDIR /src diff --git a/integrations/slack-gateway/Dockerfile b/integrations/slack-gateway/Dockerfile index adeabac..7017306 100644 --- a/integrations/slack-gateway/Dockerfile +++ b/integrations/slack-gateway/Dockerfile @@ -1,3 +1,4 @@ +# Build with the repository root as context so the shared local Go modules are available. FROM golang:1.25-alpine AS build WORKDIR /src