From 1569ad3a401c1711f59613c519879b0245350dd0 Mon Sep 17 00:00:00 2001 From: Simon Qi Date: Thu, 27 Aug 2026 00:06:57 +1200 Subject: [PATCH 1/3] Give a Session one name, composed once and never changed again (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were two fields meaning almost the same thing — a `label` the user's side composed and a `name` the agent reported — and so two answers to "what is this Session called". `CONTEXT.md` has one term for it, *Session Name*, and this collapses both into it: ` · `, composed by the lane that saw the Session, frozen by the registry, rendered as one string on the wire. The title is the agent's own — Claude's roster `name`, Codex's daemon `Thread.name`, or eight characters of the thread id when the daemon has no name for it. Nothing is asked of the Session for it: the route that asked (`legacy@1d32845:bridge/hook.py:215-253`, `bridge/daemon.py:1504-1544`) left the #67 port table on 2026-08-25, and legacy's own measurement of the transcript-derived title it replaced (30% never written, the rest a median 440s late) is why it is not coming back. The project half is `git rev-parse --git-common-dir`, as `legacy@1d32845:bridge/labels.py:28-70` asked it — *adapted* in one place: legacy raised when the workspace was in no repository and the Session stayed unnamed, and v1.0 bridges every Session the user starts, so it falls back to the workspace's own directory name. `git` is run in one module, cached per workspace, and never on the seam's pure side. Frozen means frozen: the first name a row accepts is the one it keeps (`legacy@1d32845:bridge/store.py:1875-1902`). The single exception is a change of exact identity — a Codex row gaining its thread id at its first turn (#73), or `/new` moving a pid to another thread (#77) — which is a different Session under the same row, not a name that moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CONTEXT.md | 7 +- src/gpt_voicecoding/adapters/agent/_naming.py | 53 +++++++ .../adapters/agent/_project.py | 136 +++++++++++++++++ .../adapters/agent/claude/adapter.py | 7 +- .../adapters/agent/claude/discovery.py | 43 +++++- .../adapters/agent/codex/adapter.py | 5 + .../adapters/agent/codex/discovery.py | 65 +++++++- src/gpt_voicecoding/control_plane/commands.py | 6 +- src/gpt_voicecoding/control_plane/payloads.py | 13 +- src/gpt_voicecoding/core/__init__.py | 4 +- src/gpt_voicecoding/core/bridge.py | 19 ++- src/gpt_voicecoding/core/errors.py | 10 +- .../core/instructions/catalogue.py | 16 +- .../core/instructions/voice.py | 6 +- src/gpt_voicecoding/core/router.py | 28 ++-- src/gpt_voicecoding/core/sessions.py | 139 +++++++++++++----- src/gpt_voicecoding/seams/agent.py | 16 +- src/gpt_voicecoding/seams/identity.py | 41 +++--- tests/hub.py | 4 +- tests/test_bridge.py | 2 +- tests/test_bridgectl.py | 3 +- tests/test_claude_agent.py | 4 +- tests/test_claude_discovery.py | 69 ++++++++- tests/test_claude_stop_wiring.py | 2 +- tests/test_codex_discovery.py | 67 ++++++++- tests/test_control_plane_actions.py | 8 +- tests/test_engine.py | 4 +- tests/test_persistence.py | 4 +- tests/test_realtime_call.py | 2 +- tests/test_relay_pipeline.py | 4 +- tests/test_router.py | 20 +-- tests/test_session_naming.py | 117 +++++++++++++++ tests/test_session_observation.py | 99 +++++++++++-- tests/test_session_registry.py | 50 +++---- tests/test_vocabulary.py | 32 ++-- 35 files changed, 891 insertions(+), 214 deletions(-) create mode 100644 src/gpt_voicecoding/adapters/agent/_naming.py create mode 100644 src/gpt_voicecoding/adapters/agent/_project.py create mode 100644 tests/test_session_naming.py diff --git a/CONTEXT.md b/CONTEXT.md index f6c70cd..5ed7a61 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -70,8 +70,11 @@ A process a Session spawns — a subagent, a review crew. It appears in the rost _Avoid_: child Session, subagent (the agent's mechanism word), crew **Session Name**: -What the user and the system call one Session — the agent's own name for it. -_Avoid_: label, title +What the user and the system call one Session: `<project> · <title>`, where the project is +the Git repository the Session is working in — its own directory when it is in none — and the +title is the agent's own name for the Session. Composed by the lane that first saw the Session +and never changed after that, because the user says it back to address the Session. +_Avoid_: label, title (the second half alone is not the name) **Relay**: Carrying words *into* a Session — the agent-ward direction. diff --git a/src/gpt_voicecoding/adapters/agent/_naming.py b/src/gpt_voicecoding/adapters/agent/_naming.py new file mode 100644 index 0000000..d7f2d5f --- /dev/null +++ b/src/gpt_voicecoding/adapters/agent/_naming.py @@ -0,0 +1,53 @@ +"""How both lanes turn a project and a title into one Session Name. + +**Pure, and shared.** Two lanes compose the same shape from different facts — +Claude's official roster `name`, Codex's daemon `Thread.name` — and a rule that +lived twice would drift twice. Nothing here touches the filesystem, a process or +a clock: the caller resolves the project half (`_project.py`) and hands it in, +which is what keeps this testable against strings rather than against whichever +repositories happen to be on the machine. + +**The title is validated the way the reference implementation validated it** +(`legacy@1d32845:bridge/labels.py:97-106`, *adapted*): non-empty after stripping, +and one line. Legacy raised `SessionLabelError` on both, from a resolver called +at the moment a Session reported its own title; here the caller is a discovery +pass over every row on the machine every few seconds, and an exception per +unnameable row would make a naming problem look like a lane failure. So the +answer is `None` — "this Session has no name yet" — which is the state the +roster already renders (`core/sessions.py`). + +**What is deliberately not here.** No transcript-derived title, no `ai-title`, +no mutable history: legacy measured that route at 30% of Sessions never writing +a title and the rest writing one a median of 440 s late +(`legacy@1d32845:bridge/labels.py:73-84`, *dropped, because* a record a product +writes when it feels like it cannot carry a name the user speaks with). A name +composed here is composed from a fact the lane already had. +""" + +from __future__ import annotations + +from gpt_voicecoding.seams.identity import NAME_SEPARATOR, SessionName + + +def compose(project_name: str, title: str) -> SessionName | None: + """``<project> · <title>``, or `None` when the parts cannot make one. + + Both halves are stripped first, because the sources are other people's + strings: a roster field and a daemon field, either of which may arrive + padded. A title that is empty or spans more than one line is refused rather + than flattened — a Session Name is spoken and typed after `@`, so a name + carrying a newline would be a name the user cannot say back. + + The separator is refused inside either half by `SessionName` itself, and + that refusal is caught here for the same reason as the rest: a Codex thread + the user named `a · b` is a Session that has no name, not a lane that broke. + """ + project = project_name.strip() + task = title.strip() + if not project or not task: + return None + if len(task.splitlines()) > 1: + return None + if NAME_SEPARATOR.strip() in project or NAME_SEPARATOR.strip() in task: + return None + return SessionName(project=project, task=task) diff --git a/src/gpt_voicecoding/adapters/agent/_project.py b/src/gpt_voicecoding/adapters/agent/_project.py new file mode 100644 index 0000000..dd94468 --- /dev/null +++ b/src/gpt_voicecoding/adapters/agent/_project.py @@ -0,0 +1,136 @@ +"""What project a Session is working on, read from its workspace. + +The project half of every Session Name (`_naming.py`), and the **one place** +either lane runs a command to get it — a rule worth a module of its own, because +this is asked once per row per discovery tick and a second copy of it in the +other lane would be a second subprocess per row per tick. + +**Git first, the directory second.** `git rev-parse --git-common-dir` is what the +reference implementation asked (`legacy@1d32845:bridge/labels.py:28-70`), and it +is the right question: it answers with the *repository's* directory from any +worktree or subdirectory, so two Sessions in `repo/` and `repo/src/` are working +on one project and are named for it. Legacy then raised when the answer was not +a repository at all, and the label never existed. That is **adapted** here: v1.0 +bridges every Session the user starts, so one in `~/scratch` is named +`scratch · <title>` from the directory's own basename rather than left unnamed +and unspeakable. The basename is a fact about the Session, not a guess. + +**Cached per resolved workspace, for the life of the adapter.** A workspace does +not change which repository it belongs to while a Session runs in it, and the +cadence would otherwise pay a subprocess per Session every five seconds. The +cache is keyed on the realpath, so the same repository reached by two paths is +one entry. **The basename fallback is remembered too**, deliberately: a `git` +that was busy the first time a workspace was seen would otherwise be asked again +on every tick for a Session whose name the registry has already frozen, and the +second answer could never reach the roster anyway. + +**A failure is a directory name, never an exception.** Discovery is answering +"what is running"; `git` being absent, slow or angry is not a reason to lose a +row, and the basename is standing by. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Final + +_log = logging.getLogger(__name__) + +#: The command, and the flags that make its answer one absolute path. Asked of +#: the Session's own workspace, so the answer is about the Session's repository +#: and not about wherever this engine happens to be running. +GIT_COMMAND: Final = ("git", "rev-parse", "--path-format=absolute", "--git-common-dir") + +#: How long `git` is given before the directory's basename is the answer. Short +#: on purpose: this runs inside a discovery tick, and a name is worth less than +#: the roster it would hold up. +COMMAND_TIMEOUT_SECONDS: Final = 5.0 + +#: What a Git common directory is called. A `rev-parse` answer that is not one is +#: an answer this build does not understand, and it falls back rather than +#: cutting a project name out of a shape it is guessing at. +GIT_DIRECTORY_NAME: Final = ".git" + + +#: How `git` is asked, and what it answered on stdout — `None` when it could not +#: be asked or refused. Injected for the reason the Claude lane injects its +#: roster command: a test that shelled out here would be measuring whichever +#: repositories the machine running it happens to have. +GitAnswer = Callable[[Path], Awaitable[str | None]] + + +class ProjectNames: + """The project name for each workspace this lane has seen, read once each.""" + + def __init__( + self, *, ask: GitAnswer | None = None, timeout_seconds: float = COMMAND_TIMEOUT_SECONDS + ) -> None: + self._timeout_seconds = timeout_seconds + self._ask = ask or self._run_git + #: realpath → the project name it resolved to. + self._known: dict[str, str] = {} + + async def of(self, workspace: Path) -> str | None: + """This workspace's project name, or `None` if there is no workspace. + + `None` means the row carried no `cwd` at all — the lanes spell that + `Path()` — and it is the only case that yields no name. Everything else + resolves: a repository to its own directory name, anything else to the + workspace's basename. + """ + if not str(workspace).strip() or str(workspace) == ".": + return None + resolved = os.path.realpath(workspace) + remembered = self._known.get(resolved) + if remembered is not None: + return remembered + + found = _project_in(await self._ask(Path(resolved)) or "") or Path(resolved).name.strip() + if not found: + return None + self._known[resolved] = found + return found + + async def _run_git(self, workspace: Path) -> str | None: + """Ask `git` about this workspace, and hand back what it said on stdout.""" + try: + process = await asyncio.create_subprocess_exec( + *GIT_COMMAND, + cwd=workspace, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + except (OSError, ValueError) as unavailable: + _log.info("could not ask git about %s: %s", workspace, unavailable) + return None + try: + out, _ = await asyncio.wait_for(process.communicate(), self._timeout_seconds) + except TimeoutError: + process.kill() + await process.wait() + _log.info("git did not answer about %s within %ss", workspace, self._timeout_seconds) + return None + if process.returncode != 0: + return None + return out.decode("utf-8", errors="replace") + + +def _project_in(answer: str) -> str | None: + """The project name inside one `rev-parse` answer, or `None` if it is not one. + + Three shapes are refused, all of them legacy's (`labels.py:56-70`): more than + one line, a path that is not absolute, and a directory that is not `.git`. + Each would mean the answer is not the one this asked for, and a name cut out + of it would be this build guessing. + """ + text = answer.strip() + if not text or len(text.splitlines()) > 1: + return None + common = Path(text) + if not common.is_absolute() or common.name != GIT_DIRECTORY_NAME: + return None + return common.parent.name.strip() or None diff --git a/src/gpt_voicecoding/adapters/agent/claude/adapter.py b/src/gpt_voicecoding/adapters/agent/claude/adapter.py index b27869a..7d901f6 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/adapter.py +++ b/src/gpt_voicecoding/adapters/agent/claude/adapter.py @@ -59,6 +59,7 @@ from datetime import UTC, datetime from pathlib import Path +from gpt_voicecoding.adapters.agent._project import ProjectNames from gpt_voicecoding.adapters.agent.claude import discovery as claude_discovery from gpt_voicecoding.adapters.agent.claude import inbox, stop_analysis, transcript_tail from gpt_voicecoding.adapters.agent.claude.approval import ( @@ -197,6 +198,10 @@ def __init__( #: The lane's one opener of a transcript file, shared by what a Session #: stopped on (#75) and how far along it is (#76). self._transcripts = TranscriptReader() + #: The project half of every Session Name this lane composes, read once + #: per workspace and kept for the life of the adapter (#78). Held here + #: rather than inside `discovery` so the cache outlives one tick. + self._projects = ProjectNames() self._windows = ReplyWindowWatcher( settings=self._settings, emit=self._emit, stopped_on=self.stopped_on ) @@ -425,7 +430,7 @@ async def discover(self) -> LaneDiscovery: five seconds, for the whole machine (`core/bridge.py:442`) — and `inspect` reads the same rows. """ - lane = await claude_discovery.discover() + lane = await claude_discovery.discover(projects=self._projects) if not lane.enumerated: return lane return replace(lane, rows=tuple(self._row_with_stop(row) for row in lane.rows)) diff --git a/src/gpt_voicecoding/adapters/agent/claude/discovery.py b/src/gpt_voicecoding/adapters/agent/claude/discovery.py index 483f0cd..7fcb0e7 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/discovery.py +++ b/src/gpt_voicecoding/adapters/agent/claude/discovery.py @@ -30,10 +30,12 @@ import json import logging from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Final +from gpt_voicecoding.adapters.agent import _naming +from gpt_voicecoding.adapters.agent._project import ProjectNames from gpt_voicecoding.seams.agent import ( LaneDiscovery, SessionInspection, @@ -107,7 +109,9 @@ async def run_command(argv: list[str]) -> CommandResult: ) -async def discover(*, run: Runner = run_command) -> LaneDiscovery: +async def discover( + *, run: Runner = run_command, projects: ProjectNames | None = None +) -> LaneDiscovery: """Every Claude Session running under this config directory, or why none. **A lane that could not look says so; it never reports an empty machine.** @@ -140,10 +144,10 @@ async def discover(*, run: Runner = run_command) -> LaneDiscovery: ) ) - return LaneDiscovery(rows=tuple(_rows(document))) + return LaneDiscovery(rows=tuple(await _rows(document, projects or ProjectNames()))) -def _rows(document: list[Any]) -> list[SessionInspection]: +async def _rows(document: list[Any], projects: ProjectNames) -> list[SessionInspection]: """Every row that can be read, skipping the ones that cannot. One unreadable row is not a broken roster, and refusing the whole document @@ -158,10 +162,36 @@ def _rows(document: list[Any]) -> list[SessionInspection]: if inspection is None: _log.info("skipped a roster row this build cannot address: %r", row) continue - found.append(inspection) + found.append(await _named(inspection, row, projects)) return found +async def _named( + inspection: SessionInspection, row: dict[str, Any], projects: ProjectNames +) -> SessionInspection: + """The same row, carrying its Session Name. + + **The title half is the roster's own `name`** — `workspace-claude-ed` and the + like — which is the official answer to "what is this Session called" and is + on every row from the moment the Session exists (#73). Nothing is asked of + the Session to get it and no transcript is opened for it, which is what makes + it stable enough to be the name the user speaks + (`legacy@1d32845:bridge/hook.py:215-253` had the Session report its own title + over the hook instead — *dropped, because* the amended #67 port table removed + that route on 2026-08-25). + + The project half is the workspace's, resolved here because this is the lane + that knows the workspace. A row with neither half stays unnamed. + """ + title = _name(row) + if title is None: + return inspection + project = await projects.of(inspection.workspace) + if project is None: + return inspection + return replace(inspection, name=_naming.compose(project, title)) + + def _inspection(row: dict[str, Any]) -> SessionInspection | None: """One roster row as the seam holds it, or `None` if it is not addressable.""" session_id = row.get("sessionId") @@ -195,7 +225,6 @@ def _inspection(row: dict[str, Any]) -> SessionInspection | None: # began is not when it last did anything. progress=None, last_activity=None, - name=_name(row), ) @@ -215,6 +244,6 @@ def _waiting_for(state: SessionState) -> WaitingFor: def _name(row: dict[str, Any]) -> str | None: - """The agent's own name for this Session — `workspace-claude-ed` and the like.""" + """The title half of this Session's name, straight off the official roster.""" name = row.get("name") return name.strip() if isinstance(name, str) and name.strip() else None diff --git a/src/gpt_voicecoding/adapters/agent/codex/adapter.py b/src/gpt_voicecoding/adapters/agent/codex/adapter.py index 828e423..1cf6616 100644 --- a/src/gpt_voicecoding/adapters/agent/codex/adapter.py +++ b/src/gpt_voicecoding/adapters/agent/codex/adapter.py @@ -57,6 +57,7 @@ from typing import Any from gpt_voicecoding import __version__ +from gpt_voicecoding.adapters.agent._project import ProjectNames from gpt_voicecoding.adapters.agent.codex import approvals as approval_wire from gpt_voicecoding.adapters.agent.codex import discovery as codex_discovery from gpt_voicecoding.adapters.agent.codex import thread_tail @@ -186,6 +187,9 @@ def __init__( ) #: What each loaded thread last said, read at most once per change. self._turns = codex_discovery.TurnCache() + #: The project half of every Session Name this lane composes, read once + #: per workspace and kept for the life of the adapter (#78). + self._projects = ProjectNames() #: How the machine's own process table is read. Injected for the reason #: the daemon is: a test that shelled out to `ps` would be a test whose #: answer depends on what the person running it happens to have open. @@ -348,6 +352,7 @@ async def discover(self) -> LaneDiscovery: processes=self._processes, turns=self._turns, daemon_note=self._daemon.note, + projects=self._projects, ) if not lane.enumerated: return lane diff --git a/src/gpt_voicecoding/adapters/agent/codex/discovery.py b/src/gpt_voicecoding/adapters/agent/codex/discovery.py index 4d100a1..727c319 100644 --- a/src/gpt_voicecoding/adapters/agent/codex/discovery.py +++ b/src/gpt_voicecoding/adapters/agent/codex/discovery.py @@ -31,10 +31,13 @@ import logging import os from collections.abc import Awaitable, Callable, Mapping +from dataclasses import replace from datetime import UTC, datetime from pathlib import Path from typing import Any, Final, Protocol +from gpt_voicecoding.adapters.agent import _naming +from gpt_voicecoding.adapters.agent._project import ProjectNames from gpt_voicecoding.adapters.agent.codex import rollouts, thread_tail from gpt_voicecoding.adapters.agent.codex.processes import Candidate, enumerate_sessions from gpt_voicecoding.seams.agent import ( @@ -69,6 +72,15 @@ "systemError": SessionState.IDLE, } +#: How much of a thread id stands in for a name the daemon does not have. Eight +#: characters of a UUID, which is what `codex` itself shows and short enough to +#: say out loud — the fallback title of every unnamed thread (#78). It is +#: composed here, from a fact this lane already holds: nothing is asked of the +#: Session, and the route that used to ask one (`legacy@1d32845:bridge/hook.py: +#: 215-253`, `bridge/daemon.py:1504-1544`) was *dropped* from the #67 port table +#: on 2026-08-25. +SHORT_THREAD_ID_CHARACTERS: Final = 8 + #: What every degraded reading ends with: where the rows actually came from. #: The *reason* is a separate sentence, because there are two of them and they #: are not the same fact. @@ -208,6 +220,7 @@ async def discover( home: Path | None = None, turns: TurnCache | None = None, daemon_note: str = "", + projects: ProjectNames | None = None, ) -> LaneDiscovery: """Every Codex Session on this machine, however well it can be described. @@ -221,6 +234,7 @@ async def discover( return LaneDiscovery(error=f"the process table could not be read: {unreadable}") threads, daemon_error = await _threads(client) + names = projects or ProjectNames() rows: list[SessionInspection] = [] claimed: set[int] = set() @@ -233,15 +247,15 @@ async def discover( if turns is not None and client is not None else None ) - rows.append(_from_thread(thread, pid, progress)) + rows.append( + await _named(_from_thread(thread, pid, progress), names, title=_thread_name(thread)) + ) if turns is not None: turns.retain({str(thread.get("id")) for thread in threads}) - rows.extend( - _from_process(candidate, home=home) - for candidate in candidates - if candidate.pid not in claimed - ) + for candidate in candidates: + if candidate.pid not in claimed: + rows.append(await _named(_from_process(candidate, home=home), names)) return LaneDiscovery(rows=tuple(rows), degraded=_degraded(daemon_error, daemon_note)) @@ -372,7 +386,6 @@ def _from_thread( kind = _status_of(thread) state = STATUS_TYPES.get(str(kind), SessionState.RUNNING) cwd = thread.get("cwd") - name = thread.get("name") return SessionInspection( target=SessionTarget(agent=AgentKind.CODEX, session_id=str(thread["id"]), pid=pid), workspace=Path(str(cwd)) if isinstance(cwd, str) and cwd.strip() else Path(), @@ -390,10 +403,46 @@ def _from_thread( # the thread's own account of when it last moved, which is exactly the # case `last_activity` exists to answer when nothing was said (#76). last_activity=thread_tail.last_activity(thread), - name=name.strip() if isinstance(name, str) and name.strip() else None, ) +def _thread_name(thread: Mapping[str, Any]) -> str | None: + """What the daemon calls this thread, when it calls it anything.""" + name = thread.get("name") + return name.strip() if isinstance(name, str) and name.strip() else None + + +async def _named( + row: SessionInspection, projects: ProjectNames, *, title: str | None = None +) -> SessionInspection: + """The same row, carrying its Session Name. + + One rule for both sources, because there is one kind of Codex Session: the + title is the daemon's `Thread.name` when the daemon has one, and the first + `SHORT_THREAD_ID_CHARACTERS` of the thread id when it does not. A row read + off the process table simply never has the first, so it takes the second — + and a Session that has not taken its first turn has neither, because it has + no thread id yet (#73), so it stays unnamed until it does. That is not a + reach problem: an unnamed row is listed, and what it can be addressed by is + its target, which it has had all along. + """ + chosen = title or _short_thread_id(row.target.session_id) + if chosen is None: + return row + project = await projects.of(row.workspace) + if project is None: + return row + return replace(row, name=_naming.compose(project, chosen)) + + +def _short_thread_id(thread_id: str | None) -> str | None: + """The head of a thread id, as a name for a thread nobody named.""" + if thread_id is None: + return None + short = thread_id.strip()[:SHORT_THREAD_ID_CHARACTERS] + return short or None + + def _from_process(candidate: Candidate, *, home: Path | None) -> SessionInspection: """One running TUI the daemon does not hold, named as well as disk allows. diff --git a/src/gpt_voicecoding/control_plane/commands.py b/src/gpt_voicecoding/control_plane/commands.py index 6db6d81..5ad7e8f 100644 --- a/src/gpt_voicecoding/control_plane/commands.py +++ b/src/gpt_voicecoding/control_plane/commands.py @@ -7,10 +7,10 @@ Two shapes are worth naming: -- **An address, not a label.** `codex:abc` and `claude:def:1234` are how a +- **An address, not a name.** `codex:abc` and `claude:def:1234` are how a surface writes a `SessionTarget` on one line. A Session Label is for speech and for matching, and turning one into a target is the router's job on the way in - — never this parser's, which would be addressing by label through the back + — never this parser's, which would be addressing by name through the back door. - **A refusal is rendered verbatim.** Bridge Core's words come back unchanged; this file never rephrases one. Honest wording lives in one place, and a @@ -191,7 +191,7 @@ def _roster_lines(sessions: object) -> list[str]: if not sessions: return ["sessions: none"] return ["sessions:"] + [ - f" {session['label'] or session['name'] or '(unnamed)'} — " + f" {session['name'] or '(unnamed)'} — " f"{format_address(session['target'])} — {session['workspace']} " f"({session['state']}, window {session['reply_window']})" for session in sessions diff --git a/src/gpt_voicecoding/control_plane/payloads.py b/src/gpt_voicecoding/control_plane/payloads.py index 12542ac..bb09f8a 100644 --- a/src/gpt_voicecoding/control_plane/payloads.py +++ b/src/gpt_voicecoding/control_plane/payloads.py @@ -6,10 +6,10 @@ refusal carries the refusal's own words. The reference implementation's expensive habit was a surface deciding what a result "really meant" on the user's behalf. -- **A label is never an address.** `SessionTarget` is what crosses the wire, and +- **A Session Name is never an address.** `SessionTarget` is what crosses the wire, and it is read back through the same constructor Bridge Core uses, so a Claude target without a pid is refused here rather than becoming an ambiguous - command later. Turning a spoken label into a target is the router's job, on + command later. Turning a spoken name into a target is the router's job, on the way in from the Companion Channel. Every reader raises `InvalidPayload`, which the action layer turns into one @@ -75,7 +75,7 @@ def read_flag(payload: Mapping[str, Any], key: str) -> bool: def read_target(payload: Mapping[str, Any], key: str = "target") -> SessionTarget: - """The exact identity a command carries. Never a label, never inferred.""" + """The exact identity a command carries. Never a name, never inferred.""" raw = payload.get(key) if not isinstance(raw, dict): raise InvalidPayload(f"{key!r} must name a Session: agent, session_id and pid") @@ -145,8 +145,11 @@ def session_document(session: Session) -> dict[str, Any]: """ return { "target": target_document(session.target), - "label": str(session.label) if session.label is not None else None, - "name": session.name, + # One name, rendered as the one string the user hears and types after + # `@` — `<project> · <title>`. #78 collapsed the `label`/`name` pair + # that used to travel here into it; a surface that wants the halves + # parses them back with `SessionName.parse`. + "name": str(session.name) if session.name is not None else None, "workspace": str(session.workspace), "first_seen": session.first_seen, "lifecycle": str(session.lifecycle), diff --git a/src/gpt_voicecoding/core/__init__.py b/src/gpt_voicecoding/core/__init__.py index 029fd0f..5556d81 100644 --- a/src/gpt_voicecoding/core/__init__.py +++ b/src/gpt_voicecoding/core/__init__.py @@ -18,8 +18,8 @@ - ``switches`` — the Duty / Voice / Message hierarchy and the Feature Switches configuration declares beneath it. -- ``sessions`` — the Session registry: exact targets, stale ones refused, labels - that disambiguate or ask. +- ``sessions`` — the Session registry: exact targets, stale ones refused, Session + Names that disambiguate or ask. - ``relay_queue`` — the *one* ledger of everything undelivered, retained Stop Notices included. - ``persistence`` — the durable subset, and the only component that touches disk. diff --git a/src/gpt_voicecoding/core/bridge.py b/src/gpt_voicecoding/core/bridge.py index fd0265f..6403410 100644 --- a/src/gpt_voicecoding/core/bridge.py +++ b/src/gpt_voicecoding/core/bridge.py @@ -51,7 +51,7 @@ from gpt_voicecoding.core.relay_queue import PendingRelay from gpt_voicecoding.core.relays import RelayOutcome, RelayPipeline from gpt_voicecoding.core.router import Classification, InboundClass, InboundRouter, TextGrammar -from gpt_voicecoding.core.sessions import Session +from gpt_voicecoding.core.sessions import Session, spoken_name, spoken_target from gpt_voicecoding.core.state import BridgeState from gpt_voicecoding.core.switches import SwitchSnapshot from gpt_voicecoding.core.verification import ( @@ -105,17 +105,16 @@ def name_for(session: Session | None, target: SessionTarget) -> str: - """What to call one Session out loud, best name first. + """What to call one Session out loud: its Session Name, else its address. - The Session Label if the user has one for it, else the agent's own Session - Name, else the address — because a notice that names nothing is a notice the - user cannot answer. #78 stabilises the middle one. + Two rungs rather than three, since #78 collapsed the composed label and the + agent's own name into one *Session Name*. The address is still the floor, + because a notice that names nothing is a notice the user cannot answer — and + a Session with no name is ordinary rather than broken (`core/sessions.py`). """ - if session is not None and session.label is not None: - return str(session.label) - if session is not None and session.name: - return session.name - return f"{target.agent} {target.session_id or f'pid {target.pid}'}" + if session is not None: + return spoken_name(session) + return spoken_target(target) def stop_notice_for( diff --git a/src/gpt_voicecoding/core/errors.py b/src/gpt_voicecoding/core/errors.py index e26ed9b..b43a12b 100644 --- a/src/gpt_voicecoding/core/errors.py +++ b/src/gpt_voicecoding/core/errors.py @@ -1,7 +1,7 @@ """What Bridge Core raises when it refuses. Every error here is a *refusal*, not a surprise: the state components fail closed -on an unknown switch, an unknown or stale Session, or an ambiguous label, because +on an unknown switch, an unknown or stale Session, or an ambiguous name, because the alternative — guessing — is the failure class this repository exists to avoid. Callers that must speak the refusal aloud get the offending value back on the exception rather than having to parse a message. @@ -113,11 +113,11 @@ def __init__(self, target: SessionTarget) -> None: self.target = target -class LabelMatchError(BridgeCoreError): - """Base for a label that did not resolve to exactly one Session.""" +class NameMatchError(BridgeCoreError): + """Base for a Session Name that did not resolve to exactly one Session.""" -class NoLabelMatchError(LabelMatchError): +class NoNameMatchError(NameMatchError): """Nothing matched. Ask; do not guess.""" def __init__(self, query: str) -> None: @@ -125,7 +125,7 @@ def __init__(self, query: str) -> None: self.query = query -class AmbiguousLabelError(LabelMatchError): +class AmbiguousNameError(NameMatchError): """More than one matched. Refuse and name them, rather than picking one.""" def __init__(self, query: str, candidates: tuple[Session, ...]) -> None: diff --git a/src/gpt_voicecoding/core/instructions/catalogue.py b/src/gpt_voicecoding/core/instructions/catalogue.py index 41184c3..20ae270 100644 --- a/src/gpt_voicecoding/core/instructions/catalogue.py +++ b/src/gpt_voicecoding/core/instructions/catalogue.py @@ -179,11 +179,11 @@ def _rules() -> tuple[Rule, ...]: ), ), Rule( - id="voice.identity.speak-labels", + id="voice.identity.speak-names", audience=Audience.VOICE, source="skill/SKILL.md:56-62", gist=( - "Sessions are spoken about by Session Label. A position — 'the third one' — " + "Sessions are spoken about by Session Name. A position — 'the third one' — " "refers to the row just read out, and is resolved to that row's identity " "before anything acts." ), @@ -194,9 +194,9 @@ def _rules() -> tuple[Rule, ...]: source="skill/SKILL.md:56-62", gist=( "The identity a Session is addressed by is structured and machine-side; a " - "Session Label is not a target and cannot be passed as one." + "Session Name is not a target and cannot be passed as one." ), - enforced_by="SessionLabel and SessionTarget are different types — seams/identity.py", + enforced_by="SessionName and SessionTarget are different types — seams/identity.py", ), Rule( id="delegated.outcome.only-a-successful-call-is-success", @@ -259,7 +259,7 @@ def _rules() -> tuple[Rule, ...]: source="skill/announcing.md:1-11", gist=( "A Stop Notice is spoken as ordinary sentences in the user's language, " - "naming the Session by its label and the task it is on." + "naming the Session by its name and the task it is on." ), ), Rule( @@ -555,14 +555,14 @@ def _rules() -> tuple[Rule, ...]: ), ), Rule( - id="core.start.exact-identity-until-a-label-exists", + id="core.start.exact-identity-until-a-name-exists", audience=Audience.CORE, source="skill/starting.md:108-126", gist=( "A Session is addressable by its exact identity from the moment it " - "registers, whether or not it has a Session Label yet." + "registers, whether or not it has a Session Name yet." ), - enforced_by="the Session registry's label-independent targets — core/sessions.py", + enforced_by="the Session registry's name-independent targets — core/sessions.py", ), Rule( id="dropped.starting.duty-gated-reads", diff --git a/src/gpt_voicecoding/core/instructions/voice.py b/src/gpt_voicecoding/core/instructions/voice.py index 0879c78..34b21c1 100644 --- a/src/gpt_voicecoding/core/instructions/voice.py +++ b/src/gpt_voicecoding/core/instructions/voice.py @@ -92,7 +92,7 @@ def _sections(context: InstructionContext) -> tuple[Section, ...]: title="Naming what is running", blocks=( Block( - covers=("voice.identity.speak-labels",), + covers=("voice.identity.speak-names",), text=( "Every session has a Session Label — its project and its task — and " "that is what you say out loud, the way a person would say it in a " @@ -183,8 +183,8 @@ def _sections(context: InstructionContext) -> tuple[Section, ...]: text=( "When a session stops and needs the user, you are the announcement. " "Speak ordinary sentences in the language they are speaking, naming " - "the session by its label and what it was working on. Several sessions " - "can stop within a minute, and the label is how they know which news " + "the session by its name and what it was working on. Several sessions " + "can stop within a minute, and the name is how they know which news " "this is." ), ), diff --git a/src/gpt_voicecoding/core/router.py b/src/gpt_voicecoding/core/router.py index cacb79e..10c5e86 100644 --- a/src/gpt_voicecoding/core/router.py +++ b/src/gpt_voicecoding/core/router.py @@ -15,13 +15,13 @@ /<command> … a control-plane command ><prompt> a Delegated Turn - @<label>: words the user's own words, for the Session that label names + @<name>: words the user's own words, for the Session that name names words the user's own words, when exactly one Session is live Bare text resolving to the single live Session is not a guess in the forbidden sense: it classifies into the least dangerous class, and with exactly one candidate nothing is being picked *between*. Zero or several fails closed and -asks, reusing the registry's locked "a label disambiguates or asks" rule rather +asks, reusing the registry's locked "a Session Name disambiguates or asks" rule rather than minting a second disambiguation mechanism. The one collision worth spelling out is a bare word that is also a registered @@ -38,8 +38,8 @@ from dataclasses import dataclass, field from enum import StrEnum -from gpt_voicecoding.core.errors import AmbiguousLabelError, LabelMatchError -from gpt_voicecoding.core.sessions import Session, SessionRegistry +from gpt_voicecoding.core.errors import AmbiguousNameError, NameMatchError +from gpt_voicecoding.core.sessions import Session, SessionRegistry, spoken_name from gpt_voicecoding.seams.identity import SessionTarget @@ -91,7 +91,7 @@ class Classification: text: str = "" #: Set for CONTROL only. The verb, already known to be registered. command: str = "" - #: Set for ANSWER_RELAY only. The exact Session identity, never a label. + #: Set for ANSWER_RELAY only. The exact Session identity, never a name. target: SessionTarget | None = None #: Set for UNKNOWN only. What to say back — honest, and never a guess. reply: str = "" @@ -116,7 +116,7 @@ def classify(self, text: str) -> Classification: if body.startswith(grammar.delegate_prefix): return self._as_delegation(body[len(grammar.delegate_prefix) :]) if body.startswith(grammar.relay_marker): - return self._as_labelled_relay(body[len(grammar.relay_marker) :]) + return self._as_named_relay(body[len(grammar.relay_marker) :]) return self._as_bare_text(body) def _as_command(self, rest: str) -> Classification: @@ -133,22 +133,22 @@ def _as_delegation(self, rest: str) -> Classification: return self._refuse("that asked me to delegate, but did not say what") return Classification(kind=InboundClass.DELEGATION, text=prompt) - def _as_labelled_relay(self, rest: str) -> Classification: - label, separator, words = rest.partition(":") + def _as_named_relay(self, rest: str) -> Classification: + name, separator, words = rest.partition(":") if not separator: return self._refuse( f"name the session and then the words, like " f"{self._grammar.relay_marker}<session>: your words" ) try: - session = self._sessions.match_label(label.strip()) - except AmbiguousLabelError as ambiguous: + session = self._sessions.match_name(name.strip()) + except AmbiguousNameError as ambiguous: return self._refuse(self._which_one(ambiguous.candidates)) - except LabelMatchError: - return self._refuse(f"nothing running matches {label.strip()!r}") + except NameMatchError: + return self._refuse(f"nothing running matches {name.strip()!r}") if not words.strip(): - return self._refuse(f"that named {session.label} but carried no words") + return self._refuse(f"that named {spoken_name(session)} but carried no words") return Classification( kind=InboundClass.ANSWER_RELAY, text=words.strip(), target=session.target ) @@ -184,7 +184,7 @@ def _command_collision(self, body: str, live: tuple[Session, ...]) -> Classifica def _which_one(self, candidates: tuple[Session, ...]) -> str: """Name every candidate and the form that picks one. Never picks itself.""" marker = self._grammar.relay_marker - named = ", ".join(f"{marker}{session.label}" for session in candidates) + named = ", ".join(f"{marker}{spoken_name(session)}" for session in candidates) return f"say which one: {named}" @staticmethod diff --git a/src/gpt_voicecoding/core/sessions.py b/src/gpt_voicecoding/core/sessions.py index 6fc78e3..a676adf 100644 --- a/src/gpt_voicecoding/core/sessions.py +++ b/src/gpt_voicecoding/core/sessions.py @@ -21,8 +21,9 @@ pretending the session id was never seen. - **A Child Process is seen, not spoken to.** It is listed like any other row and refused as a Relay target, by the registry rather than by a caller's memory. -- **A label disambiguates or asks.** Labels are for matching and for speech; two - candidates are answered by refusing and naming both, never by picking. +- **A Session Name disambiguates or asks.** Names are for matching and for + speech; two candidates are answered by refusing and naming both, never by + picking. There is one registry and one Reply Window per Session — and the Reply Window is **derived**, so there is no second copy to disagree with the first. The reference @@ -38,10 +39,10 @@ from pathlib import Path from gpt_voicecoding.core.errors import ( - AmbiguousLabelError, + AmbiguousNameError, ChildSessionError, DuplicateSessionError, - NoLabelMatchError, + NoNameMatchError, StaleSessionError, UnknownSessionError, ) @@ -57,7 +58,7 @@ WaitingFor, derive_reply_window, ) -from gpt_voicecoding.seams.identity import AgentKind, SessionLabel, SessionTarget +from gpt_voicecoding.seams.identity import AgentKind, SessionName, SessionTarget _log = logging.getLogger(__name__) @@ -66,20 +67,25 @@ class Session: """One coding-agent run the user started, as the roster holds it. - Every field but `first_seen` and `label` is a fact one lane observed, carried - from `SessionInspection` unchanged. `first_seen` is this registry's own — - when *we* first saw it, which no agent knows — and `label` is the user's name - for it, which #78 fills. + Every field but `first_seen` is a fact one lane observed, carried from + `SessionInspection` unchanged. `first_seen` is this registry's own — when + *we* first saw it, which no agent knows. + + **There is one name, and it is `name`.** It used to be two — a `label` the + user's side composed and a `name` the agent reported — which is two fields + meaning almost the same thing and two answers to "what is this Session + called". #78 collapsed them into the glossary's single *Session Name* + (`CONTEXT.md`): `<project> · <title>`, composed by the lane that saw the + Session and frozen here. """ target: SessionTarget workspace: Path first_seen: float - #: The user's name for this Session. `None` is ordinary: an unnamed row is - #: listed like any other (#78 stabilises naming). - label: SessionLabel | None = None - #: The agent's own name for this Session, straight off its roster. - name: str | None = None + #: What this Session is called. `None` is ordinary: an unnamed row is listed + #: like any other, and a Codex thread has neither a name nor an id to make + #: one from until it takes its first turn (#73). + name: SessionName | None = None lifecycle: SessionLifecycle = SessionLifecycle.LIVE state: SessionState = SessionState.RUNNING waiting_for: WaitingFor = field(default_factory=WaitingFor) @@ -101,18 +107,23 @@ def reply_window(self) -> ReplyWindow: def is_live(self) -> bool: return self.lifecycle is SessionLifecycle.LIVE - def observed(self, row: SessionInspection) -> Session: + def observed(self, row: SessionInspection, *, target: SessionTarget) -> Session: """This same Session, as a lane has just seen it again. - Keeps what the registry knows and the lane does not — `first_seen`, and - the user's `label` — and takes everything else from the reading, because - the reading is the newer truth. + Keeps what the registry knows and the lane does not — `first_seen` and + the Session Name it has already accepted — and takes everything else + from the reading, because the reading is the newer truth. + + `target` is the identity the registry settled on (`_better_known`), + which is not always the one the reading carried: a tick with only + process evidence names a Session it cannot see the id of. It is passed + in rather than read off the row because the naming rule turns on it. """ return replace( self, - target=row.target, + target=target, workspace=row.workspace, - name=row.name, + name=self._named_as(row, target), lifecycle=row.lifecycle, state=row.state, waiting_for=row.waiting_for, @@ -121,6 +132,42 @@ def observed(self, row: SessionInspection) -> Session: child=row.child, ) + def _named_as(self, row: SessionInspection, target: SessionTarget) -> SessionName | None: + """The Session Name this row keeps — **the first one it accepted**. + + A name is composed once per exact `SessionTarget` and never changes + after that (`legacy@1d32845:bridge/store.py:1875-1902`, *ported*: first + write wins, an exact repeat is a no-op, a different one is refused). + Stability is the whole point of naming a Session at all — the user says + the name to address it, so a name that moved between two Stop Notices + would be a name that reached the wrong Session. + + A **target change is the one case that re-composes**, and it is not a + mutable name: it is a different Session under the same row. Two ways it + happens, both measured — a Codex row takes its first turn and gains the + thread id it had no name to be built from (#73), and the user types + `/new` in that TUI so the pid stays and the thread does not (#77). The + second is a new thread; naming it after the old one is the failure this + rule exists to prevent, not the one it would be protecting. + """ + if target != self.target: + return row.name + if self.name is None: + return row.name + if row.name is not None and row.name != self.name: + # Debug rather than info on purpose: a lane that composes a + # different name composes it again on every tick, five seconds + # apart, for as long as the Session runs. At info this would be one + # steady line per Session per tick describing a decision that never + # changes. + _log.debug( + "%s is now called %s by its lane; keeping the name it was accepted with, %s", + target, + row.name, + self.name, + ) + return self.name + def _better_known(held: SessionTarget, seen: SessionTarget) -> SessionTarget: """The identity to keep when one process has been named two ways. @@ -152,7 +199,7 @@ def _better_known(held: SessionTarget, seen: SessionTarget) -> SessionTarget: def _normalise(text: str) -> str: - """Case- and whitespace-insensitive form used for label matching only.""" + """Case- and whitespace-insensitive form used for name matching only.""" return " ".join(text.split()).casefold() @@ -291,7 +338,7 @@ def observed_one(self, row: SessionInspection, *, now: float) -> Session: return fresh target = _better_known(held.target, row.target) - updated = replace(held.observed(row), target=target) + updated = held.observed(row, target=target) if target != held.target: del self._sessions[held.target] self._sessions[target] = updated @@ -357,24 +404,23 @@ def resolve(self, target: SessionTarget) -> Session: raise ChildSessionError(target, session.child.parent) return session - def match_label(self, query: str) -> Session: + def match_name(self, query: str) -> Session: """Find the one live Session a spoken name names, or refuse. - The query is matched as a fragment against the Session Label if the user - has given one and against the agent's own Session Name otherwise, and - **more than one match refuses**, with every candidate named. An exact - label is deliberately *not* given precedence, for three reasons: + The query is matched as a fragment against the Session Name, and **more + than one match refuses**, with every candidate named. An exact name is + deliberately *not* given precedence, for three reasons: - The costs are asymmetric. A refusal costs one spoken round trip; a wrong pick delivers the user's own words into the wrong Session, silently, carrying the user's authority. - Exactness is only evidence when the text is trustworthy, and the primary source here is a realtime voice transcript. "ship it" may be - the user meaning the short label, or the transcriber clipping "ship it + the user meaning the short name, or the transcriber clipping "ship it later". Exactness of lossy text says nothing about intent. - - "A label is not a target" is locked. Letting an exact label win + - "A Session Name is not a target" is locked. Letting an exact name win promotes it to a target by right, which is the first step back toward - addressing by label. + addressing by name. The collision only exists while two live names stand in a fragment relation. That is worth fixing where names are minted — by keeping a @@ -382,16 +428,22 @@ def match_label(self, query: str) -> Session: making matching cleverer here. """ wanted = _normalise(query) + if not wanted: + # Every name contains the empty fragment, so an empty query would + # match the whole roster — and match a single unnamed Session + # *exactly*, which is a silent delivery into a Session the user + # never named. + raise NoNameMatchError(query) candidates = [ held for held in self.live() - if held.child.is_main and wanted in _normalise(spoken_name(held)) + if held.child.is_main and held.name is not None and wanted in _normalise(str(held.name)) ] if not candidates: - raise NoLabelMatchError(query) + raise NoNameMatchError(query) if len(candidates) > 1: - raise AmbiguousLabelError(query, tuple(candidates)) + raise AmbiguousNameError(query, tuple(candidates)) return candidates[0] def set_state(self, target: SessionTarget, state: SessionState) -> Session: @@ -458,7 +510,20 @@ def _by_session_id(self, target: SessionTarget) -> list[Session]: def spoken_name(session: Session) -> str: - """What to match a spoken fragment against: the user's name, else the agent's.""" - if session.label is not None: - return str(session.label) - return session.name or "" + """What to call one Session out loud: its Session Name, else its address. + + **The one answer to "what is this called", so no two surfaces give two.** + Matching is deliberately not done through here — `match_name` matches names + and nothing else, because an address the user never heard is not something + they can have meant. This is for saying and for showing: a Session with no + name is still a Session the user has to be told about, and naming it by the + thing that does address it is the honest floor. + """ + if session.name is not None: + return str(session.name) + return spoken_target(session.target) + + +def spoken_target(target: SessionTarget) -> str: + """One identity, said out loud. The floor under every name.""" + return f"{target.agent} {target.session_id or f'pid {target.pid}'}" diff --git a/src/gpt_voicecoding/seams/agent.py b/src/gpt_voicecoding/seams/agent.py index dcb6664..966ff93 100644 --- a/src/gpt_voicecoding/seams/agent.py +++ b/src/gpt_voicecoding/seams/agent.py @@ -60,7 +60,12 @@ from gpt_voicecoding.seams.delivery import DeliveryReceipt from gpt_voicecoding.seams.events import Event -from gpt_voicecoding.seams.identity import AgentKind, RequestId, SessionTarget +from gpt_voicecoding.seams.identity import ( + AgentKind, + RequestId, + SessionName, + SessionTarget, +) from gpt_voicecoding.seams.verify import VerifyResult @@ -315,8 +320,13 @@ class SessionInspection: #: having said anything a reader would show, and #76 consumes both. last_activity: datetime | None = None child: ChildClassification = MAIN_SESSION - #: The agent's own name for this Session, when it has one. #78 stabilises it. - name: str | None = None + #: What this Session is called — `<project> · <title>`, composed by the lane + #: from the agent's own name for it and the workspace it runs in + #: (`adapters/agent/_naming.py`). `None` is ordinary: a Codex thread that has + #: not taken its first turn has neither a name nor an id to make one from. + #: The registry freezes the first one it is given (#78), so a lane composing + #: a different one later is composing something nobody will see. + name: SessionName | None = None @dataclass(frozen=True, slots=True) diff --git a/src/gpt_voicecoding/seams/identity.py b/src/gpt_voicecoding/seams/identity.py index 4113305..7ab3feb 100644 --- a/src/gpt_voicecoding/seams/identity.py +++ b/src/gpt_voicecoding/seams/identity.py @@ -2,9 +2,9 @@ Three locked rules are enforced here by shape rather than by discipline: -- **A label is not a target.** `SessionLabel` is for matching and for speech; it - has no session id and no pid, so it cannot be passed where a command expects - one. Commands carry a `SessionTarget`. +- **A Session Name is not a target.** `SessionName` is for matching and for + speech; it has no session id and no pid, so it cannot be passed where a + command expects one. Commands carry a `SessionTarget`. - **Claude Sessions are addressed by pid.** `--resume` forks a second process under the same session id, so a Claude target without a pid is ambiguous and is refused at construction. @@ -34,8 +34,8 @@ #: The correlation id for one request intent, minted once by its sender. RequestId = NewType("RequestId", str) -#: What separates the two halves of a Session Label when it is rendered. -LABEL_SEPARATOR = " · " +#: What separates the two halves of a Session Name when it is rendered. +NAME_SEPARATOR = " · " def new_request_id() -> RequestId: @@ -72,11 +72,14 @@ def always_named(self) -> bool: @dataclass(frozen=True, slots=True) -class SessionLabel: - """``<git project name> · <task title>`` — for matching and for speech only. - - Deliberately holds nothing a command could address: see this module's - docstring. +class SessionName: + """``<project name> · <task title>`` — for matching and for speech only. + + The one name the user and the system have for a Session (`CONTEXT.md`, + *Session Name*). Deliberately holds nothing a command could address: see + this module's docstring. Composed by the lane that observed the Session + (`adapters/agent/_naming.py`) and frozen by the Session registry, so what a + surface renders is what was true the first time the Session was seen. """ project: str @@ -85,27 +88,27 @@ class SessionLabel: def __post_init__(self) -> None: for half, value in (("project", self.project), ("task", self.task)): if not value.strip(): - raise ValueError(f"a Session Label's {half} half may not be empty") - if LABEL_SEPARATOR.strip() in value: + raise ValueError(f"a Session Name's {half} half may not be empty") + if NAME_SEPARATOR.strip() in value: raise ValueError( - f"a Session Label's {half} half may not contain {LABEL_SEPARATOR.strip()!r}" + f"a Session Name's {half} half may not contain {NAME_SEPARATOR.strip()!r}" ) def __str__(self) -> str: - return f"{self.project}{LABEL_SEPARATOR}{self.task}" + return f"{self.project}{NAME_SEPARATOR}{self.task}" @classmethod - def parse(cls, text: str) -> SessionLabel: - """Read back a rendered label, refusing anything that is not exactly one.""" - halves = text.split(LABEL_SEPARATOR.strip()) + def parse(cls, text: str) -> SessionName: + """Read back a rendered name, refusing anything that is not exactly one.""" + halves = text.split(NAME_SEPARATOR.strip()) if len(halves) != 2: - raise ValueError(f"not a Session Label: {text!r}") + raise ValueError(f"not a Session Name: {text!r}") return cls(project=halves[0].strip(), task=halves[1].strip()) @dataclass(frozen=True, slots=True) class SessionTarget: - """The exact identity a command carries. Never inferred, never a label.""" + """The exact identity a command carries. Never inferred, never a name.""" agent: AgentKind #: `None` means "this Session has not been named yet", which is a real and diff --git a/tests/hub.py b/tests/hub.py index a8fce00..2fca226 100644 --- a/tests/hub.py +++ b/tests/hub.py @@ -22,7 +22,7 @@ from gpt_voicecoding.core.switches import Switchboard, SwitchName from gpt_voicecoding.seams.agent import RelayRoute, ReplyWindow, SessionState from gpt_voicecoding.seams.delivery import Delivery -from gpt_voicecoding.seams.identity import AgentKind, SessionLabel, SessionTarget +from gpt_voicecoding.seams.identity import AgentKind, SessionName, SessionTarget CODEX = SessionTarget(agent=AgentKind.CODEX, session_id="abc") CLAUDE = SessionTarget(agent=AgentKind.CLAUDE, session_id="def", pid=100) @@ -62,7 +62,7 @@ def __init__( registry.register( Session( target=target, - label=SessionLabel("GPT-VoiceCoding", task), + name=SessionName("GPT-VoiceCoding", task), workspace=Path("/tmp/workspace"), first_seen=0.0, state=state, diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 289887a..dbbd895 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -55,7 +55,7 @@ def test_a_stop_notice_never_relays_words_back_into_a_session(self) -> None: assert hub.agent.calls == [] - def test_a_stopped_session_is_announced_by_its_label(self) -> None: + def test_a_stopped_session_is_announced_by_its_name(self) -> None: hub = Hub() hub.emit(SessionStopped(target=CODEX)) diff --git a/tests/test_bridgectl.py b/tests/test_bridgectl.py index d66fdd3..7288cf9 100644 --- a/tests/test_bridgectl.py +++ b/tests/test_bridgectl.py @@ -320,8 +320,7 @@ def reply(self, progress: object, last_activity: object = None) -> Reply: { "session": { "target": {"agent": "codex", "session_id": "abc", "pid": None}, - "label": "GPT-VoiceCoding · build it", - "name": None, + "name": "GPT-VoiceCoding · build it", "workspace": "/tmp/workspace", "state": "idle", "reply_window": "open", diff --git a/tests/test_claude_agent.py b/tests/test_claude_agent.py index 7a54c3e..5b17818 100644 --- a/tests/test_claude_agent.py +++ b/tests/test_claude_agent.py @@ -49,7 +49,7 @@ SessionState, ) from gpt_voicecoding.seams.delivery import Delivery -from gpt_voicecoding.seams.identity import AgentKind, RequestId, SessionLabel, SessionTarget +from gpt_voicecoding.seams.identity import AgentKind, RequestId, SessionName, SessionTarget from gpt_voicecoding.seams.verify import VerifyOutcome SESSION = "0b7cf6f2-0f3c-4f5e-9d1f-8a2b3c4d5e6f" @@ -870,7 +870,7 @@ async def scenario(): registry.register( Session( target=TARGET, - label=SessionLabel("GPT-VoiceCoding", "port the log"), + name=SessionName("GPT-VoiceCoding", "port the log"), workspace=Path("/tmp/workspace"), first_seen=0.0, state=SessionState.IDLE, diff --git a/tests/test_claude_discovery.py b/tests/test_claude_discovery.py index 28ac231..b35d16c 100644 --- a/tests/test_claude_discovery.py +++ b/tests/test_claude_discovery.py @@ -12,6 +12,7 @@ import asyncio from pathlib import Path +from gpt_voicecoding.adapters.agent._project import ProjectNames from gpt_voicecoding.adapters.agent.claude.discovery import CommandResult, discover from gpt_voicecoding.seams.agent import SessionState, WaitingKind from gpt_voicecoding.seams.identity import AgentKind @@ -47,8 +48,31 @@ async def run(argv: list[str]) -> CommandResult: return run -def found(rows: object, **kwargs: object) -> object: - return asyncio.run(discover(run=answering(rows, **kwargs))) # type: ignore[arg-type] +def not_a_repository() -> object: + """A `git` that says a workspace belongs to no repository.""" + + async def ask(asked: Path) -> str | None: + del asked + return None + + return ask + + +def inside(repository: str) -> object: + """A `git` answering with the common directory of exactly this repository.""" + + async def ask(asked: Path) -> str | None: + del asked + return f"{repository}/.git\n" + + return ask + + +def found(rows: object, *, git: object = None, **kwargs: object) -> object: + projects = ProjectNames(ask=git or not_a_repository()) # type: ignore[arg-type] + return asyncio.run( + discover(run=answering(rows, **kwargs), projects=projects) # type: ignore[arg-type] + ) class TestMappingOneRow: @@ -67,8 +91,45 @@ def test_the_target_carries_both_the_session_id_and_the_pid(self) -> None: def test_the_workspace_is_the_cwd_the_roster_reported(self) -> None: assert found([IDLE_ROW]).rows[0].workspace == Path("/tmp/workspace-claude") - def test_the_agents_own_name_travels(self) -> None: - assert found([IDLE_ROW]).rows[0].name == "workspace-claude-ed" + def test_the_row_is_named_for_its_project_and_the_agents_own_name(self) -> None: + """#78: `<project> · <title>`, with the roster's own `name` as the title.""" + named = found([IDLE_ROW], git=inside("/src/GPT-VoiceCoding")).rows[0].name + assert str(named) == "GPT-VoiceCoding · workspace-claude-ed" + + def test_a_workspace_outside_a_repository_is_named_for_its_directory(self) -> None: + """*Adapted*: legacy left such a Session unnamed and unspeakable.""" + assert str(found([IDLE_ROW]).rows[0].name) == "workspace-claude · workspace-claude-ed" + + def test_a_row_the_roster_did_not_name_stays_unnamed(self) -> None: + """No title, no name. An unnamed row is listed like any other.""" + assert found([IDLE_ROW | {"name": " "}]).rows[0].name is None + + def test_a_row_with_no_workspace_stays_unnamed(self) -> None: + """Half a name is not a name, and there is nothing to fill the other half with.""" + without_cwd = {key: value for key, value in IDLE_ROW.items() if key != "cwd"} + lane = found([without_cwd]) + assert len(lane.rows) == 1 + assert lane.rows[0].name is None + + def test_the_project_is_read_once_however_many_rows_share_a_workspace(self) -> None: + """The cadence asks this per row, every few seconds; `git` is asked once.""" + asked: list[Path] = [] + + async def counting(workspace: Path) -> str | None: + asked.append(workspace) + return "/src/GPT-VoiceCoding/.git" + + second = IDLE_ROW | {"pid": 3539, "sessionId": "another-session-id"} + projects = ProjectNames(ask=counting) + lane = asyncio.run( + discover(run=answering([IDLE_ROW, second]), projects=projects) # type: ignore[arg-type] + ) + + assert [str(row.name) for row in lane.rows] == [ + "GPT-VoiceCoding · workspace-claude-ed", + "GPT-VoiceCoding · workspace-claude-ed", + ] + assert len(asked) == 1 def test_a_row_in_the_roster_is_live(self) -> None: assert found([IDLE_ROW]).rows[0].lifecycle == "live" diff --git a/tests/test_claude_stop_wiring.py b/tests/test_claude_stop_wiring.py index ac65b5d..66e0862 100644 --- a/tests/test_claude_stop_wiring.py +++ b/tests/test_claude_stop_wiring.py @@ -103,7 +103,7 @@ def roster(monkeypatch: pytest.MonkeyPatch): """ def answering(lane: LaneDiscovery) -> None: - async def stub() -> LaneDiscovery: + async def stub(**_asked: object) -> LaneDiscovery: return lane monkeypatch.setattr(claude_adapter.claude_discovery, "discover", stub) diff --git a/tests/test_codex_discovery.py b/tests/test_codex_discovery.py index 2461f48..ff6dfaa 100644 --- a/tests/test_codex_discovery.py +++ b/tests/test_codex_discovery.py @@ -17,6 +17,7 @@ import os from pathlib import Path +from gpt_voicecoding.adapters.agent._project import ProjectNames from gpt_voicecoding.adapters.agent.codex import discovery from gpt_voicecoding.adapters.agent.codex.discovery import discover from gpt_voicecoding.adapters.agent.codex.processes import Candidate @@ -86,11 +87,26 @@ async def processes() -> tuple[Candidate, ...]: return processes +def not_a_repository() -> object: + """A `git` that says a workspace belongs to no repository. + + The default here, so a name in this file is the workspace's own directory + and the assertions do not depend on where the checkout running them lives. + """ + + async def ask(asked: Path) -> str | None: + del asked + return None + + return ask + + def found( client: object | None, *candidates: Candidate, home: Path | None = None, daemon_note: str = "", + git: object = None, ) -> object: return asyncio.run( discover( @@ -98,6 +114,7 @@ def found( processes=listing(*candidates), home=home, daemon_note=daemon_note, + projects=ProjectNames(ask=git or not_a_repository()), # type: ignore[arg-type] ) ) @@ -109,7 +126,7 @@ def test_a_loaded_thread_becomes_a_row(self) -> None: row = lane.rows[0] assert row.target.session_id == THREAD assert row.workspace == Path("/tmp/w") - assert row.name == "a-thread" + assert str(row.name) == "w · a-thread" def test_rows_from_the_daemon_are_not_degraded(self) -> None: assert found(FakeDaemon({THREAD: thread(THREAD, cwd="/tmp/w")})).degraded is None @@ -307,6 +324,54 @@ def test_but_it_does_claim_the_rollout_it_wrote_itself(self, tmp_path: Path) -> assert lane.rows[0].target.session_id == THREAD +class TestWhatEachRowIsCalled: + """#78: `<project> · <title>`, and the title is whatever this lane can honestly say. + + The amended #67 port table (2026-08-25) dropped the route where a Session + reported a title of its own (`legacy@1d32845:bridge/hook.py:215-253`, + `bridge/daemon.py:1504-1544`), so every title here is composed from a fact + the lane already holds and nothing is asked of the Session. + """ + + def test_a_thread_the_daemon_named_is_called_that(self) -> None: + lane = found(FakeDaemon({THREAD: thread(THREAD, cwd="/tmp/w", name="port the log")})) + assert str(lane.rows[0].name) == "w · port the log" + + def test_a_thread_the_daemon_did_not_name_is_called_by_its_short_id(self) -> None: + """Eight characters of the thread id: short enough to say out loud.""" + lane = found(FakeDaemon({THREAD: thread(THREAD, cwd="/tmp/w")})) + assert str(lane.rows[0].name) == f"w · {THREAD[:8]}" + + def test_a_row_read_off_the_process_table_is_named_the_same_way(self, tmp_path: Path) -> None: + """The daemon is where a thread name comes from, so these rows take the id.""" + workspace = tmp_path / "workspace" + workspace.mkdir() + write_rollout(tmp_path, THREAD, workspace) + + lane = found(None, running(101, workspace), home=tmp_path) + assert str(lane.rows[0].name) == f"workspace · {THREAD[:8]}" + + def test_a_session_with_no_thread_id_yet_has_no_name_yet(self) -> None: + """#73: `codex` writes the id at the first turn, and there is nothing else to use.""" + assert found(None, running(101, "/tmp/w")).rows[0].name is None + + def test_the_project_half_is_the_repository_when_the_workspace_is_in_one(self) -> None: + async def inside_a_repository(asked: Path) -> str | None: + del asked + return "/src/GPT-VoiceCoding/.git\n" + + lane = found( + FakeDaemon({THREAD: thread(THREAD, cwd="/tmp/w", name="port the log")}), + git=inside_a_repository, + ) + assert str(lane.rows[0].name) == "GPT-VoiceCoding · port the log" + + def test_a_thread_named_with_the_separator_is_left_unnamed(self) -> None: + """A name with a `·` in it cannot be read back as two halves, so it is not one.""" + lane = found(FakeDaemon({THREAD: thread(THREAD, cwd="/tmp/w", name="a · b")})) + assert lane.rows[0].name is None + + class TestWhenTheLaneCannotLookAtAll: def test_a_process_table_that_cannot_be_read_is_a_lane_error(self) -> None: lane = asyncio.run( diff --git a/tests/test_control_plane_actions.py b/tests/test_control_plane_actions.py index ad94b14..7932531 100644 --- a/tests/test_control_plane_actions.py +++ b/tests/test_control_plane_actions.py @@ -46,11 +46,11 @@ SessionState, ) from gpt_voicecoding.seams.control_plane import Action, ErrorCode, Reply, Request -from gpt_voicecoding.seams.identity import AgentKind, SessionLabel, SessionTarget +from gpt_voicecoding.seams.identity import AgentKind, SessionName, SessionTarget WORKSPACE = Path("/tmp/workspace") SECOND_WORKSPACE = Path("/tmp/another-workspace") -LABEL = SessionLabel(project="GPT-VoiceCoding", task="build the control plane") +NAME = SessionName(project="GPT-VoiceCoding", task="build the control plane") CODEX = SessionTarget(agent=AgentKind.CODEX, session_id="abc") CLAUDE = SessionTarget(agent=AgentKind.CLAUDE, session_id="claude-abc", pid=1234) SECOND_CODEX = SessionTarget(agent=AgentKind.CODEX, session_id="def") @@ -96,7 +96,7 @@ def register(self, target: SessionTarget = CODEX) -> Session: onto one. """ return self.state.sessions.register( - Session(target=target, label=LABEL, workspace=WORKSPACE, first_seen=0.0) + Session(target=target, name=NAME, workspace=WORKSPACE, first_seen=0.0) ) def open_window(self) -> None: @@ -175,7 +175,7 @@ def test_status_renders_the_whole_hub(self) -> None: assert data["switches"]["duty"] is True assert data["call_id"] is None assert data["sessions"][0]["target"] == CODEX_ADDRESS - assert data["sessions"][0]["label"] == str(LABEL) + assert data["sessions"][0]["name"] == str(NAME) # Two facts, two fields: whether the Session is still there, and what # it is doing. They used to be one enum, which is how a busy Session and # a finished one read alike. diff --git a/tests/test_engine.py b/tests/test_engine.py index 9c416c2..4ec82bd 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -38,7 +38,7 @@ ) from gpt_voicecoding.seams.companion_channel import InboundText from gpt_voicecoding.seams.control_plane import Action, Reply, Request -from gpt_voicecoding.seams.identity import AgentKind, SessionLabel, SessionTarget +from gpt_voicecoding.seams.identity import AgentKind, SessionName, SessionTarget CODEX = SessionTarget(agent=AgentKind.CODEX, session_id="abc") CLAUDE = SessionTarget( @@ -137,7 +137,7 @@ def put_on_the_roster(engine: Engine, target: SessionTarget = CODEX) -> None: engine.core._state.sessions.register( Session( target=target, - label=SessionLabel(project="GPT-VoiceCoding", task="t"), + name=SessionName(project="GPT-VoiceCoding", task="t"), workspace=Path("/tmp/workspace"), first_seen=0.0, ) diff --git a/tests/test_persistence.py b/tests/test_persistence.py index b203a87..8dfec43 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -24,7 +24,7 @@ from gpt_voicecoding.core.sessions import Session, SessionRegistry from gpt_voicecoding.core.state import BridgeState from gpt_voicecoding.core.switches import FeatureSwitch, Switchboard, SwitchName -from gpt_voicecoding.seams.identity import AgentKind, SessionLabel, SessionTarget, new_request_id +from gpt_voicecoding.seams.identity import AgentKind, SessionName, SessionTarget, new_request_id WORKSPACE = Path(__file__).resolve().parents[1] @@ -32,7 +32,7 @@ def a_session(session_id: str = "abc", pid: int = 100) -> Session: return Session( target=SessionTarget(agent=AgentKind.CLAUDE, session_id=session_id, pid=pid), - label=SessionLabel("GPT-VoiceCoding", "Implement the seam contracts"), + name=SessionName("GPT-VoiceCoding", "Implement the seam contracts"), workspace=WORKSPACE, first_seen=1_724_000_000.0, ) diff --git a/tests/test_realtime_call.py b/tests/test_realtime_call.py index 7ace21a..561677a 100644 --- a/tests/test_realtime_call.py +++ b/tests/test_realtime_call.py @@ -59,7 +59,7 @@ ) THREAD = "01a02110-d18f-74a0-916d-de1208e9977a" -HOUSE_RULES = "speak the Session Label; never invent a detail" +HOUSE_RULES = "speak the Session Name; never invent a detail" DELEGATED_RULES = "act only through the control-plane CLI" _names = iter(range(10_000)) diff --git a/tests/test_relay_pipeline.py b/tests/test_relay_pipeline.py index f5394d5..5f7b268 100644 --- a/tests/test_relay_pipeline.py +++ b/tests/test_relay_pipeline.py @@ -39,7 +39,7 @@ from gpt_voicecoding.core.sessions import Session, SessionRegistry from gpt_voicecoding.seams.agent import RelayRoute, ReplyWindow, SessionState from gpt_voicecoding.seams.delivery import Delivery -from gpt_voicecoding.seams.identity import AgentKind, SessionLabel, SessionTarget, new_request_id +from gpt_voicecoding.seams.identity import AgentKind, SessionName, SessionTarget, new_request_id CODEX = SessionTarget(agent=AgentKind.CODEX, session_id="abc") CLAUDE = SessionTarget(agent=AgentKind.CLAUDE, session_id="def", pid=100) @@ -66,7 +66,7 @@ def __init__( self.sessions.register( Session( target=target, - label=SessionLabel("GPT-VoiceCoding", f"task {target.session_id}"), + name=SessionName("GPT-VoiceCoding", f"task {target.session_id}"), workspace=Path("/tmp/workspace"), first_seen=0.0, state=state, diff --git a/tests/test_router.py b/tests/test_router.py index 4ab4412..8f77785 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -10,7 +10,7 @@ /<command> … a control-plane command ><prompt> a Delegated Turn - @<label>: words the user's own words, for the Session that label names + @<name>: words the user's own words, for the Session that name names words the user's own words, when exactly one Session is live The collision that has to be got right in both directions: a bare `stop` must @@ -24,7 +24,7 @@ from gpt_voicecoding.core.router import InboundClass, InboundRouter, TextGrammar from gpt_voicecoding.core.sessions import Session, SessionRegistry -from gpt_voicecoding.seams.identity import AgentKind, SessionLabel, SessionTarget +from gpt_voicecoding.seams.identity import AgentKind, SessionName, SessionTarget CODEX = SessionTarget(agent=AgentKind.CODEX, session_id="abc") CLAUDE = SessionTarget(agent=AgentKind.CLAUDE, session_id="def", pid=100) @@ -38,7 +38,7 @@ def registry_of(*sessions: tuple[SessionTarget, str]) -> SessionRegistry: registry.register( Session( target=target, - label=SessionLabel("GPT-VoiceCoding", task), + name=SessionName("GPT-VoiceCoding", task), workspace=Path("/tmp/workspace"), first_seen=0.0, ) @@ -109,8 +109,8 @@ def test_a_delegation_is_never_relayed_into_a_session(self) -> None: assert found.target is None -class TestAddressingASessionByLabel: - def test_a_labelled_relay_resolves_to_that_session(self) -> None: +class TestAddressingASessionByName: + def test_a_named_relay_resolves_to_that_session(self) -> None: found = router((CODEX, "port the log"), (CLAUDE, "build the shell")).classify( "@shell: ship it" ) @@ -119,14 +119,14 @@ def test_a_labelled_relay_resolves_to_that_session(self) -> None: assert found.target == CLAUDE assert found.text == "ship it" - def test_a_label_matching_nothing_fails_closed(self) -> None: + def test_a_name_matching_nothing_fails_closed(self) -> None: found = router((CODEX, "port the log")).classify("@nothing: ship it") assert found.kind is InboundClass.UNKNOWN assert found.reply - def test_a_label_matching_two_sessions_refuses_and_names_both(self) -> None: - """A label disambiguates or asks — never picks.""" + def test_a_name_matching_two_sessions_refuses_and_names_both(self) -> None: + """A Session Name disambiguates or asks — never picks.""" found = router((CODEX, "port the log"), (CLAUDE, "port the shell")).classify( "@port: ship it" ) @@ -135,7 +135,7 @@ def test_a_label_matching_two_sessions_refuses_and_names_both(self) -> None: assert "port the log" in found.reply assert "port the shell" in found.reply - def test_a_labelled_relay_with_no_words_fails_closed(self) -> None: + def test_a_named_relay_with_no_words_fails_closed(self) -> None: found = router((CODEX, "port the log")).classify("@log: ") assert found.kind is InboundClass.UNKNOWN @@ -203,7 +203,7 @@ def test_a_command_word_inside_a_sentence_is_ordinary_words(self) -> None: assert found.kind is InboundClass.ANSWER_RELAY assert found.text == "stop after the tests pass" - def test_a_labelled_command_word_is_unambiguous_and_goes_through(self) -> None: + def test_a_named_command_word_is_unambiguous_and_goes_through(self) -> None: found = router((CODEX, "port the log")).classify("@log: stop") assert found.kind is InboundClass.ANSWER_RELAY diff --git a/tests/test_session_naming.py b/tests/test_session_naming.py new file mode 100644 index 0000000..81eb573 --- /dev/null +++ b/tests/test_session_naming.py @@ -0,0 +1,117 @@ +"""How a Session Name is made: the pure composition, and the project half. + +Two modules, one rule between them (#78). `_naming.compose` is where a project +and a title become a `SessionName` and is pure, so it is tested against strings. +`_project.ProjectNames` is the one place either lane runs `git`, and the command +is injected here for the reason the Claude lane injects its roster command: a +test that really shelled out would be measuring whichever repositories the +machine running it happens to have. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from gpt_voicecoding.adapters.agent._naming import compose +from gpt_voicecoding.adapters.agent._project import ProjectNames, _project_in +from gpt_voicecoding.seams.identity import SessionName + + +def answering(*answers: str | None) -> ProjectNames: + """A resolver whose `git` says these things, in this order.""" + remaining = list(answers) + + async def ask(workspace: Path) -> str | None: + del workspace + return remaining.pop(0) if remaining else None + + return ProjectNames(ask=ask) + + +def resolved(names: ProjectNames, workspace: str) -> str | None: + return asyncio.run(names.of(Path(workspace))) + + +class TestComposingOne: + def test_the_two_halves_become_one_name(self) -> None: + assert compose("GPT-VoiceCoding", "port the log") == SessionName( + project="GPT-VoiceCoding", task="port the log" + ) + + def test_both_halves_are_stripped_because_both_are_somebody_elses_string(self) -> None: + assert str(compose(" GPT-VoiceCoding ", " port the log\n")) == ( + "GPT-VoiceCoding · port the log" + ) + + def test_an_empty_title_is_no_name(self) -> None: + """`legacy@1d32845:bridge/labels.py:100-102`, ported — and answered rather than raised.""" + assert compose("GPT-VoiceCoding", " ") is None + + def test_a_title_spanning_two_lines_is_no_name(self) -> None: + """A name the user cannot say back is not a name (`labels.py:103-105`).""" + assert compose("GPT-VoiceCoding", "port the log\nand the rest") is None + + def test_a_title_with_a_carriage_return_is_no_name(self) -> None: + assert compose("GPT-VoiceCoding", "port the log\rand the rest") is None + + def test_an_empty_project_is_no_name(self) -> None: + assert compose(" ", "port the log") is None + + def test_a_half_carrying_the_separator_is_no_name(self) -> None: + """It would not parse back into two halves, so it never becomes one.""" + assert compose("GPT-VoiceCoding", "port · the log") is None + assert compose("a · b", "port the log") is None + + +class TestTheProjectHalf: + def test_a_workspace_in_a_repository_is_named_for_the_repository(self) -> None: + names = answering("/src/GPT-VoiceCoding/.git\n") + assert resolved(names, "/src/GPT-VoiceCoding/tests") == "GPT-VoiceCoding" + + def test_a_worktree_is_named_for_the_repository_it_belongs_to(self) -> None: + """`--git-common-dir` is the question precisely because it answers this.""" + names = answering("/src/GPT-VoiceCoding/.git\n") + assert resolved(names, "/tmp/worktrees/78-naming") == "GPT-VoiceCoding" + + def test_a_workspace_outside_a_repository_is_named_for_its_own_directory(self) -> None: + """*Adapted* from legacy, which raised here and left the Session unnamed.""" + assert resolved(answering(None), "/Users/simon/scratch") == "scratch" + + def test_a_git_that_could_not_be_asked_falls_back_rather_than_failing(self) -> None: + assert resolved(answering(None), "/Users/simon/scratch") == "scratch" + + def test_a_row_with_no_workspace_at_all_has_no_project(self) -> None: + """The lanes spell "the roster carried no cwd" as `Path()`.""" + assert asyncio.run(answering("/src/x/.git").of(Path())) is None + + def test_the_answer_is_remembered_so_git_is_asked_once_per_workspace(self) -> None: + names = answering("/src/GPT-VoiceCoding/.git", "/src/somewhere-else/.git") + first = resolved(names, "/src/GPT-VoiceCoding") + second = resolved(names, "/src/GPT-VoiceCoding") + assert (first, second) == ("GPT-VoiceCoding", "GPT-VoiceCoding") + + def test_two_paths_to_one_directory_are_one_entry(self) -> None: + names = answering("/src/GPT-VoiceCoding/.git") + assert resolved(names, "/src/GPT-VoiceCoding") == "GPT-VoiceCoding" + assert resolved(names, "/src/GPT-VoiceCoding/") == "GPT-VoiceCoding" + + +class TestReadingWhatGitSaid: + """Three shapes are refused, all of them legacy's (`labels.py:56-70`).""" + + def test_the_ordinary_answer(self) -> None: + assert _project_in("/src/GPT-VoiceCoding/.git\n") == "GPT-VoiceCoding" + + def test_more_than_one_line_is_not_one_path(self) -> None: + assert _project_in("/src/a/.git\n/src/b/.git\n") is None + + def test_a_relative_path_is_refused(self) -> None: + """`--path-format=absolute` is passed, so a relative answer is not the one asked for.""" + assert _project_in(".git") is None + + def test_a_directory_that_is_not_dot_git_is_refused(self) -> None: + assert _project_in("/src/GPT-VoiceCoding/somewhere-else") is None + + def test_nothing_is_refused(self) -> None: + assert _project_in(" ") is None diff --git a/tests/test_session_observation.py b/tests/test_session_observation.py index 521f4ea..aa4c733 100644 --- a/tests/test_session_observation.py +++ b/tests/test_session_observation.py @@ -9,7 +9,6 @@ from __future__ import annotations -from dataclasses import replace from pathlib import Path from gpt_voicecoding.core.sessions import SessionRegistry @@ -19,7 +18,7 @@ SessionLifecycle, SessionState, ) -from gpt_voicecoding.seams.identity import AgentKind, SessionTarget +from gpt_voicecoding.seams.identity import AgentKind, SessionName, SessionTarget WORKSPACE = Path("/tmp/workspace") NOW = 1_000.0 @@ -41,6 +40,11 @@ def claude_row(*, session_id: str, pid: int, **fields: object) -> SessionInspect ) +def named(task: str) -> SessionName: + """One composed Session Name, as a lane hands it over.""" + return SessionName(project="GPT-VoiceCoding", task=task) + + def seeing(*rows: SessionInspection, **lane: object) -> LaneDiscovery: return LaneDiscovery(rows=rows, **lane) # type: ignore[arg-type] @@ -219,27 +223,98 @@ def test_a_lane_that_could_not_look_says_nothing_about_how_well_it_reads(self) - assert registry.lane_degradations() == {AgentKind.CODEX: "shared daemon absent"} -class TestWhatTheRowCarriesAcrossReadings: - def test_the_users_own_name_for_it_is_not_overwritten_by_a_lane(self) -> None: - """A lane knows the agent's name for a Session; it does not know the user's.""" - from gpt_voicecoding.seams.identity import SessionLabel +class TestTheNameARowKeeps: + """#78: a Session Name is composed once per exact identity and then held. + The rule the reference implementation enforced in its store — first write + wins, an exact repeat is a no-op, a different one is refused + (`legacy@1d32845:bridge/store.py:1875-1902`) — moved here, where the writes + now come from: a lane composing a name off every reading, five seconds + apart, forever. + """ + + def test_the_first_name_a_lane_composes_is_taken(self) -> None: + registry = SessionRegistry() + registry.observe( + AgentKind.CODEX, + seeing(codex_row(session_id="abc", pid=10, name=named("a task"))), + now=NOW, + ) + + assert str(registry.live()[0].name) == "GPT-VoiceCoding · a task" + + def test_a_row_seen_unnamed_takes_the_name_that_arrives_later(self) -> None: + """Filling an empty name is not changing one; an unnamed row is ordinary.""" registry = SessionRegistry() registry.observe(AgentKind.CODEX, seeing(codex_row(session_id="abc", pid=10)), now=NOW) - target = registry.live()[0].target - registry._sessions[target] = replace( # noqa: SLF001 - #78 owns the public path - registry._sessions[target], label=SessionLabel("GPT-VoiceCoding", "a task") + assert registry.live()[0].name is None + + registry.observe( + AgentKind.CODEX, + seeing(codex_row(session_id="abc", pid=10, name=named("a task"))), + now=NOW + 5, + ) + + assert str(registry.live()[0].name) == "GPT-VoiceCoding · a task" + + def test_a_second_different_name_for_one_identity_is_ignored(self) -> None: + """The whole point of naming: the user says the name they were told.""" + registry = SessionRegistry() + registry.observe( + AgentKind.CODEX, + seeing(codex_row(session_id="abc", pid=10, name=named("a task"))), + now=NOW, + ) + registry.observe( + AgentKind.CODEX, + seeing(codex_row(session_id="abc", pid=10, name=named("something else"))), + now=NOW + 5, ) + assert str(registry.live()[0].name) == "GPT-VoiceCoding · a task" + + def test_a_codex_row_that_gains_its_thread_id_is_named_for_it(self) -> None: + """A new exact identity re-composes: #73's row had no id to be named from.""" + registry = SessionRegistry() + registry.observe(AgentKind.CODEX, seeing(codex_row(session_id=None, pid=10)), now=NOW) registry.observe( AgentKind.CODEX, - seeing(codex_row(session_id="abc", pid=10, name="something-else")), + seeing(codex_row(session_id="abc", pid=10, name=named("abc12345"))), now=NOW + 5, ) - assert str(registry.live()[0].label) == "GPT-VoiceCoding · a task" - assert registry.live()[0].name == "something-else" + assert str(registry.live()[0].name) == "GPT-VoiceCoding · abc12345" + def test_a_new_thread_under_the_same_pid_is_named_for_the_new_thread(self) -> None: + """`/new` in that TUI (#77): same process, different Session, different name.""" + registry = SessionRegistry() + registry.observe( + AgentKind.CODEX, + seeing(codex_row(session_id="abc", pid=10, name=named("the first thread"))), + now=NOW, + ) + registry.observe( + AgentKind.CODEX, + seeing(codex_row(session_id="def", pid=10, name=named("the second thread"))), + now=NOW + 5, + ) + + assert str(registry.live()[0].name) == "GPT-VoiceCoding · the second thread" + + def test_a_tick_with_only_process_evidence_keeps_the_name(self) -> None: + """The identity did not change, so neither does the name it was given.""" + registry = SessionRegistry() + registry.observe( + AgentKind.CODEX, + seeing(codex_row(session_id="abc", pid=10, name=named("a task"))), + now=NOW, + ) + registry.observe(AgentKind.CODEX, seeing(codex_row(session_id=None, pid=10)), now=NOW + 5) + + assert str(registry.live()[0].name) == "GPT-VoiceCoding · a task" + + +class TestWhatTheRowCarriesAcrossReadings: def test_the_state_a_lane_read_replaces_the_one_held(self) -> None: registry = SessionRegistry() registry.observe( diff --git a/tests/test_session_registry.py b/tests/test_session_registry.py index fa61b73..8e4d9ef 100644 --- a/tests/test_session_registry.py +++ b/tests/test_session_registry.py @@ -1,7 +1,7 @@ """The Session registry — Bridge Core state, deliberately not a module. The behaviours under test are the ones the reference implementation got wrong or -left to prose: a target is exact or it is refused, a label disambiguates or asks, +left to prose: a target is exact or it is refused, a Session Name disambiguates or asks, and a stale identity fails closed rather than resolving to something plausible. """ @@ -13,9 +13,9 @@ import pytest from gpt_voicecoding.core.errors import ( - AmbiguousLabelError, + AmbiguousNameError, DuplicateSessionError, - NoLabelMatchError, + NoNameMatchError, StaleSessionError, UnknownSessionError, ) @@ -27,7 +27,7 @@ SessionLifecycle, SessionState, ) -from gpt_voicecoding.seams.identity import AgentKind, SessionLabel, SessionTarget +from gpt_voicecoding.seams.identity import AgentKind, SessionName, SessionTarget WORKSPACE = Path(__file__).resolve().parents[1] @@ -35,7 +35,7 @@ def claude(session_id: str, pid: int, task: str = "a task") -> Session: return Session( target=SessionTarget(agent=AgentKind.CLAUDE, session_id=session_id, pid=pid), - label=SessionLabel("GPT-VoiceCoding", task), + name=SessionName("GPT-VoiceCoding", task), workspace=WORKSPACE, first_seen=1_000.0, ) @@ -44,7 +44,7 @@ def claude(session_id: str, pid: int, task: str = "a task") -> Session: def codex(session_id: str, task: str = "a task") -> Session: return Session( target=SessionTarget(agent=AgentKind.CODEX, session_id=session_id), - label=SessionLabel("GPT-VoiceCoding", task), + name=SessionName("GPT-VoiceCoding", task), workspace=WORKSPACE, first_seen=1_000.0, ) @@ -124,24 +124,24 @@ def test_a_pid_carried_on_a_codex_target_does_not_change_the_answer(self) -> Non assert resolved.target.pid is None -class TestMatchingLabels: - def test_a_label_matches_the_one_session_that_carries_it(self) -> None: +class TestMatchingNames: + def test_a_name_matches_the_one_session_that_carries_it(self) -> None: registry = SessionRegistry() session = codex("abc", task="Implement the seam contracts") registry.register(session) - assert registry.match_label("GPT-VoiceCoding · Implement the seam contracts") == session + assert registry.match_name("GPT-VoiceCoding · Implement the seam contracts") == session def test_a_fragment_matches(self) -> None: registry = SessionRegistry() session = codex("abc", task="Implement the seam contracts") registry.register(session) - assert registry.match_label("seam contracts") == session + assert registry.match_name("seam contracts") == session def test_matching_ignores_case_and_extra_whitespace(self) -> None: registry = SessionRegistry() session = codex("abc", task="Implement the seam contracts") registry.register(session) - assert registry.match_label(" SEAM CONTRACTS ") == session + assert registry.match_name(" SEAM CONTRACTS ") == session def test_two_candidates_refuse_rather_than_pick(self) -> None: registry = SessionRegistry() @@ -149,49 +149,49 @@ def test_two_candidates_refuse_rather_than_pick(self) -> None: second = claude("def", pid=100, task="Implement the seam contracts, part two") registry.register(first) registry.register(second) - with pytest.raises(AmbiguousLabelError) as raised: - registry.match_label("seam contracts") + with pytest.raises(AmbiguousNameError) as raised: + registry.match_name("seam contracts") assert set(raised.value.candidates) == {first, second} - def test_a_whole_label_that_is_also_a_fragment_of_another_still_refuses(self) -> None: + def test_a_whole_name_that_is_also_a_fragment_of_another_still_refuses(self) -> None: """ "ship it" names both "ship it" and "ship it later". Ask, do not prefer.""" registry = SessionRegistry() exact = codex("abc", task="ship it") longer = claude("def", pid=100, task="ship it later") registry.register(exact) registry.register(longer) - with pytest.raises(AmbiguousLabelError) as raised: - registry.match_label("GPT-VoiceCoding · ship it") + with pytest.raises(AmbiguousNameError) as raised: + registry.match_name("GPT-VoiceCoding · ship it") assert set(raised.value.candidates) == {exact, longer} - def test_a_label_shared_by_two_sessions_refuses_even_though_it_is_exact(self) -> None: + def test_a_name_shared_by_two_sessions_refuses_even_though_it_is_exact(self) -> None: registry = SessionRegistry() first = codex("abc", task="ship it") second = claude("def", pid=100, task="ship it") registry.register(first) registry.register(second) - with pytest.raises(AmbiguousLabelError): - registry.match_label("GPT-VoiceCoding · ship it") + with pytest.raises(AmbiguousNameError): + registry.match_name("GPT-VoiceCoding · ship it") def test_no_match_fails_closed(self) -> None: registry = SessionRegistry() registry.register(codex("abc")) - with pytest.raises(NoLabelMatchError): - registry.match_label("something else entirely") + with pytest.raises(NoNameMatchError): + registry.match_name("something else entirely") def test_an_ended_session_is_not_a_candidate(self) -> None: registry = SessionRegistry() ended = codex("abc", task="Implement the seam contracts") registry.register(ended) registry.mark_ended(ended.target) - with pytest.raises(NoLabelMatchError): - registry.match_label("seam contracts") + with pytest.raises(NoNameMatchError): + registry.match_name("seam contracts") - def test_a_match_returns_a_session_never_a_target_built_from_the_label(self) -> None: + def test_a_match_returns_a_session_never_a_target_built_from_the_name(self) -> None: registry = SessionRegistry() session = codex("abc", task="Implement the seam contracts") registry.register(session) - assert registry.match_label("seam contracts").target == session.target + assert registry.match_name("seam contracts").target == session.target class TestReplyWindow: diff --git a/tests/test_vocabulary.py b/tests/test_vocabulary.py index 4a0a6a1..ae6025c 100644 --- a/tests/test_vocabulary.py +++ b/tests/test_vocabulary.py @@ -1,6 +1,6 @@ """The shared vocabulary the seams publish, and the rules it enforces by shape. -Two locked rules are structural here rather than remembered: a Session Label can +Two locked rules are structural here rather than remembered: a Session Name can never be passed where a target is expected, and a Claude Session cannot be addressed without a pid. Both are enforced by the types, so an adapter that gets it wrong fails at construction rather than at delivery time. @@ -13,7 +13,7 @@ from gpt_voicecoding.seams.delivery import Delivery, DeliveryReceipt from gpt_voicecoding.seams.identity import ( AgentKind, - SessionLabel, + SessionName, SessionTarget, new_request_id, ) @@ -29,35 +29,35 @@ def test_a_request_id_is_a_plain_string_so_every_route_can_carry_it(self) -> Non assert isinstance(new_request_id(), str) -class TestSessionLabel: - def test_a_label_renders_as_project_then_task(self) -> None: - assert str(SessionLabel("GPT-VoiceCoding", "Implement the seam contracts")) == ( +class TestSessionName: + def test_a_name_renders_as_project_then_task(self) -> None: + assert str(SessionName("GPT-VoiceCoding", "Implement the seam contracts")) == ( "GPT-VoiceCoding · Implement the seam contracts" ) - def test_a_rendered_label_parses_back(self) -> None: - label = SessionLabel("GPT-VoiceCoding", "Implement the seam contracts") - assert SessionLabel.parse(str(label)) == label + def test_a_rendered_name_parses_back(self) -> None: + name = SessionName("GPT-VoiceCoding", "Implement the seam contracts") + assert SessionName.parse(str(name)) == name - def test_a_label_is_not_a_target(self) -> None: + def test_a_name_is_not_a_target(self) -> None: """The locked rule, made structural: no attribute a command could carry.""" - label = SessionLabel("GPT-VoiceCoding", "a task") - assert not hasattr(label, "session_id") - assert not hasattr(label, "pid") + name = SessionName("GPT-VoiceCoding", "a task") + assert not hasattr(name, "session_id") + assert not hasattr(name, "pid") def test_an_empty_half_is_refused(self) -> None: with pytest.raises(ValueError): - SessionLabel("", "a task") + SessionName("", "a task") with pytest.raises(ValueError): - SessionLabel("GPT-VoiceCoding", " ") + SessionName("GPT-VoiceCoding", " ") def test_text_without_the_separator_does_not_parse(self) -> None: with pytest.raises(ValueError): - SessionLabel.parse("GPT-VoiceCoding") + SessionName.parse("GPT-VoiceCoding") def test_text_with_two_separators_does_not_parse(self) -> None: with pytest.raises(ValueError): - SessionLabel.parse("a · b · c") + SessionName.parse("a · b · c") class TestSessionTarget: From 99a3395f289d75dcca0e048fca4fa02d503b6bb6 Mon Sep 17 00:00:00 2001 From: Simon Qi <okqixiaobao727@gmail.com> Date: Thu, 27 Aug 2026 00:17:52 +1200 Subject: [PATCH 2/3] Never name a Child Process, and say the second half one way (#78 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the review of the change before it: - A Child Process could be named. #78's own table calls that risk 1 — a name the user can say and nothing can answer — and the rule now sits in the registry beside "a Child Process is seen, not spoken to", so it holds however #79 comes to find children rather than in whichever lane finds one. - The second half of a Session Name was called `title` in the new code and `task` in the type it was written into. `CONTEXT.md` avoids "title", so it is `task` everywhere, and the three places that still said "Session Label" — including the instruction block the Voice model reads — say Session Name. - `name_for` had become a two-line forward with one caller, and its two helpers are the answer already. Also: `compose` now logs the reason it refused a name, so a Session that never gets one is findable; and the empty-query refusal in `match_name` says what it is citing, which is that legacy has no spoken matching to cite — gen-1 addressed a session by id inside a tool call and only ever spoke its labels. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CONTEXT.md | 10 ++-- src/gpt_voicecoding/adapters/agent/_naming.py | 47 ++++++++++++------- .../adapters/agent/_project.py | 4 +- .../adapters/agent/claude/discovery.py | 12 ++--- .../adapters/agent/codex/discovery.py | 10 ++-- src/gpt_voicecoding/control_plane/commands.py | 2 +- src/gpt_voicecoding/control_plane/payloads.py | 2 +- src/gpt_voicecoding/core/bridge.py | 18 ++----- .../core/instructions/catalogue.py | 2 +- .../core/instructions/voice.py | 2 +- src/gpt_voicecoding/core/sessions.py | 31 +++++++++--- src/gpt_voicecoding/seams/identity.py | 2 +- tests/test_session_observation.py | 33 +++++++++++++ 13 files changed, 113 insertions(+), 62 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 5ed7a61..bb98ad3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -70,11 +70,11 @@ A process a Session spawns — a subagent, a review crew. It appears in the rost _Avoid_: child Session, subagent (the agent's mechanism word), crew **Session Name**: -What the user and the system call one Session: `<project> · <title>`, where the project is -the Git repository the Session is working in — its own directory when it is in none — and the -title is the agent's own name for the Session. Composed by the lane that first saw the Session -and never changed after that, because the user says it back to address the Session. -_Avoid_: label, title (the second half alone is not the name) +What the user and the system call one Session: `<project> · <task>`, where the project is the +Git repository the Session is working in — its own directory when it is in none — and the task +is the agent's own name for the Session. Composed by the lane that first saw the Session and +never changed after that, because the user says it back to address the Session. +_Avoid_: label, title **Relay**: Carrying words *into* a Session — the agent-ward direction. diff --git a/src/gpt_voicecoding/adapters/agent/_naming.py b/src/gpt_voicecoding/adapters/agent/_naming.py index d7f2d5f..a567ca0 100644 --- a/src/gpt_voicecoding/adapters/agent/_naming.py +++ b/src/gpt_voicecoding/adapters/agent/_naming.py @@ -1,4 +1,4 @@ -"""How both lanes turn a project and a title into one Session Name. +"""How both lanes turn a project and a task into one Session Name. **Pure, and shared.** Two lanes compose the same shape from different facts — Claude's official roster `name`, Codex's daemon `Thread.name` — and a rule that @@ -7,18 +7,19 @@ which is what keeps this testable against strings rather than against whichever repositories happen to be on the machine. -**The title is validated the way the reference implementation validated it** +**The task half is validated the way the reference implementation validated it** (`legacy@1d32845:bridge/labels.py:97-106`, *adapted*): non-empty after stripping, and one line. Legacy raised `SessionLabelError` on both, from a resolver called -at the moment a Session reported its own title; here the caller is a discovery +at the moment a Session reported its own task; here the caller is a discovery pass over every row on the machine every few seconds, and an exception per unnameable row would make a naming problem look like a lane failure. So the answer is `None` — "this Session has no name yet" — which is the state the -roster already renders (`core/sessions.py`). +roster already renders (`core/sessions.py`) — and the refusal is logged, so a +name that never appears is findable rather than silent. -**What is deliberately not here.** No transcript-derived title, no `ai-title`, +**What is deliberately not here.** No transcript-derived task, no `ai-title`, no mutable history: legacy measured that route at 30% of Sessions never writing -a title and the rest writing one a median of 440 s late +one and the rest writing one a median of 440 s late (`legacy@1d32845:bridge/labels.py:73-84`, *dropped, because* a record a product writes when it feels like it cannot carry a name the user speaks with). A name composed here is composed from a fact the lane already had. @@ -26,28 +27,38 @@ from __future__ import annotations +import logging + from gpt_voicecoding.seams.identity import NAME_SEPARATOR, SessionName +_log = logging.getLogger(__name__) + -def compose(project_name: str, title: str) -> SessionName | None: - """``<project> · <title>``, or `None` when the parts cannot make one. +def compose(project_name: str, task: str) -> SessionName | None: + """``<project> · <task>``, or `None` when the parts cannot make one. Both halves are stripped first, because the sources are other people's strings: a roster field and a daemon field, either of which may arrive - padded. A title that is empty or spans more than one line is refused rather + padded. A task that is empty or spans more than one line is refused rather than flattened — a Session Name is spoken and typed after `@`, so a name carrying a newline would be a name the user cannot say back. The separator is refused inside either half by `SessionName` itself, and - that refusal is caught here for the same reason as the rest: a Codex thread + that refusal is made here for the same reason as the rest: a Codex thread the user named `a · b` is a Session that has no name, not a lane that broke. """ project = project_name.strip() - task = title.strip() - if not project or not task: - return None - if len(task.splitlines()) > 1: - return None - if NAME_SEPARATOR.strip() in project or NAME_SEPARATOR.strip() in task: - return None - return SessionName(project=project, task=task) + wanted = task.strip() + if not project or not wanted: + return _unnamed(project_name, task, "a half with no words in it") + if len(wanted.splitlines()) > 1: + return _unnamed(project_name, task, "a task spanning more than one line") + if NAME_SEPARATOR.strip() in project or NAME_SEPARATOR.strip() in wanted: + return _unnamed(project_name, task, f"a half carrying {NAME_SEPARATOR.strip()!r}") + return SessionName(project=project, task=wanted) + + +def _unnamed(project_name: str, task: str, reason: str) -> None: + """Say why this Session has no name, and answer that it has none.""" + _log.info("no Session Name for %r · %r: %s", project_name, task, reason) + return None diff --git a/src/gpt_voicecoding/adapters/agent/_project.py b/src/gpt_voicecoding/adapters/agent/_project.py index dd94468..5d14862 100644 --- a/src/gpt_voicecoding/adapters/agent/_project.py +++ b/src/gpt_voicecoding/adapters/agent/_project.py @@ -10,9 +10,9 @@ is the right question: it answers with the *repository's* directory from any worktree or subdirectory, so two Sessions in `repo/` and `repo/src/` are working on one project and are named for it. Legacy then raised when the answer was not -a repository at all, and the label never existed. That is **adapted** here: v1.0 +a repository at all, and legacy's label never existed. That is **adapted** here: v1.0 bridges every Session the user starts, so one in `~/scratch` is named -`scratch · <title>` from the directory's own basename rather than left unnamed +`scratch · <task>` from the directory's own basename rather than left unnamed and unspeakable. The basename is a fact about the Session, not a guess. **Cached per resolved workspace, for the life of the adapter.** A workspace does diff --git a/src/gpt_voicecoding/adapters/agent/claude/discovery.py b/src/gpt_voicecoding/adapters/agent/claude/discovery.py index 7fcb0e7..aa9891f 100644 --- a/src/gpt_voicecoding/adapters/agent/claude/discovery.py +++ b/src/gpt_voicecoding/adapters/agent/claude/discovery.py @@ -171,25 +171,25 @@ async def _named( ) -> SessionInspection: """The same row, carrying its Session Name. - **The title half is the roster's own `name`** — `workspace-claude-ed` and the + **The task half is the roster's own `name`** — `workspace-claude-ed` and the like — which is the official answer to "what is this Session called" and is on every row from the moment the Session exists (#73). Nothing is asked of the Session to get it and no transcript is opened for it, which is what makes it stable enough to be the name the user speaks - (`legacy@1d32845:bridge/hook.py:215-253` had the Session report its own title + (`legacy@1d32845:bridge/hook.py:215-253` had the Session report a task title over the hook instead — *dropped, because* the amended #67 port table removed that route on 2026-08-25). The project half is the workspace's, resolved here because this is the lane that knows the workspace. A row with neither half stays unnamed. """ - title = _name(row) - if title is None: + task = _name(row) + if task is None: return inspection project = await projects.of(inspection.workspace) if project is None: return inspection - return replace(inspection, name=_naming.compose(project, title)) + return replace(inspection, name=_naming.compose(project, task)) def _inspection(row: dict[str, Any]) -> SessionInspection | None: @@ -244,6 +244,6 @@ def _waiting_for(state: SessionState) -> WaitingFor: def _name(row: dict[str, Any]) -> str | None: - """The title half of this Session's name, straight off the official roster.""" + """The task half of this Session's name, straight off the official roster.""" name = row.get("name") return name.strip() if isinstance(name, str) and name.strip() else None diff --git a/src/gpt_voicecoding/adapters/agent/codex/discovery.py b/src/gpt_voicecoding/adapters/agent/codex/discovery.py index 727c319..4ef55a0 100644 --- a/src/gpt_voicecoding/adapters/agent/codex/discovery.py +++ b/src/gpt_voicecoding/adapters/agent/codex/discovery.py @@ -74,7 +74,7 @@ #: How much of a thread id stands in for a name the daemon does not have. Eight #: characters of a UUID, which is what `codex` itself shows and short enough to -#: say out loud — the fallback title of every unnamed thread (#78). It is +#: say out loud — the fallback task of every unnamed thread (#78). It is #: composed here, from a fact this lane already holds: nothing is asked of the #: Session, and the route that used to ask one (`legacy@1d32845:bridge/hook.py: #: 215-253`, `bridge/daemon.py:1504-1544`) was *dropped* from the #67 port table @@ -248,7 +248,7 @@ async def discover( else None ) rows.append( - await _named(_from_thread(thread, pid, progress), names, title=_thread_name(thread)) + await _named(_from_thread(thread, pid, progress), names, task=_thread_name(thread)) ) if turns is not None: turns.retain({str(thread.get("id")) for thread in threads}) @@ -413,12 +413,12 @@ def _thread_name(thread: Mapping[str, Any]) -> str | None: async def _named( - row: SessionInspection, projects: ProjectNames, *, title: str | None = None + row: SessionInspection, projects: ProjectNames, *, task: str | None = None ) -> SessionInspection: """The same row, carrying its Session Name. One rule for both sources, because there is one kind of Codex Session: the - title is the daemon's `Thread.name` when the daemon has one, and the first + task is the daemon's `Thread.name` when the daemon has one, and the first `SHORT_THREAD_ID_CHARACTERS` of the thread id when it does not. A row read off the process table simply never has the first, so it takes the second — and a Session that has not taken its first turn has neither, because it has @@ -426,7 +426,7 @@ async def _named( reach problem: an unnamed row is listed, and what it can be addressed by is its target, which it has had all along. """ - chosen = title or _short_thread_id(row.target.session_id) + chosen = task or _short_thread_id(row.target.session_id) if chosen is None: return row project = await projects.of(row.workspace) diff --git a/src/gpt_voicecoding/control_plane/commands.py b/src/gpt_voicecoding/control_plane/commands.py index 5ad7e8f..32c7b7f 100644 --- a/src/gpt_voicecoding/control_plane/commands.py +++ b/src/gpt_voicecoding/control_plane/commands.py @@ -8,7 +8,7 @@ Two shapes are worth naming: - **An address, not a name.** `codex:abc` and `claude:def:1234` are how a - surface writes a `SessionTarget` on one line. A Session Label is for speech and + surface writes a `SessionTarget` on one line. A Session Name is for speech and for matching, and turning one into a target is the router's job on the way in — never this parser's, which would be addressing by name through the back door. diff --git a/src/gpt_voicecoding/control_plane/payloads.py b/src/gpt_voicecoding/control_plane/payloads.py index bb09f8a..bd65ba3 100644 --- a/src/gpt_voicecoding/control_plane/payloads.py +++ b/src/gpt_voicecoding/control_plane/payloads.py @@ -146,7 +146,7 @@ def session_document(session: Session) -> dict[str, Any]: return { "target": target_document(session.target), # One name, rendered as the one string the user hears and types after - # `@` — `<project> · <title>`. #78 collapsed the `label`/`name` pair + # `@` — `<project> · <task>`. #78 collapsed the `label`/`name` pair # that used to travel here into it; a surface that wants the halves # parses them back with `SessionName.parse`. "name": str(session.name) if session.name is not None else None, diff --git a/src/gpt_voicecoding/core/bridge.py b/src/gpt_voicecoding/core/bridge.py index 6403410..eeb1523 100644 --- a/src/gpt_voicecoding/core/bridge.py +++ b/src/gpt_voicecoding/core/bridge.py @@ -104,19 +104,6 @@ NO_DELEGATE_HANDLER = "I can't take a delegated turn right now — nothing is wired to answer it" -def name_for(session: Session | None, target: SessionTarget) -> str: - """What to call one Session out loud: its Session Name, else its address. - - Two rungs rather than three, since #78 collapsed the composed label and the - agent's own name into one *Session Name*. The address is still the floor, - because a notice that names nothing is a notice the user cannot answer — and - a Session with no name is ordinary rather than broken (`core/sessions.py`). - """ - if session is not None: - return spoken_name(session) - return spoken_target(target) - - def stop_notice_for( session: Session | None, target: SessionTarget, waiting_for: WaitingFor | None = None ) -> str: @@ -130,7 +117,10 @@ def stop_notice_for( """ stopped_on = _stopped_on(waiting_for) if waiting_for is not None else "" tail = f" — {stopped_on}" if stopped_on else "" - return f"{name_for(session, target)} stopped and may need you{tail}" + # What to call it is `core/sessions.py`'s answer, and its floor is the + # address: a notice that names nothing is a notice the user cannot answer. + called = spoken_name(session) if session is not None else spoken_target(target) + return f"{called} stopped and may need you{tail}" def _state_behind(window: ReplyWindow, held: SessionState) -> SessionState: diff --git a/src/gpt_voicecoding/core/instructions/catalogue.py b/src/gpt_voicecoding/core/instructions/catalogue.py index 20ae270..7be8811 100644 --- a/src/gpt_voicecoding/core/instructions/catalogue.py +++ b/src/gpt_voicecoding/core/instructions/catalogue.py @@ -325,7 +325,7 @@ def _rules() -> tuple[Rule, ...]: audience=Audience.VOICE, source="skill/announcing.md:64-94", gist=( - "Label and state first, then the question in one sentence, then each option " + "Name and state first, then the question in one sentence, then each option " "by name, then whose recommendation it is, then what is needed from the " "user — and when the detail is known to be stale, say the gap out loud." ), diff --git a/src/gpt_voicecoding/core/instructions/voice.py b/src/gpt_voicecoding/core/instructions/voice.py index 34b21c1..87354df 100644 --- a/src/gpt_voicecoding/core/instructions/voice.py +++ b/src/gpt_voicecoding/core/instructions/voice.py @@ -94,7 +94,7 @@ def _sections(context: InstructionContext) -> tuple[Section, ...]: Block( covers=("voice.identity.speak-names",), text=( - "Every session has a Session Label — its project and its task — and " + "Every session has a Session Name — its project and its task — and " "that is what you say out loud, the way a person would say it in a " "sentence. Machine identities stay inside your commands; hearing one " 'tells the user nothing. When they point by position — "the third ' diff --git a/src/gpt_voicecoding/core/sessions.py b/src/gpt_voicecoding/core/sessions.py index a676adf..695b5c5 100644 --- a/src/gpt_voicecoding/core/sessions.py +++ b/src/gpt_voicecoding/core/sessions.py @@ -20,7 +20,9 @@ same session id — so the refusal names the pids that *are* live instead of pretending the session id was never seen. - **A Child Process is seen, not spoken to.** It is listed like any other row and - refused as a Relay target, by the registry rather than by a caller's memory. + refused as a Relay target, by the registry rather than by a caller's memory — + and it is never named, whatever a lane composed for it (#78, #79). A name is + what the user says to reach a Session, and there is nothing here to reach. - **A Session Name disambiguates or asks.** Names are for matching and for speech; two candidates are answered by refusing and naming both, never by picking. @@ -75,7 +77,7 @@ class Session: user's side composed and a `name` the agent reported — which is two fields meaning almost the same thing and two answers to "what is this Session called". #78 collapsed them into the glossary's single *Session Name* - (`CONTEXT.md`): `<project> · <title>`, composed by the lane that saw the + (`CONTEXT.md`): `<project> · <task>`, composed by the lane that saw the Session and frozen here. """ @@ -135,6 +137,11 @@ def observed(self, row: SessionInspection, *, target: SessionTarget) -> Session: def _named_as(self, row: SessionInspection, target: SessionTarget) -> SessionName | None: """The Session Name this row keeps — **the first one it accepted**. + A Child Process keeps none. It is listed and it is never a target, so a + name for it would be a name the user could say and nothing could answer + — the risk #78's own table names, held here rather than in each lane so + it holds however #79 comes to find children. + A name is composed once per exact `SessionTarget` and never changes after that (`legacy@1d32845:bridge/store.py:1875-1902`, *ported*: first write wins, an exact repeat is a no-op, a different one is refused). @@ -150,6 +157,8 @@ def _named_as(self, row: SessionInspection, target: SessionTarget) -> SessionNam second is a new thread; naming it after the old one is the failure this rule exists to prevent, not the one it would be protecting. """ + if not row.child.is_main: + return None if target != self.target: return row.name if self.name is None: @@ -204,12 +213,16 @@ def _normalise(text: str) -> str: def session_from(row: SessionInspection, *, first_seen: float) -> Session: - """A row a lane just saw, as a roster entry seen for the first time.""" + """A row a lane just saw, as a roster entry seen for the first time. + + A Child Process arrives unnamed for the reason `Session._named_as` gives: + the roster lists it and nothing can be said to it. + """ return Session( target=row.target, workspace=row.workspace, first_seen=first_seen, - name=row.name, + name=row.name if row.child.is_main else None, lifecycle=row.lifecycle, state=row.state, waiting_for=row.waiting_for, @@ -430,9 +443,13 @@ def match_name(self, query: str) -> Session: wanted = _normalise(query) if not wanted: # Every name contains the empty fragment, so an empty query would - # match the whole roster — and match a single unnamed Session - # *exactly*, which is a silent delivery into a Session the user - # never named. + # match the whole roster — and match a single Session *exactly*, + # which is a silent delivery into a Session the user never named. + # The reference implementation has no matching behaviour at all to + # cite here: gen-1 addressed a session by its id inside a tool call + # and its labels were only ever spoken (`legacy@1d32845` composes a + # label in `bridge/labels.py` and never looks one up), so spoken + # matching and every refusal in it are this generation's. raise NoNameMatchError(query) candidates = [ held diff --git a/src/gpt_voicecoding/seams/identity.py b/src/gpt_voicecoding/seams/identity.py index 7ab3feb..6e42f8d 100644 --- a/src/gpt_voicecoding/seams/identity.py +++ b/src/gpt_voicecoding/seams/identity.py @@ -73,7 +73,7 @@ def always_named(self) -> bool: @dataclass(frozen=True, slots=True) class SessionName: - """``<project name> · <task title>`` — for matching and for speech only. + """``<project> · <task>`` — for matching and for speech only. The one name the user and the system have for a Session (`CONTEXT.md`, *Session Name*). Deliberately holds nothing a command could address: see diff --git a/tests/test_session_observation.py b/tests/test_session_observation.py index aa4c733..d606b02 100644 --- a/tests/test_session_observation.py +++ b/tests/test_session_observation.py @@ -13,6 +13,8 @@ from gpt_voicecoding.core.sessions import SessionRegistry from gpt_voicecoding.seams.agent import ( + ChildClassification, + ChildKind, LaneDiscovery, SessionInspection, SessionLifecycle, @@ -301,6 +303,37 @@ def test_a_new_thread_under_the_same_pid_is_named_for_the_new_thread(self) -> No assert str(registry.live()[0].name) == "GPT-VoiceCoding · the second thread" + def test_a_child_process_is_listed_and_never_named(self) -> None: + """#78's table risk 1: a name the user could say and nothing could answer.""" + registry = SessionRegistry() + registry.observe( + AgentKind.CODEX, + seeing( + codex_row( + session_id="abc", + pid=10, + name=named("a task"), + child=ChildClassification(kind=ChildKind.CHILD, parent="the parent"), + ) + ), + now=NOW, + ) + + assert len(registry.live()) == 1 + assert registry.live()[0].name is None + + def test_a_child_seen_again_is_still_not_named(self) -> None: + registry = SessionRegistry() + child = ChildClassification(kind=ChildKind.CHILD, parent="the parent") + for tick in (NOW, NOW + 5): + registry.observe( + AgentKind.CODEX, + seeing(codex_row(session_id="abc", pid=10, name=named("a task"), child=child)), + now=tick, + ) + + assert registry.live()[0].name is None + def test_a_tick_with_only_process_evidence_keeps_the_name(self) -> None: """The identity did not change, so neither does the name it was given.""" registry = SessionRegistry() From 76b9c98e8f317510284d513b43c900921b1dd55c Mon Sep 17 00:00:00 2001 From: Simon Qi <okqixiaobao727@gmail.com> Date: Thu, 27 Aug 2026 00:21:13 +1200 Subject: [PATCH 3/3] Say why compose answers rather than raises, on the advisor's ruling (#78) The reviewer read #78's `-> SessionLabel` as requiring the refusal legacy raised. The ruling amends the signature: legacy could raise because its caller was one self-report ingress for one Session that had just spoken; this caller is a per-row pass over the whole machine every few seconds, where one bad string must never cost an enumeration. The citation now says that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- src/gpt_voicecoding/adapters/agent/_naming.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/gpt_voicecoding/adapters/agent/_naming.py b/src/gpt_voicecoding/adapters/agent/_naming.py index a567ca0..ded0de9 100644 --- a/src/gpt_voicecoding/adapters/agent/_naming.py +++ b/src/gpt_voicecoding/adapters/agent/_naming.py @@ -9,13 +9,15 @@ **The task half is validated the way the reference implementation validated it** (`legacy@1d32845:bridge/labels.py:97-106`, *adapted*): non-empty after stripping, -and one line. Legacy raised `SessionLabelError` on both, from a resolver called -at the moment a Session reported its own task; here the caller is a discovery -pass over every row on the machine every few seconds, and an exception per -unnameable row would make a naming problem look like a lane failure. So the -answer is `None` — "this Session has no name yet" — which is the state the -roster already renders (`core/sessions.py`) — and the refusal is logged, so a -name that never appears is findable rather than silent. +and one line. Legacy *raised* `SessionLabelError` on both — and could, because +its caller was a single self-report ingress handling one Session that had just +spoken. This caller is a discovery pass over every row on the machine every few +seconds, where one bad string must never cost an enumeration: an unnameable row +is one row without a name, not a lane that failed. So the answer is `None` — +"this Session has no name yet", the state the roster already renders +(`core/sessions.py`) — and the reason is logged, so a name that never appears is +findable rather than silent. (#78 writes the signature as returning a name; +amended by advisor ruling, 2026-08-27.) **What is deliberately not here.** No transcript-derived task, no `ai-title`, no mutable history: legacy measured that route at 30% of Sessions never writing