diff --git a/gateway/app.py b/gateway/app.py index 67e268b..89107ac 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -15098,6 +15098,15 @@ async def launch_kit(kit_id: str, request: Request, body_in: KitLaunchBody | Non # current package on the next press of Launch. Same version: nothing is rewritten. existing = await _kit_plugin_ensure(org, hid0, existing, kit_plugin, tuple( str(d["name"]) for d in (decl, media_decl) if d and d.get("name"))) or existing + # The prompt too: it is the kit's, like the package, and a kit whose prompt changed + # (the sheet one gained its output contract on 2026-09-22) must reach the Harness + # people already run, or the fix ships to new launches only. + want_prompt = str((kit.get("harness") or {}).get("system_prompt") or "") + if want_prompt and str(existing.get("system_prompt") or "") != want_prompt: + await _vg_upsert("Harness", hid0, {"system_prompt": want_prompt, + "updated_at": str(int(time.time() * 1000))}) + existing = await _vertex_get(hid0) or {**existing, "system_prompt": want_prompt} + print(f"[kits] {kit_id}: prompt refreshed on {hid0}", flush=True) return {"kit": kit_id, "harnessId": hid0, "route": (kit.get("app") or {}).get("route") or "", "created": False, "harness": _harness_out(existing)} diff --git a/gateway/tests/test_kits_workspace.py b/gateway/tests/test_kits_workspace.py index 96807dc..a61fa14 100644 --- a/gateway/tests/test_kits_workspace.py +++ b/gateway/tests/test_kits_workspace.py @@ -116,3 +116,21 @@ async def _mcp_write(hid, servers): assert len(conns) == 1 and conns[0]["harness"] == hid and conns[0]["secret_key"] == key assert conns[0]["workspace"] == "org.a__ws1" and conns[0]["database"] == "shop" and "u:p@" not in json.dumps(conns[0]) assert asyncio.run(gw._connections_of("org.a", hid))[0]["database"] == "shop" + + +def test_a_relaunch_carries_the_kits_current_prompt(world, monkeypatch): + """The prompt is the kit's, like its package: a kit whose prompt changed reaches the Harness a + workspace already runs on the next launch, and an unchanged prompt rewrites nothing.""" + store, who = world + kit = json.loads(json.dumps(KIT)); kit["harness"]["system_prompt"] = "Build decks." + monkeypatch.setattr(gw, "_kits", lambda: {"slides": kit}) + hid = asyncio.run(gw.launch_kit("slides", _Req(), None))["harnessId"] + assert store[hid]["system_prompt"] == "Build decks." + stamp = store[hid]["updated_at"] + again = asyncio.run(gw.launch_kit("slides", _Req(), None)) + assert not again["created"] and store[hid]["updated_at"] == stamp # same prompt: untouched + kit["harness"]["system_prompt"] = "Build decks. The person sees only ./deck.json." + third = asyncio.run(gw.launch_kit("slides", _Req(), None)) + assert not third["created"] and third["harnessId"] == hid + assert store[hid]["system_prompt"] == "Build decks. The person sees only ./deck.json." + assert third["harness"]["systemPrompt"] == "Build decks. The person sees only ./deck.json." diff --git a/ui/src/app/(app)/kits/page.tsx b/ui/src/app/(app)/kits/page.tsx index 58158f6..183385b 100644 --- a/ui/src/app/(app)/kits/page.tsx +++ b/ui/src/app/(app)/kits/page.tsx @@ -83,17 +83,17 @@ export default function KitsPage() { .catch(() => {}); }, []); - /** A kit app runs outside this Next app, so it gets its own tab. */ - /** The kit app is this origin's own page, so the tab keeps its handle: `noopener` makes - * window.open return null, which is what left the tab launch opened blank forever. */ - function openApp(route: string) { - window.open(route, '_blank'); - } - + /** Launch, and open the kit app in its own tab (it runs outside this Next app). Opening a + * kit that is already running goes through the same call: launch is idempotent, and it is + * where a running kit picks up what its kit definition has changed since (its package, its + * prompt, the tools it needs), so a kit fixed after someone launched it is fixed for them the + * next time they open it, not only for whoever launches it fresh. + * + * The tab is opened NOW, on the click, and navigated when the launch returns. Opening it after + * the await is a popup the browser is entitled to block, because by then it is no longer a + * user gesture. The kit app is this origin's own page, so the tab keeps its handle: `noopener` + * makes window.open return null, which is what left the tab launch opened blank forever. */ async function launch(kit: Kit, base?: string, model?: string, database?: DbDraft) { - // Open the tab NOW, on the click, and navigate it when the launch returns. Opening it after - // the await is a popup the browser is entitled to block, because by then it is no longer a - // user gesture. const tab = window.open('', '_blank'); setBusy(kit.id); setErr(''); try { @@ -115,7 +115,7 @@ export default function KitsPage() { if (!r.ok) throw new Error((await r.json().catch(() => null))?.detail || `${r.status}`); const { route } = await r.json(); const url = route || `/kits/${kit.id}`; - if (tab) tab.location.href = url; else openApp(url); + if (tab) tab.location.href = url; else window.location.assign(url); setPicking(null); setBusy(''); reload(); } catch (e) { @@ -233,8 +233,8 @@ export default function KitsPage() { )}