Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,15 +230,16 @@ The single canonical command is:
pnpm test:pr
```

This runs the exact target set the `PR` workflow runs in CI (`nx affected --targets=test:sherif,test:knip,test:docs,test:eslint,test:lib,test:types,test:build,build --exclude=examples/**,testing/**`).
This runs the exact target set the `PR` workflow runs in CI: `nx affected --targets=test:sherif,test:knip,test:docs,test:kiira,test:eslint,test:lib,test:types,test:build,build`. There is **no** `--exclude=examples/**,testing/**` carve-out — the example apps and `testing/` packages are included, so Nx runs whatever of these targets they define (in practice `build` and `test:types`). Including them means `test:types` is checked at the call sites where the library is actually consumed, catching call-site type regressions that only manifest there (see issue #820). To type-check just the example apps + `testing/` packages locally, run `nx run-many --targets=test:types --projects=examples/**,testing/**`.

If you can't run `test:pr` (e.g. it's too slow on your machine), at minimum run each of these and confirm they're green before pushing:

- `pnpm test:sherif` — workspace consistency
- `pnpm test:knip` — unused dependencies
- `pnpm test:docs` — doc link verification
- `pnpm test:eslint` — lint
- `pnpm test:types` — typecheck
- `pnpm test:types` — typecheck (packages)
- `nx run-many --targets=test:types --projects=examples/**,testing/**` — typecheck the example apps + `testing/` packages
- `pnpm test:lib` — unit tests
- `pnpm test:build` — build artifact verification
- `pnpm build` — build all affected packages
Expand All @@ -248,7 +249,7 @@ Do **not** rely on CI as your first signal. Run locally, fix, then push.

### Working with Examples

Examples are not built by Nx. To run an example:
Nx type-checks and builds examples as part of `test:pr`/`test:ci` (via their inferred `test:types` and `build` targets). To run an example locally:

```bash
cd examples/ts-react-chat
Expand Down
3 changes: 2 additions & 1 deletion examples/ts-angular-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:types": "node scripts/typecheck.mjs"
},
"dependencies": {
"@angular/common": "^21.2.0",
Expand Down
28 changes: 28 additions & 0 deletions examples/ts-angular-chat/scripts/typecheck.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Type-check this Angular example, including template type-checking.
//
// Angular template type-checking (`strictTemplates`) requires the Angular
// compiler (`ngc`), not plain `tsc`. `ngc` ships in `@angular/compiler-cli`,
// which is a *peer* dependency of `@angular/build` (a direct devDependency of
// this example). We resolve it from there instead of declaring it directly so
// CI's frozen lockfile install stays unchanged.
import { createRequire } from 'node:module'
import { spawnSync } from 'node:child_process'
import { dirname, join } from 'node:path'

const require = createRequire(import.meta.url)

// `@angular/compiler-cli` is resolvable from `@angular/build`'s location.
const buildPkg = require.resolve('@angular/build/package.json')
const cliPkgPath = require.resolve('@angular/compiler-cli/package.json', {
paths: [buildPkg],
})
const cliPkg = require(cliPkgPath)
const ngc = join(dirname(cliPkgPath), cliPkg.bin.ngc)

const { status } = spawnSync(
process.execPath,
[ngc, '-p', 'tsconfig.app.json', '--noEmit'],
{ stdio: 'inherit' },
)

process.exit(status ?? 1)
2 changes: 1 addition & 1 deletion examples/ts-angular-chat/src/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export class AppComponent {

/** A message is worth rendering if it has visible text or a tool call. */
isRenderable(message: {
parts: ReadonlyArray<{ type: string; content?: string }>
parts: ReadonlyArray<{ type: string; content?: unknown }>
}): boolean {
return message.parts.some(
(part) =>
Expand Down
31 changes: 31 additions & 0 deletions examples/ts-code-mode-web/src/lib/tool-result-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { UIMessage } from '@tanstack/ai-react'

type ToolResultPart = Extract<
UIMessage['parts'][number],
{ type: 'tool-result' }
>

/** `string | Array<ContentPart>` — a tool result's raw content. */
type ToolResultContent = ToolResultPart['content']

type ContentPartItem = Exclude<ToolResultContent, string>[number]

/**
* Reduce a tool-result part's `content` to a plain string for rendering.
*
* Tool results carry `string | Array<ContentPart>` (multimodal results are
* normalized to an array of content parts upstream). These demos render tool
* results as plain strings, so array content is flattened to the concatenation
* of its text parts; non-text parts (image, audio, video, document) have no
* string form here and are skipped.
*/
export function toolResultContentToString(content: ToolResultContent): string {
if (typeof content === 'string') return content
return content
.filter(
(part): part is Extract<ContentPartItem, { type: 'text' }> =>
part.type === 'text',
)
.map((part) => part.content)
.join('')
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '@/components/reports/useReportSSE'
import ChatInput from '@/components/ChatInput'
import { Header } from '@/components'
import { toolResultContentToString } from '@/lib/tool-result-content'
import { applyUIEvent, applyUIUpdates } from '@/lib/reports/apply-event'
import type {
RefreshResult,
Expand Down Expand Up @@ -159,7 +160,7 @@ function Messages({ messages }: { messages: Array<UIMessage> }) {
for (const p of message.parts) {
if (p.type === 'tool-result') {
toolResults.set(p.toolCallId, {
content: p.content,
content: toolResultContentToString(p.content),
state: p.state,
error: p.error,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { AnyTextAdapter, ServerTool, StreamChunk } from '@tanstack/ai'
import type { IsolateDriver } from '@tanstack/ai-code-mode'

import { databaseTools, getSchemaInfoTool } from '@/lib/tools/database-tools'
import { maxTokensModelOptions } from '@/lib/max-tokens-model-options'

type Provider = 'anthropic' | 'openai' | 'gemini'

Expand Down Expand Up @@ -284,7 +285,7 @@ export const Route = createFileRoute(
systemPrompts,
agentLoopStrategy: maxIterations(15),
abortController,
maxTokens: 8192,
modelOptions: maxTokensModelOptions(rawAdapter, 8192),
})

const instrumentedStream = wrapWithTimingEvents(stream, rawAdapter)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { VMEvent } from '@/components'
import { CodeBlock, ExecutionResult, JavaScriptVM, Header } from '@/components'
import ChatInput from '@/components/ChatInput'
import { formatDuration } from '@/lib/efficiency'
import { toolResultContentToString } from '@/lib/tool-result-content'

export const Route = createFileRoute('/_database-demo/database-demo' as any)({
component: DatabaseDemoPage,
Expand Down Expand Up @@ -378,7 +379,7 @@ function Messages({
for (const p of message.parts) {
if (p.type === 'tool-result') {
toolResults.set(p.toolCallId, {
content: p.content,
content: toolResultContentToString(p.content),
state: p.state,
error: p.error,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { openaiText } from '@tanstack/ai-openai'
import { geminiText } from '@tanstack/ai-gemini'
import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai'
import { productTools } from '@/lib/tools/product-tools'
import { maxTokensModelOptions } from '@/lib/max-tokens-model-options'

type Provider = 'anthropic' | 'openai' | 'gemini'

Expand Down Expand Up @@ -90,7 +91,7 @@ export const Route = createFileRoute('/_home/api/product-regular')({
],
agentLoopStrategy: maxIterations(30),
abortController,
maxTokens: 8192,
modelOptions: maxTokensModelOptions(adapter, 8192),
})

const requestStartTimeMs = Date.now()
Expand Down
5 changes: 3 additions & 2 deletions examples/ts-code-mode-web/src/routes/_home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { parsePartialJSON } from '@tanstack/ai'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import type { VMEvent } from '@/components'
import { CodeBlock, ExecutionResult, JavaScriptVM, Header } from '@/components'
import { toolResultContentToString } from '@/lib/tool-result-content'

export const Route = createFileRoute('/_home/')({
component: ProductDemoPage,
Expand Down Expand Up @@ -792,7 +793,7 @@ function CodeModePanel({
for (const p of message.parts) {
if (p.type === 'tool-result') {
toolResults.set(p.toolCallId, {
content: p.content,
content: toolResultContentToString(p.content),
state: p.state,
error: p.error,
})
Expand Down Expand Up @@ -1088,7 +1089,7 @@ function RegularToolsPanel({
for (const part of message.parts) {
if (part.type === 'tool-result') {
toolResults.set(part.toolCallId, {
content: part.content,
content: toolResultContentToString(part.content),
state: part.state,
error: part.error,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai'
import { allTools } from '@/lib/tools'
import { CODE_MODE_SYSTEM_PROMPT } from '@/lib/prompts'
import { exportConversationToPdfTool } from '@/lib/tools/export-pdf-tool'
import { maxTokensModelOptions } from '@/lib/max-tokens-model-options'

type Provider = 'anthropic' | 'openai' | 'gemini'

Expand Down Expand Up @@ -121,7 +122,7 @@ export const Route = createFileRoute('/_npm-github-chat/api/codemode')({
agentLoopStrategy: maxIterations(15),
abortController,
// Increase max tokens to allow for complex code generation
maxTokens: 8192,
modelOptions: maxTokensModelOptions(adapter, 8192),
})

const requestStartTimeMs = Date.now()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from '@/components'
import { NpmDataSidebar } from '@/components/NpmDataSidebar'
import { exportConversationToPdfTool } from '@/lib/tools/export-pdf-tool'
import { toolResultContentToString } from '@/lib/tool-result-content'

export const Route = createFileRoute('/_npm-github-chat/npm-github-chat')({
component: CodeModePage,
Expand Down Expand Up @@ -252,7 +253,7 @@ function Messages({
for (const p of message.parts) {
if (p.type === 'tool-result') {
toolResults.set(p.toolCallId, {
content: p.content,
content: toolResultContentToString(p.content),
state: p.state,
error: p.error,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { allTools } from '@/lib/tools'
import { CODE_MODE_SYSTEM_PROMPT, REPORTS_SYSTEM_PROMPT } from '@/lib/prompts'
import { reportTools } from '@/lib/reports/tools'
import { createReportBindings } from '@/lib/reports/create-report-bindings'
import { maxTokensModelOptions } from '@/lib/max-tokens-model-options'

type Provider = 'anthropic' | 'openai' | 'gemini'

Expand Down Expand Up @@ -79,7 +80,7 @@ export const Route = createFileRoute('/_reporting/api/reports' as any)({
],
agentLoopStrategy: maxIterations(20),
abortController,
maxTokens: 8192,
modelOptions: maxTokensModelOptions(adapter, 8192),
})

const sseStream = toServerSentEventsStream(stream, abortController)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
usePersistedReports,
} from '@/components/reports'
import type { Report, UIEvent } from '@/lib/reports/types'
import { toolResultContentToString } from '@/lib/tool-result-content'

export const Route = createFileRoute('/_reporting/reporting-agent')({
component: ReportingAgentPage,
Expand Down Expand Up @@ -251,7 +252,7 @@ function Messages({
for (const p of message.parts) {
if (p.type === 'tool-result') {
toolResults.set(p.toolCallId, {
content: p.content,
content: toolResultContentToString(p.content),
state: p.state,
error: p.error,
})
Expand Down
11 changes: 9 additions & 2 deletions examples/ts-react-chat/src/routes/generations.image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createFileRoute } from '@tanstack/react-router'
import { useGenerateImage } from '@tanstack/ai-react'
import type { UseGenerateImageReturn } from '@tanstack/ai-react'
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { resolveMediaPrompt } from '@tanstack/ai'
import { generateImageFn, generateImageStreamFn } from '../lib/server-fns'

function StreamingImageGeneration() {
Expand All @@ -29,7 +30,10 @@ function DirectImageGeneration() {
const [numberOfImages, setNumberOfImages] = useState(1)

const hookReturn = useGenerateImage({
fetcher: (input) => generateImageFn({ data: input }),
fetcher: (input) =>
generateImageFn({
data: { ...input, prompt: resolveMediaPrompt(input.prompt).text },
}),
})

return (
Expand All @@ -48,7 +52,10 @@ function ServerFnImageGeneration() {
const [numberOfImages, setNumberOfImages] = useState(1)

const hookReturn = useGenerateImage({
fetcher: (input) => generateImageStreamFn({ data: input }),
fetcher: (input) =>
generateImageStreamFn({
data: { ...input, prompt: resolveMediaPrompt(input.prompt).text },
}),
})

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,6 @@ function StructuredOutputPage() {
outputSchema: GuitarRecommendationSchema,
connection: fetchServerSentEvents('/api/structured-output'),
forwardedProps: { provider, model, stream },
devtools: {
outputKind: 'structured',
},
onChunk: handleChunk,
onError: (err) => {
setError(err.message)
Expand Down
11 changes: 9 additions & 2 deletions examples/ts-react-chat/src/routes/generations.video.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createFileRoute } from '@tanstack/react-router'
import { useGenerateVideo } from '@tanstack/ai-react'
import type { UseGenerateVideoReturn } from '@tanstack/ai-react'
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { resolveMediaPrompt } from '@tanstack/ai'
import { generateVideoFn, generateVideoStreamFn } from '../lib/server-fns'

function StreamingVideoGeneration() {
Expand All @@ -21,7 +22,10 @@ function DirectVideoGeneration() {
const [prompt, setPrompt] = useState('')

const hookReturn = useGenerateVideo({
fetcher: (input) => generateVideoFn({ data: input }),
fetcher: (input) =>
generateVideoFn({
data: { ...input, prompt: resolveMediaPrompt(input.prompt).text },
}),
})

return (
Expand All @@ -33,7 +37,10 @@ function ServerFnVideoGeneration() {
const [prompt, setPrompt] = useState('')

const hookReturn = useGenerateVideo({
fetcher: (input) => generateVideoStreamFn({ data: input }),
fetcher: (input) =>
generateVideoStreamFn({
data: { ...input, prompt: resolveMediaPrompt(input.prompt).text },
}),
})

return (
Expand Down
12 changes: 4 additions & 8 deletions examples/ts-react-chat/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
import { clientTools } from '@tanstack/ai-client'
import { ThinkingPart } from '@tanstack/ai-react-ui'
import type { UIMessage } from '@tanstack/ai-react'
import type { ContentPart, TranscriptionResult } from '@tanstack/ai'
import type { ContentPart } from '@tanstack/ai'
import type { GeminiInteractionsCustomEventValue } from '@tanstack/ai-gemini/experimental'
import type { ModelOption } from '@/lib/model-selection'
import GuitarRecommendation from '@/components/example-GuitarRecommendation'
Expand Down Expand Up @@ -436,18 +436,14 @@ function ChatPage() {
// Voice input: record from the mic, transcribe via /api/transcribe, then drop
// the text into the composer for the user to review/edit/send. (Text chat
// models don't accept raw audio; transcription is the path that works.)
// NOTE: the explicit type arg works around an inference bug in the generation
// hooks' `onResult` (same root cause as the recorder's `onComplete`, now
// fixed there) — without it, `r` is implicitly `any` and the call won't
// typecheck under strict mode.
// `onResult`'s `r` infers as `TranscriptionResult` from the hook (no explicit
// type arg needed — the generation hooks' result-type inference handles it).
// Surface voice-input failures (permission denied, recorder error,
// transcription error) to the user rather than only logging them — a silent
// mic button is the worst outcome.
const [recordError, setRecordError] = useState<string | null>(null)

const { generate: transcribe, isLoading: isTranscribing } = useTranscription<
(r: TranscriptionResult) => void
>({
const { generate: transcribe, isLoading: isTranscribing } = useTranscription({
connection: fetchServerSentEvents('/api/transcribe'),
onResult: (r) => setInput((prev) => (prev ? `${prev} ${r.text}` : r.text)),
// A failed transcription (network/provider) is just as silent as a mic
Expand Down
1 change: 1 addition & 0 deletions examples/ts-react-native-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"dev:app": "node -e \"const { spawnSync } = require('node:child_process'); const env = { ...process.env, EXPO_NO_DOTENV: '1' }; delete env.OPENAI_API_KEY; delete env.OPENAI_MODEL; const args = ['exec', 'expo', 'start', '--lan', '--clear']; const result = process.platform === 'win32' ? spawnSync(env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', 'pnpm.cmd', ...args], { stdio: 'inherit', env }) : spawnSync('pnpm', args, { stdio: 'inherit', env }); process.exit(result.status ?? 1)\"",
"test:dev-script": "node --test scripts/dev.test.mjs",
"typecheck": "tsc --noEmit",
"test:types": "tsc --noEmit",
"smoke:server": "tsx scripts/smoke-server.ts",
"smoke:expo": "node -e \"const { spawnSync } = require('node:child_process'); const env = { ...process.env, EXPO_NO_DOTENV: '1' }; delete env.OPENAI_API_KEY; delete env.OPENAI_MODEL; const args = ['exec', 'expo', 'export', '--platform', 'ios', '--no-bytecode', '--output-dir', '.expo-example-dist', '--max-workers', '0']; const result = process.platform === 'win32' ? spawnSync(env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', 'pnpm.cmd', ...args], { stdio: 'inherit', env }) : spawnSync('pnpm', args, { stdio: 'inherit', env }); process.exit(result.status ?? 1)\"",
"verify:react-resolution": "node scripts/verify-react-resolution.mjs",
Expand Down
Loading
Loading