@mawaru/sdk 0.9.0 → 0.13.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.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { aiManifestSchema } from "./ai-manifest.js"
2
+ import { AI_MODELS, aiManifestSchema } from "./ai-manifest.js"
3
3
  import { envelopeSchemaOf, instructionSchema } from "./port-spec.js"
4
4
 
5
5
  const envelope = envelopeSchemaOf({ type: "string" })
@@ -32,11 +32,18 @@ describe("aiManifestSchema", () => {
32
32
  outputs: { main: envelope },
33
33
  })
34
34
  expect(manifest.name).toBeUndefined()
35
- expect(manifest.model).toBe("claude-opus-4-8")
35
+ expect(manifest.model).toBe("claude-opus-5")
36
36
  expect(manifest.skills).toEqual([])
37
37
  expect(manifest.env).toEqual([])
38
38
  })
39
39
 
40
+ it("model は AI_MODELS の enum(バージョン固定。外の値は拒否)", () => {
41
+ for (const model of AI_MODELS) {
42
+ expect(aiManifestSchema.parse({ ...valid, model }).model).toBe(model)
43
+ }
44
+ expect(() => aiManifestSchema.parse({ ...valid, model: "opus" })).toThrow()
45
+ })
46
+
40
47
  it("prompt は必須・非空(repo に置く時点で完成品)", () => {
41
48
  expect(() => aiManifestSchema.parse({ ...valid, prompt: "" })).toThrow()
42
49
  const { prompt: _prompt, ...rest } = valid
@@ -77,7 +84,7 @@ describe("aiManifestSchema", () => {
77
84
  it("outputs は1件以上・key 自由(AI の複数出口=分岐)", () => {
78
85
  expect(() => aiManifestSchema.parse({ ...valid, outputs: {} })).toThrow()
79
86
  expect(() =>
80
- aiManifestSchema.parse({ ...valid, outputs: { draft: envelope } }),
87
+ aiManifestSchema.parse({ ...valid, outputs: { reply: envelope } }),
81
88
  ).not.toThrow()
82
89
  expect(() =>
83
90
  aiManifestSchema.parse({
@@ -2,15 +2,18 @@ import { z } from "zod"
2
2
  import { jsonSchemaSchema } from "./node.js"
3
3
  import { isAiEnvelopeSchema, manifestInKeysViolation } from "./port-spec.js"
4
4
 
5
- // AI ノードのモデル選択肢(既定は Opus:下書き品質が価値の中心。費用はユーザー持ち)
5
+ // AI ノードのモデル選択肢(既定は Opus:下書き品質が価値の中心。費用はユーザー持ち)。
6
+ // エイリアス("opus" 等)は採らずバージョンを固定する:実行のたびに黙って別モデルへ
7
+ // 乗り換わると品質も費用も再現しないため。新モデルが出たらこの配列を足して SDK を publish する
6
8
  export const AI_MODELS = [
9
+ "claude-opus-5",
7
10
  "claude-opus-4-8",
8
11
  "claude-sonnet-5",
9
12
  "claude-haiku-4-5",
10
13
  ] as const
11
14
  export const aiModelSchema = z.enum(AI_MODELS)
12
15
  export type AiModel = z.infer<typeof aiModelSchema>
13
- export const DEFAULT_AI_MODEL = "claude-opus-4-8" satisfies AiModel
16
+ export const DEFAULT_AI_MODEL = "claude-opus-5" satisfies AiModel
14
17
 
15
18
  // nodes/ai/<dir>/config.json(実行repoの構造見直し)。AI ノードの定義(prompt/model/skills/
16
19
  // 入出力スキーマ/env)は repo 側が正で、エディタが取り込んで ports を自動生成する
@@ -0,0 +1,235 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { duplicateSelection } from "./duplicate-selection.js"
3
+ import type { GraphNode } from "./graph.js"
4
+ import { RUN_STARTER } from "./graph.js"
5
+ import type { GraphPort } from "./node.js"
6
+
7
+ // テスト用の決定的な uuid(extract-component.test.ts と同じ流儀)
8
+ let seq = 0
9
+ const uuid = () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`
10
+
11
+ const port = (io: "in" | "out", key: string): GraphPort => ({
12
+ id: uuid(),
13
+ io,
14
+ key,
15
+ name: key,
16
+ schema: { type: "object" },
17
+ })
18
+
19
+ const makeNode = (
20
+ kind: GraphNode["kind"],
21
+ name: string,
22
+ x = 0,
23
+ y = 0,
24
+ ): GraphNode => ({
25
+ id: uuid(),
26
+ kind,
27
+ name,
28
+ ports: [port("in", "in"), port("out", "main")],
29
+ hooks: [],
30
+ position_x: x,
31
+ position_y: y,
32
+ ...(kind === "wait"
33
+ ? { wait: { wait_type: "duration" as const, duration_seconds: 90 } }
34
+ : {}),
35
+ })
36
+
37
+ const inOf = (n: GraphNode) => {
38
+ const p = n.ports.find((p) => p.io === "in")
39
+ if (!p) throw new Error("in がない")
40
+ return p
41
+ }
42
+ const outOf = (n: GraphNode, key = "main") => {
43
+ const p = n.ports.find((p) => p.io === "out" && p.key === key)
44
+ if (!p) throw new Error("out がない")
45
+ return p
46
+ }
47
+ const connect = (from: GraphNode, to: GraphNode) => ({
48
+ from_port_id: outOf(from).id,
49
+ to_port_id: inOf(to).id,
50
+ })
51
+
52
+ // 直列 A → B → C → D(B, C を選択するのが基本ケース)
53
+ const chain = () => {
54
+ const a = makeNode("program", "A", 0, 0)
55
+ const b = makeNode("wait", "B", 100, 0)
56
+ const c = makeNode("program", "C", 200, 100)
57
+ const d = makeNode("program", "D", 300, 0)
58
+ const nodes = [a, b, c, d]
59
+ const connections = [connect(a, b), connect(b, c), connect(c, d)]
60
+ return { a, b, c, d, nodes, connections }
61
+ }
62
+
63
+ const dup = (
64
+ nodes: GraphNode[],
65
+ connections: { from_port_id: string; to_port_id: string }[],
66
+ selectedIds: string[],
67
+ dx = 0,
68
+ dy = 0,
69
+ ) =>
70
+ duplicateSelection({
71
+ nodes,
72
+ connections,
73
+ selectedIds,
74
+ dx,
75
+ dy,
76
+ newId: uuid,
77
+ })
78
+
79
+ describe("duplicateSelection(選択ノードの複製)", () => {
80
+ it("ノード id・ポート id を全て新規に採番する", () => {
81
+ const { b, c, nodes, connections } = chain()
82
+ const result = dup(nodes, connections, [b.id, c.id])
83
+
84
+ expect(result.nodes).toHaveLength(2)
85
+ const oldNodeIds = new Set([b.id, c.id])
86
+ const oldPortIds = new Set([...b.ports, ...c.ports].map((p) => p.id))
87
+ for (const n of result.nodes) {
88
+ expect(oldNodeIds.has(n.id)).toBe(false)
89
+ for (const p of n.ports) expect(oldPortIds.has(p.id)).toBe(false)
90
+ }
91
+ // ポート id はグラフ全体で一意(複製内でも重複しない)
92
+ const newPortIds = result.nodes.flatMap((n) => n.ports.map((p) => p.id))
93
+ expect(new Set(newPortIds).size).toBe(newPortIds.length)
94
+ })
95
+
96
+ it("元のノード・接続を変更しない", () => {
97
+ const { b, c, nodes, connections } = chain()
98
+ const snapshot = structuredClone({ nodes, connections })
99
+ dup(nodes, connections, [b.id, c.id], 40, 40)
100
+ expect({ nodes, connections }).toEqual(snapshot)
101
+ })
102
+
103
+ it("選択内で完結する接続だけを新しいポート id へ張り替えてコピーする", () => {
104
+ const { b, c, nodes, connections } = chain()
105
+ const result = dup(nodes, connections, [b.id, c.id])
106
+
107
+ // A→B(入口)と C→D(出口)は落ち、B→C だけが残る
108
+ expect(result.connections).toHaveLength(1)
109
+ const newB = result.nodes.find((n) => n.name === "B")
110
+ const newC = result.nodes.find((n) => n.name === "C")
111
+ if (!newB || !newC) throw new Error("複製ノードがない")
112
+ expect(result.connections[0]).toEqual({
113
+ from_port_id: outOf(newB).id,
114
+ to_port_id: inOf(newC).id,
115
+ })
116
+ })
117
+
118
+ it("同じ out ポートからの fan-out も両方コピーされる", () => {
119
+ const a = makeNode("program", "A", 0, 0)
120
+ const b = makeNode("program", "B", 100, 0)
121
+ const c = makeNode("program", "C", 100, 100)
122
+ const nodes = [a, b, c]
123
+ const connections = [connect(a, b), connect(a, c)]
124
+ const result = dup(nodes, connections, [a.id, b.id, c.id])
125
+
126
+ expect(result.nodes).toHaveLength(3)
127
+ expect(result.connections).toHaveLength(2)
128
+ const newA = result.nodes.find((n) => n.name === "A")
129
+ if (!newA) throw new Error("複製ノードがない")
130
+ for (const conn of result.connections) {
131
+ expect(conn.from_port_id).toBe(outOf(newA).id)
132
+ }
133
+ })
134
+
135
+ it("start / end は選択に含まれていても複製しない", () => {
136
+ const start = makeNode("start", "開始", 0, 0)
137
+ const b = makeNode("program", "B", 100, 0)
138
+ const end = makeNode("end", "終了", 200, 0)
139
+ const nodes = [start, b, end]
140
+ const connections = [connect(start, b), connect(b, end)]
141
+ const result = dup(nodes, connections, [start.id, b.id, end.id])
142
+
143
+ expect(result.nodes.map((n) => n.name)).toEqual(["B"])
144
+ // start / end 側の線は「選択内で完結」しないので落ちる
145
+ expect(result.connections).toEqual([])
146
+ })
147
+
148
+ it("選択が空・start/end のみのときは空の結果を返す", () => {
149
+ const start = makeNode("start", "開始", 0, 0)
150
+ const end = makeNode("end", "終了", 200, 0)
151
+ const nodes = [start, end]
152
+ const connections = [connect(start, end)]
153
+
154
+ expect(dup(nodes, connections, [])).toEqual({ nodes: [], connections: [] })
155
+ expect(dup(nodes, connections, [start.id, end.id])).toEqual({
156
+ nodes: [],
157
+ connections: [],
158
+ })
159
+ })
160
+
161
+ it("kind 別設定・hooks・ポートの属性を引き継ぐ", () => {
162
+ const wait = makeNode("wait", "待つ", 0, 0)
163
+ const human: GraphNode = {
164
+ ...makeNode("human", "承認", 100, 0),
165
+ ports: [
166
+ { ...port("in", "in"), side: "left", offset: 0.25 },
167
+ {
168
+ ...port("out", "approve"),
169
+ name: "承認",
170
+ schema: { type: "string" },
171
+ side: "right",
172
+ offset: 0.75,
173
+ },
174
+ port("out", "reject"),
175
+ ],
176
+ human: {
177
+ assignment: {
178
+ strategy: "fixed",
179
+ approval_mode: "any",
180
+ units: [[RUN_STARTER]],
181
+ },
182
+ },
183
+ }
184
+ const program: GraphNode = {
185
+ ...makeNode("program", "実行", 200, 0),
186
+ program: { program_dir: "sendReply" },
187
+ hooks: [
188
+ {
189
+ hook_dir: "notify",
190
+ on_input: false,
191
+ on_output: true,
192
+ on_signal: false,
193
+ },
194
+ ],
195
+ }
196
+ const component: GraphNode = {
197
+ ...makeNode("component", "部品", 300, 0),
198
+ component: { component_loop_id: uuid() },
199
+ }
200
+ const nodes = [wait, human, program, component]
201
+ const result = dup(nodes, [], [wait.id, human.id, program.id, component.id])
202
+
203
+ const byName = new Map(result.nodes.map((n) => [n.name, n]))
204
+ expect(byName.get("待つ")?.wait).toEqual(wait.wait)
205
+ expect(byName.get("承認")?.human).toEqual(human.human)
206
+ expect(byName.get("実行")?.program).toEqual(program.program)
207
+ expect(byName.get("実行")?.hooks).toEqual(program.hooks)
208
+ // component 参照はそのまま(同じ子 loop を指す2つ目の配置)
209
+ expect(byName.get("部品")?.component).toEqual(component.component)
210
+
211
+ // ポートの io / key / name / schema / side / offset が保たれる
212
+ const newHuman = byName.get("承認")
213
+ if (!newHuman) throw new Error("複製ノードがない")
214
+ expect(newHuman.ports.map(({ id: _id, ...rest }) => rest)).toEqual(
215
+ human.ports.map(({ id: _id, ...rest }) => rest),
216
+ )
217
+ })
218
+
219
+ it("dx / dy を全ノードへ一律に適用し、相対配置を保つ", () => {
220
+ const { b, c, nodes, connections } = chain()
221
+ const result = dup(nodes, connections, [b.id, c.id], 40, -20)
222
+
223
+ const byName = new Map(result.nodes.map((n) => [n.name, n]))
224
+ expect(byName.get("B")?.position_x).toBe(b.position_x + 40)
225
+ expect(byName.get("B")?.position_y).toBe(b.position_y - 20)
226
+ expect(byName.get("C")?.position_x).toBe(c.position_x + 40)
227
+ expect(byName.get("C")?.position_y).toBe(c.position_y - 20)
228
+ })
229
+
230
+ it("元グラフでの並び順を保って返す(選択の指定順には依存しない)", () => {
231
+ const { b, c, nodes, connections } = chain()
232
+ const result = dup(nodes, connections, [c.id, b.id])
233
+ expect(result.nodes.map((n) => n.name)).toEqual(["B", "C"])
234
+ })
235
+ })
@@ -0,0 +1,48 @@
1
+ import type { GraphConnection, GraphNode } from "./graph.js"
2
+
3
+ // 選択ノードのコピー&ペースト・複製(ノードのコピー&ペーストと複数選択.md)。
4
+ // 純関数(id 採番は newId で注入)。エディタとテストの両方から使う。
5
+ //
6
+ // グラフは全量置換 PUT なので、複製は「id を全部採番し直して足す」だけで成立する。
7
+ // ポート数の制約(in は kind ごとに固定・out は1以上)や (io,key) のノード内一意は
8
+ // ノードを丸ごと写すので自動的に保たれる。
9
+
10
+ export const duplicateSelection = (params: {
11
+ nodes: GraphNode[]
12
+ connections: GraphConnection[]
13
+ selectedIds: readonly string[]
14
+ // 貼り付け位置の移動量(world 座標。選択の相対配置はそのまま保つ)
15
+ dx: number
16
+ dy: number
17
+ newId: () => string
18
+ }): { nodes: GraphNode[]; connections: GraphConnection[] } => {
19
+ const { nodes, connections, selectedIds, dx, dy, newId } = params
20
+ const selected = new Set(selectedIds)
21
+
22
+ // start / end は回路の構造ノードなので複製しない(選択に混ざっていても落とす)。
23
+ // 並び順は元グラフのまま(選択の指定順に依存させない)
24
+ const portIdMap = new Map<string, string>()
25
+ const copied = nodes
26
+ .filter((n) => selected.has(n.id) && n.kind !== "start" && n.kind !== "end")
27
+ .map((n) => ({
28
+ ...n,
29
+ id: newId(),
30
+ ports: n.ports.map((p) => {
31
+ const portId = newId()
32
+ portIdMap.set(p.id, portId)
33
+ return { ...p, id: portId }
34
+ }),
35
+ position_x: n.position_x + dx,
36
+ position_y: n.position_y + dy,
37
+ }))
38
+
39
+ // 両端が複製対象のポートである線(=選択内で完結する線)だけを張り替えて写す。
40
+ // 外部との線(入口・出口)はコピーしない
41
+ const copiedConnections = connections.flatMap((c) => {
42
+ const from = portIdMap.get(c.from_port_id)
43
+ const to = portIdMap.get(c.to_port_id)
44
+ return from && to ? [{ from_port_id: from, to_port_id: to }] : []
45
+ })
46
+
47
+ return { nodes: copied, connections: copiedConnections }
48
+ }
@@ -153,8 +153,8 @@ describe("programConfigSchema", () => {
153
153
 
154
154
  describe("aiConfigSchema", () => {
155
155
  it("ai_dir を受け付ける(ref は省略可・日本語可)", () => {
156
- expect(aiConfigSchema.parse({ ai_dir: "draftReply" })).toEqual({
157
- ai_dir: "draftReply",
156
+ expect(aiConfigSchema.parse({ ai_dir: "replyComposer" })).toEqual({
157
+ ai_dir: "replyComposer",
158
158
  })
159
159
  expect(
160
160
  aiConfigSchema.parse({ ai_dir: "返信下書き", ref: "develop" }).ref,
@@ -181,14 +181,12 @@ describe("humanConfigSchema", () => {
181
181
 
182
182
  // 承認ビューは不透明なキー(program_dir / ai_dir と同じ)。実在するか・そのテナントが
183
183
  // 選べるかは mawaru 側のカタログ(非公開)と保存 API が見るので、ここは形だけ
184
- it("承認ビューとそのパラメータを受け付ける", () => {
184
+ it("承認ビューを受け付ける", () => {
185
185
  const parsed = humanConfigSchema.parse({
186
186
  assignment: {},
187
187
  view: "acme/invoice",
188
- view_config: { threshold: 1000 },
189
188
  })
190
189
  expect(parsed.view).toBe("acme/invoice")
191
- expect(parsed.view_config).toEqual({ threshold: 1000 })
192
190
  })
193
191
 
194
192
  it("空のビューキーは拒否する", () => {
@@ -627,12 +625,13 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
627
625
  })
628
626
 
629
627
  describe("saveGraphBodySchema(型の接続バリデーション)", () => {
630
- it("human reject ai の instruction は許可。ai の in や ai 以外は拒否", () => {
628
+ // 接続の可否は kind ではなく型で決まる(reject instruction は両端とも
629
+ // instructionSchema() で固定されているので、型検証だけで組み合わせが決まる)
630
+ // → docs/tasks/wip/Humanの入出力を承認ビューの宣言に一本化する.md
631
+ it("human の reject → ai の instruction は型が一致するので許可", () => {
631
632
  const human = humanNode()
632
633
  const ai = aiNode()
633
- const prog = programNode()
634
634
  const rejectPort = outByKey(human, "reject")
635
- // 差し戻しは指示の口(instruction)へ
636
635
  expect(() =>
637
636
  saveGraphBodySchema.parse({
638
637
  nodes: [human, ai],
@@ -644,40 +643,39 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
644
643
  ],
645
644
  }),
646
645
  ).not.toThrow()
647
- // 上流データの口(in)には繋げない(意味の違う入力を fan-in させない)
648
- expect(() =>
649
- saveGraphBodySchema.parse({
650
- nodes: [human, ai],
651
- connections: [
652
- { from_port_id: rejectPort.id, to_port_id: inByKey(ai, "in").id },
653
- ],
654
- }),
655
- ).toThrow(/instruction/)
656
- // ai 以外にも繋げない
646
+ })
647
+
648
+ it("reject は ai 以外へも繋げる(接続鏡映の in が指示型を採用する)", () => {
649
+ const human = humanNode()
650
+ const prog = programNode()
657
651
  expect(() =>
658
652
  saveGraphBodySchema.parse({
659
653
  nodes: [human, prog],
660
654
  connections: [
661
- { from_port_id: rejectPort.id, to_port_id: inOf(prog).id },
655
+ {
656
+ from_port_id: outByKey(human, "reject").id,
657
+ to_port_id: inOf(prog).id,
658
+ },
662
659
  ],
663
660
  }),
664
- ).toThrow(/instruction/)
661
+ ).not.toThrow()
665
662
  })
666
663
 
667
- it("instruction ポートに繋げるのは human の reject だけ", () => {
668
- const ai = aiNode()
669
- const prog = programNode()
664
+ it("instruction ポートには型の合わない出力を繋げない(kind ではなく型で弾く)", () => {
665
+ const upstream = aiNode()
666
+ const downstream = aiNode()
667
+ // ai.main は封筒型 { data, description }、instruction は { prompt, files }
670
668
  expect(() =>
671
669
  saveGraphBodySchema.parse({
672
- nodes: [prog, ai],
670
+ nodes: [upstream, downstream],
673
671
  connections: [
674
672
  {
675
- from_port_id: outOf(prog).id,
676
- to_port_id: inByKey(ai, "instruction").id,
673
+ from_port_id: outOf(upstream).id,
674
+ to_port_id: inByKey(downstream, "instruction").id,
677
675
  },
678
676
  ],
679
677
  }),
680
- ).toThrow(/instruction ポートに接続できるのは/)
678
+ ).toThrow(/適合しません/)
681
679
  })
682
680
 
683
681
  it("human の in はどの型でも接続できる(封筒型の制約は課さない)", () => {
@@ -1068,7 +1066,7 @@ describe("saveGraphBodySchema:コンポーネントノード(component kind
1068
1066
  nodes: [prog, comp],
1069
1067
  connections: [connect(prog, comp)],
1070
1068
  }),
1071
- ).toThrow(/入力契約に適合しません/)
1069
+ ).toThrow(/適合しません/)
1072
1070
  // 適合(string → string)なら通る
1073
1071
  const okComp = componentNode({ type: "string" })
1074
1072
  expect(() =>
package/_schemas/graph.ts CHANGED
@@ -148,8 +148,6 @@ export const humanConfigSchema = z.object({
148
148
  // どのキーが実在するか・そのテナントが選べるかは保存時に mawaru 側が検証する
149
149
  // (カタログは非公開。@mawaru/common の approval-view.ts)。省略時は標準ビュー
150
150
  view: z.string().min(1).optional(),
151
- // ビューに渡すパラメータ(列・ラベル・閾値など)。形はビューごとに違うので任意の JSON
152
- view_config: z.unknown().nullish(),
153
151
  assignment: z
154
152
  .object({
155
153
  strategy: z.enum(["fixed", "round_robin"]).default("fixed"),
@@ -482,64 +480,24 @@ export const saveGraphBodySchema = z
482
480
  })
483
481
  }
484
482
 
485
- // human の reject の接続先は AI instruction ポートのみ。
486
- // 差し戻しは「元のセッションに指示を与える」操作で、上流データの口(in)とは
487
- // 意味が違う(docs/tasks/wip/ポートスキーマのany撲滅.md)
488
- if (
489
- fromNode.kind === "human" &&
490
- from.key === "reject" &&
491
- !(toNode.kind === "ai" && to.key === AI_INSTRUCTION_PORT_KEY)
492
- ) {
493
- ctx.addIssue({
494
- code: "custom",
495
- path: ["connections", i, "to_port_id"],
496
- message:
497
- "差し戻し(reject)の接続先は AI ノードの instruction ポートのみです",
498
- })
499
- }
500
-
501
- // instruction ポートに繋げるのは human の reject だけ(データを流し込ませない)
502
- if (
503
- toNode.kind === "ai" &&
504
- to.key === AI_INSTRUCTION_PORT_KEY &&
505
- !(fromNode.kind === "human" && from.key === "reject")
506
- ) {
507
- ctx.addIssue({
508
- code: "custom",
509
- path: ["connections", i, "to_port_id"],
510
- message:
511
- "instruction ポートに接続できるのは human の差し戻し(reject)だけです",
512
- })
513
- }
514
-
483
+ // 差し戻し(reject)と instruction の組み合わせは kind で縛らない:両端とも
484
+ // instructionSchema()({ prompt, files })で型が固定されているので、下の型検証だけで
485
+ // 「型が合う口にしか繋がらない」が成立する。差し戻し先が AI である必要はない
486
+ // ({ prompt, files } を受ける program へも繋げる)
487
+
488
+ // 型検証は kind によらず常に 伝播型(out) ⊆ 伝播型(in)。
489
+ // 未宣言は unknown=判断できないので通す(作りかけのグラフを保存できなくしない)。
490
+ // 接続鏡映する口(program / ai の in)とコピー宣言のビューは接続元の型を採用する
491
+ // ので自明に通り、実質の検証対象は宣言で固定された口
492
+ // (component の契約・ai の instruction・宣言のあるビューの human.in)になる
493
+ // docs/tasks/wip/Humanの入出力を承認ビューの宣言に一本化する.md
515
494
  const fromSchema = resolved.get(conn.from_port_id) ?? from.schema
516
-
517
- // human の in はどの型でも接続できる(上流が AI なら封筒 { data, description }、
518
- // それ以外は input 全体を data として表示する。型制約は課さない)
519
-
520
- // program の in への接続は 伝播型 ⊆ 宣言スキーマ。in は接続鏡映(sticky)なので
521
- // 具体型の接続では自明に通り、接続元が未宣言 × 宣言維持のときだけ従来の意味を持つ
522
- // (未宣言は unknown=判断できないので通す。作りかけのグラフを保存できなくしない)
523
495
  const toSchema = resolved.get(conn.to_port_id) ?? to.schema
524
- if (toNode.kind === "program" && !isSchemaSubset(fromSchema, toSchema)) {
496
+ if (!isSchemaSubset(fromSchema, toSchema)) {
525
497
  ctx.addIssue({
526
498
  code: "custom",
527
499
  path: ["connections", i, "to_port_id"],
528
- message:
529
- "接続元の型が program の入力スキーマに適合しません(伝播型 ⊆ 宣言スキーマ)",
530
- })
531
- }
532
-
533
- // component の in は子 loop の契約(焼き込み済み宣言)。program と同じ包含チェック
534
- if (
535
- toNode.kind === "component" &&
536
- !isSchemaSubset(fromSchema, toSchema)
537
- ) {
538
- ctx.addIssue({
539
- code: "custom",
540
- path: ["connections", i, "to_port_id"],
541
- message:
542
- "接続元の型がコンポーネントの入力契約に適合しません(伝播型 ⊆ 契約)",
500
+ message: `「${fromNode.name}」の出力が「${toNode.name}」の入力スキーマに適合しません`,
543
501
  })
544
502
  }
545
503
  })
@@ -153,7 +153,7 @@ describe("kindPortsViolation(kind 別ポート構成)", () => {
153
153
  it("ai は out の key 自由・複数可。in は in / instruction の2口固定(AI の複数出口)", () => {
154
154
  const ins = [port("in", "in"), port("in", "instruction")]
155
155
  expect(kindPortsViolation("ai", [...ins, port("out", "main")])).toBeNull()
156
- expect(kindPortsViolation("ai", [...ins, port("out", "draft")])).toBeNull()
156
+ expect(kindPortsViolation("ai", [...ins, port("out", "reply")])).toBeNull()
157
157
  expect(
158
158
  kindPortsViolation("ai", [
159
159
  ...ins,
@@ -219,7 +219,10 @@ describe("kindPortsViolation(kind 別ポート構成)", () => {
219
219
  describe("型伝播(導出ポートの解決)", () => {
220
220
  const envelope = envelopeSchemaOf({ type: "string" })
221
221
 
222
- it("ai human 直結:approve は封筒を剥がした data 型、reject は指示エンベロープ", () => {
222
+ // human in / approve はビューの宣言をそのまま転写する(上流の kind は見ない)。
223
+ // sdk はカタログを知らないので、呼び出し元が viewPortSchemasOf で解決して渡す
224
+ // → docs/tasks/wip/Humanの入出力を承認ビューの宣言に一本化する.md
225
+ it("ai → human 直結(コピー宣言):in も approve も上流の out と同型、reject は指示エンベロープ", () => {
223
226
  const aiOut = port("out", "main", envelope)
224
227
  const ai = {
225
228
  id: uuid(),
@@ -237,12 +240,99 @@ describe("型伝播(導出ポートの解決)", () => {
237
240
  const resolved = resolvePortSchemas(
238
241
  [ai, human],
239
242
  [{ from_port_id: aiOut.id, to_port_id: humanIn.id }],
243
+ { viewPortSchemasOf: () => ({ in: "T", out: "T" }) },
240
244
  )
245
+ // 上流が AI でも中身を取り出さない:AI の出力形式がそのまま下流へ流れる
241
246
  expect(resolved.get(humanIn.id)).toEqual(envelope)
242
- expect(resolved.get(approve.id)).toEqual({ type: "string" })
247
+ expect(resolved.get(approve.id)).toEqual(envelope)
243
248
  expect(resolved.get(reject.id)).toEqual(instructionSchema())
244
249
  })
245
250
 
251
+ describe("承認ビューの宣言を human のポートへ転写する", () => {
252
+ const viewIn = {
253
+ type: "object",
254
+ properties: {
255
+ data: {
256
+ type: "object",
257
+ properties: { prs: { type: "array", items: { type: "object" } } },
258
+ required: ["prs"],
259
+ },
260
+ description: { type: "string" },
261
+ },
262
+ required: ["data", "description"],
263
+ }
264
+ const viewOut = {
265
+ type: "object",
266
+ properties: { prs: { type: "array", items: { type: "object" } } },
267
+ required: ["prs"],
268
+ }
269
+ const build = (upstreamKind: string, upstreamOut: JsonSchema) => {
270
+ const out = port("out", "main", upstreamOut)
271
+ const upstream = {
272
+ id: uuid(),
273
+ kind: upstreamKind,
274
+ ports: [port("in", "in"), out],
275
+ }
276
+ const humanIn = port("in", "in")
277
+ const approve = port("out", "approve")
278
+ const human = {
279
+ id: uuid(),
280
+ kind: "human",
281
+ ports: [humanIn, approve, port("out", "reject")],
282
+ }
283
+ return { upstream, human, out, humanIn, approve }
284
+ }
285
+
286
+ it("宣言のあるビューは上流の kind に依らず同じ型になる", () => {
287
+ for (const kind of ["ai", "program"]) {
288
+ const { upstream, human, out, humanIn, approve } = build(kind, envelope)
289
+ const resolved = resolvePortSchemas(
290
+ [upstream, human],
291
+ [{ from_port_id: out.id, to_port_id: humanIn.id }],
292
+ { viewPortSchemasOf: () => ({ in: viewIn, out: viewOut }) },
293
+ )
294
+ expect(resolved.get(humanIn.id)).toEqual(viewIn)
295
+ expect(resolved.get(approve.id)).toEqual(viewOut)
296
+ }
297
+ })
298
+
299
+ it("上流の out は書き換えない(逆伝播しない)", () => {
300
+ const { upstream, human, out, humanIn } = build("ai", envelope)
301
+ const resolved = resolvePortSchemas(
302
+ [upstream, human],
303
+ [{ from_port_id: out.id, to_port_id: humanIn.id }],
304
+ { viewPortSchemasOf: () => ({ in: viewIn, out: viewOut }) },
305
+ )
306
+ expect(resolved.get(out.id)).toEqual(envelope)
307
+ })
308
+
309
+ it("コピー宣言(標準ビュー)は上流の型をそのまま in と approve へ流す", () => {
310
+ const { upstream, human, out, humanIn, approve } = build("ai", envelope)
311
+ const resolved = resolvePortSchemas(
312
+ [upstream, human],
313
+ [{ from_port_id: out.id, to_port_id: humanIn.id }],
314
+ { viewPortSchemasOf: () => ({ in: "T", out: "T" }) },
315
+ )
316
+ expect(resolved.get(humanIn.id)).toEqual(envelope)
317
+ expect(resolved.get(approve.id)).toEqual(envelope)
318
+ })
319
+
320
+ it("未接続でも宣言で固定される", () => {
321
+ const humanIn = port("in", "in")
322
+ const approve = port("out", "approve")
323
+ const human = {
324
+ id: uuid(),
325
+ kind: "human",
326
+ ports: [humanIn, approve, port("out", "reject")],
327
+ }
328
+ const resolved = resolvePortSchemas([human], [], {
329
+ viewPortSchemasOf: () => ({ in: viewIn, out: viewOut }),
330
+ })
331
+ expect(resolved.get(humanIn.id)).toEqual(viewIn)
332
+ expect(resolved.get(approve.id)).toEqual(viewOut)
333
+ })
334
+ })
335
+
246
336
  it("ai → wait → human:素通しで封筒型が伝播し、reject は指示エンベロープのまま", () => {
247
337
  // ai の in は宣言型(nodes/ai/<dir>/config.json の inputs.in 由来。実行repoの構造見直し)
248
338
  const aiInSchema = {