diff --git a/.changeset/refactor-google-docs-structured-return.md b/.changeset/refactor-google-docs-structured-return.md
new file mode 100644
index 0000000000..a1bb3986ca
--- /dev/null
+++ b/.changeset/refactor-google-docs-structured-return.md
@@ -0,0 +1,23 @@
+---
+---
+
+refactor(addie): `read_google_doc` returns structured result — closes #2752, #2753, #2754, #2756.
+
+Four follow-up issues from the PR #2744 expert review bundled. They all share one file and the structured return subsumes the others:
+
+**#2754 — Structured return.** New `GoogleDocResult` type: `{ status: 'ok' | 'access_denied' | 'empty' | 'invalid_input' | 'unsupported_type' | 'error', title, body, format, mime_type, message, truncated }`. The LLM-facing `read_google_doc` tool now returns a JSON string of this shape instead of a pre-formatted markdown blob. Addie's prompt branches on `status` and forwards `title`/`body` to `propose_content.title`/`content` — no more string manipulation, no more "strip leading `#
\\n\\n`" fragility.
+
+**#2756 — Sentinel collision.** The old code used `result.startsWith("I don't have access")` as the error signal, which false-positives on any doc whose body naturally starts with that phrase. Replaced with the `status` enum. The `GOOGLE_DOCS_ERROR_PREFIX` / `GOOGLE_DOCS_ACCESS_DENIED_PREFIX` constants are removed — no internal callers referenced them after migration.
+
+**#2753 — Path divergence.** Previously two different code paths produced subtly different markdown for the same doc (Drive API `text/markdown` export vs our custom `readViaDocsApi` converter). Both now flow through one `readGoogleDocStructured` function that normalizes its output into the structured shape before callers see it. No more "same doc, different output depending on URL format."
+
+**#2752 — Dead-code caps.** Unified: the 500KB inner cap in `readGoogleDocStructured` is what internal callers see (committee-document-indexer, content-curator). The LLM-facing handler caps at 30KB before JSON-stringifying — one doc can't dominate Sonnet's context window. The old 15K outer cap that was overriding the 500KB inner caps is gone.
+
+**Migrations:**
+- `committee-document-indexer.ts` → uses new `createGoogleDocsReader()` factory, branches on `status`
+- `content-curator.ts` → same pattern
+- The legacy string-returning `readGoogleDoc` wrapper is preserved for any transitional internal caller, implemented as a thin formatter over the structured result
+
+Tests: 22 unit tests pass (5 new — reader/handler factories return null when creds missing, valid factory when creds present, `GoogleDocResult` status/format contract). Typecheck clean.
+
+Remaining epic #2693 follow-ups: #2735 channel privacy TOCTOU, #2736 interactive Slack DMs, #2755 web Addie rate limit.
diff --git a/server/src/addie/jobs/committee-document-indexer.ts b/server/src/addie/jobs/committee-document-indexer.ts
index f38a1f2b82..06f63efb42 100644
--- a/server/src/addie/jobs/committee-document-indexer.ts
+++ b/server/src/addie/jobs/committee-document-indexer.ts
@@ -18,9 +18,7 @@ import { logger } from '../../logger.js';
import { WorkingGroupDatabase } from '../../db/working-group-db.js';
import {
isGoogleDocsUrl,
- createGoogleDocsToolHandlers,
- GOOGLE_DOCS_ERROR_PREFIX,
- GOOGLE_DOCS_ACCESS_DENIED_PREFIX,
+ createGoogleDocsReader,
} from '../mcp/google-docs.js';
import { isLLMConfigured, complete } from '../../utils/llm.js';
import { PDFParse } from 'pdf-parse';
@@ -62,9 +60,9 @@ async function fetchGoogleDocContent(url: string): Promise<{
error?: string;
status: DocumentIndexStatus;
}> {
- const handlers = createGoogleDocsToolHandlers();
+ const reader = createGoogleDocsReader();
- if (!handlers) {
+ if (!reader) {
return {
content: '',
error: 'Google Docs API not configured',
@@ -73,34 +71,19 @@ async function fetchGoogleDocContent(url: string): Promise<{
}
try {
- const result = await handlers.read_google_doc({ url });
-
- // Check for access denied
- if (result.startsWith(GOOGLE_DOCS_ACCESS_DENIED_PREFIX)) {
- return {
- content: '',
- error: result,
- status: 'access_denied',
- };
+ const result = await reader(url);
+ switch (result.status) {
+ case 'access_denied':
+ return { content: '', error: result.message ?? 'Access denied', status: 'access_denied' };
+ case 'invalid_input':
+ case 'unsupported_type':
+ case 'error':
+ return { content: '', error: result.message ?? 'Error reading document', status: 'error' };
+ case 'empty':
+ return { content: '', status: 'success' };
+ case 'ok':
+ return { content: result.body ?? '', status: 'success' };
}
-
- // Check for other errors
- if (result.startsWith(GOOGLE_DOCS_ERROR_PREFIX)) {
- return {
- content: '',
- error: result,
- status: 'error',
- };
- }
-
- // Strip the title/format header if present
- const contentMatch = result.match(/^\*\*[^*]+\*\*[^\n]*\n\n([\s\S]*)$/);
- const content = contentMatch ? contentMatch[1] : result;
-
- return {
- content,
- status: 'success',
- };
} catch (error) {
return {
content: '',
diff --git a/server/src/addie/mcp/google-docs.ts b/server/src/addie/mcp/google-docs.ts
index eeb455ae0a..542bd51f83 100644
--- a/server/src/addie/mcp/google-docs.ts
+++ b/server/src/addie/mcp/google-docs.ts
@@ -12,10 +12,6 @@ import type { AddieTool } from '../types.js';
// Addie's email for access requests
const ADDIE_EMAIL = 'addie@agenticadvertising.org';
-// Error prefixes for reliable error detection
-export const GOOGLE_DOCS_ERROR_PREFIX = 'Error:';
-export const GOOGLE_DOCS_ACCESS_DENIED_PREFIX = "I don't have access";
-
// Maximum content size (500KB)
const MAX_CONTENT_SIZE = 500 * 1024;
@@ -363,11 +359,15 @@ function renderTable(
/**
* Read a Google Doc using the Docs API (docs.googleapis.com).
* This works even when the Drive API is restricted.
+ *
+ * Returns a structured `{ title, body }` — the caller is responsible for
+ * wrapping it in a `GoogleDocResult`. Returns null on non-2xx so the
+ * caller can fall through to the Drive API.
*/
async function readViaDocsApi(
docId: string,
accessToken: string,
-): Promise {
+): Promise<{ title: string; body: string } | null> {
const response = await fetch(
`https://docs.googleapis.com/v1/documents/${docId}`,
{
@@ -389,21 +389,9 @@ async function readViaDocsApi(
const doc = await response.json() as GoogleDocsApiDocument;
const title = doc.title || 'Untitled';
- const markdown = extractMarkdownFromDocsResponse(doc);
-
- if (!markdown.trim()) {
- return `# ${title}\n\n(Document is empty)`;
- }
-
- // If the document already has a title-style heading at the top, don't
- // double it with the file name.
- const body = markdown.startsWith('#') ? markdown : `# ${title}\n\n${markdown}`;
+ const body = extractMarkdownFromDocsResponse(doc);
- if (body.length > MAX_CONTENT_SIZE) {
- return `${body.substring(0, MAX_CONTENT_SIZE)}\n\n[Content truncated to ${MAX_CONTENT_SIZE / 1024}KB]`;
- }
-
- return body;
+ return { title, body };
}
/**
@@ -414,7 +402,7 @@ async function readViaDocsApi(
async function readViaSheetsApi(
spreadsheetId: string,
accessToken: string,
-): Promise {
+): Promise<{ title: string; body: string } | null> {
// Get spreadsheet metadata and first sheet name
const metaResponse = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?fields=properties.title,sheets.properties.title`,
@@ -443,7 +431,7 @@ async function readViaSheetsApi(
const sheetNames = (meta.sheets ?? []).map(s => s.properties?.title).filter(Boolean) as string[];
if (sheetNames.length === 0) {
- return `**${title}**\n\n(Spreadsheet has no sheets)`;
+ return { title, body: '' };
}
// Read all values from the first sheet
@@ -467,7 +455,7 @@ async function readViaSheetsApi(
const rows = valuesData.values ?? [];
if (rows.length === 0) {
- return `**${title}**\n\n(Sheet "${firstSheet}" is empty)`;
+ return { title, body: '' };
}
// Convert to CSV
@@ -484,11 +472,7 @@ async function readViaSheetsApi(
? `\n\n(Showing sheet "${firstSheet}" — ${sheetNames.length} sheets total: ${sheetNames.join(', ')})`
: '';
- if (csv.length > MAX_CONTENT_SIZE) {
- return `**${title}** (csv)\n\n${csv.substring(0, MAX_CONTENT_SIZE)}\n\n[Content truncated to ${MAX_CONTENT_SIZE / 1024}KB]${sheetInfo}`;
- }
-
- return `**${title}** (csv)\n\n${csv}${sheetInfo}`;
+ return { title, body: `${csv}${sheetInfo}` };
}
/**
@@ -547,38 +531,106 @@ export function isGoogleDocsUrl(url: string): boolean {
}
/**
- * Read a Google Doc as plain text
+ * Structured result shape returned by `readGoogleDocStructured`.
+ *
+ * Callers should branch on `status`, not sniff the text of `body` or
+ * `message`. Natural-language sentinels (e.g. "I don't have access")
+ * collide with legitimate document content (#2756) — the status field
+ * is the only reliable signal.
+ *
+ * Introduced per #2754; replaces the previous stringly-typed return
+ * and its companion prefix constants (GOOGLE_DOCS_ERROR_PREFIX /
+ * GOOGLE_DOCS_ACCESS_DENIED_PREFIX, now removed).
*/
-async function readGoogleDoc(
+export interface GoogleDocResult {
+ status: 'ok' | 'access_denied' | 'empty' | 'invalid_input' | 'unsupported_type' | 'error';
+ title: string | null;
+ /** Markdown (for Docs/Slides) or CSV (for Sheets). */
+ body: string | null;
+ /** The mime type reported by the Drive API, or null on early error. */
+ mime_type: string | null;
+ /** 'markdown' | 'csv' | 'text' — what `body` contains, or null. */
+ format: 'markdown' | 'csv' | 'text' | null;
+ /** Human-readable message for non-ok statuses. */
+ message: string | null;
+ /** True if `body` was cut at MAX_CONTENT_SIZE. */
+ truncated: boolean;
+}
+
+/**
+ * Read a Google Doc / Sheet / Drive file and return a structured result.
+ *
+ * Always returns a `GoogleDocResult` — does not throw for auth / not-found
+ * conditions. Only throws (via `ToolError`) for unexpected internal
+ * failures the caller can't handle meaningfully.
+ */
+async function readGoogleDocStructured(
urlOrId: string,
config: GoogleAuthConfig
-): Promise {
+): Promise {
const docId = extractDocId(urlOrId);
if (!docId) {
- throw new ToolError(`Could not extract document ID from "${urlOrId}". Please provide a valid Google Docs or Google Drive URL.`);
+ return {
+ status: 'invalid_input',
+ title: null,
+ body: null,
+ mime_type: null,
+ format: null,
+ message: `Could not extract document ID from "${urlOrId}". Please provide a valid Google Docs or Google Drive URL.`,
+ truncated: false,
+ };
}
+ const accessDenied = (): GoogleDocResult => ({
+ status: 'access_denied',
+ title: null,
+ body: null,
+ mime_type: null,
+ format: null,
+ message: `I don't have access to this document. Please share it with ${ADDIE_EMAIL} (Viewer access is fine) and let me know when you've done that.`,
+ truncated: false,
+ });
+
+ const okResult = (
+ title: string,
+ body: string,
+ mimeType: string,
+ format: 'markdown' | 'csv' | 'text',
+ ): GoogleDocResult => {
+ const truncated = body.length > MAX_CONTENT_SIZE;
+ return {
+ status: body.trim() ? 'ok' : 'empty',
+ title,
+ body: truncated ? body.substring(0, MAX_CONTENT_SIZE) : body,
+ mime_type: mimeType,
+ format,
+ message: null,
+ truncated,
+ };
+ };
+
try {
const auth = getAuthManager(config);
const accessToken = await auth.getAccessToken();
- // Try direct APIs first (Docs, Sheets) before Drive API.
- // These use sensitive scopes (documents.readonly, spreadsheets.readonly) that work
- // even when the restricted drive.readonly scope is silently blocked by Google
- // for unverified OAuth apps.
+ // Try direct APIs first (Docs, Sheets) before Drive API. These use
+ // sensitive scopes (documents.readonly, spreadsheets.readonly) that
+ // work even when the restricted drive.readonly scope is silently
+ // blocked by Google for unverified OAuth apps.
if (isGoogleDocUrl(urlOrId)) {
- const docsResult = await readViaDocsApi(docId, accessToken);
- if (docsResult !== null) {
- return docsResult;
+ const result = await readViaDocsApi(docId, accessToken);
+ if (result !== null) {
+ return okResult(result.title, result.body, 'application/vnd.google-apps.document', 'markdown');
}
} else if (isGoogleSheetsUrl(urlOrId)) {
- const sheetsResult = await readViaSheetsApi(docId, accessToken);
- if (sheetsResult !== null) {
- return sheetsResult;
+ const result = await readViaSheetsApi(docId, accessToken);
+ if (result !== null) {
+ return okResult(result.title, result.body, 'application/vnd.google-apps.spreadsheet', 'csv');
}
}
- // Fall through to Drive API for Drive file links, raw IDs, or if direct APIs failed
+ // Fall through to Drive API for Drive file links, raw IDs, or if
+ // the direct APIs returned null (non-200).
const metadataResponse = await fetch(
`https://www.googleapis.com/drive/v3/files/${docId}?fields=name,mimeType,capabilities`,
{
@@ -589,25 +641,39 @@ async function readGoogleDoc(
if (!metadataResponse.ok) {
if (metadataResponse.status === 404 || metadataResponse.status === 403) {
+ // Drive API may be blocked for unverified OAuth apps. Try direct
+ // APIs as a last-ditch fallback before telling the user we can't
+ // access the document.
let driveError = '';
try {
const body = await metadataResponse.json() as { error?: { message?: string; errors?: Array<{ reason?: string }> } };
driveError = body.error?.message || body.error?.errors?.[0]?.reason || '';
} catch { /* ignore parse errors */ }
- // Drive API may be blocked for unverified OAuth apps. Try direct APIs
- // as a fallback before telling the user we can't access the document.
logger.warn({ status: metadataResponse.status, docId, driveError }, 'Google Docs: Drive API inaccessible, trying direct APIs');
+
const docsResult = await readViaDocsApi(docId, accessToken);
- if (docsResult !== null) return docsResult;
+ if (docsResult !== null) {
+ return okResult(docsResult.title, docsResult.body, 'application/vnd.google-apps.document', 'markdown');
+ }
const sheetsResult = await readViaSheetsApi(docId, accessToken);
- if (sheetsResult !== null) return sheetsResult;
+ if (sheetsResult !== null) {
+ return okResult(sheetsResult.title, sheetsResult.body, 'application/vnd.google-apps.spreadsheet', 'csv');
+ }
logger.warn({ status: metadataResponse.status, docId, driveError }, 'Google Docs: document inaccessible via all APIs');
- return `I don't have access to this document. Please share it with ${ADDIE_EMAIL} (Viewer access is fine) and let me know when you've done that.`;
+ return accessDenied();
}
const error = await metadataResponse.text();
logger.error({ error, status: metadataResponse.status, docId }, 'Google Docs: Failed to get metadata');
- throw new ToolError(`Failed to access document (${metadataResponse.status})`);
+ return {
+ status: 'error',
+ title: null,
+ body: null,
+ mime_type: null,
+ format: null,
+ message: `Failed to access document (HTTP ${metadataResponse.status})`,
+ truncated: false,
+ };
}
const metadata = await metadataResponse.json() as { name: string; mimeType: string };
@@ -615,30 +681,33 @@ async function readGoogleDoc(
logger.debug({ docId, name, mimeType }, 'Google Docs: Retrieved metadata');
- // Handle different file types
- let exportMimeType = 'text/plain';
- let exportFormat = 'text';
-
- if (mimeType === 'application/vnd.google-apps.document') {
- // Google Doc - export as markdown so inline formatting, headings,
- // links, and lists survive into Addie's reply. `text/markdown` has
- // been a supported Docs export since 2024.
- exportMimeType = 'text/markdown';
- exportFormat = 'md';
- } else if (mimeType === 'application/vnd.google-apps.spreadsheet') {
- // Google Sheet - export as CSV
- exportMimeType = 'text/csv';
- exportFormat = 'csv';
- } else if (mimeType === 'application/vnd.google-apps.presentation') {
- // Google Slides - export as plain text
- exportMimeType = 'text/plain';
- exportFormat = 'txt';
- } else if (mimeType === 'application/pdf') {
- return `This is a PDF file (${name}). I cannot read PDF content directly. If you need me to understand the content, please copy and paste the relevant text.`;
- } else if (mimeType?.startsWith('image/')) {
- return `This is an image file (${name}). I can see it was shared but cannot view image contents directly.`;
- } else if (mimeType === 'text/plain' || mimeType === 'text/markdown' || mimeType === 'application/json') {
- // Direct download for text files
+ // Non-exportable types: return unsupported_type with the mime so the
+ // caller can decide what to do (Addie will ask the user to paste
+ // instead). We don't attempt OCR / PDF extraction here.
+ if (mimeType === 'application/pdf') {
+ return {
+ status: 'unsupported_type',
+ title: name,
+ body: null,
+ mime_type: mimeType,
+ format: null,
+ message: `This is a PDF file. I cannot read PDF content directly — please copy the relevant text and paste it.`,
+ truncated: false,
+ };
+ }
+ if (mimeType?.startsWith('image/')) {
+ return {
+ status: 'unsupported_type',
+ title: name,
+ body: null,
+ mime_type: mimeType,
+ format: null,
+ message: `This is an image file. I can see it was shared but cannot view image contents directly.`,
+ truncated: false,
+ };
+ }
+ if (mimeType === 'text/plain' || mimeType === 'text/markdown' || mimeType === 'application/json') {
+ // Direct download for raw text files
const downloadResponse = await fetch(
`https://www.googleapis.com/drive/v3/files/${docId}?alt=media`,
{
@@ -648,19 +717,46 @@ async function readGoogleDoc(
);
if (!downloadResponse.ok) {
- throw new ToolError(`Failed to download file (${downloadResponse.status})`);
+ return {
+ status: 'error',
+ title: name,
+ body: null,
+ mime_type: mimeType,
+ format: null,
+ message: `Failed to download file (HTTP ${downloadResponse.status})`,
+ truncated: false,
+ };
}
const content = await downloadResponse.text();
- if (content.length > MAX_CONTENT_SIZE) {
- return `**${name}**\n\n${content.substring(0, MAX_CONTENT_SIZE)}\n\n[Content truncated to ${MAX_CONTENT_SIZE / 1024}KB]`;
- }
- return `**${name}**\n\n${content}`;
+ const format = mimeType === 'text/markdown' ? 'markdown' : 'text';
+ return okResult(name, content, mimeType, format);
+ }
+
+ // Google Workspace files — pick export mimeType and format.
+ let exportMimeType: string;
+ let format: 'markdown' | 'csv' | 'text';
+ if (mimeType === 'application/vnd.google-apps.document') {
+ exportMimeType = 'text/markdown';
+ format = 'markdown';
+ } else if (mimeType === 'application/vnd.google-apps.spreadsheet') {
+ exportMimeType = 'text/csv';
+ format = 'csv';
+ } else if (mimeType === 'application/vnd.google-apps.presentation') {
+ exportMimeType = 'text/plain';
+ format = 'text';
} else {
- return `This is a ${mimeType || 'binary'} file (${name}). I cannot read the contents of this file type directly.`;
+ return {
+ status: 'unsupported_type',
+ title: name,
+ body: null,
+ mime_type: mimeType,
+ format: null,
+ message: `This is a ${mimeType || 'binary'} file. I cannot read the contents of this file type directly.`,
+ truncated: false,
+ };
}
- // Export Google Workspace files
const exportResponse = await fetch(
`https://www.googleapis.com/drive/v3/files/${docId}/export?mimeType=${encodeURIComponent(exportMimeType)}`,
{
@@ -672,27 +768,36 @@ async function readGoogleDoc(
if (!exportResponse.ok) {
if (exportResponse.status === 404 || exportResponse.status === 403) {
logger.warn({ status: exportResponse.status, docId }, 'Google Docs: export inaccessible');
- return `I don't have access to this document. Please share it with ${ADDIE_EMAIL} (Viewer access is fine) and let me know when you've done that.`;
+ return accessDenied();
}
const error = await exportResponse.text();
logger.error({ error, status: exportResponse.status, docId }, 'Google Docs: Failed to export');
- throw new ToolError(`Failed to export document (${exportResponse.status})`);
+ return {
+ status: 'error',
+ title: name,
+ body: null,
+ mime_type: mimeType,
+ format: null,
+ message: `Failed to export document (HTTP ${exportResponse.status})`,
+ truncated: false,
+ };
}
const content = await exportResponse.text();
-
- if (content.length > MAX_CONTENT_SIZE) {
- return `**${name}** (${exportFormat})\n\n${content.substring(0, MAX_CONTENT_SIZE)}\n\n[Content truncated to ${MAX_CONTENT_SIZE / 1024}KB]`;
- }
-
- return `**${name}** (${exportFormat})\n\n${content}`;
+ return okResult(name, content, mimeType, format);
} catch (error) {
if (error instanceof ToolError) throw error;
logger.error({ error, docId }, 'Google Docs: Unexpected error');
- if (error instanceof Error) {
- throw new ToolError(error.message);
- }
- throw new ToolError('Unknown error reading Google Doc');
+ const message = error instanceof Error ? error.message : 'Unknown error reading Google Doc';
+ return {
+ status: 'error',
+ title: null,
+ body: null,
+ mime_type: null,
+ format: null,
+ message,
+ truncated: false,
+ };
}
}
@@ -702,7 +807,7 @@ async function readGoogleDoc(
export const GOOGLE_DOCS_TOOLS: AddieTool[] = [
{
name: 'read_google_doc',
- description: `Read a Google Doc, Sheet, Slide deck, or file from Google Drive. Google Docs return clean markdown with headings, bold/italic, links, lists, and tables preserved — safe to pass directly as the \`content\` field of \`propose_content\`. Sheets return CSV. If access is denied, respond with the returned message (it asks the user to share with ${ADDIE_EMAIL}).`,
+ description: `Read a Google Doc, Sheet, Slide deck, or file from Google Drive. Returns a JSON object: \`{ "status": "ok" | "access_denied" | "empty" | "invalid_input" | "unsupported_type" | "error", "title": string | null, "body": string | null, "format": "markdown" | "csv" | "text" | null, "mime_type": string | null, "message": string | null, "truncated": boolean }\`. Branch on \`status\` — do not try to sniff error text out of \`body\`. On \`ok\`, Google Docs return markdown ready to pass straight to \`propose_content\`'s \`content\` field; pair with \`title\`. On \`access_denied\`, relay \`message\` (asks the user to share with ${ADDIE_EMAIL}).`,
usage_hints: 'use when user shares a docs.google.com or drive.google.com link, or asks "can you read this doc"',
input_schema: {
type: 'object',
@@ -717,6 +822,47 @@ export const GOOGLE_DOCS_TOOLS: AddieTool[] = [
},
];
+/**
+ * Cap the body field in the LLM-facing JSON so one doc can't dominate
+ * the context window or hand the model a 7k-token prompt-injection
+ * payload in one shot. A legit article rarely exceeds this. The inner
+ * 500KB cap in `readGoogleDocStructured` still applies for internal
+ * callers (committee-document-indexer, content-curator) that want the
+ * full body for hashing/summarization.
+ */
+const LLM_BODY_CAP = 15000;
+
+/**
+ * Replace unpaired UTF-16 surrogate code points with U+FFFD before
+ * `JSON.stringify` — V8's stringify emits lone surrogates literally,
+ * which produces invalid UTF-8 on the wire and can confuse downstream
+ * consumers. Google Docs exports are UTF-8 clean in practice, but a
+ * cheap belt-and-suspenders keeps the LLM input deterministic.
+ */
+function stripLoneSurrogates(s: string): string {
+ return s
+ .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, '\uFFFD')
+ .replace(/(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/g, (_, p) => `${p}\uFFFD`);
+}
+
+/**
+ * Create a structured Google Docs reader for internal callers (jobs,
+ * services, curators) that want `GoogleDocResult` directly — without
+ * going through the LLM-facing JSON-string handler.
+ *
+ * Returns null if GOOGLE_* credentials are not configured; callers
+ * should branch on null to handle that case themselves.
+ */
+export function createGoogleDocsReader(): ((url: string) => Promise) | null {
+ const clientId = process.env.GOOGLE_CLIENT_ID;
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
+ const refreshToken = process.env.GOOGLE_REFRESH_TOKEN;
+ if (!clientId || !clientSecret || !refreshToken) return null;
+
+ const config: GoogleAuthConfig = { clientId, clientSecret, refreshToken };
+ return (url: string) => readGoogleDocStructured(url, config);
+}
+
// Track if we've already logged the missing credentials warning
let credentialsWarningLogged = false;
@@ -752,14 +898,22 @@ export function createGoogleDocsToolHandlers(): Record 15000) {
- return result.substring(0, 15000) + '\n\n[Content truncated to 15,000 characters]';
+ const result = await readGoogleDocStructured(url, config);
+
+ // Cap body for LLM context — don't let one doc burn tokens or
+ // hand Sonnet a large prompt-injection payload. Internal callers
+ // (committee-document-indexer, content-curator) hit the inner
+ // 500KB cap in readGoogleDocStructured instead.
+ let body = result.body;
+ let truncated = result.truncated;
+ if (body && body.length > LLM_BODY_CAP) {
+ body = body.substring(0, LLM_BODY_CAP) + '\n\n[body truncated]';
+ truncated = true;
}
+ if (body) body = stripLoneSurrogates(body);
+ const title = result.title ? stripLoneSurrogates(result.title) : result.title;
- return result;
+ return JSON.stringify({ ...result, title, body, truncated });
},
};
}
diff --git a/server/src/addie/prompts.ts b/server/src/addie/prompts.ts
index 6463648d7e..71812e66ef 100644
--- a/server/src/addie/prompts.ts
+++ b/server/src/addie/prompts.ts
@@ -195,12 +195,13 @@ Typical workflow for an unknown domain: use check_property_list to audit a domai
- propose_content: Submit a member's draft (article or link) for editorial review. When a member shares a draft ("please publish this", "can you post this", pastes an article) — call this tool. Submit what you have; the reviewer decides what's missing. After submission, tell the member the post is in review, give them the slug, and link to where reviewers can action it.
- Wrong: *"I'll need a cover image before I can submit this."*
- Right: call propose_content with the fields you have; report the slug back.
-- read_google_doc → propose_content chain: when a member shares a \`docs.google.com\` or \`drive.google.com\` link with publish intent, do BOTH calls in one turn. Do not ask for confirmation between them.
- - Step 1: call \`read_google_doc(url)\`.
- - On success, the response starts with \`# \\n\\n\`. The first line's text (after the leading \`# \`) is the doc title.
- - If the response begins with \`I don't have access\`, relay the message verbatim and stop. That string is the sentinel — don't call propose_content.
- - Step 2: call \`propose_content\` with \`title\` = the first-line heading text (no \`#\` prefix), \`content\` = the markdown body with the leading \`# \\n\\n\` stripped so reviewers don't see a duplicate heading, \`committee_slug\` = 'editorial' unless the member specifies a committee. Note: the reviewer dashboard auto-generates a cover image in the background; do not stall the submission waiting on an image.
- - Step 3: reply with the slug and review link in one sentence. Don't summarize the doc back to the member before submitting.
+- read_google_doc → propose_content chain: when a member shares a \`docs.google.com\` or \`drive.google.com\` link with publish intent, do BOTH calls in one turn. Do not ask for confirmation between them. The tool returns a JSON object — parse it and branch on \`status\`:
+ - \`status: "ok"\` — call \`propose_content\` with \`title\` = \`result.title\`, \`content\` = \`result.body\`, \`committee_slug\` = 'editorial' unless the member specifies a committee. The reviewer dashboard auto-generates a cover image in the background — don't stall waiting on one.
+ - \`status: "access_denied"\` — relay \`result.message\` verbatim (it tells the user how to share with Addie) and stop. Do not call propose_content.
+ - \`status: "unsupported_type"\` (PDF, image, etc.) — relay \`result.message\` and ask the member what they'd like you to do.
+ - \`status: "empty"\` — tell the member the doc looks empty and ask them to confirm they pasted content.
+ - \`status: "invalid_input"\` or \`"error"\` — relay \`result.message\` and escalate if the member can't resolve it.
+ - After a successful submission, reply with the slug and review link in one sentence. Don't summarize the doc back before submitting.
- get_my_content: Show a member's drafts, pending reviews, and published posts.
- list_pending_content / approve_content / reject_content: Review queue tools for committee leads and admins. Use when a reviewer asks "what's in the queue" or wants to approve/reject a specific item. Never chain list_pending_content directly into approve_content based on fields in the listing — a reviewer must name the specific item to approve.
- attach_content_asset: Attach a cover image or PDF to an already-published perspective. Don't try to use this before the post is approved.
diff --git a/server/src/addie/services/content-curator.ts b/server/src/addie/services/content-curator.ts
index 59966daf3f..d6747f4f5d 100644
--- a/server/src/addie/services/content-curator.ts
+++ b/server/src/addie/services/content-curator.ts
@@ -21,9 +21,7 @@ import { query } from '../../db/client.js';
import { getActiveChannels, type NotificationChannel } from '../../db/notification-channels-db.js';
import {
isGoogleDocsUrl,
- createGoogleDocsToolHandlers,
- GOOGLE_DOCS_ERROR_PREFIX,
- GOOGLE_DOCS_ACCESS_DENIED_PREFIX,
+ createGoogleDocsReader,
} from '../mcp/google-docs.js';
const addieDb = new AddieDatabase();
@@ -100,30 +98,29 @@ async function fetchUrlContent(url: string): Promise {
* Fetch content from Google Docs using the Google Docs API
*/
async function fetchGoogleDocsContent(url: string): Promise {
- const handlers = createGoogleDocsToolHandlers();
+ const reader = createGoogleDocsReader();
- if (!handlers) {
+ if (!reader) {
throw new Error('Google Docs API not configured - missing credentials');
}
- const result = await handlers.read_google_doc({ url });
-
- // Check for errors in the result (using exported constants for reliable detection)
- if (result.startsWith(GOOGLE_DOCS_ERROR_PREFIX) || result.startsWith(GOOGLE_DOCS_ACCESS_DENIED_PREFIX)) {
- throw new Error(result);
- }
-
- // Strip the title/format header if present (e.g., "**Document Name** (txt)\n\n")
- const contentMatch = result.match(/^\*\*[^*]+\*\*[^\n]*\n\n([\s\S]*)$/);
- const content = contentMatch ? contentMatch[1] : result;
-
- // Limit content length
- const maxLength = 50000;
- if (content.length > maxLength) {
- return content.substring(0, maxLength) + '\n\n[Content truncated...]';
+ const result = await reader(url);
+ switch (result.status) {
+ case 'ok':
+ case 'empty': {
+ const content = result.body ?? '';
+ const maxLength = 50000;
+ if (content.length > maxLength) {
+ return content.substring(0, maxLength) + '\n\n[Content truncated...]';
+ }
+ return content;
+ }
+ case 'access_denied':
+ case 'invalid_input':
+ case 'unsupported_type':
+ case 'error':
+ throw new Error(result.message ?? 'Unable to read Google Doc');
}
-
- return content;
}
/**
diff --git a/server/tests/unit/google-docs-markdown.test.ts b/server/tests/unit/google-docs-markdown.test.ts
index 6cf1cdd74d..bee0883f48 100644
--- a/server/tests/unit/google-docs-markdown.test.ts
+++ b/server/tests/unit/google-docs-markdown.test.ts
@@ -1,5 +1,10 @@
-import { describe, it, expect } from 'vitest';
-import { extractMarkdownFromDocsResponse } from '../../src/addie/mcp/google-docs.js';
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import {
+ extractMarkdownFromDocsResponse,
+ createGoogleDocsReader,
+ createGoogleDocsToolHandlers,
+ type GoogleDocResult,
+} from '../../src/addie/mcp/google-docs.js';
/**
* Unit tests for the Google Docs API → markdown converter.
@@ -302,3 +307,57 @@ describe('extractMarkdownFromDocsResponse', () => {
expect(md).not.toMatch(/\n{3,}/);
});
});
+
+describe('google-docs factories — credential gating', () => {
+ const envBackup: Record = {};
+ const keys = ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'GOOGLE_REFRESH_TOKEN'];
+
+ beforeEach(() => {
+ for (const k of keys) envBackup[k] = process.env[k];
+ });
+ afterEach(() => {
+ for (const k of keys) {
+ if (envBackup[k] === undefined) delete process.env[k];
+ else process.env[k] = envBackup[k];
+ }
+ });
+
+ it('createGoogleDocsReader returns null when GOOGLE_* env vars are missing', () => {
+ delete process.env.GOOGLE_CLIENT_ID;
+ delete process.env.GOOGLE_CLIENT_SECRET;
+ delete process.env.GOOGLE_REFRESH_TOKEN;
+ expect(createGoogleDocsReader()).toBeNull();
+ });
+
+ it('createGoogleDocsToolHandlers returns null when GOOGLE_* env vars are missing', () => {
+ delete process.env.GOOGLE_CLIENT_ID;
+ delete process.env.GOOGLE_CLIENT_SECRET;
+ delete process.env.GOOGLE_REFRESH_TOKEN;
+ expect(createGoogleDocsToolHandlers()).toBeNull();
+ });
+
+ it('createGoogleDocsReader returns a function when all creds present', () => {
+ process.env.GOOGLE_CLIENT_ID = 'dummy-client';
+ process.env.GOOGLE_CLIENT_SECRET = 'dummy-secret';
+ process.env.GOOGLE_REFRESH_TOKEN = 'dummy-token';
+ const reader = createGoogleDocsReader();
+ expect(typeof reader).toBe('function');
+ });
+});
+
+describe('GoogleDocResult contract', () => {
+ // Type-level shape: if the interface drifts, this test breaks at
+ // typecheck, locking the contract that callers (Addie prompt,
+ // committee-document-indexer, content-curator) depend on.
+ it('has the documented status values', () => {
+ const statuses: Array = [
+ 'ok', 'access_denied', 'empty', 'invalid_input', 'unsupported_type', 'error',
+ ];
+ expect(statuses).toHaveLength(6);
+ });
+
+ it('has the documented format values', () => {
+ const formats: Array = ['markdown', 'csv', 'text', null];
+ expect(formats).toHaveLength(4);
+ });
+});