From 725d662634871e8d235271e7f1757292f527cc6c Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Wed, 12 Aug 2026 11:57:15 +0200 Subject: [PATCH] Harden Go agentic development tooling --- .codex/config.toml | 8 ++ .github/workflows/ci.yml | 2 +- .github/workflows/fuzz.yml | 36 ++++++++ .github/workflows/smoke.yml | 2 +- .pre-commit-config.yaml | 10 +++ AGENTS.md | 8 ++ Dockerfile | 2 +- Makefile | 34 ++++++- README.md | 34 ++++++- docs/QUALITY_GATES.md | 12 ++- go.mod | 4 +- go.sum | 4 +- internal/cli/root_test.go | 4 +- internal/llm/llm.go | 7 +- internal/llm/openresponses/client.go | 16 ++-- internal/llm/openresponses/client_test.go | 24 ++--- .../openresponses/fakeprovider/provider.go | 5 +- .../fakeprovider/provider_test.go | 14 +-- internal/llm/openresponses/fuzz_test.go | 50 +++++++++++ internal/markdown/fuzz_test.go | 90 +++++++++++++++++++ internal/storage/sqlite_test.go | 2 +- internal/web/server_test.go | 4 +- 22 files changed, 320 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 internal/llm/openresponses/fuzz_test.go create mode 100644 internal/markdown/fuzz_test.go diff --git a/.codex/config.toml b/.codex/config.toml index 04cb47f..a35ac8b 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -15,3 +15,11 @@ extends = ":workspace" [permissions.local-web-dev-broad.network] enabled = true allow_local_binding = true + +[mcp_servers.gopls] +command = ".bin/gopls" +args = ["mcp"] +cwd = "." +enabled = true +required = false +default_tools_approval_mode = "writes" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd9b3b0..7368ba3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.26.4" + go-version: "1.26.5" - name: Run unit CI run: make ci diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..84cdf53 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,36 @@ +name: Fuzz + +on: + workflow_dispatch: + schedule: + - cron: "0 3 * * 1" + +permissions: + contents: read + +jobs: + fuzz: + name: Native Go fuzzing + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.5" + + - name: Fuzz untrusted input boundaries + run: make fuzz FUZZ_TIME=1m + + - name: Upload crash corpus + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crash-corpus + path: | + internal/markdown/testdata/fuzz + internal/llm/openresponses/testdata/fuzz + if-no-files-found: ignore diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 5bfb472..33f4a3f 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.26.4" + go-version: "1.26.5" - name: Run container smoke test run: make container-smoke diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cfa7c9e..a033024 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,6 +11,16 @@ repos: entry: make tidy-check language: system pass_filenames: false + - id: mod-verify + name: Go module cache verification + entry: make mod-verify + language: system + pass_filenames: false + - id: modernize-check + name: Go modernizer check + entry: make modernize-check + language: system + pass_filenames: false - id: lint-fast name: fast Go lint entry: make lint-fast diff --git a/AGENTS.md b/AGENTS.md index 9af9550..ca6d7fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,14 @@ Reference repositories live under `.agent/skills/references/*/repo` as skill-bac - Keep generated artifacts, coverage files, binaries, local tools, and temporary files out of Git. - Preserve clear package boundaries. Avoid adding abstractions before there is concrete duplication or complexity to remove. +## Go Agent Workflow + +- Run `make tools` after cloning so the pinned Go development tools, including `gopls`, are available under `.bin/`. +- Prefer the project-scoped `gopls` MCP server for semantic navigation, references, diagnostics, and refactors. It runs in detached mode and sees saved files only. +- Treat `gopls`, compiler, and modernizer output as evidence to review, not as a substitute for tests or architectural judgment. +- Run `make modernize-check` after Go changes. If it reports a useful change, inspect the diff before running `make modernize`. +- During development, run the narrowest relevant Go test first. Before committing, format with `gofmt` and `goimports` and run `make all-tests`. + ## Testing Standards - Use red/green TDD for all core functionality diff --git a/Dockerfile b/Dockerfile index 6cc260d..df9d5a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.4-alpine AS builder +FROM golang:1.26.5-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ diff --git a/Makefile b/Makefile index 104112c..5945001 100644 --- a/Makefile +++ b/Makefile @@ -38,14 +38,17 @@ GOLANGCI_LINT_VERSION := v2.12.2 GOVULNCHECK_VERSION := v1.3.0 GOSEC_VERSION := v2.26.1 DEADCODE_VERSION := v0.45.0 +GOPLS_VERSION := v0.23.0 GOIMPORTS := $(BIN_DIR)/goimports GOLANGCI_LINT := $(BIN_DIR)/golangci-lint GOVULNCHECK := $(BIN_DIR)/govulncheck GOSEC := $(BIN_DIR)/gosec DEADCODE := $(BIN_DIR)/deadcode +GOPLS := $(BIN_DIR)/gopls COVERAGE_PROFILE ?= coverage.out COVERAGE_MIN ?= 95.0 +FUZZ_TIME ?= 30s GO_FILES := $(shell find . \( -path './.agent/skills/references/*/repo' -o -path './third_party' -o -path './.cache' -o -path './.bin' -o -path './bin' \) -prune -o -name '*.go' -print) @@ -54,7 +57,7 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -X 'example.com/llm-chat-web/internal/buildinfo.Version=$(VERSION)' -X 'example.com/llm-chat-web/internal/buildinfo.Commit=$(COMMIT)' -X 'example.com/llm-chat-web/internal/buildinfo.Date=$(DATE)' -.PHONY: help cache-dirs fmt fmt-check imports imports-check tidy tidy-check test test-race coverage lint lint-fast vet vuln security deadcode build build-fake-responses image container-smoke serve-start serve-stop serve-status serve-restart serve-fake-start serve-fake-stop clean pre-commit all-tests ci tools +.PHONY: help cache-dirs fmt fmt-check imports imports-check tidy tidy-check mod-verify modernize modernize-check test test-race coverage fuzz lint lint-fast vet vuln security deadcode gopls build build-fake-responses image container-smoke serve-start serve-stop serve-status serve-restart serve-fake-start serve-fake-stop clean pre-commit all-tests ci tools help: @printf '%s\n' \ @@ -65,15 +68,20 @@ help: ' imports-check Check goimports formatting.' \ ' tidy Run go mod tidy.' \ ' tidy-check Check go.mod/go.sum tidiness.' \ + ' mod-verify Download and verify module cache contents.' \ + ' modernize Apply Go modernizers with go fix.' \ + ' modernize-check Check whether go fix would change Go files.' \ ' test Run unit tests.' \ ' test-race Run unit tests with the race detector.' \ ' coverage Generate coverage.out, print summary, and enforce minimum coverage.' \ + ' fuzz Fuzz untrusted input boundaries (FUZZ_TIME per target).' \ ' lint Run golangci-lint.' \ ' lint-fast Run fast local lint checks.' \ ' vet Run go vet.' \ ' vuln Run govulncheck.' \ ' security Run gosec.' \ ' deadcode Run deadcode as an advisory check.' \ + ' gopls Install the pinned gopls language server.' \ ' build Build bin/pyttechat.' \ ' build-fake-responses Build bin/fake-responses.' \ ' image Build the single-container image.' \ @@ -110,7 +118,12 @@ $(GOSEC): | $(BIN_DIR) cache-dirs $(DEADCODE): | $(BIN_DIR) cache-dirs GOBIN=$(BIN_DIR) $(GO) install golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -tools: $(GOIMPORTS) $(GOLANGCI_LINT) $(GOVULNCHECK) $(GOSEC) $(DEADCODE) +$(GOPLS): | $(BIN_DIR) cache-dirs + GOBIN=$(BIN_DIR) $(GO) install golang.org/x/tools/gopls@$(GOPLS_VERSION) + +tools: $(GOIMPORTS) $(GOLANGCI_LINT) $(GOVULNCHECK) $(GOSEC) $(DEADCODE) $(GOPLS) + +gopls: $(GOPLS) fmt: $(GOFMT) -w $(GO_FILES) @@ -139,6 +152,16 @@ tidy-check: cache-dirs rm -rf "$$tmp_dir"; \ exit $$status +mod-verify: cache-dirs + $(GO) mod download + $(GO) mod verify + +modernize: cache-dirs + $(GO) fix ./... + +modernize-check: cache-dirs + $(GO) fix -diff ./... + test: cache-dirs $(GO) test ./... @@ -162,6 +185,11 @@ coverage: cache-dirs printf "coverage %.1f%% meets required %.1f%%\n", total, minimum; \ }' +fuzz: cache-dirs + $(GO) test ./internal/markdown -run=^$$ -fuzz=^FuzzRendererRender$$ -fuzztime=$(FUZZ_TIME) + $(GO) test ./internal/markdown -run=^$$ -fuzz=^FuzzBlockStreamerChunking$$ -fuzztime=$(FUZZ_TIME) + $(GO) test ./internal/llm/openresponses -run=^$$ -fuzz=^FuzzOpenResponsesStream$$ -fuzztime=$(FUZZ_TIME) + lint: cache-dirs $(GOLANGCI_LINT) $(GOLANGCI_LINT) run ./... @@ -410,6 +438,6 @@ clean: pre-commit: cache-dirs pre-commit run --all-files -all-tests: fmt-check imports-check tidy-check vet lint-fast test test-race coverage build +all-tests: fmt-check imports-check tidy-check mod-verify modernize-check vet lint-fast test test-race coverage build ci: all-tests lint vuln security deadcode diff --git a/README.md b/README.md index dca6070..4ef1945 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,34 @@ curl -N http://localhost:8080/v1/responses \ Expected: an SSE stream with typed response lifecycle events, text deltas, `response.completed`, and final `data: [DONE]`. +## Agentic Go Tooling + +Install the repository's pinned Go tools after cloning: + +```sh +make tools +``` + +This includes `gopls`, which the trusted project configuration exposes to Codex as a non-required, approval-gated MCP server. Restart Codex after installing the tools, then use `codex mcp list` to confirm that `gopls` is available. The detached server sees saved files only; compiler and test results remain the source of truth. + +Use the Go 1.26 modernizers and module-integrity checks directly when iterating: + +```sh +make modernize-check +make mod-verify +``` + +Review the proposed modernization diff before applying it with `make modernize`. + +Native fuzz targets cover untrusted Markdown rendering, streamed Markdown chunking, and OpenResponses SSE parsing. Run each target for 30 seconds by default, or choose a duration per target: + +```sh +make fuzz +make fuzz FUZZ_TIME=10s +``` + +Regular unit tests run each fuzz seed deterministically. A separate scheduled workflow performs open-ended input generation without making pull-request checks nondeterministic. + ## Pre-Commit Install the hooks after cloning: @@ -152,7 +180,7 @@ Run the same hooks manually: make pre-commit ``` -The pre-commit checks are intentionally fast: formatting, module tidiness, fast linting, and unit tests. +The pre-commit checks are intentionally fast: formatting, module tidiness and integrity, modernization, fast linting, and unit tests. ## Local CI @@ -168,7 +196,7 @@ Run the broader PR-quality checks locally: make ci ``` -The Makefile installs pinned Go tools into `.bin/` when needed. +The Makefile installs pinned Go tools into `.bin/` when needed. `make all-tests` also rejects pending `go fix` modernizations and modified module-cache contents. `make coverage` enforces 95.0% Go statement coverage. Container-level smoke coverage is kept separate from `all-tests`/`ci` because it requires Docker: @@ -176,7 +204,7 @@ The Makefile installs pinned Go tools into `.bin/` when needed. make container-smoke ``` -The CI workflow in `.github/workflows/ci.yml` runs `make ci`, and the smoke workflow in `.github/workflows/smoke.yml` runs container smoke as its own CI job. +The CI workflow in `.github/workflows/ci.yml` runs `make ci`, the smoke workflow in `.github/workflows/smoke.yml` runs container smoke as its own CI job, and `.github/workflows/fuzz.yml` performs weekly and manually dispatched native fuzzing. ## Reference Skills diff --git a/docs/QUALITY_GATES.md b/docs/QUALITY_GATES.md index 85557d8..4790052 100644 --- a/docs/QUALITY_GATES.md +++ b/docs/QUALITY_GATES.md @@ -1,12 +1,14 @@ # Quality Gates -The current scaffold has a tiny deterministic unit test and local build/test tooling. Future application work should expand coverage before adding production behavior. +The repository uses deterministic commit gates, broader security checks, container smoke coverage, and separately scheduled native fuzzing. ## Current Checks - `make fmt-check` - `make imports-check` - `make tidy-check` +- `make mod-verify` downloads the module graph and verifies cached contents against recorded checksums. +- `make modernize-check` fails when Go's modernizers would change source files. - `make vet` - `make lint-fast` - `make test` @@ -30,6 +32,14 @@ Smoke/integration coverage is intentionally separate from the normal Go quality The smoke workflow builds the container image, starts the fake Responses API, registers a browser user through the server-rendered app, submits a chat turn, consumes the SSE stream, and verifies conversation persistence across a container restart. +Coverage-guided fuzzing is also separate so pull-request checks remain deterministic: + +- `make fuzz` runs each target for `FUZZ_TIME`, defaulting to 30 seconds per target. +- `.github/workflows/fuzz.yml` runs weekly and on manual dispatch with one minute per target. +- Fuzz targets cover Markdown rendering and sanitization, streaming chunk boundaries, and upstream OpenResponses SSE parsing. + +Every fuzz seed still runs during ordinary `go test`. If fuzzing finds a failure, fix the behavior and retain the minimized input under the generated `testdata/fuzz` corpus as a deterministic regression. + `make deadcode` is advisory during the scaffold phase because placeholder code can produce noisy findings. It should become strict once the app has a stable shape. Project quality gates are scoped to this module's own Go packages. The reference submodules under `.agent/skills/references/*/repo` are read-only design references and are not part of lint, security, vulnerability, or deadcode enforcement. diff --git a/go.mod b/go.mod index 546dd1b..779a293 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module example.com/llm-chat-web go 1.26 +toolchain go1.26.5 + require ( github.com/alecthomas/chroma/v2 v2.2.0 github.com/microcosm-cc/bluemonday v1.0.27 @@ -45,7 +47,7 @@ require ( golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/grpc v1.81.1 // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index dddbd83..b732d22 100644 --- a/go.sum +++ b/go.sum @@ -112,8 +112,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 6a293df..9d9d8c1 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1480,10 +1480,10 @@ func servedSSEData(t *testing.T, body, eventName string) []string { t.Helper() var matches []string - for _, raw := range strings.Split(strings.TrimSpace(body), "\n\n") { + for raw := range strings.SplitSeq(strings.TrimSpace(body), "\n\n") { var event string var data strings.Builder - for _, line := range strings.Split(raw, "\n") { + for line := range strings.SplitSeq(raw, "\n") { switch { case strings.HasPrefix(line, "event: "): event = strings.TrimPrefix(line, "event: ") diff --git a/internal/llm/llm.go b/internal/llm/llm.go index bae19b7..0bfb22d 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -2,6 +2,7 @@ package llm import ( "context" + "strings" "time" ) @@ -63,13 +64,13 @@ func NewTextMessage(role Role, text string) Message { } func (m Message) Text() string { - var text string + var text strings.Builder for _, part := range m.Parts { if part.Type == PartText { - text += part.Text + text.WriteString(part.Text) } } - return text + return text.String() } func (m Message) Clone() Message { diff --git a/internal/llm/openresponses/client.go b/internal/llm/openresponses/client.go index 584a2f7..780c7ac 100644 --- a/internal/llm/openresponses/client.go +++ b/internal/llm/openresponses/client.go @@ -260,8 +260,8 @@ func (s *stream) Next() (llm.Event, error) { if strings.HasPrefix(line, ":") || strings.HasPrefix(line, "event:") { continue } - if strings.HasPrefix(line, "data:") { - s.data = append(s.data, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + if after, ok := strings.CutPrefix(line, "data:"); ok { + s.data = append(s.data, strings.TrimPrefix(after, " ")) } } } @@ -384,7 +384,7 @@ func (s *stream) missingMessageText(payload map[string]any) string { rawContent, _ := item["content"].([]any) outputIndex := intField(payload, "output_index") itemID := stringField(item, "id") - var text string + var text strings.Builder for i, value := range rawContent { part, ok := value.(map[string]any) if !ok { @@ -402,9 +402,9 @@ func (s *stream) missingMessageText(payload map[string]any) string { continue } s.markTextSeen(key) - text += partText + text.WriteString(partText) } - return text + return text.String() } func (s *stream) markTextSeen(key string) { @@ -489,15 +489,15 @@ func textFromContentArray(value any) string { if !ok { return "" } - var text string + var text strings.Builder for _, item := range raw { part, ok := item.(map[string]any) if !ok { continue } - text += stringField(part, "text") + text.WriteString(stringField(part, "text")) } - return text + return text.String() } func streamError(payload map[string]any) error { diff --git a/internal/llm/openresponses/client_test.go b/internal/llm/openresponses/client_test.go index 3d8c0de..5f7cdbb 100644 --- a/internal/llm/openresponses/client_test.go +++ b/internal/llm/openresponses/client_test.go @@ -73,18 +73,18 @@ func TestClientStreamsFromFakeProvider(t *testing.T) { } events := drainEvents(t, stream) - var text string + var text strings.Builder var completed llm.Event for _, event := range events { switch event.Type { case llm.EventTextDelta: - text += event.Delta + text.WriteString(event.Delta) case llm.EventCompleted: completed = event } } - if text != "Echo: hello" { - t.Fatalf("streamed text = %q, want fake provider echo", text) + if text.String() != "Echo: hello" { + t.Fatalf("streamed text = %q, want fake provider echo", text.String()) } if completed.ResponseID == "" { t.Fatalf("completed response id is empty") @@ -498,14 +498,14 @@ func TestClientDoesNotDuplicateFinalTextAfterDeltas(t *testing.T) { } events := drainEvents(t, stream) - var text string + var text strings.Builder for _, event := range events { if event.Type == llm.EventTextDelta { - text += event.Delta + text.WriteString(event.Delta) } } - if text != "Hello" { - t.Fatalf("streamed text = %q, want exactly one final answer", text) + if text.String() != "Hello" { + t.Fatalf("streamed text = %q, want exactly one final answer", text.String()) } } @@ -527,14 +527,14 @@ func TestClientSkipsSeenAndInvalidMessageOutputItemContent(t *testing.T) { } events := drainEvents(t, stream) - var text string + var text strings.Builder for _, event := range events { if event.Type == llm.EventTextDelta { - text += event.Delta + text.WriteString(event.Delta) } } - if text != "Hello!" { - t.Fatalf("streamed text = %q, want deduplicated fallback text", text) + if text.String() != "Hello!" { + t.Fatalf("streamed text = %q, want deduplicated fallback text", text.String()) } } diff --git a/internal/llm/openresponses/fakeprovider/provider.go b/internal/llm/openresponses/fakeprovider/provider.go index 8c60ee3..3061236 100644 --- a/internal/llm/openresponses/fakeprovider/provider.go +++ b/internal/llm/openresponses/fakeprovider/provider.go @@ -473,10 +473,7 @@ func chunkText(text string) []string { } chunks := make([]string, 0, (len(runes)+chunkRunes-1)/chunkRunes) for start := 0; start < len(runes); start += chunkRunes { - end := start + chunkRunes - if end > len(runes) { - end = len(runes) - } + end := min(start+chunkRunes, len(runes)) chunks = append(chunks, string(runes[start:end])) } return chunks diff --git a/internal/llm/openresponses/fakeprovider/provider_test.go b/internal/llm/openresponses/fakeprovider/provider_test.go index c3c38c7..ceb898a 100644 --- a/internal/llm/openresponses/fakeprovider/provider_test.go +++ b/internal/llm/openresponses/fakeprovider/provider_test.go @@ -190,7 +190,7 @@ func TestStreamingEventsHaveMatchingTypesAndIncreasingSequenceNumbers(t *testing func TestStreamingTextDoneFieldsMatchCompletedOutputText(t *testing.T) { frames := parseSSE(t, streamResponse(t, `{"input":"hello 🌍","stream":true}`)) - var deltaText string + var deltaText strings.Builder var textDone string var partDoneText string var itemDoneText string @@ -199,7 +199,7 @@ func TestStreamingTextDoneFieldsMatchCompletedOutputText(t *testing.T) { for _, frame := range frames { switch frame.Event { case "response.output_text.delta": - deltaText += stringField(frame.Payload, "delta") + deltaText.WriteString(stringField(frame.Payload, "delta")) case "response.output_text.done": textDone = stringField(frame.Payload, "text") case "response.content_part.done": @@ -216,7 +216,7 @@ func TestStreamingTextDoneFieldsMatchCompletedOutputText(t *testing.T) { } for name, got := range map[string]string{ - "delta text": deltaText, + "delta text": deltaText.String(), "output_text.done": textDone, "content_part.done": partDoneText, "output_item.done": itemDoneText, @@ -560,7 +560,7 @@ func parseSSE(t *testing.T, body []byte) []sseFrame { continue } var frame sseFrame - for _, line := range strings.Split(raw, "\n") { + for line := range strings.SplitSeq(raw, "\n") { switch { case strings.HasPrefix(line, "event: "): frame.Event = strings.TrimPrefix(line, "event: ") @@ -590,14 +590,14 @@ func stringField(payload map[string]any, name string) string { func outputItemText(item map[string]any) string { content, _ := item["content"].([]any) - var text string + var text strings.Builder for _, value := range content { part, _ := value.(map[string]any) if part["type"] == "output_text" { - text += stringField(part, "text") + text.WriteString(stringField(part, "text")) } } - return text + return text.String() } func requireSlice(t *testing.T, value any, name string) []any { diff --git a/internal/llm/openresponses/fuzz_test.go b/internal/llm/openresponses/fuzz_test.go new file mode 100644 index 0000000..b9623fb --- /dev/null +++ b/internal/llm/openresponses/fuzz_test.go @@ -0,0 +1,50 @@ +package openresponses + +import ( + "bufio" + "bytes" + "context" + "io" + "testing" +) + +const maxFuzzStreamSize = 64 << 10 + +func FuzzOpenResponsesStream(f *testing.F) { + for _, seed := range [][]byte{ + []byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n"), + []byte("event: response.completed\r\ndata: {\"type\":\"response.completed\",\"response\":{}}\r\n\r\n"), + []byte("data: {\"type\":\ndata: \"response.completed\"}\n\n"), + []byte("data: [DONE]\n\n"), + []byte("data: {\n\n"), + []byte(": comment\n\n"), + } { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, payload []byte) { + if len(payload) > maxFuzzStreamSize { + return + } + + body := io.NopCloser(bytes.NewReader(payload)) + stream := &stream{ + body: body, + reader: bufio.NewReader(body), + textSeen: map[string]bool{}, + ctx: context.Background(), + } + maxCalls := bytes.Count(payload, []byte("\n")) + 2 + for calls := 0; ; calls++ { + if calls > maxCalls { + t.Fatalf("Next() did not terminate after %d calls", calls) + } + if _, err := stream.Next(); err != nil { + break + } + } + if err := stream.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + }) +} diff --git a/internal/markdown/fuzz_test.go b/internal/markdown/fuzz_test.go new file mode 100644 index 0000000..bba022c --- /dev/null +++ b/internal/markdown/fuzz_test.go @@ -0,0 +1,90 @@ +package markdown + +import ( + "strings" + "testing" +) + +const maxFuzzInputSize = 64 << 10 + +func FuzzRendererRender(f *testing.F) { + for _, seed := range []string{ + "hello **world**", + "", + ":::artifact title=\"Example\" type=\"text/plain\"\ncontent\n:::\n", + "```go\nfmt.Println(\"hello\")\n```\n", + "Citation [turn1search0] and math $x^2$.", + "\x00\xff\xfe", + } { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, input string) { + if len(input) > maxFuzzInputSize { + return + } + + renderer := NewRenderer() + first, err := renderer.Render(input) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + second, err := NewRenderer().Render(input) + if err != nil { + t.Fatalf("second Render() error = %v", err) + } + if first != second { + t.Fatalf("Render() is nondeterministic: first = %q, second = %q", first, second) + } + + sanitized := renderer.sanitizer.SanitizeBytes([]byte(first)) + if string(first) != string(sanitized) { + t.Fatalf("Render() output is not sanitizer-idempotent: output = %q, sanitized = %q", first, sanitized) + } + }) +} + +func FuzzBlockStreamerChunking(f *testing.F) { + f.Add("first\n\nsecond\n", []byte{1, 2, 3}) + f.Add("```go\nfmt.Println(1)\n```\n\nnext", []byte{4, 1, 8}) + f.Add("alpha\r\n\r\nbeta", []byte{2, 5}) + f.Add("", []byte{}) + + f.Fuzz(func(t *testing.T, input string, chunkSizes []byte) { + if len(input) > maxFuzzInputSize || len(chunkSizes) > maxFuzzInputSize { + return + } + + var streamer BlockStreamer + var emitted strings.Builder + offset := 0 + for _, rawSize := range chunkSizes { + if offset == len(input) { + break + } + size := 1 + int(rawSize)%(len(input)-offset) + for _, block := range streamer.Add(input[offset : offset+size]) { + emitted.WriteString(block) + } + offset += size + } + if offset < len(input) { + for _, block := range streamer.Add(input[offset:]) { + emitted.WriteString(block) + } + } + for _, block := range streamer.Flush() { + emitted.WriteString(block) + } + + if got := emitted.String(); got != input { + t.Fatalf("emitted content = %q, want %q", got, input) + } + if got := streamer.FullMarkdown(); got != input { + t.Fatalf("FullMarkdown() = %q, want %q", got, input) + } + if blocks := streamer.Flush(); len(blocks) != 0 { + t.Fatalf("second Flush() emitted %q, want no blocks", blocks) + } + }) +} diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 2e10ff0..f3d780b 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -16,7 +16,7 @@ import ( func TestSQLiteMigrateIsIdempotent(t *testing.T) { store := newTestSQLite(t) - for i := 0; i < 2; i++ { + for i := range 2 { if err := store.Migrate(context.Background()); err != nil { t.Fatalf("Migrate #%d error = %v, want nil", i+1, err) } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index c5683df..1ed9f6a 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -1467,7 +1467,7 @@ func TestTurnJobDisconnectsSlowSubscriberWithoutLosingReplay(t *testing.T) { t.Fatalf("new turn is terminal") } - for i := 0; i < 130; i++ { + for i := range 130 { turn.emit("preview", htmlEvent{ TurnID: turn.id, AssistantMessageID: turn.assistantMessageID, @@ -3030,7 +3030,7 @@ func parseSSE(t *testing.T, body string) []sseFrame { continue } var frame sseFrame - for _, line := range strings.Split(raw, "\n") { + for line := range strings.SplitSeq(raw, "\n") { switch { case strings.HasPrefix(line, "id: "): frame.ID = strings.TrimPrefix(line, "id: ")