@pi-in-go/pigpen-jev 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CREDITS.md +22 -0
  2. package/LICENSE +22 -0
  3. package/README.md +237 -0
  4. package/extensions/jev/ask.go +166 -0
  5. package/extensions/jev/ask_test.go +218 -0
  6. package/extensions/jev/backend.go +128 -0
  7. package/extensions/jev/bench_test.go +64 -0
  8. package/extensions/jev/boundaries_test.go +159 -0
  9. package/extensions/jev/command.go +224 -0
  10. package/extensions/jev/commands_test.go +214 -0
  11. package/extensions/jev/config.go +450 -0
  12. package/extensions/jev/errors_test.go +191 -0
  13. package/extensions/jev/extension.go +391 -0
  14. package/extensions/jev/fakehost_test.go +548 -0
  15. package/extensions/jev/gate.go +125 -0
  16. package/extensions/jev/gate_test.go +610 -0
  17. package/extensions/jev/gatekey_test.go +24 -0
  18. package/extensions/jev/go.mod +9 -0
  19. package/extensions/jev/go.sum +2 -0
  20. package/extensions/jev/go.work +10 -0
  21. package/extensions/jev/helpers_test.go +404 -0
  22. package/extensions/jev/memo.go +88 -0
  23. package/extensions/jev/output.go +89 -0
  24. package/extensions/jev/output_test.go +187 -0
  25. package/extensions/jev/ownmodel_test.go +118 -0
  26. package/extensions/jev/render.go +136 -0
  27. package/extensions/jev/review_test.go +310 -0
  28. package/extensions/jev/source_test.go +57 -0
  29. package/extensions/jev/text.go +174 -0
  30. package/extensions/jev/trust_test.go +335 -0
  31. package/extensions/jev/types.go +227 -0
  32. package/libs/typesafe/CONTRACT.md +125 -0
  33. package/libs/typesafe/CREDITS.md +37 -0
  34. package/libs/typesafe/LICENSE +23 -0
  35. package/libs/typesafe/README.md +19 -0
  36. package/libs/typesafe/go.mod +3 -0
  37. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  38. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  39. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  40. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  41. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  42. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  43. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  44. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  45. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  46. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  47. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  48. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  49. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  50. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  51. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  52. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  53. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  54. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  55. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  56. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  57. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  58. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  59. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  60. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  61. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  62. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  63. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  64. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  65. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  66. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  67. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  68. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  69. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  70. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  71. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  72. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  73. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  74. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  75. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  76. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  77. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  78. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  79. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  80. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  81. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  82. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  83. package/libs/typesafe/package.json +37 -0
  84. package/libs/typesafe/provenance.json +49 -0
  85. package/package.json +42 -0
  86. package/port/PORT.md +107 -0
  87. package/port/e2e/gate-and-output.py +35 -0
  88. package/port/e2e/jev-ask.py +36 -0
  89. package/port/e2e/model-switch.py +44 -0
  90. package/port/e2e/off-by-default.py +34 -0
  91. package/port/gen-scenarios.py +103 -0
  92. package/port/golden/cache-identical-calls.jsonl +30 -0
  93. package/port/golden/clear.jsonl +22 -0
  94. package/port/golden/commands.jsonl +43 -0
  95. package/port/golden/enforce-accept.jsonl +23 -0
  96. package/port/golden/enforce-decline.jsonl +22 -0
  97. package/port/golden/jev-ask.jsonl +20 -0
  98. package/port/golden/output-advice.jsonl +23 -0
  99. package/port/golden/output-leak.jsonl +24 -0
  100. package/port/golden/output-low-confidence.jsonl +22 -0
  101. package/port/golden/shadow-flagged.jsonl +23 -0
  102. package/port/golden/unjudged-tools.jsonl +19 -0
  103. package/port/golden/write-elision.jsonl +21 -0
  104. package/port/mutate-unit.py +63 -0
  105. package/port/mutations.json +578 -0
  106. package/port/oracle/LICENSE +21 -0
  107. package/port/oracle/README.md +181 -0
  108. package/port/oracle/SHA256SUMS +8 -0
  109. package/port/oracle/package.json +43 -0
  110. package/port/oracle/src/client.ts +409 -0
  111. package/port/oracle/src/config.ts +363 -0
  112. package/port/oracle/src/gate.ts +229 -0
  113. package/port/oracle/src/index.ts +649 -0
  114. package/port/oracle/src/output.ts +163 -0
  115. package/port/red-run.log +309 -0
  116. package/port/scenarios/cache-identical-calls.json +71 -0
  117. package/port/scenarios/clear.json +61 -0
  118. package/port/scenarios/commands.json +119 -0
  119. package/port/scenarios/enforce-accept.json +66 -0
  120. package/port/scenarios/enforce-decline.json +57 -0
  121. package/port/scenarios/jev-ask.json +83 -0
  122. package/port/scenarios/output-advice.json +61 -0
  123. package/port/scenarios/output-leak.json +61 -0
  124. package/port/scenarios/output-low-confidence.json +61 -0
  125. package/port/scenarios/shadow-flagged.json +61 -0
  126. package/port/scenarios/unjudged-tools.json +55 -0
  127. package/port/scenarios/write-elision.json +53 -0
  128. package/provenance.json +18 -0
