catalog.test.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import { describe, expect, test } from "bun:test"
  2. import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
  3. import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
  4. const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
  5. path,
  6. description,
  7. signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
  8. pinned,
  9. })
  10. const lookup = entry(
  11. "orders.lookup",
  12. "Look up an order by ID",
  13. "tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}>",
  14. )
  15. const render = (entries: ReadonlyArray<CodeModeCatalog.Entry>, budget?: number) =>
  16. CodeModeInstructions.render(CodeModeCatalog.summarize(entries, budget))
  17. const update = (
  18. previous: ReadonlyArray<CodeModeCatalog.Entry>,
  19. current: ReadonlyArray<CodeModeCatalog.Entry>,
  20. budget?: number,
  21. ) =>
  22. CodeModeInstructions.update(CodeModeCatalog.summarize(previous, budget), CodeModeCatalog.summarize(current, budget))
  23. describe("CodeModeCatalog.summarize", () => {
  24. test("retains namespace inventory without retaining tools outside the inline budget", () => {
  25. const catalog = CodeModeCatalog.summarize(
  26. Array.from({ length: 10_000 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`)),
  27. 0,
  28. )
  29. expect(catalog).toEqual({
  30. total: 10_000,
  31. shown: 0,
  32. namespaces: [{ name: "bulk", count: 10_000, entries: [] }],
  33. })
  34. })
  35. test("retains every namespace when no full tool listing fits", () => {
  36. const catalog = CodeModeCatalog.summarize(
  37. [entry("alpha.one", "One"), entry("beta.two", "Two"), entry("gamma.three", "Three")],
  38. 0,
  39. )
  40. expect(catalog.namespaces.map((namespace) => namespace.name)).toEqual(["alpha", "beta", "gamma"])
  41. expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
  42. })
  43. test("always retains pinned tools beyond the inline budget", () => {
  44. const pinned = [
  45. entry("alpha.first", "First", undefined, true),
  46. entry("beta.second", "Second", undefined, true),
  47. ]
  48. const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
  49. expect(catalog.shown).toBe(2)
  50. expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
  51. "alpha.first",
  52. "beta.second",
  53. ])
  54. })
  55. test("spends the budget remaining after pinned tools on unpinned tools", () => {
  56. const pinned = entry("alpha.pinned", "Pinned", undefined, true)
  57. const unpinned = entry("beta.unpinned", "Unpinned")
  58. const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
  59. const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
  60. expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
  61. expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
  62. })
  63. test("retains only the rendered portion of inline descriptions", () => {
  64. const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
  65. expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
  66. })
  67. test("limits inline descriptions to 120 characters", () => {
  68. const catalog = CodeModeCatalog.summarize([entry("alpha.one", "x".repeat(121))])
  69. const description = catalog.namespaces[0]?.entries[0]?.line.split(" // ")[1]
  70. expect(description).toHaveLength(120)
  71. expect(description).toEndWith("...")
  72. })
  73. })
  74. describe("CodeModeInstructions.render", () => {
  75. test("inlines complete catalogs without search guidance", () => {
  76. const instructions = render([lookup])
  77. expect(instructions).toContain("## Available tools")
  78. expect(instructions).toContain("- orders (1 tool)")
  79. expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
  80. expect(instructions).not.toContain("## Search")
  81. expect(instructions).toContain("The Code Mode tool catalog below is complete.")
  82. expect(instructions).toContain("This catalog is the complete set of tools available within Code Mode.")
  83. expect(instructions).not.toContain("surrounding top-level agent tools")
  84. })
  85. test("adds search guidance when the catalog exceeds the budget", () => {
  86. const partial = render([lookup], 0)
  87. expect(partial).toContain("## Available tools")
  88. expect(partial).toContain("- orders (1 tool, none shown)")
  89. expect(partial).toContain("## Search")
  90. expect(partial).toContain("The Code Mode tool catalog below is partial.")
  91. expect(partial).toContain(
  92. "The Code Mode catalog and `search` results are the complete set of tools available within Code Mode.",
  93. )
  94. expect(partial).not.toContain("surrounding top-level agent tools")
  95. expect(partial).toContain("- search(input: {")
  96. expect(partial).toContain(" limit?: number,\n offset?: number,")
  97. expect(partial).not.toContain("tools.orders.lookup(input:")
  98. })
  99. test("budgets signatures round-robin so every namespace remains visible", () => {
  100. const cheapAlpha = entry("alpha.cheap", "Cheap")
  101. const cheapBeta = entry("beta.cheap", "Cheap")
  102. const expensive = entry(
  103. "alpha.expensive",
  104. "Expensive",
  105. `tools.alpha.expensive(input: {\n aVeryLongParameterName: string,\n anotherEvenLongerParameterName: number,\n yetAnotherExtremelyVerboseParameterName: string,\n}): Promise<string>`,
  106. )
  107. // Round 1 places alpha.cheap and beta.cheap; in round 2 alpha.expensive does not fit,
  108. // which marks only alpha done - it must NOT prevent other namespaces from inlining.
  109. const instructions = render([cheapAlpha, expensive, cheapBeta], 40)
  110. expect(instructions).toContain("## Search")
  111. expect(instructions).toContain("- alpha (2 tools, 1 shown)")
  112. expect(instructions).toContain(` - ${cheapAlpha.signature} // Cheap`)
  113. expect(instructions).not.toContain("tools.alpha.expensive(")
  114. expect(instructions).toContain("- beta (1 tool)")
  115. expect(instructions).toContain(` - ${cheapBeta.signature} // Cheap`)
  116. })
  117. test("charges inline JSDoc in signatures against the catalog token budget", () => {
  118. const documented = entry(
  119. "records.lookup",
  120. "Look up a record",
  121. `tools.records.lookup(input: {\n /** ${"A detailed identifier description. ".repeat(20).trim()} */\n id: string,\n}): Promise<string>`,
  122. )
  123. const instructions = render([documented], 40)
  124. expect(instructions).toContain("- records (1 tool, none shown)")
  125. expect(instructions).not.toContain("tools.records.lookup(input:")
  126. })
  127. test("renders only the no-tools notice for an empty catalog", () => {
  128. expect(render([])).toBe(
  129. "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool.",
  130. )
  131. })
  132. })
  133. describe("CodeModeInstructions.update", () => {
  134. const echo = entry("notes.echo", "Echo text")
  135. test("renders additions, changes, and removals as a compact semantic delta", () => {
  136. const changed = { ...echo, signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>" }
  137. const added = entry("notes.list", "List notes")
  138. const unchanged = Array.from({ length: 5 }, (_, index) => entry(`stable.tool${index}`, `Stable ${index}`))
  139. const text = update([echo, lookup, ...unchanged], [changed, added, ...unchanged])
  140. expect(text).toContain("The Code Mode tool catalog has changed.")
  141. expect(text).toContain(`New tools are available in addition to those previously listed:\n - ${added.signature}`)
  142. expect(text).toContain(
  143. `Changed tool listings supersede the previously listed ones:\n - ${changed.signature} // Echo text`,
  144. )
  145. expect(text).toContain("The following tools are no longer available and must not be called: tools.orders.lookup.")
  146. expect(text).not.toContain("## Available tools")
  147. })
  148. test("names removed tools with exact callable expressions including bracket notation", () => {
  149. const dashed = entry("context7.resolve-library-id", "Resolve a library ID")
  150. const text = update([echo, dashed], [echo])
  151. expect(text).toContain(
  152. 'The following tools are no longer available and must not be called: tools.context7["resolve-library-id"].',
  153. )
  154. })
  155. test("restates the full catalog when the rendering mode crosses full and compact", () => {
  156. const wide = Array.from({ length: 40 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
  157. const text = update([echo], [echo, ...wide], 30)
  158. expect(text).toContain(
  159. "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
  160. )
  161. expect(text).toContain("## Search")
  162. expect(text).toContain("## Available tools")
  163. })
  164. test("falls back to full replacement when the delta is larger than the catalog", () => {
  165. const previous = Array.from({ length: 200 }, (_, index) => entry(`bulk.tool${index}`, `Tool ${index}`))
  166. const text = update([...previous, echo], [echo])
  167. expect(text).toContain("This catalog supersedes the previous Code Mode tool catalog.")
  168. expect(text).toContain("## Available tools")
  169. expect(text).not.toContain("## Search")
  170. expect(text).not.toContain("The following tools are no longer available")
  171. })
  172. test("renders namespace-only deltas without persisting hidden tool entries", () => {
  173. const alpha = Array.from({ length: 10 }, (_, index) => entry(`alpha.tool${index}`, `Tool ${index}`))
  174. const text = update(alpha, [...alpha, entry("alpha.tool10", "Tool 10")], 0)
  175. expect(text).toContain("`alpha` now has 11 tools")
  176. expect(text).toContain("search them again before relying on previous results")
  177. expect(text).not.toContain("tools.alpha.tool10(input:")
  178. expect(text).not.toContain("## Available tools")
  179. })
  180. })