JacobLinCool Codex commited on
Commit
151c180
·
verified ·
1 Parent(s): 9a6f09d

fix: stream deployed advisor turns through app api

Browse files
Files changed (5) hide show
  1. .gitattributes +0 -44
  2. app.py +88 -51
  3. static/app.js +44 -20
  4. tests/test_app.py +40 -0
  5. tests/test_frontend_copy.py +3 -0
.gitattributes CHANGED
@@ -1,46 +1,2 @@
1
  # Auto detect text files and perform LF normalization
2
  * text=auto
3
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libavif.16.4.1.dylib filter=lfs diff=lfs merge=lfs -text
4
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libbrotlicommon.1.2.0.dylib filter=lfs diff=lfs merge=lfs -text
5
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libbrotlidec.1.2.0.dylib filter=lfs diff=lfs merge=lfs -text
6
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libfreetype.6.dylib filter=lfs diff=lfs merge=lfs -text
7
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libharfbuzz.0.dylib filter=lfs diff=lfs merge=lfs -text
8
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libjpeg.62.4.0.dylib filter=lfs diff=lfs merge=lfs -text
9
- .venv/lib/python3.11/site-packages/PIL/.dylibs/liblcms2.2.dylib filter=lfs diff=lfs merge=lfs -text
10
- .venv/lib/python3.11/site-packages/PIL/.dylibs/liblzma.5.dylib filter=lfs diff=lfs merge=lfs -text
11
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libopenjp2.2.5.4.dylib filter=lfs diff=lfs merge=lfs -text
12
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libpng16.16.dylib filter=lfs diff=lfs merge=lfs -text
13
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libtiff.6.dylib filter=lfs diff=lfs merge=lfs -text
14
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libwebp.7.dylib filter=lfs diff=lfs merge=lfs -text
15
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libwebpmux.3.dylib filter=lfs diff=lfs merge=lfs -text
16
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libxcb.1.1.0.dylib filter=lfs diff=lfs merge=lfs -text
17
- .venv/lib/python3.11/site-packages/PIL/.dylibs/libz.1.3.1.zlib-ng.dylib filter=lfs diff=lfs merge=lfs -text
18
- .venv/lib/python3.11/site-packages/dateutil/zoneinfo/dateutil-zoneinfo.tar.gz filter=lfs diff=lfs merge=lfs -text
19
- .venv/lib/python3.11/site-packages/gradio/media_assets/audio/cantina.wav filter=lfs diff=lfs merge=lfs -text
20
- .venv/lib/python3.11/site-packages/gradio/media_assets/audio/recording1.wav filter=lfs diff=lfs merge=lfs -text
21
- .venv/lib/python3.11/site-packages/gradio/media_assets/audio/sax.wav filter=lfs diff=lfs merge=lfs -text
22
- .venv/lib/python3.11/site-packages/gradio/media_assets/images/groot.jpeg filter=lfs diff=lfs merge=lfs -text
23
- .venv/lib/python3.11/site-packages/gradio/media_assets/images/tower.jpg filter=lfs diff=lfs merge=lfs -text
24
- .venv/lib/python3.11/site-packages/gradio/media_assets/models3d/Duck.glb filter=lfs diff=lfs merge=lfs -text
25
- .venv/lib/python3.11/site-packages/gradio/media_assets/models3d/sofia.stl filter=lfs diff=lfs merge=lfs -text
26
- .venv/lib/python3.11/site-packages/gradio/media_assets/videos/a.mp4 filter=lfs diff=lfs merge=lfs -text
27
- .venv/lib/python3.11/site-packages/gradio/media_assets/videos/b.avi filter=lfs diff=lfs merge=lfs -text
28
- .venv/lib/python3.11/site-packages/gradio/media_assets/videos/b.mp4 filter=lfs diff=lfs merge=lfs -text
29
- .venv/lib/python3.11/site-packages/gradio/media_assets/videos/world.mp4 filter=lfs diff=lfs merge=lfs -text
30
- .venv/lib/python3.11/site-packages/gradio/templates/frontend/static/fonts/Source[[:space:]]Sans[[:space:]]Pro/SourceSansPro-Bold.woff2 filter=lfs diff=lfs merge=lfs -text
31
- .venv/lib/python3.11/site-packages/gradio/templates/frontend/static/fonts/Source[[:space:]]Sans[[:space:]]Pro/SourceSansPro-Regular.woff2 filter=lfs diff=lfs merge=lfs -text
32
- .venv/lib/python3.11/site-packages/gradio/templates/node/build/client/_app/immutable/chunks/5J0xJHOV.js.br filter=lfs diff=lfs merge=lfs -text
33
- .venv/lib/python3.11/site-packages/gradio/templates/node/build/client/_app/immutable/chunks/BMdH9xbf.js.br filter=lfs diff=lfs merge=lfs -text
34
- .venv/lib/python3.11/site-packages/gradio/templates/node/build/client/_app/immutable/chunks/BmzEVe0D.js.br filter=lfs diff=lfs merge=lfs -text
35
- .venv/lib/python3.11/site-packages/gradio/templates/node/build/client/_app/immutable/chunks/CGkloIng.js.br filter=lfs diff=lfs merge=lfs -text
36
- .venv/lib/python3.11/site-packages/gradio/templates/node/build/client/_app/immutable/chunks/CLj6IfqE.js.br filter=lfs diff=lfs merge=lfs -text
37
- .venv/lib/python3.11/site-packages/gradio/templates/node/build/client/_app/immutable/chunks/CZccx8Oi.js.br filter=lfs diff=lfs merge=lfs -text
38
- .venv/lib/python3.11/site-packages/gradio/templates/node/build/client/_app/immutable/chunks/CgysOIik.js.br filter=lfs diff=lfs merge=lfs -text
39
- .venv/lib/python3.11/site-packages/gradio/templates/node/build/client/_app/immutable/chunks/DuZ9aF7D.js.br filter=lfs diff=lfs merge=lfs -text
40
- .venv/lib/python3.11/site-packages/gradio/templates/node/build/client/_app/immutable/chunks/Dufjs8Ut.js.br filter=lfs diff=lfs merge=lfs -text
41
- .venv/lib/python3.11/site-packages/gradio/templates/node/build/client/_app/immutable/chunks/Ku4z6Hhd.js.br filter=lfs diff=lfs merge=lfs -text
42
- .venv/lib/python3.11/site-packages/pip/_vendor/distlib/t64-arm.exe filter=lfs diff=lfs merge=lfs -text
43
- .venv/lib/python3.11/site-packages/pip/_vendor/distlib/t64.exe filter=lfs diff=lfs merge=lfs -text
44
- .venv/lib/python3.11/site-packages/pip/_vendor/distlib/w64-arm.exe filter=lfs diff=lfs merge=lfs -text
45
- .venv/lib/python3.11/site-packages/pip/_vendor/distlib/w64.exe filter=lfs diff=lfs merge=lfs -text
46
- .venv/lib/python3.11/site-packages/playwright/driver/node filter=lfs diff=lfs merge=lfs -text
 
