saeeol 1.2.2 → 1.2.4

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 (52) hide show
  1. package/bin/saeeol.cjs +203 -0
  2. package/npm/bin/saeeol +0 -0
  3. package/package.json +2 -2
  4. package/src/cli/cmd/tui/component/dialog/dialog-mcp.tsx +3 -3
  5. package/src/cli/cmd/tui/component/dialog/dialog-model.tsx +1 -1
  6. package/src/cli/cmd/tui/component/dialog/dialog-provider.tsx +5 -5
  7. package/src/cli/cmd/tui/component/dialog/dialog-session-delete-failed.tsx +4 -4
  8. package/src/cli/cmd/tui/component/dialog/dialog-session-list.tsx +5 -5
  9. package/src/cli/cmd/tui/component/dialog/dialog-session-rename.tsx +3 -3
  10. package/src/cli/cmd/tui/component/dialog/dialog-stash.tsx +2 -2
  11. package/src/cli/cmd/tui/component/dialog/dialog-status.tsx +3 -3
  12. package/src/cli/cmd/tui/component/dialog/dialog-theme-list.tsx +4 -4
  13. package/src/cli/cmd/tui/component/dialog/dialog-workspace-create.tsx +4 -4
  14. package/src/cli/cmd/tui/component/dialog/dialog-workspace-unavailable.tsx +4 -4
  15. package/src/cli/cmd/tui/context/app/args.tsx +15 -0
  16. package/src/cli/cmd/tui/context/app/directory.ts +15 -0
  17. package/src/cli/cmd/tui/context/app/editor-zed.ts +281 -0
  18. package/src/cli/cmd/tui/context/app/editor.ts +425 -0
  19. package/src/cli/cmd/tui/context/app/helper.tsx +25 -0
  20. package/src/cli/cmd/tui/context/app/project.tsx +109 -0
  21. package/src/cli/cmd/tui/context/app/route.tsx +67 -0
  22. package/src/cli/cmd/tui/context/app/sdk.tsx +142 -0
  23. package/src/cli/cmd/tui/context/app/sync.tsx +713 -0
  24. package/src/cli/cmd/tui/context/app/theme.tsx +307 -0
  25. package/src/cli/cmd/tui/context/app/tui-config.tsx +9 -0
  26. package/src/cli/cmd/tui/context/args.tsx +1 -15
  27. package/src/cli/cmd/tui/context/directory.ts +1 -15
  28. package/src/cli/cmd/tui/context/editor-zed.ts +1 -281
  29. package/src/cli/cmd/tui/context/editor.ts +1 -425
  30. package/src/cli/cmd/tui/context/event.ts +1 -45
  31. package/src/cli/cmd/tui/context/exit.tsx +1 -67
  32. package/src/cli/cmd/tui/context/helper.tsx +1 -25
  33. package/src/cli/cmd/tui/context/keybind.tsx +1 -105
  34. package/src/cli/cmd/tui/context/kv.tsx +1 -76
  35. package/src/cli/cmd/tui/context/local.tsx +1 -478
  36. package/src/cli/cmd/tui/context/plugin-keybinds.ts +1 -41
  37. package/src/cli/cmd/tui/context/project.tsx +1 -109
  38. package/src/cli/cmd/tui/context/prompt.tsx +1 -18
  39. package/src/cli/cmd/tui/context/route.tsx +1 -67
  40. package/src/cli/cmd/tui/context/runtime/event.ts +45 -0
  41. package/src/cli/cmd/tui/context/runtime/exit.tsx +67 -0
  42. package/src/cli/cmd/tui/context/runtime/keybind.tsx +105 -0
  43. package/src/cli/cmd/tui/context/runtime/kv.tsx +76 -0
  44. package/src/cli/cmd/tui/context/runtime/local.tsx +478 -0
  45. package/src/cli/cmd/tui/context/runtime/plugin-keybinds.ts +41 -0
  46. package/src/cli/cmd/tui/context/sdk.tsx +1 -142
  47. package/src/cli/cmd/tui/context/session/prompt.tsx +18 -0
  48. package/src/cli/cmd/tui/context/sync.tsx +1 -713
  49. package/src/cli/cmd/tui/context/theme.tsx +1 -307
  50. package/src/cli/cmd/tui/context/tui-config.tsx +1 -9
  51. package/src/ltm/pipeline.ts +103 -1
  52. package/test/server/contract.test.ts +249 -0
