@pi-in-go/pigpen-pi-typesafe 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 (189) hide show
  1. package/CREDITS.md +14 -0
  2. package/LICENSE +22 -0
  3. package/README.md +45 -0
  4. package/extensions/pi-typesafe/branches_test.go +185 -0
  5. package/extensions/pi-typesafe/command.go +319 -0
  6. package/extensions/pi-typesafe/export_test.go +9 -0
  7. package/extensions/pi-typesafe/extension.go +188 -0
  8. package/extensions/pi-typesafe/extension_test.go +321 -0
  9. package/extensions/pi-typesafe/fakehost_test.go +548 -0
  10. package/extensions/pi-typesafe/format.go +191 -0
  11. package/extensions/pi-typesafe/format_test.go +75 -0
  12. package/extensions/pi-typesafe/go.mod +10 -0
  13. package/extensions/pi-typesafe/go.sum +2 -0
  14. package/extensions/pi-typesafe/go.work +11 -0
  15. package/extensions/pi-typesafe/harness_test.go +200 -0
  16. package/extensions/pi-typesafe/ownmodel_test.go +100 -0
  17. package/extensions/pi-typesafe/review_test.go +134 -0
  18. package/extensions/pi-typesafe/tool.go +193 -0
  19. package/extensions/pi-typesafe/twin_test.go +28 -0
  20. package/libs/pi-typesafe-api/CREDITS.md +14 -0
  21. package/libs/pi-typesafe-api/LICENSE +22 -0
  22. package/libs/pi-typesafe-api/README.md +30 -0
  23. package/libs/pi-typesafe-api/ask.go +62 -0
  24. package/libs/pi-typesafe-api/ask_test.go +76 -0
  25. package/libs/pi-typesafe-api/auth.go +249 -0
  26. package/libs/pi-typesafe-api/auth_test.go +131 -0
  27. package/libs/pi-typesafe-api/backends.go +336 -0
  28. package/libs/pi-typesafe-api/backends_test.go +404 -0
  29. package/libs/pi-typesafe-api/batch.go +202 -0
  30. package/libs/pi-typesafe-api/batch_test.go +202 -0
  31. package/libs/pi-typesafe-api/battery_test.go +41 -0
  32. package/libs/pi-typesafe-api/calibrate.go +354 -0
  33. package/libs/pi-typesafe-api/calibrate_test.go +186 -0
  34. package/libs/pi-typesafe-api/client.go +615 -0
  35. package/libs/pi-typesafe-api/client_test.go +490 -0
  36. package/libs/pi-typesafe-api/credentials.go +252 -0
  37. package/libs/pi-typesafe-api/credentials_test.go +216 -0
  38. package/libs/pi-typesafe-api/doc.go +14 -0
  39. package/libs/pi-typesafe-api/errors.go +143 -0
  40. package/libs/pi-typesafe-api/evaluation.go +86 -0
  41. package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
  42. package/libs/pi-typesafe-api/gaps_test.go +77 -0
  43. package/libs/pi-typesafe-api/go.mod +9 -0
  44. package/libs/pi-typesafe-api/go.sum +2 -0
  45. package/libs/pi-typesafe-api/helpers_test.go +169 -0
  46. package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
  47. package/libs/pi-typesafe-api/json.go +299 -0
  48. package/libs/pi-typesafe-api/json_test.go +92 -0
  49. package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
  50. package/libs/pi-typesafe-api/package.json +40 -0
  51. package/libs/pi-typesafe-api/provenance.json +18 -0
  52. package/libs/pi-typesafe-api/review_test.go +23 -0
  53. package/libs/pi-typesafe-api/schema.go +473 -0
  54. package/libs/pi-typesafe-api/schema_test.go +262 -0
  55. package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
  56. package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
  57. package/libs/pi-typesafe-api/twin_test.go +28 -0
  58. package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
  59. package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
  60. package/libs/pi-typesafe-api/ui/login.go +106 -0
  61. package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
  62. package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
  63. package/libs/pi-typesafe-api/usage.go +366 -0
  64. package/libs/pi-typesafe-api/usage_test.go +139 -0
  65. package/libs/typesafe/CONTRACT.md +125 -0
  66. package/libs/typesafe/CREDITS.md +37 -0
  67. package/libs/typesafe/LICENSE +23 -0
  68. package/libs/typesafe/README.md +19 -0
  69. package/libs/typesafe/go.mod +3 -0
  70. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  71. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  72. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  73. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  74. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  75. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  76. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  77. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  78. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  79. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  80. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  81. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  82. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  83. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  84. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  85. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  86. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  87. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  88. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  89. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  90. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  91. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  92. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  93. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  94. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  95. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  96. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  97. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  98. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  99. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  100. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  101. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  102. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  103. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  104. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  105. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  106. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  107. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  108. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  109. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  110. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  111. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  112. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  113. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  114. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  115. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  116. package/libs/typesafe/package.json +37 -0
  117. package/libs/typesafe/provenance.json +49 -0
  118. package/package.json +42 -0
  119. package/port/PORT.md +98 -0
  120. package/port/accepted-gaps.json +3 -0
  121. package/port/golden/enable-confirm.jsonl +11 -0
  122. package/port/golden/enable-decline.jsonl +20 -0
  123. package/port/golden/enable-missing-key.jsonl +4 -0
  124. package/port/golden/login-shadow.jsonl +4 -0
  125. package/port/golden/logout-env-key.jsonl +6 -0
  126. package/port/golden/playground-cancel.jsonl +4 -0
  127. package/port/golden/playground-invalid-json.jsonl +5 -0
  128. package/port/golden/playground-invalid-questions.jsonl +5 -0
  129. package/port/golden/status-env-key.jsonl +6 -0
  130. package/port/golden/status-no-key.jsonl +6 -0
  131. package/port/golden/tool-disabled.jsonl +18 -0
  132. package/port/golden/trailing-words.jsonl +10 -0
  133. package/port/library-mutations.py +44 -0
  134. package/port/mutations.json +302 -0
  135. package/port/oracle/.env.example +4 -0
  136. package/port/oracle/CHANGELOG.md +91 -0
  137. package/port/oracle/CONTRIBUTING.md +35 -0
  138. package/port/oracle/LICENSE +21 -0
  139. package/port/oracle/README.md +159 -0
  140. package/port/oracle/docs/api.md +143 -0
  141. package/port/oracle/docs/ci-cd.md +97 -0
  142. package/port/oracle/examples/decision-extension.ts +41 -0
  143. package/port/oracle/extensions/index.js +2 -0
  144. package/port/oracle/package.json +89 -0
  145. package/port/oracle/scripts/dev-pi.mjs +23 -0
  146. package/port/oracle/scripts/live-smoke.mjs +35 -0
  147. package/port/oracle/src/ask.ts +42 -0
  148. package/port/oracle/src/auth.ts +171 -0
  149. package/port/oracle/src/backends.ts +196 -0
  150. package/port/oracle/src/batch.ts +170 -0
  151. package/port/oracle/src/calibrate.ts +237 -0
  152. package/port/oracle/src/client.ts +310 -0
  153. package/port/oracle/src/credentials.ts +136 -0
  154. package/port/oracle/src/errors.ts +53 -0
  155. package/port/oracle/src/extension.ts +204 -0
  156. package/port/oracle/src/index.ts +31 -0
  157. package/port/oracle/src/key-prompt.ts +51 -0
  158. package/port/oracle/src/login.ts +60 -0
  159. package/port/oracle/src/schema.ts +158 -0
  160. package/port/oracle/src/ui.ts +4 -0
  161. package/port/oracle/src/usage.ts +258 -0
  162. package/port/oracle/tests/ask.test.ts +63 -0
  163. package/port/oracle/tests/auth.test.ts +141 -0
  164. package/port/oracle/tests/backends.test.ts +380 -0
  165. package/port/oracle/tests/batch.test.ts +156 -0
  166. package/port/oracle/tests/calibrate.test.ts +144 -0
  167. package/port/oracle/tests/client.test.ts +499 -0
  168. package/port/oracle/tests/credentials.test.ts +144 -0
  169. package/port/oracle/tests/extension.test.ts +276 -0
  170. package/port/oracle/tests/key-prompt.test.ts +47 -0
  171. package/port/oracle/tests/login.test.ts +101 -0
  172. package/port/oracle/tests/schema.test.ts +85 -0
  173. package/port/oracle/tests/usage.test.ts +106 -0
  174. package/port/oracle/tsconfig.build.json +10 -0
  175. package/port/oracle/tsconfig.json +14 -0
  176. package/port/scenarios/enable-confirm.json +5 -0
  177. package/port/scenarios/enable-decline.json +3 -0
  178. package/port/scenarios/enable-missing-key.json +2 -0
  179. package/port/scenarios/login-shadow.json +2 -0
  180. package/port/scenarios/logout-env-key.json +3 -0
  181. package/port/scenarios/playground-cancel.json +2 -0
  182. package/port/scenarios/playground-invalid-json.json +2 -0
  183. package/port/scenarios/playground-invalid-questions.json +2 -0
  184. package/port/scenarios/status-env-key.json +3 -0
  185. package/port/scenarios/status-no-key.json +3 -0
  186. package/port/scenarios/tool-disabled.json +2 -0
  187. package/port/scenarios/trailing-words.json +5 -0
  188. package/port/upstream-tests.json +160 -0
  189. package/provenance.json +18 -0
