From f771fcb0987adb3455a11b59e67afb4d620acfb5 Mon Sep 17 00:00:00 2001 From: jiangge Date: Mon, 27 Jul 2026 00:17:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(hooks):=20=E9=99=90=E5=88=B6=20Pi=20?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E6=B3=A8=E5=85=A5=E4=BD=93=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/hooks/agents/pi/pi_test.go | 5 + .../agents/pi/templates/mainline.ts.tmpl | 56 +++- internal/hooks/dispatcher.go | 242 +++++++++++++++--- internal/hooks/dispatcher_context_test.go | 97 +++++++ 4 files changed, 356 insertions(+), 44 deletions(-) create mode 100644 internal/hooks/dispatcher_context_test.go diff --git a/internal/hooks/agents/pi/pi_test.go b/internal/hooks/agents/pi/pi_test.go index 96d10607..79048916 100644 --- a/internal/hooks/agents/pi/pi_test.go +++ b/internal/hooks/agents/pi/pi_test.go @@ -42,6 +42,11 @@ func TestInstallWritesManagedExtensionAndIsIdempotent(t *testing.T) { `debug("running " + hookName + " in " + commandCwd);`, `debug("exited " + hookName + " with code " + code`, `systemPromptAppend`, + `const HOOK_STDOUT_MAX_CHARS = 256 * 1024;`, + `const MAINLINE_CONTEXT_MAX_CHARS = 48 * 1024;`, + `hook context exceeded Pi safety budget`, + `combined hook context exceeded Pi safety budget`, + `stdout from " + hookName + " exceeded safety budget`, } { if !strings.Contains(text, want) { t.Fatalf("extension missing %q:\n%s", want, text) diff --git a/internal/hooks/agents/pi/templates/mainline.ts.tmpl b/internal/hooks/agents/pi/templates/mainline.ts.tmpl index c7455ae7..20e6dde9 100644 --- a/internal/hooks/agents/pi/templates/mainline.ts.tmpl +++ b/internal/hooks/agents/pi/templates/mainline.ts.tmpl @@ -9,6 +9,10 @@ const MAINLINE_COMMAND = {{ .Command }}; const MAINLINE_ARGS_PREFIX = {{ .ArgsPrefix }}; const MAINLINE_COMMAND_CWD: string | undefined = {{ .CommandCwd }}; const HOOK_TIMEOUT_MS = 30000; +const HOOK_STDOUT_MAX_CHARS = 256 * 1024; +const MAINLINE_CONTEXT_MAX_CHARS = 48 * 1024; +const CONTEXT_LIMIT_WARNING = + "# Mainline hook context omitted\n\nThe hook returned more context than Pi's safety budget permits. Run `mainline preflight --json`, `mainline status --json`, and task-specific `mainline context` / `mainline show` commands to load details on demand."; type HookOutput = { systemPromptAppend?: string; @@ -29,7 +33,21 @@ function sessionId(ctx: { sessionManager?: { getSessionFile?: () => string | und } function extractContext(out: HookOutput | undefined): string | undefined { - return out?.systemPromptAppend ?? out?.additionalContext ?? out?.context; + const context = out?.systemPromptAppend ?? out?.additionalContext ?? out?.context; + if (!context) return undefined; + if (context.length <= MAINLINE_CONTEXT_MAX_CHARS) return context; + debug("hook context exceeded Pi safety budget; injecting compact warning"); + return CONTEXT_LIMIT_WARNING; +} + +function combineContexts(parts: Array): string | undefined { + const combined = parts.filter( + (part): part is string => typeof part === "string" && part.length > 0, + ).join("\n\n"); + if (!combined) return undefined; + if (combined.length <= MAINLINE_CONTEXT_MAX_CHARS) return combined; + debug("combined hook context exceeded Pi safety budget; injecting compact warning"); + return CONTEXT_LIMIT_WARNING; } async function runMainlineHook( @@ -47,25 +65,37 @@ async function runMainlineHook( let stdout = ""; let stderr = ""; + let settled = false; + const finish = (value: HookOutput | undefined): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; const timer = setTimeout(() => { debug("timed out running " + hookName); child.kill("SIGTERM"); - resolve(undefined); + finish(undefined); }, HOOK_TIMEOUT_MS); child.stdout.on("data", (chunk) => { + if (settled) return; stdout += String(chunk); + if (stdout.length > HOOK_STDOUT_MAX_CHARS) { + debug("stdout from " + hookName + " exceeded safety budget"); + child.kill("SIGTERM"); + finish({ systemPromptAppend: CONTEXT_LIMIT_WARNING }); + } }); child.stderr.on("data", (chunk) => { stderr += String(chunk); }); child.on("error", (err) => { - clearTimeout(timer); debug("failed to run " + hookName + ": " + err.message); - resolve(undefined); + finish(undefined); }); child.on("close", (code, signal) => { - clearTimeout(timer); + if (settled) return; if (signal) { debug("exited " + hookName + " with signal " + signal); } else if (code !== 0) { @@ -74,14 +104,14 @@ async function runMainlineHook( if (stderr.trim()) debug("stderr from " + hookName + ": " + stderr.trim()); const text = stdout.trim(); if (!text) { - resolve(undefined); + finish(undefined); return; } try { - resolve(JSON.parse(text) as HookOutput); + finish(JSON.parse(text) as HookOutput); } catch { debug("non-JSON hook output from " + hookName); - resolve(undefined); + finish(undefined); } }); child.stdin.on("error", (err) => { @@ -92,7 +122,7 @@ async function runMainlineHook( child.stdin.end(JSON.stringify(payload)); } catch (err) { debug("failed to write payload for " + hookName + ": " + String(err)); - resolve(undefined); + finish(undefined); } }); } @@ -126,12 +156,10 @@ export default function mainlinePiExtension(pi: ExtensionAPI) { }, ctx.cwd, ); - const parts = [pendingSessionContext, extractContext(out)].filter( - (part): part is string => typeof part === "string" && part.length > 0, - ); + const context = combineContexts([pendingSessionContext, extractContext(out)]); pendingSessionContext = undefined; - if (parts.length === 0) return undefined; - return { systemPrompt: event.systemPrompt + "\n\n" + parts.join("\n\n") }; + if (!context) return undefined; + return { systemPrompt: event.systemPrompt + "\n\n" + context }; }); pi.on("agent_end", async (event, ctx) => { diff --git a/internal/hooks/dispatcher.go b/internal/hooks/dispatcher.go index 2aba487d..3a291ac2 100644 --- a/internal/hooks/dispatcher.go +++ b/internal/hooks/dispatcher.go @@ -83,6 +83,15 @@ type nopNotifier struct{} func (nopNotifier) Notify(string) {} +const ( + // Hook context is injected into an agent's system prompt, so it must stay + // comfortably below model context limits even when repository state is huge. + sessionStartContextMaxBytes = 32 * 1024 + turnStartContextMaxBytes = 16 * 1024 + contextListLimit = 5 + contextStringMaxBytes = 2 * 1024 +) + // Dispatcher is the central event-to-action router. One instance per // hook invocation; constructed by the cli, fed an Event by the // agent's ParseEvent, and produces engine state changes + emits @@ -329,14 +338,13 @@ func (d *Dispatcher) RenderSessionStartContext(syncResult any, status any) strin b.WriteString("## status snapshot\n\n") if status == nil { b.WriteString("_status unavailable_\n\n") - } else if raw, err := json.MarshalIndent(status, "", " "); err == nil { + } else if raw, ok := compactStatusJSON(status); ok { b.WriteString("```json\n") b.Write(raw) b.WriteString("\n```\n\n") + b.WriteString("_Large status collections are summarized; use `mainline status --json`, `mainline preflight --json`, or `mainline show --json` for details._\n\n") } else { - b.WriteString("_status unmarshallable: ") - b.WriteString(err.Error()) - b.WriteString("_\n\n") + b.WriteString("_status unavailable or unmarshallable_\n\n") } b.WriteString("## sync summary\n\n") @@ -346,14 +354,13 @@ func (d *Dispatcher) RenderSessionStartContext(syncResult any, status any) strin b.WriteString("`. Treat this snapshot as potentially stale and re-run `mainline sync` once your network is healthy.\n\n") } else if syncResult == nil { b.WriteString("_sync was disabled or skipped_\n\n") - } else if raw, err := json.MarshalIndent(syncResult, "", " "); err == nil { + } else if raw, ok := compactArbitraryJSON(syncResult); ok { b.WriteString("```json\n") b.Write(raw) b.WriteString("\n```\n\n") + b.WriteString("_Large sync collections are summarized; run `mainline sync --json` for the full result._\n\n") } else { - b.WriteString("_sync result unmarshallable: ") - b.WriteString(err.Error()) - b.WriteString("_\n\n") + b.WriteString("_sync result unavailable or unmarshallable_\n\n") } b.WriteString("## what to do next\n\n") @@ -365,7 +372,7 @@ func (d *Dispatcher) RenderSessionStartContext(syncResult any, status any) strin b.WriteString("- When the task is complete, commit code, then `mainline seal --prepare --json`, fill the SealResult (fingerprint generously), then `mainline seal --submit --json < seal.json`. If the response carries a `conflicts` array, treat it as phase-1 overlap warnings: inspect/classify first, escalate only when uncertain or likely semantic, and do not paste raw JSON by default.\n") b.WriteString("- Re-run `mainline status` whenever you are about to make an architectural decision; sessionStart context is a one-shot snapshot, not a live view.\n") b.WriteString("\n") - return b.String() + return boundHookContext(b.String(), sessionStartContextMaxBytes) } // RenderTurnStartContext composes the small per-prompt reminder used @@ -412,9 +419,9 @@ func (d *Dispatcher) RenderTurnStartContext(status any, proposals any, statusErr b.WriteString("- Read-only diagnosis, history lookup, or proposal-only prompts should not start an intent.\n") b.WriteString("- If there is no `active_intent`, start one only when the prompt authorizes non-trivial edits, commit/seal/handoff, or another durable engineering record.\n") b.WriteString("- If there is an `active_intent`, append only after a meaningful logical change; hooks still do not decide that for you.\n") - b.WriteString("- Before non-trivial changes, still run task-specific Mainline context commands when this snapshot is not enough.\n") + b.WriteString("- Before non-trivial changes, run task-specific commands such as `mainline context --files ... --json`, `mainline context --query \"\" --json`, or `mainline show --json` when this snapshot is not enough.\n") b.WriteString("\n") - return b.String() + return boundHookContext(b.String(), turnStartContextMaxBytes) } func compactStatusJSON(status any) ([]byte, bool) { @@ -426,47 +433,185 @@ func compactStatusJSON(status any) ([]byte, bool) { return nil, false } var in struct { + Initialized bool `json:"initialized"` Branch string `json:"branch,omitempty"` ActorID string `json:"actor_id,omitempty"` + LocalHead string `json:"local_head,omitempty"` + MainHead string `json:"main_head,omitempty"` ActiveIntent *struct { IntentID string `json:"intent_id,omitempty"` Status string `json:"status,omitempty"` Thread string `json:"thread,omitempty"` Goal string `json:"goal,omitempty"` + Turns []any `json:"turns,omitempty"` } `json:"active_intent,omitempty"` TurnCount int `json:"turn_count"` ProposedCount int `json:"proposed_count"` SyncStale bool `json:"sync_stale"` Coverage *struct { - UncoveredCount int `json:"uncovered_count"` + WindowSize int `json:"window_size"` + CoveredCount int `json:"covered_count"` + SkippedCount int `json:"skipped_count"` + UncoveredCount int `json:"uncovered_count"` + Uncovered []any `json:"uncovered,omitempty"` } `json:"coverage,omitempty"` + AgentAuthority *struct { + Team struct { + Autonomy string `json:"autonomy,omitempty"` + MaxAutonomy string `json:"max_autonomy,omitempty"` + Source string `json:"source,omitempty"` + } `json:"team"` + Effective struct { + Autonomy string `json:"autonomy,omitempty"` + StopLine string `json:"stop_line,omitempty"` + } `json:"effective"` + Current struct { + AllowedBoundary string `json:"allowed_boundary,omitempty"` + BlockedByPreflight bool `json:"blocked_by_preflight"` + } `json:"current"` + } `json:"agent_authority,omitempty"` + Suggestions []string `json:"suggestions,omitempty"` + ActionableItems []struct { + Kind string `json:"kind,omitempty"` + Title string `json:"title,omitempty"` + RecommendedCommand string `json:"recommended_command,omitempty"` + } `json:"actionable_items,omitempty"` } if err := json.Unmarshal(raw, &in); err != nil { return nil, false } - out := struct { - Branch string `json:"branch,omitempty"` - ActorID string `json:"actor_id,omitempty"` - ActiveIntent any `json:"active_intent,omitempty"` - TurnCount int `json:"turn_count"` - ProposedCount int `json:"proposed_count"` - SyncStale bool `json:"sync_stale"` - UncoveredCount *int `json:"uncovered_count,omitempty"` - }{ - Branch: in.Branch, - ActorID: in.ActorID, - ActiveIntent: in.ActiveIntent, - TurnCount: in.TurnCount, - ProposedCount: in.ProposedCount, - SyncStale: in.SyncStale, + + type compactIntent struct { + IntentID string `json:"intent_id,omitempty"` + Status string `json:"status,omitempty"` + Thread string `json:"thread,omitempty"` + Goal string `json:"goal,omitempty"` + TurnsOmitted int `json:"turns_omitted,omitempty"` + } + type compactCoverage struct { + WindowSize int `json:"window_size"` + CoveredCount int `json:"covered_count"` + SkippedCount int `json:"skipped_count"` + UncoveredCount int `json:"uncovered_count"` + UncoveredOmitted int `json:"uncovered_details_omitted,omitempty"` + } + var active any + if in.ActiveIntent != nil { + active = compactIntent{ + IntentID: in.ActiveIntent.IntentID, + Status: in.ActiveIntent.Status, + Thread: in.ActiveIntent.Thread, + Goal: truncateContextString(in.ActiveIntent.Goal), + TurnsOmitted: len(in.ActiveIntent.Turns), + } } + var coverage *compactCoverage if in.Coverage != nil { - out.UncoveredCount = &in.Coverage.UncoveredCount + coverage = &compactCoverage{ + WindowSize: in.Coverage.WindowSize, + CoveredCount: in.Coverage.CoveredCount, + SkippedCount: in.Coverage.SkippedCount, + UncoveredCount: in.Coverage.UncoveredCount, + UncoveredOmitted: len(in.Coverage.Uncovered), + } + } + suggestionLimit := len(in.Suggestions) + if suggestionLimit > contextListLimit { + suggestionLimit = contextListLimit + } + for i := 0; i < suggestionLimit; i++ { + in.Suggestions[i] = truncateContextString(in.Suggestions[i]) + } + actionableLimit := len(in.ActionableItems) + if actionableLimit > contextListLimit { + actionableLimit = contextListLimit + } + for i := 0; i < actionableLimit; i++ { + in.ActionableItems[i].Title = truncateContextString(in.ActionableItems[i].Title) + in.ActionableItems[i].RecommendedCommand = truncateContextString(in.ActionableItems[i].RecommendedCommand) + } + out := struct { + Initialized bool `json:"initialized"` + Branch string `json:"branch,omitempty"` + ActorID string `json:"actor_id,omitempty"` + LocalHead string `json:"local_head,omitempty"` + MainHead string `json:"main_head,omitempty"` + ActiveIntent any `json:"active_intent,omitempty"` + TurnCount int `json:"turn_count"` + ProposedCount int `json:"proposed_count"` + SyncStale bool `json:"sync_stale"` + Coverage *compactCoverage `json:"coverage,omitempty"` + AgentAuthority any `json:"agent_authority,omitempty"` + Suggestions []string `json:"suggestions,omitempty"` + SuggestionsOmitted int `json:"suggestions_omitted,omitempty"` + ActionableItems any `json:"actionable_items,omitempty"` + ActionableOmitted int `json:"actionable_items_omitted,omitempty"` + }{ + Initialized: in.Initialized, + Branch: truncateContextString(in.Branch), + ActorID: truncateContextString(in.ActorID), + LocalHead: truncateContextString(in.LocalHead), + MainHead: truncateContextString(in.MainHead), + ActiveIntent: active, + TurnCount: in.TurnCount, + ProposedCount: in.ProposedCount, + SyncStale: in.SyncStale, + Coverage: coverage, + AgentAuthority: in.AgentAuthority, + Suggestions: in.Suggestions[:suggestionLimit], + SuggestionsOmitted: len(in.Suggestions) - suggestionLimit, + ActionableItems: in.ActionableItems[:actionableLimit], + ActionableOmitted: len(in.ActionableItems) - actionableLimit, } raw, err = json.MarshalIndent(out, "", " ") return raw, err == nil } +func compactArbitraryJSON(value any) ([]byte, bool) { + raw, err := json.Marshal(value) + if err != nil { + return nil, false + } + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, false + } + compacted := compactJSONValue(decoded, 0) + raw, err = json.MarshalIndent(compacted, "", " ") + return raw, err == nil +} + +func compactJSONValue(value any, depth int) any { + if depth >= 6 { + return "[nested value omitted]" + } + switch value := value.(type) { + case string: + return truncateContextString(value) + case []any: + limit := len(value) + if limit > contextListLimit { + limit = contextListLimit + } + out := make([]any, 0, limit+1) + for _, item := range value[:limit] { + out = append(out, compactJSONValue(item, depth+1)) + } + if omitted := len(value) - limit; omitted > 0 { + out = append(out, map[string]any{"omitted": omitted}) + } + return out + case map[string]any: + out := make(map[string]any, len(value)) + for key, item := range value { + out[key] = compactJSONValue(item, depth+1) + } + return out + default: + return value + } +} + func compactProposalsJSON(proposals any) ([]byte, bool) { if proposals == nil { return nil, false @@ -487,22 +632,59 @@ func compactProposalsJSON(proposals any) ([]byte, bool) { return nil, false } limit := len(in.Proposals) - if limit > 5 { - limit = 5 + if limit > contextListLimit { + limit = contextListLimit + } + for i := 0; i < limit; i++ { + in.Proposals[i].Title = truncateContextString(in.Proposals[i].Title) } out := struct { Count int `json:"count"` Shown int `json:"shown"` + Omitted int `json:"omitted,omitempty"` Proposals any `json:"proposals"` }{ Count: len(in.Proposals), Shown: limit, + Omitted: len(in.Proposals) - limit, Proposals: in.Proposals[:limit], } raw, err = json.MarshalIndent(out, "", " ") return raw, err == nil } +func truncateContextString(value string) string { + if len(value) <= contextStringMaxBytes { + return value + } + return truncateUTF8(value, contextStringMaxBytes-len("… [truncated]")) + "… [truncated]" +} + +func boundHookContext(value string, maxBytes int) string { + if len(value) <= maxBytes { + return value + } + marker := "\n\n> **Mainline context truncated to its safety budget.** Run `mainline preflight --json`, `mainline status --json`, and task-specific `mainline context` / `mainline show` commands for omitted details.\n\n\n" + if len(marker) >= maxBytes { + return truncateUTF8(marker, maxBytes) + } + return truncateUTF8(value, maxBytes-len(marker)) + marker +} + +func truncateUTF8(value string, maxBytes int) string { + if maxBytes <= 0 { + return "" + } + if len(value) <= maxBytes { + return value + } + end := maxBytes + for end > 0 && value[end]&0xc0 == 0x80 { + end-- + } + return value[:end] +} + // ----------------------------------------------------------- // Helpers // ----------------------------------------------------------- diff --git a/internal/hooks/dispatcher_context_test.go b/internal/hooks/dispatcher_context_test.go new file mode 100644 index 00000000..a895686a --- /dev/null +++ b/internal/hooks/dispatcher_context_test.go @@ -0,0 +1,97 @@ +package hooks + +import ( + "strings" + "testing" +) + +func TestRenderSessionStartContextBoundsLargeState(t *testing.T) { + turns := make([]any, 500) + uncovered := make([]any, 500) + changes := make([]any, 500) + for i := range turns { + turns[i] = map[string]any{ + "description": strings.Repeat("historical-diff-secret ", 400), + "files_changed": []string{strings.Repeat("large/path/", 100)}, + } + uncovered[i] = map[string]any{"subject": strings.Repeat("uncovered-secret ", 400)} + changes[i] = map[string]any{"path": strings.Repeat("sync-secret/", 400)} + } + status := map[string]any{ + "initialized": true, + "branch": "feat/pi-hooks", + "active_intent": map[string]any{ + "intent_id": "int_large", + "status": "drafting", + "thread": "feat/pi-hooks", + "goal": "keep the Pi hook context bounded", + "turns": turns, + }, + "turn_count": len(turns), + "proposed_count": 7, + "coverage": map[string]any{ + "window_size": 500, + "covered_count": 0, + "skipped_count": 0, + "uncovered_count": len(uncovered), + "uncovered": uncovered, + }, + "agent_authority": map[string]any{ + "effective": map[string]any{"autonomy": "handoff", "stop_line": "proposed_intent"}, + "current": map[string]any{"allowed_boundary": "proposed_intent"}, + }, + } + syncResult := map[string]any{"changes": changes} + + d := NewDispatcher(nil, nil, DefaultDispatchSettings()) + got := d.RenderSessionStartContext(syncResult, status) + if len(got) > sessionStartContextMaxBytes { + t.Fatalf("session context = %d bytes, budget = %d", len(got), sessionStartContextMaxBytes) + } + for _, want := range []string{ + "int_large", + "keep the Pi hook context bounded", + "proposed_intent", + `"turns_omitted": 500`, + `"uncovered_details_omitted": 500`, + `"omitted": 495`, + "mainline status --json", + } { + if !strings.Contains(got, want) { + t.Fatalf("bounded context missing %q:\n%s", want, got) + } + } + for _, secret := range []string{"historical-diff-secret", "uncovered-secret"} { + if strings.Contains(got, secret) { + t.Fatalf("bounded context leaked omitted detail %q", secret) + } + } +} + +func TestRenderTurnStartContextBoundsLargeStrings(t *testing.T) { + proposals := make([]map[string]any, 30) + for i := range proposals { + proposals[i] = map[string]any{ + "intent_id": "int_other", + "title": strings.Repeat("proposal-title ", 1000), + } + } + status := map[string]any{ + "branch": "feat/pi-hooks", + "active_intent": map[string]any{ + "intent_id": "int_large", + "goal": strings.Repeat("goal ", 10000), + }, + } + + d := NewDispatcher(nil, nil, DefaultDispatchSettings()) + got := d.RenderTurnStartContext(status, map[string]any{"proposals": proposals}, nil, nil) + if len(got) > turnStartContextMaxBytes { + t.Fatalf("turn context = %d bytes, budget = %d", len(got), turnStartContextMaxBytes) + } + for _, want := range []string{"int_large", "[truncated]", `"omitted": 25`, "mainline context"} { + if !strings.Contains(got, want) { + t.Fatalf("bounded turn context missing %q:\n%s", want, got) + } + } +} From 88034700b83ca1407a23d5f6767db0c68181c58a Mon Sep 17 00:00:00 2001 From: cat Date: Mon, 27 Jul 2026 08:43:25 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(hooks):=20=E8=A6=86=E7=9B=96=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E5=AE=89=E5=85=A8=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/hooks/agents/pi/pi_test.go | 72 +++++++++++++++++++++++ internal/hooks/dispatcher_context_test.go | 15 ++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/internal/hooks/agents/pi/pi_test.go b/internal/hooks/agents/pi/pi_test.go index 79048916..fed2cffa 100644 --- a/internal/hooks/agents/pi/pi_test.go +++ b/internal/hooks/agents/pi/pi_test.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" + "runtime" "strconv" "strings" "testing" @@ -73,6 +75,76 @@ func TestInstallWritesManagedExtensionAndIsIdempotent(t *testing.T) { } } +func TestGeneratedExtensionEnforcesContextBudgets(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test uses an executable script as the fake mainline command") + } + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is not available") + } + if err := exec.Command(node, "--experimental-strip-types", "--input-type=module", "--eval", "").Run(); err != nil { + t.Skip("node does not support TypeScript type stripping") + } + + dir := t.TempDir() + fakeMainline := filepath.Join(dir, "fake-mainline") + fakeSource := `#!/usr/bin/env node +const hook = process.argv.at(-1); +const scenario = process.env.FAKE_MAINLINE_SCENARIO; +let size = 2; +if (scenario === "combined") size = hook === "session-start" ? 40000 : 20000; +if (scenario === "single") size = hook === "user-prompt-submit" ? 50000 : 2; +if (scenario === "stdout") size = hook === "user-prompt-submit" ? 300000 : 2; +process.stdout.write(JSON.stringify({systemPromptAppend: "X".repeat(size)})); +` + if err := os.WriteFile(fakeMainline, []byte(fakeSource), 0o755); err != nil { + t.Fatal(err) + } + if _, err := (Agent{}).Install(dir, hooks.InstallOptions{BinPath: fakeMainline}); err != nil { + t.Fatal(err) + } + + harnessPath := filepath.Join(dir, "probe.mjs") + harness := `import mainlinePiExtension from "./.pi/extensions/mainline.ts"; +const handlers = new Map(); +mainlinePiExtension({on: (name, handler) => handlers.set(name, handler)}); +const ctx = {cwd: process.cwd(), sessionManager: {getSessionFile: () => "session.jsonl"}}; +await handlers.get("session_start")({reason: "new"}, ctx); +const result = await handlers.get("before_agent_start")({prompt: "test", systemPrompt: "BASE"}, ctx); +const prompt = result?.systemPrompt ?? ""; +process.stdout.write(JSON.stringify({ + warning: prompt.includes("Mainline hook context omitted"), + leaked: prompt.includes("XXXXXXXXXX"), +})); +` + if err := os.WriteFile(harnessPath, []byte(harness), 0o644); err != nil { + t.Fatal(err) + } + + for _, scenario := range []string{"single", "combined", "stdout"} { + t.Run(scenario, func(t *testing.T) { + cmd := exec.Command(node, "--experimental-strip-types", harnessPath) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "FAKE_MAINLINE_SCENARIO="+scenario) + raw, err := cmd.Output() + if err != nil { + t.Fatalf("run generated extension: %v", err) + } + var got struct { + Warning bool `json:"warning"` + Leaked bool `json:"leaked"` + } + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("decode probe output: %v\n%s", err, raw) + } + if !got.Warning || got.Leaked { + t.Fatalf("unexpected bounded context result: %#v", got) + } + }) + } +} + func TestDefaultInstallUsesLocalDevInMainlineSourceRepo(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module github.com/mainline-org/mainline\n"), 0o644); err != nil { diff --git a/internal/hooks/dispatcher_context_test.go b/internal/hooks/dispatcher_context_test.go index a895686a..ebfc8023 100644 --- a/internal/hooks/dispatcher_context_test.go +++ b/internal/hooks/dispatcher_context_test.go @@ -1,8 +1,10 @@ package hooks import ( + "fmt" "strings" "testing" + "unicode/utf8" ) func TestRenderSessionStartContextBoundsLargeState(t *testing.T) { @@ -42,12 +44,18 @@ func TestRenderSessionStartContextBoundsLargeState(t *testing.T) { }, } syncResult := map[string]any{"changes": changes} + for i := 0; i < 20; i++ { + syncResult[fmt.Sprintf("padding_%02d", i)] = strings.Repeat("边界", 2000) + } d := NewDispatcher(nil, nil, DefaultDispatchSettings()) got := d.RenderSessionStartContext(syncResult, status) if len(got) > sessionStartContextMaxBytes { t.Fatalf("session context = %d bytes, budget = %d", len(got), sessionStartContextMaxBytes) } + if !utf8.ValidString(got) { + t.Fatal("bounded session context is not valid UTF-8") + } for _, want := range []string{ "int_large", "keep the Pi hook context bounded", @@ -55,6 +63,7 @@ func TestRenderSessionStartContextBoundsLargeState(t *testing.T) { `"turns_omitted": 500`, `"uncovered_details_omitted": 500`, `"omitted": 495`, + "Mainline context truncated to its safety budget", "mainline status --json", } { if !strings.Contains(got, want) { @@ -74,6 +83,7 @@ func TestRenderTurnStartContextBoundsLargeStrings(t *testing.T) { proposals[i] = map[string]any{ "intent_id": "int_other", "title": strings.Repeat("proposal-title ", 1000), + "thread": strings.Repeat("分支/", 6000), } } status := map[string]any{ @@ -89,7 +99,10 @@ func TestRenderTurnStartContextBoundsLargeStrings(t *testing.T) { if len(got) > turnStartContextMaxBytes { t.Fatalf("turn context = %d bytes, budget = %d", len(got), turnStartContextMaxBytes) } - for _, want := range []string{"int_large", "[truncated]", `"omitted": 25`, "mainline context"} { + if !utf8.ValidString(got) { + t.Fatal("bounded turn context is not valid UTF-8") + } + for _, want := range []string{"int_large", "[truncated]", `"omitted": 25`, "Mainline context truncated to its safety budget", "mainline context"} { if !strings.Contains(got, want) { t.Fatalf("bounded turn context missing %q:\n%s", want, got) }