@@ -1,307 +1 @@
1
- import { CliRenderEvents, type TerminalColors } from "@opentui/core"
2
- import path from "path"
3
- import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
4
- import { createSimpleContext } from "./helper"
5
- import { Glob } from "@saeeol/core/util/glob"
6
- import { useKV } from "./kv"
7
- import { useRenderer } from "@opentui/solid"
8
- import { createStore, produce } from "solid-js/store"
9
- import { Global } from "@saeeol/core/global"
10
- import { Filesystem } from "@/util/filesystem"
11
- import { useTuiConfig } from "./tui-config"
12
- import { isRecord } from "@/util/record"
13
- import { selectedForeground, tint } from "./theme/theme-types"
14
- import type { ThemeJson, Theme } from "./theme/theme-types"
15
- import { DEFAULT_THEMES, isValidTheme } from "./theme/theme-themes"
16
- import { resolveTheme } from "./theme/theme-resolve"
17
- import { generateSystem } from "./theme/theme-system"
18
- import { generateSyntax, generateSubtleSyntax } from "./theme/theme-syntax"
19
-
20
- export { selectedForeground, tint }
21
- export type { ThemeJson }
22
- export { DEFAULT_THEMES }
23
- export { resolveTheme }
24
-
25
- type State = {
26
- themes: Record<string, ThemeJson>
27
- mode: "dark" | "light"
28
- lock: "dark" | "light" | undefined
29
- active: string
30
- ready: boolean
31
- }
32
-
33
- const pluginThemes: Record<string, ThemeJson> = {}
34
- let customThemes: Record<string, ThemeJson> = {}
35
- let systemTheme: ThemeJson | undefined
36
-
37
- function listThemes() {
38
- const themes = {
39
- ...DEFAULT_THEMES,
40
- ...pluginThemes,
41
- ...customThemes,
42
- }
43
- if (!systemTheme) return themes
44
- return {
45
- ...themes,
46
- system: systemTheme,
47
- }
48
- }
49
-
50
- function syncThemes() {
51
- setStore("themes", listThemes())
52
- }
53
-
54
- const [store, setStore] = createStore<State>({
55
- themes: listThemes(),
56
- mode: "dark",
57
- lock: undefined,
58
- active: "saeeol",
59
- ready: false,
60
- })
61
-
62
- export function allThemes() {
63
- return store.themes
64
- }
65
-
66
- function isTheme(theme: unknown): theme is ThemeJson {
67
- if (!isRecord(theme)) return false
68
- if (!isRecord(theme.theme)) return false
69
- return true
70
- }
71
-
72
- export function hasTheme(name: string) {
73
- if (!name) return false
74
- return allThemes()[name] !== undefined
75
- }
76
-
77
- export function addTheme(name: string, theme: unknown) {
78
- if (!name) return false
79
- if (!isTheme(theme)) return false
80
- if (hasTheme(name)) return false
81
- pluginThemes[name] = theme
82
- syncThemes()
83
- return true
84
- }
85
-
86
- export function upsertTheme(name: string, theme: unknown) {
87
- if (!name) return false
88
- if (!isTheme(theme)) return false
89
- if (customThemes[name] !== undefined) {
90
- customThemes[name] = theme
91
- } else {
92
- pluginThemes[name] = theme
93
- }
94
- syncThemes()
95
- return true
96
- }
97
-
98
- export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
99
- name: "Theme",
100
- init: (props: { mode: "dark" | "light" }) => {
101
- const renderer = useRenderer()
102
- const config = useTuiConfig()
103
- const kv = useKV()
104
- const pick = (value: unknown) => {
105
- if (value === "dark" || value === "light") return value
106
- return
107
- }
108
-
109
- setStore(
110
- produce((draft) => {
111
- const lock = pick(kv.get("theme_mode_lock"))
112
- const mode = lock ?? pick(renderer.themeMode) ?? props.mode
113
- if (!lock && pick(kv.get("theme_mode")) !== undefined) {
114
- kv.set("theme_mode", undefined)
115
- }
116
- draft.mode = mode
117
- draft.lock = lock
118
- const active = config.theme ?? kv.get("theme", "saeeol")
119
- draft.active = typeof active === "string" ? active : "saeeol"
120
- draft.ready = false
121
- }),
122
- )
123
-
124
- createEffect(() => {
125
- const theme = config.theme
126
- if (theme) setStore("active", theme)
127
- })
128
-
129
- function init() {
130
- void Promise.allSettled([
131
- resolveSystemTheme(store.mode),
132
- getCustomThemes()
133
- .then((custom) => {
134
- customThemes = custom
135
- syncThemes()
136
- })
137
- .catch(() => {
138
- setStore("active", "saeeol")
139
- }),
140
- ]).finally(() => {
141
- setStore("ready", true)
142
- })
143
- }
144
-
145
- onMount(init)
146
-
147
- function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
148
- return renderer
149
- .getPalette({
150
- size: 16,
151
- })
152
- .then((colors: TerminalColors) => {
153
- if (!colors.palette[0]) {
154
- systemTheme = undefined
155
- syncThemes()
156
- if (store.active === "system") {
157
- setStore("active", "saeeol")
158
- }
159
- return
160
- }
161
- systemTheme = generateSystem(colors, mode)
162
- syncThemes()
163
- })
164
- .catch(() => {
165
- systemTheme = undefined
166
- syncThemes()
167
- if (store.active === "system") {
168
- setStore("active", "saeeol")
169
- }
170
- })
171
- }
172
-
173
- function apply(mode: "dark" | "light") {
174
- if (store.lock !== undefined) kv.set("theme_mode", mode)
175
- if (store.mode === mode) return
176
- setStore("mode", mode)
177
- renderer.clearPaletteCache()
178
- void resolveSystemTheme(mode)
179
- }
180
-
181
- function pin(mode: "dark" | "light" = store.mode) {
182
- setStore("lock", mode)
183
- kv.set("theme_mode_lock", mode)
184
- apply(mode)
185
- }
186
-
187
- function free() {
188
- setStore("lock", undefined)
189
- kv.set("theme_mode_lock", undefined)
190
- kv.set("theme_mode", undefined)
191
- const mode = renderer.themeMode
192
- if (mode) apply(mode)
193
- }
194
-
195
- const handle = (mode: "dark" | "light") => {
196
- if (store.lock) return
197
- apply(mode)
198
- }
199
- renderer.on(CliRenderEvents.THEME_MODE, handle)
200
-
201
- const refresh = () => {
202
- renderer.clearPaletteCache()
203
- init()
204
- }
205
- process.on("SIGUSR2", refresh)
206
-
207
- onCleanup(() => {
208
- renderer.off(CliRenderEvents.THEME_MODE, handle)
209
- process.off("SIGUSR2", refresh)
210
- })
211
- const values = createMemo(() => {
212
- const active = store.themes[store.active]
213
- if (active) {
214
- return resolveTheme(active, store.mode)
215
- }
216
-
217
- const saved = kv.get("theme")
218
- if (typeof saved === "string") {
219
- const theme = store.themes[saved]
220
- if (theme) {
221
- return resolveTheme(theme, store.mode)
222
- }
223
- }
224
-
225
- return resolveTheme(store.themes.saeeol, store.mode)
226
- })
227
-
228
- createEffect(() => {
229
- renderer.setBackgroundColor(values().background)
230
- })
231
-
232
- const syntax = createMemo(() => generateSyntax(values()))
233
- const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
234
- return {
235
- theme: new Proxy({} as Theme, {
236
- get(_target, prop) {
237
- // @ts-expect-error
238
- return values()[prop]
239
- },
240
- }),
241
- get selected() {
242
- return store.active
243
- },
244
- all() {
245
- return allThemes()
246
- },
247
- has(name: string) {
248
- return hasTheme(name)
249
- },
250
- syntax,
251
- subtleSyntax,
252
- mode() {
253
- return store.mode
254
- },
255
- locked() {
256
- return store.lock !== undefined
257
- },
258
- lock() {
259
- pin(store.mode)
260
- },
261
- unlock() {
262
- free()
263
- },
264
- setMode(mode: "dark" | "light") {
265
- pin(mode)
266
- },
267
- set(theme: string) {
268
- if (!hasTheme(theme)) return false
269
- setStore("active", theme)
270
- kv.set("theme", theme)
271
- return true
272
- },
273
- get ready() {
274
- return store.ready
275
- },
276
- }
277
- },
278
- })
279
-
280
- async function getCustomThemes() {
281
- const directories = [
282
- Global.Path.config,
283
- ...(await Array.fromAsync(
284
- Filesystem.up({
285
- targets: [".saeeol", ".saeeol"],
286
- start: process.cwd(),
287
- }),
288
- )),
289
- ]
290
-
291
- const result: Record<string, ThemeJson> = {}
292
- for (const dir of directories) {
293
- for (const item of await Glob.scan("themes/*.json", {
294
- cwd: dir,
295
- absolute: true,
296
- dot: true,
297
- symlink: true,
298
- })) {
299
- const name = path.basename(item, ".json")
300
- if (name in DEFAULT_THEMES) continue
301
- const json = await Filesystem.readJson(item).catch(() => null)
302
- if (!isValidTheme(json)) continue
303
- result[name] = json
304
- }
305
- }
306
- return result
307
- }
1
+ export * from "./app/theme"
@@ -1,9 +1 @@
1
- import { TuiConfig } from "@/cli/cmd/tui/config/tui"
2
- import { createSimpleContext } from "./helper"
3
-
4
- export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
5
- name: "TuiConfig",
6
- init: (props: { config: TuiConfig.Info }) => {
7
- return props.config
8
- },
9
- })
1
+ export * from "./app/tui-config"
@@ -11,11 +11,13 @@ import * as Procedural from "@/ltm/memory/procedural"
11
11
  import * as Scheduler from "@/ltm/scheduler"