@@ -0,0 +1,188 @@
1
+ // Package pi_typesafe is a Go port of pi-typesafe's extension half
2
+ // (https://github.com/DevMortimer/pi-typesafe, MIT, Ryan Gapac): the typesafe_evaluate tool that hands
3
+ // batched Choice, Score and Noul questions to Jev, the /typesafe command (login, consent, usage, sample
4
+ // test, JSON playground), and the result renderers. The typed API other extensions build on is the library
5
+ // package components/pi-typesafe.
6
+ //
7
+ // The tool is off until the operator runs /typesafe enable or sets PI_TYPESAFE_ENABLED=1. Content submitted
8
+ // goes to api.typesafe.ai, unless the own-model backend is selected (PI_TYPESAFE_BACKEND=ownmodel or
9
+ // /typesafe backend ownmodel): then it goes to the provider of the model PiG is configured with.
10
+ package pi_typesafe
11
+
12
+ import (
13
+ "context"
14
+ "os"
15
+ "strings"
16
+ "sync"
17
+
18
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
19
+ pitypesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api"
20
+ "github.com/MichaelKinsy/pigpen/components/pi-typesafe-api/hostmodel"
21
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/ownmodel"
22
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
23
+ )
24
+
25
+ const (
26
+ toolName = "typesafe_evaluate"
27
+ entryType = "typesafe-result"
28
+ statusType = "typesafe-status"
29
+ envEnabled = "PI_TYPESAFE_ENABLED"
30
+ envBackend = "PI_TYPESAFE_BACKEND"
31
+ typesafeMsg = "Submitted state and questions will be sent to api.typesafe.ai and may incur charges. Do not include secrets. The extension does not collect files or conversation history. Results are model judgments, not proof or authorization."
32
+ )
33
+
34
+ // Options inject the parts a test or an embedder replaces; the zero value is the production extension.
35
+ type Options struct {
36
+ // HTTPClient is the transport for the TypeSafe API.
37
+ HTTPClient typesafe.HTTPDoer
38
+ // Ledger is the usage ledger; nil uses the store next to the key.
39
+ Ledger pitypesafe.UsageLedger
40
+ // Evaluator replaces the own-model evaluator (which reads PiG's configured model).
41
+ Evaluator typesafe.Evaluator
42
+ }
43
+
44
+ // Extension returns the extension.
45
+ func Extension() *sdk.Extension { return New(Options{}) }
46
+
47
+ // state is one session's mutable configuration.
48
+ type state struct {
49
+ mu sync.Mutex
50
+ opts Options
51
+ ext *sdk.Extension
52
+ enabled bool
53
+ backend string
54
+ client *pitypesafe.TypeSafe
55
+ calledOut string
56
+ // consent counts backend switches. Consent is per destination: a call or a dialog that was admitted under
57
+ // one value must not be sent after it changed.
58
+ consent int
59
+ }
60
+
61
+ // New returns the extension with injected parts.
62
+ func New(opts Options) *sdk.Extension {
63
+ s := &state{opts: opts, ext: sdk.New("pi-typesafe")}
64
+ s.enabled = os.Getenv(envEnabled) == "1"
65
+ s.backend = backendFromEnv()
66
+ s.registerTool()
67
+ s.ext.EntryRenderer(entryType, s.renderEntry)
68
+ s.ext.OnSessionStart(s.onSessionStart)
69
+ s.registerCommand()
70
+ return s.ext
71
+ }
72
+
73
+ func backendFromEnv() string {
74
+ if strings.TrimSpace(os.Getenv(envBackend)) == pitypesafe.BackendOwnModel {
75
+ return pitypesafe.BackendOwnModel
76
+ }
77
+ return pitypesafe.BackendTypeSafe
78
+ }
79
+
80
+ func (s *state) isEnabled() bool { s.mu.Lock(); defer s.mu.Unlock(); return s.enabled }
81
+ func (s *state) setEnabled(v bool) { s.mu.Lock(); s.enabled = v; s.mu.Unlock() }
82
+ func (s *state) currentBackend() string { s.mu.Lock(); defer s.mu.Unlock(); return s.backend }
83
+
84
+ // admit reads the consent gate and the destination it was given for under one lock.
85
+ func (s *state) admit() (enabled bool, consent int) {
86
+ s.mu.Lock()
87
+ defer s.mu.Unlock()
88
+ return s.enabled, s.consent
89
+ }
90
+
91
+ // consentNow is the current destination's consent generation.
92
+ func (s *state) consentNow() int { s.mu.Lock(); defer s.mu.Unlock(); return s.consent }
93
+
94
+ // errDestinationChanged stops a call or a dialog whose destination changed after it was admitted.
95
+ var errDestinationChanged = &pitypesafe.IntegrationError{Code: pitypesafe.CodeConfiguration, Message: "The judgment backend changed after this request was admitted; nothing was sent. Run /typesafe enable to consent to the new destination."}
96
+
97
+ // clientFor is getClient for a request admitted under consent generation consent: it fails with
98
+ // errDestinationChanged when a backend switch happened since, so the request cannot reach a destination
99
+ // nobody consented to. A switch after it returns does not affect the request (in-flight requests continue).
100
+ func (s *state) clientFor(consent int) (*pitypesafe.TypeSafe, error) {
101
+ s.mu.Lock()
102
+ defer s.mu.Unlock()
103
+ if s.consent != consent {
104
+ return nil, errDestinationChanged
105
+ }
106
+ return s.clientLocked()
107
+ }
108
+
109
+ // clientLocked builds the client on first use (s.mu held); a new session or a login clears it.
110
+ func (s *state) clientLocked() (*pitypesafe.TypeSafe, error) {
111
+ if s.client != nil {
112
+ return s.client, nil
113
+ }
114
+ options := pitypesafe.Options{Backend: s.backend, HTTPClient: s.opts.HTTPClient, Ledger: s.opts.Ledger}
115
+ if s.backend == pitypesafe.BackendOwnModel {
116
+ options.Evaluator = s.opts.Evaluator
117
+ if options.Evaluator == nil {
118
+ options.Evaluator = hostmodel.New(hostmodel.Options{AnswerMode: ownmodel.Probabilities, MalformedRetries: 1})
119
+ }
120
+ }
121
+ client, err := pitypesafe.New(options)
122
+ if err != nil {
123
+ return nil, err
124
+ }
125
+ s.client = client
126
+ return client, nil
127
+ }
128
+
129
+ func (s *state) resetClient() { s.mu.Lock(); s.client = nil; s.mu.Unlock() }
130
+
131
+ func (s *state) peekClient() *pitypesafe.TypeSafe { s.mu.Lock(); defer s.mu.Unlock(); return s.client }
132
+
133
+ // callOut says something once per distinct degradation per session: a long run must not bury the reason in
134
+ // repeated notices.
135
+ func (s *state) callOut(ctx sdk.Context, key, text string) {
136
+ s.mu.Lock()
137
+ if s.calledOut == key {
138
+ s.mu.Unlock()
139
+ return
140
+ }
141
+ s.calledOut = key
142
+ s.mu.Unlock()
143
+ // Reporting must never replace the failure it describes, and a headless run may have no message channel.
144
+ if ctx.HasUI() {
145
+ ctx.Notify(text, "warning")
146
+ return
147
+ }
148
+ _ = ctx.SendMessage(statusType, text, true, sdk.SendMessageOptions{})
149
+ }
150
+
151
+ func (s *state) onSessionStart(ctx sdk.Context, _ map[string]any) (any, error) {
152
+ s.mu.Lock()
153
+ s.enabled = os.Getenv(envEnabled) == "1"
154
+ if backend := backendFromEnv(); backend != s.backend {
155
+ s.backend = backend
156
+ s.consent++
157
+ }
158
+ s.client = nil
159
+ s.calledOut = ""
160
+ backend := s.backend
161
+ s.mu.Unlock()
162
+ // An enabled extension with no usable key used to look exactly like a working one. Say it at startup; an
163
+ // unverified-but-present key stays quiet, because the first request is what proves it.
164
+ state, err := pitypesafe.GetAuthState(pitypesafe.AuthOptions{Backend: backend})
165
+ if err != nil {
166
+ return nil, nil
167
+ }
168
+ report, err := pitypesafe.DescribeAuth(state)
169
+ if err == nil && s.isEnabled() && report.Level == pitypesafe.LevelError {
170
+ s.callOut(ctx, "start:"+string(report.Level), "TypeSafe is enabled but judgments are skipped. "+report.Text)
171
+ }
172
+ return nil, nil
173
+ }
174
+
175
+ // goContext adapts a request Context to a context.Context that ends when the request is cancelled and carries
176
+ // the Context for the own-model backend.
177
+ func goContext(ctx sdk.Context) (context.Context, context.CancelFunc) {
178
+ c, cancel := context.WithCancel(hostmodel.WithContext(context.Background(), ctx))
179
+ done := ctx.Done()
180
+ go func() {
181
+ select {
182
+ case <-done:
183
+ cancel()
184
+ case <-c.Done():
185
+ }
186
+ }()
187
+ return c, cancel
188
+ }
@@ -0,0 +1,321 @@
1
+ package pi_typesafe_test
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "os"
7
+ "path/filepath"
8
+ "regexp"
9
+ "runtime"
10
+ "strings"
11
+ "testing"
12
+
13
+ pi_typesafe "github.com/MichaelKinsy/pigpen/components/pi-typesafe/extensions/pi-typesafe"
14
+ )
15
+
16
+ func matches(pattern, s string) bool { return regexp.MustCompile(pattern).MatchString(s) }
17
+
18
+ func TestExtension(t *testing.T) {
19
+ r := newRig(t)
20
+ t.Setenv("TYPESAFE_API_KEY", "offline-test-key")
21
+ storedPath := filepath.Join(r.agentDir, "pi-typesafe", "auth.json")
22
+
23
+ tw(t, "extension", "Pi loads a tool, a slash command, and a result renderer without network calls", func(t *testing.T) {
24
+ if r.network.Load() != 0 {
25
+ t.Fatal("registering must not touch the network")
26
+ }
27
+ if !r.host.tools["typesafe_evaluate"] || !r.host.cmds["typesafe"] {
28
+ t.Fatal("tool or command missing")
29
+ }
30
+ raw, failure := r.host.roundTrip(map[string]any{"method": "command_argument_completions", "tool": "typesafe", "args": json.RawMessage(`"pla"`)})
31
+ if failure != "" || !strings.Contains(string(raw), `"playground"`) {
32
+ t.Fatalf("completions = %s, %s", raw, failure)
33
+ }
34
+ guidelines := pi_typesafe.ToolGuidelines()
35
+ found := false
36
+ for _, g := range guidelines {
37
+ found = found || matches(`one question per item per dimension`, g)
38
+ }
39
+ if !found {
40
+ t.Error("the per-item guideline is missing")
41
+ }
42
+ // Models that never saw a payload author questions as an array; the guidelines must show one that actually validates.
43
+ var example string
44
+ for _, g := range guidelines {
45
+ if strings.Contains(g, `"state":`) {
46
+ example = g
47
+ }
48
+ }
49
+ if example == "" || strings.Contains(example, "\n") || len(example) >= 1024 {
50
+ t.Fatalf("the example must exist on one short line: %q", example)
51
+ }
52
+ var payload map[string]any
53
+ if err := json.Unmarshal([]byte(example[strings.Index(example, "{"):]), &payload); err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ var kinds []string
57
+ for _, q := range payload["questions"].(map[string]any) {
58
+ kinds = append(kinds, q.(map[string]any)["type"].(string))
59
+ }
60
+ if len(kinds) != 3 || !strings.Contains(strings.Join(sortStrings(kinds), ","), "choice,noul,score") {
61
+ t.Errorf("kinds = %v", kinds)
62
+ }
63
+ if !matches(`named state field`, pi_typesafe.ToolDescription("typesafe")) {
64
+ t.Error("the description must teach named state fields")
65
+ }
66
+ })
67
+ tw(t, "extension", "default-disabled tool cannot submit data", func(t *testing.T) {
68
+ if _, failure := r.tool(nil); !matches(`disabled`, failure) {
69
+ t.Fatalf("failure = %q", failure)
70
+ }
71
+ if r.network.Load() != 0 {
72
+ t.Fatal("a disabled tool must not submit")
73
+ }
74
+ })
75
+ tw(t, "extension", "setup and status never display the API key", func(t *testing.T) {
76
+ r.command("setup")
77
+ r.command("status")
78
+ found := false
79
+ for _, n := range r.noticesSince(0) {
80
+ found = found || strings.Contains(n, "TypeSafe key: TYPESAFE_API_KEY")
81
+ if strings.Contains(n, "offline-test-key") {
82
+ t.Fatalf("the key leaked: %s", n)
83
+ }
84
+ }
85
+ if !found {
86
+ t.Fatalf("notices = %v", r.noticesSince(0))
87
+ }
88
+ })
89
+ tw(t, "extension", "status names the model the configured backend actually sends", func(t *testing.T) {
90
+ r.command("status")
91
+ if !strings.Contains(r.lastNotice(), "Model: jev-latest.") {
92
+ t.Fatalf("status = %s", r.lastNotice())
93
+ }
94
+ })
95
+ tw(t, "extension", "login refuses to shadow an environment key", func(t *testing.T) {
96
+ r.command("login")
97
+ if !strings.Contains(r.lastNotice(), "takes precedence") || r.modelList.Load() != 0 {
98
+ t.Fatalf("notice = %s", r.lastNotice())
99
+ }
100
+ })
101
+ tw(t, "extension", "declining consent keeps the tool disabled", func(t *testing.T) {
102
+ r.confirmResult = false
103
+ r.command("enable")
104
+ if _, failure := r.tool(nil); !matches(`disabled`, failure) || r.network.Load() != 0 {
105
+ t.Fatalf("failure = %q", failure)
106
+ }
107
+ })
108
+ tw(t, "extension", "explicit consent enables the real registered tool and returns structured results", func(t *testing.T) {
109
+ r.confirmResult = true
110
+ r.command("enable")
111
+ details, failure := r.tool(nil)
112
+ if failure != "" || yes(details) != 0.9 || r.network.Load() != 1 || r.confirmations < 2 {
113
+ t.Fatalf("details=%v failure=%q network=%d confirmations=%d", details, failure, r.network.Load(), r.confirmations)
114
+ }
115
+ // The renderer draws the result at every width without overflowing it.
116
+ detailsJSON, _ := json.Marshal(details)
117
+ for _, width := range []int{40, 80, 120} {
118
+ raw, failure := r.host.roundTrip(map[string]any{"method": "render_tool", "tool": "typesafe_evaluate", "args": mustJSON(map[string]any{
119
+ "card": "c1", "phase": "result", "width": width, "options": map[string]any{"expanded": true, "isPartial": false},
120
+ "result": map[string]any{"content": []any{}, "details": json.RawMessage(detailsJSON)},
121
+ })})
122
+ if failure != "" {
123
+ t.Fatalf("render failed: %s", failure)
124
+ }
125
+ var lines struct{ Lines []string }
126
+ _ = json.Unmarshal(raw, &lines)
127
+ for _, l := range lines.Lines {
128
+ if w := len([]rune(l)); w > width {
129
+ t.Errorf("width %d: line of %d cells: %q", width, w, l)
130
+ }
131
+ }
132
+ if !strings.Contains(strings.Join(lines.Lines, "\n"), "P(yes)") {
133
+ t.Errorf("width %d: no P(yes) in %v", width, lines.Lines)
134
+ }
135
+ }
136
+ })
137
+ tw(t, "extension", "disable stops future calls without resetting usage", func(t *testing.T) {
138
+ r.command("disable")
139
+ if _, failure := r.tool(nil); !matches(`disabled`, failure) {
140
+ t.Fatalf("failure = %q", failure)
141
+ }
142
+ r.command("status")
143
+ if !strings.Contains(r.lastNotice(), "1/20 attempts") {
144
+ t.Fatalf("status = %s", r.lastNotice())
145
+ }
146
+ })
147
+ tw(t, "extension", "invalid playground JSON and cancellation do not submit data", func(t *testing.T) {
148
+ r.editorText, r.editorOK = `{"broken":`, true
149
+ r.command("playground")
150
+ if !strings.Contains(r.lastNotice(), "Invalid JSON") {
151
+ t.Fatalf("notice = %s", r.lastNotice())
152
+ }
153
+ r.editorOK = false
154
+ r.command("playground")
155
+ if r.network.Load() != 1 {
156
+ t.Fatalf("network = %d", r.network.Load())
157
+ }
158
+ })
159
+ tw(t, "extension", "playground validates questions before requesting consent", func(t *testing.T) {
160
+ r.editorText, r.editorOK = `{"state":"example","questions":{}}`, true
161
+ prior := r.confirmations
162
+ r.command("playground")
163
+ if r.confirmations != prior || r.network.Load() != 1 || !strings.Contains(r.lastNotice(), "Invalid evaluation request") {
164
+ t.Fatalf("confirmations=%d network=%d notice=%s", r.confirmations, r.network.Load(), r.lastNotice())
165
+ }
166
+ })
167
+ tw(t, "extension", "test command requires confirmation and does not enable agent calls", func(t *testing.T) {
168
+ r.confirmResult = false
169
+ r.command("test")
170
+ if r.network.Load() != 1 {
171
+ t.Fatalf("network = %d", r.network.Load())
172
+ }
173
+ if _, failure := r.tool(nil); !matches(`disabled`, failure) {
174
+ t.Fatalf("failure = %q", failure)
175
+ }
176
+ })
177
+ tw(t, "extension", "login verifies, stores with owner-only permissions, and never echoes the key", func(t *testing.T) {
178
+ t.Setenv("TYPESAFE_API_KEY", "")
179
+ r.customResult = nil
180
+ r.command("login")
181
+ if !strings.Contains(r.lastNotice(), "cancelled") || fileExists(storedPath) {
182
+ t.Fatalf("cancel: %s", r.lastNotice())
183
+ }
184
+ r.customResult = "nope"
185
+ r.command("login")
186
+ if !strings.Contains(r.lastNotice(), "does not look like") || r.modelList.Load() != 0 || fileExists(storedPath) {
187
+ t.Fatalf("bad key: %s", r.lastNotice())
188
+ }
189
+ r.customResult = "ts_live_key_0123456789abcdef"
190
+ r.command("login")
191
+ if r.modelList.Load() != 1 || !strings.Contains(r.lastNotice(), "Key verified (1 model available)") {
192
+ t.Fatalf("login: %s", r.lastNotice())
193
+ }
194
+ for _, n := range r.noticesSince(0) {
195
+ if strings.Contains(n, "ts_live_key") {
196
+ t.Fatalf("the key leaked: %s", n)
197
+ }
198
+ }
199
+ if info, err := os.Stat(storedPath); err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) {
200
+ t.Fatalf("stored file: %v %v", info, err)
201
+ }
202
+ r.command("status")
203
+ if !strings.Contains(r.lastNotice(), "TypeSafe key: /typesafe login") {
204
+ t.Fatalf("status: %s", r.lastNotice())
205
+ }
206
+ r.command("setup")
207
+ if !strings.Contains(r.lastNotice(), "configured via /typesafe login") {
208
+ t.Fatalf("setup: %s", r.lastNotice())
209
+ }
210
+ // The stored key powers the real tool after consent.
211
+ r.confirmResult = true
212
+ r.command("enable")
213
+ if _, failure := r.tool(nil); failure != "" || r.network.Load() != 2 {
214
+ t.Fatalf("tool: %q network=%d", failure, r.network.Load())
215
+ }
216
+ r.command("logout")
217
+ if fileExists(storedPath) {
218
+ t.Fatal("logout must delete the key")
219
+ }
220
+ if _, failure := r.tool(nil); !matches(`disabled`, failure) {
221
+ t.Fatalf("failure = %q", failure)
222
+ }
223
+ r.command("status")
224
+ if !strings.Contains(r.lastNotice(), "TypeSafe key: missing") {
225
+ t.Fatalf("status: %s", r.lastNotice())
226
+ }
227
+ t.Setenv("TYPESAFE_API_KEY", "offline-test-key")
228
+ })
229
+ tw(t, "extension", "new sessions reset opt-in; headless opt-in is explicit", func(t *testing.T) {
230
+ r.startSession("new")
231
+ if _, failure := r.tool(nil); !matches(`disabled`, failure) {
232
+ t.Fatalf("failure = %q", failure)
233
+ }
234
+ t.Setenv("PI_TYPESAFE_ENABLED", "1")
235
+ r.startSession("startup")
236
+ if _, failure := r.tool(nil); failure != "" || r.network.Load() != 3 {
237
+ t.Fatalf("failure=%q network=%d", failure, r.network.Load())
238
+ }
239
+ r.command("status")
240
+ if !strings.Contains(r.lastNotice(), "1/20 attempts") {
241
+ t.Fatalf("status: %s", r.lastNotice())
242
+ }
243
+ })
244
+ tw(t, "extension", "an enabled session with no key announces that judgments are skipped", func(t *testing.T) {
245
+ t.Setenv("TYPESAFE_API_KEY", "")
246
+ t.Setenv("PI_TYPESAFE_ENABLED", "1")
247
+ before := r.noticeCount()
248
+ r.startSession("startup")
249
+ said := strings.Join(r.noticesSince(before), "\n")
250
+ if !strings.Contains(said, "judgments are skipped") || !strings.Contains(said, "TypeSafe key: missing") {
251
+ t.Fatalf("said = %q", said)
252
+ }
253
+ // A key that appears later silences the next startup notice.
254
+ t.Setenv("TYPESAFE_API_KEY", "offline-test-key")
255
+ again := r.noticeCount()
256
+ r.startSession("reload")
257
+ for _, n := range r.noticesSince(again) {
258
+ if strings.Contains(n, "judgments are skipped") {
259
+ t.Fatalf("stale callout: %s", n)
260
+ }
261
+ }
262
+ })
263
+ tw(t, "extension", "a rejected key is called out once per session and shows up in status", func(t *testing.T) {
264
+ t.Setenv("PI_TYPESAFE_ENABLED", "1")
265
+ r.startSession("startup")
266
+ before := r.noticeCount()
267
+ r.respond = func(*http.Request) *http.Response {
268
+ return jsonResponse(401, map[string]any{"error": map[string]any{"message": "invalid key"}})
269
+ }
270
+ defer func() { r.respond = nil }()
271
+ for i := 0; i < 2; i++ {
272
+ if _, failure := r.tool(nil); !matches(`HTTP 401`, failure) {
273
+ t.Fatalf("failure = %q", failure)
274
+ }
275
+ }
276
+ // Two failed calls, one callout: the reason is loud once, not once per call.
277
+ count := 0
278
+ for _, n := range r.noticesSince(before) {
279
+ if strings.Contains(n, "not authenticated") {
280
+ count++
281
+ }
282
+ }
283
+ if count != 1 {
284
+ t.Fatalf("callouts = %d in %v", count, r.noticesSince(before))
285
+ }
286
+ r.command("status")
287
+ last := r.lastNotice()
288
+ if !strings.Contains(last, "was rejected") || !strings.Contains(last, "Today ") || !strings.Contains(last, "failed") {
289
+ t.Fatalf("status = %s", last)
290
+ }
291
+ })
292
+ tw(t, "extension", "the registered tool admits the same near-miss aliases as the library", func(t *testing.T) {
293
+ t.Setenv("PI_TYPESAFE_ENABLED", "1")
294
+ r.startSession("startup")
295
+ before := r.network.Load()
296
+ details, failure := r.tool(map[string]any{"state": "synthetic", "questions": map[string]any{"yes": map[string]any{"type": "noul", "instructions": "Is this synthetic?", "criteria": "Is this synthetic data?"}}})
297
+ if failure != "" || r.network.Load() != before+1 || yes(details) != 0.9 {
298
+ t.Fatalf("failure=%q details=%v", failure, details)
299
+ }
300
+ })
301
+ }
302
+
303
+ func sortStrings(in []string) []string {
304
+ out := append([]string(nil), in...)
305
+ for i := range out {
306
+ for j := i + 1; j < len(out); j++ {
307
+ if out[j] < out[i] {
308
+ out[i], out[j] = out[j], out[i]
309
+ }
310
+ }
311
+ }
312
+ return out
313
+ }
314
+
315
+ func mustJSON(v any) json.RawMessage {
316
+ b, err := json.Marshal(v)
317
+ if err != nil {
318
+ panic(err)
319
+ }
320
+ return b
321
+ }