Skip to content
Open
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
37 changes: 36 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,41 @@ jobs:
- run: npm run test:coverage
- run: npm run build

# -----------------------------------------------------------------------
# Word add-in: hermetic Office.js browser tests. Unlike the main web E2E
# suite, these use an in-page Word host mock and require no Supabase stack,
# model key, or external services, so they can gate every PR cheaply.
# -----------------------------------------------------------------------
word-addin:
name: Word add-in build and tests
runs-on: ubuntu-latest
env:
REACT_APP_API_BASE_URL: https://api.example.invalid
REACT_APP_SUPABASE_URL: https://supabase.example.invalid
REACT_APP_SUPABASE_ANON_KEY: ci-publishable-key
REACT_APP_WEB_APP_URL: https://app.example.invalid
WORD_ADDIN_PUBLIC_URL: https://word.example.invalid
defaults:
run:
working-directory: word-addin
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: word-addin/package-lock.json

- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run typecheck
- run: npm run build
- run: npx office-addin-manifest validate manifest.xml
- run: npx office-addin-manifest validate dist/manifest.xml
- run: npm run build:e2e
- run: npm run test:e2e

# -----------------------------------------------------------------------
# Slower job: migration validation against real local Supabase (3-5 min)
# Runs on PRs and pushes. Supabase's local stack applies the migrations
Expand Down Expand Up @@ -178,7 +213,7 @@ jobs:
deploy-migrations:
name: Deploy migrations
runs-on: ubuntu-latest
needs: [api, packages, web, migrations, evals, python-sdk]
needs: [api, packages, web, word-addin, migrations, evals, python-sdk]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"build:word-addin": "npm run build --prefix word-addin",
"typecheck:word-addin": "npm run typecheck --prefix word-addin",
"test:word-addin": "npm run build:e2e --prefix word-addin && npm run test:e2e --prefix word-addin",
"test:python-sdk": "cd sdks/python && python3 -m pytest",
"test:evals": "node evals/run.mjs --threshold 1.0",
"test:all": "npm run test --workspaces --if-present && npm run test:evals && npm run test:python-sdk",
"verify:all": "npm run lint --workspaces --if-present && npm run typecheck --workspaces --if-present && npm run build --workspaces --if-present && npm run test:all"
"test:all": "npm run test --workspaces --if-present && npm run test:evals && npm run test:word-addin && npm run test:python-sdk",
"verify:all": "npm run lint --workspaces --if-present && npm run typecheck --workspaces --if-present && npm run typecheck:word-addin && npm run build --workspaces --if-present && npm run build:word-addin && npm run test:all"
},
"devDependencies": {
"@playwright/test": "^1.61.1"
Expand Down
85 changes: 85 additions & 0 deletions packages/api-client/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,26 @@ import {
isMfaRequiredError,
mapTRMessages,
MikeApiError,
readSSE,
} from "./index";

// Build a fake `Response` whose body is a `ReadableStream` that emits the given
// chunks (as UTF-8 bytes). Lets readSSE be exercised without a network.
function sseResponse(chunks: string[]): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
return new Response(body, {
headers: { "content-type": "text/event-stream" },
});
}

// This client hand-remaps the server's snake_case JSON into the camelCase (and
// event-flattened) shapes the apps render. That remap is exactly where contract
// drift bites — a renamed or newly-nullable server field silently produces
Expand Down Expand Up @@ -282,6 +300,73 @@ describe("error-shape mapping", () => {
});
});

describe("readSSE", () => {
it("delivers content frames to onEvent in order", async () => {
const events: unknown[] = [];
await readSSE(
sseResponse([
'data: {"type":"content","text":"Hello "}\n',
'data: {"type":"content","text":"world"}\n',
"data: [DONE]\n",
]),
(data) => events.push(data),
);
expect(events).toEqual([
{ type: "content", text: "Hello " },
{ type: "content", text: "world" },
]);
});

it("treats [DONE] as terminal — frames after it are not delivered", async () => {
const events: unknown[] = [];
await readSSE(
sseResponse([
'data: {"type":"content","text":"hi"}\n',
"data: [DONE]\n",
// The backend emits this harmless trailing frame after [DONE];
// callers rely on never seeing it.
'data: {"type":"error"}\n',
]),
(data) => events.push(data),
);
expect(events).toEqual([{ type: "content", text: "hi" }]);
});

it("ignores non-data lines and unparseable JSON", async () => {
const events: unknown[] = [];
await readSSE(
sseResponse([
"event: message\n",
": this is an SSE comment\n",
"data: not-json{\n",
"\n",
'data: {"type":"content","text":"ok"}\n',
"data: [DONE]\n",
]),
(data) => events.push(data),
);
expect(events).toEqual([{ type: "content", text: "ok" }]);
});

it("reports done:true when [DONE] terminates the stream, false otherwise", async () => {
const withDone = await readSSE(
sseResponse([
'data: {"type":"content","text":"x"}\n',
"data: [DONE]\n",
]),
() => {},
);
expect(withDone).toEqual({ done: true });

// Stream that ends (reader EOF) without ever sending [DONE].
const withoutDone = await readSSE(
sseResponse(['data: {"type":"content","text":"x"}\n']),
() => {},
);
expect(withoutDone).toEqual({ done: false });
});
});

describe("getCourtlistenerOpinions", () => {
// getCourtlistenerOpinions uses the module-global client, so configure it
// and reset afterwards.
Expand Down
73 changes: 73 additions & 0 deletions packages/api-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,7 @@ export async function streamChat(payload: {
chat_id?: string;
project_id?: string;
model?: string;
documentContext?: string;
ask_inputs_response?: {
responses: (
| {
Expand Down Expand Up @@ -1204,6 +1205,78 @@ export async function streamChat(payload: {
});
}

/**
* Read an SSE (text/event-stream) response body frame-by-frame and hand each
* parsed `data:` payload to `onEvent`. Pure transport: it does not interpret
* event types — that is the caller's job. The Word add-in uses this shared
* transport instead of carrying a second, subtly different SSE parser.
*
* `[DONE]` is terminal: the backend emits a harmless trailing `{"type":"error"}`
* frame after it, and callers rely on never seeing anything past `[DONE]`.
*/
export async function readSSE(
response: Response,
onEvent: (data: unknown) => void,
options?: { signal?: AbortSignal },
): Promise<{ done: boolean }> {
if (!response.body) {
throw new Error("Response body is null — streaming not supported");
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let cancelled = false;
const cancel = async () => {
if (cancelled) return;
cancelled = true;
await reader.cancel().catch(() => {});
};

// Abort the read if the caller's signal fires mid-stream.
const signal = options?.signal;
const onAbort = () => {
void cancel();
};
signal?.addEventListener("abort", onAbort);

// Returns true when the line was the terminal [DONE] frame.
const processLine = (line: string): boolean => {
const trimmed = line.trim();
if (!trimmed) return false;
if (!trimmed.startsWith("data:")) return false;
const dataStr = trimmed.slice(5).trim();
if (dataStr === "[DONE]") return true;
try {
const parsed = JSON.parse(dataStr);
onEvent(parsed);
} catch {
// Malformed control noise — swallow silently.
}
return false;
};

try {
if (signal?.aborted) return { done: false };
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) {
// Flush any trailing partial line; report whether it was [DONE].
return { done: processLine(buffer) };
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (processLine(line)) return { done: true };
}
}
} finally {
signal?.removeEventListener("abort", onAbort);
await cancel();
}
}

type StreamChatMessage = {
role: string;
content: string;
Expand Down
5 changes: 5 additions & 0 deletions word-addin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Playwright E2E artifacts
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
Loading
Loading