@mawaru/sdk 0.13.0 → 0.14.1

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,8 +1,8 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { AI_MODELS, aiManifestSchema } from "./ai-manifest.js"
3
- import { envelopeSchemaOf, instructionSchema } from "./port-spec.js"
3
+ import { instructionSchema } from "./port-spec.js"
4
4
 
5
- const envelope = envelopeSchemaOf({ type: "string" })
5
+ const textOutput = { type: "string" }
6
6
 
7
7
  const valid = {
8
8
  name: "返信下書き",
@@ -11,8 +11,8 @@ const valid = {
11
11
  model: "claude-opus-4-8",
12
12
  skills: ["answer-faq"],
13
13
  env: ["ANTHROPIC_API_KEY"],
14
- inputs: { in: {} },
15
- outputs: { main: envelope },
14
+ inputs: { in: { schema: {} } },
15
+ outputs: { main: textOutput },
16
16
  }
17
17
 
18
18
  describe("aiManifestSchema", () => {
@@ -28,8 +28,8 @@ describe("aiManifestSchema", () => {
28
28
  it("name / description / model / skills / env は省略できる(既定値が補われる)", () => {
29
29
  const manifest = aiManifestSchema.parse({
30
30
  prompt: valid.prompt,
31
- inputs: { in: {} },
32
- outputs: { main: envelope },
31
+ inputs: { in: { schema: {} } },
32
+ outputs: { main: textOutput },
33
33
  })
34
34
  expect(manifest.name).toBeUndefined()
35
35
  expect(manifest.model).toBe("claude-opus-5")
@@ -56,90 +56,85 @@ describe("aiManifestSchema", () => {
56
56
  ).toThrow()
57
57
  })
58
58
 
59
- it('inputs は "in" 必須・instruction は任意(kind が固定する2口)', () => {
60
- // in は必須
59
+ it("inputs は key 自由・1件以上(in / instruction の固定構成は廃止)", () => {
60
+ // 0件は拒否
61
61
  expect(() => aiManifestSchema.parse({ ...valid, inputs: {} })).toThrow()
62
+ // key は自由(旧固定の in / instruction 以外も宣言できる)
62
63
  expect(() =>
63
- aiManifestSchema.parse({ ...valid, inputs: { data: {} } }),
64
- ).toThrow()
65
- // 固定ポートの instruction も宣言に載る(ノードの現在の姿を書き出すため)
66
- expect(
67
64
  aiManifestSchema.parse({
68
65
  ...valid,
69
- inputs: { in: {}, instruction: instructionSchema() },
70
- }).inputs.instruction,
71
- ).toEqual(instructionSchema())
72
- // in を2口に分ける前に同期された config.json(in だけ)もそのまま読める
66
+ inputs: { task: { schema: {} }, feedback: { schema: {} } },
67
+ }),
68
+ ).not.toThrow()
69
+ // key はポート key の規則(英小文字始まり・英数字と _)
70
+ for (const key of ["Bad", "1st", "a-b", ""]) {
71
+ expect(() =>
72
+ aiManifestSchema.parse({ ...valid, inputs: { [key]: { schema: {} } } }),
73
+ ).toThrow()
74
+ }
75
+ })
76
+
77
+ it("inputs は { schema, resume } の新形式で宣言でき、resume が読める", () => {
78
+ const parsed = aiManifestSchema.parse({
79
+ ...valid,
80
+ inputs: {
81
+ in: { schema: { type: "object" } },
82
+ instruction: { schema: instructionSchema(), resume: true },
83
+ },
84
+ })
85
+ expect(parsed.inputs.in).toEqual({ schema: { type: "object" } })
86
+ expect(parsed.inputs.instruction?.schema).toEqual(instructionSchema())
87
+ expect(parsed.inputs.instruction?.resume).toBe(true)
88
+ })
89
+
90
+ it("旧形式(値が素の JSON Schema)は { schema: 値 } として読める", () => {
91
+ const legacy = aiManifestSchema.parse({
92
+ ...valid,
93
+ inputs: { in: { type: "object" }, instruction: instructionSchema() },
94
+ })
95
+ expect(legacy.inputs.in).toEqual({ schema: { type: "object" } })
96
+ expect(legacy.inputs.instruction).toEqual({
97
+ schema: instructionSchema(),
98
+ })
99
+ // 未宣言 {} も旧形式として { schema: {} } になる
73
100
  expect(
74
- Object.keys(
75
- aiManifestSchema.parse({ ...valid, inputs: { in: {} } }).inputs,
76
- ),
77
- ).toEqual(["in"])
78
- // kind が固定しない key は宣言できない
79
- expect(() =>
80
- aiManifestSchema.parse({ ...valid, inputs: { in: {}, extra: {} } }),
81
- ).toThrow()
101
+ aiManifestSchema.parse({ ...valid, inputs: { in: {} } }).inputs.in,
102
+ ).toEqual({ schema: {} })
82
103
  })
83
104
 
84
105
  it("outputs は1件以上・key 自由(AI の複数出口=分岐)", () => {
85
106
  expect(() => aiManifestSchema.parse({ ...valid, outputs: {} })).toThrow()
86
107
  expect(() =>
87
- aiManifestSchema.parse({ ...valid, outputs: { reply: envelope } }),
108
+ aiManifestSchema.parse({ ...valid, outputs: { reply: textOutput } }),
88
109
  ).not.toThrow()
89
110
  expect(() =>
90
111
  aiManifestSchema.parse({
91
112
  ...valid,
92
- outputs: { spam: envelope, sales: envelope, support: envelope },
113
+ outputs: { spam: textOutput, sales: textOutput, support: textOutput },
93
114
  }),
94
115
  ).not.toThrow()
95
116
  })
96
117
 
97
- it("outputs は全出口に封筒型 { data, description } を強制する(data は任意の型)", () => {
118
+ it("outputs は任意の JSON Schema を受け付ける(形の強制はしない)", () => {
98
119
  for (const schema of [
99
120
  {}, // any
100
121
  { type: "string" },
101
- { type: "object", properties: { text: { type: "string" } } }, // data キーなし
102
- {
103
- type: "object",
104
- properties: { data: { type: "string" } }, // description なし
105
- required: ["data"],
106
- },
122
+ { type: "object", properties: { text: { type: "string" } } },
123
+ { type: "array", items: { type: "number" } },
107
124
  {
125
+ // かつて強制していた { data, description } もただの object として valid
108
126
  type: "object",
109
127
  properties: {
110
128
  data: { type: "string" },
111
- description: { type: "string" }, // description が optional
129
+ description: { type: "string" },
112
130
  },
113
- required: ["data"],
131
+ required: ["data", "description"],
114
132
  },
115
133
  ]) {
116
134
  expect(() =>
117
135
  aiManifestSchema.parse({ ...valid, outputs: { main: schema } }),
118
- ).toThrow()
119
- // 妥当な出口に混ざっていても、封筒型でない出口があれば弾く
120
- expect(() =>
121
- aiManifestSchema.parse({
122
- ...valid,
123
- outputs: { main: envelope, other: schema },
124
- }),
125
- ).toThrow()
136
+ ).not.toThrow()
126
137
  }
127
- // data が任意の構造(カタログ外の object)でも通る
128
- expect(() =>
129
- aiManifestSchema.parse({
130
- ...valid,
131
- outputs: {
132
- main: envelopeSchemaOf({
133
- type: "object",
134
- properties: {
135
- title: { type: "string" },
136
- count: { type: "number" },
137
- },
138
- required: ["title"],
139
- }),
140
- },
141
- }),
142
- ).not.toThrow()
143
138
  })
144
139
 
145
140
  it("ディレクトリ名として不正な skill 名を拒否する", () => {
@@ -1,6 +1,5 @@
1
1
  import { z } from "zod"
2
- import { jsonSchemaSchema } from "./node.js"
3
- import { isAiEnvelopeSchema, manifestInKeysViolation } from "./port-spec.js"
2
+ import { jsonSchemaSchema, PORT_KEY_PATTERN } from "./node.js"
4
3
 
5
4
  // AI ノードのモデル選択肢(既定は Opus:下書き品質が価値の中心。費用はユーザー持ち)。
6
5
  // エイリアス("opus" 等)は採らずバージョンを固定する:実行のたびに黙って別モデルへ
@@ -15,6 +14,23 @@ export const aiModelSchema = z.enum(AI_MODELS)
15
14
  export type AiModel = z.infer<typeof aiModelSchema>
16
15
  export const DEFAULT_AI_MODEL = "claude-opus-5" satisfies AiModel
17
16
 
17
+ // 入力口の宣言:スキーマ+実行属性。resume: true の口から入力が届いた実行は
18
+ // 同じノードの直近セッションを復元して再開する(backend が ports.resume へ同期して
19
+ // payload 発行時に参照する。ポート型固定の撤去とセッション再開のポート属性化.md)
20
+ export const aiInputDeclSchema = z.object({
21
+ schema: jsonSchemaSchema,
22
+ resume: z.boolean().optional(),
23
+ })
24
+ export type AiInputDecl = z.infer<typeof aiInputDeclSchema>
25
+
26
+ // 旧形式(値が素の JSON Schema)は { schema: 値 } として読む。判別は「schema キーを
27
+ // 持つか」:JSON Schema のトップレベルに "schema" キーは現れない(メタスキーマ参照は
28
+ // "$schema")ので実用上一意。書き戻し(backend の syncNodeConfigs)は常に新形式で書く
29
+ const aiInputValueSchema = z.union([
30
+ aiInputDeclSchema,
31
+ jsonSchemaSchema.transform((schema): AiInputDecl => ({ schema })),
32
+ ])
33
+
18
34
  // nodes/ai/<dir>/config.json(実行repoの構造見直し)。AI ノードの定義(prompt/model/skills/
19
35
  // 入出力スキーマ/env)は repo 側が正で、エディタが取り込んで ports を自動生成する
20
36
  // (実行時の正は ports テーブルのまま。プログラム規約 v2 と同じ関係)。
@@ -42,38 +58,37 @@ export const aiManifestSchema = z
42
58
  // 実行中の経過(発話・ツール呼び出し)を Actions ログへ逐次出すデバッグフラグ。
43
59
  // 通常時は config.json にキーを書かない(インスペクタも false ならキーを消す)
44
60
  verbose: z.boolean().optional(),
45
- inputs: z.record(z.string(), jsonSchemaSchema),
61
+ inputs: z.record(z.string(), aiInputValueSchema),
46
62
  outputs: z.record(z.string(), jsonSchemaSchema),
47
63
  })
48
64
  .superRefine((manifest, ctx) => {
49
- // in の構成は kind が固定する(ai は上流データの in 差し戻しの instruction の2口)。
50
- // ルールの正は port-spec 側で、ここは参照するだけ
51
- const inViolation = manifestInKeysViolation(
52
- "ai",
53
- Object.keys(manifest.inputs),
54
- )
55
- if (inViolation) {
56
- ctx.addIssue({ code: "custom", path: ["inputs"], message: inViolation })
57
- }
58
- // outputs は1件以上・key 自由(複数出口=分岐。AIノードの複数出口(分岐).md)
59
- if (Object.keys(manifest.outputs).length < 1) {
65
+ // inputs は1件以上・key 自由(in / instruction の固定構成は廃止。
66
+ // セッション再開は key ではなく resume 属性で決まる)
67
+ const inKeys = Object.keys(manifest.inputs)
68
+ if (inKeys.length < 1) {
60
69
  ctx.addIssue({
61
70
  code: "custom",
62
- path: ["outputs"],
63
- message: "outputs は1件以上宣言してください",
71
+ path: ["inputs"],
72
+ message: "inputs は1件以上宣言してください",
64
73
  })
65
74
  }
66
- // Human 承認 UI が依存する封筒型 { data: T, description: string } を全出口に強制
67
- // (どの出口も human に接続できる、という AI ノードの不変条件。saveGraph と同じ判定。
68
- // data=T は任意の JSON Schema)
69
- for (const [key, schema] of Object.entries(manifest.outputs)) {
70
- if (!isAiEnvelopeSchema(schema)) {
75
+ for (const key of inKeys) {
76
+ if (!PORT_KEY_PATTERN.test(key)) {
71
77
  ctx.addIssue({
72
78
  code: "custom",
73
- path: ["outputs", key],
74
- message: `outputs.${key} は封筒型 { data, description } で宣言してください(両方必須・description は string)`,
79
+ path: ["inputs", key],
80
+ message: `inputs の key が不正です: ${key}(英小文字始まり・英数字と _ のみ)`,
75
81
  })
76
82
  }
77
83
  }
84
+ // outputs は1件以上・key 自由(複数出口=分岐。AIノードの複数出口(分岐).md)。
85
+ // スキーマは任意の JSON Schema(形の強制はしない)
86
+ if (Object.keys(manifest.outputs).length < 1) {
87
+ ctx.addIssue({
88
+ code: "custom",
89
+ path: ["outputs"],
90
+ message: "outputs は1件以上宣言してください",
91
+ })
92
+ }
78
93
  })
79
94
  export type AiManifest = z.infer<typeof aiManifestSchema>
@@ -110,10 +110,20 @@ describe("analyzeExtractSelection(境界の分類と V1 制約)", () => {
110
110
  expect(ok.ok).toBe(true)
111
111
  })
112
112
 
113
- it("空・1ノードのみ・start/end 含みは拒否する", () => {
113
+ it("1ノード選択:そのノードが入口、その out が出口になる", () => {
114
+ const { a, b, c, nodes, connections } = chain()
115
+ const result = analyzeExtractSelection(nodes, connections, [b.id])
116
+ if (!result.ok) throw new Error(result.reason)
117
+ expect(result.entryNodeId).toBe(b.id)
118
+ expect(result.exitPortId).toBe(outOf(b).id)
119
+ expect(result.internal).toEqual([])
120
+ expect(result.inbound).toEqual([connect(a, b)])
121
+ expect(result.outbound).toEqual([connect(b, c)])
122
+ })
123
+
124
+ it("空・start/end 含みは拒否する", () => {
114
125
  const { b, nodes, connections } = chain()
115
126
  expect(analyzeExtractSelection(nodes, connections, []).ok).toBe(false)
116
- expect(analyzeExtractSelection(nodes, connections, [b.id]).ok).toBe(false)
117
127
 
118
128
  const start = makeNode("start", "開始")
119
129
  const withStart = [...nodes, start]
@@ -217,4 +227,43 @@ describe("buildExtractPlan(子グラフと親置換の生成)", () => {
217
227
  void b
218
228
  void c
219
229
  })
230
+
231
+ it("1ノード選択:start.main → ノード → end.in と配線され、親では component ノードに置き換わる", () => {
232
+ const { a, b, c, nodes, connections } = chain()
233
+ const childStartOutPortId = uuid()
234
+ const childEndInPortId = uuid()
235
+ const plan = buildExtractPlan({
236
+ nodes,
237
+ connections,
238
+ selectedIds: [b.id],
239
+ name: "待つだけ",
240
+ componentLoopId: uuid(),
241
+ childStartOutPortId,
242
+ childEndInPortId,
243
+ newId: uuid,
244
+ })
245
+ if (!plan.ok) throw new Error(plan.reason)
246
+ expect(plan.childNodes).toHaveLength(1)
247
+ const [nb] = plan.childNodes
248
+ if (!nb) throw new Error("コピーがない")
249
+ expect(nb.wait).toEqual({ wait_type: "duration", duration_seconds: 90 })
250
+ expect(plan.childConnections).toContainEqual({
251
+ from_port_id: childStartOutPortId,
252
+ to_port_id: inOf(nb).id,
253
+ })
254
+ expect(plan.childConnections).toContainEqual({
255
+ from_port_id: outOf(nb).id,
256
+ to_port_id: childEndInPortId,
257
+ })
258
+ // 親:B が component ノードに置き換わり、A.main → group.in/group.main → C.in
259
+ expect(plan.parentNodes.some((n) => n.id === b.id)).toBe(false)
260
+ expect(plan.parentConnections).toContainEqual({
261
+ from_port_id: outOf(a).id,
262
+ to_port_id: inOf(plan.groupNode).id,
263
+ })
264
+ expect(plan.parentConnections).toContainEqual({
265
+ from_port_id: outOf(plan.groupNode).id,
266
+ to_port_id: inOf(c).id,
267
+ })
268
+ })
220
269
  })
@@ -26,8 +26,10 @@ export const analyzeExtractSelection = (
26
26
  selectedIds: readonly string[],
27
27
  ): ExtractAnalysis => {
28
28
  const selected = new Set(selectedIds)
29
- if (selected.size < 2) {
30
- return { ok: false, reason: "2つ以上のノードを選択してください" }
29
+ // 1ノードからコンポーネント化できる(グループ化の「2つ以上」はエディタ側の
30
+ // 有効条件。コンポーネントの設定値(refs)と1ノードのコンポーネント化.md)
31
+ if (selected.size < 1) {
32
+ return { ok: false, reason: "ノードを選択してください" }
31
33
  }
32
34
  const selectedNodes = nodes.filter((n) => selected.has(n.id))
33
35
  const flow = selectedNodes.find((n) => n.kind === "start" || n.kind === "end")
@@ -8,7 +8,7 @@ import {
8
8
  waitConfigSchema,
9
9
  } from "./graph.js"
10
10
  import type { GraphPort } from "./node.js"
11
- import { envelopeSchemaOf, resolvePortSchemas } from "./port-spec.js"
11
+ import { resolvePortSchemas } from "./port-spec.js"
12
12
 
13
13
  // テスト用の決定的な uuid(common は node 型定義を持たないため crypto は使わない)
14
14
  let seq = 0
@@ -89,15 +89,14 @@ const port = (io: "in" | "out", key: string): GraphPort => ({
89
89
  schema: { type: "object" },
90
90
  })
91
91
 
92
- // ai の out 宣言は封筒型 { data, description } 固定なので、デフォルトノードは
93
- // data=テキストの封筒を持つ
94
- const textEnvelope = envelopeSchemaOf({ type: "string" })
92
+ // ai の out も自由な宣言型。デフォルトノードはテキスト(string)を宣言する
93
+ const textOutput = { type: "string" }
95
94
 
96
95
  // 既定は ai ノード。ai の in は 上流データの in + 差し戻しの instruction の2口
97
96
  const aiPorts = (): GraphPort[] => [
98
97
  port("in", "in"),
99
98
  port("in", "instruction"),
100
- { ...port("out", "main"), schema: textEnvelope },
99
+ { ...port("out", "main"), schema: textOutput },
101
100
  ]
102
101
 
103
102
  // ai 以外に流用するとき用(in は1つ)
@@ -388,17 +387,27 @@ describe("saveGraphBodySchema", () => {
388
387
  ).toThrow(/key が重複/)
389
388
  })
390
389
 
391
- it("in ポートがちょうど1つでない node を拒否する(0個・2個)", () => {
390
+ it("in ポートの口数:ai は1つ以上(自由)、program 等は ちょうど1つ", () => {
391
+ // ai:0個は拒否、複数は許可(in の追加・削除は自由)
392
392
  const zero = makeNode([port("out", "main")])
393
393
  expect(() =>
394
394
  saveGraphBodySchema.parse({ nodes: [zero], connections: [] }),
395
395
  ).toThrow(/in ポート/)
396
-
397
- const two = makeNode([
396
+ const many = makeNode([
398
397
  port("in", "in"),
399
- port("in", "in2"),
398
+ port("in", "feedback"),
399
+ port("in", "extra"),
400
400
  port("out", "main"),
401
401
  ])
402
+ expect(() =>
403
+ saveGraphBodySchema.parse({ nodes: [many], connections: [] }),
404
+ ).not.toThrow()
405
+
406
+ // program:ちょうど1つ
407
+ const two = {
408
+ ...makeNode([port("in", "in"), port("in", "in2"), port("out", "main")]),
409
+ kind: "program" as const,
410
+ }
402
411
  expect(() =>
403
412
  saveGraphBodySchema.parse({ nodes: [two], connections: [] }),
404
413
  ).toThrow(/in ポート/)
@@ -497,8 +506,6 @@ describe("saveGraphBodySchema", () => {
497
506
 
498
507
  // ---- kind 別ポート構成と型の接続バリデーション(ポートのkind別仕様.md) ----
499
508
 
500
- const aiEnvelope = textEnvelope
501
-
502
509
  const humanNode = () => ({
503
510
  ...makeNode([
504
511
  port("in", "in"),
@@ -511,7 +518,7 @@ const aiNode = () => ({
511
518
  ...makeNode([
512
519
  port("in", "in"),
513
520
  port("in", "instruction"),
514
- { ...port("out", "main"), schema: aiEnvelope },
521
+ { ...port("out", "main"), schema: textOutput },
515
522
  ]),
516
523
  kind: "ai" as const,
517
524
  })
@@ -549,27 +556,27 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
549
556
  ...makeNode([
550
557
  port("in", "in"),
551
558
  port("in", "instruction"),
552
- { ...port("out", "spam"), schema: aiEnvelope },
553
- { ...port("out", "sales"), schema: aiEnvelope },
554
- { ...port("out", "support"), schema: aiEnvelope },
559
+ { ...port("out", "spam"), schema: textOutput },
560
+ { ...port("out", "sales"), schema: textOutput },
561
+ { ...port("out", "support"), schema: textOutput },
555
562
  ]),
556
563
  kind: "ai" as const,
557
564
  }
558
565
  expect(() =>
559
566
  saveGraphBodySchema.parse({ nodes: [multi], connections: [] }),
560
567
  ).not.toThrow()
561
- // in の構成は in / instruction 固定のまま
562
- const badIn = {
568
+ // in key も自由(in / instruction の固定構成は廃止)
569
+ const freeIn = {
563
570
  ...makeNode([
564
571
  port("in", "input"),
565
- port("in", "instruction"),
566
- { ...port("out", "main"), schema: aiEnvelope },
572
+ port("in", "feedback"),
573
+ { ...port("out", "main"), schema: textOutput },
567
574
  ]),
568
575
  kind: "ai" as const,
569
576
  }
570
577
  expect(() =>
571
- saveGraphBodySchema.parse({ nodes: [badIn], connections: [] }),
572
- ).toThrow(/ai の in ポート/)
578
+ saveGraphBodySchema.parse({ nodes: [freeIn], connections: [] }),
579
+ ).not.toThrow()
573
580
  })
574
581
 
575
582
  it("program は out を複数持てる(key 自由)", () => {
@@ -586,49 +593,38 @@ describe("saveGraphBodySchema(kind 別ポート構成)", () => {
586
593
  ).not.toThrow()
587
594
  })
588
595
 
589
- it("ai の出力宣言が封筒型 { data, description } でないと拒否する(全出口に適用)", () => {
590
- const bad = makeNode([
591
- port("in", "in"),
592
- port("in", "instruction"),
593
- { ...port("out", "main"), schema: { type: "object" } },
594
- ])
595
- expect(() =>
596
- saveGraphBodySchema.parse({ nodes: [bad], connections: [] }),
597
- ).toThrow(/封筒型 \{ data, description \}/)
598
- // main 以外の出口も封筒型を強制する
599
- const badExtra = makeNode([
600
- port("in", "in"),
601
- port("in", "instruction"),
602
- { ...port("out", "main"), schema: aiEnvelope },
603
- { ...port("out", "extra"), schema: { type: "object" } },
604
- ])
605
- expect(() =>
606
- saveGraphBodySchema.parse({ nodes: [badExtra], connections: [] }),
607
- ).toThrow(/封筒型 \{ data, description \}/)
608
- // data は任意の型でよい(カタログ外の object も通る)
609
- const custom = makeNode([
610
- port("in", "in"),
611
- port("in", "instruction"),
596
+ it("ai の出力宣言は任意の JSON Schema でよい(形の強制はしない)", () => {
597
+ for (const schema of [
598
+ { type: "object" },
599
+ { type: "string" },
600
+ { type: "array", items: { type: "number" } },
612
601
  {
613
- ...port("out", "main"),
614
- schema: envelopeSchemaOf({
615
- type: "object",
616
- properties: { title: { type: "string" } },
617
- required: ["title"],
618
- }),
602
+ // かつて強制していた { data, description } もただの object として valid
603
+ type: "object",
604
+ properties: {
605
+ data: { type: "string" },
606
+ description: { type: "string" },
607
+ },
608
+ required: ["data", "description"],
619
609
  },
620
- ])
621
- expect(() =>
622
- saveGraphBodySchema.parse({ nodes: [custom], connections: [] }),
623
- ).not.toThrow()
610
+ ]) {
611
+ const node = makeNode([
612
+ port("in", "in"),
613
+ port("in", "instruction"),
614
+ { ...port("out", "main"), schema },
615
+ ])
616
+ expect(() =>
617
+ saveGraphBodySchema.parse({ nodes: [node], connections: [] }),
618
+ ).not.toThrow()
619
+ }
624
620
  })
625
621
  })
626
622
 
627
623
  describe("saveGraphBodySchema(型の接続バリデーション)", () => {
628
- // 接続の可否は kind ではなく型で決まる(reject instruction は両端とも
629
- // instructionSchema() で固定されているので、型検証だけで組み合わせが決まる)
630
- // → docs/tasks/wip/Humanの入出力を承認ビューの宣言に一本化する.md
631
- it("human の reject → ai の instruction は型が一致するので許可", () => {
624
+ // 接続の可否は kind ではなく型で決まる。reject / instruction の型固定は廃止され、
625
+ // ai の in はどの口も接続鏡映(接続元の型に同期)なので接続は自明に通る
626
+ // → docs/tasks/wip/ポート型固定の撤去とセッション再開のポート属性化.md
627
+ it("human の reject → ai の instruction を許可(kind の縛りも型固定も無い)", () => {
632
628
  const human = humanNode()
633
629
  const ai = aiNode()
634
630
  const rejectPort = outByKey(human, "reject")
@@ -645,7 +641,7 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
645
641
  ).not.toThrow()
646
642
  })
647
643
 
648
- it("reject は ai 以外へも繋げる(接続鏡映の in が指示型を採用する)", () => {
644
+ it("reject は ai 以外へも繋げる(接続鏡映の in が接続元の型を採用する)", () => {
649
645
  const human = humanNode()
650
646
  const prog = programNode()
651
647
  expect(() =>
@@ -661,10 +657,11 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
661
657
  ).not.toThrow()
662
658
  })
663
659
 
664
- it("instruction ポートには型の合わない出力を繋げない(kind ではなく型で弾く)", () => {
660
+ it("instruction も他の in と同じ接続鏡映:宣言と違う型の out でも同期して繋がる", () => {
665
661
  const upstream = aiNode()
666
662
  const downstream = aiNode()
667
- // ai.main は封筒型 { data, description }、instruction { prompt, files }
663
+ // ai.main string、instruction の宣言は { type: "object" } だが、
664
+ // 鏡映で接続元に同期される(instruction の特別扱いは無い)
668
665
  expect(() =>
669
666
  saveGraphBodySchema.parse({
670
667
  nodes: [upstream, downstream],
@@ -675,10 +672,10 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
675
672
  },
676
673
  ],
677
674
  }),
678
- ).toThrow(/適合しません/)
675
+ ).not.toThrow()
679
676
  })
680
677
 
681
- it("human の in はどの型でも接続できる(封筒型の制約は課さない)", () => {
678
+ it("human の in はどの型でも接続できる(形の制約は課さない)", () => {
682
679
  const ai = aiNode()
683
680
  const human = humanNode()
684
681
  expect(() =>
@@ -688,8 +685,7 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
688
685
  }),
689
686
  ).not.toThrow()
690
687
 
691
- // program out が封筒型でない({ type: "object" } のまま)でも接続できる
692
- // (input 全体を data として表示する)
688
+ // program out が素の object でも接続できる
693
689
  const human2 = humanNode()
694
690
  const progPlain = {
695
691
  ...makeNode([
@@ -707,7 +703,8 @@ describe("saveGraphBodySchema(型の接続バリデーション)", () => {
707
703
  })
708
704
 
709
705
  it("program の in への接続は 伝播型 ⊆ 宣言スキーマ(human 経由の伝播込み)", () => {
710
- // ai(封筒型) → human(approve は data 型に剥がれる) program(string を要求) は通る
706
+ // ai → humanapprove は in と同型)→ program(string を要求) は、
707
+ // program の in が接続鏡映(sticky)なので通る
711
708
  const ai = aiNode()
712
709
  const human = humanNode()
713
710
  const prog = programNode({ type: "string" })
@@ -1027,6 +1024,30 @@ describe("saveGraphBodySchema:コンポーネントノード(component kind
1027
1024
  ).not.toThrow()
1028
1025
  })
1029
1026
 
1027
+ it("component.refs(配置の設定値)を受け付けて保持する(省略も可)", () => {
1028
+ const withRefs = {
1029
+ ...componentNode(),
1030
+ component: {
1031
+ component_loop_id: uuid(),
1032
+ refs: { labels: ["bug"], assignee: "kazuwombat" },
1033
+ },
1034
+ }
1035
+ const parsed = saveGraphBodySchema.parse({
1036
+ nodes: [withRefs],
1037
+ connections: [],
1038
+ })
1039
+ expect(parsed.nodes[0]?.component?.refs).toEqual({
1040
+ labels: ["bug"],
1041
+ assignee: "kazuwombat",
1042
+ })
1043
+ // 省略時は undefined のまま(既存グラフの互換。保存側で {} 扱い)
1044
+ const omitted = saveGraphBodySchema.parse({
1045
+ nodes: [componentNode()],
1046
+ connections: [],
1047
+ })
1048
+ expect(omitted.nodes[0]?.component?.refs).toBeUndefined()
1049
+ })
1050
+
1030
1051
  it("component_loop_id の無い component ノードを拒否する", () => {
1031
1052
  const node = { ...componentNode(), component: undefined }
1032
1053
  expect(() =>