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: ")