1
  # Auto detect text files and perform LF normalization
2
  * text=auto
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -6,7 +6,7 @@ from pathlib import Path
6
  from typing import Any, Iterator
7
 
8
  from fastapi import Body
9
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
10
  from gradio import Server
11
 
12
  from hackathon_advisor.agent import AdvisorEngine
@@ -46,6 +46,48 @@ def _engine_turn(message: str, session: dict[str, Any]):
46
  return engine.turn(message, session)
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  @app.get("/", response_class=HTMLResponse)
50
  def home() -> FileResponse:
51
  return FileResponse(STATIC_DIR / "index.html")
@@ -135,6 +177,45 @@ def artifact_png(artifact: dict[str, Any] | None = Body(default=None)) -> Respon
135
  )
136
 
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  @app.get("/api/lora-training-kit.zip")
139
  def lora_training_kit() -> Response:
140
  runtime_status = engine.runtime_status()
@@ -160,19 +241,13 @@ def tool_contract_check(model_output: str, fallback_query: str = "") -> dict:
160
 
161
  @app.api(name="trace_artifact", concurrency_limit=8)
162
  def trace_artifact(session_json: str = "{}") -> str:
163
- try:
164
- session = json.loads(session_json or "{}")
165
- except json.JSONDecodeError:
166
- session = {}
167
  return build_trace_jsonl(session, trace_metadata(index))
