catalog.test.ts 9.6 KB

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