feat(server): workspace_panes envelope + read_tab_by_number tool
Widen the sessions.workspace_panes JSONB from a bare WorkspacePane[] to a
WorkspaceState envelope { panes, tabNumbers, nextTabNumber, closedPaneStack }.
The PATCH validator accepts either the legacy array or the envelope (zod union)
and normalizes to a full envelope before storing, so existing array-shaped rows
migrate transparently on next write. The session_workspace_updated WS frame
schema is widened to match (kept byte-identical to the web copy; parity test
passes).
Adds read_tab_by_number, a read-only tool that resolves a session-scoped tab
number to its chat via the persisted tabNumbers map and returns that chat's
transcript (oldest-first, sentinels skipped, capped at 20k chars). Tools gain an
optional ToolExecCtx ({ sql, sessionId }) 4th param on ToolDef.execute, threaded
through executeToolCall from executeToolPhase; the param is optional so existing
filesystem tools and the apps/coder consumer stay compatible. Registered in
ALL_TOOLS + READ_ONLY_TOOL_NAMES.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,9 +28,7 @@ const HtmlArtifactStateZ = z.object({
|
|||||||
title: z.string().max(500),
|
title: z.string().max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
const WorkspacePaneZ = z.object({
|
const PaneKindZ = z.enum([
|
||||||
id: z.string().min(1).max(200),
|
|
||||||
kind: z.enum([
|
|
||||||
'chat',
|
'chat',
|
||||||
'terminal',
|
'terminal',
|
||||||
'coder',
|
'coder',
|
||||||
@@ -39,7 +37,11 @@ const WorkspacePaneZ = z.object({
|
|||||||
'settings',
|
'settings',
|
||||||
'markdown_artifact',
|
'markdown_artifact',
|
||||||
'html_artifact',
|
'html_artifact',
|
||||||
]),
|
]);
|
||||||
|
|
||||||
|
const WorkspacePaneZ = z.object({
|
||||||
|
id: z.string().min(1).max(200),
|
||||||
|
kind: PaneKindZ,
|
||||||
chatId: z.string().min(1).max(200).optional(),
|
chatId: z.string().min(1).max(200).optional(),
|
||||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
activeChatIdx: z.number().int(),
|
activeChatIdx: z.number().int(),
|
||||||
@@ -47,8 +49,27 @@ const WorkspacePaneZ = z.object({
|
|||||||
html_artifact_state: HtmlArtifactStateZ.optional(),
|
html_artifact_state: HtmlArtifactStateZ.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v2.6.x: workspace_panes column widened from a bare WorkspacePane[] to a
|
||||||
|
// WorkspaceState envelope (panes + stable session-scoped tab numbering +
|
||||||
|
// reopen stack). closedPaneStack entries are lighter than full panes — just
|
||||||
|
// the kind + chat ids needed to recreate a closed pane on reopen.
|
||||||
|
const ClosedPaneEntryZ = z.object({
|
||||||
|
kind: PaneKindZ,
|
||||||
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
|
activeChatIdx: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const WorkspaceStateZ = z.object({
|
||||||
|
panes: z.array(WorkspacePaneZ).max(10),
|
||||||
|
tabNumbers: z.record(z.string(), z.number().int()).default({}),
|
||||||
|
nextTabNumber: z.number().int().default(1),
|
||||||
|
closedPaneStack: z.array(ClosedPaneEntryZ).max(10).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept either the legacy bare array OR the envelope. The handler normalizes
|
||||||
|
// to a full envelope before storing (see MIGRATION rule in the PATCH handler).
|
||||||
const WorkspacePanesBody = z.object({
|
const WorkspacePanesBody = z.object({
|
||||||
workspace_panes: z.array(WorkspacePaneZ).max(10),
|
workspace_panes: z.union([z.array(WorkspacePaneZ).max(10), WorkspaceStateZ]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const PatchBody = z.object({
|
const PatchBody = z.object({
|
||||||
@@ -308,12 +329,20 @@ export function registerSessionRoutes(
|
|||||||
reply.code(400);
|
reply.code(400);
|
||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
const workspacePanes = parsed.data.workspace_panes.map((pane) =>
|
// v2.6.x MIGRATION: the body is either a legacy bare WorkspacePane[] or
|
||||||
|
// the WorkspaceState envelope. Normalize to a full envelope so the column
|
||||||
|
// always stores the envelope shape going forward.
|
||||||
|
const body = parsed.data.workspace_panes;
|
||||||
|
const envelope = Array.isArray(body)
|
||||||
|
? { panes: body, tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] }
|
||||||
|
: body;
|
||||||
|
// agent → coder normalization on the panes array (unchanged write rule).
|
||||||
|
envelope.panes = envelope.panes.map((pane) =>
|
||||||
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
|
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
|
||||||
);
|
);
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET workspace_panes = ${sql.json(workspacePanes as never)},
|
SET workspace_panes = ${sql.json(envelope as never)},
|
||||||
updated_at = clock_timestamp()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Agent, Session, ToolCall } from '../../types/api.js';
|
|||||||
import * as modelContext from '../model-context.js';
|
import * as modelContext from '../model-context.js';
|
||||||
import { PathScopeError } from '../path_guard.js';
|
import { PathScopeError } from '../path_guard.js';
|
||||||
import { TOOLS_BY_NAME } from '../tools.js';
|
import { TOOLS_BY_NAME } from '../tools.js';
|
||||||
|
import type { ToolExecCtx } from '../tools.js';
|
||||||
import { matchToolGlob } from '../agents.js';
|
import { matchToolGlob } from '../agents.js';
|
||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
||||||
@@ -31,6 +32,7 @@ async function executeToolCall(
|
|||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
toolCall: ToolCall,
|
toolCall: ToolCall,
|
||||||
extraRoots: readonly string[],
|
extraRoots: readonly string[],
|
||||||
|
toolCtx?: ToolExecCtx,
|
||||||
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
||||||
const tool = TOOLS_BY_NAME[toolCall.name];
|
const tool = TOOLS_BY_NAME[toolCall.name];
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
@@ -65,7 +67,7 @@ async function executeToolCall(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const output = await tool.execute(parsed.data, projectRoot, extraRoots);
|
const output = await tool.execute(parsed.data, projectRoot, extraRoots, toolCtx);
|
||||||
const truncated =
|
const truncated =
|
||||||
typeof output === 'object' && output !== null && 'truncated' in output
|
typeof output === 'object' && output !== null && 'truncated' in output
|
||||||
? Boolean((output as { truncated: unknown }).truncated)
|
? Boolean((output as { truncated: unknown }).truncated)
|
||||||
@@ -289,7 +291,10 @@ export async function executeToolPhase(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths, {
|
||||||
|
sql: ctx.sql,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||||
}
|
}
|
||||||
|
|||||||
142
apps/server/src/services/read_tab_by_number.ts
Normal file
142
apps/server/src/services/read_tab_by_number.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// v2.6.x: read_tab_by_number tool. Reads the conversation transcript of the
|
||||||
|
// chat that occupies a given session-scoped tab number. Stable tab numbers are
|
||||||
|
// stored in the session's workspace_panes envelope (WorkspaceState.tabNumbers),
|
||||||
|
// keyed by chat id. Lives in its own file (not appended to tools.ts) so tests
|
||||||
|
// can import the executor directly without dragging in the whole tool registry.
|
||||||
|
// Registered in tools.ts ALL_TOOLS + READ_ONLY_TOOL_NAMES.
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
// type-only import to dodge the runtime cycle (tools.ts re-exports this tool
|
||||||
|
// via ALL_TOOLS; importing ToolDef/ToolExecCtx at type level keeps the dep
|
||||||
|
// one-way).
|
||||||
|
import type { ToolDef, ToolExecCtx } from './tools.js';
|
||||||
|
|
||||||
|
const ReadTabByNumberInput = z.object({
|
||||||
|
number: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
export type ReadTabByNumberInputT = z.infer<typeof ReadTabByNumberInput>;
|
||||||
|
|
||||||
|
// Cap total transcript size so a long conversation can't blow the context
|
||||||
|
// window. The model gets a clear truncation marker when the cap is hit.
|
||||||
|
const MAX_TRANSCRIPT_CHARS = 20_000;
|
||||||
|
|
||||||
|
// WorkspaceState envelope shape (panes omitted — we only need tabNumbers here).
|
||||||
|
interface WorkspaceStateLike {
|
||||||
|
panes?: unknown;
|
||||||
|
tabNumbers?: Record<string, number>;
|
||||||
|
nextTabNumber?: number;
|
||||||
|
closedPaneStack?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIGRATION: the stored workspace_panes value may be the legacy bare
|
||||||
|
// WorkspacePane[] OR the WorkspaceState envelope. Normalize to an envelope so
|
||||||
|
// tabNumbers is always available (empty for the legacy shape — no tab numbers
|
||||||
|
// were tracked before the envelope landed).
|
||||||
|
function normalizeWorkspaceState(v: unknown): {
|
||||||
|
tabNumbers: Record<string, number>;
|
||||||
|
} {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
return { tabNumbers: {} };
|
||||||
|
}
|
||||||
|
if (v && typeof v === 'object' && Array.isArray((v as WorkspaceStateLike).panes)) {
|
||||||
|
const env = v as WorkspaceStateLike;
|
||||||
|
return { tabNumbers: env.tabNumbers ?? {} };
|
||||||
|
}
|
||||||
|
return { tabNumbers: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure executor split out from the ToolDef wrapper so tests can call it with a
|
||||||
|
// mocked Sql. Returns a transcript string (read-only — never writes).
|
||||||
|
export async function executeReadTabByNumber(
|
||||||
|
input: ReadTabByNumberInputT,
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const sessionRows = await sql<{ workspace_panes: unknown }[]>`
|
||||||
|
SELECT workspace_panes FROM sessions WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
return `Session not found.`;
|
||||||
|
}
|
||||||
|
const { tabNumbers } = normalizeWorkspaceState(sessionRows[0]!.workspace_panes);
|
||||||
|
|
||||||
|
// Reverse-lookup: find the chat id whose stable tab number equals the input.
|
||||||
|
let chatId: string | null = null;
|
||||||
|
for (const [cid, num] of Object.entries(tabNumbers)) {
|
||||||
|
if (num === input.number) {
|
||||||
|
chatId = cid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chatId === null) {
|
||||||
|
return `No tab is numbered ${input.number} in this session.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the conversation: skip system sentinels (role='system') and empty
|
||||||
|
// content rows. Oldest first.
|
||||||
|
const messages = await sql<{ role: string; content: string }[]>`
|
||||||
|
SELECT role, content
|
||||||
|
FROM messages
|
||||||
|
WHERE chat_id = ${chatId}
|
||||||
|
AND role <> 'system'
|
||||||
|
AND content <> ''
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return `Tab ${input.number} (chat ${chatId}) has no messages yet.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a compact transcript, capping total output size.
|
||||||
|
const parts: string[] = [];
|
||||||
|
let total = 0;
|
||||||
|
let truncated = false;
|
||||||
|
for (const m of messages) {
|
||||||
|
const block = `### ${m.role}\n${m.content}`;
|
||||||
|
// +2 accounts for the "\n\n" joiner between blocks.
|
||||||
|
if (total + block.length + 2 > MAX_TRANSCRIPT_CHARS) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parts.push(block);
|
||||||
|
total += block.length + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = parts.join('\n\n');
|
||||||
|
if (truncated) {
|
||||||
|
out += `\n\n[transcript truncated at ${MAX_TRANSCRIPT_CHARS} chars]`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readTabByNumber: ToolDef<ReadTabByNumberInputT> = {
|
||||||
|
name: 'read_tab_by_number',
|
||||||
|
description:
|
||||||
|
'Read the conversation transcript of the tab with the given session-scoped tab number. Tab numbers are stable per session (shown in the workspace tab strip). Returns the messages of that tab oldest-first as a compact transcript. Read-only.',
|
||||||
|
inputSchema: ReadTabByNumberInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'read_tab_by_number',
|
||||||
|
description:
|
||||||
|
'Read the conversation transcript of the tab with the given session-scoped tab number. Read-only.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'The session-scoped tab number (positive integer).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['number'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, _projectRoot, _extraRoots, toolCtx?: ToolExecCtx) {
|
||||||
|
if (!toolCtx) {
|
||||||
|
return 'read_tab_by_number unavailable: no session context';
|
||||||
|
}
|
||||||
|
return await executeReadTabByNumber(input, toolCtx.sql, toolCtx.sessionId);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||||
import { resolve, basename, relative } from 'node:path';
|
import { resolve, basename, relative } from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
import { pathGuard, PathScopeError } from './path_guard.js';
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||||
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js';
|
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js';
|
||||||
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
||||||
@@ -30,6 +31,9 @@ import {
|
|||||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||||
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
||||||
import { requestReadAccess } from './request_read_access.js';
|
import { requestReadAccess } from './request_read_access.js';
|
||||||
|
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
|
||||||
|
// tab number. Needs DB/session context (ToolExecCtx 4th arg).
|
||||||
|
import { readTabByNumber } from './read_tab_by_number.js';
|
||||||
|
|
||||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
const DEFAULT_VIEW_LINES = 200;
|
const DEFAULT_VIEW_LINES = 200;
|
||||||
@@ -48,6 +52,16 @@ export interface ToolJsonSchema {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.6.x: optional DB/session context threaded into a tool's execute(). Only
|
||||||
|
// tools that need to read session-scoped DB state (e.g. read_tab_by_number)
|
||||||
|
// use it; every other tool ignores the 4th arg. Kept optional so existing
|
||||||
|
// 3-arg execute() implementations stay assignable (apps/coder consumes this
|
||||||
|
// type from the compiled dist — the optional param keeps it backward-compatible).
|
||||||
|
export interface ToolExecCtx {
|
||||||
|
sql: Sql;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolDef<TInput> {
|
export interface ToolDef<TInput> {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -59,7 +73,15 @@ export interface ToolDef<TInput> {
|
|||||||
// view_truncated_output) forward it to pathGuard; other tools accept the
|
// view_truncated_output) forward it to pathGuard; other tools accept the
|
||||||
// arg and ignore it. The execute signature stays compatible with
|
// arg and ignore it. The execute signature stays compatible with
|
||||||
// pre-v1.13.17 callsites because the parameter is optional.
|
// pre-v1.13.17 callsites because the parameter is optional.
|
||||||
execute(input: TInput, projectRoot: string, extraRoots?: readonly string[]): Promise<unknown>;
|
// v2.6.x: optional 4th param toolCtx carries DB/session context for tools
|
||||||
|
// that read session-scoped state (read_tab_by_number). Optional so 3-arg
|
||||||
|
// implementations remain assignable.
|
||||||
|
execute(
|
||||||
|
input: TInput,
|
||||||
|
projectRoot: string,
|
||||||
|
extraRoots?: readonly string[],
|
||||||
|
toolCtx?: ToolExecCtx,
|
||||||
|
): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ViewFileInput = z.object({
|
const ViewFileInput = z.object({
|
||||||
@@ -694,6 +716,9 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
|||||||
// state change is appending to sessions.allowed_read_paths via the
|
// state change is appending to sessions.allowed_read_paths via the
|
||||||
// grant endpoint, gated by user consent.
|
// grant endpoint, gated by user consent.
|
||||||
requestReadAccess as ToolDef<unknown>,
|
requestReadAccess as ToolDef<unknown>,
|
||||||
|
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
||||||
|
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
||||||
|
readTabByNumber as ToolDef<unknown>,
|
||||||
].sort((a, b) => a.name.localeCompare(b.name));
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||||
@@ -734,6 +759,9 @@ export const READ_ONLY_TOOL_NAMES = [
|
|||||||
// state directly (the grant endpoint appends to sessions.allowed_read_paths
|
// state directly (the grant endpoint appends to sessions.allowed_read_paths
|
||||||
// only with user consent). Belongs in the read-only budget tier.
|
// only with user consent). Belongs in the read-only budget tier.
|
||||||
'request_read_access',
|
'request_read_access',
|
||||||
|
// v2.6.x: reads a tab's transcript from session-scoped DB state; never
|
||||||
|
// writes. Belongs in the read-only budget tier.
|
||||||
|
'read_tab_by_number',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||||
|
|||||||
@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
|
|||||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||||
type: z.literal('session_workspace_updated'),
|
type: z.literal('session_workspace_updated'),
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
workspace_panes: z.array(OpaqueObject),
|
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||||
|
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||||
|
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||||
|
// every envelope frame at validation. MUST be mirrored in the server's
|
||||||
|
// byte-identical copy (parity test).
|
||||||
|
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ChatCreatedFrame = z.object({
|
export const ChatCreatedFrame = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user