|
1 | 1 | import { describe, expect, it, vi } from 'vitest' |
2 | 2 | import { chat, createChatOptions } from '../src/activities/chat/index' |
| 3 | +import { DISCOVERY_TOOL_NAME } from '../src/activities/chat/tools/lazy-tool-manager' |
3 | 4 | import { EventType } from '../src/types' |
4 | 5 | import type { StreamChunk, Tool } from '../src/types' |
5 | 6 | import { |
@@ -1767,6 +1768,158 @@ describe('chat()', () => { |
1767 | 1768 | expect(toolNames).not.toContain('__lazy__tool__discovery__') |
1768 | 1769 | expect(toolNames).toContain('normalTool') |
1769 | 1770 | }) |
| 1771 | + |
| 1772 | + it('should not error when the model re-requests discovery after all lazy tools are discovered (#788)', async () => { |
| 1773 | + const weatherExecute = vi.fn().mockReturnValue({ temp: 72 }) |
| 1774 | + const toolNamesPerCall: Array<Array<string>> = [] |
| 1775 | + |
| 1776 | + let callCount = 0 |
| 1777 | + const { adapter } = createMockAdapter({ |
| 1778 | + chatStreamFn: (opts: any) => { |
| 1779 | + callCount++ |
| 1780 | + toolNamesPerCall.push((opts.tools ?? []).map((t: any) => t.name)) |
| 1781 | + |
| 1782 | + if (callCount === 1) { |
| 1783 | + // Discover the only lazy tool -> all discovered, so the discovery |
| 1784 | + // tool is dropped from the advertised set. |
| 1785 | + return (async function* () { |
| 1786 | + yield ev.runStarted() |
| 1787 | + yield ev.toolStart('c1', DISCOVERY_TOOL_NAME) |
| 1788 | + yield ev.toolArgs( |
| 1789 | + 'c1', |
| 1790 | + JSON.stringify({ toolNames: ['getWeather'] }), |
| 1791 | + ) |
| 1792 | + yield ev.runFinished('tool_calls') |
| 1793 | + })() |
| 1794 | + } else if (callCount === 2) { |
| 1795 | + // Model overlooks that getWeather is already available and asks to |
| 1796 | + // discover it again, even though the discovery tool is no longer |
| 1797 | + // advertised. |
| 1798 | + return (async function* () { |
| 1799 | + yield ev.runStarted() |
| 1800 | + yield ev.toolStart('c2', DISCOVERY_TOOL_NAME) |
| 1801 | + yield ev.toolArgs( |
| 1802 | + 'c2', |
| 1803 | + JSON.stringify({ toolNames: ['getWeather'] }), |
| 1804 | + ) |
| 1805 | + yield ev.runFinished('tool_calls') |
| 1806 | + })() |
| 1807 | + } |
| 1808 | + return (async function* () { |
| 1809 | + yield ev.runStarted() |
| 1810 | + yield ev.textStart() |
| 1811 | + yield ev.textContent('done') |
| 1812 | + yield ev.textEnd() |
| 1813 | + yield ev.runFinished('stop') |
| 1814 | + })() |
| 1815 | + }, |
| 1816 | + }) |
| 1817 | + |
| 1818 | + const stream = chat({ |
| 1819 | + adapter, |
| 1820 | + messages: [{ role: 'user', content: 'Weather?' }], |
| 1821 | + tools: [lazyServerTool('getWeather', weatherExecute)], |
| 1822 | + }) |
| 1823 | + |
| 1824 | + const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>) |
| 1825 | + |
| 1826 | + // The discovery tool was removed from the advertised set on the 2nd call, |
| 1827 | + // proving we fixed execution without re-advertising it. |
| 1828 | + expect(toolNamesPerCall[1]).not.toContain(DISCOVERY_TOOL_NAME) |
| 1829 | + expect(toolNamesPerCall[1]).toContain('getWeather') |
| 1830 | + |
| 1831 | + // Re-requesting discovery must NOT produce an "Unknown tool" error. |
| 1832 | + const toolResults = chunks.filter( |
| 1833 | + (c) => c.type === 'TOOL_CALL_RESULT', |
| 1834 | + ) as Array<any> |
| 1835 | + const unknownToolError = toolResults.find( |
| 1836 | + (c: any) => |
| 1837 | + typeof c.content === 'string' && c.content.includes('Unknown tool'), |
| 1838 | + ) |
| 1839 | + expect(unknownToolError).toBeUndefined() |
| 1840 | + |
| 1841 | + // The run progressed past the re-discovery turn to the final answer. |
| 1842 | + const text = chunks |
| 1843 | + .filter((c) => c.type === 'TEXT_MESSAGE_CONTENT') |
| 1844 | + .map((c: any) => c.delta) |
| 1845 | + .join('') |
| 1846 | + expect(text).toContain('done') |
| 1847 | + }) |
| 1848 | + |
| 1849 | + it('should handle a discovery call batched with an already-available tool in one turn', async () => { |
| 1850 | + const lazyAExecute = vi.fn().mockReturnValue({ a: 1 }) |
| 1851 | + const lazyBExecute = vi.fn().mockReturnValue({ b: 2 }) |
| 1852 | + const toolNamesPerCall: Array<Array<string>> = [] |
| 1853 | + |
| 1854 | + let callCount = 0 |
| 1855 | + const { adapter } = createMockAdapter({ |
| 1856 | + chatStreamFn: (opts: any) => { |
| 1857 | + callCount++ |
| 1858 | + toolNamesPerCall.push((opts.tools ?? []).map((t: any) => t.name)) |
| 1859 | + |
| 1860 | + if (callCount === 1) { |
| 1861 | + // Discover lazyA only (lazyB stays undiscovered). |
| 1862 | + return (async function* () { |
| 1863 | + yield ev.runStarted() |
| 1864 | + yield ev.toolStart('d1', DISCOVERY_TOOL_NAME) |
| 1865 | + yield ev.toolArgs('d1', JSON.stringify({ toolNames: ['lazyA'] })) |
| 1866 | + yield ev.runFinished('tool_calls') |
| 1867 | + })() |
| 1868 | + } else if (callCount === 2) { |
| 1869 | + // One batch: call the already-discovered lazyA AND discover lazyB. |
| 1870 | + return (async function* () { |
| 1871 | + yield ev.runStarted() |
| 1872 | + yield ev.toolStart('a1', 'lazyA') |
| 1873 | + yield ev.toolArgs('a1', '{}') |
| 1874 | + yield ev.toolStart('d2', DISCOVERY_TOOL_NAME) |
| 1875 | + yield ev.toolArgs('d2', JSON.stringify({ toolNames: ['lazyB'] })) |
| 1876 | + yield ev.runFinished('tool_calls') |
| 1877 | + })() |
| 1878 | + } else if (callCount === 3) { |
| 1879 | + // lazyB is now available -> call it. |
| 1880 | + return (async function* () { |
| 1881 | + yield ev.runStarted() |
| 1882 | + yield ev.toolStart('b1', 'lazyB') |
| 1883 | + yield ev.toolArgs('b1', '{}') |
| 1884 | + yield ev.runFinished('tool_calls') |
| 1885 | + })() |
| 1886 | + } |
| 1887 | + return (async function* () { |
| 1888 | + yield ev.runStarted() |
| 1889 | + yield ev.textStart() |
| 1890 | + yield ev.textContent('ok') |
| 1891 | + yield ev.textEnd() |
| 1892 | + yield ev.runFinished('stop') |
| 1893 | + })() |
| 1894 | + }, |
| 1895 | + }) |
| 1896 | + |
| 1897 | + const stream = chat({ |
| 1898 | + adapter, |
| 1899 | + messages: [{ role: 'user', content: 'go' }], |
| 1900 | + tools: [ |
| 1901 | + lazyServerTool('lazyA', lazyAExecute), |
| 1902 | + lazyServerTool('lazyB', lazyBExecute), |
| 1903 | + ], |
| 1904 | + }) |
| 1905 | + |
| 1906 | + const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>) |
| 1907 | + |
| 1908 | + // lazyA executed despite sharing a batch with a discovery call. |
| 1909 | + expect(lazyAExecute).toHaveBeenCalledTimes(1) |
| 1910 | + // lazyB became available after the batched discovery and executed. |
| 1911 | + expect(toolNamesPerCall[2]).toContain('lazyB') |
| 1912 | + expect(lazyBExecute).toHaveBeenCalledTimes(1) |
| 1913 | + |
| 1914 | + const toolResults = chunks.filter( |
| 1915 | + (c) => c.type === 'TOOL_CALL_RESULT', |
| 1916 | + ) as Array<any> |
| 1917 | + const unknownToolError = toolResults.find( |
| 1918 | + (c: any) => |
| 1919 | + typeof c.content === 'string' && c.content.includes('Unknown tool'), |
| 1920 | + ) |
| 1921 | + expect(unknownToolError).toBeUndefined() |
| 1922 | + }) |
1770 | 1923 | }) |
1771 | 1924 |
|
1772 | 1925 | // ========================================================================== |
|
0 commit comments