fix(client): TagExtractor tolerates paren-less moves + scrubs misformatted move tags (live-review LF1/LF2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:15:18 -05:00
parent 86d1352b19
commit 2b1c11b1e6
2 changed files with 45 additions and 1 deletions

View File

@@ -7,8 +7,9 @@ extends RefCounted
static var _fact_re: RegEx = RegEx.create_from_string("\\[FACT:\\s*(.*?)\\]")
static var _dispo_re: RegEx = RegEx.create_from_string("\\[ADJUST_DISPOSITION:\\s*([+-]?\\d+)\\]")
static var _move_re: RegEx = RegEx.create_from_string("\\[MOVE:\\s*(\\w+)\\(([^)]*)\\)\\]")
static var _move_re: RegEx = RegEx.create_from_string("\\[MOVE:\\s*(\\w+)(?:\\(([^)]*)\\))?\\]")
static var _any_re: RegEx = RegEx.create_from_string("\\[[A-Z_]+:[^\\]]*\\]")
static var _bare_move_re: RegEx = RegEx.create_from_string("\\[[a-z_]+\\([^\\]]*\\)\\]")
static func extract(prose: String) -> Dictionary:
@@ -30,6 +31,7 @@ static func extract(prose: String) -> Dictionary:
moves.append({"name": m.get_string(1), "args": args})
var clean := _any_re.sub(prose, "", true)
clean = _bare_move_re.sub(clean, "", true)
# Collapse whitespace left where inline tags were removed.
while clean.contains(" "):
clean = clean.replace(" ", " ")

View File

@@ -38,3 +38,45 @@ func test_no_tags_is_clean_passthrough():
assert_eq(r["dispositions"], [])
assert_eq(r["moves"], [])
assert_eq(r["clean_text"], "no tags here")
# LF1 — a live model emitted [MOVE: refuse] with no parens at all. The move
# must still be recognized so the legal move isn't silently dropped.
func test_move_without_parens_is_parsed():
var r := TagExtractor.extract("[MOVE: refuse]")
assert_eq(r["moves"][0]["name"], "refuse")
assert_eq(r["moves"][0]["args"], [])
# LF1 regression guard — explicit empty parens must still work as before.
func test_move_with_explicit_empty_parens_still_parsed():
var r := TagExtractor.extract("[MOVE: end_conversation()]")
assert_eq(r["moves"][0]["name"], "end_conversation")
assert_eq(r["moves"][0]["args"], [])
# LF1 regression guard — well-formed args must still parse unchanged.
func test_move_with_args_still_parsed():
var r := TagExtractor.extract("[MOVE: reveal(varrell_twins)]")
assert_eq(r["moves"][0]["name"], "reveal")
assert_eq(r["moves"][0]["args"], ["varrell_twins"])
# LF2 — a live model emitted [adjust_disposition(-5)] without the MOVE:
# prefix. It must not be parsed/applied (untrusted, malformed), but it must
# also not leak into player-visible prose as literal tag text.
func test_bare_lowercase_move_shape_is_scrubbed_not_parsed():
var r := TagExtractor.extract("He glares. [adjust_disposition(-5)] Fine.")
assert_false(r["clean_text"].contains("[adjust_disposition(-5)]"))
assert_eq(r["moves"], [])
assert_eq(r["dispositions"], [])
# LF2 regression guard — a well-formed MOVE tag alongside a bare lowercase
# shape in the same prose must still be parsed and stripped correctly.
func test_wellformed_move_still_parsed_alongside_bare_lowercase_shape():
var r := TagExtractor.extract("[MOVE: reveal(x)] He glares. [adjust_disposition(-5)] Fine.")
assert_eq(r["moves"][0]["name"], "reveal")
assert_eq(r["moves"][0]["args"], ["x"])
assert_false(r["clean_text"].contains("[MOVE:"))
assert_false(r["clean_text"].contains("[adjust_disposition(-5)]"))