168
 
169
 
170
  @app.api(name="field_notes", concurrency_limit=8)
171
  def field_notes_artifact(session_json: str = "{}") -> str:
172
- try:
173
- session = json.loads(session_json or "{}")
174
- except json.JSONDecodeError:
175
- session = {}
176
  return build_field_notes_markdown(
177
  session,
178
  {
@@ -184,10 +259,7 @@ def field_notes_artifact(session_json: str = "{}") -> str:
184
 
185
  @app.api(name="chapter", concurrency_limit=8)
186
  def chapter_artifact(session_json: str = "{}") -> str:
187
- try:
188
- session = json.loads(session_json or "{}")
189
- except json.JSONDecodeError:
190
- session = {}
191
  return build_chapter_markdown(
192
  session,
193
  {
@@ -199,10 +271,7 @@ def chapter_artifact(session_json: str = "{}") -> str:
199
 
200
  @app.api(name="lora_dataset", concurrency_limit=8)
201
  def lora_dataset_artifact(session_json: str = "{}") -> str:
202
- try:
203
- session = json.loads(session_json or "{}")
204
- except json.JSONDecodeError:
205
- session = {}
206
  return build_lora_dataset_jsonl(
207
  session,
208
  {
@@ -214,10 +283,7 @@ def lora_dataset_artifact(session_json: str = "{}") -> str:
214
 
215
  @app.api(name="submission_packet", concurrency_limit=8)
216
  def submission_packet_artifact(session_json: str = "{}") -> str:
217
- try:
218
- session = json.loads(session_json or "{}")
219
- except json.JSONDecodeError:
220
- session = {}
221
  runtime_status = engine.runtime_status()
222
  return build_submission_packet_markdown(
223
  session,
@@ -231,36 +297,7 @@ def submission_packet_artifact(session_json: str = "{}") -> str:
231
 
232
  @app.api(name="agent_turn", concurrency_limit=4, stream_every=0.04)
233
  def agent_turn(message: str, session_json: str = "{}") -> Iterator[str]:
234
- try:
235
- session = json.loads(session_json or "{}")
236
- except json.JSONDecodeError:
237
- session = {}
238
-
239
- result = _engine_turn(message, session)
240
- yield _json_event(
241
- {
242
- "type": "start",
243
- "corrections": [correction.to_dict() for correction in result.corrections],
244
- "normalized_text": result.normalized_text,
245
- "tool_events": [event.to_dict() for event in result.tool_events],
246
- }
247
- )
248
-
249
- for chunk in result.stream_chunks():
250
- yield _json_event({"type": "token", "text": chunk})
251
-
252
- yield _json_event(
253
- {
254
- "type": "done",
255
- "state": result.state,
256
- "response": result.response,
257
- "projects": [project.to_public_dict() for project in result.projects],
258
- "whitespace": [item.to_dict() for item in result.whitespace],
259
- "score": result.score.to_dict() if result.score else None,
260
- "plan": result.plan,
261
- "artifact": result.artifact,
262
- }
263
- )
264
 
265
 
266
  if __name__ == "__main__":
 
6
  from typing import Any, Iterator
7
 
8
  from fastapi import Body
9
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
10
  from gradio import Server
11
 
12
  from hackathon_advisor.agent import AdvisorEngine
 
46
  return engine.turn(message, session)
47
 
48
 
49
+ def _session_from_json(session_json: str = "{}") -> dict[str, Any]:
50
+ try:
51
+ session = json.loads(session_json or "{}")
52
+ except json.JSONDecodeError:
53
+ return {}
54
+ return session if isinstance(session, dict) else {}
55
+
56
+
57
+ def _session_from_payload(payload: dict[str, Any] | None) -> dict[str, Any]:
58
+ payload = payload or {}
59
+ return _session_from_json(str(payload.get("session_json") or "{}"))
60
+
61
+
62
+ def _agent_turn_events(message: str, session_json: str = "{}") -> Iterator[str]:
63
+ session = _session_from_json(session_json)
64
+ result = _engine_turn(message, session)
65
+ yield _json_event(
66
+ {
67
+ "type": "start",
68
+ "corrections": [correction.to_dict() for correction in result.corrections],
69
+ "normalized_text": result.normalized_text,
70
+ "tool_events": [event.to_dict() for event in result.tool_events],
71
+ }
72
+ )
73
+
74
+ for chunk in result.stream_chunks():
75
+ yield _json_event({"type": "token", "text": chunk})
76
+
77
+ yield _json_event(
78
+ {
79
+ "type": "done",
80
+ "state": result.state,
81
+ "response": result.response,
82
+ "projects": [project.to_public_dict() for project in result.projects],
83
+ "whitespace": [item.to_dict() for item in result.whitespace],
84
+ "score": result.score.to_dict() if result.score else None,
85
+ "plan": result.plan,
86
+ "artifact": result.artifact,
87
+ }
88
+ )
89
+
90
+
91
  @app.get("/", response_class=HTMLResponse)
92
  def home() -> FileResponse:
93
  return FileResponse(STATIC_DIR / "index.html")
 
177
  )
178
 
179
 
180
+ @app.post("/api/agent-turn")
181
+ def agent_turn_stream(payload: dict[str, Any] | None = Body(default=None)) -> StreamingResponse:
182
+ payload = payload or {}
183
+ message = str(payload.get("message") or "")
184
+ session_json = str(payload.get("session_json") or "{}")
185
+
186
+ def stream() -> Iterator[str]:
187
+ for event in _agent_turn_events(message, session_json):
188
+ yield f"{event}\n"
189
+
190
+ return StreamingResponse(stream(), media_type="application/x-ndjson")
191
+
192
+
193
+ @app.post("/api/field-notes")
194
+ def field_notes_api(payload: dict[str, Any] | None = Body(default=None)) -> Response:
195
+ session = _session_from_payload(payload)
196
+ content = build_field_notes_markdown(
197
+ session,
198
+ {
199
+ **trace_metadata(index),
200
+ "project_count": len(index.projects),
201
+ },
202
+ )
203
+ return Response(content=content, media_type="text/markdown; charset=utf-8")
204
+
205
+
206
+ @app.post("/api/chapter")
207
+ def chapter_api(payload: dict[str, Any] | None = Body(default=None)) -> Response:
208
+ session = _session_from_payload(payload)
209
+ content = build_chapter_markdown(
210
+ session,
211
+ {
212
+ **trace_metadata(index),
213
+ "project_count": len(index.projects),
214
+ },
215
+ )
216
+ return Response(content=content, media_type="text/markdown; charset=utf-8")
217
+
218
+
219
  @app.get("/api/lora-training-kit.zip")
220
  def lora_training_kit() -> Response:
221
  runtime_status = engine.runtime_status()
 
241
 
242
  @app.api(name="trace_artifact", concurrency_limit=8)
243
  def trace_artifact(session_json: str = "{}") -> str:
244
+ session = _session_from_json(session_json)
 
 
 
245
  return build_trace_jsonl(session, trace_metadata(index))
246
 
247
 
248
  @app.api(name="field_notes", concurrency_limit=8)
249
  def field_notes_artifact(session_json: str = "{}") -> str:
250
+ session = _session_from_json(session_json)
 
 
 
251
  return build_field_notes_markdown(
252
  session,
253
  {
 
259
 
260
  @app.api(name="chapter", concurrency_limit=8)
261
  def chapter_artifact(session_json: str = "{}") -> str:
262
+ session = _session_from_json(session_json)
 
 
 
263
  return build_chapter_markdown(
264
  session,
265
  {
 
271
 
272
  @app.api(name="lora_dataset", concurrency_limit=8)
273
  def lora_dataset_artifact(session_json: str = "{}") -> str:
274
+ session = _session_from_json(session_json)
 
 
 
275
  return build_lora_dataset_jsonl(
276
  session,
277
  {
 
283
 
284
  @app.api(name="submission_packet", concurrency_limit=8)
285
  def submission_packet_artifact(session_json: str = "{}") -> str:
286
+ session = _session_from_json(session_json)
 
 
 
287
  runtime_status = engine.runtime_status()
288
  return build_submission_packet_markdown(
289
  session,
 
297
 
298
  @app.api(name="agent_turn", concurrency_limit=4, stream_every=0.04)
299
  def agent_turn(message: str, session_json: str = "{}") -> Iterator[str]:
300
+ yield from _agent_turn_events(message, session_json)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
 
302
 
303
  if __name__ == "__main__":
static/app.js CHANGED
@@ -1,5 +1,3 @@
1
- import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
2
-
3
  const form = document.querySelector("#turn-form");
4
  const input = document.querySelector("#message");
5
  const submit = document.querySelector("#submit");
@@ -35,7 +33,6 @@ const CHAPTER_FILENAME = "hackathon-advisor-chapter.md";
35
  const PNG_EXPORT_LABEL = "PNG";
36
 
37
  let session = {};
38
- let clientPromise = Client.connect(window.location.origin);
39
  let currentArtifact = null;
40
  let goalOptions = [];
41
  let goalProfiles = [];
@@ -150,18 +147,19 @@ async function runTurn(message) {
150
 
151
  let completed = false;
152
  try {
153
- const client = await clientPromise;
154
- const submission = client.submit("/agent_turn", {
155
- message,
156
- session_json: JSON.stringify(session),
 
 
 
157
  });
 
 
158
 
159
- for await (const event of submission) {
160
- if (event.type !== "data") continue;
161
- const payloads = Array.isArray(event.data) ? event.data : [event.data];
162
- for (const raw of payloads) {
163
- handleEvent(JSON.parse(raw));
164
- }
165
  }
166
  completed = true;
167
  } catch (error) {
@@ -179,6 +177,29 @@ async function runTurn(message) {
179
  return completed;
180
  }
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  async function runCommand(command) {
183
  if (!command) return;
184
  const draft = input.value.trim();
@@ -993,7 +1014,7 @@ function setSessionStatus(message) {
993
 
994
  async function exportNotes() {
995
  await exportMarkdown({
996
- endpoint: "/field_notes",
997
  filename: FIELD_NOTES_FILENAME,
998
  button: exportNotesButton,
999
  busyLabel: "Notes...",
@@ -1004,7 +1025,7 @@ async function exportNotes() {
1004
 
1005
  async function exportChapter() {
1006
  await exportMarkdown({
1007
- endpoint: "/chapter",
1008
  filename: CHAPTER_FILENAME,
1009
  button: exportChapterButton,
1010
  busyLabel: "Chapter...",
@@ -1023,12 +1044,15 @@ async function exportMarkdown({ endpoint, filename, button, busyLabel, pendingLa
1023
  corrections.textContent = session.ui_status;
1024
  saveSession();
1025
  try {
1026
- const client = await clientPromise;
1027
- const result = await client.predict(endpoint, {
1028
- session_json: JSON.stringify(session),
 
 
 
1029
  });
1030
- const data = Array.isArray(result.data) ? result.data[0] : result.data;
1031
- const text = String(data || "");
1032
  if (!text.trim()) throw new Error("empty export");
1033
  if (!isCurrentSessionRevision(revision)) return;
1034
  downloadText(filename, text, "text/markdown;charset=utf-8");
 
 
 
1
  const form = document.querySelector("#turn-form");
2
  const input = document.querySelector("#message");
3
  const submit = document.querySelector("#submit");
 
33
  const PNG_EXPORT_LABEL = "PNG";
34
 
35
  let session = {};
 
36
  let currentArtifact = null;
37
  let goalOptions = [];
38
  let goalProfiles = [];
 
147
 
148
  let completed = false;
149
  try {
150
+ const response = await fetch("/api/agent-turn", {
151
+ method: "POST",
152
+ headers: { "Content-Type": "application/json" },
153
+ body: JSON.stringify({
154
+ message,
155
+ session_json: JSON.stringify(session),
156
+ }),
157
  });
158
+ if (!response.ok) throw new Error(`advisor failed with ${response.status}`);
159
+ if (!response.body) throw new Error("advisor stream was empty");
160
 
161
+ for await (const raw of readNdjson(response.body)) {
162
+ handleEvent(JSON.parse(raw));
 
 
 
 
163
  }
164
  completed = true;
165
  } catch (error) {
 
177
  return completed;
178
  }
179
 
180
+ async function* readNdjson(stream) {
181
+ const reader = stream.getReader();
182
+ const decoder = new TextDecoder();
183
+ let buffer = "";
184
+
185
+ while (true) {
186
+ const { value, done } = await reader.read();
187
+ if (done) break;
188
+ buffer += decoder.decode(value, { stream: true });
189
+ let newlineIndex = buffer.indexOf("\n");
190
+ while (newlineIndex >= 0) {
191
+ const line = buffer.slice(0, newlineIndex).trim();
192
+ buffer = buffer.slice(newlineIndex + 1);
193
+ if (line) yield line;
194
+ newlineIndex = buffer.indexOf("\n");
195
+ }
196
+ }
197
+
198
+ buffer += decoder.decode();
199
+ const finalLine = buffer.trim();
200
+ if (finalLine) yield finalLine;
201
+ }
202
+
203
  async function runCommand(command) {
204
  if (!command) return;
205
  const draft = input.value.trim();
 
1014
 
1015
  async function exportNotes() {
1016
  await exportMarkdown({
1017
+ endpoint: "/api/field-notes",
1018
  filename: FIELD_NOTES_FILENAME,
1019
  button: exportNotesButton,
1020
  busyLabel: "Notes...",
 
1025
 
1026
  async function exportChapter() {
1027
  await exportMarkdown({
1028
+ endpoint: "/api/chapter",
1029
  filename: CHAPTER_FILENAME,
1030
  button: exportChapterButton,
1031
  busyLabel: "Chapter...",
 
1044
  corrections.textContent = session.ui_status;
1045
  saveSession();
1046
  try {
1047
+ const response = await fetch(endpoint, {
1048
+ method: "POST",
1049
+ headers: { "Content-Type": "application/json" },
1050
+ body: JSON.stringify({
1051
+ session_json: JSON.stringify(session),
1052
+ }),
1053
  });
1054
+ if (!response.ok) throw new Error(`export failed with ${response.status}`);
1055
+ const text = await response.text();
1056
  if (!text.trim()) throw new Error("empty export");
1057
  if (!isCurrentSessionRevision(revision)) return;
1058
  downloadText(filename, text, "text/markdown;charset=utf-8");
tests/test_app.py CHANGED
@@ -1,14 +1,18 @@
1
  import json
 
2
  from io import BytesIO
3
  from zipfile import ZipFile
4
 
5
  from app import (
 
6
  artifact_png,
7
  bootstrap,
 
8
  chapter_artifact,
9
  demo_bundle,
10
  demo_session,
11
  engine,
 
12
  field_notes_artifact,
13
  health,
14
  index,
@@ -23,6 +27,13 @@ from app import (
23
  )
24
 
25
 
 
 
 
 
 
 
 
26
  def test_health_exposes_index_metadata() -> None:
27
  payload = health()
28
 
@@ -50,6 +61,35 @@ def test_bootstrap_exposes_index_metadata() -> None:
50
  assert all("trace" not in goal["description"].lower() for goal in payload["goal_profiles"])
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def test_trace_artifact_endpoint_exports_jsonl() -> None:
54
  state = engine.turn("A local-first archive cartographer for family photos", {}).state
55
  payload = trace_artifact(json.dumps(state))
 
1
  import json
2
+ import asyncio
3
  from io import BytesIO
4
  from zipfile import ZipFile
5
 
6
  from app import (
7
+ agent_turn_stream,
8
  artifact_png,
9
  bootstrap,
10
+ chapter_api,
11
  chapter_artifact,
12
  demo_bundle,
13
  demo_session,
14
  engine,
15
+ field_notes_api,
16
  field_notes_artifact,
17
  health,
18
  index,
 
27
  )
28
 
29
 
30
+ async def _read_streaming_response(response) -> str:
31
+ chunks = []
32
+ async for chunk in response.body_iterator:
33
+ chunks.append(chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk)
34
+ return "".join(chunks)
35
+
36
+
37
  def test_health_exposes_index_metadata() -> None:
38
  payload = health()
39
 
 
61
  assert all("trace" not in goal["description"].lower() for goal in payload["goal_profiles"])
62
 
63
 
64
+ def test_agent_turn_stream_endpoint_exports_ndjson_events() -> None:
65
+ response = agent_turn_stream(
66
+ {
67
+ "message": "A local-first archive cartographer for family photos",
68
+ "session_json": "{}",
69
+ }
70
+ )
71
+ payload = asyncio.run(_read_streaming_response(response))
72
+ lines = [json.loads(line) for line in payload.splitlines()]
73
+
74
+ assert response.media_type == "application/x-ndjson"
75
+ assert lines[0]["type"] == "start"
76
+ assert any(line["type"] == "token" for line in lines)
77
+ assert lines[-1]["type"] == "done"
78
+ assert lines[-1]["state"]["ideas"]
79
+
80
+
81
+ def test_markdown_api_endpoints_return_plain_markdown() -> None:
82
+ state = engine.turn("A local-first archive cartographer for family photos", {}).state
83
+
84
+ notes = field_notes_api({"session_json": json.dumps(state)})
85
+ chapter = chapter_api({"session_json": json.dumps(state)})
86
+
87
+ assert notes.media_type == "text/markdown; charset=utf-8"
88
+ assert notes.body.decode("utf-8").startswith("# Hackathon Advisor Field Notes")
89
+ assert chapter.media_type == "text/markdown; charset=utf-8"
90
+ assert chapter.body.decode("utf-8").startswith("# The Unwritten Almanac Chapter")
91
+
92
+
93
  def test_trace_artifact_endpoint_exports_jsonl() -> None:
94
  state = engine.turn("A local-first archive cartographer for family photos", {}).state
95
  payload = trace_artifact(json.dumps(state))
tests/test_frontend_copy.py CHANGED
@@ -12,6 +12,9 @@ def test_main_interface_copy_is_builder_facing() -> None:
12
  assert "Loading an example idea board." in app_js
13
  assert "Example idea board loaded with a plan and share page." in app_js
14
  assert "/api/artifact.png" in app_js
 
 
 
15
  assert "renderArtifactCanvas" not in app_js
16
  assert "canvas.toDataURL" not in app_js
17
  assert 'aria-label="Export build notes"' in html
 
12
  assert "Loading an example idea board." in app_js
13
  assert "Example idea board loaded with a plan and share page." in app_js
14
  assert "/api/artifact.png" in app_js
15
+ assert "/api/agent-turn" in app_js
16
+ assert "readNdjson" in app_js
17
+ assert "@gradio/client" not in app_js
18
  assert "renderArtifactCanvas" not in app_js
19
  assert "canvas.toDataURL" not in app_js
20
  assert 'aria-label="Export build notes"' in html