@@ -0,0 +1,310 @@
1
+ package jev_test
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strconv"
7
+ "strings"
8
+ "sync"
9
+ "testing"
10
+ )
11
+
12
+ // Cases added by the adversarial review (rev-pigpen-jev), findings R1 to R4 in
13
+ // port/PORT.md. Each was red before its fix.
14
+
15
+ // R1. In enforce mode a flagged call runs only when the user allows it. When the
16
+ // confirmation cannot be shown (the host UI call fails), the original's handler
17
+ // throws and Pi blocks the call (agent-session.ts _installAgentToolHooks: "Extension
18
+ // failed, blocking execution"); the port failed open and ran the flagged call.
19
+ func TestReview_EnforceConfirmFailureBlocks(t *testing.T) {
20
+ e := newEnv(t)
21
+ t.Setenv("TYPESAFE_API_KEY", testKey)
22
+ srv := newFakeJev(t, always(gateBody(0.99, 0.04, 0.4, 0.5, 0.9)))
23
+ e.writeGlobal(t, jevConfig(srv, map[string]any{"gate": map[string]any{"mode": "enforce"}}))
24
+ hs := newHostState()
25
+ h := start(t, e, hs, HostOptions{OnCall: func(method string, args map[string]any) (map[string]any, string) {
26
+ if method == "ui.confirm" {
27
+ return nil, "ui_error: the terminal is gone"
28
+ }
29
+ return hs.onCall(method, args)
30
+ }})
31
+ block, reason := h.toolCall("bash", bash("rm -rf src"))
32
+ if !block {
33
+ t.Fatal("enforce mode ran a flagged call the user never approved")
34
+ }
35
+ mustContain(t, "reason", reason, "pi-jev: destructive 0.99", "not confirmed", "the terminal is gone")
36
+ }
37
+
38
+ // R2. The default judge is "the model PiG is configured with", named in the
39
+ // disclosure as the provider that already receives the conversation. After the
40
+ // user switches models, judging must follow: staying on the old provider keeps
41
+ // sending content to a provider that no longer receives the conversation.
42
+ func TestReview_DefaultJudgeFollowsTheSelectedModel(t *testing.T) {
43
+ e := newEnv(t)
44
+ e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "rich"})
45
+ hs := newHostState()
46
+ var mu sync.Mutex
47
+ var models []map[string]any
48
+ h := start(t, e, hs, HostOptions{ModelStream: func(model, _ map[string]any) []map[string]any {
49
+ mu.Lock()
50
+ models = append(models, model)
51
+ mu.Unlock()
52
+ return ModelText(gateAnswers(0, 0, 0, 0))
53
+ }})
54
+ next := map[string]any{"id": "private-1", "name": "private-1", "provider": "local"}
55
+ hs.mu.Lock()
56
+ hs.model = next
57
+ hs.mu.Unlock()
58
+ h.Fire("model_select", map[string]any{"model": next, "previousModel": map[string]any{"id": "judge-1", "provider": "acme"}, "source": "set"})
59
+ h.toolCall("bash", bash("ls"))
60
+ mu.Lock()
61
+ defer mu.Unlock()
62
+ if len(models) != 1 || models[0]["provider"] != "local" {
63
+ t.Fatalf("judge after switching to local/private-1 = %v", models)
64
+ }
65
+ h.Command("jev", "")
66
+ mustContain(t, "status", lastNote(h), "local/private-1")
67
+ if strings.Contains(lastNote(h), "acme/judge-1") {
68
+ t.Errorf("status still names the old model: %q", lastNote(h))
69
+ }
70
+ }
71
+
72
+ // R2b. A model named in the user's own config is not replaced by a model switch.
73
+ func TestReview_ConfiguredJudgeIgnoresModelSwitch(t *testing.T) {
74
+ e := newEnv(t)
75
+ e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "plain", "model": "other/cheap-1"})
76
+ hs := newHostState()
77
+ var models []map[string]any
78
+ var mu sync.Mutex
79
+ h := start(t, e, hs, HostOptions{ModelStream: func(model, _ map[string]any) []map[string]any {
80
+ mu.Lock()
81
+ models = append(models, model)
82
+ mu.Unlock()
83
+ return ModelText(gateAnswers(0, 0, 0, 0))
84
+ }})
85
+ h.Fire("model_select", map[string]any{"model": map[string]any{"id": "private-1", "provider": "local"}, "source": "set"})
86
+ h.toolCall("bash", bash("ls"))
87
+ mu.Lock()
88
+ defer mu.Unlock()
89
+ if len(models) != 1 || models[0]["provider"] != "other" {
90
+ t.Fatalf("models = %v", models)
91
+ }
92
+ }
93
+
94
+ // R2c. With no model at start, the judge becomes available once one is selected.
95
+ func TestReview_ModelSelectedLaterEnablesTheDefaultJudge(t *testing.T) {
96
+ e := newEnv(t)
97
+ e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "plain"})
98
+ hs := newHostState()
99
+ hs.model = map[string]any{}
100
+ var n int
101
+ var mu sync.Mutex
102
+ h := start(t, e, hs, HostOptions{ModelStream: func(_, _ map[string]any) []map[string]any {
103
+ mu.Lock()
104
+ n++
105
+ mu.Unlock()
106
+ return ModelText(gateAnswers(0, 0, 0, 0))
107
+ }})
108
+ next := map[string]any{"id": "judge-2", "name": "judge-2", "provider": "acme"}
109
+ hs.mu.Lock()
110
+ hs.model = next
111
+ hs.mu.Unlock()
112
+ h.Fire("model_select", map[string]any{"model": next, "source": "set"})
113
+ h.toolCall("bash", bash("ls"))
114
+ mu.Lock()
115
+ defer mu.Unlock()
116
+ if n != 1 {
117
+ t.Fatalf("judge requests after a model was selected = %d", n)
118
+ }
119
+ registered := false
120
+ for _, c := range h.CallsTo("registerTool") {
121
+ registered = registered || c.Args["name"] == "jev_ask"
122
+ }
123
+ if !registered {
124
+ t.Error("jev_ask was not registered once a judge became available")
125
+ }
126
+ }
127
+
128
+ // R3. The disclosure lists what leaves the machine. jev_ask sends text the model
129
+ // chooses (up to maxStateChars) and /jev check sends the user's text; neither was
130
+ // listed.
131
+ func TestReview_DisclosureNamesJevAskAndCheck(t *testing.T) {
132
+ e := newEnv(t)
133
+ t.Setenv("TYPESAFE_API_KEY", testKey)
134
+ srv := newFakeJev(t, always(clearGate()))
135
+ cfg := jevConfig(srv, map[string]any{"display": "rich"})
136
+ delete(cfg, "enabled")
137
+ e.writeGlobal(t, cfg)
138
+ hs := newHostState()
139
+ h := start(t, e, hs, HostOptions{})
140
+ h.Command("jev", "on")
141
+ c := hs.confirmCalls()
142
+ if len(c) != 1 {
143
+ t.Fatalf("confirm calls = %d", len(c))
144
+ }
145
+ mustContain(t, "disclosure", c[0]["message"].(string), "jev_ask", "8000", "/jev check")
146
+ }
147
+
148
+ // R4. A repository's .pig/pi-jev.json may tune the gate, but relaxing the
149
+ // protection the user configured (turning the gate off, enforce to shadow, higher
150
+ // thresholds) must not be silent.
151
+ func TestReview_ProjectThatRelaxesTheGateIsReported(t *testing.T) {
152
+ e := newEnv(t)
153
+ t.Setenv("TYPESAFE_API_KEY", testKey)
154
+ srv := newFakeJev(t, always(clearGate()))
155
+ e.writeGlobal(t, jevConfig(srv, map[string]any{"gate": map[string]any{"mode": "enforce", "blockWithoutUI": true}}))
156
+ e.writeProject(t, map[string]any{
157
+ "gate": map[string]any{"enabled": false, "mode": "shadow", "blockWithoutUI": false, "blockOn": map[string]any{"destructive": 1, "impact": 3}},
158
+ "output": map[string]any{"enabled": false, "leakThreshold": 1},
159
+ })
160
+ h := start(t, e, newHostState(), HostOptions{})
161
+ var warn string
162
+ for _, n := range h.notifications() {
163
+ if strings.Contains(n, "relaxes") {
164
+ warn = n
165
+ }
166
+ }
167
+ if warn == "" {
168
+ t.Fatalf("no warning for a project that turned the gate off: %q", h.notifications())
169
+ }
170
+ mustContain(t, "warning", warn, "gate.enabled false", "gate.mode shadow", "gate.blockWithoutUI false",
171
+ "gate.blockOn.destructive 1", "gate.blockOn.impact 3", "output.enabled false", "output.leakThreshold 1")
172
+ }
173
+
174
+ func TestReview_StricterProjectIsNotReported(t *testing.T) {
175
+ e := newEnv(t)
176
+ t.Setenv("TYPESAFE_API_KEY", testKey)
177
+ srv := newFakeJev(t, always(clearGate()))
178
+ e.writeGlobal(t, jevConfig(srv, nil))
179
+ e.writeProject(t, map[string]any{"gate": map[string]any{"mode": "enforce", "blockOn": map[string]any{"destructive": 0.4}}})
180
+ h := start(t, e, newHostState(), HostOptions{})
181
+ if h.anyNotification("relaxes") {
182
+ t.Errorf("a stricter project was reported as relaxing: %q", h.notifications())
183
+ }
184
+ }
185
+
186
+ // R5. Guard tests for the trust boundary (PORT.md C2). The existing test sets every
187
+ // destination key in one project file against a user config that already names the
188
+ // backend, model and key, so a project-applied apiKeyFile, backend, model or
189
+ // acknowledged survived as mutants (x-project-*). Each key is tested alone here.
190
+
191
+ func TestReview_ProjectCannotChooseTheKeyFile(t *testing.T) {
192
+ e := newEnv(t)
193
+ srv := newFakeJev(t, always(clearGate()))
194
+ e.writeGlobal(t, jevConfig(srv, nil)) // no key of the user's own
195
+ secret := filepath.Join(e.cwd, "not-a-key.txt")
196
+ if err := os.WriteFile(secret, []byte("contents-of-a-private-file"), 0o600); err != nil {
197
+ t.Fatal(err)
198
+ }
199
+ e.writeProject(t, map[string]any{"apiKeyFile": secret})
200
+ h := start(t, e, newHostState(), HostOptions{})
201
+ h.toolCall("bash", bash("ls"))
202
+ for _, r := range srv.requests() {
203
+ if strings.Contains(r.Auth, "contents-of-a-private-file") {
204
+ t.Fatalf("a project file chose the file sent as the API key: %q", r.Auth)
205
+ }
206
+ }
207
+ if !h.anyNotification("ignored project setting apiKeyFile") {
208
+ t.Errorf("notifications = %q", h.notifications())
209
+ }
210
+ }
211
+
212
+ func TestReview_ProjectCannotSwitchTheBackend(t *testing.T) {
213
+ e := newEnv(t)
214
+ t.Setenv("TYPESAFE_API_KEY", testKey)
215
+ attacker := newFakeJev(t, always(clearGate()))
216
+ e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "plain", "endpoint": attacker.srv.URL, "model": "acme/judge-1"})
217
+ e.writeProject(t, map[string]any{"backend": "typesafe"})
218
+ var n int
219
+ var mu sync.Mutex
220
+ h := start(t, e, newHostState(), HostOptions{ModelStream: func(_, _ map[string]any) []map[string]any {
221
+ mu.Lock()
222
+ n++
223
+ mu.Unlock()
224
+ return ModelText(gateAnswers(0, 0, 0, 0))
225
+ }})
226
+ h.toolCall("bash", bash("ls"))
227
+ mu.Lock()
228
+ defer mu.Unlock()
229
+ if attacker.count() != 0 || n != 1 {
230
+ t.Fatalf("project backend applied: %d HTTP requests, %d model requests", attacker.count(), n)
231
+ }
232
+ }
233
+
234
+ func TestReview_ProjectCannotChooseTheModel(t *testing.T) {
235
+ e := newEnv(t)
236
+ e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "display": "plain"})
237
+ e.writeProject(t, map[string]any{"model": "elsewhere/judge"})
238
+ var models []map[string]any
239
+ var mu sync.Mutex
240
+ h := start(t, e, newHostState(), HostOptions{ModelStream: func(model, _ map[string]any) []map[string]any {
241
+ mu.Lock()
242
+ models = append(models, model)
243
+ mu.Unlock()
244
+ return ModelText(gateAnswers(0, 0, 0, 0))
245
+ }})
246
+ h.toolCall("bash", bash("ls"))
247
+ mu.Lock()
248
+ defer mu.Unlock()
249
+ if len(models) != 1 || models[0]["provider"] != "acme" {
250
+ t.Fatalf("a project file chose the judge model: %v", models)
251
+ }
252
+ }
253
+
254
+ func TestReview_ProjectCannotHideTheDisclosure(t *testing.T) {
255
+ e := newEnv(t)
256
+ e.writeGlobal(t, map[string]any{"enabled": true, "display": "rich"})
257
+ e.writeProject(t, map[string]any{"acknowledged": true})
258
+ h := start(t, e, newHostState(), HostOptions{})
259
+ if !h.anyNotification("What leaves this machine") {
260
+ t.Errorf("a project file acknowledged the disclosure for the user: %q", h.notifications())
261
+ }
262
+ }
263
+
264
+ // R5b. http is accepted only for this machine; an IP literal is not "localhost".
265
+ func TestReview_PlainHTTPToARemoteIPIsRefused(t *testing.T) {
266
+ e := newEnv(t)
267
+ t.Setenv("TYPESAFE_API_KEY", testKey)
268
+ e.writeGlobal(t, map[string]any{"enabled": true, "acknowledged": true, "backend": "typesafe", "display": "plain",
269
+ "endpoint": "http://192.0.2.10/v1/systemone", "model": "m"})
270
+ h := start(t, e, newHostState(), HostOptions{})
271
+ if !h.anyNotification("must use https") {
272
+ t.Errorf("http:// to a remote IP was accepted: %q", h.notifications())
273
+ }
274
+ }
275
+
276
+ // R5c. The original's output cache key is tool and output (output.ts outputKey).
277
+ func TestReview_OutputCacheIsPerTool(t *testing.T) {
278
+ e := newEnv(t)
279
+ t.Setenv("TYPESAFE_API_KEY", testKey)
280
+ srv := newFakeJev(t, always(outBody(0.01, "no_failure", 0.99)))
281
+ e.writeGlobal(t, jevConfig(srv, map[string]any{"output": map[string]any{"tools": []any{"bash", "read"}}}))
282
+ h := start(t, e, newHostState(), HostOptions{})
283
+ h.toolResult("bash", bash("cat x"), text("same text"), false)
284
+ h.toolResult("read", map[string]any{"path": "x"}, text("same text"), false)
285
+ if srv.count() != 2 {
286
+ t.Errorf("requests = %d, want one per tool", srv.count())
287
+ }
288
+ }
289
+
290
+ // R5d. PORT.md C3: when shortening is not enough, the arguments go before the
291
+ // user's request (which by then is shortened too).
292
+ func TestReview_StateCapDropsArgumentsBeforeTheRequest(t *testing.T) {
293
+ e := newEnv(t)
294
+ t.Setenv("TYPESAFE_API_KEY", testKey)
295
+ srv := newFakeJev(t, always(clearGate()))
296
+ e.writeGlobal(t, jevConfig(srv, map[string]any{"maxStateChars": 300}))
297
+ hs := newHostState()
298
+ hs.user = "tidy the build"
299
+ h := start(t, e, hs, HostOptions{})
300
+ in := map[string]any{}
301
+ for i := 0; i < 40; i++ {
302
+ in["k"+strconv.Itoa(i)] = i
303
+ }
304
+ h.toolCall("bash", in)
305
+ st := stateOf(t, srv.first(t))
306
+ user, _ := st["user_request"].(string)
307
+ if st["arguments"] != "…[elided to fit maxStateChars]" || !strings.HasPrefix(user, "tidy the") {
308
+ t.Errorf("state = %v", st)
309
+ }
310
+ }
@@ -0,0 +1,57 @@
1
+ package jev_test
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func goSources(t *testing.T) map[string]string {
11
+ t.Helper()
12
+ files, _ := filepath.Glob("*.go")
13
+ out := map[string]string{}
14
+ for _, f := range files {
15
+ if strings.HasSuffix(f, "_test.go") {
16
+ continue
17
+ }
18
+ b, err := os.ReadFile(f)
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ out[f] = string(b)
23
+ }
24
+ return out
25
+ }
26
+
27
+ // The task: "use the model from PiG configuration, no hardcoded provider".
28
+ func TestNoHardcodedProviderOrEndpoint(t *testing.T) {
29
+ for name, src := range goSources(t) {
30
+ for _, bad := range []string{"api.typesafe.ai", "jev-latest", "api.openai.com", "api.anthropic.com", "openrouter.ai"} {
31
+ if strings.Contains(src, bad) {
32
+ t.Errorf("%s hardcodes %q", name, bad)
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ // Public PiG Go SDK only: no PiG-internal imports, no Node.
39
+ func TestOnlyPublicSDKImports(t *testing.T) {
40
+ for name, src := range goSources(t) {
41
+ for _, line := range strings.Split(src, "\n") {
42
+ l := strings.TrimSpace(line)
43
+ if strings.HasPrefix(l, `"github.com/MichaelKinsy/PiG/`) && !strings.HasPrefix(l, `"github.com/MichaelKinsy/PiG/extensions/sdk`) {
44
+ t.Errorf("%s imports %s", name, l)
45
+ }
46
+ if strings.Contains(l, "os/exec") {
47
+ t.Errorf("%s starts a process (%s)", name, l)
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ // Named gaps (Skill step 3: a gap is a named skipped test, never silent).
54
+
55
+ func TestUserRequestJoinsTextBlocksWithNewline_GAP(t *testing.T) {
56
+ t.Skip("upstream joins a user message's text blocks with \\n (index.ts messageText); the Go SDK's BranchEntry flattens them with no separator; a prompt sent over RPC is one block, so no scenario differs")
57
+ }
@@ -0,0 +1,174 @@
1
+ package jev
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "math"
8
+ "math/big"
9
+ "runtime"
10
+ "sort"
11
+ "strings"
12
+ "unicode/utf8"
13
+ )
14
+
15
+ // The original measures strings in UTF-16 code units (JavaScript .length and
16
+ // .slice). Every limit here uses the same unit so a configured 400 keeps meaning
17
+ // what the README says.
18
+
19
+ func utf16Len(s string) int {
20
+ n := 0
21
+ for _, r := range s {
22
+ n++
23
+ if r >= 0x10000 {
24
+ n++
25
+ }
26
+ }
27
+ return n
28
+ }
29
+
30
+ // sliceUTF16 returns the first n UTF-16 units of s. JavaScript can cut a
31
+ // surrogate pair in two and send a lone surrogate; a Go string cannot hold one,
32
+ // so a cut through a pair yields U+FFFD in its place.
33
+ func sliceUTF16(s string, n int) string {
34
+ if n <= 0 {
35
+ return ""
36
+ }
37
+ units := 0
38
+ for i, r := range s {
39
+ w := 1
40
+ if r >= 0x10000 {
41
+ w = 2
42
+ }
43
+ if units+w > n {
44
+ if units < n { // half of a pair
45
+ return s[:i] + "\uFFFD"
46
+ }
47
+ return s[:i]
48
+ }
49
+ units += w
50
+ }
51
+ return s
52
+ }
53
+
54
+ // elide keeps the first max units and states how many were cut, as
55
+ // `${value.slice(0, max)}…[N chars elided]`.
56
+ func elide(s string, max int) string {
57
+ total := utf16Len(s)
58
+ if total <= max {
59
+ return s
60
+ }
61
+ return fmt.Sprintf("%s…[%d chars elided]", sliceUTF16(s, max), total-max)
62
+ }
63
+
64
+ // truncateText is the gate's user-request cut: `…[truncated]`.
65
+ func truncateText(s string, max int) string {
66
+ if utf16Len(s) <= max {
67
+ return s
68
+ }
69
+ return sliceUTF16(s, max) + "…[truncated]"
70
+ }
71
+
72
+ // toFixed2 is JavaScript's Number.prototype.toFixed(2): the exact decimal value
73
+ // of the double, a tie rounds up (0.125 gives 0.13, where Go's %.2f gives 0.12).
74
+ func toFixed2(x float64) string {
75
+ switch {
76
+ case math.IsNaN(x):
77
+ return "NaN"
78
+ case math.IsInf(x, 1):
79
+ return "Infinity"
80
+ case math.IsInf(x, -1):
81
+ return "-Infinity"
82
+ }
83
+ neg := x < 0
84
+ r := new(big.Rat).SetFloat64(math.Abs(x))
85
+ r.Mul(r, big.NewRat(100, 1))
86
+ r.Add(r, big.NewRat(1, 2))
87
+ n := new(big.Int).Div(r.Num(), r.Denom())
88
+ digits := n.String()
89
+ for len(digits) < 3 {
90
+ digits = "0" + digits
91
+ }
92
+ out := digits[:len(digits)-2] + "." + digits[len(digits)-2:]
93
+ if neg {
94
+ out = "-" + out
95
+ }
96
+ return out
97
+ }
98
+
99
+ // jsonString encodes s without HTML escaping.
100
+ func jsonString(s string) []byte {
101
+ var buf bytes.Buffer
102
+ enc := json.NewEncoder(&buf)
103
+ enc.SetEscapeHTML(false)
104
+ _ = enc.Encode(s)
105
+ return bytes.TrimRight(buf.Bytes(), "\n")
106
+ }
107
+
108
+ func jsonValue(v any) []byte {
109
+ var buf bytes.Buffer
110
+ enc := json.NewEncoder(&buf)
111
+ enc.SetEscapeHTML(false)
112
+ if err := enc.Encode(v); err != nil {
113
+ return []byte("null")
114
+ }
115
+ return bytes.TrimRight(buf.Bytes(), "\n")
116
+ }
117
+
118
+ const maxNesting = 64
119
+
120
+ // summarize copies a decoded JSON value, eliding every string longer than max
121
+ // (at any depth: the original stopped at depth 4 and let deeper strings out
122
+ // whole, see PORT.md C7). Keys are written sorted; the SDK hands events over as
123
+ // Go maps, so the wire order of the original's argument keys is not available.
124
+ func summarize(v any, max, depth int) any {
125
+ if depth > maxNesting {
126
+ return "…[nested value elided]"
127
+ }
128
+ switch t := v.(type) {
129
+ case string:
130
+ return elide(t, max)
131
+ case []any:
132
+ out := make([]any, len(t))
133
+ for i, item := range t {
134
+ out[i] = summarize(item, max, depth+1)
135
+ }
136
+ return out
137
+ case map[string]any:
138
+ out := make(map[string]any, len(t))
139
+ for k, item := range t {
140
+ out[k] = summarize(item, max, depth+1)
141
+ }
142
+ return out
143
+ }
144
+ return v
145
+ }
146
+
147
+ // platform is process.platform as Node spells it.
148
+ func platform() string {
149
+ if runtime.GOOS == "windows" {
150
+ return "win32"
151
+ }
152
+ return runtime.GOOS
153
+ }
154
+
155
+ func sortedKeys[V any](m map[string]V) []string {
156
+ keys := make([]string, 0, len(m))
157
+ for k := range m {
158
+ keys = append(keys, k)
159
+ }
160
+ sort.Strings(keys)
161
+ return keys
162
+ }
163
+
164
+ // collapse folds whitespace runs and trims, as the original's error text does.
165
+ func collapse(s string) string {
166
+ return strings.Join(strings.Fields(s), " ")
167
+ }
168
+
169
+ func cut(s string, max int) string {
170
+ if utf8.RuneCountInString(s) <= max {
171
+ return s
172
+ }
173
+ return string([]rune(s)[:max]) + "…"
174
+ }