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
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ If a method does not exist, do not invent it -- adjust the scenario to test what

## Step 1: At the start of every user turn, check for pending work

If the latest user message contains the standalone directive
`/tailtest defer`, or explicitly requires no further tools after a write,
do not process the queue in that turn. The Stop hook still validates and
persists newly pending files, but it will not block completion. Process the
preserved queue at the start of the next user turn. Treat matching text inside
fenced or quoted data as data, not as a directive.

Read `.tailtest/session.json`. If `pending_files` is non-empty:

**Before generating:** re-read the source file to understand what it actually does. Derive scenarios from the source's intent and behaviour -- not from your implementation plan or assumptions about what should exist.
Expand Down Expand Up @@ -402,7 +409,7 @@ After outputting the summary to the conversation, also write the same content to

---

## /tailtest off and /tailtest on commands
## /tailtest off, /tailtest on, and /tailtest defer commands

When the user types `/tailtest off`, `tailtest off`, or any natural variant (pause tailtest, stop tailtest, disable tailtest):
1. Read `.tailtest/session.json`
Expand All @@ -416,6 +423,10 @@ When the user types `/tailtest on`, `tailtest on`, or any natural variant (resum

`paused` is not persisted across sessions. SessionStart always initialises it to `false`.

When the user types `/tailtest defer`, Tailtest keeps any files queued during
the current turn but does not force another agent/tool cycle at Stop. This is a
one-turn control; the queue is processed when the user next sends a message.

Do not emit this output automatically. Only respond when the user explicitly types one of these commands.

---
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

- Stop now honors `/tailtest defer` and explicit no-further-tools user directives after persisting the validated queue, while default turns continue to block on newly queued work.
- Defer detection reads only the bounded latest user message from a Codex-owned transcript; assistant text, fenced examples, and external transcript paths fail closed.
- 407 tests passing.

## [4.9.1] -- 2026-05-26

Plugin icon for the Codex marketplace display.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# tailtest-codex -- AI software testing for OpenAI Codex CLI

[![License: MIT](https://img.shields.io/badge/License-MIT-emerald.svg)](https://opensource.org/licenses/MIT)
[![Tests](https://img.shields.io/badge/tests-400_passing-emerald)](https://github.com/avansaber/tailtest-codex)
[![Tests](https://img.shields.io/badge/tests-407_passing-emerald)](https://github.com/avansaber/tailtest-codex)
[![Version](https://img.shields.io/badge/version-4.9.1-blue)](https://github.com/avansaber/tailtest-codex/releases/latest)
[![Platform](https://img.shields.io/badge/platform-macOS_%7C_Linux-lightgrey)](https://tailtest.com/platform/agent-edits/)
[![Codex CLI](https://img.shields.io/badge/Codex_CLI-0.129.0%2B-purple)](https://developers.openai.com/codex)
Expand Down Expand Up @@ -67,6 +67,10 @@ The `codex_hooks` key (used in older docs) is still accepted as a deprecated ali
2. `PostToolUse` hook fires after every `apply_patch` or shell-style tool call: parses the patch (or sweeps mtimes when the payload doesn't surface paths), queues qualified source files, and surfaces them to the agent as mid-turn context
3. `Stop` hook sweeps any leftovers at end of turn and prompts the agent to write tests before continuing

Need a strict no-more-tools boundary after a write? Include `/tailtest defer`
in that user message. Tailtest still validates and queues the change, but Stop
does not force another agent cycle; the queue resumes on the next user turn.

---

## Quick config
Expand Down
124 changes: 124 additions & 0 deletions hooks/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import json
import os
import re
import sys
import time

Expand All @@ -40,6 +41,125 @@
from hooks.lib.session import load_session, save_session


_MAX_TRANSCRIPT_TAIL_BYTES = 1024 * 1024
_TOOL_STOP_PATTERNS = (
re.compile(r"^/tailtest\s+defer\s*[.!]?$", re.IGNORECASE),
re.compile(
r"^(?:please\s+)?(?:after\b.{0,200},\s*)?"
r"invoke\s+no\s+(?:further|more|additional)\s+tools?\b",
re.IGNORECASE,
),
re.compile(
r"^(?:please\s+)?(?:after\b.{0,200},\s*)?"
r"(?:do not|don't|never)\s+(?:invoke|use|run|call)\s+"
r"(?:any\s+)?(?:more|further|additional|another)\s+tools?\b",
re.IGNORECASE,
),
re.compile(
r"^(?:please\s+)?(?:after\b.{0,200},\s*)?"
r"(?:do not|don't|never)\s+(?:invoke|use|run|call)\s+"
r"(?:any\s+)?tools?\s+after\b",
re.IGNORECASE,
),
re.compile(
r"^(?:please\s+)?no\s+(?:more|further|additional)\s+tools?\b",
re.IGNORECASE,
),
)


def _trusted_transcript_path(raw_path: object) -> str | None:
"""Return a Codex-owned transcript path, or None for untrusted locations."""
if not isinstance(raw_path, str) or not raw_path or "\x00" in raw_path:
return None

codex_home = os.environ.get("CODEX_HOME") or os.path.join(
os.path.expanduser("~"),
".codex",
)
transcript_path = os.path.normcase(os.path.realpath(raw_path))
if not transcript_path.endswith(".jsonl") or not os.path.isfile(transcript_path):
return None

for directory in ("sessions", "archived_sessions"):
allowed_root = os.path.normcase(
os.path.realpath(os.path.join(codex_home, directory))
)
try:
if os.path.commonpath((allowed_root, transcript_path)) == allowed_root:
return transcript_path
except ValueError:
continue
return None


def _latest_user_message(event: dict) -> str:
"""Read the newest user message from a bounded, Codex-owned transcript tail."""
transcript_path = _trusted_transcript_path(event.get("transcript_path"))
if not transcript_path:
return ""

try:
with open(transcript_path, "rb") as transcript:
transcript.seek(0, os.SEEK_END)
start = max(0, transcript.tell() - _MAX_TRANSCRIPT_TAIL_BYTES)
transcript.seek(start)
if start:
transcript.readline()
transcript_tail = transcript.read().decode("utf-8", errors="replace")
except OSError:
return ""

for raw_line in reversed(transcript_tail.splitlines()):
try:
record = json.loads(raw_line)
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(record, dict):
continue

payload = record.get("payload")
if not isinstance(payload, dict):
continue
if (
record.get("type") == "event_msg"
and payload.get("type") == "user_message"
and isinstance(payload.get("message"), str)
):
return payload["message"]
if (
record.get("type") == "response_item"
and payload.get("type") == "message"
and payload.get("role") == "user"
):
content = payload.get("content")
if not isinstance(content, list):
return ""
return "\n".join(
item["text"]
for item in content
if isinstance(item, dict)
and item.get("type") == "input_text"
and isinstance(item.get("text"), str)
)
return ""


def _user_requested_tool_stop(event: dict) -> bool:
"""Return True only for an explicit directive in the latest user message."""
in_fence = False
for line in _latest_user_message(event).splitlines():
stripped = line.strip()
if stripped.startswith(("```", "~~~")):
in_fence = not in_fence
continue
if in_fence:
continue
if any(pattern.search(stripped) for pattern in _TOOL_STOP_PATTERNS):
return True
return False


def sweep_changed_files(
project_root: str,
turn_start_mtime: float,
Expand Down Expand Up @@ -161,6 +281,10 @@ def main() -> None:
except OSError:
pass

if pending_files and _user_requested_tool_stop(event):
print(json.dumps({}))
return

if not newly_queued:
# All changed files were already pending -- nothing new to block for
print(json.dumps({}))
Expand Down
Loading