12
12
  import type { LTMConfig, LLMBakeParams } from "@/ltm/types"
13
13
  import { LTMEvent } from "@/ltm/events"
14
+ import { SessionEvent } from "@/session/core/session-types"
14
15
 
15
16
  const log = Log.create({ service: "ltm/pipeline" })
16
17
 
17
18
  let running = false
18
19
  let config: LTMConfig | undefined
20
+ let unsubscribers: Array<() => void> = []
19
21
 
20
22
  // ── 파이프라인 수명 ──
21
23
 
@@ -57,6 +59,9 @@ export async function start(cfg: LTMConfig, bake: LLMBakeParams): Promise<void>
57
59
  log.info("trimmed memories to max", { removed: toRemove.length })
58
60
  }
59
61
 
62
+ // BusEvent 구독 바인딩
63
+ bindSubscriptions()
64
+
60
65
  log.info("pipeline started", { memoryCount: await Store.count() })
61
66
  }
62
67
 
@@ -64,6 +69,13 @@ export async function start(cfg: LTMConfig, bake: LLMBakeParams): Promise<void>
64
69
  export async function stop(): Promise<void> {
65
70
  if (!running) return
66
71
  running = false
72
+
73
+ // 구독 해제
74
+ for (const unsub of unsubscribers) {
75
+ try { unsub() } catch { /* ignore */ }
76
+ }
77
+ unsubscribers = []
78
+
67
79
  await Embedder.stop()
68
80
  config = undefined
69
81
  log.info("pipeline stopped")
@@ -74,7 +86,90 @@ export function isActive(): boolean {
74
86
  return running
75
87
  }
76
88
 
77
- // ── 이벤트 핸들러 ──
89
+ // ── BusEvent 구독 바인딩 ──
90
+
91
+ function bindSubscriptions() {
92
+ if (!config) return
93
+
94
+ // 1. 세션 diff → 에피소드 기억 (코드 변경 후 AI 응답 완료 시)
95
+ const unsubDiff = Bus.subscribe(SessionEvent.Diff, async (event) => {
96
+ if (!running || !config?.episodic.enabled) return
97
+
98
+ const sessionID = event.properties.sessionID
99
+ // diff 이벤트는 assistant 응답 후 세션 상태가 업데이트될 때 발생
100
+ // 사용자 메시지와 assistant 응답을 모두 포함할 수 있는 컨텍스트로 처리
101
+ const diffs = event.properties.diff
102
+ if (!diffs || diffs.length === 0) return
103
+
104
+ const summary = `code changes: ${diffs.map((d) => `${d.filePath} (${d.status})`).join(", ")}`
105
+ try {
106
+ const vector = await Embedder.embedOne(summary)
107
+ const memory = {
108
+ id: `epi:${sessionID}:${Date.now()}`,
109
+ type: "episodic" as const,
110
+ content: summary.slice(0, 500),
111
+ summary,
112
+ vector,
113
+ metadata: {
114
+ source: `session:${sessionID}`,
115
+ timestamp: Date.now(),
116
+ sessionID,
117
+ tags: ["code-change"],
118
+ },
119
+ }
120
+ await Store.upsert(memory)
121
+ void Bus.publish(LTMEvent.MemoryStored, {
122
+ id: memory.id,
123
+ type: memory.type,
124
+ source: memory.metadata.source,
125
+ })
126
+ } catch (e) {
127
+ log.error("failed to store session diff memory", { error: e })
128
+ }
129
+ })
130
+ unsubscribers.push(unsubDiff)
131
+
132
+ // 2. 세션 에러 → 에피소드 기억 (문제/해결 추적)
133
+ const unsubError = Bus.subscribe(SessionEvent.Error, async (event) => {
134
+ if (!running || !config?.episodic.enabled) return
135
+ const sessionID = event.properties.sessionID
136
+ if (!sessionID) return
137
+
138
+ const err = event.properties.error
139
+ if (!err) return
140
+
141
+ const summary = `error in session: ${typeof err === "object" ? JSON.stringify(err).slice(0, 200) : String(err).slice(0, 200)}`
142
+ try {
143
+ const vector = await Embedder.embedOne(summary)
144
+ const memory = {
145
+ id: `epi:err:${sessionID}:${Date.now()}`,
146
+ type: "episodic" as const,
147
+ content: summary.slice(0, 500),
148
+ summary,
149
+ vector,
150
+ metadata: {
151
+ source: `session:${sessionID}`,
152
+ timestamp: Date.now(),
153
+ sessionID,
154
+ tags: ["error"],
155
+ },
156
+ }
157
+ await Store.upsert(memory)
158
+ void Bus.publish(LTMEvent.MemoryStored, {
159
+ id: memory.id,
160
+ type: memory.type,
161
+ source: memory.metadata.source,
162
+ })
163
+ } catch (e) {
164
+ log.error("failed to store error memory", { error: e })
165
+ }
166
+ })
167
+ unsubscribers.push(unsubError)
168
+
169
+ log.info("bus subscriptions bound", { count: unsubscribers.length })
170
+ }
171
+
172
+ // ── 직접 호출 핸들러 (외부에서 명시적으로 호출) ──
78
173
 
79
174
  /** 대화 메시지 → 에피소드 기억 */
80
175
  export async function onMessageCompleted(
@@ -104,6 +199,13 @@ export async function onFileChanged(
104
199
  ): Promise<void> {
105
200
  if (!running || !config?.semantic.enabled || !config.semantic.indexOnFileChange) return
106
201
 
202
+ // VRAM 스케줄러 확인 — 임베딩 작업 가능한지
203
+ const canRun = await Scheduler.canRunEmbedding()
204
+ if (!canRun) {
205
+ log.info("skipping file indexing — VRAM budget exceeded", { filePath })
206
+ return
207
+ }
208
+
107
209
  // 파일을 청크로 분할 (간단한 줄 기반)
108
210
  const lines = content.split("\n")
109
211
  const chunkSize = 50
@@ -0,0 +1,249 @@
1
+ /**
2
+ * contract.test.ts — SAEEOL 서버 계약 테스트
3
+ *
4
+ * closebook 패턴 기반:
5
+ * - response-conformance: Effect Schema로 응답 바디 적합성 검사
6
+ * - consumer-contract: SDK/클라이언트가 기대하는 API 응답 스키마 검증
7
+ * - stateful-api-testing: 세션 생성 → 프롬프트 → 메시지 조회 → 삭제 순차 테스트
8
+ *
9
+ * Hono app.request()로 실제 HTTP 서버 없이 테스트.
10
+ * Bun test + Effect + zod 검증.
11
+ */
12
+
13
+ import { afterEach, describe, expect } from "bun:test"
14
+ import { Effect } from "effect"
15
+ import { Flag } from "@saeeol/core/flag/flag"
16
+ import { Server } from "../../src/server/server"
17
+ import { resetDatabase } from "../fixture/db"
18
+ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
19
+ import { it } from "../lib/effect"
20
+ import * as Log from "@saeeol/core/util/log"
21
+ import { z } from "zod"
22
+
23
+ void Log.init({ print: false })
24
+
25
+ const original = Flag.SAEEOL_EXPERIMENTAL_HTTPAPI
26
+
27
+ function app(experimental = true) {
28
+ Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = experimental
29
+ return experimental ? Server.Default().app : Server.Legacy().app
30
+ }
31
+
32
+ afterEach(async () => {
33
+ Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = original
34
+ await disposeAllInstances()
35
+ })
36
+
37
+ // ── Zod 스키마 (계약: 클라이언트가 기대하는 응답 형태) ──
38
+
39
+ const HealthSchema = z.object({
40
+ healthy: z.boolean(),
41
+ version: z.string(),
42
+ })
43
+
44
+ const ConfigSchema = z.object({
45
+ model: z.string().optional(),
46
+ username: z.string().optional(),
47
+ }).passthrough()
48
+
49
+ const SessionListSchema = z.array(z.object({
50
+ id: z.string(),
51
+ title: z.string().optional(),
52
+ createdAt: z.number().optional(),
53
+ }).passthrough())
54
+
55
+ const SessionCreateSchema = z.object({
56
+ id: z.string(),
57
+ }).passthrough()
58
+
59
+ const AgentListSchema = z.array(z.unknown())
60
+
61
+ const ErrorResponseSchema = z.object({
62
+ error: z.string().or(z.record(z.unknown())),
63
+ }).passthrough()
64
+
65
+ // ── 응답 적합성 검증 (closebook: response-conformance) ──
66
+
67
+ function checkConformance<T extends z.ZodType>(
68
+ body: unknown,
69
+ status: number,
70
+ schema: T,
71
+ allowedStatuses: number[] = [200],
72
+ ): { ok: boolean; violations: string[] } {
73
+ const violations: string[] = []
74
+
75
+ if (!allowedStatuses.includes(status)) {
76
+ violations.push(`status: expected ${allowedStatuses.join("/")}, got ${status}`)
77
+ }
78
+
79
+ const parsed = schema.safeParse(body)
80
+ if (!parsed.success) {
81
+ for (const issue of parsed.error.issues) {
82
+ violations.push(`${issue.path.join(".") || "(root)"}: ${issue.message}`)
83
+ }
84
+ }
85
+
86
+ return { ok: violations.length === 0, violations }
87
+ }
88
+
89
+ // ── 테스트 ──
90
+
91
+ describe("SAEEOL server contract tests", () => {
92
+ describe("GET /global/health", () => {
93
+ it.live("returns valid health response (legacy)", () =>
94
+ Effect.gen(function* () {
95
+ const res = yield* Effect.promise(async () => await app(false).request("/global/health"))
96
+ const body = yield* Effect.promise(async () => await res.json())
97
+ expect(res.status).toBe(200)
98
+ const { ok, violations } = checkConformance(body, res.status, HealthSchema)
99
+ expect(ok).toBe(true)
100
+ if (!ok) console.error("Health violations:", violations)
101
+ }),
102
+ )
103
+
104
+ it.live("returns valid health response (experimental)", () =>
105
+ Effect.gen(function* () {
106
+ const res = yield* Effect.promise(async () => await app(true).request("/global/health"))
107
+ const body = yield* Effect.promise(async () => await res.json())
108
+ expect(res.status).toBe(200)
109
+ const { ok, violations } = checkConformance(body, res.status, HealthSchema)
110
+ expect(ok).toBe(true)
111
+ if (!ok) console.error("Health violations:", violations)
112
+ }),
113
+ )
114
+ })
115
+
116
+ describe("GET /global/config", () => {
117
+ it.live("returns valid config response", () =>
118
+ Effect.gen(function* () {
119
+ await using tmp = yield* Effect.promise(() => tmpdir({ config: {} }))
120
+ const res = yield* Effect.promise(
121
+ async () => await app(false).request("/global/config", {
122
+ headers: { "x-saeeol-directory": tmp.path },
123
+ }),
124
+ )
125
+ const body = yield* Effect.promise(async () => await res.json())
126
+ const { ok, violations } = checkConformance(body, res.status, ConfigSchema)
127
+ expect(ok).toBe(true)
128
+ if (!ok) console.error("Config violations:", violations)
129
+ }),
130
+ )
131
+ })
132
+
133
+ describe("GET /agent", () => {
134
+ it.live("returns agent list", () =>
135
+ Effect.gen(function* () {
136
+ await using tmp = yield* Effect.promise(() => tmpdir({ config: {} }))
137
+ const res = yield* Effect.promise(
138
+ async () => await app(false).request("/agent", {
139
+ headers: { "x-saeeol-directory": tmp.path },
140
+ }),
141
+ )
142
+ const body = yield* Effect.promise(async () => await res.json())
143
+ const { ok, violations } = checkConformance(body, res.status, AgentListSchema)
144
+ expect(ok).toBe(true)
145
+ if (!ok) console.error("Agent violations:", violations)
146
+ }),
147
+ )
148
+ })
149
+
150
+ describe("session CRUD", () => {
151
+ it.live("creates and lists sessions", () =>
152
+ Effect.gen(function* () {
153
+ await using tmp = yield* Effect.promise(() => tmpdir({ git: true, config: {} }))
154
+ const headers = { "x-saeeol-directory": tmp.path, "Content-Type": "application/json" }
155
+
156
+ // Create session
157
+ const createRes = yield* Effect.promise(
158
+ async () => await app(false).request("/session", {
159
+ method: "POST",
160
+ headers,
161
+ body: JSON.stringify({ title: "contract-test" }),
162
+ }),
163
+ )
164
+ const createBody = yield* Effect.promise(async () => await createRes.json())
165
+ const { ok: createOk, violations: createViolations } = checkConformance(
166
+ createBody,
167
+ createRes.status,
168
+ SessionCreateSchema,
169
+ )
170
+ expect(createOk).toBe(true)
171
+ if (!createOk) console.error("Session create violations:", createViolations)
172
+
173
+ // List sessions
174
+ const listRes = yield* Effect.promise(
175
+ async () => await app(false).request("/session", { headers }),
176
+ )
177
+ const listBody = yield* Effect.promise(async () => await listRes.json())
178
+ const { ok: listOk, violations: listViolations } = checkConformance(
179
+ listBody,
180
+ listRes.status,
181
+ SessionListSchema,
182
+ )
183
+ expect(listOk).toBe(true)
184
+ if (!listOk) console.error("Session list violations:", listViolations)
185
+ }),
186
+ )
187
+ })
188
+
189
+ describe("unknown route", () => {
190
+ it.live("returns error for unknown endpoints", () =>
191
+ Effect.gen(function* () {
192
+ const res = yield* Effect.promise(async () => await app(false).request("/nonexistent"))
193
+ expect(res.status).toBe(404)
194
+ }),
195
+ )
196
+ })
197
+
198
+ // ── 계약 파일 생성 (closebook: consumer-contract) ──
199
+
200
+ describe("pact artifact generation", () => {
201
+ it.live("collects verified interactions into contract", () =>
202
+ Effect.gen(function* () {
203
+ await using tmp = yield* Effect.promise(() => tmpdir({ config: {} }))
204
+ const headers = { "x-saeeol-directory": tmp.path }
205
+ const interactions = []
206
+
207
+ // Health 인터랙션
208
+ const healthRes = yield* Effect.promise(
209
+ async () => await app(false).request("/global/health"),
210
+ )
211
+ const healthBody = yield* Effect.promise(async () => await healthRes.json())
212
+ interactions.push({
213
+ description: "health check",
214
+ providerStates: [],
215
+ request: { method: "GET", path: "/global/health" },
216
+ response: { status: healthRes.status, body: healthBody },
217
+ })
218
+
219
+ // Config 인터랙션
220
+ const configRes = yield* Effect.promise(
221
+ async () => await app(false).request("/global/config", { headers }),
222
+ )
223
+ const configBody = yield* Effect.promise(async () => await configRes.json())
224
+ interactions.push({
225
+ description: "global config",
226
+ providerStates: [],
227
+ request: { method: "GET", path: "/global/config" },
228
+ response: { status: configRes.status, body: configBody },
229
+ })
230
+
231
+ // 계약 파일 검증
232
+ const pact = {
233
+ consumer: { name: "saeeol-vscode" },
234
+ provider: { name: "saeeol-server" },
235
+ interactions,
236
+ metadata: { version: "1.0.0" },
237
+ }
238
+
239
+ expect(pact.interactions.length).toBeGreaterThanOrEqual(2)
240
+ for (const ix of pact.interactions) {
241
+ expect(ix).toHaveProperty("description")
242
+ expect(ix).toHaveProperty("request")
243
+ expect(ix).toHaveProperty("response")
244
+ expect(ix.response.status).toBeLessThan(500)
245
+ }
246
+ }),
247
+ )
248
+ })
249
+ })